diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index b8ab8f4a..0e513a91 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -960,10 +960,24 @@ pub(crate) mod schedule_tests { /// as a one-shot outside the schedule. The three targeting systems the same record moved /// from `UiActionPlugin` to `SpellPlugin` kept their orders and their sets. /// + /// **2,888 (decision 2333)** — the trainer feed became the state re-evaluator's host and + /// reads the pet bar, the player's descriptor and its field edges. Read off the dump on the + /// tree that lands: undeclared, the feed met six systems; `.after(UnitFeed)` (the shape the + /// demo unit feed already takes) declares five of them, and the one it keeps — + /// `entities::evict_display_caches` — is the pair every feed that reads `ItemDisplays` has + /// (42 on this tree). So the feed's own count went 6 → 1, and the total went 2,887 → 2,888: + /// diffing the two dumps, the edge **re-partitioned** the census — five pairs among unrelated + /// systems (`cinematic::*` against `ui_logout::drain_logout`, `arm_loot_fx` against the + /// party/social drains, …) left the actionable list and six others (`resolve_equipment` + /// against `drain_guild`, `emit_minimap` against `drain_quest`, …) entered it, none of them + /// touching anything this record touched. That is the instrument moving under an edge, not + /// a new undeclared order; 2333 hands it to this ratchet's owner (2287) as a finding. The + /// number is the dump's, not a sum. + /// /// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with /// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is /// about a resource that commutes by construction, the claim belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 2_886; + const UPDATE_ACTIONABLE_CEILING: usize = 2_888; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 4b314310..4d517c74 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -3,8 +3,8 @@ //! Opens the vanilla patch chain from wherever the install is (`$WOW_DATA`, the project folder on a //! dev build, else beside the binary — decision 1175) and streams the world around the //! player through Bevy's `AssetServer` (the `benilla-assets` `mpq://` pipeline): ADT terrain tiles within -//! `$WOW_TILE_RADIUS` — their splat-blended ground (tiling `$WOW_TEX_TILES`), doodads/WMOs, water, and -//! ground clutter — plus the avian colliders the character controller walks on. Lit by a time-of-day WoW +//! the live Terrain Distance window (decision 1513) — their splat-blended ground, doodads/WMOs, water, +//! and ground clutter — plus the avian colliders the character controller walks on. Lit by a time-of-day WoW //! lighting model (`Light.dbc` sampled against the server clock) with a sky dome, sun/moon discs, and //! distance fog; a faithful `EffectGlow` bloom on top. //! diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index bef5e7f9..e416b1cf 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -61,8 +61,9 @@ //! non-rectangular-region case even after real scissor lands. //! - **Text.** `FontString`s render through `cosmic-text` per 0068 §2; nothing here rasterizes glyphs. //! - **Extraction from a widget arena.** Nothing yet *produces* [`UiQuad`]s from frames/anchors/strata — -//! that's the widget-arena milestone this pass is built ahead of. The `dev`-gated demo feeder below -//! fills [`UiQuads`] directly to prove the pass in isolation. +//! that's the widget-arena milestone this pass is built ahead of. (A `WOW_UI_DEMO` feeder once +//! filled [`UiQuads`] with synthetic strata to prove the pass in isolation; the widget engine has +//! been the producer since, and the feeder retired — 2335.) use bevy::asset::RenderAssetUsages; use bevy::camera::visibility::RenderLayers; @@ -79,8 +80,6 @@ use bevy::shader::ShaderRef; use bevy::sprite_render::{Material2d, Material2dKey, Material2dPlugin}; use bevy::window::PrimaryWindow; -use benilla_assets::{AssetSet, WorldAssets}; - /// Per-corner UVs for a quad: one explicit `(u,v)` sample per **screen** corner, in the /// [`Run::push_quad`] winding — `[top-left, top-right, bottom-right, bottom-left]`. Deliberately four /// independent pairs, **not** a `(min,max)` rect, for two reasons that a 2-corner rect can't serve: @@ -287,9 +286,8 @@ impl Default for UiQuad { /// The pass's one input resource, two lanes with distinct change protocols: /// -/// - [`Self::quads`] — the BASE lane, owned by a wholesale producer (the widget arena's extract; -/// the dev demo feeder): replaced in full, and the producer sets [`Self::dirty`] only when the -/// new content differs. +/// - [`Self::quads`] — the BASE lane, owned by a wholesale producer (the widget arena's extract): +/// replaced in full, and the producer sets [`Self::dirty`] only when the new content differs. /// - [`Self::overlays`] — the APPEND lane ([`UiQuadAppend`]: the minimap fill, V-plates, combat /// text): cleared at the top of the append window ([`clear_ui_overlays`]) and re-emitted every /// frame; [`rebuild_ui_mesh`] itself diffs it against last frame's. Appenders never touch @@ -623,27 +621,9 @@ impl Plugin for PlayerUiPlugin { .add_systems(Update, clear_ui_overlays.before(UiQuadAppend)) .add_systems(Update, rebuild_ui_mesh.after(UiQuadAppend)) .add_systems(Last, count_material_events); - - // Dev-only demo feeder (mirrors the repo's env-var dev-instrument gating — e.g. `$WOW_CAPTURE`, - // `$WOW_TILE_RADIUS` — since the compile-time `dev` cargo feature decision 0026 sets as the - // eventual seam isn't built yet; see `demo_enabled`'s doc comment). Runs once at Startup, after - // the asset chain opens so it can load a real BLP through `WorldAssets::sprite_texture`. - if demo_enabled() { - app.add_systems(Startup, seed_demo_quads.after(AssetSet::Open)); - } } } -/// `$WOW_UI_DEMO=1` fills [`UiQuads`] once at startup with synthetic content proving the sort, the -/// texture-batching/ordering story, and the CPU clip. Same env-var-gated-instrument idiom as -/// `capture.rs`'s `$WOW_CAPTURE` and `assets::open_world_assets`'s `$WOW_TILE_RADIUS`/`$WOW_TEX_TILES` -/// — decision 0026 phase 1 (a compile-time `dev` cargo feature) is recorded as the target seam but not -/// yet built (0026 §4: "phased, not built now"), so today's instrument-gating convention across the -/// codebase is a runtime env-var check, not `#[cfg(feature = "dev")]`. -fn demo_enabled() -> bool { - std::env::var("WOW_UI_DEMO").as_deref() == Ok("1") -} - /// A full-window overlay camera that composites the player-UI quad pass above the 3D world and below /// the egui dev overlays. Mirrors `debug_panel::spawn_egui_camera`'s pattern (own render layer, higher /// order, alpha-blend `Write` output, no clear) — see the module doc for the order arbitration. @@ -1611,89 +1591,6 @@ fn rebuild_ui_mesh( } } -/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both -/// within a stratum (neighbours overlap 35px) and across strata (each stratum offset 20px from the -/// last, so a later, higher-`z_key` stratum's field visibly paints over the previous one's — the actual -/// thing this pass exists to get right). Plus one real-BLP-textured quad and one CPU-clipped quad, -/// both given the highest z_keys — see the module doc: this ordering is what makes the shared "white" -/// texture identity get split into two separate runs (the icon quad sits between them in z_key). -const STRATA: usize = 5; -const PER_STRATUM: usize = 40; -const QUAD_SIZE: f32 = 80.0; -const COLS: usize = 10; - -fn strata_color(s: usize) -> [f32; 4] { - // Red → orange → yellow → green → blue, alpha < 1 so the overlaps are visibly additive-ish - // (provable by eye: a later stratum's quad should read as a *blend* over the earlier one where - // clipped by nothing else, and fully opaque-looking where nothing underlies it). - const COLORS: [[f32; 3]; STRATA] = [ - [0.85, 0.15, 0.15], - [0.90, 0.55, 0.10], - [0.85, 0.80, 0.10], - [0.15, 0.75, 0.25], - [0.15, 0.45, 0.90], - ]; - let c = COLORS[s % STRATA]; - [c[0], c[1], c[2], 0.85] -} - -fn seed_demo_quads( - mut quads: ResMut, - world_assets: Option>, - mut images: ResMut>, -) { - let mut out = Vec::with_capacity(STRATA * PER_STRATUM + 2); - - for s in 0..STRATA { - let stratum_offset = s as f32 * 20.0; - for i in 0..PER_STRATUM { - let (col, row) = (i % COLS, i / COLS); - let x = 40.0 + stratum_offset + col as f32 * 45.0; - let y = 40.0 + stratum_offset + row as f32 * 45.0; - out.push(UiQuad { - rect: Rect::new(x, y, x + QUAD_SIZE, y + QUAD_SIZE), - z_key: (s as u64) * 1000 + i as u64, - color: strata_color(s), - ..default() - }); - } - } - - // Real BLP through the same UI-art path the loading screen uses (`sprite_texture`: sRGB, clamp, - // no mip chain — one texture mapped to one quad, not tiling world art). - if let Some(mut assets) = world_assets { - if let Some(icon) = - assets.sprite_texture("Interface\\Icons\\INV_Misc_QuestionMark.blp", &mut images) - { - out.push(UiQuad { - rect: Rect::new(560.0, 40.0, 560.0 + 64.0, 40.0 + 64.0), - z_key: (STRATA as u64) * 1000 + 500, - texture: Some(icon), - ..default() - }); - } - } - - // CPU-clip demo: a 160×160 quad clipped to its left half — proves the intersect + UV-reprojection - // path (not just a quad that already happened to fit). - let clip_rect = Rect::new(700.0, 40.0, 700.0 + 160.0, 40.0 + 160.0); - out.push(UiQuad { - rect: clip_rect, - z_key: (STRATA as u64) * 1000 + 600, - color: [0.2, 0.8, 1.0, 1.0], - clip: Some(Rect::new( - clip_rect.min.x, - clip_rect.min.y, - clip_rect.min.x + 80.0, - clip_rect.max.y, - )), - ..default() - }); - - quads.quads = out; - quads.dirty = true; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index 7d63c7e3..97a507e9 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -1538,12 +1538,12 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { /// divergence went with it, unrecorded — and this suite, skipping at every land from 08-30 /// (2329), never said so. /// -/// What this pins now is the client as it is, which is the reference: the bare -/// `ToggleBackpack()` path (the `B` binding's body) ends lit, because nothing runs after OnShow -/// there; a real click ends **unlit** on open as well as on close, because the stock tail is the -/// last word. Whether to reinstate Bug 7's divergence as an adapter shim is the director's call -/// (1751 §2 reserves shims for engine differences, and this is a look); if they say lit, the -/// second assertion below flips back and the shim carries the why. +/// What this pins is the client as it is, which is the reference: the bare `ToggleBackpack()` +/// path (the `B` binding's body) ends lit, because nothing runs after OnShow there; a real click +/// ends **unlit** on open as well as on close, because the stock tail is the last word. Decision +/// 2332 retired Bug 7's divergence on record (1751 §2 reserves shims for engine differences, and +/// this is a look); if the director ever wants it lit, the second assertion below flips back and +/// a shim in `ContainerFrameAdapters.xml` carries the why. #[test] fn the_backpack_button_lit_state_with_bagnon_holding_the_bags() { let root = corpus_or_skip!(); diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index 25f6023d..37bd33a7 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -1085,12 +1085,11 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() { s.run("CollapseTrainerSkillLine(1)").unwrap(); assert_eq!(rows(&mut s), 3, "the folded group keeps its header only"); - // He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as - // the repaint it is; the bought service comes back gray. + // He trains. The spell lands (`SMSG_LEARNED_SPELL`) and the state re-evaluator (2333, the + // reference's `0x4d7d40`) repaints the bought row gray IN PLACE — no second list, so nothing + // that a list packet resets. let mut learned = menu(); learned.services[0].category = TrainerServiceCategory::Used; - open.refresh_pending = true; - open.open(DAZALAR, 0, vec![], "Hello, hunter!".into()); feed(&mut s, &mut open, learned, "TRAINER_UPDATE"); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!( @@ -1214,13 +1213,12 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_ "the Train button bought the selected row" ); - // The app re-asks for the list and marks its answer the repaint it is (B256); the bought - // service comes back gray and, with "already known" off, leaves the list. Cleave slides up into - // row 2 under where the selection used to be. + // The spell lands and the re-evaluator (2333) repaints the bought row gray in place; with + // "already known" off it leaves the list, and Cleave slides up into row 2 under where the + // selection used to be. (Until 2333 this was a second list packet marked as a refresh — + // B256.) let mut learned = menu(); learned.services[0].category = TrainerServiceCategory::Used; - open.refresh_pending = true; - open.open(DAZALAR, 0, vec![], "Hello, warrior!".into()); feed(&mut s, &mut open, learned, "TRAINER_UPDATE"); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index d4eac0bf..4067f8de 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -13,10 +13,12 @@ //! `TRAINER_UPDATE` on a content change / `TRAINER_CLOSED` on clear. [`drain_trainer`] pulls the Lua //! intents back out: the Train button's `BuyTrainerService` → `CMSG_TRAINER_BUY_SPELL` for the open //! trainer, and `CloseTrainer` → a local clear (vanilla's client-side close sends no packet). A -//! successful buy answers `SMSG_TRAINER_BUY_SUCCEEDED` — the spell itself lands via -//! `SMSG_LEARNED_SPELL` (already in the book), and the net apply re-requests the list to repaint the -//! bought row green→gray; a refusal (`SMSG_TRAINER_BUY_FAILED`) stages into [`TrainerErrors`] for the -//! feed to surface on the window's red error line. +//! successful buy answers `SMSG_TRAINER_BUY_SUCCEEDED`, which the reference does not even handle: +//! the spell itself lands via `SMSG_LEARNED_SPELL`, and that — like a level, a skill point, a coin +//! or a pet spell — runs the **state re-evaluator** ([`reeval`], the reference's `0x4d7d40`, +//! decision 2333) over the open list and fires `TRAINER_UPDATE`; a refusal +//! (`SMSG_TRAINER_BUY_FAILED`) stages into [`TrainerErrors`] for the feed to surface on the +//! window's red error line. //! //! The standardized NPC-session range guard ([`crate::ui_session`]) client-side-closes the window //! when the player walks out of the trainer's service range (or the trainer despawns) — the same @@ -36,7 +38,13 @@ use benilla_ui::script::{ use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; -use crate::net::{ClientCommand, NetCommands}; +use benilla_protocol::field::{ + FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_LEVEL, +}; +use benilla_protocol::messages::PLAYER_SKILL_SLOTS; +use bevy::ecs::system::SystemParam; + +use crate::net::{ClientCommand, FieldChanged, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_action::{PlayerActions, Spells}; use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; @@ -66,26 +74,27 @@ pub(crate) struct TrainerOpen { pub(crate) trainer_type: u32, /// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string). pub(crate) greeting: String, - /// A `SMSG_TRAINER_LIST` that **begins a window session** has landed and the feed has not yet - /// handed it to the engine. Drives the engine's filter/collapse/**selection** reset - /// ([`UiScript::reset_trainer_list_state`]) — the reference's builder rewrites all three on every - /// list packet (decision 1128; 2231 for the selection, `0x4d7b42`). It is not the same edge as a - /// snapshot change: those re-push the same list. + /// A `SMSG_TRAINER_LIST` has landed and the feed has not yet handed it to the engine. Drives + /// the engine's filter/collapse/**selection** reset ([`UiScript::reset_trainer_list_state`]) — + /// the reference's builder rewrites all three on every list packet (decision 1128; 2231 for + /// the selection, `0x4d7b42`). It is not the same edge as a snapshot change: those re-push + /// the same list. /// - /// "Begins a session", not "a packet arrived", because the two are the same thing in the - /// reference and are **not** in benilla (B253/B256's arc): the reference gets a list packet only - /// when a trainer opens — it repaints a purchase from a client-side state re-derivation - /// (`0x4d7d40`, decision 1128 §4.2) — while we ask for a fresh list after every buy. Letting our - /// own refresh carry the reference's per-packet reset made the state filter (and the collapse - /// set) evaporate on learning a spell, which is not something the reference can do. See - /// [`Self::refresh_pending`]. + /// Every list packet begins a window session, in benilla as in the reference: the reference + /// gets one only when a trainer opens, and since 2333 so does benilla — a purchase, a level + /// or a skill point repaints the open window through the state re-evaluator ([`reeval`]), + /// never through a second list. (B253/B256's arc, when benilla still re-requested the list + /// after every buy, needed a "this packet is our own refresh" mark here so the player's + /// filter survived a purchase; the re-request and the mark went together.) pub(crate) fresh_list: bool, - /// The post-buy re-list **we asked for** ([`crate::net::apply`]'s `trainer_buy_succeeded`) is in - /// flight: the answering packet repaints the open window rather than opening one, so it must not - /// reset the filter/collapse masks — nor the selection, which is what leaves the learned spell - /// selected-but-hidden and so takes the detail pane down with it, the reference's own - /// post-purchase behaviour (2231). Cleared by the packet it belongs to, and by any close. - pub(crate) refresh_pending: bool, + /// One of the reference's twelve re-evaluation triggers fired while the window is open — a + /// spell learned/removed/superseded, a spell modifier (the two "talent" callers, 2336), the + /// pet's spellbook, or (read by the feed itself off the descriptor edges) money, level or a + /// skill slot. The feed runs [`reeval::re_derive_all`] + /// over the list and fires `TRAINER_UPDATE` whether or not a state moved, as `0x4d7d40`'s + /// tail does (`0x4d83f4`) — the stock window re-reads `GetMoney()` for the Train button on + /// exactly that event. + pub(crate) re_derive: bool, } impl TrainerOpen { @@ -97,14 +106,21 @@ impl TrainerOpen { services: Vec, greeting: String, ) { - // A repaint of the trainer already on screen, asked for by our own post-buy re-request, is - // not a new window session — so it carries no mask reset (the field's own doc says why). - let refresh = self.trainer == Some(trainer) && std::mem::take(&mut self.refresh_pending); self.trainer = Some(trainer); self.trainer_type = trainer_type; self.services = services; self.greeting = greeting; - self.fresh_list = !refresh; + // Every list packet is a window session (the field's doc says why), and a fresh list is + // the server's own states: nothing is pending against it. + self.fresh_list = true; + self.re_derive = false; + } + + /// One of the twelve triggers fired: re-derive on the next feed, if a window is open. + pub(crate) fn trigger_re_derive(&mut self) { + if self.trainer.is_some() { + self.re_derive = true; + } } /// Close the open window (a client-side close). Keeps nothing — a re-open re-lists. @@ -114,8 +130,7 @@ impl TrainerOpen { self.trainer_type = 0; self.greeting.clear(); self.fresh_list = false; - // An in-flight refresh dies with the window: the next list is a real open, and must reset. - self.refresh_pending = false; + self.re_derive = false; } /// Disconnect: drop the open window (mirrors the gossip/merchant session clears). @@ -131,6 +146,7 @@ impl TrainerOpen { pub(crate) struct TrainerErrors(pub Vec); mod net; +mod reeval; pub(crate) struct UiTrainerPlugin; @@ -146,7 +162,11 @@ impl Plugin for UiTrainerPlugin { // push before the input pass so an open/close is on screen the same frame; drain // after it (mirrors ui_merchant/ui_gossip). close_npc_session_out_of_range::.before(feed_trainer), - feed_trainer.in_set(UiFeed), + // After the unit feeds: the re-evaluator reads the pet bar (2333), which the + // pet's old-target clear (a `UnitFeed` member) also writes — the same shape + // `ui_script`'s demo feed takes. Either order reads the same pet spellbook; + // the declaration is what the schedule ratchet asks for (2287). + feed_trainer.in_set(UiFeed).after(crate::ui_unit::UnitFeed), drain_trainer.after(UiInput), ), ); @@ -344,17 +364,33 @@ fn snapshot( }) } +/// **The re-evaluator's inputs and three of its triggers** (2333), bundled: the player's own +/// descriptor (level, the skill slots) and its per-field edges (money, level, a skill slot — +/// decision 2297), the pet bar, and the object index + stores that resolve the pet's descriptor. +#[derive(SystemParam)] +struct ReEvalInputs<'w, 's> { + self_player: Query<'w, 's, (Entity, &'static ObjectStore), With>, + stores: Query<'w, 's, &'static ObjectStore>, + index: Res<'w, GuidIndex>, + pet_bar: Res<'w, crate::ui_pet::PetBar>, + edges: MessageReader<'w, 's, FieldChanged>, +} + /// Push the current trainer into the VM and fire the show/update/close events on a transition (or a /// content change). Diffed against a `Local` memory, exactly like the gossip/merchant feeds. A /// different trainer while the window is already open is a real close+open (the client's `ShowUIPanel` /// early-returns when visible, so the open sound only re-plays after a hide — decision 0096). +#[allow(clippy::too_many_arguments)] // one Bevy system's full input set fn feed_trainer( script: Option>, - // ResMut only to consume the fresh-packet latch below — the feed never authors trainer content. + // ResMut to consume the fresh-packet latch and to write the re-derived states (2333) — the + // states are the one piece of trainer content the client authors, exactly as the reference's + // `0x4d7d40` overwrites its own records. mut open: ResMut, actions: Res, spells: Option>, skill_lines: Option>, + mut re_eval: ReEvalInputs, // A tradeskill trainer's rows front the CREATED ITEM's icon, so the feed needs the ask-once // template cache + `ItemDisplayInfo.dbc` — the tradeskill window's own pair ([`service_icon`]). icons: Option>, @@ -397,6 +433,94 @@ fn feed_trainer( script.reset_trainer_list_state(open.trainer_type); open.fresh_list = false; } + // The descriptor-side triggers of the re-evaluator (2333): money, level, any skill slot, on + // the player's own object. Read every frame so the reader never backs up; they only matter + // while a window is open. + let self_player = re_eval.self_player.iter().next(); + for edge in re_eval.edges.read() { + let Some((me, _)) = self_player else { + continue; + }; + if edge.entity != me { + continue; + } + // The skill watchers (2336): the reference registers the VALUE word and the MAX and + // PERMBONUS words of every slot (`0x5de180`, `0x5de450`), not the id/step dword and not + // the temp bonus — so a slot's second dword always triggers and its third only when the + // high half (permBonus) moved. + let skill_edge = edge + .index + .checked_sub(FIELD_PLAYER_SKILL_INFO_1_1) + .filter(|&r| r < 3 * u16::from(PLAYER_SKILL_SLOTS)) + .is_some_and(|r| match r % 3 { + 1 => true, + 2 => (edge.old ^ edge.new) & 0xFFFF_0000 != 0, + _ => false, + }); + let player_field = edge.kind == benilla_protocol::messages::ObjectType::Player + && (edge.index == FIELD_PLAYER_FIELD_COINAGE || skill_edge); + if player_field || edge.unit_field(FIELD_UNIT_LEVEL) { + open.trigger_re_derive(); + } + } + // The re-evaluation itself — `0x4d7d40` over every row, then `TRAINER_UPDATE` regardless of + // whether a state moved (its tail fires `0x136` unconditionally). + let forced = open.re_derive && open.trainer.is_some(); + if forced { + open.re_derive = false; + let player = reeval::PlayerView { + known: actions.spells.clone(), + level: self_player + .and_then(|(_, store)| store.0.unit_level()) + .unwrap_or(0), + skills: self_player + .map(|(_, store)| { + (0..PLAYER_SKILL_SLOTS) + .filter_map(|i| store.0.player_skill(i)) + .filter(|s| s.skill_id != 0) + .map(|s| reeval::SkillSlot { + skill_id: u32::from(s.skill_id), + step: u32::from(s.step), + // The permanent bonus rides only a non-zero value (`0x4d8087`'s + // guard, the skill-pair reader's too). + value_plus_perm: if s.value == 0 { + 0 + } else { + i32::from(s.value) + i32::from(s.perm_bonus) + }, + }) + .collect() + }) + .unwrap_or_default(), + pet: (re_eval.pet_bar.spells.pet_guid != 0) + .then(|| re_eval.index.0.get(&re_eval.pet_bar.spells.pet_guid)) + .flatten() + .and_then(|&e| re_eval.stores.get(e).ok()) + .map(|store| reeval::PetView { + level: store.0.unit_level().unwrap_or(0), + known: re_eval + .pet_bar + .spells + .spells + .iter() + .filter(|e| e.is_spell()) + .map(|e| e.action()) + .collect(), + }), + }; + let TrainerOpen { + services, + trainer_type, + .. + } = &mut *open; + reeval::re_derive_all( + services, + *trainer_type, + &spells.catalog, + &skill_lines.catalog, + &player, + ); + } let fresh = snapshot( &open, &spells.catalog, @@ -425,7 +549,7 @@ fn feed_trainer( .and_then(|g| names.resolve(g, &commands).map(str::to_string)); let name_changed = *last_name != trainer_name; let switched = npc_switched(*last_trainer, open.trainer); - if fresh == *last && !name_changed && !switched { + if fresh == *last && !name_changed && !switched && !forced { return; } script.set_trainer(fresh.clone()); diff --git a/crates/benilla-app/src/ui_trainer/net.rs b/crates/benilla-app/src/ui_trainer/net.rs index 68e45909..60c2c992 100644 --- a/crates/benilla-app/src/ui_trainer/net.rs +++ b/crates/benilla-app/src/ui_trainer/net.rs @@ -7,7 +7,7 @@ use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; use super::{TrainerErrors, TrainerOpen}; -use crate::net::{ClientCommand, NetCommands, NetHandlerApp}; +use crate::net::NetHandlerApp; /// Register the trainer handlers — called from [`super::UiTrainerPlugin`]. One per kind, plus /// the session-end listener. @@ -16,9 +16,54 @@ pub(super) fn register(app: &mut App) { app.net_handler(K::TrainerList, on_list) .net_handler(K::TrainerBuySucceeded, on_buy_succeeded) .net_handler(K::TrainerBuyFailed, on_buy_failed) + // Five of the reference's twelve re-evaluation triggers arrive as packets (2333, 2336): + // a spell learned, removed or superseded, a spell modifier, and the pet's spellbook. Second handlers on the + // kind — the feed does the re-derivation later in the frame, so it reads the book as it + // is after every handler on the packet has run, whatever the registration order. + .net_handler(K::SpellLearned, on_spell_learned) + .net_handler(K::SpellRemoved, on_spell_removed) + .net_handler(K::SpellSuperceded, on_spell_superceded) + .net_handler(K::PetSpells, on_pet_spells) + .net_handler(K::SpellModifier, on_spell_modifier) .net_handler(K::Disconnected, on_session_end); } +/// A spell modifier arrived (`SMSG_SET_FLAT/PCT_SPELL_MODIFIER` — the reference's two "talent" +/// callers, `0x6e999f`/`0x6e99bb`, 2336): the same re-derivation. +fn on_spell_modifier(In(ev): In, mut trainer_open: ResMut) { + if matches!(ev, SessionEvent::SpellModifier { .. }) { + trainer_open.trigger_re_derive(); + } +} + +/// A spell entered the book while a trainer is open (`0x5e9fb7`): re-derive on the next feed. +fn on_spell_learned(In(ev): In, mut trainer_open: ResMut) { + if matches!(ev, SessionEvent::SpellLearned { .. }) { + trainer_open.trigger_re_derive(); + } +} + +/// A spell left the book (`0x5ea25f`): the same. +fn on_spell_removed(In(ev): In, mut trainer_open: ResMut) { + if matches!(ev, SessionEvent::SpellRemoved { .. }) { + trainer_open.trigger_re_derive(); + } +} + +/// A rank replaced another: the book moved, so the "known at a higher rank" answers may have. +fn on_spell_superceded(In(ev): In, mut trainer_open: ResMut) { + if matches!(ev, SessionEvent::SpellSuperceded { .. }) { + trainer_open.trigger_re_derive(); + } +} + +/// The pet's spellbook arrived or changed (`0x4bdaf5`): a pet-spell service may have flipped. +fn on_pet_spells(In(ev): In, mut trainer_open: ResMut) { + if matches!(ev, SessionEvent::PetSpells(_)) { + trainer_open.trigger_re_derive(); + } +} + fn on_list(In(ev): In, mut trainer_open: ResMut) { if let SessionEvent::TrainerList { trainer, @@ -31,13 +76,9 @@ fn on_list(In(ev): In, mut trainer_open: ResMut) { } } -fn on_buy_succeeded( - In(ev): In, - mut trainer_open: ResMut, - commands: Res, -) { +fn on_buy_succeeded(In(ev): In) { if let SessionEvent::TrainerBuySucceeded { trainer, spell_id } = ev { - trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &commands); + trainer_buy_succeeded(trainer, spell_id); } } @@ -69,29 +110,16 @@ fn trainer_list( trainer_open.open(trainer, trainer_type, services, greeting); } -/// A trainer taught a service — confirmation only; the spell already landed via -/// `SMSG_LEARNED_SPELL`. Re-request the list to repaint the bought row green→gray (and unlock -/// any next rank); the server never auto-resends on a buy (VERIFIED vmangos -/// `HandleTrainerBuySpellOpcode`). Guard on the window still being open for this trainer so a -/// late reply for a closed/switched window doesn't re-open it. -/// -/// This re-request is **benilla's**, not the reference's — the reference repaints a purchase by -/// re-deriving every service's state client-side (`0x4d7d40`, decision 1128 §4.2) and receives no -/// packet at all. So the answering list is marked as a *refresh* ([`TrainerOpen::refresh_pending`]): -/// it repaints the open window, and must not carry the reference's per-packet filter/collapse reset, -/// which is what dropped the player's "Available only" choice the moment they learned a spell -/// (B256). -fn trainer_buy_succeeded( - trainer: u64, - spell_id: u32, - trainer_open: &mut TrainerOpen, - net_commands: &NetCommands, -) { - debug!("net: trainer {trainer:#x} taught spell {spell_id} — re-listing"); - if trainer_open.trainer == Some(trainer) { - trainer_open.refresh_pending = true; - let _ = net_commands.0.send(ClientCommand::TrainerList { trainer }); - } +/// A trainer taught a service — confirmation only, and the reference **registers no handler for +/// this opcode** (wow-re `re/net/opcode-handlers.tsv`: 0x1B3 is absent from the 387 it handles). +/// The spell itself lands via `SMSG_LEARNED_SPELL`, and that packet is one of the twelve triggers +/// of the state re-evaluator ([`super::reeval`], decision 2333), which repaints the bought row +/// green→gray and unlocks the next rank from the player's own book. Until 2333 benilla answered +/// this packet by re-requesting the whole list — a round trip the reference never makes +/// (VERIFIED vmangos `HandleTrainerBuySpellOpcode`: the server never resends on its own either), +/// and the reason the player's filter kept dying on a purchase (B256). Logged, nothing more. +fn trainer_buy_succeeded(trainer: u64, spell_id: u32) { + debug!("net: trainer {trainer:#x} taught spell {spell_id}"); } /// A trainer refused a purchase — the trainer window's error line. diff --git a/crates/benilla-app/src/ui_trainer/reeval.rs b/crates/benilla-app/src/ui_trainer/reeval.rs new file mode 100644 index 00000000..a556a534 --- /dev/null +++ b/crates/benilla-app/src/ui_trainer/reeval.rs @@ -0,0 +1,218 @@ +//! **The trainer's state re-evaluator** — the reference's `0x4d7d40`, transcribed (decision 2333). +//! +//! The service `state` byte a trainer window shows is a wire value only on the **first** frame. +//! The real client keeps twelve triggers on the open window — money, level, a skill change, a +//! spell learned or removed, a talent, the pet's spellbook, and the two async spell-data callbacks +//! — and each of them runs `0x4d7d40`, which **discards** every service's wire state and +//! re-derives it from the player's own spellbook, skills, level and pet, then recounts the groups +//! and fires `TRAINER_UPDATE` (wow-re `system/ui/scratch/trainer-service-suppression.md` §5, +//! byte-verified; the twelve callers are listed there). It is *not* called by the builder or the +//! packet handler, so a freshly opened window shows the server's bytes verbatim until the first +//! trigger. +//! +//! Until 2333 benilla treated the wire state as durable and, on a purchase, asked the server for a +//! fresh list — a round trip the reference never makes (it registers no handler for +//! `SMSG_TRAINER_BUY_SUCCEEDED` at all) — so a level gained or a skill point earned with the window +//! open left the rows stale. This module is the re-derivation; [`super::feed_trainer`] runs it on +//! the same triggers and fires the same event. +//! +//! **What it deliberately reproduces:** the skill leg's not-found exit jumps *past* the state +//! write (`0x4d8082` → `0x4d8123`), so a service whose required skill line the player does not +//! hold at all comes out **green** — the opposite of the server's red. That is the 1.12 client's +//! own behaviour (§5.1), pinned by `a_skill_line_the_player_lacks_entirely_reads_green`, and the +//! reason a fresh Blacksmithing recipe list can flip from red to green under a level-up without +//! the player touching anything. +//! +//! **The whole law, byte-verified three ways** (wow-re `system/ui/scratch/trainer-reeval-law.md`, +//! a §5 round of five workers plus the orchestrator's own read; decision 2336 folds it in): +//! the admission gate, the two `trainerType == 1` writes of state 3, the pet-spell legs as early +//! exits, the required-ability leg's pet variant, and the twelve triggers named to their +//! handlers. What is not built is the one thing no vmangos server reaches: a type-1 (mount) +//! trainer's layout, where a row re-derived to state 2 also has its group key severed +//! (`0x4d82c1`) — noted, not transcribed, until that layout exists (§7.5). + +use std::collections::BTreeSet; + +use benilla_formats::{LearnEffect, SkillLineCatalog, SpellCatalog}; +use benilla_protocol::messages::trainer_spell_state::{GRAY, GREEN, RED}; +use benilla_protocol::messages::TrainerSpell; + +/// The `trainerType` the reference gates its state-3 writes on (`ds:0xb73a08 == 1`). +const TRAINER_TYPE_STATE_THREE: u32 = 1; +/// The state the type-1 required-ability leg writes instead of RED (`0x4d83a4`): a row no filter +/// can ever show (`SetTrainerServiceTypeFilter` sets bits 0/1/2 only — §5.2). +const HIDDEN: u8 = 3; + +/// One `PLAYER_SKILL_INFO` slot as the re-evaluator reads it: the line id, the step word +/// (`+0x84a`) and `value + permBonus` (`+0x84c` + `+0x852`) — the bonus added only when the +/// value is non-zero, the same guard the skill-pair reader `0x5ea460` has (verified, 2336). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SkillSlot { + pub skill_id: u32, + pub step: u32, + pub value_plus_perm: i32, +} + +/// The player's pet, when the bar names one whose object has streamed: its level and its own +/// spellbook (the `IsSpellKnown` pet path scans the pet's book, not the player's). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct PetView { + pub level: u32, + pub known: BTreeSet, +} + +/// Everything `0x4d7d40` reads off the player, gathered once per re-evaluation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct PlayerView { + /// The player's known-spell set (`[0xb710fc]`'s bitmap). + pub known: BTreeSet, + /// `UNIT_FIELD_LEVEL`. + pub level: u32, + /// The occupied `PLAYER_SKILL_INFO` slots. + pub skills: Vec, + /// The pet, or `None` — "no pet" is itself a verdict for a pet-spell service. + pub pet: Option, +} + +impl PlayerView { + fn skill(&self, skill_id: u32) -> Option<&SkillSlot> { + self.skills.iter().find(|s| s.skill_id == skill_id) + } +} + +/// `IsSpellKnown(unit, id) || KnownHigherRank(unit, id)` — `0x60c740` OR `0x60c8d0` +/// (wow-re `trainer-requirement.md`), against whichever unit's book `known` is. +fn known_or_higher(id: u32, known: &BTreeSet, skill_lines: &SkillLineCatalog) -> bool { + known.contains(&id) || skill_lines.higher_rank_known(id, known) +} + +/// Re-derive one service's state byte from the player, as `0x4d7d40` does per row. The wire +/// state is an input only through the admission gate: a row whose wire spell has no `Spell.dbc` +/// record is skipped whole and keeps it (`0x4d7dcd` → next row); every other row restarts at 0 +/// (`0x4d7dec`). +pub(super) fn re_derive( + wire: &TrainerSpell, + trainer_type: u32, + spells: &SpellCatalog, + skill_lines: &SkillLineCatalog, + player: &PlayerView, +) -> u8 { + // 0 · the admission gate. + if spells.get(wire.spell).is_none() { + return wire.state; + } + // 1 · the wire state is discarded; the state restarts at 0. + let mut state = GREEN; + // 2 · per learn effect of the WIRE spell, in slot order; every write overwrites the last. + let mut learn_effects = 0u32; + let mut known_effects = 0u32; + // The row's LEARN_SPELL target (`[ebp-0x28]`) and the pet its LEARN_PET_SPELL resolved + // (`[ebp-0x18]`) — both read again by the required-ability leg. + let mut taught: Option = None; + let mut pet_resolved: Option<&PetView> = None; + for effect in spells.learn_effects(wire.spell) { + match *effect { + LearnEffect::Spell(t) => { + learn_effects += 1; + taught = Some(t); + if trainer_type == TRAINER_TYPE_STATE_THREE + && skill_lines.higher_rank_known(t, &player.known) + { + // `0x4d7e3e`: at a type-1 trainer a rank the player has already passed is + // hidden outright — counted as a learn effect, never as a known one, so + // step 3 cannot turn it into 2. + state = HIDDEN; + } else if known_or_higher(t, &player.known, skill_lines) { + known_effects += 1; + } + } + LearnEffect::SkillStep { skill, step } => { + // The player's step word for that line against the effect's value (`0x4d7f17`); + // no slot for the line ⇒ no write (`0x4d7ecf`). + if player.skill(skill).is_some_and(|s| s.step >= step) { + state = GRAY; + } + } + LearnEffect::PetSpell(t) => { + learn_effects += 1; + // No pet ⇒ unavailable (`0x4d803c`), and the loop ends there. + let Some(pet) = &player.pet else { + state = RED; + break; + }; + pet_resolved = Some(pet); + if known_or_higher(t, &pet.known, skill_lines) { + // Known by the PET (`IsSpellKnown`'s pet path scans the pet's book). + known_effects += 1; + } else if wire.req_level > 1 && pet.level < u32::from(wire.req_level) { + // A pet below the row's level ⇒ unavailable (`0x4d7fac`), loop ends. + state = RED; + break; + } + } + } + } + // 3 · every learn effect known ⇒ known (`0x4d7fc6`), over whatever the loop left. + if learn_effects > 0 && known_effects == learn_effects { + state = GRAY; + } + // 4 · only while still 0: the skill leg. A slot for the line, below the requirement ⇒ + // unavailable (`0x4d811f`); NO slot ⇒ no write at all (`0x4d8082` jumps past it) — the + // reference's own green-for-a-skill-you-lack (§5.1). + if state == GREEN && wire.req_skill != 0 { + if let Some(slot) = player.skill(wire.req_skill) { + if slot.value_plus_perm < wire.req_skill_value as i32 { + state = RED; + } + } + } + // 5 · still 0: the required abilities, against the PET's book when the row resolved one + // (`0x4d81c4`) and the player's otherwise; the first missing one decides. On the pet the + // verdict is always unavailable (`0x4d8204`). On the player at a type-1 trainer it is the + // hidden state (`0x4d83a4`) when the missing ability is exactly the rank before what the + // row teaches — `SkillLineAbility.forward(missing) == taught` — and unavailable + // (`0x4d83ad`) for any other missing ability. + if state == GREEN { + let (book, on_pet) = match pet_resolved { + Some(pet) => (&pet.known, true), + None => (&player.known, false), + }; + let missing = wire + .req_spells + .iter() + .copied() + .filter(|&id| id != 0) + .find(|&id| !known_or_higher(id, book, skill_lines)); + if let Some(missing) = missing { + let previous_rank_of_taught = + taught.is_some_and(|t| skill_lines.rank_successor(missing) == Some(t)); + state = + if !on_pet && trainer_type == TRAINER_TYPE_STATE_THREE && previous_rank_of_taught { + HIDDEN + } else { + RED + }; + } + } + // Still 0: a level requirement above 1 the player has not reached (`0x4d8239`); no + // trainer-type variant. + if state == GREEN && wire.req_level > 1 && player.level < u32::from(wire.req_level) { + state = RED; + } + state +} + +/// Re-derive every listed service in place — the whole of `0x4d7d40`'s per-row pass. The group +/// recount the reference does next (`0x4d8260`–`0x4d83c9`) is the engine's, synthesized from the +/// pushed states (`benilla_ui::script::trainer`), so nothing is counted here. +pub(super) fn re_derive_all( + services: &mut [TrainerSpell], + trainer_type: u32, + spells: &SpellCatalog, + skill_lines: &SkillLineCatalog, + player: &PlayerView, +) { + for wire in services.iter_mut() { + wire.state = re_derive(wire, trainer_type, spells, skill_lines, player); + } +} diff --git a/crates/benilla-app/src/ui_trainer/tests.rs b/crates/benilla-app/src/ui_trainer/tests.rs index bba7307c..4a16d759 100644 --- a/crates/benilla-app/src/ui_trainer/tests.rs +++ b/crates/benilla-app/src/ui_trainer/tests.rs @@ -695,18 +695,15 @@ fn tooltip_falls_back_to_the_wire_spell() { ); } -/// **B256 — the state filter died on every purchase.** The reset the reference's list builder does -/// per packet (`0x4d75d9`, decision 1128) is only ever observable there at a window *opening*: the -/// reference never receives a list packet with the window already up, because it repaints a purchase -/// from a client-side state re-derivation (`0x4d7d40`). benilla has no such re-derivation and asks -/// the server for a fresh list after every buy — so our synthetic packet was dragging a reset along -/// that the reference cannot produce, and the player's "Available only" came back as -/// available+unavailable the moment they learned a spell (their collapsed groups went with it). -/// -/// So [`TrainerOpen::fresh_list`] means *this packet begins a window session*, and the post-buy -/// re-request marks its own answer as the repaint it is. The four cases below are the whole law. +/// **Every list packet begins a window session.** The reset the reference's list builder does +/// per packet (`0x4d75d9`, decision 1128) is only ever observable at a window *opening*, because +/// the reference never receives a list packet with the window already up: a purchase, a level, a +/// skill point repaint the open window through the state re-evaluator (`0x4d7d40`, +/// [`super::reeval`], decision 2333), never through a second list. Since 2333 benilla is the +/// same — the post-buy re-request that B256's "refresh" mark existed to tell apart is gone — so +/// the law is one line: a list packet opens, and opening resets. #[test] -fn only_a_packet_that_opens_a_window_resets_the_filter() { +fn every_list_packet_begins_a_window_session() { const TRAINER: u64 = 0xabc; let list = || vec![wire(1, trainer_spell_state::GREEN, 10, 1, 0)]; let mut open = TrainerOpen::default(); @@ -715,37 +712,409 @@ fn only_a_packet_that_opens_a_window_resets_the_filter() { assert!(open.fresh_list, "a first list opens the window: reset"); open.fresh_list = false; // the feed consumes it - // The post-buy re-request (`ui_trainer::net::trainer_buy_succeeded`) marks its own answer. - open.refresh_pending = true; - open.open(TRAINER, 0, list(), "Greetings".into()); - assert!( - !open.fresh_list, - "our own re-list repaints the open window — the player's filter and collapse stand" - ); - assert!( - !open.refresh_pending, - "and the mark is spent, so the NEXT packet is a real open again" - ); - open.open(TRAINER, 0, list(), "Greetings".into()); assert!( open.fresh_list, - "an unmarked packet is always a session start" + "the same trainer's list again is a session start again" ); - // A refresh that never came back must not eat a later open's reset: the close spends it. - open.refresh_pending = true; + // A pending re-derivation never survives a list: the packet carries the server's own states. + open.fresh_list = false; + open.trigger_re_derive(); + assert!(open.re_derive); + open.open(TRAINER, 0, list(), "Greetings".into()); + assert!( + !open.re_derive, + "a fresh list is the server's states — nothing pending against it" + ); + + // And a trigger with no window open is nothing at all (`0x4d7d46`'s early-out). open.clear(); - open.open(TRAINER, 0, list(), "Greetings".into()); - assert!(open.fresh_list, "a close drops any in-flight refresh mark"); + open.trigger_re_derive(); + assert!(!open.re_derive); +} - // Nor may a mark meant for one trainer be spent by another's list. - open.refresh_pending = true; - open.open(TRAINER + 1, 0, list(), "Greetings".into()); - assert!( - open.fresh_list, - "a different trainer is a new window whatever is in flight" - ); +mod re_derive { + //! The state re-evaluator's clauses ([`super::super::reeval`]), one test per byte-verified + //! leg of `0x4d7d40` (wow-re `trainer-service-suppression.md` §5), on synthetic catalogs. + use super::super::reeval::{re_derive, PetView, PlayerView, SkillSlot}; + use super::*; + use benilla_formats::{LearnEffect, SlaInfo, SpellDisplay}; + use std::collections::HashMap; + + const BLACKSMITHING: u32 = 164; + /// Wrapper 2020 teaches 2018 and steps Blacksmithing to 1 — the real openers' shape. + const OPENER: u32 = 2020; + const TAUGHT: u32 = 2018; + /// A class ability wrapper: rank 1 teaches 100, rank 2 teaches 101 (100 → 101 by + /// `forward_spellid`), no skill step. + const RANK1_WRAPPER: u32 = 500; + const RANK1: u32 = 100; + const RANK2: u32 = 101; + /// A pet wrapper: teaches the pet 300. + const PET_WRAPPER: u32 = 700; + const PET_SPELL: u32 = 300; + + /// A rank-2 wrapper: teaches RANK2 (whose previous rank is RANK1). + const RANK2_WRAPPER: u32 = 501; + + fn catalog() -> SpellCatalog { + // Every wrapper has a record — the admission gate (`0x4d7dcd`) skips a row without one. + let displays = [OPENER, RANK1_WRAPPER, RANK2_WRAPPER, PET_WRAPPER] + .into_iter() + .map(|id| (id, SpellDisplay::default())) + .collect(); + SpellCatalog::from_displays_and_effects( + displays, + HashMap::from([ + ( + OPENER, + vec![ + LearnEffect::Spell(TAUGHT), + LearnEffect::SkillStep { + skill: BLACKSMITHING, + step: 1, + }, + ], + ), + (RANK1_WRAPPER, vec![LearnEffect::Spell(RANK1)]), + (RANK2_WRAPPER, vec![LearnEffect::Spell(RANK2)]), + (PET_WRAPPER, vec![LearnEffect::PetSpell(PET_SPELL)]), + ]), + ) + } + + fn skill_lines() -> SkillLineCatalog { + let row = |skill_id, forward| SlaInfo { + skill_id, + req_skill_value: 0, + forward_spell_id: forward, + trivial_low: 0, + trivial_high: 0, + }; + SkillLineCatalog::from_abilities([(RANK1, row(1, RANK2)), (RANK2, row(1, 0))]) + } + + fn service(spell: u32) -> TrainerSpell { + // The WIRE state is RED on purpose: every case below must ignore it (`0x4d7dec`). + let mut w = wire(spell, trainer_spell_state::RED, 10, 1, 0); + w.req_skill_value = 0; + w + } + + fn player() -> PlayerView { + PlayerView { + known: BTreeSet::new(), + level: 10, + skills: Vec::new(), + pet: None, + } + } + + fn state(wire: &TrainerSpell, trainer_type: u32, player: &PlayerView) -> u8 { + re_derive(wire, trainer_type, &catalog(), &skill_lines(), player) + } + + #[test] + fn the_wire_state_is_discarded_and_a_met_service_reads_green() { + assert_eq!( + state(&service(RANK1_WRAPPER), 0, &player()), + trainer_spell_state::GREEN + ); + } + + #[test] + fn every_learn_effect_known_reads_gray() { + let mut p = player(); + p.known.insert(RANK1); + assert_eq!( + state(&service(RANK1_WRAPPER), 0, &p), + trainer_spell_state::GRAY + ); + } + + #[test] + fn a_higher_known_rank_counts_as_known() { + // Rank 2 in the book, rank 1 offered: `KnownHigherRank` (0x60c8d0) says known. + let mut p = player(); + p.known.insert(RANK2); + assert_eq!( + state(&service(RANK1_WRAPPER), 0, &p), + trainer_spell_state::GRAY + ); + } + + #[test] + fn a_skill_step_the_player_already_holds_reads_gray() { + let mut p = player(); + p.skills.push(SkillSlot { + skill_id: BLACKSMITHING, + step: 1, + value_plus_perm: 1, + }); + assert_eq!(state(&service(OPENER), 0, &p), trainer_spell_state::GRAY); + } + + #[test] + fn a_skill_line_below_the_requirement_reads_red() { + // A recipe-shaped service: a plain learn wrapper with a skill requirement. (An OPENER + // would read gray here — the player already holds the step it teaches.) + let mut w = service(RANK1_WRAPPER); + w.req_skill = BLACKSMITHING; + w.req_skill_value = 75; + let mut p = player(); + p.skills.push(SkillSlot { + skill_id: BLACKSMITHING, + step: 1, + value_plus_perm: 40, + }); + assert_eq!(state(&w, 0, &p), trainer_spell_state::RED); + // Met (value + permanent bonus reaches it): the leg leaves the state alone. + p.skills[0].value_plus_perm = 75; + assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN); + } + + /// The reference's own wart (§5.1): the skill scan's not-found exit jumps past the compare, + /// so a line the player lacks entirely writes nothing — green, where the server sends red. + #[test] + fn a_skill_line_the_player_lacks_entirely_reads_green() { + let mut w = service(RANK1_WRAPPER); + w.req_skill = BLACKSMITHING; + w.req_skill_value = 75; + assert_eq!(state(&w, 0, &player()), trainer_spell_state::GREEN); + } + + #[test] + fn an_unknown_required_ability_reads_red_or_hidden_at_type_one() { + // The opener (its taught spell unknown, no skill slot held) with a prerequisite ability. + let mut w = service(OPENER); + w.req_spells = [RANK1, 0, 0]; + assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED); + assert_eq!(state(&w, 2, &player()), trainer_spell_state::RED); + // Trainer type 1 too: RANK1 is not the rank before TAUGHT, so this is a plain miss, not + // the hidden state (`0x4d83a4`'s extra conjunct — `a_missing_previous_rank_…` pins it). + assert_eq!(state(&w, 1, &player()), trainer_spell_state::RED); + // Known — or known at a higher rank — satisfies it. + let mut p = player(); + p.known.insert(RANK2); + assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN); + let mut p = player(); + p.known.insert(RANK1); + assert_eq!(state(&w, 1, &p), trainer_spell_state::GREEN); + } + + #[test] + fn a_level_requirement_above_one_gates_on_the_players_level() { + let mut w = service(RANK1_WRAPPER); + w.req_level = 20; + assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED); + let mut p = player(); + p.level = 20; + assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN); + // `req_level <= 1` is not a gate at all (`row[+0x14] > 1`). + w.req_level = 1; + let mut p = player(); + p.level = 0; + assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN); + } + + #[test] + fn a_pet_spell_needs_a_pet_of_the_rows_level_and_reads_off_the_pets_book() { + let mut w = service(PET_WRAPPER); + w.req_level = 12; + // The row's level binds the player too (`0x4d8239`); keep the player clear of it so the + // pet legs are what decides. + let player = || PlayerView { + level: 20, + ..player() + }; + assert_eq!(state(&w, 3, &player()), trainer_spell_state::RED, "no pet"); + let mut p = player(); + p.pet = Some(PetView { + level: 11, + known: BTreeSet::new(), + }); + assert_eq!( + state(&w, 3, &p), + trainer_spell_state::RED, + "pet below the row's level" + ); + p.pet = Some(PetView { + level: 12, + known: BTreeSet::new(), + }); + assert_eq!(state(&w, 3, &p), trainer_spell_state::GREEN); + // Known by the PET, not the player: gray. + p.pet = Some(PetView { + level: 12, + known: BTreeSet::from([PET_SPELL]), + }); + assert_eq!(state(&w, 3, &p), trainer_spell_state::GRAY); + p.known.insert(PET_SPELL); + p.pet = Some(PetView { + level: 12, + known: BTreeSet::new(), + }); + assert_eq!( + state(&w, 3, &p), + trainer_spell_state::GREEN, + "the player's own book is not the pet's" + ); + } + + #[test] + fn the_legs_run_in_the_references_order() { + // A known service with an unmet skill line and an unmet level is GRAY: legs 4 and 5 run + // only while the state is still 0. + let mut w = service(OPENER); + w.req_skill = BLACKSMITHING; + w.req_skill_value = 300; + w.req_level = 60; + let mut p = player(); + p.known.insert(TAUGHT); + p.skills.push(SkillSlot { + skill_id: BLACKSMITHING, + step: 1, + value_plus_perm: 1, + }); + assert_eq!(state(&w, 2, &p), trainer_spell_state::GRAY); + } + + /// The admission gate (`0x4d7dcd`): a wire spell with no `Spell.dbc` record is skipped + /// whole — its wire state stands, whatever the player has. + #[test] + fn a_row_without_a_spell_record_keeps_its_wire_state() { + let w = wire(999_999, trainer_spell_state::RED, 10, 1, 0); + assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED); + let w = wire(999_999, trainer_spell_state::GRAY, 10, 1, 0); + assert_eq!(state(&w, 0, &player()), trainer_spell_state::GRAY); + } + + /// `0x4d7e3e`: at a type-1 trainer, a rank the player has already passed is hidden — and it + /// is counted as a learn effect but never as a known one, so it cannot come back as gray. + #[test] + fn a_higher_rank_known_at_a_type_one_trainer_hides_the_row() { + let mut p = player(); + p.known.insert(RANK2); + assert_eq!(state(&service(RANK1_WRAPPER), 1, &p), 3); + assert_eq!( + state(&service(RANK1_WRAPPER), 0, &p), + trainer_spell_state::GRAY + ); + // Knowing exactly the taught rank is not "higher": gray at every type. + let mut p = player(); + p.known.insert(RANK1); + assert_eq!( + state(&service(RANK1_WRAPPER), 1, &p), + trainer_spell_state::GRAY + ); + } + + /// `0x4d83a4`: the type-1 hidden state on the required-ability leg needs the missing ability + /// to be exactly the rank before what the row teaches; any other missing ability is plain + /// unavailable, and so is every miss at every other type. + #[test] + fn a_missing_previous_rank_at_a_type_one_trainer_hides_the_row() { + let mut w = service(RANK2_WRAPPER); + w.req_spells = [RANK1, 0, 0]; + assert_eq!( + state(&w, 1, &player()), + 3, + "the missing ability is the previous rank" + ); + assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED); + let mut w = service(RANK2_WRAPPER); + w.req_spells = [RANK1 + 500, 0, 0]; + assert_eq!( + state(&w, 1, &player()), + trainer_spell_state::RED, + "an unrelated ability" + ); + } + + /// The pet-spell legs are early exits: nothing after a missing or under-level pet is + /// visited (`0x4d803c`/`0x4d7fac` leave the loop), and a pet that KNOWS the spell is never + /// level-tested at all. + #[test] + fn the_pet_legs_leave_the_loop_and_a_knowing_pet_skips_the_level_test() { + let spells = SpellCatalog::from_displays_and_effects( + HashMap::from([(PET_WRAPPER, SpellDisplay::default())]), + HashMap::from([( + PET_WRAPPER, + vec![ + LearnEffect::PetSpell(PET_SPELL), + LearnEffect::SkillStep { + skill: BLACKSMITHING, + step: 1, + }, + ], + )]), + ); + let mut w = service(PET_WRAPPER); + w.req_level = 12; + let mut p = player(); + p.level = 20; + p.skills.push(SkillSlot { + skill_id: BLACKSMITHING, + step: 1, + value_plus_perm: 1, + }); + // No pet: the step effect after it is never reached, so the row stays unavailable. + assert_eq!( + re_derive(&w, 3, &spells, &skill_lines(), &p), + trainer_spell_state::RED + ); + // A pet that knows the spell, below the row's level: known, not level-tested. + p.pet = Some(PetView { + level: 5, + known: BTreeSet::from([PET_SPELL]), + }); + assert_eq!( + re_derive(&w, 3, &spells, &skill_lines(), &p), + trainer_spell_state::GRAY + ); + } + + /// The required-ability leg runs on the PET's book when the row resolved a pet, and its + /// failure is unavailable at every trainer type — the type-1 hidden state is the player + /// variant's alone. + #[test] + fn a_pet_rows_required_abilities_are_the_pets_and_never_hide() { + let mut w = service(PET_WRAPPER); + w.req_spells = [RANK1, 0, 0]; + let mut p = player(); + p.level = 20; + p.known.insert(RANK1); // the PLAYER knows it — irrelevant for a pet row + p.pet = Some(PetView { + level: 20, + known: BTreeSet::new(), + }); + assert_eq!(state(&w, 3, &p), trainer_spell_state::RED); + assert_eq!( + state(&w, 1, &p), + trainer_spell_state::RED, + "no hidden state on the pet path" + ); + p.pet = Some(PetView { + level: 20, + known: BTreeSet::from([RANK2]), + }); + assert_eq!( + state(&w, 3, &p), + trainer_spell_state::GREEN, + "the pet knows a higher rank" + ); + } + + #[test] + fn re_derive_all_rewrites_every_row_in_place() { + let mut list = vec![service(RANK1_WRAPPER), service(OPENER)]; + let mut p = player(); + p.known.insert(RANK1); + super::super::reeval::re_derive_all(&mut list, 0, &catalog(), &skill_lines(), &p); + assert_eq!(list[0].state, trainer_spell_state::GRAY); + assert_eq!(list[1].state, trainer_spell_state::GREEN); + } } /// The three group-header keys against the player's REAL `GlobalStrings.lua` — the guard the diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index df322d43..d041a26a 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -792,7 +792,7 @@ impl WorldAssets { } /// World/render configuration read once at startup from the environment, shared by the subsystems -/// that need it: terrain reads `tex_tiles` (splat tiling) + `unload_budget` (the release lane). +/// that need it: terrain reads `unload_budget` (the release lane). /// Inserted by [`AssetPlugin`] alongside [`WorldAssets`]; its *presence* is also the "there is a /// client install" gate the world-side setups key on. /// @@ -801,8 +801,6 @@ impl WorldAssets { /// Distance setting), as the reference derives it (decision 1513). #[derive(Resource, Clone, Copy)] pub struct RenderConfig { - /// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8). - pub tex_tiles: f32, /// Stale tiles released per frame on a within-map window shift (`$WOW_TILE_UNLOAD`, /// default 1; `0` = unbudgeted — the whole trailing row in one frame, the pre-B181 /// behaviour, kept as the controlled A/B leg on one build). diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 918d2cb6..41794b3a 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -197,14 +197,14 @@ pub use spells::{ cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii, load_spell_ranges, min_max_range, substitute, CcExemption, FormRefusal, LearnAnnouncement, - OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, - SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange, - SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, - ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, - SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM, - SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, - SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, - SPELL_EFFECT_TRADE_SKILL, + LearnEffect, OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, + SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, + SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, + ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, + ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, + SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, + SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, + SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, }; mod skill_lines; pub use skill_lines::{ @@ -266,7 +266,7 @@ pub use terrain::{ adt_to_tile_mesh, area_id_at, find_tile_near, ground_effect_at, impassable_at, load_tile_mesh, load_tiles_around, mcsh_shadowed_at, terrain_height_at, triangle_z_at, ChunkMesh, Doodad, MapTiles, TileMesh, WmoInstance, ALPHA_MAP_SIZE, CHUNK_SIZE, SHADOW_MAP_SIZE, STORMWIND_XY, - TILE_SIZE, + TERRAIN_LAYER_TILES, TILE_SIZE, }; mod wdl; /// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles diff --git a/crates/benilla-formats/src/skill_lines/mod.rs b/crates/benilla-formats/src/skill_lines/mod.rs index 4fc76ed3..ab2f6dea 100644 --- a/crates/benilla-formats/src/skill_lines/mod.rs +++ b/crates/benilla-formats/src/skill_lines/mod.rs @@ -351,6 +351,46 @@ impl SkillLineCatalog { head } + /// **Is a higher rank of `spell_id` known?** — the reference's `KnownHigherRank` + /// (`0x60c8d0`, wow-re `trainer-requirement.md`): walk the `forward_spellid` chain forward + /// from `spell_id` (exclusive) and answer on the first known rank. The trainer's requirement + /// colouring and its state re-evaluator both OR this with plain known-ness (2333). + pub fn higher_rank_known( + &self, + spell_id: u32, + known: &std::collections::BTreeSet, + ) -> bool { + let mut cur = spell_id; + for _ in 0..MAX_RANK_CHAIN { + let Some(next) = self.rank_successor(cur) else { + return false; + }; + if known.contains(&next) { + return true; + } + cur = next; + } + false + } + + /// A catalog holding only ability rows — for tests that need a rank chain or a skill + /// requirement without the DBCs ([`Self::from_spell_lines`]'s fuller sibling). + pub fn from_abilities(abilities: impl IntoIterator) -> Self { + let abilities: HashMap = abilities.into_iter().collect(); + let rank_prev = abilities + .iter() + .filter(|(_, a)| a.forward_spell_id != 0) + .map(|(id, a)| (a.forward_spell_id, *id)) + .collect(); + Self { + abilities, + lines: HashMap::new(), + categories: HashMap::new(), + race_class: HashMap::new(), + rank_prev, + } + } + /// The **highest rank of `spell_id`'s ability that `known` contains** — the rank an action-bar /// slot pointing at `spell_id` must actually hold. `None` when no rank of the chain is known /// (an empty book, or an ability the character never learned): the caller leaves the slot diff --git a/crates/benilla-formats/src/skill_lines/tests.rs b/crates/benilla-formats/src/skill_lines/tests.rs index 7741d492..c0546294 100644 --- a/crates/benilla-formats/src/skill_lines/tests.rs +++ b/crates/benilla-formats/src/skill_lines/tests.rs @@ -385,3 +385,34 @@ fn real_rank_chains_resolve_the_highest_known_rank() { assert_eq!(cat.rank_successor(6603), None); assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603)); } + +/// `KnownHigherRank` (`0x60c8d0`): walk the `forward_spellid` chain forward from the spell and +/// answer on the first known rank — the spell itself is not a "higher" rank of itself. +#[test] +fn a_higher_rank_is_known_only_forward_along_the_chain() { + use std::collections::BTreeSet; + let row = |forward| SlaInfo { + skill_id: 1, + req_skill_value: 0, + forward_spell_id: forward, + trivial_low: 0, + trivial_high: 0, + }; + // 10 → 11 → 12. + let cat = SkillLineCatalog::from_abilities([(10, row(11)), (11, row(12)), (12, row(0))]); + let known = |ids: &[u32]| ids.iter().copied().collect::>(); + assert!(cat.higher_rank_known(10, &known(&[12]))); + assert!(cat.higher_rank_known(10, &known(&[11]))); + assert!( + !cat.higher_rank_known(10, &known(&[10])), + "the spell itself is not a higher rank" + ); + assert!( + !cat.higher_rank_known(12, &known(&[10, 11])), + "nothing is forward of the top rank" + ); + assert!( + !cat.higher_rank_known(99, &known(&[10, 11, 12])), + "an unchained spell has no chain" + ); +} diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index fe8055c3..910a0e69 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -298,6 +298,24 @@ const COL_EFFECT_TRIGGER_1: usize = 109; /// `SpellEffects` value `36` — `SPELL_EFFECT_LEARN_SPELL`: a trainer's learn wrapper carries it, and /// its `EffectTriggerSpell` is the ability the player ends up with (decision 0247's taught spell). pub const SPELL_EFFECT_LEARN_SPELL: u32 = 36; + +/// One of a spell's three effect slots, as the trainer's state re-evaluator reads them +/// (`0x4d7d40`, wow-re `trainer-service-suppression.md` §5; decision 2333): the three effect +/// types that decide whether a service is already "known". Any other effect type is not a learn +/// effect and is skipped. Slot order is kept — the reference walks `+0xf4/+0xf8/+0xfc` in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LearnEffect { + /// `SPELL_EFFECT_LEARN_SPELL` — teaches `EffectTriggerSpell`. + Spell(u32), + /// `SPELL_EFFECT_SKILL_STEP` — raises skill line `skill` (`EffectMiscValue`) to `step`. The + /// step is the effect's value, `EffectBasePoints + EffectDieSides`: on the real build-5875 + /// file every profession opener carries `die = 1` and `base = step − 1` (Apprentice `0+1`, + /// Journeyman `1+1`, Expert `2+1`, Artisan `3+1` — pinned by + /// `the_openers_skill_steps_read_off_the_real_file`). + SkillStep { skill: u32, step: u32 }, + /// `SPELL_EFFECT_LEARN_PET_SPELL` — teaches the player's pet `EffectTriggerSpell`. + PetSpell(u32), +} /// `SpellEffects` value `57` — `SPELL_EFFECT_LEARN_PET_SPELL`, the learn wrapper's pet twin. The /// trainer's **icon** law accepts either in its three-slot wrapper scan (byte-verified: the paired /// `cmp ecx,0x24` / `cmp ecx,0x39` at `0x4d8ff5`/`0x4d8ffa`, wow-re @@ -609,6 +627,9 @@ pub const SPELL_EFFECT_PROSPECTING: u32 = 127; pub struct SpellCatalog { spells: HashMap, learned_spell: HashMap, + /// Spell id → its learn effects in slot order ([`SpellCatalog::learn_effects`]); only spells + /// that carry at least one have an entry. + learn_effects: HashMap>, /// Spell id → the `Languages.dbc` id its `Effect_1` declares /// ([`SpellCatalog::declared_language`]). declared_language: HashMap, @@ -620,14 +641,39 @@ impl SpellCatalog { /// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing) and /// no dispel table, so [`Self::dispel_name`] answers `None` for everything. pub fn from_displays(spells: HashMap) -> Self { + Self::from_displays_and_effects(spells, HashMap::new()) + } + + /// [`Self::from_displays`] with an explicit learn-effect table — for the trainer tests, whose + /// synthetic wrappers need to teach something. + pub fn from_displays_and_effects( + spells: HashMap, + learn_effects: HashMap>, + ) -> Self { + let learned_spell = learn_effects + .iter() + .filter_map(|(id, effects)| { + effects.iter().find_map(|e| match e { + LearnEffect::Spell(taught) => Some((*id, *taught)), + _ => None, + }) + }) + .collect(); Self { spells, - learned_spell: HashMap::new(), + learned_spell, + learn_effects, declared_language: HashMap::new(), dispel_types: SpellDispelTypes::default(), } } + /// A spell's learn effects in slot order — empty for a plain ability. What the trainer's + /// state re-evaluator walks (decision 2333). + pub fn learn_effects(&self, id: u32) -> &[LearnEffect] { + self.learn_effects.get(&id).map_or(&[], Vec::as_slice) + } + pub fn get(&self, id: u32) -> Option<&SpellDisplay> { self.spells.get(&id) } @@ -770,6 +816,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?; let mut spells: HashMap = HashMap::new(); let mut learned_spell: HashMap = HashMap::new(); + let mut learn_effects: HashMap> = HashMap::new(); let mut declared_language: HashMap = HashMap::new(); for r in spells_set.records() { let Some(id) = u32_at(r, 0) else { continue }; @@ -784,6 +831,30 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { } } } + // The learn effects in slot order, for the trainer's state re-evaluator (2333). A + // SKILL_STEP's step is the effect's value: base points + die sides (see `LearnEffect`). + let effects: Vec = (0..3) + .filter_map(|i| { + let trigger = || u32_at(r, COL_EFFECT_TRIGGER_1 + i).filter(|&t| t != 0); + match u32_at(r, COL_EFFECT_1 + i)? { + SPELL_EFFECT_LEARN_SPELL => trigger().map(LearnEffect::Spell), + SPELL_EFFECT_LEARN_PET_SPELL => trigger().map(LearnEffect::PetSpell), + SPELL_EFFECT_SKILL_STEP => { + let skill = i32_at(r, COL_EFFECT_MISC_1 + i).filter(|&m| m > 0)? as u32; + let value = i32_at(r, COL_EFFECT_BASE_POINTS_1 + i).unwrap_or(0) + + i32_at(r, COL_EFFECT_DIE_SIDES_1 + i).unwrap_or(0); + Some(LearnEffect::SkillStep { + skill, + step: value.max(0) as u32, + }) + } + _ => None, + } + }) + .collect(); + if !effects.is_empty() { + learn_effects.insert(id, effects); + } // The language declaration (wow-re `chat-language-scramble.md` §8). **Effect slot 0 only** // — the reference dispatches on `Effect_1` alone (`[SpellRec+0xf4]`) and reads // `EffectMiscValue_1` (`+0x1a8`); it does not scan the other two slots the way the learn @@ -949,6 +1020,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { Ok(SpellCatalog { spells, learned_spell, + learn_effects, declared_language, dispel_types, }) @@ -957,3 +1029,50 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { #[cfg(test)] #[path = "catalog_tests.rs"] mod tests; + +#[cfg(test)] +mod learn_effect_tests { + use super::*; + + /// The profession openers on the real build-5875 file: the WIRE spell (the wrapper a trainer + /// lists) carries both the learn effect and the skill step, and the step is base points + die + /// sides. What `0x4d7d40` compares the player's step word against (2333). + #[test] + fn the_openers_skill_steps_read_off_the_real_file() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("Spell.dbc"); + let blacksmithing = 164; + for (wrapper, taught, step) in [ + (2020, 2018, 1), + (2021, 3100, 2), + (3539, 3538, 3), + (9786, 9785, 4), + ] { + assert_eq!( + cat.learn_effects(wrapper), + &[ + LearnEffect::Spell(taught), + LearnEffect::SkillStep { + skill: blacksmithing, + step + } + ], + "wrapper {wrapper}" + ); + } + // Mining's opener: base −1 + die 1 on the LEARN slot is not a step; the SKILL_STEP slot is. + assert_eq!( + cat.learn_effects(2581), + &[ + LearnEffect::Spell(2575), + LearnEffect::SkillStep { + skill: 186, + step: 1 + } + ] + ); + // A plain ability teaches nothing. + assert!(cat.learn_effects(2383).is_empty(), "Find Herbs"); + } +} diff --git a/crates/benilla-formats/src/terrain.rs b/crates/benilla-formats/src/terrain.rs index 6c7420ca..89a6ecea 100644 --- a/crates/benilla-formats/src/terrain.rs +++ b/crates/benilla-formats/src/terrain.rs @@ -40,6 +40,12 @@ pub(crate) const UNIT_SIZE: f32 = CHUNK_SIZE / 8.0; /// World (x, y) of Stormwind, from `TaxiNodes.dbc` — a handy anchor for "Elwynn". pub const STORMWIND_XY: (f32, f32) = (-8840.56, 489.7); +/// Ground-texture repeats per chunk — how many times a layer texture tiles across one MCNK. The +/// binary's own value is 8 (each of the chunk's 8×8 cells carries one repeat), and it is not a +/// setting: a `$WOW_TEX_TILES` knob once let a capture try other values and was retired (2335) +/// because nothing in the reference tunes it. +pub const TERRAIN_LAYER_TILES: f32 = 8.0; + /// One MCNK chunk as an indexed triangle mesh in **raw WoW coords**, plus texturing data. /// /// Vertex arrays (`positions`, `normals`, `uvs`) hold **145 entries** in MCVT's stride-17 order: @@ -56,8 +62,8 @@ pub struct ChunkMesh { /// removes the lighting seams at MCNK boundaries. Empty if the chunk had no MCNR data. pub normals: Vec<[f32; 3]>, /// Per-vertex texture coordinates (0..1 across the chunk; outer at `(c/8, r/8)`, inner at - /// `((c+½)/8, (r+½)/8)`). Renderers scale `× tex_tiles` (=8 per the binary) for layer - /// sampling and use the raw 0..1 for alpha/shadow. + /// `((c+½)/8, (r+½)/8)`). Renderers scale `× `[`TERRAIN_LAYER_TILES`] for layer sampling and + /// use the raw 0..1 for alpha/shadow. pub uvs: Vec<[f32; 2]>, /// Triangle-list indices into `positions` (4-tri center-fan/cell, holed blocks omitted). pub indices: Vec, diff --git a/crates/benilla-world/src/assets/mod.rs b/crates/benilla-world/src/assets/mod.rs index 35739e26..146b48bd 100644 --- a/crates/benilla-world/src/assets/mod.rs +++ b/crates/benilla-world/src/assets/mod.rs @@ -82,21 +82,13 @@ fn open_world_assets(mut commands: Commands, device: Res) { // How much terrain is resident is NOT a knob here: the streamer derives its window from the // live `farclip` (`view::ViewDistance`, the player's Terrain Distance setting) the way the // reference does — `terrain_stream::window`, decision 1513. `$WOW_TILE_RADIUS` is retired. - // Ground-texture repeats per chunk; tunable live in the panel afterward. - let tex_tiles = std::env::var("WOW_TEX_TILES") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(8.0); // See the field doc: the tile-unload budget (B181). Default 1 — even the fastest focus // (boosted free-fly, ~1 stale row/s) produces stale tiles far slower than 60/s drains them. let unload_budget = std::env::var("WOW_TILE_UNLOAD") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(1); - commands.insert_resource(RenderConfig { - tex_tiles, - unload_budget, - }); + commands.insert_resource(RenderConfig { unload_budget }); match open_chain(&data) { Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)), diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs index 922e0d4f..638c786a 100644 --- a/crates/benilla-world/src/clutter.rs +++ b/crates/benilla-world/src/clutter.rs @@ -163,14 +163,13 @@ pub(crate) const DETAIL_DOODAD_FADE_FAR: f32 = 70.0; /// — player-settable through **either** registered CVar over this one field, [`ClutterConfig::frill_density`] /// being the conversion: `WorldDetail` (the panel's stop, 0/1/2 → ×1/×2/×3) or `frillDensity` (the /// reference's own cells-per-chunk, 1..256), both arms in `benilla-app`'s `cvars`; -/// `scale` resizes each doodad model; `alpha_ref` is the alpha-test cutout threshold +/// `alpha_ref` is the alpha-test cutout threshold /// ([`DETAIL_DOODAD_ALPHA_REF`]); `fade_far` is the clutter draw-distance horizon (yd, /// [`DETAIL_DOODAD_FADE_FAR`]; fade starts at 0.75×). Initial values from `$WOW_CLUTTER_DENSITY` / -/// `$WOW_CLUTTER_SCALE` / `$WOW_CLUTTER_ALPHA` / `$WOW_CLUTTER_FADE`; `density 0` disables clutter. +/// `$WOW_CLUTTER_ALPHA` / `$WOW_CLUTTER_FADE`; `density 0` disables clutter. #[derive(Resource, Clone, Copy)] pub struct ClutterConfig { pub density: f32, - pub scale: f32, pub alpha_ref: f32, pub fade_far: f32, } @@ -216,7 +215,6 @@ impl Default for ClutterConfig { // overdraw on the ground — the thing a bandwidth-bound part has least of. Medium is the // nearest stop no sparser than a fresh install, and the row is one drag from High. density: env("WOW_CLUTTER_DENSITY").unwrap_or(2.0).max(0.0), - scale: env("WOW_CLUTTER_SCALE").unwrap_or(1.0).max(0.01), alpha_ref: env("WOW_CLUTTER_ALPHA") .unwrap_or(DETAIL_DOODAD_ALPHA_REF) .clamp(0.0, 1.0), @@ -402,7 +400,6 @@ pub(crate) fn scatter_tile_clutter( fn build_chunk_clutter( chunk_entity: Entity, models: &[(String, Vec)], - scale: f32, alpha_ref: f32, fade_far: f32, geometry: &mut ClutterGeometry, @@ -437,8 +434,8 @@ fn build_chunk_clutter( let base = positions.len() as u32; let origin = wow_to_bevy(d.position); let rot = Quat::from_rotation_y(d.yaw); - // Per-instance scale (client's random [0.9,1.1]) × the debug-panel size multiplier. - let inst_scale = scale * d.scale; + // Per-instance scale (the client's random [0.9,1.1]). + let inst_scale = d.scale; // MCSH tint per placement — same value on every vertex of THIS instance, so all // tris of one grass clump share the lit/shadowed colour. The shader multiplies // `texture × vertex_color` in gamma space (the faithful MODULATE 1×). @@ -626,7 +623,6 @@ pub(crate) fn stream_chunk_clutter( let built = build_chunk_clutter( ent, &cc.models, - cfg.scale, cfg.alpha_ref, cfg.fade_far, &mut geometry, diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index fdfa3fdd..42417814 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -750,7 +750,6 @@ fn stream_terrain( // wall moves this with it. let center = focus.resolve(camera.single().ok().map(|c| c.translation)); let window = StreamWindow::at(view.farclip, center[0], center[1]); - let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0); let (cx, cy) = window.focus_tile(); let same_window = state.focus == (cx, cy) && state.reach == Some((window.inner, window.outer)); // Never while a load is in flight: `paced` is false from a snap until the body settles, and @@ -960,7 +959,7 @@ fn stream_terrain( layer_array: adt.layer_array.clone(), alpha_array: adt.alpha_array.clone(), shadow_array: adt.shadow_array.clone(), - params: Vec4::new(tiling, 0.0, 0.0, 0.0), + params: Vec4::new(benilla_formats::TERRAIN_LAYER_TILES, 0.0, 0.0, 0.0), light_buf: shared_light.0.clone(), }, });