bogus form requirements, the hover jump, and mining at skill 1

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 18:03:19 +07:00
co-authored by Claude Fable 5
parent a76530ab62
commit 7171a4997c
26 changed files with 652 additions and 103 deletions
@@ -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;
+12 -1
View File
@@ -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;
}
+60 -1
View File
@@ -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
+1
View File
@@ -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,
@@ -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,
+153 -10
View File
@@ -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<Res<'w, crate::go_templates::Locks>>,
pub(crate) lock_types: Option<Res<'w, crate::go_templates::LockTypes>>,
pub(crate) spells: Option<Res<'w, crate::ui_action::Spells>>,
/// 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<Res<'w, crate::ui_spellbook::SkillLines>>,
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<u32>,
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<u32>,
) -> 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<Item = benilla_protocol::messages::PlayerSkillSlot>,
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,
+3 -3
View File
@@ -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))
}
}
+4 -1
View File
@@ -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;
+3
View File
@@ -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,
})
})
+20 -1
View File
@@ -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,
@@ -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
@@ -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) {
+168
View File
@@ -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<DefaultLanguages> {
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<u32, LanguagePool>);
/// 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<String>,
/// `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<Vec<u32>>,
}
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<Item = &str> {
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<LanguageWords> {
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<u32, LanguagePool> = 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::<Vec<_>>(),
["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");
}
}
+3 -1
View File
@@ -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,
@@ -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<Item = (u32, u32)>) -> 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<u32> {
self.abilities.get(&spell_id).map(|a| a.skill_id)
@@ -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
}
@@ -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
+64 -17
View File
@@ -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<OpenLock>,
/// `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<i32> {
/// **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<i32> {
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 {
+19 -4
View File
@@ -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<SpellCatalog> {
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
+2 -2
View File
@@ -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,
@@ -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
@@ -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};
+17 -8
View File
@@ -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 `<PlayerModel>` 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 `<PlayerModel>` 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 `<PlayerModel>` 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,
+2 -1
View File
@@ -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<WowModelMaterial>,
pub blend: Option<&'a Handle<WowModelMaterial>>,
@@ -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
@@ -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,