From 6ff3f88b87f4990034992b051480299b62f6b2d2 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 21 Aug 2026 18:03:19 +0700 Subject: [PATCH] bogus form requirements, the hover jump, and mining at skill 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - priest tooltips drop their bogus red lines ("Requires Shadowform" on Psychic Scream and Inner Fire, "Requires Spirit of Redemption" on Flash Heal): `Spell.dbc`'s Stances mask is an allow-list, not a requirement, when `AttributesEx2` bit 19 is set — 244 of the 522 masked spells carry the waiver, and the tooltip now consults the same gate the cast validator already did; Charge's "Requires Battle Stance" still asserts - a hovering mover refuses the jump, silently — no packet, no toast — exactly as `CMovement::Jump` does: the HOVER test sits ahead of the swim select, so a levitating swimmer can't breach either; the old gate refused on ROOT only - opening a lock gates on your skill, never your level: mining with 1 Mining no longer appears to open a 300 vein only to be bounced by the server — the client-side "Requires Mining ≥N" refusal fires with no packet sent, and the level term rides the skill block through the same clamp the reference uses - the Mod/Mod2x fade was never a deviation — preset 5 IS the identity-lerp, byte-exact, five comment sites re-lawed with no behaviour change; and the garble word pool (`LanguageWords.dbc`) loads, the first hop of the language scramble that lands next Co-Authored-By: Claude Fable 5 --- .../src/entities/equipment/spawn.rs | 10 +- crates/benilla-app/src/player.rs | 13 +- crates/benilla-app/src/player/mover.rs | 61 ++++++- crates/benilla-app/src/target/click.rs | 1 + crates/benilla-app/src/target/cursor_mode.rs | 1 + crates/benilla-app/src/target/lock.rs | 163 +++++++++++++++-- crates/benilla-app/src/ui_action/mod.rs | 6 +- crates/benilla-app/src/ui_action/usable.rs | 5 +- crates/benilla-app/src/ui_craft.rs | 3 + crates/benilla-app/src/ui_tooltip/mod.rs | 21 ++- crates/benilla-app/src/ui_tooltip/tests.rs | 21 +++ .../benilla-assets/src/shaders/wow_model.wgsl | 23 +-- crates/benilla-formats/src/languages.rs | 168 ++++++++++++++++++ crates/benilla-formats/src/lib.rs | 4 +- crates/benilla-formats/src/skill_lines/mod.rs | 28 +++ .../benilla-formats/src/spells/cast_times.rs | 13 +- .../src/spells/catalog_tests.rs | 46 +++-- crates/benilla-formats/src/spells/display.rs | 81 +++++++-- crates/benilla-formats/src/spells/mod.rs | 23 ++- crates/benilla-protocol/src/messages/mod.rs | 4 +- .../src/messages/update_object/fields/mod.rs | 6 +- .../src/messages/update_object/mod.rs | 5 +- crates/benilla-world/src/ffx_glow.rs | 25 ++- crates/benilla-world/src/model_fade.rs | 3 +- .../benilla-world/src/model_render/batch.rs | 10 +- .../src/terrain_stream/spawn/assemble.rs | 11 +- 26 files changed, 652 insertions(+), 103 deletions(-) diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index d4acdd9d..846e771c 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -1192,11 +1192,11 @@ mod tests { /// **The director's login report on the Naxx items** (decision 0865): a MULTIPLY sheen batch /// (Mod2x — the ARMORREFLECT family) used to spawn Steady, popping as a full-strength ×2 layer /// over a body still fading in. Its blend equation reads no alpha, so no material swap can - /// feather it (0528, byte-reconfirmed by wow-re `m2-item-texture-fill.md`) — instead the part - /// now arms the ramp on its STEADY material (the "twin" is itself) and the shader lerps its - /// colour toward the blend identity by the tag alpha. Headless, this asserts the arm half: - /// joined ramp, tag alpha ≈ 0, no material swap, and a FadeMaterials record so despawn/stealth - /// ramps re-arm it too. + /// feather it (0528) — instead the part arms the ramp on its STEADY material (the "twin" is + /// itself) and the shader lerps its colour toward the blend identity by the tag alpha, which + /// is the reference's own preset-5 fade (1489; 0865 built it believing it a deviation). + /// Headless, this asserts the arm half: joined ramp, tag alpha ≈ 0, no material swap, and a + /// FadeMaterials record so despawn/stealth ramps re-arm it too. #[test] fn a_multiply_sheen_joins_the_wearers_ramp_on_its_steady_material() { const KIND: ItemModelKind = ItemModelKind::Weapon; diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 3612fc6a..ebd751fd 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -1068,6 +1068,11 @@ fn control( } else { run_speed }; + // Root is refused HERE (the reference refuses it twice upstream of the handler: the Lua + // gate's `test ch,0x12` and the replay allow-list `0x615c71`/`0x618030`, where command + // id 7 is blocked). HOVER's refusal is NOT here — it belongs to the movement handler + // itself (`0x7c623a`, the breach term below and [`mover::step`]'s grounded arm), which + // is what keeps the mounted flourish reachable while hovering, as the reference has it. let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && !player.modes.rooted; // Swim vs walk: the water over our feet decides. Hysteresis-latched (`update_swimming`, @@ -1090,7 +1095,13 @@ fn control( // frame — the byte handler runs before the mover, clearing SWIMMING unconditionally — // so the latch drops now and this frame's mover, flags, and wire all see the leap as a // jump. - let breach = swimming && want_jump; + // HOVER refuses the breach too: `0x7c623a`'s test sits AHEAD of the SWIMMING take-off + // select (`0x7c6261` only picks the seed velocity, it gates nothing), and hover does not + // suppress swim entry — `0x6030c0` tests only LEVITATING (`0x400`) — so a hovering + // swimmer is a real state and their Space does nothing at all (wow-re + // `fall-steep-response.md` §10). The land leg's refusal lives in [`mover::step`], + // the same handler's grounded arm. + let breach = swimming && want_jump && !player.modes.hover; if breach { player.swimming = false; } diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs index 98004f08..4e62ab9a 100644 --- a/crates/benilla-app/src/player/mover.rs +++ b/crates/benilla-app/src/player/mover.rs @@ -167,7 +167,17 @@ pub(super) fn step( player.horiz_vel = Vec3::ZERO; } else if grounded { player.vel_y = 0.0; - if want_jump { + // **HOVER refuses the jump** — the FIRST test in `CMovement::Jump 0x7c6230` + // (`0x7c623a test [ecx+0x40], 0x40000000`; wow-re `fall-steep-response.md` §10). The + // keyboard jump IS the forced jump (`moveflag-family.md` §4.2, CORRECTED 2026-08-08: + // the queued command's replay pushes force=1 at `0x615eba`; the sole force=0 caller is + // the wire's SetHover(0) path, which exists so a server move can jump a hovering unit). + // The refusal is SILENT, byte-for-byte: the caller drops it at `0x615ec5` — no + // MSG_MOVE_JUMP (its only emission site sits behind this very test), no toast, no retry + // latch, no cooldown. The mounted flourish is deliberately NOT gated on hover — + // `0x60dea0` diverts to MountSpecial before the command ever reaches this handler + // (`player::control`'s flourish block runs upstream of this call, same order). + if want_jump && !player.modes.hover { player.vel_y = JUMP_SPEED; player.wedged = false; player.steep_support = false; @@ -1843,6 +1853,55 @@ mod tests { assert!(out.dot(n) >= -1e-6); } + #[test] + fn a_hovering_mover_refuses_the_jump_and_leaves_no_trace() { + // `0x7c623a` — HOVER is the FIRST test in `CMovement::Jump 0x7c6230`, and the keyboard + // jump is the forced jump it applies to (wow-re `fall-steep-response.md` §10; + // `moveflag-family.md` §4.2 CORRECTED 2026-08-08 — every player-side call site pushes + // force=1). Refused means NOTHING happens: no take-off velocity, `jumped` false (the + // wire's MSG_MOVE_JUMP rides that bit), no latch. The same press with hover off is the + // control that proves this fixture can jump at all. + let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)]; + let rows = world_from_profile(&flat) + .world_mut() + .run_system_once(|world: benilla_world::collision::WorldCollision| { + let capsule = player_capsule(); + let mut time = Time::default(); + time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0)); + [true, false].map(|hover| { + let mut player = Player { + modes: super::super::state::MoveModes { + hover, + ..Default::default() + }, + ..Default::default() + }; + let out = step( + &mut player, + &time, + &world, + &capsule, + false, + Vec3::X, + 0.0, + true, + None, + ); + (out.jumped, player.vel_y) + }) + }) + .unwrap(); + let [(hover_jumped, hover_vy), (plain_jumped, plain_vy)] = rows; + assert!( + !hover_jumped && hover_vy == 0.0, + "hover must refuse the jump silently: jumped {hover_jumped}, vel_y {hover_vy}" + ); + assert!( + plain_jumped && plain_vy > 0.0, + "the hover-free control must take off: jumped {plain_jumped}, vel_y {plain_vy}" + ); + } + /// **The Elwynn hillside the director jump-climbed** — a 55° face (`ny = +0.574`, the normal /// the capture read off the real slope at WoW `(-9236.8, -341.9, 101.6)`) rising out of flat /// ground. Too steep to walk by a wide margin, so nothing here may ever end with the body diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 4122de83..dc089bfd 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -503,6 +503,7 @@ pub(crate) fn resolve_go_action( slots, known, inputs.spells.as_deref(), + inputs.skill_lines.as_ref().map(|s| &s.catalog), me_store, &inputs.items, facts, diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 9cefb267..dfcfea1a 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -779,6 +779,7 @@ pub(super) fn classify_cursor( slots, &player_actions.spells, go_inputs.spells.as_deref(), + go_inputs.skill_lines.as_ref().map(|s| &s.catalog), Some(self_store), &go_inputs.items, facts, diff --git a/crates/benilla-app/src/target/lock.rs b/crates/benilla-app/src/target/lock.rs index 4f141a39..40be999b 100644 --- a/crates/benilla-app/src/target/lock.rs +++ b/crates/benilla-app/src/target/lock.rs @@ -19,13 +19,18 @@ //! gate hands every padlocked door in the game to every player. The Searing Gorge gate (lock 84) //! was the one that refused because it is the one that carries no `Action 0` slot. //! -//! ## Satisfaction is the SPELL's value, not the player's skill +//! ## Satisfaction is the SPELL's value — and the value's level term IS the player's skill //! //! `0x5f850f` compares the matched spell's own OPEN_LOCK **effect value** //! ([`benilla_formats::SpellDisplay::open_lock_skill`]) against the slot's requirement — which is -//! `Skill[i]`, or **`GAMEOBJECT_LEVEL × 5`** when `Skill[i]` is zero (`0x5f84be`). It never reads -//! the skill block. That is not an approximation on the client's part: the DBC encodes the same -//! number (Pick Lock at 60 → 300, a rogue's cap; Mining at 60 → 300). +//! `Skill[i]`, or **`GAMEOBJECT_LEVEL × 5`** when `Skill[i]` is zero (`0x5f84be`). The sentence +//! that used to stand here — "it never reads the skill block" — was `cursor-system.md` §8.8's +//! absolute, and wow-re REFUTED it at the bytes (`openlock-spell-store-order.md` §4a, +//! 2026-08-14): the value's level term routes through the CGPlayer vtable (`0x6e384d → 0x6e3130 +//! → [vtbl+0xa8] = 0x5ea690`) into `PLAYER_SKILL_INFO` for the spell's own SkillLineAbility +//! line ([`spell_skill_value`]). At skill cap the two readings coincide (`skill/5 == level`), +//! which is how the old one survived every at-cap cross-check while a level-60 with 1 Mining +//! satisfied a 300-skill vein (decision 1320 named the bug; this module now carries the fix). use std::collections::BTreeSet; @@ -46,6 +51,9 @@ pub(crate) struct GoLockInputs<'w> { pub(crate) locks: Option>, pub(crate) lock_types: Option>, pub(crate) spells: Option>, + /// The skill-line catalog — the opener-value level term's spell→line hop + /// ([`spell_skill_value`]). Absent without client data, which reads as skill 0 (fail-closed). + pub(crate) skill_lines: Option>, pub(crate) items: ResMut<'w, crate::items::Items>, } @@ -140,16 +148,17 @@ pub(crate) const GO_FLAG_LOCKED: u32 = 0x2; /// — so iterating a `HashSet` put Blizzard's placeholder name on the cast bar at the hash's whim /// (B247). `known` is a [`BTreeSet`] for exactly that reason: ascending spell id is the reference /// array's own order after login, the server building `SMSG_INITIAL_SPELLS` out of a `std::map`. +#[allow(clippy::too_many_arguments)] // the reference fn's own inputs, plus the two out-params pub(crate) fn resolve_lock( slots: &[LockSlot], known: &BTreeSet, spells: Option<&crate::ui_action::Spells>, + skill_lines: Option<&benilla_formats::SkillLineCatalog>, me: Option<&ObjectStore>, items: &crate::items::Items, go: GoFacts, matched_spell: &mut Option, ) -> LockOutcome { - let caster_level = me.and_then(|s| s.0.unit_level()).unwrap_or(0); let mut real = false; for slot in slots { match slot.key_type { @@ -169,7 +178,8 @@ pub(crate) fn resolve_lock( // `0x5f84f8` — the out-param is written on the LockType match, before the // value test. matched_spell.get_or_insert(id); - let provides = spell.open_lock_skill(caster_level).unwrap_or(0); + let skill = spell_skill_value(me, skill_lines, id); + let provides = spell.open_lock_skill(skill).unwrap_or(0); if provides >= required_skill(slot, go.level) { return LockOutcome::OpenBySpell(id); } @@ -194,6 +204,45 @@ pub(crate) fn resolve_lock( } } +/// The player's skill value for `spell_id`'s own line — the opener-value level term's source +/// (wow-re `openlock-spell-store-order.md` §4a): `0x5ea690` hops spell → SkillLineAbility line +/// (`0x6de040`, `[+4]`), then `0x5ea520` scans `PLAYER_SKILL_INFO` (`PLAYER_SKILL_INFO_1_1 + +/// 3·slot`) and returns value **plus both bonus halves** (`0x5ea56d`/`0x5ea578`/`0x5ea580`). +/// Every absent leg is fail-closed like the reference's null paths: no catalog, no store, no +/// SLA record, or a line the player does not carry all answer **0** — the opener then provides +/// only its flat terms. +fn spell_skill_value( + me: Option<&ObjectStore>, + skill_lines: Option<&benilla_formats::SkillLineCatalog>, + spell_id: u32, +) -> u32 { + let Some(line) = skill_lines.and_then(|c| c.spell_to_line(spell_id)) else { + return 0; + }; + let Some(store) = me else { return 0 }; + line_skill_value( + (0..benilla_protocol::messages::PLAYER_SKILL_SLOTS) + .filter_map(|slot| store.0.player_skill(slot)), + line, + ) +} + +/// The `0x5ea520` sum on one line's slot: `value + temp_bonus + perm_bonus`, floored at 0 (the +/// bonuses are signed — a curse can push the sum below the base). First matching slot wins; +/// a line absent from the block reads 0. +fn line_skill_value( + slots: impl Iterator, + line: u32, +) -> u32 { + for s in slots { + if u32::from(s.skill_id) == line { + let v = i32::from(s.value) + i32::from(s.temp_bonus) + i32::from(s.perm_bonus); + return v.max(0) as u32; + } + } + 0 +} + /// **`0x5f8260`** — the *targeting cursor's* lock question, and this chain's **third** consumer /// (decision 0949). Reached from the object dispatcher `0x4828d0`'s targeting step /// (`0x482910 → 0x6e6460`, whose GameObject arm calls it at `6e670f` with the GO, the cast-item @@ -369,6 +418,89 @@ mod tests { assert_eq!(required_skill(&skill_slot(1, 280, 1), 60), 280); } + /// **The 1320 bug, fixed** (wow-re `openlock-spell-store-order.md` §4a): the opener's level + /// term is the player's SKILL in the spell's own line, never their character level — so a + /// level-60 with 1 Mining is refused by a 250-skill vein the old caster-level reading handed + /// them. Also pins the two fail-closed legs (`0x623b70`-style: absent data reads skill 0) + /// and the bonus halves of the `0x5ea520` sum. + #[test] + fn the_lock_value_tracks_the_players_skill_not_their_level() { + use benilla_formats::{OpenLock, SkillLineCatalog, SpellCatalog, SpellDisplay}; + use benilla_protocol::messages::{ObjectFields, FIELD_PLAYER_SKILL_INFO_1_1}; + + // Mining 2575, real shape: `−1 + 1 + 5.0·Δ`, baseLevel 0, LockType 3. + let mining = SpellDisplay { + open_lock: Some(OpenLock { + lock_type: 3, + effect: 0, + }), + effect_base_points: [-1, 0, 0], + effect_base_dice: [1, 0, 0], + effect_real_points_per_level: [5.0, 0.0, 0.0], + ..Default::default() + }; + let spells = crate::ui_action::Spells { + catalog: SpellCatalog::from_displays([(2575, mining)].into_iter().collect()), + ..crate::ui_action::Spells::empty_for_tests() + }; + // Spell → line: Mining is SkillLine 186. + let lines = SkillLineCatalog::from_spell_lines([(2575, 186)]); + let known = BTreeSet::from([2575]); + let items = crate::items::Items::default(); + // A 250-skill vein, available (Action 0, READY, not flagged). + let mut vein = [LockSlot::default(); 8]; + vein[0] = skill_slot(3, 250, 0); + let facts = GoFacts { + state: GO_STATE_READY, + flag_locked: false, + level: 0, + }; + // A level-60 store (UNIT_FIELD_LEVEL = 34) whose skill block holds Mining at `value`, + // with the bonus dwords in the third word ([`PlayerSkillSlot`]'s verified packing). + let store_with_mining = |value: u32, bonus_word: u32| { + ObjectStore(ObjectFields::from_pairs(&[ + (34, 60), + (FIELD_PLAYER_SKILL_INFO_1_1, 186), + (FIELD_PLAYER_SKILL_INFO_1_1 + 1, value | (300 << 16)), + (FIELD_PLAYER_SKILL_INFO_1_1 + 2, bonus_word), + ])) + }; + let resolve = |store: Option<&ObjectStore>, lines: Option<&SkillLineCatalog>| { + resolve_lock( + &vein, + &known, + Some(&spells), + lines, + store, + &items, + facts, + &mut None, + ) + }; + + // 300 Mining opens; 1 Mining — the level-60 the old reading waved through — is refused. + let skilled = store_with_mining(300, 0); + assert_eq!( + resolve(Some(&skilled), Some(&lines)), + LockOutcome::OpenBySpell(2575) + ); + let unskilled = store_with_mining(1, 0); + assert_eq!(resolve(Some(&unskilled), Some(&lines)), LockOutcome::Unmet); + + // The `0x5ea520` sum counts both bonus halves: 235 + 10 temp + 5 perm = 250, exactly + // enough. + let buffed = store_with_mining(235, 10 | (5 << 16)); + assert_eq!( + resolve(Some(&buffed), Some(&lines)), + LockOutcome::OpenBySpell(2575) + ); + + // Fail-closed legs: no skill-line catalog, or no store, reads skill 0 — refused, never + // a fall-back to the character level. + assert_eq!(resolve(Some(&skilled), None), LockOutcome::Unmet); + assert_eq!(resolve(None, Some(&lines)), LockOutcome::Unmet); + } + /// A door whose only opener is gated out by its Action must refuse, **not** fall through to /// `CMSG_GAMEOBJ_USE`: the binary marks the lock real (`[ebp-1] = 1`) *before* asking /// `0x5f81d0`. This is the difference between "locked door refuses" and "locked door opens". @@ -385,6 +517,7 @@ mod tests { &BTreeSet::new(), None, None, + None, &items, GoFacts { state: GO_STATE_READY, @@ -447,7 +580,7 @@ mod tests { effect_base_points: [4, 0, 0], effect_base_dice: [1, 0, 0], effect_real_points_per_level: [5.0, 0.0, 0.0], - spell_level: 1, + base_level: 1, ..Default::default() }; let spells = crate::ui_action::Spells { @@ -471,6 +604,7 @@ mod tests { &BTreeSet::from([6247]), Some(&spells), None, + None, &items, locked_shut, &mut matched, @@ -490,6 +624,7 @@ mod tests { &BTreeSet::from([6247]), Some(&spells), None, + None, &items, GoFacts { flag_locked: false, @@ -500,9 +635,11 @@ mod tests { LockOutcome::OpenBySpell(6247), ); - // Pick Lock sits on an Action 1 slot, so the flag *selects* it — but with no self store - // the level reads 0, Pick Lock provides 5, and 5 < 280 refuses. The out-param is still - // written, which is §8.8's `0xdf`-vs-`0xe0` discriminator. + // Pick Lock sits on an Action 1 slot, so the flag *selects* it — but with no store and no + // skill-line catalog the skill reads 0 (the fail-closed leg), Pick Lock provides its flat + // 5, and 5 < 280 refuses. The out-param is still written, which is §8.8's + // `0xdf`-vs-`0xe0` discriminator. (The satisfied-by-skill path is + // `the_lock_value_tracks_the_players_skill_not_their_level` below.) let mut matched = None; assert_eq!( resolve_lock( @@ -510,6 +647,7 @@ mod tests { &BTreeSet::from([1804]), Some(&spells), None, + None, &items, locked_shut, &mut matched, @@ -538,6 +676,7 @@ mod tests { &BTreeSet::from([6247]), Some(&spells), None, + None, &items, locked_shut, &mut None, @@ -569,6 +708,7 @@ mod tests { &BTreeSet::from([2575]), Some(&with_mining), None, + None, &items, GoFacts { state: GO_STATE_READY, @@ -633,6 +773,7 @@ mod tests { &BTreeSet::from([6478, 22810]), Some(&spells), None, + None, &items, unlocked, &mut matched, @@ -650,6 +791,7 @@ mod tests { &BTreeSet::from([22810, 6478]), Some(&spells), None, + None, &items, unlocked, &mut matched, @@ -670,6 +812,7 @@ mod tests { &BTreeSet::new(), None, None, + None, &items, GoFacts { state: GO_STATE_ACTIVE, diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index c29d2b5a..f1ded6d8 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -153,9 +153,9 @@ pub(crate) struct Spells { impl Spells { /// The resolved cast time, ms — `GetCastTime 0x6e3340`'s level-scaled walk (wow-re /// `wave-cooldown.md`/`moving-cast-gate.md`, byte-verified): `CastingTimeIndex` resolves the - /// [`Self::cast_times`] row, `base + perLevel·(casterLevel − spellLevel)` floors to the + /// [`Self::cast_times`] row, `base + perLevel·(casterLevel − baseLevel)` floors to the /// row's minimum (row 1, the all-zero instant sentinel, resolves 0). The level term keys on - /// the `SpellRec+0x70` column ([`SpellDisplay::spell_level`]); spellmod op `0xa` + /// the `SpellRec+0x70` column ([`SpellDisplay::base_level`]); spellmod op `0xa` /// (SPELLMOD_CASTING_TIME) is unmodeled — benilla has no spellmod system — a named /// micro-divergence (a talent-shortened 0-second cast doesn't exist in the 1.12 data). /// A missing row reads 0 (instant), like a failed catalog load everywhere else. @@ -166,7 +166,7 @@ impl Spells { ) -> u32 { self.cast_times .get(def.casting_time_index) - .map_or(0, |row| row.resolved_ms(caster_level, def.spell_level)) + .map_or(0, |row| row.resolved_ms(caster_level, def.base_level)) } } diff --git a/crates/benilla-app/src/ui_action/usable.rs b/crates/benilla-app/src/ui_action/usable.rs index 2baad3c9..616cf428 100644 --- a/crates/benilla-app/src/ui_action/usable.rs +++ b/crates/benilla-app/src/ui_action/usable.rs @@ -220,7 +220,10 @@ pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore) -> u32 { } else { store.0.unit_max_power(power_type as u8).unwrap_or(0) }; - let level_delta = i64::from(store.0.unit_level().unwrap_or(0)) - i64::from(d.spell_level); + // The per-level column here has ALWAYS read `+0x70` (baseLevel); whether `0x6e31b0`'s own + // level term reads `+0x70` or `+0x74` is un-re-verified — dormant either way, since every + // nonzero `manaCostPerlevel` row is a creature spell (the fn doc above). + let level_delta = i64::from(store.0.unit_level().unwrap_or(0)) - i64::from(d.base_level); let cost = i64::from(d.mana_cost) + level_delta * i64::from(d.mana_cost_per_level) + i64::from(base) * i64::from(d.mana_cost_pct) / 100; diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 43097b23..59b27d10 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -255,6 +255,9 @@ fn feed_craft( reagents, tools, tooltip: craft_tooltip(s, d), + // The engine field's law names `+0x74` (real spellLevel, col 29); until the + // base_level rename this was silently fed col 28. Identical on every ranked + // trainer family observed, but the wiring now matches the recorded law. spell_level: d.spell_level, }) }) diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index cb4b9eb0..af43c027 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -184,7 +184,25 @@ fn spell_tooltip_view( }); // The required-form line (law §3.6): the Stances mask's form names off // SpellShapeshiftForm.dbc, joined; bit b = form id b+1. Met against the CURRENT form. - let requires_form = (d.stances != 0) + // + // `Stances != 0` is NOT the gate on its own — the column is overloaded. When `AttributesEx2` + // bit 19 is set the mask is *permissive* ("may ALSO be cast in these forms"), which is how + // 5875 encodes Shadowform-castable priest spells, Spirit-of-Redemption-castable heals and + // Moonkin-castable balance spells; reading those as requirements put a red "Requires + // Shadowform" on Inner Fire and "Requires Spirit of Redemption" on Flash Heal (1483). + // + // Now byte-carved as wow-re §3-REQFORM, `[0x52f10a, 0x52f2ae)`: `52f115` reads AttributesEx2, + // ands `0x80000`, and jumps *over the whole name loop* — the loop is the only writer of the + // "nothing appended" flag `[ebp-8]`, so the AddLine is skipped entirely. The line is + // **suppressed, not recoloured**. Three earned negatives came with it: StancesNot `+0x30` is + // never read here, `Attributes` bit 16 is never tested, and `0x612480` is never called — the + // builder duplicates the gate inline rather than sharing the usable walk's helper, which is + // why the two could drift apart in the first place. + // + // Two ref behaviours we knowingly don't model, both unreachable for us: the composed text is + // capped at 0x80 bytes (our longest join is "Battle Stance, Berserker Stance"), and a *met* + // requirement is dropped in the COMPACT tooltip — benilla has no compact spell tooltip. + let requires_form = (d.stances != 0 && !d.form_mask_is_permissive()) .then(|| { let names: Vec<&str> = (0..32u32) .filter(|b| d.stances & (1 << b) != 0) @@ -733,6 +751,7 @@ fn drive_mouseover_tooltip( slots, &player_actions.spells, go_inputs.spells.as_deref(), + go_inputs.skill_lines.as_ref().map(|s| &s.catalog), self_store, &go_inputs.items, facts, diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 702a546b..c4f3beb8 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -116,6 +116,27 @@ fn fireball_view_on_real_data() { ); let v = spell_tooltip_view(100, &spells, &mut t.ctx(17, None)).expect("Charge view"); assert!(v.form_met, "form 17 = Battle Stance satisfies the mask"); + + // 1483 — a PERMISSIVE Stances mask prints no line at all. 5875 overloads the column: with + // `AttributesEx2` bit 19 set the bits say "may ALSO be cast in these forms", so reading them + // as a requirement invented a red "Requires Shadowform" on Inner Fire / Psychic Scream and + // "Requires Spirit of Redemption" on Flash Heal. Bit 27 = form 28, bit 31 = form 32 — the + // names resolve, which is exactly why the bug looked plausible. + for (id, name, mask) in [ + (588u32, "Inner Fire", 0x0800_0000u32), + (8122, "Psychic Scream", 0x0800_0000), + (2061, "Flash Heal", 0x8000_0000), + (5176, "Wrath", 0x4000_0000), + ] { + let d = spells.catalog.get(id).expect(name); + assert_eq!(d.stances, mask, "{name} carries the permissive mask"); + assert!( + d.form_mask_is_permissive(), + "{name} carries AttributesEx2 b19" + ); + let v = spell_tooltip_view(id, &spells, &mut t.ctx(0, None)).expect(name); + assert_eq!(v.requires_form, None, "{name} demands no form"); + } } /// The cost and cast cells' full law on the REAL 5875 data (decision 1074, B192): the health diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index abcf93b0..d845e804 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -1138,16 +1138,19 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { out_rgb = out_rgb * faded_alpha; } // MULTIPLY batches (Mod bit 7 / Mod2x bit 8 — the ARMORREFLECT sheen family, 0528): their - // blend equation reads no source alpha, so the instance fade cannot ride the alpha channel — - // the reference genuinely pops these at full strength through every instanceAlpha ramp - // (byte-confirmed for items: wow-re `m2-item-texture-fill.md` — the type-3 stage is never - // bound and draws the flat primary colour, ~white, so Mod2x is a full-strength ×2 layer - // mid-fade). benilla fades them anyway, a deliberate deviation (decision 0865): lerp the - // source colour toward the blend IDENTITY — Mod: white (src·dst = dst); Mod2x: 0.5 grey - // (2·0.5·dst = dst) — by the instance fade `obj_fade` (never the texture alpha). At - // obj_fade 1 this is exact parity with the steady look; at 0 the layer contributes nothing, - // so the sheen rides the appear/despawn/stealth/distance ramps like every other batch - // instead of popping over a body that hasn't faded in. + // blend equation reads no source alpha, so the instance fade cannot ride the alpha channel. + // It rides the SOURCE COLOUR instead, and that is the reference's own mechanism, not a + // deviation: texenv preset 5 (`INTERPOLATE, TEXTURE·PREVIOUS·PREVIOUS`) computes + // `mix(prev.rgb, tex.rgb, prev.a)`, the mode-5/6 arms force the primary colour to the blend + // IDENTITY — V_A=0, V_B = white (Mod: src·dst = dst) / 0.5 grey (Mod2x: 2·0.5·dst = dst), + // discarding tint AND the M2Color track — and prev.a is the combined instance alpha, so a + // fading Mod batch converges continuously onto "framebuffer unchanged", the same endpoint + // as the A<=0 cull (wow-re `m2-mod-fade-source-colour.md`, byte-verified; decision 1489, + // re-lawing 0865's identical mechanism from deliberate deviation to byte-faithful; 0528's + // "holds full strength and pops" and its non-white-M2Color residual both fall with it). + // The lerp factor is `obj_fade` (never the texture alpha) and it commutes with the White/ + // Grey fog above exactly because the fog target IS the identity colour. At obj_fade 1 the + // mix degenerates to the texture colour — the steady look — for any identity value. let is_mod = (u32(m.clutter_fade.z) & 128u) != 0u; let is_mod2x = (u32(m.clutter_fade.z) & 256u) != 0u; if (is_mod || is_mod2x) { diff --git a/crates/benilla-formats/src/languages.rs b/crates/benilla-formats/src/languages.rs index 1e3a45a4..5d17c87d 100644 --- a/crates/benilla-formats/src/languages.rs +++ b/crates/benilla-formats/src/languages.rs @@ -35,6 +35,7 @@ use crate::Chain; const LANGUAGES: &str = "DBFilesClient\\Languages.dbc"; const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; +const LANGUAGE_WORDS: &str = "DBFilesClient\\LanguageWords.dbc"; /// How many locale columns a `Name_lang` block carries in 5875 (`Languages.dbc` is /// `ID + 8 names + NameFlags` = 10 fields). @@ -117,6 +118,127 @@ pub fn load_default_languages(chain: &mut Chain) -> Result { Ok(DefaultLanguages(out)) } +/// `LanguageWords.dbc` — the fake-word pool the client substitutes, word for word, when it renders +/// speech in a language the listener's character does not know. +/// +/// **The wire carries plaintext.** `SMSG_MESSAGECHAT` ships the real sentence plus a language id +/// and the server never rewrites it (vmangos `ChatHandler.cpp`); turning it into gibberish is +/// entirely the client's job, which is why an unmodelled garble step renders opposite-faction +/// speech perfectly readable (B262). The reference's garble routine is `0x49b560`, called from the +/// chat display chokepoint `0x49a870` at `0x49aa7c` — the same function whose `cmp edi,-0x1` at +/// `0x49a89b` is the `LANG_ADDON` test. +/// +/// **The shipped table**, read off this install's chain: **1481 rows x 3 fields, 12-byte records** +/// — `ID`, `LanguageID`, `Word` — over **13 languages** (ids 1, 2, 3, 6-14, 33: the same id set +/// `Languages.dbc` carries, minus none and plus none). Each language's pool is 79-128 words, and +/// the words are authored in **runs of ascending length**, one to seventeen characters: Orcish +/// opens `A N G O L` (one letter), then `Ha Ko No Mu Ag Ka Gi` (two), and so on. That authored +/// shape is what makes a length index the natural read of the pool — a substitution that ignored +/// length would have no use for a table built this way. +#[derive(Debug, Default, Clone)] +pub struct LanguageWords(HashMap); + +/// One language's substitution pool: the words in file order, plus an index from word length to +/// the rows of exactly that length. +/// +/// The index is **shape, not policy**. Storing which words are one character long commits us to +/// nothing about *how* the reference picks among them (exact-length match, a clamp at the longest +/// authored word, buckets); every one of those rules reads this index. The rule itself is +/// `0x49b560`'s and is filled in from the RE verdict, not guessed here. +#[derive(Debug, Default, Clone)] +pub struct LanguagePool { + words: Vec, + /// `by_len[n]` = indices into `words` of every word whose length is exactly `n`; slot 0 is + /// always empty (the table has no zero-length word) and the vector runs to the longest word + /// this language authored. + by_len: Vec>, +} + +impl LanguagePool { + /// Every word in the pool, in file order. + pub fn words(&self) -> &[String] { + &self.words + } + + /// The words of exactly `len` characters, in file order — empty when the language authored + /// none that long (and for `len` past its longest word). + pub fn of_len(&self, len: usize) -> impl Iterator { + self.by_len + .get(len) + .map(Vec::as_slice) + .unwrap_or(&[]) + .iter() + .map(|&i| self.words[i as usize].as_str()) + } + + /// How many words of exactly `len` characters this language authored. + pub fn count_of_len(&self, len: usize) -> usize { + self.by_len.get(len).map_or(0, Vec::len) + } + + /// The longest word this language authored, in characters (0 for an empty pool). + pub fn max_len(&self) -> usize { + self.by_len.len().saturating_sub(1) + } +} + +impl LanguageWords { + /// The substitution pool for a `Languages.dbc` id, or `None` for an id the table has no rows + /// for — which includes `0` (universal) and `-1` (`LANG_ADDON`), neither of which is ever + /// garbled. + pub fn pool(&self, language: u32) -> Option<&LanguagePool> { + self.0.get(&language) + } + + /// How many languages have a pool. + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// `LanguageWords.dbc` — `ID`, `LanguageID`, `Word`. +fn language_words_schema() -> Schema { + let mut s = Schema::new("LanguageWords"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("LanguageID", FieldType::UInt32)); + s.add_field(SchemaField::new("Word", FieldType::String)); + s +} + +/// Load the per-language substitution pools. +/// +/// Word length is counted in **`char`s, not bytes**: every word in the shipped enUS table is +/// plain ASCII (a hyphen is the only non-letter), so the two agree today — but the count that +/// matters is the one a length rule compares against a *source* word, and a source word is +/// arbitrary player text. +pub fn load_language_words(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(LANGUAGE_WORDS) + .with_context(|| format!("reading {LANGUAGE_WORDS}"))?; + let table = parse(&bytes, language_words_schema(), "LanguageWords.dbc")?; + let mut out: HashMap = HashMap::new(); + for r in table.records() { + let (Some(lang), Some(word)) = (u32_at(r, 1), str_at(&table, r, 2)) else { + continue; + }; + if word.is_empty() { + continue; + } + let pool = out.entry(lang).or_default(); + let len = word.chars().count(); + let idx = u32::try_from(pool.words.len()).unwrap_or(u32::MAX); + pool.words.push(word); + if pool.by_len.len() <= len { + pool.by_len.resize(len + 1, Vec::new()); + } + pool.by_len[len].push(idx); + } + Ok(LanguageWords(out)) +} + #[cfg(test)] mod tests { use super::*; @@ -145,4 +267,50 @@ mod tests { // Only enUS is populated in this install; a higher locale column is empty, not a panic. assert_eq!(langs.name(1, 5), None); } + + /// The garble pool, against the real chain for the same reason as above — the shape of the + /// shipped table *is* the finding, and a fixture would only test the parser. + #[test] + fn the_shipped_word_pools_cover_every_language_and_index_by_length() { + let data = crate::wow_data_or_skip!(); + let mut chain = Chain::open(&data).expect("open patch chain"); + let words = load_language_words(&mut chain).expect("load"); + + // One pool per `Languages.dbc` row — the id sets match exactly, so no spoken language can + // reach the garble step and find nothing to substitute. + assert_eq!(words.len(), 13, "one pool per language"); + let langs = load_default_languages(&mut chain).expect("load languages"); + let _ = &langs; // the join above already asserts that table; here only the count matters. + for id in [1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] { + assert!(words.pool(id).is_some(), "language {id} has no word pool"); + } + // 0 (universal) and the addon sentinel are absent by construction, never garbled. + assert!(words.pool(0).is_none()); + + // Orcish (1), the shipped numbers: 100 words, the shortest one character, the longest + // thirteen, and the one-character run is the five vowels-and-consonants `A N G O L`. + let orcish = words.pool(1).expect("orcish pool"); + assert_eq!(orcish.words().len(), 100); + assert_eq!(orcish.max_len(), 13); + assert_eq!(orcish.count_of_len(1), 5); + assert_eq!( + orcish.of_len(1).collect::>(), + ["A", "N", "G", "O", "L"] + ); + // Nothing is authored at length zero, and asking past the longest word is empty, not a + // panic — the two edges any length rule will lean on. + assert_eq!(orcish.count_of_len(0), 0); + assert_eq!(orcish.count_of_len(99), 0); + + // Every pool is non-trivial and its length index accounts for every word it holds. + let mut total = 0; + for id in [1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] { + let p = words.pool(id).expect("pool"); + assert!(p.words().len() >= 79, "language {id} pool is thin"); + let indexed: usize = (0..=p.max_len()).map(|n| p.count_of_len(n)).sum(); + assert_eq!(indexed, p.words().len(), "language {id} length index"); + total += p.words().len(); + } + assert_eq!(total, 1481, "every LanguageWords row landed in a pool"); + } } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 43afa53b..dc823ff4 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -52,7 +52,9 @@ pub use factions::{ mod creature_types; pub use creature_types::{load_creature_type_flags, CreatureTypeFlags}; mod languages; -pub use languages::{load_default_languages, DefaultLanguages}; +pub use languages::{ + load_default_languages, load_language_words, DefaultLanguages, LanguagePool, LanguageWords, +}; mod creature_families; pub use creature_families::{ load_creature_families, load_pet_food_names, CreatureFamilies, CreatureFamily, PetFoodNames, diff --git a/crates/benilla-formats/src/skill_lines/mod.rs b/crates/benilla-formats/src/skill_lines/mod.rs index 113b915a..4fc76ed3 100644 --- a/crates/benilla-formats/src/skill_lines/mod.rs +++ b/crates/benilla-formats/src/skill_lines/mod.rs @@ -289,6 +289,34 @@ pub struct SkillLineCatalog { } impl SkillLineCatalog { + /// Build a catalog holding only a spell→line map — for tests and synthetic fixtures + /// ([`crate::SpellCatalog::from_displays`]'s sibling). The live path is + /// [`load_skill_line_catalog`]; everything but [`Self::spell_to_line`]/[`Self::ability`] + /// answers empty. + pub fn from_spell_lines(pairs: impl IntoIterator) -> Self { + Self { + abilities: pairs + .into_iter() + .map(|(spell, line)| { + ( + spell, + SlaInfo { + skill_id: line, + req_skill_value: 0, + forward_spell_id: 0, + trivial_low: 0, + trivial_high: 0, + }, + ) + }) + .collect(), + lines: HashMap::new(), + categories: HashMap::new(), + race_class: HashMap::new(), + rank_prev: HashMap::new(), + } + } + /// The skill line a spell belongs to, if `SkillLineAbility.dbc` names one. pub fn spell_to_line(&self, spell_id: u32) -> Option { self.abilities.get(&spell_id).map(|a| a.skill_id) diff --git a/crates/benilla-formats/src/spells/cast_times.rs b/crates/benilla-formats/src/spells/cast_times.rs index 8237053a..7b0dca16 100644 --- a/crates/benilla-formats/src/spells/cast_times.rs +++ b/crates/benilla-formats/src/spells/cast_times.rs @@ -39,12 +39,13 @@ pub struct SpellCastTime { impl SpellCastTime { /// The level-scaled cast time, ms — `Spell_C::GetCastTime 0x6e3340`'s walk over this row - /// (module docs): `base + perLevel·(casterLevel − spellLevel)`, floored to the row minimum - /// and to zero. `spell_level` is the `SpellRec+0x70` column the client scales against - /// ([`crate::spells::SpellDisplay::spell_level`]). Spellmod op `0xa` (SPELLMOD_CASTING_TIME) - /// is the caller's concern — no downstream consumer models spellmods yet. - pub fn resolved_ms(&self, caster_level: u32, spell_level: u32) -> u32 { - let delta = i64::from(caster_level.saturating_sub(spell_level)); + /// (module docs): `base + perLevel·(casterLevel − baseLevel)`, floored to the row minimum + /// and to zero. `base_level` is the `SpellRec+0x70` column the client scales against + /// ([`crate::spells::SpellDisplay::base_level`] — the DBC's `baseLevel`, col 28, not its + /// `spellLevel`, col 29). Spellmod op `0xa` (SPELLMOD_CASTING_TIME) is the caller's + /// concern — no downstream consumer models spellmods yet. + pub fn resolved_ms(&self, caster_level: u32, base_level: u32) -> u32 { + let delta = i64::from(caster_level.saturating_sub(base_level)); let scaled = i64::from(self.base_ms) + i64::from(self.per_level_ms) * delta; scaled.max(i64::from(self.minimum_ms)).max(0) as u32 } diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 1e518ce5..df5d44b6 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -341,41 +341,51 @@ fn real_spell_catalog_pins_the_skin_latch_effects() { } /// The **skill an opener provides** on the real Spell.dbc — the left-hand side of the client's lock -/// satisfaction test (`0x5f850f`; decision 0752). This walk decides whether a right-click opens a -/// lock at all, so its inputs (spellLevel 28 · EffectDieSides 64 · EffectBaseDice 67 · +/// satisfaction test (`0x5f850f`; decision 0752, level term corrected by wow-re +/// `openlock-spell-store-order.md` §4a). This walk decides whether a right-click opens a lock at +/// all, so its inputs (maxLevel 27 · baseLevel 28 · EffectDieSides 64 · EffectBaseDice 67 · /// EffectDicePerLevel 70 · EffectRealPointsPerLevel 73 · EffectBasePoints 76) are pinned by -/// *result*, against anchors whose right answers are known from the game rather than the file. -/// Skips without client data. +/// *result*, against anchors whose right answers are known from the game rather than the file — +/// the below-cap rows are §4a's own discriminating table (the values the refuted caster-level +/// reading could not produce). Skips without client data. #[test] fn real_spell_catalog_computes_the_lock_skill_an_opener_provides() { 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("load Spell/SpellIcon"); - // Pick Lock (1804): `4 + 1 + 5.0×(level − 1)` — a rogue's Lockpicking cap, 5×level, exactly. - assert_eq!(cat.get(1804).unwrap().open_lock_skill(60), Some(300)); - assert_eq!(cat.get(1804).unwrap().open_lock_skill(45), Some(225)); - // Mining (2575) / Herb Gathering (2366): `−1 + 1 + 5.0×level` — the same profession cap, but - // quoted at spellLevel 0, so they do not lose the first level the way Pick Lock does. - assert_eq!(cat.get(2575).unwrap().open_lock_skill(60), Some(300)); - assert_eq!(cat.get(2366).unwrap().open_lock_skill(60), Some(300)); + // Pick Lock (1804): `4 + 1 + 5.0×(skill/5 − 1)` — the skill itself, exactly: a capped rogue + // provides 300, and a 150-skill rogue provides 150 (§4a's discriminator; the old reading + // said 300 for both at level 60). + assert_eq!(cat.get(1804).unwrap().open_lock_skill(300), Some(300)); + assert_eq!(cat.get(1804).unwrap().open_lock_skill(225), Some(225)); + assert_eq!(cat.get(1804).unwrap().open_lock_skill(150), Some(150)); + // Mining (2575) / Herb Gathering (2366): `−1 + 1 + 5.0×(skill/5)` — the skill, quoted at + // baseLevel 0, so they do not lose the first rung the way Pick Lock does. 1 Mining provides + // 0 — the level-60-with-1-Mining bug (decision 1320) is the 300 the old reading put here. + assert_eq!(cat.get(2575).unwrap().open_lock_skill(300), Some(300)); + assert_eq!(cat.get(2575).unwrap().open_lock_skill(100), Some(100)); + assert_eq!(cat.get(2575).unwrap().open_lock_skill(1), Some(0)); + assert_eq!(cat.get(2366).unwrap().open_lock_skill(300), Some(300)); // Small / Large Seaforium Charge (4056 / 4075): flat `149 + 1` = 150 and `249 + 1` = 250 — and // lock 92 asks for `Blasting 150`. The charge exists to open exactly that door, so the equality - // is the cross-check: a column slip anywhere in the walk breaks it. - assert_eq!(cat.get(4056).unwrap().open_lock_skill(1), Some(150)); - assert_eq!(cat.get(4075).unwrap().open_lock_skill(1), Some(250)); - // The universally-known "Opening"/"Closing" family is flat 100 at every level — which is why + // is the cross-check: a column slip anywhere in the walk breaks it. Skill-independent — the + // caster's Engineering changes nothing. + assert_eq!(cat.get(4056).unwrap().open_lock_skill(0), Some(150)); + assert_eq!(cat.get(4056).unwrap().open_lock_skill(300), Some(150)); + assert_eq!(cat.get(4075).unwrap().open_lock_skill(0), Some(250)); + // The universally-known "Opening"/"Closing" family is flat 100 at every skill — which is why // the Action gate, not the value test, is what keeps them off a padlocked door (decision 0752). for id in [3365, 6233, 6246, 6247, 6477, 6478, 21651, 21652] { assert_eq!( - cat.get(id).unwrap().open_lock_skill(1), + cat.get(id).unwrap().open_lock_skill(0), Some(100), "spell {id} is a flat-100 opener" ); - assert_eq!(cat.get(id).unwrap().open_lock_skill(60), Some(100)); + assert_eq!(cat.get(id).unwrap().open_lock_skill(300), Some(100)); } // A spell with no OPEN_LOCK effect provides nothing. - assert_eq!(cat.get(133).unwrap().open_lock_skill(60), None); + assert_eq!(cat.get(133).unwrap().open_lock_skill(300), None); } /// The cooldown/cost/range columns on the real build-5875 `Spell.dbc`, pinned 2026-07-10 diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 8b220715..96e9e9fc 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -69,8 +69,17 @@ pub struct SpellDisplay { /// spells: "Opening" for keyless chests, "Mining"/"Herb Gathering"/"Pick Lock" for skill locks. /// The skill it *provides* is [`Self::open_lock_skill`]. pub open_lock: Option, - /// `spellLevel` (column 28, `SpellRec+0x70`) — the level this spell's effect values are quoted - /// at; the effect-value walk scales by `casterLevel − spellLevel`, floored at 0 (`0x6e3854`). + /// `baseLevel` (column 28, `SpellRec+0x70`; **not** `spellLevel`, column 29 `+0x74` — wow-re + /// `openlock-spell-store-order.md` §4a pinned the split) — the level the effect values are + /// quoted at: the effect-value walk subtracts it, floored at 0 (`0x6e3826`), and the + /// cast-time scaling reads the same column (`0x6e3340`). + pub base_level: u32, + /// `maxLevel` (column 27, `SpellRec+0x6c`) — caps the skill-derived level term in + /// [`Self::open_lock_skill`] at `maxLevel × 5` (`0x5ea6e3`); `0` = uncapped. + pub max_level: u32, + /// `spellLevel` (column 29, `SpellRec+0x74`) — the DBC's actual spellLevel. One consumer: + /// the Beast Training rank comparator, whose recorded law names `+0x74` (`benilla-ui` + /// `craft.rs`). Everything level-scaled here reads [`Self::base_level`] instead. pub spell_level: u32, /// `Dispel` (column 4, `SpellRec+0x10`) — the `SpellDispelType.dbc` id. The reference client /// reads exactly this field for `GetPlayerBuffDispelType` / `UnitDebuff`'s third return @@ -266,6 +275,8 @@ impl Default for SpellDisplay { cast_ui: 0, effects: [0, 0, 0], open_lock: None, + base_level: 0, + max_level: 0, spell_level: 0, dispel: 0, category: 0, @@ -332,20 +343,39 @@ impl SpellDisplay { self.open_lock.map(|o| o.lock_type) } - /// The **skill value this spell provides** to a lock at `caster_level` — the client's - /// effect-value *min* (`0x6e3800`) put through its caller's rounding (`0x6e3760`: - /// `round(2x ∓ 0.5) >> 1`), and the left-hand side of the lock resolver's satisfaction test - /// (`0x5f850f`: `cmp eax,esi; jge`). `None` when the spell opens no lock. + /// The **skill value this spell provides** to a lock, given the player's skill in the + /// spell's own line — the client's effect-value *min* (`0x6e3800`) put through its caller's + /// rounding (`0x6e3760`: `round(2x ∓ 0.5) >> 1`), and the left-hand side of the lock + /// resolver's satisfaction test (`0x5f850f`: `cmp eax,esi; jge`). `None` when the spell + /// opens no lock. /// - /// The client compares **this**, never the player's skill block — and that is faithful, - /// because the DBC encodes the same number: Pick Lock 1804 at 60 → `4 + 1 + 5.0·(60−1)` = - /// **300**, a rogue's Lockpicking cap; Mining 2575 at 60 → `−1 + 1 + 5.0·60` = **300**; and - /// Small Seaforium Charge 4056 → `149 + 1` = **150**, exactly the `Blasting 150` its lock - /// (id 92) asks for. The universally-known "Opening" spells are flat 100. - pub fn open_lock_skill(&self, caster_level: u32) -> Option { + /// **The level term IS the player's skill** (wow-re `openlock-spell-store-order.md` §4a, + /// byte-verified 2026-08-14, overturning `cursor-system.md` §8.8's "no player skill block is + /// read anywhere in the chain" — and this doc's own earlier "never the player's skill block" + /// gloss with it): `0x6e3800`'s level call (`0x6e384d → 0x6e3130`) resolves the CGPlayer + /// vtable slot `+0xa8` = `0x5ea690`, which reads `PLAYER_SKILL_INFO` for the spell's + /// SkillLineAbility line — value **plus** bonuses (`0x5ea56d`/`0x5ea578`/`0x5ea580`) — + /// clamps it to `maxLevel × 5` (`0x5ea6e3`), and returns it `/5` (`0x6e3195`). So: + /// `Δ = max(0, min(skill, maxLevel·5)/5 − baseLevel)`. Pick Lock 1804 at skill 300 → `4 + + /// 1 + 5.0·(60−1)` = **300**; at skill 150 → **150**. Mining 2575 → exactly the skill. The + /// flat spells are unchanged: Small Seaforium Charge 4056 → `149 + 1` = **150**, the + /// "Opening" family flat 100. + /// + /// The old reading fed the **caster level** here — indistinguishable at skill cap (where + /// `skill/5 == level`, which is how it survived every at-cap cross-check) and wrong + /// everywhere else: a level-60 with 1 Mining satisfied a 300-skill vein. `skill_value = 0` + /// (line unknown, or the player lacks it) is the fail-closed leg — the opener provides only + /// its flat terms. + pub fn open_lock_skill(&self, skill_value: u32) -> Option { let e = self.open_lock?.effect; - // `0x6e3826`: level delta, floored at 0, and only subtracted when spellLevel > 0. - let delta = caster_level.saturating_sub(self.spell_level) as f32; + // `0x5ea6e3`: skill capped at maxLevel·5 (0 = uncapped); `0x6e3195`: /5 (integer); + // `0x6e3826`: baseLevel subtraction, floored at 0. + let capped = if self.max_level > 0 { + skill_value.min(self.max_level * 5) + } else { + skill_value + }; + let delta = (capped / 5).saturating_sub(self.base_level) as f32; let v = self.effect_base_points[e] as f32 + self.effect_base_dice[e] as f32 + self.effect_dice_per_level[e] as f32 * delta @@ -602,6 +632,25 @@ impl SpellDisplay { .any(|a| TRACKING_AURA_TYPES.contains(a)) } + /// The spell's `Stances` mask is an **allow-list, not a requirement** — `AttributesEx2` + /// bit 19. Two independent consumers test exactly this bit: the form gate `0x612480` (which + /// [`Self::form_refusal`] transcribes) and, separately, the spell tooltip's required-form + /// line at `52f115` (wow-re §3-REQFORM) — the builder duplicates the test inline rather than + /// calling the gate, so one predicate here keeps our two callers from drifting the way they + /// did in 1483. + /// + /// 5875 leans on this hard, and reading `Stances != 0` as "requires a form" without it is + /// simply wrong: every Shadowform-castable priest spell carries `Stances` bit 27 (Inner Fire, + /// Psychic Scream, Shadow Word: Pain, Power Word: Shield…), every Spirit-of-Redemption-castable + /// heal carries bit 31 (Flash Heal, Renew, Prayer of Healing), and the 52 balance-druid spells + /// castable in Moonkin carry bit 30 (Wrath, Moonfire, Starfire, Thorns…) — the mask records + /// "may ALSO be cast in that form". All 244 of them carry bit 19; **none** of the 274 spells + /// that genuinely require a form (the warrior stances, the druid forms, the stealth openers) + /// does. So this is the clean separator between the two readings of one column. + pub fn form_mask_is_permissive(&self) -> bool { + self.attributes_ex2 & ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED != 0 + } + /// The shapeshift-form gate as a boolean — the usable walk's leg 6. See /// [`Self::form_refusal`], the reason-carrying transcription this wraps. pub fn usable_in_form(&self, form: u8, form_is_stance: bool) -> bool { @@ -637,9 +686,7 @@ impl SpellDisplay { } else { None } - } else if self.stances != 0 - && self.attributes_ex2 & ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED == 0 - { + } else if self.stances != 0 && !self.form_mask_is_permissive() { // Unshifted (or a stance): a form-requiring spell is refused here unless waived. Some(FormRefusal::OnlyShapeshift) } else { diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index e24a27b4..1381cea1 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -278,10 +278,23 @@ pub const SPELL_EFFECT_SKILL_STEP: u32 = 44; /// interact-cast matches (decision 0239; RE `cursor-system.md` §8, the client's /// `cmp [SpellRec+0xf4], 0x21`). A spell carrying it opens the `LockType` its `EffectMiscValue` names. const SPELL_EFFECT_OPEN_LOCK: u32 = 0x21; -/// `spellLevel` — column 28 (`SpellRec+0x70`, the level the effect-value walk subtracts at -/// `0x6e3854`). Pinned by value on the extracted 5875 file: Pick Lock 1804 and Fireball rank 1 -/// read `1`, the professions' openers `0`. -const COL_SPELL_LEVEL: usize = 28; +/// `baseLevel` — column 28 (`SpellRec+0x70`), the level term the effect-value walk subtracts at +/// `0x6e3826`/`0x6e3854` and the cast-time scaling's base (`0x6e3340`). **Not** the DBC's +/// `spellLevel` (column 29, `+0x74`) — nothing here reads that one; the field carried that name +/// for a while (wow-re `openlock-spell-store-order.md` §4a pinned the split at the bytes). +/// Pinned by value on the extracted 5875 file: Pick Lock 1804 and Fireball rank 1 read `1`, the +/// professions' openers `0`. +const COL_BASE_LEVEL: usize = 28; +/// `maxLevel` — column 27 (`SpellRec+0x6c`), the cap on the skill-derived level term in an +/// opener's value: the player's skill is clamped to `maxLevel × 5` at `0x5ea6e3` before the +/// `/5` (`0x6e3195`); `0` = uncapped (the professions' openers read 0 here, and their §4a table +/// rows would compute 0 under an unconditional clamp). Same wow-re record. +const COL_MAX_LEVEL: usize = 27; +/// `spellLevel` — column 29 (`SpellRec+0x74`), the DBC's actual spellLevel. Parsed for the ONE +/// consumer whose recorded law names `+0x74` (the Beast Training rank comparator, +/// `benilla-ui` `craft.rs`); before [`COL_BASE_LEVEL`]'s rename that consumer was silently fed +/// column 28. +const COL_SPELL_LEVEL: usize = 29; /// `EffectApplyAuraName[0]` (column 91, `61 + 10×3` — tenth of the effect-`[3]` blocks; pinned on /// the extracted 5875 file: every form spell — Battle Stance 2457, Bear 5487, Cat 768, Stealth /// 1784, Moonkin 24858 — carries `36` here, and columns 94-96 don't). The stance bar's @@ -663,6 +676,8 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { passive: attributes & ATTR_PASSIVE != 0, cast_ui: u32_at(r, COL_CAST_UI).unwrap_or(0), effects: [0, 1, 2].map(|i| u32_at(r, COL_EFFECT_1 + i).unwrap_or(0)), + base_level: u32_at(r, COL_BASE_LEVEL).unwrap_or(0), + max_level: u32_at(r, COL_MAX_LEVEL).unwrap_or(0), spell_level: u32_at(r, COL_SPELL_LEVEL).unwrap_or(0), // Scan the three effects for OPEN_LOCK and take that effect's LockType (its // EffectMiscValue) together with the value inputs the lock resolver compares diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index cd48e1f8..6738f192 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -198,8 +198,8 @@ pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_sta pub use update_object::{ quest_slot_state, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, - PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, - UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, + FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, + PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, }; pub use vendor::{ buy_item, buy_result, buyback_item, list_inventory, repair_item, sell_item, sell_result, diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 8129e39d..29c978fb 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -419,8 +419,10 @@ const FIELD_PLAYER_MOD_DAMAGE_DONE_PCT: u16 = 1215; // ×7, **FLOAT despite the // `SetFloatValue(PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + // school, multiplier)` — decision 0208's flagged // question, now closed: a true float, default 1.0. -const FIELD_PLAYER_SKILL_INFO_1_1: u16 = 718; // UNIT_END+0x212, ×384 (128 skills × 3 dwords) — see - // [`PlayerSkillSlot`] for the verified packing. + // UNIT_END+0x212, ×384 (128 skills × 3 dwords) — see [`PlayerSkillSlot`] for the verified + // packing. `pub` for fixtures that seed a skill block through [`ObjectFields::from_pairs`] + // (the lock resolver's skill-term tests); live reads go through [`ObjectFields::player_skill`]. +pub const FIELD_PLAYER_SKILL_INFO_1_1: u16 = 718; // PLAYER_CHARACTER_POINTS1/2 (INT, PRIVATE — self only): unspent talent points / free primary // professions — the reference's `UnitCharacterPoints("player")` pair (decision 0304). Derived by diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index d479d01a..bb672c71 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -14,8 +14,9 @@ mod movement; pub use fields::{ quest_slot_state, ObjectFields, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, - AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, PLAYER_EXPLORED_ZONES_SLOTS, - PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, + AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1, + PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, + UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, }; pub use movement::{CreateSpline, MovementBlock, ObjectType}; diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 34426228..400578df 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -54,10 +54,18 @@ pub struct FfxGlow { /// view. /// /// **The law, and why it is ONE field.** The reference runs its FFX pass inside the - /// WorldFrame's own paint (`0x48350e` → the apply hook `0x6cd890`, wow-re `ffxeffects.md`); - /// every UI frame — a `` pane, and our portrait bakes standing in for one — - /// composites *afterwards*, so no effect keyed on the **viewer's own state** can reach a bake. - /// The zone glow is not in this class (it is authored scene data, and a bake wants world + /// WorldFrame's own paint — a BEGIN/END pair, `0x6cd890`/`0x6cda70`, bracketed at + /// `0x48350e`/`0x48379d` inside the one paint method `0x483460` (wow-re `death-pass.md` §5, + /// VERIFIED). It **cannot reach a bake**, for reasons that need no frame ordering: the pass's + /// render targets come from three module globals (`0xce8b5c` full, `0xce8ae8` quarter, + /// `0xce8b98` backbuffer) and it never observes the ambient binding — while the reference's + /// own portrait bake `0x524f60` binds no target at all and *copies* the framebuffer corner + /// out (`0x58acd0`/`0x449bf0`), so the pixels it keeps are pre-pass in every ordering. + /// + /// The binary does not separate the two lanes: the haze is `primary.z` of the glow combine + /// `0x6cb020`, which shares the single active-pass slot `0xce8bb4` with the death pass and + /// runs through the same bracket into the same three targets. So they are **one class**, and + /// this is one field. The zone glow is not in it (authored scene data, and a bake wants world /// parity for it — decision 0638), which is why [`Self::gain_scale`] stays separate. /// /// The haze had its own `haze_scale` and the death gate had none, so a released ghost's @@ -85,10 +93,11 @@ impl FfxGlow { state_scale: 0.0, }; /// **Decode only, no glow** — for a bake that stands in for a 1.12 *UI model widget*. The - /// reference applies its FFX pass inside the WorldFrame's own paint (`0x48350e` → the apply - /// hook `0x6cd890`, wow-re `ffxeffects.md`); every UI frame paints afterwards, at its own - /// strata, so a `` pane is composited over an already-glowed world and never - /// glows itself. (The give-away in-game: the reference's chat text and buttons don't bloom.) + /// reference applies its FFX pass inside the WorldFrame's own paint (the `0x6cd890`/`0x6cda70` + /// BEGIN/END bracket at `0x48350e`/`0x48379d`, wow-re `death-pass.md` §5); every UI frame + /// paints afterwards, at its own strata, so a `` pane is composited over an + /// already-glowed world and never glows itself. (The give-away in-game: the reference's chat + /// text and buttons don't bloom.) pub const UI_PANE: Self = Self { gain_scale: 0.0, state_scale: 0.0, diff --git a/crates/benilla-world/src/model_fade.rs b/crates/benilla-world/src/model_fade.rs index 3ffbe259..09ed1bac 100644 --- a/crates/benilla-world/src/model_fade.rs +++ b/crates/benilla-world/src/model_fade.rs @@ -635,7 +635,8 @@ impl FadeMaterials { /// opaque while its unit is still ramping. A MULTIPLY batch (Mod/Mod2x) passes its **steady self** /// as the twin: its blend equation reads no alpha (decision 0528), so no material swap can feather /// it — instead it arms the ramp like any other part and `wow_model.wgsl` lerps its colour toward -/// the blend identity by the tag alpha (the deliberate deviation of decision 0865). +/// the blend identity by the tag alpha — 0865's mechanism, since proven the reference's own +/// (preset 5 lerps the source colour by the instance alpha; decision 1489). pub struct FadeSet<'a> { pub steady: &'a Handle, pub blend: Option<&'a Handle>, diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs index 0f354bb5..99ceb78c 100644 --- a/crates/benilla-world/src/model_render/batch.rs +++ b/crates/benilla-world/src/model_render/batch.rs @@ -239,11 +239,11 @@ impl M2BatchMaterials<'_> { &light, ); // A multiply batch (Mod/Mod2x) and an authored-Blend batch are their own twin: the first - // because its blend equation reads no alpha at all — the reference's instanceAlpha ramp - // cannot feather it, and benilla's deliberate deviation feathers it through the shader's - // identity-lerp on the tag alpha instead (decision 0865) — the second because its colour - // pass already blends. Everything else gets a real twin, built from the SOURCE blend so - // the 224/255 cutout marker matches what the colour pass discards (decision 0842). + // because its blend equation reads no alpha at all — the fade rides the shader's + // identity-lerp on the tag alpha instead (0865's mechanism, 1489-verified as the + // reference's own preset-5 shape) — the second because its colour pass already blends. + // Everything else gets a real twin, built from the SOURCE blend so the 224/255 cutout + // marker matches what the colour pass discards (decision 0842). let own_twin = matches!( sub.blend, ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index bba0153d..684a1da7 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -274,11 +274,12 @@ pub fn spawn_model_entities( ); // The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when // this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack - // ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so no - // material swap can feather it — the reference's instanceAlpha fade leaves it at full - // strength (0528). Since decision 0865 the tag alpha it already carries is no longer inert: - // `wow_model.wgsl` lerps a multiply batch's colour toward the blend identity by it, so the - // rack sheen now rides the distance fade too (the deliberate deviation). + // ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so + // no material swap can feather it — the fade rides the SOURCE COLOUR instead: + // `wow_model.wgsl` lerps a multiply batch's colour toward the blend identity by the tag + // alpha it already carries, which is the reference's own preset-5 mechanism (0865 built + // it as a deviation; 1489 re-lawed it byte-faithful), so the rack sheen rides the + // distance fade like everything else. let blend = if steady_interior_prop || matches!( sub.blend,