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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user