the world gets its own Lua state, and the trainer follows the spell

- entering the world builds its own Lua state instead of adopting the character screen's, so a login one-shot fired into the frameless glue VM is spent again against the interface (2226)
- after training, the trainer's detail pane moves on: the selection is the service's spell id, re-found by scan, and a filtered row is hidden, not removed (2231)
- a spell's miss or resist floats in spell gold, and a travelling spell's word waits for the projectile; only a white hit's miss is white (2229)
- a press that lands on a nameplate is the plate's: no freelook, no camera turn, the plate's own click slot (2233)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:58:29 +07:00
co-authored by Claude Fable 5.1
parent 37e55168c2
commit 16abf643b1
24 changed files with 1541 additions and 259 deletions
+18
View File
@@ -163,6 +163,24 @@ pub(crate) enum DamageSource {
Pet,
}
/// The color law's `B` bit — "this damage is melee-STYLED" — from the spell record the emitting
/// call site pushes: `(recordPtr == 0) || sign(byte[SpellRec+0x25])`, i.e. no record at all, or
/// `AttributesEx3` bit 15 ([`benilla_formats::SpellDisplay::melee_white_damage`]). `display` is
/// the row the site resolved; `None` stands for BOTH "the site pushed NULL" (a melee swing, a
/// damage shield) and "we have no catalog" — the client degrades a NULL record to melee-styled,
/// so a missing catalog degrades the same way.
///
/// **It is the WORD emitter's bit as much as the number's** (decision 2229). `0x607140` and
/// `0x6128b0` are separate functions — different arg counts, different register allocation, not
/// the "byte-identical twins" an earlier reading called them — but they compute `B` and `K`
/// identically, and seven of the eight `0x607140` call sites push a resolved SpellRec. Only the
/// melee swing's word site (`0x624511`, whose seven predecessors are all `6a 00 push 0x0`) pushes
/// NULL. So a spell's "Miss"/"Resist" is spell-GOLD exactly like its number, and only a white
/// hit's miss is white.
pub(crate) fn melee_styled(display: Option<&benilla_formats::SpellDisplay>) -> bool {
display.is_none_or(benilla_formats::SpellDisplay::melee_white_damage)
}
/// The color branch (`0x6128b0` `6128f6`–`612964`): the effective override for a qualifying
/// source, by `B` (`melee` = record NULL; the AttributesEx bit-15 leg is a named divergence) and
/// `K`. `None` = the whole emit is gated off (CombatDamage master, or the pet path's Pet* cvar);
+194 -19
View File
@@ -23,12 +23,25 @@
//! (PetMeleeDamage cvar); pet spell → GOLD (PetSpellDamage cvar); **any OTHER source is
//! suppressed entirely** — another unit's fight floats nothing (the emitter returns before
//! submitting; it was never "white"). Crit does NOT recolor (it only picks the pop row); there
//! is NO school coloring. The bit-15 flip is implemented at the spell-packet emitters
//! (`net/apply/combat_log.rs::melee_styled`, decision 0376): the flag is
//! `SPELL_ATTR3_NORMAL_RANGED_ATTACK` — set on exactly the ranged basic shots on the real DBC —
//! so a Throw/Auto Shot number floats white off `SMSG_SPELLNONMELEEDAMAGELOG`. Remaining
//! divergence: the `SMSG_SPELLLOGMISS` word site's record push is unpinned — those outcome
//! words stay the row-default white (open).
//! is NO school coloring. The bit-15 flip is [`melee_styled`] (decision 0376): the flag is
//! `SPELL_ATTR3_NORMAL_RANGED_ATTACK` — set on exactly 8 rows of the real DBC, the ranged
//! auto-attack family — so a Throw/Auto Shot number floats white off
//! `SMSG_SPELLNONMELEEDAMAGELOG`.
//!
//! **The WORD emitter runs that same law** (decision 2229, closing what phase 2 left open). The
//! two emitters are separate functions — `0x607140` takes three stack args and `0x6128b0` four,
//! so they are not the "byte-identical twins" the first reading called them — but they compute
//! `B` and `K` identically, and **seven of the eight `0x607140` call sites push a resolved
//! SpellRec**. Only the melee swing's word site (`0x624511`, every one of its seven predecessors
//! a `6a 00 push 0x0`) pushes NULL. So a spell's "Miss"/"Resist"/"Immune" is spell-GOLD exactly
//! like its number, and only a white hit's miss is white. The three CVar gates live *inside*
//! `0x607140`, so `CombatDamage 0` silences words as well as numbers, on every path.
//!
//! **A travelling spell's word is DEFERRED to the projectile's arrival.** `SMSG_SPELL_GO`'s
//! inline emit is skipped whenever `Spell.dbc` Speed is nonzero (`0x6e7d4e`), and the missile's
//! own arrival handlers re-resolve the record and call the same emitter: a resisted Fireball
//! reads "Resist" when the ball lands, a missed Sinister Strike at packet receive.
//! [`missile_miss_text`] is that arrival half, fed by [`crate::entities::MissileMiss`].
//!
//! **The render geometry** (wow-re `worldtext-geometry-law.md`, §5-verified, 9af65294 — zero free
//! parameters; the anchor-seat half corrected here, see the seating comment in
@@ -107,11 +120,17 @@
mod law;
/// The spell-gold override, re-exported so the emitter arms' own tests can assert the colour they
/// now pass through (decision 2229). Live code never names it — [`damage_color`] picks it.
#[cfg(test)]
pub(crate) use law::COLOR_SPELL_GOLD;
use law::{
argb, claimed_box_px, fade_alpha, melee_text, scale_value, shadow_offset_px, text_px,
CATEGORIES,
};
pub(crate) use law::{damage_color, miss_word, spell_text, DamageSource, DamageTextGates};
pub(crate) use law::{
damage_color, melee_styled, miss_word, spell_text, DamageSource, DamageTextGates,
};
use bevy::prelude::*;
@@ -459,6 +478,67 @@ fn drop_indices<T>(v: &mut Vec<T>, mut idx: Vec<usize>) {
});
}
/// The `0x5efea0` source-ownership class (`K`) over an attacker **entity** — the ECS-side twin of
/// `net/apply/combat_log.rs::classify_source`, which does the same job from a guid. `None` is the
/// classifier's "every other source", which suppresses the emit outright.
fn source_class(
attacker: Entity,
self_player: &Query<(), With<crate::net::SelfPlayer>>,
self_guid: &crate::net::SelfGuid,
stores: &Query<&crate::net::ObjectStore>,
) -> Option<DamageSource> {
if self_player.contains(attacker) {
return Some(DamageSource::Player);
}
let me = self_guid.0?;
stores
.get(attacker)
.is_ok_and(|st| st.0.unit_summoned_by() == Some(me) || st.0.unit_created_by() == Some(me))
.then_some(DamageSource::Pet)
}
/// The **deferred** outcome-word producer: a travelling spell's miss word, floated when the
/// projectile lands rather than when `SMSG_SPELL_GO` arrives (decision 2229).
///
/// `0x6e7a70`'s inline word emit is skipped whenever the spell's `Spell.dbc` Speed is nonzero
/// (`0x6e7d4e fld [SpellRec+0x94]; fcomp 0.0; test ah,0x44; jp 0x6e7e71`); the projectile's own
/// arrival handlers re-resolve the record from `[missile+0x18]` and call the same word emitter
/// `0x607140`. So a resisted Fireball reads "Resist" when the ball reaches the target, while a
/// Speed-0 ability's word prints at packet receive. The colour law is the same one the inline
/// site runs — the arrival pushes a real record, so a bit-15-clear spell's word is spell-GOLD.
fn missile_miss_text(
mut arrivals: MessageReader<crate::entities::MissileMiss>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
self_guid: Res<crate::net::SelfGuid>,
stores: Query<&crate::net::ObjectStore>,
gates: Res<DamageTextGates>,
spells: Option<Res<crate::ui_action::Spells>>,
mut text: MessageWriter<CombatTextSpawn>,
) {
for arrival in arrivals.read() {
if self_player.contains(arrival.anchor) {
continue; // Gate A: never over your own head
}
let Some(source) = source_class(arrival.caster, &self_player, &self_guid, &stores) else {
continue; // K = other: never drawn
};
let display = spells
.as_ref()
.and_then(|s| s.catalog.get(arrival.spell_id));
let Some(color) = damage_color(*gates, source, melee_styled(display)) else {
continue; // the CombatDamage / Pet* gates, read inside `0x607140` itself
};
if let Some((word, category)) = miss_word(arrival.code) {
text.write(CombatTextSpawn {
anchor: arrival.anchor,
text: word.to_string(),
category,
color,
});
}
}
}
/// The MELEE number/word producer: consumes [`SwingImpact`] (the swing clip's impact keyframe —
/// `creature_anim::impact`, the client's `0x6247d0 → 0x624530` deferral) rather than the packet,
/// so the number pops when the blow lands, with the blood and the flinch. Gate A on entities: a
@@ -485,17 +565,7 @@ fn melee_impact_text(
if self_player.contains(victim) {
continue; // Gate A: never over your own head
}
// The source-ownership class over the attacker ENTITY (the guid-side twin lives in
// `net/apply/combat_log.rs::classify_source` for the packet emitters).
let source = if self_player.contains(s.attacker) {
DamageSource::Player
} else if self_guid.0.is_some()
&& stores.get(s.attacker).is_ok_and(|st| {
st.0.unit_summoned_by() == self_guid.0 || st.0.unit_created_by() == self_guid.0
})
{
DamageSource::Pet
} else {
let Some(source) = source_class(s.attacker, &self_player, &self_guid, &stores) else {
continue; // K = other: never drawn
};
let Some(color) = damage_color(*gates, source, true) else {
@@ -526,7 +596,7 @@ impl Plugin for CombatTextPlugin {
.add_message::<CombatTextSpawn>()
.add_systems(
Update,
(melee_impact_text, float_combat_text)
(melee_impact_text, missile_miss_text, float_combat_text)
.chain()
.in_set(UiQuadAppend),
);
@@ -582,6 +652,111 @@ mod tests {
assert_eq!(gold.color, COLOR_SPELL_GOLD);
}
/// **A travelling spell's outcome word is DEFERRED, and it is GOLD** (decision 2229) — the
/// arrival half of the miss text, and the case the whole round came from: a resisted Fireball
/// reads "Resist" in spell gold when the ball lands, not white at GO.
///
/// Four legs, each an independent branch of the law `0x607140` runs for every one of its
/// callers:
/// - my spell's word takes the **gold** override — `B=0` (Fireball's `AttributesEx3` bit 15 is
/// clear) and `K=self`;
/// - a **ranged basic shot** carries that bit, so a missed Auto Shot floats melee-**white**
/// even though it travels too — decision 0376's number pairing, now reaching the words;
/// - **Gate A** — a word never floats over your own head, whatever the colour;
/// - **`CombatDamage 0`** silences it, because that gate is read inside the word emitter
/// itself and so binds every word path, not just the numbers.
#[test]
fn a_travelling_spells_miss_word_is_gold_and_deferred_to_arrival() {
use crate::entities::MissileMiss;
use crate::net::{ObjectStore, SelfGuid, SelfPlayer};
const FIREBALL: u32 = 133;
const AUTO_SHOT: u32 = 75;
let catalog = || {
benilla_formats::SpellCatalog::from_displays(
[
(
FIREBALL,
benilla_formats::SpellDisplay {
name: "Fireball".into(),
speed: 24.0,
..Default::default()
},
),
(
AUTO_SHOT,
benilla_formats::SpellDisplay {
name: "Auto Shot".into(),
speed: 40.0,
attributes_ex3: 0x8000,
..Default::default()
},
),
]
.into_iter()
.collect(),
)
};
// `(spell, anchor-is-me, CombatDamage)` → the words the arrival floats.
let fire = |spell: u32, over_self: bool, combat_damage: bool| {
let mut app = App::new();
app.add_message::<MissileMiss>()
.add_message::<CombatTextSpawn>()
.init_resource::<SelfGuid>()
.insert_resource(DamageTextGates {
combat_damage,
..Default::default()
})
.insert_resource(crate::ui_action::Spells {
catalog: catalog(),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
})
.add_systems(Update, missile_miss_text);
let me = app
.world_mut()
.spawn((SelfPlayer, ObjectStore::default()))
.id();
let victim = app.world_mut().spawn(ObjectStore::default()).id();
app.world_mut().write_message(MissileMiss {
caster: me,
anchor: if over_self { me } else { victim },
spell_id: spell,
code: 2, // RESIST
});
app.update();
app.world_mut()
.resource_mut::<Messages<CombatTextSpawn>>()
.drain()
.map(|s| (s.text, s.category, s.color))
.collect::<Vec<_>>()
};
assert_eq!(
fire(FIREBALL, false, true),
vec![("Resist".to_string(), 3, Some(law::COLOR_SPELL_GOLD))],
"my Fireball's resist word is spell gold, category 3, over the victim"
);
assert_eq!(
fire(AUTO_SHOT, false, true),
vec![("Resist".to_string(), 3, None)],
"AttributesEx3 bit 15 keeps a ranged basic shot's word melee-white"
);
assert!(
fire(FIREBALL, true, true).is_empty(),
"Gate A: never over your own head"
);
assert!(
fire(FIREBALL, false, false).is_empty(),
"CombatDamage 0 is read inside the word emitter — it silences words too"
);
}
/// The destroy half of the off-screen cull (1344): the walk collects indices out of order,
/// against the *pre-removal* list, so the reap has to take them all at once and keep the rest
/// in order. Slots are freed by this and by nothing else.
+3 -1
View File
@@ -582,7 +582,9 @@ pub(crate) enum CastEventKind {
/// — the `0x60d450` fallback a basic shot's impact kit resolves through (decision 0370).
Impact { weapon_visual: Option<u32> },
/// A projectile arrived at a **ground point** instead of on a unit — the client's per-tick
/// missile dispatch taking its ground arm (`0x61e1d0` → `0x61d870`), reached by the single
/// missile dispatch taking `0x61e1d0` → `0x61d870`, which is the **no-live-target** arm, not
/// a ground arm as such (a target that despawned mid-flight lands there too; wow-re
/// `missile-arrival-dispatch.md`). Ours is reached only by the single
/// missile a dest-targeted GO with an empty hit list launches
/// ([`spell_visual::MissileSpawn::ground_aim`]). `entity` is the **caster** — `0x61d870`
/// plays the kit on it, not on anything at the point — and `pos` is the arrival position,
@@ -1039,7 +1039,8 @@ pub(super) fn route_cast_visuals(
);
}
CastEventKind::GroundImpact { pos } => {
// A missile arrived at a POINT (`0x61e1d0`'s ground arm → `0x61d870`): play
// A missile arrived at a POINT (`0x61e1d0` → `0x61d870`, its no-live-target
// arm — see [`crate::creature_anim::CastEventKind::GroundImpact`]): play
// `SpellVisual` field 13 — the **area kit** — at stage 3 on the caster, with
// `extra` = the landing point. The reference then walks the missile's own
// server-recorded hit array (`CMissile+0x58/+0x5c`) for per-unit impact/state
+3 -1
View File
@@ -93,8 +93,8 @@ pub(crate) use collision_height::CollisionHeight;
/// Spell-visual effect models (decision 0099 phase 3): a casting unit's attach-point `.mdx` glows,
/// spawned under the same attach-point joints as held items, lifetime per the kit stage.
mod missile;
pub(crate) use missile::MissileSound;
use missile::{attach_missile_models, move_missiles, spawn_missiles};
pub(crate) use missile::{MissileMiss, MissileSound};
/// WMO-display GameObject doodad props (the ship's sails / the zeppelin's rotor): the WMO's MODD
/// M2s spawned as children of the streamed gameobject, so they ride a moving transport.
@@ -803,6 +803,8 @@ impl Plugin for EntitiesPlugin {
.init_resource::<missile::PendingMissiles>()
// The projectile flight-loop edges (`crate::sound::missile` consumes them).
.add_message::<MissileSound>()
// A travelling spell's DEFERRED outcome word (`crate::combat_text`, decision 2229).
.add_message::<MissileMiss>()
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
.add_message::<dest_fx::GroundBurst>()
// A live display-id swap's rebuild edge — consumed by the morph-latch replay
+150 -36
View File
@@ -28,7 +28,23 @@
//! so a moving target bends the path (homing) and arrival lands exactly on schedule. Arrival on a
//! landed target writes [`CastEventKind::Impact`] back to the router (the client's unit-impact
//! hand-off `0x61dc50`); arrival on a missed target plays the victim's dodge/block defense clip
//! instead ([`miss_defense_state`] — the `0x61ceb0` dispatch, never Parry).
//! instead ([`miss_defense_state`]) and floats the outcome WORD ([`MissileMiss`], decision 2229).
//!
//! **`0x61e1d0` picks between THREE arms, and the selector is not the one an earlier reading
//! recorded** (wow-re `missile-arrival-dispatch.md`; it is not ground-vs-unit): first whether the
//! missile's target guid still resolves to a live object, then a **HIT bit** — `[missile+0x38] &
//! 1`, written once image-wide at the spawn `0x60a4e8` from an explicit argument.
//!
//! | arm | reached when | what it does | ours |
//! |---|---|---|---|
//! | `0x61dc50` | live target, HIT set | wound/flinch + impact visual. **Floats no word.** | [`CastEventKind::Impact`] |
//! | `0x61dd50` | live target, HIT clear | floats the outcome word + UNIT_COMBAT + chat, then disposes of the projectile per code | the defense clip + [`MissileMiss`] |
//! | `0x61d870` | **no live target resolved** | plays on the CASTER, then walks the missile's own guid list (skipping code 0 per target) | [`CastEventKind::GroundImpact`] |
//!
//! Two consequences worth stating because they are easy to get backwards. A missed travelling
//! spell's wound/flinch is not "skipped" — `0x61dc50` is never *reached*, because a nonzero
//! outcome means the spawn was not told HIT. And `0x61d870` is **not** "the ground arm": a
//! ground cast is only its commonest cause; a target that despawned mid-flight lands there too.
//!
//! A missile with no visual-chain model flies the **wire ammo model** instead (decision 0099
//! phase 5 — every basic shot spell: Auto Shot, the Shoot family, Throw, all `SpellVisual = 0`):
@@ -46,16 +62,21 @@
//! [`RELEASE_WAIT_MAX`] never-played timeout) where the client gets a per-anim finish callback;
//! homing aims at the dest attach point rather than solving the client's
//! ray-vs-bounding-sphere intercept (`0x61d230` — same body point in practice); a missed
//! target's arrival plays its dodge/block clip but the projectile itself still ends there
//! rather than deflecting (`0x61dd50`'s bounce).
//!
//! target's arrival plays its dodge/block clip and floats its word, but the projectile itself
//! always ends there. **`0x61dd50`'s per-outcome DISPOSITION table is not implemented** (its
//! jump table `0x61d76c` + case bytes `0x61d784`): the reference despawns on MISS/DODGE/EVADE
//! and on DEFLECT/ABSORB, but persists a BLOCKed missile 5000 ms (`0x61e7c0`), rebuilds the
//! model for RESIST/IMMUNE (`0x707350` on `0x861790`) before persisting, **deflects** a parried
//! one along a curve (`0x4531e0` + two `0x7be490` rotations + a velocity damp), and on REFLECT
//! re-targets the CASTER and re-launches with the wire's `reflectResult` as the new code. We
//! despawn in every case.//!
//! A GO with **no unit targets at all** but a point on the wire flies **one** projectile at the
//! point — the client's location fallback (`0x6e8a50`'s empty-hit-array arm → `0x60a3d0` once,
//! owning unit slot −1; wow-re `spell-go-dest-effect.md` §3). That is the whole visible flight of
//! a pure ground cast: the hunter's Flare arcing out to where it was placed, a bomb thrown at
//! empty dirt. Such a missile homes to nothing — its aim is a fixed world point ([`Aim::Ground`])
//! — and it arrives as [`CastEventKind::GroundImpact`] on the CASTER (`0x61e1d0`'s ground arm
//! `0x61d870`), never through the unit hand-off. Named approximation: it flies the same straight
//! — and it arrives as [`CastEventKind::GroundImpact`] on the CASTER (`0x61d870`, the
//! no-live-target arm — see the dispatch table above), never through the unit hand-off. Named approximation: it flies the same straight
//! arrive-on-time line a unit missile does; the reference's trajectory *class* comes from
//! `CMissile+0x48` through `0x61d720`'s remap table, whose wire origin wow-re traced to
//! consumption only — so a lobbed shot reads as a straight glide here.
@@ -306,12 +327,53 @@ fn ensure_ammo_model(
Some(key)
}
/// A travelling spell's **deferred outcome word** (decision 2229) — the arrival half of the
/// floating-combat-text miss word.
///
/// `SMSG_SPELL_GO`'s inline word emit is skipped whenever `Spell.dbc` Speed is nonzero
/// (`0x6e7d4e`), and the projectile floats the word when it lands instead: the arrival handlers
/// re-resolve the spell record from `[missile+0x18]` and call the same word emitter `0x607140`
/// the inline site calls. Written here because only the flight knows *when*; the source class,
/// the CVar gates and the colour are `crate::combat_text::missile_miss_text`'s, which owns that
/// law for every emitter.
#[derive(Message, Clone, Copy)]
pub(crate) struct MissileMiss {
/// The unit that cast it — the colour law's source class (`K`).
pub(crate) caster: Entity,
/// The unit the word floats over — **always the missed target**, `0x61ddb3`'s `this = edi`
/// (`0x61d9dc`'s is the per-entry target). Unlike the *inline* site, the arrival never
/// re-anchors a REFLECT to the caster: `0x61dd50` floats "Reflect" over the target and then
/// re-launches the projectile at the caster, so the caster's word comes from that second
/// flight — which we do not yet fly (the disposition table, module doc).
pub(crate) anchor: Entity,
/// The spell — the colour law's `B` bit resolves off its record.
pub(crate) spell_id: u32,
/// The wire's `SpellMissInfo` code (1–11).
pub(crate) code: u8,
}
/// **A travelling spell cannot be PARRIED — it is DEFLECTED.** The launch classifier `0x61d720`
/// (sole caller `0x61cebb`, inside the launch `0x61ceb0`) rewrites the outcome as it picks the
/// projectile's disposition: `61d753 or eax,0x40` then `61d756 mov dword ptr [ecx+0x48],0x9` —
/// PARRY(4) is stored back as DEFLECT(9), so every downstream reader (the word emitter, the
/// UNIT_COMBAT feed, the chat line) sees 9. This is the mechanism behind the recorded negative
/// that a ranged arrival never plays a parry clip: by arrival there is no parry left to play.
/// Every other code passes through.
fn launch_outcome_code(code: u8) -> u8 {
if code == 4 {
9
} else {
code
}
}
/// The wire `SpellMissInfo` codes whose missile arrival plays a victim defense clip, mapped to
/// the melee `$CPP` dispatch's victimState keys (the [`DefenseAnim`] consumer's
/// `select::defense_anim`): DODGE(3) → the dodge state (Dodge 30), BLOCK(5) → the block state
/// (ShieldBlock 24). Every other code — miss/resist/evade/immune/deflect — plays nothing, and
/// PARRY(4) maps to nothing by construction (the recorded negative: a ranged arrival never
/// parries; wow-re `smsg-attackerstate-consequences.md` §Q4).
/// PARRY(4) can never reach here at all: [`launch_outcome_code`] has already rewritten it to
/// DEFLECT(9), which is the mechanism behind the recorded negative that a ranged arrival never
/// parries (wow-re `smsg-attackerstate-consequences.md` §Q4).
fn miss_defense_state(code: u8) -> Option<u32> {
match code {
3 => Some(2), // SPELL_MISS_DODGE → the dispatch's DODGES state
@@ -320,16 +382,34 @@ fn miss_defense_state(code: u8) -> Option<u32> {
}
}
/// **The arrival hand-off's writer set, as one system parameter.** Both missile systems sit at
/// Bevy's 16-`SystemParam` ceiling, and these three are one concern anyway: everything a
/// projectile emits at the moment it lands — the router's impact/ground event, the victim's
/// defense clip, and the deferred outcome word. Bundled for the same reason
/// [`crate::ui_chat::combat::CombatFeedbackCvars`] is, so the call sites read `out.words` rather
/// than a positional tuple field.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct ArrivalOut<'w> {
/// The router's landed-target impact / ground-burst event.
impacts: MessageWriter<'w, CastEvent>,
/// A missed target's dodge/block clip ([`miss_defense_state`]).
defenses: MessageWriter<'w, DefenseAnim>,
/// A missed target's floating outcome WORD ([`MissileMiss`]).
words: MessageWriter<'w, MissileMiss>,
}
/// The arrival hand-off — the client's per-tick outcome dispatch (`0x61e1d0` over `0x61ceb0`'s
/// clock): a landed spell routes back to the router as [`CastEventKind::Impact`] on the target
/// (the impact/state kits + wound flinch); a missed one instead plays the victim's defense clip
/// per [`miss_defense_state`], and no impact kit plays; a **ground** arrival routes
/// [`CastEventKind::GroundImpact`] to the CASTER carrying the landing point (`0x61d870`).
///
/// A missed arrival ALSO floats the outcome WORD ([`MissileMiss`], decision 2229) — the deferred
/// half of the miss text, which `SMSG_SPELL_GO` skipped because this spell has a travel speed.
fn arrival_handoff(
missile: &Missile,
at: Vec3,
impacts: &mut MessageWriter<CastEvent>,
defenses: &mut MessageWriter<DefenseAnim>,
out: &mut ArrivalOut,
play_seq: &mut crate::creature_anim::PlaySeq,
) {
// Scene-time stamp: the client plays the arrival after the frame's packet handlers — a fresh
@@ -347,7 +427,7 @@ fn arrival_handoff(
let kind = CastEventKind::Impact {
weapon_visual: missile.weapon_visual,
};
impacts.write(event(target, kind));
out.impacts.write(event(target, kind));
}
Aim::Unit {
target,
@@ -355,14 +435,20 @@ fn arrival_handoff(
..
} => {
if let Some(victim_state) = miss_defense_state(code) {
defenses.write(DefenseAnim {
out.defenses.write(DefenseAnim {
victim: target,
victim_state,
});
}
out.words.write(MissileMiss {
caster: missile.caster,
anchor: target,
spell_id: missile.spell_id,
code,
});
}
Aim::Ground(_) => {
impacts.write(event(
out.impacts.write(event(
missile.caster,
CastEventKind::GroundImpact { pos: at },
));
@@ -384,8 +470,7 @@ fn launch_go(
units: &AttachPosQuery,
joints: &Query<&GlobalTransform>,
sounds: &mut MessageWriter<MissileSound>,
impacts: &mut MessageWriter<CastEvent>,
defenses: &mut MessageWriter<DefenseAnim>,
out: &mut ArrivalOut,
play_seq: &mut crate::creature_anim::PlaySeq,
) {
// The unit targets, else the location fallback's lone ground shot (the client consults the
@@ -399,7 +484,7 @@ fn launch_go(
.map(|&(target, miss)| Aim::Unit {
target,
dest_tag: go.spawn.dest_tag,
miss,
miss: miss.map(launch_outcome_code),
})
.collect()
};
@@ -418,7 +503,7 @@ fn launch_go(
weapon_visual: go.spawn.weapon_visual,
};
if missile.arrive_in <= 0.0 {
arrival_handoff(&missile, aim, impacts, defenses, play_seq);
arrival_handoff(&missile, aim, out, play_seq);
continue;
}
let dir = (aim - launch).normalize_or(-Vec3::Z);
@@ -470,8 +555,7 @@ pub(super) fn spawn_missiles(
)>,
anim_data: Option<Res<AnimData>>,
mut sounds: MessageWriter<MissileSound>,
mut impacts: MessageWriter<CastEvent>,
mut defenses: MessageWriter<DefenseAnim>,
mut out: ArrivalOut,
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
) {
let Some(mut fx) = fx else { return };
@@ -509,8 +593,7 @@ pub(super) fn spawn_missiles(
&units,
&joints,
&mut sounds,
&mut impacts,
&mut defenses,
&mut out,
&mut play_seq,
);
} // else: caster gone before its projectile left — nothing to show
@@ -536,8 +619,7 @@ pub(super) fn spawn_missiles(
&units,
&joints,
&mut sounds,
&mut impacts,
&mut defenses,
&mut out,
&mut play_seq,
);
}
@@ -581,8 +663,7 @@ pub(super) fn spawn_missiles(
&units,
&joints,
&mut sounds,
&mut impacts,
&mut defenses,
&mut out,
&mut play_seq,
);
}
@@ -666,23 +747,25 @@ pub(super) fn move_missiles(
mut missiles: Query<(Entity, &mut Missile, &mut Transform)>,
units: AttachPosQuery,
joints: Query<&GlobalTransform>,
mut impacts: MessageWriter<CastEvent>,
mut defenses: MessageWriter<DefenseAnim>,
mut out: ArrivalOut,
mut sounds: MessageWriter<MissileSound>,
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
) {
let dt = time.delta_secs();
for (entity, mut missile, mut transform) in &mut missiles {
let Some(aim) = aim_point(missile.aim, &units, &joints) else {
// The target streamed out mid-flight (the client's dead-handle ground path — ours
// just ends the flight). A ground shot has no target to lose.
// The target streamed out mid-flight. The reference routes this to `0x61d870` (the
// no-live-target arm), which plays on the CASTER and still floats a word per guid
// from the missile's own list — the eighth `0x607140` call site, `0x61d9dc`. Ours
// just ends the flight silently: a named divergence, and the only word path decision
// 2229 left unbuilt. A ground shot has no target to lose.
sounds.write(MissileSound::Stop { entity });
commands.entity(entity).despawn();
continue;
};
let to_target = aim - transform.translation;
if missile.arrive_in <= dt {
arrival_handoff(&missile, aim, &mut impacts, &mut defenses, &mut play_seq);
arrival_handoff(&missile, aim, &mut out, &mut play_seq);
sounds.write(MissileSound::Stop { entity });
commands.entity(entity).despawn();
continue;
@@ -725,7 +808,8 @@ mod tests {
.add_message::<AnimSoundEvent>()
.add_message::<MissileSound>()
.add_message::<CastEvent>()
.add_message::<DefenseAnim>();
.add_message::<DefenseAnim>()
.add_message::<MissileMiss>();
app.add_systems(Update, spawn_missiles);
app
}
@@ -970,12 +1054,26 @@ mod tests {
}
/// A missed target's arrival plays the victim's defense clip, not the impact (the
/// `Missile_C::Update` dispatch, wow-re `smsg-attackerstate-consequences.md` §Q4):
/// DODGE(3) → the dodge state, BLOCK(5) → the block state, a plain MISS(1) → nothing —
/// and PARRY(4) → nothing (the recorded "never Parry" negative).
/// `0x61dd50` arm of `0x61e1d0`'s dispatch): DODGE(3) → the dodge state, BLOCK(5) → the
/// block state, a plain MISS(1) → nothing — and PARRY(4) → nothing.
///
/// It also floats the outcome WORD (decision 2229), and two of its legs are only visible
/// here:
/// - **PARRY is gone by arrival.** [`launch_outcome_code`] rewrote it to DEFLECT(9) at
/// launch (`0x61d756`), so the word reads "Deflect" — which is *why* no parry clip plays,
/// rather than a separate rule that suppresses one.
/// - **REFLECT floats over the TARGET**, not the caster. The inline site re-anchors a
/// reflect (`0x6e7e51`); this one does not — `0x61dd50` words the target and then
/// re-launches at the caster, and the caster's word belongs to that second flight.
#[test]
fn missed_arrival_plays_dodge_or_block_never_impact_or_parry() {
for (code, expect_state) in [(3u8, Some(2u32)), (5, Some(5)), (1, None), (4, None)] {
fn missed_arrival_words_the_target_deflects_a_parry_and_plays_dodge_or_block() {
for (code, expect_state, expect_word) in [
(3u8, Some(2u32), "Dodge"),
(5, Some(5), "Block"),
(1, None, "Miss"),
(4, None, "Deflect"),
(11, None, "Reflect"),
] {
let mut app = app();
let caster = caster(&mut app, Vec3::ZERO);
// 2.4 units at speed 24 = a 0.1 s flight; parked 0.2 s → arrival on the spot.
@@ -1019,6 +1117,22 @@ mod tests {
}
None => assert!(defenses.is_empty(), "code {code} plays nothing"),
}
let words: Vec<_> = app
.world_mut()
.resource_mut::<Messages<MissileMiss>>()
.drain()
.map(|w| (w.anchor, w.caster, w.code))
.collect();
assert_eq!(
words,
vec![(target, caster, if code == 4 { 9 } else { code })],
"one word over the target (code {code})"
);
assert_eq!(
crate::combat_text::miss_word(words[0].2).map(|(w, _)| w),
Some(expect_word),
"code {code} words as {expect_word}"
);
}
}
+1
View File
@@ -1775,6 +1775,7 @@ pub(crate) fn apply_net_updates(
&mut ui_actions.8,
&mut ui_actions.14,
&mut audio.7,
*ui_actions.1 .5.damage_text,
&mut audio.10,
&mut loot_latch,
(
+112 -21
View File
@@ -63,18 +63,12 @@ pub(super) fn classify_source(
// attacker swing clip's impact keyframe with the rest of the victim feedback
// (`crate::combat_text::melee_impact_text`, fed by `creature_anim::impact`).
/// The color law's `B` bit for a spell-packet emit (`0x6128b0`: `recordPtr == 0 ||
/// sign(byte[SpellRec+0x25])`): melee-styled (white) when the spell id resolves to no record —
/// the client's NULL-record half — or the record carries `AttributesEx3 & 0x8000`
/// ([`benilla_formats::SpellDisplay::melee_white_damage`] — how a ranged basic shot's
/// `SMSG_SPELLNONMELEEDAMAGELOG` floats white; decision 0376). No catalog at all degrades the
/// same way the client degrades with no record: melee-styled.
fn melee_styled(spells: Option<&crate::ui_action::Spells>, spell_id: u32) -> bool {
let Some(spells) = spells else { return true };
spells
.catalog
.get(spell_id)
.is_none_or(|d| d.melee_white_damage())
/// The color law's `B` bit for a spell-packet emit, over the catalog the arms carry: resolve the
/// spell's record and hand it to [`crate::combat_text::melee_styled`] (which owns the law and its
/// NULL-record degradation). This is how a ranged basic shot's `SMSG_SPELLNONMELEEDAMAGELOG`
/// floats white (decision 0376) while an ordinary spell floats gold.
pub(super) fn melee_styled(spells: Option<&crate::ui_action::Spells>, spell_id: u32) -> bool {
crate::combat_text::melee_styled(spells.and_then(|s| s.catalog.get(spell_id)))
}
/// The spell arms' shared `UNIT_COMBAT` split: a landed amount is `WOUND` (`CRITICAL` descriptor
@@ -529,8 +523,9 @@ fn miss_action(code: u8) -> Option<&'static str> {
/// `SMSG_SPELLLOGMISS` → one outcome word per missed target (`0x5e7e00`). Disjoint from the
/// SPELL_GO miss list in practice: vmangos routes only impact-time outcomes (immune, evade) here.
/// Source-classified like every emitter (the classifier lives inside the word twin too); the
/// site's record push is unpinned, so the words keep the row-default white (flagged open).
/// Source-classified like every emitter (the classifier lives inside the word twin too), and
/// **coloured by the same B/K law as a number** — `0x5e7f63 mov ecx,[ebp-0x8]; 0x5e7f66 push ecx`
/// pushes the resolved SpellRec, not NULL, so this site's words are spell-GOLD (decision 2229).
#[allow(clippy::too_many_arguments)] // one dispatch arm's full writer set
pub(super) fn spell_log_miss(
s: SpellLogMiss,
@@ -574,14 +569,9 @@ pub(super) fn spell_log_miss(
let Some(source) = classify_source(s.caster, index, self_guid, stores) else {
return; // K = other: never drawn
};
let Some(_override_color) = damage_color(gates, source, melee_styled(spells, s.spell_id))
else {
let Some(color) = damage_color(gates, source, melee_styled(spells, s.spell_id)) else {
return; // the CombatDamage / Pet* gates
};
// The colour it picks is deliberately NOT applied here — that is the doc comment's "unpinned
// record push", and closing it is a visible change that belongs to whoever is looking at
// combat-text colour, not to the CVar wiring. (`melee_impact_text` already applies the
// override to ITS words, so the inconsistency is ours, not the reference's.)
for &(target, code) in &s.misses {
if let (Some(anchor), Some((word, category))) =
(gated_anchor(target, index, self_guid), miss_word(code))
@@ -590,7 +580,7 @@ pub(super) fn spell_log_miss(
anchor,
text: word.to_string(),
category,
color: None,
color,
});
}
}
@@ -690,3 +680,104 @@ pub(super) fn level_up(l: LevelUpInfo, chat_log: &mut ChatLog) {
// a permanent one; this is what one looks like when it is found.
chat_log.push_level_up_gains(&l, talent_points);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::combat_text::COLOR_SPELL_GOLD;
use crate::net::{Guid, SelfPlayer};
use bevy::ecs::system::RunSystemOnce;
/// **`SMSG_SPELLLOGMISS`'s word is spell GOLD** (decision 2229) — the third and last of the
/// three word paths this client had drawing white on a hardcoded `color: None`.
///
/// `0x5e7f63 mov ecx,[ebp-0x8]` / `0x5e7f66 push ecx` hands `0x607140` the resolved SpellRec,
/// not the literal NULL the melee swing site pushes — so the emitter's `B` bit comes from the
/// spell's own `AttributesEx3` and an ordinary spell's word is gold, exactly like its number.
/// Unlike the GO's inline emit there is **no Speed test** at this site: the word prints on
/// receipt whatever the spell's travel time.
#[test]
fn the_spell_log_miss_word_is_spell_gold() {
const IMMOLATE: u32 = 348;
let spells = crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[(
IMMOLATE,
benilla_formats::SpellDisplay {
name: "Immolate".into(),
speed: 24.0, // travels, and it still prints here
..Default::default()
},
)]
.into_iter()
.collect(),
),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
};
let mut app = App::new();
app.add_message::<CombatTextSpawn>()
.add_message::<UnitCombatFeedback>()
.add_message::<CombatTextEvent>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>();
let self_e = app
.world_mut()
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
let victim_e = app
.world_mut()
.spawn((Guid(20), ObjectStore::default()))
.id();
{
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(10, self_e);
index.0.insert(20, victim_e);
}
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
app.world_mut()
.run_system_once(
move |index: Res<GuidIndex>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut text: MessageWriter<CombatTextSpawn>,
mut feedback: MessageWriter<UnitCombatFeedback>,
mut center: MessageWriter<CombatTextEvent>| {
spell_log_miss(
SpellLogMiss {
spell_id: IMMOLATE,
caster: 10,
misses: vec![(20, 7)], // IMMUNE — the outcome vmangos routes here
},
&index,
&self_guid,
&stores,
Some(&spells),
DamageTextGates::default(),
&mut text,
&mut feedback,
&mut center,
);
},
)
.unwrap();
let spawned: Vec<_> = app
.world_mut()
.resource_mut::<Messages<CombatTextSpawn>>()
.drain()
.map(|s| (s.text, s.category, s.color, s.anchor))
.collect();
assert_eq!(
spawned,
vec![("Immune".to_string(), 3, Some(COLOR_SPELL_GOLD), victim_e)],
"gold, category 3, over the immune target"
);
}
}
+218 -20
View File
@@ -379,6 +379,9 @@ pub(super) fn spell_go(
pending: &mut PendingCast,
queued_melee: &mut QueuedMeleeSpell,
text: &mut MessageWriter<crate::combat_text::CombatTextSpawn>,
// The three floating-text CVars, read inside the word emitter `0x607140` itself — so they
// gate the miss words below exactly as they gate a damage number (decision 2229).
text_gates: crate::combat_text::DamageTextGates,
go_lid: &mut MessageWriter<crate::go_anim::GoLidOpen>,
// The client-local loot-target latch — armed here for a chest (decision 1477, §6 above).
loot_latch: &mut crate::ui_loot::LootLatch,
@@ -612,26 +615,46 @@ pub(super) fn spell_go(
// The miss list's floating words (0137 phase 2, the `0x6e7a70` handler): one outcome word
// over each missed target — except REFLECT, which re-anchors to the caster (`0x6e7e51`).
// Gate A applies to whichever unit the word lands over. Source-classified first (the color
// law's K, inside every emitter twin): another caster's misses draw nothing. The words keep
// the row-default white (this site's record push is unpinned — flagged open).
if !misses.is_empty()
&& super::combat_log::classify_source(caster, index, self_guid, stores).is_some()
{
for &(guid, code) in &misses {
let anchor_guid = if code == 11 { caster } else { guid };
if self_guid.0 == Some(anchor_guid) {
continue;
}
if let (Some(&anchor), Some((word, category))) = (
index.0.get(&anchor_guid),
crate::combat_text::miss_word(code),
) {
text.write(crate::combat_text::CombatTextSpawn {
anchor,
text: word.to_string(),
category,
color: None,
});
// law's K, inside every emitter): another caster's misses draw nothing.
//
// **Two laws phase 2 left open, both closed by the 2229 §5.**
//
// *Timing* — `0x6e7d4e fld [SpellRec+0x94]; fcomp 0.0; test ah,0x44; jp 0x6e7e71` skips this
// inline emit whenever `Spell.dbc` Speed is nonzero. A TRAVELLING spell's word is floated by
// the projectile's own arrival instead ([`crate::entities::MissileMiss`]), so a resisted
// Fireball reads "Resist" when the ball lands; a Speed-0 ability prints here, now. No
// catalog degrades to printing here, the way a NULL record degrades everywhere else.
//
// *Colour* — `0x6e7d73 mov edi,[ebp-0x8]` / `0x6e7dcc push edi` pushes the resolved SpellRec,
// not NULL, so the word runs the same B/K override as a number: a bit-15-clear spell's "Miss"
// is spell-GOLD. The three CVar gates come with it — they are read inside `0x607140`, which
// every word path calls, so `CombatDamage 0` silences these words too.
if !misses.is_empty() && display.is_none_or(|d| d.speed == 0.0) {
if let Some(color) = super::combat_log::classify_source(caster, index, self_guid, stores)
.and_then(|source| {
crate::combat_text::damage_color(
text_gates,
source,
crate::combat_text::melee_styled(display),
)
})
{
for &(guid, code) in &misses {
let anchor_guid = if code == 11 { caster } else { guid };
if self_guid.0 == Some(anchor_guid) {
continue;
}
if let (Some(&anchor), Some((word, category))) = (
index.0.get(&anchor_guid),
crate::combat_text::miss_word(code),
) {
text.write(crate::combat_text::CombatTextSpawn {
anchor,
text: word.to_string(),
category,
color,
});
}
}
}
}
@@ -1131,6 +1154,7 @@ mod tests {
&mut pending,
&mut queued_melee,
&mut text,
crate::combat_text::DamageTextGates::default(),
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
@@ -1170,6 +1194,177 @@ mod tests {
assert!(matches!(feed[0], CastBarEdge::Stop), "…with a STOP");
}
/// **The GO's inline miss word: gold, gated, and only for an INSTANT spell** (decision 2229).
///
/// Phase 2 shipped this emit unconditional and hardcoded white. The §5 closed both halves:
/// - `0x6e7d4e fld [SpellRec+0x94]; fcomp 0.0; test ah,0x44; jp 0x6e7e71` — a spell with a
/// **travel Speed** prints nothing here; its word rides the projectile's arrival instead
/// ([`crate::entities::MissileMiss`]). Sinister Strike (Speed 0) prints now; Fireball
/// (Speed 24) does not;
/// - `0x6e7d73`/`0x6e7dcc` push the resolved SpellRec, so the word takes the same B/K
/// override a number would — **spell gold**, not the category-3 row default;
/// - and the `CombatDamage` gate lives inside `0x607140`, which this path calls, so it
/// silences the word too — the leg this site was missing entirely.
#[test]
fn the_gos_inline_miss_word_is_gold_instant_only_and_cvar_gated() {
use crate::combat_text::{CombatTextSpawn, COLOR_SPELL_GOLD};
use crate::creature_anim::Casting;
use crate::go_anim::GoLidOpen;
use crate::net::{Guid, SelfPlayer};
use bevy::ecs::system::RunSystemOnce;
const SINISTER_STRIKE: u32 = 1752; // Speed 0 — an instant melee ability
const FIREBALL: u32 = 133; // Speed 24 — it travels
let make_spells = || crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[
(
SINISTER_STRIKE,
benilla_formats::SpellDisplay {
name: "Sinister Strike".into(),
..Default::default()
},
),
(
FIREBALL,
benilla_formats::SpellDisplay {
name: "Fireball".into(),
speed: 24.0,
..Default::default()
},
),
]
.into_iter()
.collect(),
),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
};
// Our cast (guid 10) misses a creature (guid 20). Returns the words it floated.
let fire = |spell: u32, combat_damage: bool| {
let mut app = App::new();
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
.world_mut()
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
let victim_e = app
.world_mut()
.spawn((Guid(20), ObjectStore::default()))
.id();
{
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(10, self_e);
index.0.insert(20, victim_e);
}
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
let (tx, _rx) = crossbeam_channel::unbounded();
let spells = make_spells();
let gates = crate::combat_text::DamageTextGates {
combat_damage,
..Default::default()
};
app.world_mut()
.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut go_targets: MessageWriter<SpellGoTargets>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
10,
spell,
0,
vec![],
vec![(20, 1)], // one MISS
Some(20),
None,
None,
None,
None,
&mut commands,
&index,
&casting,
&mut cast_events,
&mut go_targets,
&self_guid,
&stores,
&mut cast_bar,
&mut pending,
&mut queued_melee,
&mut text,
gates,
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
&mut cooldowns,
Some(&spells),
&mut items,
&net_commands,
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut sheath,
false,
),
1,
);
},
)
.unwrap();
app.world_mut()
.resource_mut::<Messages<CombatTextSpawn>>()
.drain()
.map(|s| (s.text, s.category, s.color))
.collect::<Vec<_>>()
};
assert_eq!(
fire(SINISTER_STRIKE, true),
vec![("Miss".to_string(), 3, Some(COLOR_SPELL_GOLD))],
"an instant ability's miss word prints here, in spell gold — not white"
);
assert!(
fire(FIREBALL, true).is_empty(),
"Speed 24: `0x6e7d4e` skips the inline emit — the projectile floats it on arrival"
);
assert!(
fire(SINISTER_STRIKE, false).is_empty(),
"CombatDamage 0 gates the word emitter, not just the number emitter"
);
}
/// **The GO handler's PET leg** (decision 1031): a spell going off on a unit WE own arms the
/// pet's own cooldown bank, and nothing else does — vmangos sends no cooldown packet for a
/// pet's cast, so without this the pet bar's sweep never runs in play.
@@ -1289,6 +1484,7 @@ mod tests {
&mut pending,
&mut queued_melee,
&mut text,
crate::combat_text::DamageTextGates::default(),
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
@@ -1433,6 +1629,7 @@ mod tests {
&mut pending,
&mut queued_melee,
&mut text,
crate::combat_text::DamageTextGates::default(),
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
@@ -1739,6 +1936,7 @@ mod tests {
&mut pending,
&mut queued_melee,
&mut text,
crate::combat_text::DamageTextGates::default(),
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
+26 -11
View File
@@ -946,18 +946,33 @@ impl WorldMouse {
/// gesture the world already has.
pub(super) fn latch_world_mouse(
buttons: Res<ButtonInput<MouseButton>>,
// **The CHROME flag, not the raw one** — a V-plate is UI the camera looks straight through
// (decision 2159, and the concept is named on `PointerOverUiPanel`). Plates became real
// mouse-enabled widgets in 2148, so the pointer inside one makes `PointerOverUi` true — right
// for the world PICK (the plate publishes the mouseover itself, and its click selects) and
// wrong for the camera: a drag that begins over a plate has to turn the view, or nameplates
// become dead patches you cannot swing the camera from. The reference says the same thing from
// the other end — entering freelook DISABLES plate mouse input (`0x60f830`, from `0x483e80`),
// a toggle that would have nothing to do if a press on a plate could not reach freelook.
// **The raw flag — a press that lands on a V-plate is the PLATE's, not the camera's** (decision
// 2233, reversing 2159's exception).
//
// The click does NOT leak through with it: `PlayerUiClickConsumed` still suppresses the
// `WorldClick`/`WorldRightClick` below, so a plate click stays the plate's own.
pointer_over_ui: Res<crate::ui_script::PointerOverUiPanel>,
// 2159 read it the other way and excepted plates here, on an inference from the other end:
// entering freelook disables plate mouse input (`0x60f830`, from `0x483e80`), "a toggle that
// would have nothing to do if a press on a plate could not reach freelook". The wow-re round
// this session dispatched refuted that: `0x60f830` has plenty to do for a press that starts on
// the **world** and then drags the pointer across a plate mid-turn. The press itself never gets
// there. `0x7662c0` delivers a mouse-down to exactly ONE frame — `[root+0x80]` else
// `[root+0x7c]` — sets the capture at `0x7663e9`, calls `[vt+0x68]`, and returns 0, stopping
// the bus walk; and `CBindings::ExecuteBinding 0x4b7990` has exactly six call sites image-wide,
// every one inside a `CGWorldFrame` vtable handler. So with a plate under the cursor the
// binding that would start mouselook is never reached, and the release goes to the plate's own
// click slot (`0x7792d0` → `0x7cb910` → `0x4949f0`, mask 1 select / mask 4 interact — the same
// two terminals the world right-click's object leg reaches).
//
// What it costs is real and is the reference's own cost: **you cannot swing the camera by
// dragging off a nameplate.** Plates are small dead patches for turning, exactly as in 1.12.
// The one exception is the plate's own `+0x3c` veto (`0x7cba30`) — while a ground-targeted
// spell is armed the plates refuse the hit test, the WorldFrame wins the press, and the gesture
// *does* enter mouselook. That is built where it belongs, in the hit test itself
// ([`benilla_ui::script::UiScript::set_nameplate_hit_test_veto`]), so it arrives here for free
// as `PointerOverUi` simply being false over a vetoing plate.
//
// `PointerOverUiPanel` keeps its other reader — the wheel still zooms with the cursor on a
// plate, which is a different law (the wheel walks PAST a frame that merely takes the mouse).
pointer_over_ui: Res<crate::ui_script::PointerOverUi>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
+6 -5
View File
@@ -64,11 +64,12 @@ pub(super) fn world_right_click_payload(
/// pick already names the plate's unit. A scripted right-click on a plate does nothing yet, which
/// is a stated gap rather than a guess about which leg it should take.
///
/// The **physical** right-click on a plate does not come through here at all and never did: the
/// press engages freelook (the camera looks through a plate — 2159), freelook hands the plates'
/// mouse back (`0x60f830`), and the release therefore hit-tests off the plate, so its `OnClick`
/// never fires. That gesture is the camera arbiter's own [`WorldRightClick`], acting on the pick
/// the press latched — which is exactly what decision 2230 made it read.
/// Since 2233 the **physical** right-click on a plate comes through here, and this is the only path
/// it has: the press is the plate's (the camera no longer looks through one), so no look session
/// starts and the camera arbiter emits no [`WorldRightClick`] of its own. The plate's click slot
/// fires on the up edge and the replay below turns it into the interact leg — which is the
/// reference's own shape, `0x7cb910` → `0x4949f0(mask 4)` → `0x492820`, the same terminal the world
/// right-click's object leg `0x492ce0` reaches.
#[allow(clippy::too_many_arguments)]
pub(super) fn select_on_plate_click(
mut plate: ResMut<crate::vplates::PlateClicks>,
+44 -40
View File
@@ -2722,8 +2722,11 @@ fn the_free_professions_line_is_printed_once() {
/// Pre-2119 the restore was an `Update` system and this probe saw `windows = nil`,
/// `colors = nil`, `registered = ""` at `PLAYER_LOGIN`.
///
/// The probe is planted in the boot VM the way `world_entry_tests` plants its addon: the entry
/// load runs onto the VM that already exists, so a frame created here hears the whole load.
/// The probe is planted as a real loose ADDON, the way `world_entry_tests` plants its own. It
/// cannot be a frame created on the boot VM beforehand: since 2226 the entry load BUILDS the VM it
/// runs on, so anything seated on the character screen's VM is gone before the first event fires.
/// An addon's file scope runs inside the load — after the XML, before `VARIABLES_LOADED` — which
/// is exactly the vantage point this probe wants, and the one the reference gives an addon too.
#[test]
fn the_chat_cache_restore_is_finished_before_player_login() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -2732,14 +2735,46 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-chat-order-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("benilla-config")).expect("hermetic home");
let home = tmp.join("benilla-config");
let probe = home.join("AddOns").join("ChatOrderProbe");
std::fs::create_dir_all(&probe).expect("hermetic home + probe addon dir");
std::fs::write(
probe.join("ChatOrderProbe.toc"),
"## Interface: 11200\nChatOrderProbe.lua\n",
)
.expect("probe toc");
std::fs::write(
probe.join("ChatOrderProbe.lua"),
r#"
ChatOrderProbe = { order = "" }
local f = CreateFrame("Frame")
f:RegisterEvent("VARIABLES_LOADED")
f:RegisterEvent("UPDATE_CHAT_WINDOWS")
f:RegisterEvent("UPDATE_CHAT_COLOR")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function()
-- The burst is 100+ events; record it once so the order string stays readable.
if not string.find(ChatOrderProbe.order, event, 1, 1) then
ChatOrderProbe.order = ChatOrderProbe.order .. event .. " "
end
if event == "UPDATE_CHAT_WINDOWS" then
ChatOrderProbe.windows = (ChatOrderProbe.windows or 0) + 1
elseif event == "UPDATE_CHAT_COLOR" then
ChatOrderProbe.colors = (ChatOrderProbe.colors or 0) + 1
elseif event == "PLAYER_LOGIN" then
ChatOrderProbe.loginWindows = ChatOrderProbe.windows or 0
ChatOrderProbe.loginColors = ChatOrderProbe.colors or 0
ChatOrderProbe.loginRegistered =
(ChatFrame1 and ChatFrame1.messageTypeList
and table.concat(ChatFrame1.messageTypeList, ",")) or ""
end
end)
"#,
)
.expect("probe lua");
let _capture = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _home = crate::local_state::test_env::EnvGuard::set(
"BENILLA_HOME",
tmp.join("benilla-config")
.to_str()
.expect("utf-8 temp path"),
);
let _home =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().expect("utf-8"));
let mut world = bevy::prelude::World::new();
world.init_resource::<crate::ui_script::AddOnIdentity>();
@@ -2749,37 +2784,6 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
world.init_resource::<super::settings::ChatWindowFile>();
crate::ui_script::setup_script(&mut world);
world
.non_send_resource::<benilla_ui::script::UiScript>()
.run(
r#"
ChatOrderProbe = { order = "" }
local f = CreateFrame("Frame")
f:RegisterEvent("VARIABLES_LOADED")
f:RegisterEvent("UPDATE_CHAT_WINDOWS")
f:RegisterEvent("UPDATE_CHAT_COLOR")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function()
-- The burst is 100+ events; record it once so the order string stays readable.
if not string.find(ChatOrderProbe.order, event, 1, 1) then
ChatOrderProbe.order = ChatOrderProbe.order .. event .. " "
end
if event == "UPDATE_CHAT_WINDOWS" then
ChatOrderProbe.windows = (ChatOrderProbe.windows or 0) + 1
elseif event == "UPDATE_CHAT_COLOR" then
ChatOrderProbe.colors = (ChatOrderProbe.colors or 0) + 1
elseif event == "PLAYER_LOGIN" then
ChatOrderProbe.loginWindows = ChatOrderProbe.windows or 0
ChatOrderProbe.loginColors = ChatOrderProbe.colors or 0
ChatOrderProbe.loginRegistered =
(ChatFrame1 and ChatFrame1.messageTypeList
and table.concat(ChatFrame1.messageTypeList, ",")) or ""
end
end)
"#,
)
.expect("order probe");
world.insert_resource(crate::char_select::Roster::with_pending_pick(
vec![benilla_protocol::Character {
guid: 1,
+175 -24
View File
@@ -26,11 +26,11 @@ pub(crate) fn setup_script(world: &mut World) {
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
/// font-object registry, and no frames at all.
///
/// This is the state the client sits at outside the world — the login and character screens — and
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
/// is what makes a second login a genuine second load rather than a re-entry into the first
/// login's Lua state.
/// This is the state the client sits at outside the world — the login and character screens. It is
/// installed at `Startup`, at every [`end_ui_session`], and **at every world entry**
/// ([`load_ingame_ui_on_world_entry`], decision 2226): the in-game UI is never loaded onto the VM
/// the glue phase was using, so no login inherits a session id — and therefore a
/// [`super::VmMemo`] — from the character screen that preceded it.
///
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
@@ -231,6 +231,26 @@ pub(crate) fn arm_entry_ui_load(world: &mut World) {
}
}
/// **Retire the glue phase's VM and build the one the entry load will run on** (decision 2226) —
/// `0x48fe97`, the reset inside `UI_Init 0x48fbf0` itself, expressed against our own load.
///
/// The fold first, because it is the one thing that must outlive the VM being discarded. The glue
/// phase can write CVars — the character screen's AddOns panel is the live case, a *Load out of
/// date AddOns* click — and `sync_cvars` (1291) registers them onto whichever VM is in the world,
/// so this is the last moment that table exists. Folded into the persist state, the
/// `set_cvar_saved_base` + `register_cvars` pair in the load below starts the new VM at the
/// player's value. That is exactly the route `/reload` has always taken through
/// [`end_ui_session`]; 2226 makes the first login take it too.
///
/// Nothing else has to survive: every other thing the glue VM was carrying is either re-seeded
/// onto the new VM a few lines later (the realm, the player seat, the addon array, the minimap
/// zoom, the text measurer, the strings, the emote tokens, the font registry) or is a
/// [`super::VmMemo`], which is *supposed* to reset here — that reset is the whole point.
fn mint_entry_vm(world: &mut World) {
crate::cvars::fold_dying_vm_cvars(world);
install_boot_vm(world);
}
/// The parked boot VM back into the world, if one is parked — the session end and the
/// left-before-the-load arm both want the VM where the tail expects it.
fn unpark_boot_vm(world: &mut World) {
@@ -252,11 +272,26 @@ fn unpark_boot_vm(world: &mut World) {
/// symptom moved between characters. The self descriptor arriving inside the deferral window is
/// all it takes.
///
/// The reference has no such window: `UI_Init 0x48fbf0` loads all of FrameXML and *then* fires the
/// world-enter cascade (`PLAYER_LOGIN` at `0x49094b`, `PLAYER_ENTERING_WORLD` at `0x490965`) from
/// inside itself, so a UI-less client never sees a unit event at all. This is that same ordering,
/// expressed against our deferred load: the first frame that answers true is a fresh, unlatched
/// world, and the feed delivers the full set in order.
/// **The reference has no such window — but not for the reason this doc used to give** (corrected
/// 2226). It said `UI_Init 0x48fbf0` fires the world-enter cascade from inside itself, so a UI-less
/// client never sees a unit event; that is 1348's reading and the bytes refute it, because the
/// cascade call at `0x490168` is gated on an active-player GUID that is still 0/0 on a fresh login
/// (see [`finish_ui_init`]). The conclusion survives, on four independent structural grounds, all
/// re-verified in wow-5875-re:
///
/// - World entry is a **category-5** callee (`0x420d63`), dispatched after the same iteration's
/// **category-6** inbound drain (`0x420d55`) has already returned.
/// - A nested drain is impossible: the drain's container `0x420c00` is entered **once per
/// process**, so no unresolved indirect call inside the load can re-enter it.
/// - Reception is decoupled — the socket threads only **enqueue** (`0x537b7c`); handlers run only
/// from the main-thread drain. `SMSG_PONG` (`0x1dd`, inline at `0x537b56`) is the single bypass
/// in the image, and it carries no game state.
/// - The client does not even **send** the login request until a later frame (`0x46c272`), gated on
/// the async loads being idle — i.e. not until the UI is up.
///
/// So the real client cannot process world state without an interface, structurally, and our
/// deferred load is expressing that same property with a run condition. The first frame this
/// answers true on is a fresh, unlatched world, and the feed delivers the full set in order.
///
/// **TWO terms, because `not(ingame_ui_pending)` is only half of it** (B376). The latch is armed
/// at `OnEnter(InWorld)`, and that edge trails the wire by a frame: `apply_net_updates` drains
@@ -313,11 +348,18 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
let start = std::time::Instant::now();
load_ingame_ui_on_world_entry(world);
// The standing instrument for this burst: the one number that says whether the cover is
// still hiding it, on every entry, in every log.
// still hiding it, on every entry, in every log — and, since 2226, WHICH VM it came up on.
// The session is the load-bearing half of that record: it is what every `VmMemo` keys on, so
// a login whose session did not move from the character screen's is a login where the whole
// one-shot class is already spent. Printing it means the next report of a missing login line
// can be answered from the log the run already wrote.
info!(
"ui_script: in-game UI up in {:.0} ms (behind the cover: {})",
"ui_script: in-game UI up in {:.0} ms (behind the cover: {}, vm session: {})",
start.elapsed().as_secs_f32() * 1000.0,
covering
covering,
world
.get_non_send_resource::<UiScript>()
.map_or(0, UiScript::session),
);
}
@@ -352,19 +394,35 @@ fn seat_text_measurer_for_load(world: &mut World, script: &mut UiScript) {
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
/// character's file. [`world_entry_tests`] holds both ends.
///
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
/// **The entry BUILDS its VM; it does not adopt the glue phase's** (decision 2226). That is the
/// reference's own shape — the next Lua state is born *inside* `UI_Init 0x48fbf0`, at `0x48fe97`,
/// before the bindings and the FrameXML walk — and it is what [`benilla_ui::script::UiScript::session`]'s contract has
/// claimed all along ("destroys its Lua state at logout and builds another at the next world
/// entry"). We were half-keeping it: [`install_boot_vm`] ran at `end_ui_session`, so a *second*
/// login was a genuine second load, but the boot VM that `Startup` (or that same logout) left
/// behind then carried its session **across the entry edge** — the glue phase and the world that
/// followed it were one VM with one session id.
///
/// That shared id is what made the login one-shot class (1348, B376) so hard to close. A
/// [`super::VmMemo`] keys on the session, so an edge spent against the FRAMELESS glue VM — any
/// feed that reached it in the window between the wire turning in-world and this load — was spent
/// for the whole login: the event went to a VM with no frames to hear it, and the memo said
/// "already told" forever after. Gating the feeds (1978's park, B376's `ingame_ui_up`) closes
/// that window one feed at a time and silently misses the next one written. A new session closes
/// it for **every** memo at once, without any feed having to know this problem exists: whatever
/// was spent against the glue VM is re-spent here, against a VM that has an interface.
///
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
/// capture boots straight into `InWorld`, so before that this would have run ahead of
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// The VM the entry edge parked (1978); the live slot is the fallback for a caller that did
// not go through the arm.
// not go through the arm. It goes back in the world because the fold below reads it there.
unpark_boot_vm(world);
if !ui_wanted(world) {
return;
}
mint_entry_vm(world);
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
@@ -426,11 +484,13 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
script.set_unit("player", Some(seat));
}
// The addon version gate (decision 1292): the live table when this VM has one, else the
// persisted value. On a first login the boot VM was seeded during the glue phase, so a
// *Load out of date AddOns* click at character select reads back here even inside the
// save debounce; on a reload the fresh boot VM has no table yet and the persisted value is
// current by construction (1291's session-edge fold ran moments ago). Absent both (a bare
// test world) is the registrar default: check ON.
// persisted value. **Every entry is now the reload case** (2226) — the VM was born a few
// lines up, so its table is whatever `register_cvars` just seeded off the persisted base, and
// that base is current by construction because the fold runs in the same call. Before 2226
// this read the glue phase's own live table, which is how a *Load out of date AddOns* click at
// character select reached here inside the save debounce; the fold carries that click now, by
// the route the reload path always used. Absent both (a bare test world) is the registrar
// default: check ON.
let version_check = script
.cvar("checkAddonVersion")
.map(|v| v != "0")
@@ -648,8 +708,11 @@ pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
/// roots — and, with the writes done, **the end of this session's Lua state**.
///
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
/// The reference's own shutdown destroys the frames and nils the 216 bindings (`0x490bd0`, after
/// `AddOns.txt`) and then replaces the state a little later, in `ShutdownGame` (`0x491231`) —
/// **not in `0x490bd0`'s tail, as this doc used to say** (corrected 2226: nothing in `0x490bd0`'s
/// call graph touches `ds:0xceef74`). Ours ends by replacing it with a fresh boot VM
/// ([`install_boot_vm`]), which is the
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
@@ -797,13 +860,37 @@ pub(crate) fn shutdown_on_exit(
/// |---|---|
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) — **gated, see below** |
///
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
///
/// **The third row does not run on a fresh login, and this doc used to say it did** (corrected
/// 2226; the claim came from 1348 and was re-checked in wow-5875-re against the bytes). The call
/// at `0x490168` is gated three instructions earlier:
///
/// ```text
/// 49015f: call 0x468550 ; the active player's GUID from [[0xb41414]+0xc0/+0xc4]
/// 490164: or eax,edx
/// 490166: je 0x49016d ; TAKEN on a fresh login — the pair is still 0/0
/// 490168: call 0x4908c0
/// ```
///
/// The object-manager ctor zeroed that pair at `0x4015f3`, three calls earlier, so on a first
/// entry the branch is taken and `0x4908c0` is reached only later, from a data-dependent episode
/// once an object-update packet has populated the GUID. On a `/reload` — where the player is
/// already in the world and the pair is set — it does fire from here, which is the case 1348 was
/// looking at when it generalised.
///
/// **This is a fidelity question we have NOT settled, deliberately left open rather than quietly
/// resolved.** Our `finish_ui_init` fires the cascade inside the load, which matches the reload
/// case and not the fresh-login one; whether to split those is its own change with its own
/// measurements, and 2226 (which was about the VM's identity, not the cascade's timing) is not it.
/// wow-re's own `DEFERRED:` entry — which of `0x4908c0`'s two data-dependent entries fires first on
/// a real login — is still open, so the target shape is not yet known.
///
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
@@ -983,6 +1070,70 @@ mod tests {
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
}
/// **The world entry builds its own VM** (decision 2226) — the reference's reset at
/// `0x48fe97`, inside `UI_Init` itself, and what
/// [`benilla_ui::script::UiScript::session`]'s contract already claimed.
///
/// Against the old shape this reads *"the in-game UI loaded onto the character screen's own
/// Lua state, so the session id never moved across the entry edge"* — and a session that
/// never moves is a [`super::VmMemo`] that never resets, which is how a login one-shot fired
/// into the frameless glue VM (1348, B376) stayed spent for the rest of the login.
#[test]
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
let mut world = World::new();
let glue = UiScript::new().unwrap();
let glue_session = glue.session();
world.insert_non_send_resource(glue);
mint_entry_vm(&mut world);
let entry = world
.get_non_send_resource::<UiScript>()
.expect("the entry load has a VM to run on");
assert_ne!(
entry.session(),
glue_session,
"the entry VM is a new session — every VmMemo resets here, so a one-shot spent \
against the frameless glue VM is spent again against the one that has an interface"
);
}
/// **The login one-shot class, at the mechanism** (decision 2226) — the reason the session has
/// to move, expressed without any particular feed in it.
///
/// This is the shape of every one of them: a feed reaches the VM in the window between the
/// wire turning in-world and the deferred entry load, fires its event at a state with no
/// frames to hear it (`0x703f50` drops an event with no listener), and its [`super::VmMemo`]
/// records "told". Before 2226 the entry load then ran onto that same VM, the memo still
/// matched, and the telling never happened again for the rest of the login — 1348 on the unit
/// feed, B376 on the guild MOTD, the cinematic and `feed_chat`.
///
/// Against the old shape the third assertion reads *"still spent"*, and it does so for every
/// feed at once, gated or not.
#[test]
fn a_one_shot_spent_against_the_character_screens_vm_is_spent_again_after_the_entry() {
let mut world = World::new();
world.insert_non_send_resource(UiScript::new().expect("a glue VM"));
let mut told: super::super::VmMemo<bool> = Default::default();
assert!(
told.claim(world.non_send_resource::<UiScript>()),
"the window: a feed fires its login one-shot at the frameless character-screen VM"
);
assert!(
!told.claim(world.non_send_resource::<UiScript>()),
"and the memo has it as told — which is correct for THAT VM"
);
mint_entry_vm(&mut world);
assert!(
told.claim(world.non_send_resource::<UiScript>()),
"the VM the in-game UI loads onto has never been told, so the feed tells it again — \
no run condition, no ordering edge, and nothing the feed had to know about"
);
}
/// **A rebuilt VM inherits the running `GetTime()` clock, and so does the conversion pair**
/// (decision 2116) — the bug the director reported as *cooldowns are lost on relog*.
///
+32 -1
View File
@@ -1294,7 +1294,38 @@ mod pointer_arbiter_tests {
);
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"…and the camera and the wheel look straight through it"
"…and the WHEEL looks straight through it"
);
}
/// …and what each of the two bits is now FOR, which is the half 2233 moved.
///
/// The camera reads the raw flag: a press landing on a plate is the plate's, because
/// `0x7662c0` delivers a mouse-down to exactly one frame and stops the bus walk, so the
/// binding that starts mouselook is never reached. The wheel reads the chrome flag: it is the
/// one genuine fall-through in the frame system and walks **past** a frame that merely takes
/// the mouse, so scroll-zoom still works with the cursor on a plate. Two different reference
/// laws, which is why there are two bits and not one — and 2159 had the camera on the wrong
/// one for two days.
#[test]
fn the_camera_yields_to_a_plate_and_the_wheel_does_not() {
let mut app = app();
let plate = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut()
.resource_mut::<crate::vplates::PlateHover>()
.0 = Some(plate);
app.update();
// `latch_world_mouse` reads this one; `world_press` is `!over_ui`, so the press never
// becomes the world's and no look session starts.
assert!(
app.world().resource::<PointerOverUi>().0,
"the camera must yield the press to the plate"
);
// …and `bindings`' wheel branch reads this one.
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"the wheel must still reach the world over a plate"
);
}
@@ -856,7 +856,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
.unwrap());
assert!(s.eval::<i64>("return GetNumTrainerServices()").unwrap() < 6);
s.reset_trainer_filter(0);
s.reset_trainer_list_state(0);
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap(),
@@ -875,7 +875,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
);
// A mount trainer wants available|used instead — what makes a known mount visible at all.
s.reset_trainer_filter(1);
s.reset_trainer_list_state(1);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap());
@@ -1049,7 +1049,7 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_filter(open.trainer_type);
s.reset_trainer_list_state(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
@@ -1140,3 +1140,115 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The detail pane after learning a spell** — the director's report: the trained row vanished, the
/// highlight landed on the row that slid into its place, and the name/icon/cost/`Requires:` block
/// below went on describing the spell that was gone.
///
/// The stock Lua repaints that pane on `TRAINER_UPDATE` through **one** door:
/// `ClassTrainer_SelectFirstLearnableSkill`, the only thing that restores
/// `ClassTrainerFrame.showSkillDetails` after `ClassTrainerTrainButton_OnClick` cleared it —
/// `ClassTrainer_SetSelection` early-returns while it is nil. Which door `TRAINER_UPDATE` takes is
/// decided entirely by the engine's `GetTrainerSelectionIndex()`, and the reference's answer for a
/// service that has gone off screen is its index in the hidden **tail** — past
/// `GetNumTrainerServices()`, never a live row (`0x4d7520`; see `benilla_ui`'s `selected_row`). So
/// the reference takes the `> 1` branch, resets the scroll, and hides the pane.
///
/// **What a player sees after training, in the reference and now here: an empty detail pane and no
/// highlighted row.** It does not advance to the next spell — that is the *fresh window's*
/// behaviour, which is what a selection reading 0 or 1 would have produced.
///
/// Driven through the real [`crate::ui_trainer::TrainerOpen`] like B256's test above, for the same
/// reason: the packet-vs-repaint decision under test is the app's own.
#[test]
fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_one() {
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
let mut s = trainer_script();
// The reference's own file-scope defaults (1128) — "already known" OFF, which is what makes a
// learned service leave the list at all.
s.run("TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0")
.unwrap();
s.fire_event(
"ADDON_LOADED",
vec![ScriptValue::Str("Blizzard_TrainerUI".into())],
);
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_list_state(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
s.fire_event(event, vec![ScriptValue::Str("Dazalar".into())]);
}
let pane = |s: &mut UiScript| -> (bool, String) {
s.eval::<(bool, String)>(
"return ClassTrainerSkillName:IsVisible() and true or false, \
ClassTrainerSkillName:GetText() or ''",
)
.unwrap()
};
let row_name = |s: &mut UiScript, row: i64| {
s.eval::<String>(&format!("return (GetTrainerServiceInfo({row})) or ''"))
.unwrap()
};
let mut open = TrainerOpen::default();
open.open(DAZALAR, 0, vec![], "Hello, warrior!".into());
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
// Row 2 is the first learnable — the row the window opens on, and the one he trains.
assert_eq!(row_name(&mut s, 2), "Heroic Strike");
assert_eq!(
pane(&mut s),
(true, "Heroic Strike".into()),
"the window opens describing its own selection"
);
s.run("ClassTrainerTrainButton:Click()").unwrap();
assert_eq!(
s.take_trainer_buys(),
vec![78],
"the Train button bought the selected row"
);
// The app re-asks for the list and marks its answer the repaint it is (B256); the bought
// service comes back gray and, with "already known" off, leaves the list. Cleave slides up into
// row 2 under where the selection used to be.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, warrior!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
row_name(&mut s, 2),
"Cleave",
"the trained row is gone from the list and the next one took its place"
);
assert!(
!pane(&mut s).0,
"and the pane below is empty rather than still describing the spell he just learned: {:?}",
pane(&mut s).1
);
assert!(
!s.eval::<bool>("return ClassTrainerSkillHighlightFrame:IsVisible()")
.unwrap(),
"nothing is highlighted either — the selection is off screen, not on Cleave"
);
assert!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap()
> s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
"because the engine answers with the hidden row, which is what steers the window there"
);
// And he can pick the next one up by hand, which is the whole of the recovery.
s.run("this = ClassTrainerSkill2 ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert_eq!(pane(&mut s), (true, "Cleave".into()));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+8 -6
View File
@@ -67,10 +67,10 @@ pub(crate) struct TrainerOpen {
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
pub(crate) greeting: String,
/// A `SMSG_TRAINER_LIST` that **begins a window session** has landed and the feed has not yet
/// handed it to the engine. Drives the engine's filter/collapse reset
/// ([`UiScript::reset_trainer_filter`]) — the reference's builder rewrites both masks on every
/// list packet (decision 1128). It is not the same edge as a snapshot change: those re-push the
/// same list.
/// handed it to the engine. Drives the engine's filter/collapse/**selection** reset
/// ([`UiScript::reset_trainer_list_state`]) — the reference's builder rewrites all three on every
/// list packet (decision 1128; 2231 for the selection, `0x4d7b42`). It is not the same edge as a
/// snapshot change: those re-push the same list.
///
/// "Begins a session", not "a packet arrived", because the two are the same thing in the
/// reference and are **not** in benilla (B253/B256's arc): the reference gets a list packet only
@@ -82,7 +82,9 @@ pub(crate) struct TrainerOpen {
pub(crate) fresh_list: bool,
/// The post-buy re-list **we asked for** ([`crate::net::apply`]'s `trainer_buy_succeeded`) is in
/// flight: the answering packet repaints the open window rather than opening one, so it must not
/// reset the filter/collapse masks. Cleared by the packet it belongs to, and by any close.
/// reset the filter/collapse masks — nor the selection, which is what leaves the learned spell
/// selected-but-hidden and so takes the detail pane down with it, the reference's own
/// post-purchase behaviour (2231). Cleared by the packet it belongs to, and by any close.
pub(crate) refresh_pending: bool,
}
@@ -395,7 +397,7 @@ fn feed_trainer(
// reference's builder does (decision 1128) — before the snapshot goes in, so `TRAINER_SHOW`
// finds the reset mask and the window's own show handler pushes the SAVED filter back over it.
if open.fresh_list {
script.reset_trainer_filter(open.trainer_type);
script.reset_trainer_list_state(open.trainer_type);
open.fresh_list = false;
}
let fresh = snapshot(
+14
View File
@@ -433,6 +433,8 @@ struct PlateWorld<'w, 's> {
camera: Query<'w, 's, (&'static Camera, &'static Transform), With<WorldCamera>>,
// The cursor, for the plate-rect hover (OnEnter — the yellow name, this frame's rects).
window: Query<'w, 's, &'static Window, With<bevy::window::PrimaryWindow>>,
// The pending ground-target cast — the plate's `+0x3c` hit-test veto (`0x7cba30`).
targeting: Res<'w, crate::ui_action::SpellTargeting>,
}
/// Gate + draw, every frame: decide which units carry a plate (into [`VPlates`], the
@@ -522,6 +524,18 @@ fn drive_vplates(
*mouse_told.get(&script) = Some(looking);
script.set_nameplate_mouse(!looking);
}
// **The plate's OWN veto** (`0x7cba30`, the `+0x3c` override), which is a different mechanism
// from the freelook toggle above and has to stay one: while a ground-targeted spell is armed a
// plate refuses the hit test *before* the rect, so the reticle can be placed through it — and
// it does that without touching the mouse-enabled bit, so `IsMouseEnabled()` still answers
// truthfully to an addon. `TargetingWants::Location` is `0x6e6320`'s `flag & 0x60` verbatim.
// Written every frame rather than on an edge: it is a plain flag read at hit-test time, not a
// walk over the plate list, so there is no edge worth memoising.
script.set_nameplate_hit_test_veto(
world
.targeting
.wants(crate::ui_action::targeting::TargetingWants::Location),
);
let hovered_key = script.hovered_nameplate();
let clicked = script.take_nameplate_clicks();
let my_level = self_store.and_then(|s| s.0.unit_level()).unwrap_or(1);
+19 -5
View File
@@ -720,11 +720,25 @@ impl UiScript {
/// **Which VM this is** — a fresh number for every [`UiScript::new`], never reused.
///
/// The client destroys its Lua state at logout and builds another at the next world entry
/// (the reference's own `0x490bd0` ↔ `0x48fbf0` pair), so a host that remembers *what it last
/// pushed into the VM* is remembering something that may no longer exist. Anything the host
/// seeded — a registry, a catalog, a change-detection memo — is only valid for the session it
/// was seeded into, and this number is what says so.
/// The client destroys its Lua state and builds another several times over a login cycle, so a
/// host that remembers *what it last pushed into the VM* is remembering something that may no
/// longer exist. Anything the host seeded — a registry, a catalog, a change-detection memo — is
/// only valid for the session it was seeded into, and this number is what says so.
///
/// **The addresses, corrected** (2226; this doc carried `0x490bd0` ↔ `0x48fbf0` from 1290 and
/// that pair is wrong). `ds:0xceef74` is a single global *slot* holding successive instances,
/// written at exactly two sites image-wide: `0x7039ed` opens, `0x703bab` closes. The reset
/// choke point `0x703b80` closes-if-present and then **tail-jmps** into the open — which is why
/// a `call`-only census of it reads "one state per process" and is wrong. Its three callers are
/// `0x48fe97` (inside `UI_Init 0x48fbf0`, unconditional), `0x491231` (`ShutdownGame`,
/// unconditional) and `0x46a87b` (the glue builder, gated on its arg). `0x490bd0` destroys the
/// frame-script *owner object* (`0x490c97`, vtable `0x81c380` slot+4 = `0x764360`) and nils the
/// 216 bindings; it never touches `ds:0xceef74`.
///
/// So the boundary is `0x48fe97` ↔ `0x491231`/`0x46a87b`: **the rebuild replaces the state, the
/// teardown does not** — and the rebuild lives *inside* the function that loads FrameXML, which
/// is why benilla's world entry mints its VM at the top of its own load (2226) rather than
/// adopting the character screen's. GlueXML and FrameXML never share an instance.
///
/// A host keying its memory on this cannot go stale by omission: a new VM simply does not
/// match, so the seed happens again. That is the property, and it is why this is a VM-side fact
+9 -4
View File
@@ -1213,11 +1213,16 @@ pub(crate) struct Model {
pub(crate) bank_close: bool,
/// The open trainer's service snapshot the app pushes (`None` = no trainer open), the
/// `BuyTrainerService` intents it drains, the engine-held 1-based selection (0 = none), and
/// whether `CloseTrainer` was called — the trainer seam ([`trainer`], decision 0237).
/// `BuyTrainerService` intents it drains, the engine-held selection, and whether `CloseTrainer`
/// was called — the trainer seam ([`trainer`], decision 0237).
///
/// The selection is the selected service's **spell id**, not its row number. A row number is a
/// coordinate in a list that three independent things move under it — the state filter, a
/// collapse, and a re-list — and `GetTrainerSelectionIndex` answering with a stale-but-in-range
/// one is what left the detail pane describing a spell that had already left the window.
pub(crate) trainer: Option<trainer::TrainerState>,
pub(crate) trainer_buys: Vec<u32>,
pub(crate) trainer_selection: u32,
pub(crate) trainer_selection: Option<u32>,
pub(crate) trainer_close: bool,
/// The three state filters (available / unavailable / used) — the real client hides filtered
/// service rows itself ([`trainer`]); all shown by default. A state filter hides *services*, never
@@ -2099,7 +2104,7 @@ impl Model {
bank_close: false,
trainer: None,
trainer_buys: Vec::new(),
trainer_selection: 0,
trainer_selection: None,
trainer_close: false,
trainer_filter: [true; 3],
trainer_collapsed: HashSet::new(),
+36
View File
@@ -239,10 +239,28 @@ pub(crate) struct NamePlates {
/// Completed clicks on plates, drained by the app each frame
/// ([`UiScript::take_nameplate_clicks`]).
clicks: Vec<NamePlateClick>,
/// **The plate's own hit-test veto** — `0x7cba30`, the `+0x3c` override, set while a
/// ground-targeted spell is armed ([`UiScript::set_nameplate_hit_test_veto`]).
///
/// Deliberately NOT the mouse-enabled bit: the reference has two distinct mechanisms here and
/// only one of them is visible from Lua. `0x60f830` (freelook) toggles input kind 2 on every
/// plate, so `IsMouseEnabled()` answers `false` — while `0x7cba30` refuses the *hit test*
/// before testing the rect and never touches the bit, so an addon asking a plate whether it
/// takes the mouse during ground targeting is told `true`, truthfully. Folding the two into
/// one flag would have been simpler here and wrong there.
hit_test_vetoed: bool,
/// The geometry every live plate is currently laid out under.
geometry: Option<PlateGeometry>,
}
impl NamePlates {
/// Does the `0x7cba30` veto refuse `frame` this point? True only for a plate, and only while
/// the veto stands.
pub(crate) fn vetoes(&self, frame: FrameHandle) -> bool {
self.hit_test_vetoed && self.by_frame.contains_key(&frame)
}
}
/// A completed click on a plate — what the reference's own `CGNamePlateFrame` click slot
/// (`0x7cb910`) turns into a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -327,6 +345,24 @@ impl UiScript {
}
}
/// **The plate hit-test veto** (`0x7cba30`, the plate's `+0x3c` override — the one of the six
/// that can refuse *before* testing the rect): while a ground-targeted spell is armed the
/// plates stop taking the mouse and the click falls through to the `WorldFrame`, so the reticle
/// can be placed through a plate.
///
/// The reference's predicate is `IsTargeting() && TargetingWantsLocation(flag & 0x60) &&
/// !0x6e6180()`. The caller supplies the first two verbatim — benilla's `TargetingWants::
/// Location` **is** that `& 0x60` mask. The third is another mask predicate over the same
/// pending-spell word (`& 0x878e`, per wow-re `item-target-cursor-and-dropitemonunit.md`) whose
/// meaning is not settled; a word that reaches our targeting cursor with location bits is a
/// pure ground target (the resolver binds or refuses a unit word before then), so it is left
/// unmodelled rather than guessed at.
pub fn set_nameplate_hit_test_veto(&mut self, vetoed: bool) {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.nameplates.hit_test_vetoed = vetoed;
}
/// **Retire every live plate** — hide them all and return them to the pool, without touching
/// the geometry they are laid out under.
///
+7
View File
@@ -211,6 +211,13 @@ impl UiScript {
// `<TitleRegion setAllPoints="true"/>` stay inert so it drags by its tab and not its
// body, and a hover over plain text reassigns to whatever is underneath.
(model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y))
// **The nameplate's own `+0x3c` veto** (`0x7cba30`): while a ground-targeted spell
// is armed a plate refuses the hit test *before* the rect is tested, and the point
// falls through to the `WorldFrame` behind it so the reticle can be placed through
// a plate. It is not the mouse-enabled bit — see
// [`super::UiScript::set_nameplate_hit_test_veto`] for why that distinction is
// load-bearing rather than pedantic.
&& !model.nameplates.vetoes(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
@@ -723,8 +723,13 @@ fn a_scripted_click_selects_too() {
/// **The mouselook toggle** (`0x60f830`): entering freelook hands the mouse back on every plate,
/// leaving takes it again — the reference walks its own intrusive plate list doing exactly this,
/// from `0x483e80` (enter) and `0x483e70` (leave). Without it a right-drag that begins over a plate
/// would be a plate click instead of a camera turn.
/// from `0x483e80` (enter) and `0x483e70` (leave).
///
/// **What it is for is NOT "a drag that begins over a plate"** — that gesture never reaches
/// mouselook at all, because `0x7662c0` hands the mouse-down to the plate and stops the bus walk
/// before any binding runs (decision 2233, which reversed the inference this doc used to carry).
/// It is for a turn that started on the **world** and then dragged the pointer across a plate: the
/// plates must not take a pointer that is hidden and locked to the camera.
#[test]
fn freelook_hands_the_mouse_back() {
let mut s = vm();
@@ -747,3 +752,49 @@ fn freelook_hands_the_mouse_back() {
"and it takes it back on leave"
);
}
/// **The plate's own `+0x3c` veto** (`0x7cba30`) — a DIFFERENT mechanism from the freelook toggle
/// above, and the reason it is not folded into the same flag.
///
/// While a ground-targeted spell is armed the plate refuses the hit test *before* testing its rect
/// (`0x6e48a0() && 0x6e6320() && !0x6e6180()` → `xor eax,eax; ret 4`, never calling the base), so
/// the point falls through to the `WorldFrame` at (strata 0, level 0) behind it and the reticle can
/// be placed through a plate. Crucially it never touches `[+0xcc]`: `IsMouseEnabled()` still
/// answers **true** throughout, which is exactly what `0x60f830` does not do.
#[test]
fn a_ground_target_veto_refuses_the_hit_without_disabling_the_mouse() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
s.mouse_move(500.0, 384.0);
assert!(s.hovered_nameplate().is_some(), "the plate takes the mouse");
s.set_nameplate_hit_test_veto(true);
s.mouse_move(500.0, 384.1);
assert_eq!(
s.hovered_nameplate(),
None,
"the reticle must be placeable through a plate"
);
// …and the press falls through with it, which is what lets the WorldFrame win the gesture and
// the ground cast commit where the player clicked.
s.mouse_button(500.0, 384.1, "LeftButton", true);
s.mouse_button(500.0, 384.1, "LeftButton", false);
assert!(
s.take_nameplate_clicks().is_empty(),
"a vetoed plate takes no click either"
);
// The bit the veto must NOT have touched — an addon asking is told the truth.
assert_eq!(
s.eval::<i64>("local p = WorldFrame:GetChildren() return p:IsMouseEnabled()")
.unwrap(),
1,
"the veto refuses the hit test, it does not disable the mouse"
);
s.set_nameplate_hit_test_veto(false);
s.mouse_move(500.0, 384.0);
assert!(
s.hovered_nameplate().is_some(),
"and the plate takes it back when the cast is gone"
);
}
+168 -52
View File
@@ -47,11 +47,17 @@
//! At the other types a service whose group key is `0` (unresolved skill line) is still dropped,
//! matching the client.
//!
//! `GetNumTrainerServices`/every getter index the **visible** rows: headers always show, but a
//! service hides when its state fails the dropdown filter ([`Model::trainer_filter`]) or its group is
//! collapsed ([`Model::trainer_collapsed`]). `Collapse/ExpandTrainerSkillLine(id)` take the **display
//! index of a header row** (`id == 0` = all groups — the collapse-all button); they toggle that
//! group's visibility, never reorder.
//! A service hides when its state fails the dropdown filter ([`Model::trainer_filter`]) or its group
//! is collapsed ([`Model::trainer_collapsed`]) — but **hiding is not removing**: the client parks a
//! hidden record in a tail behind the visible ones and keeps its index, so `GetNumTrainerServices`
//! answers with the *visible* count while every getter's index space is the *whole* array ([`rows`]).
//! An index past the count is a legal hidden row, and the stock window is handed exactly one after a
//! purchase ([`selected_row`]). `Collapse/ExpandTrainerSkillLine(id)` take the **display index of a
//! header row** (`id == 0` = all groups — the collapse-all button); they toggle that group's
//! visibility, never reorder.
//!
//! The **selection** is likewise the selected service's **spell id** (`ds:0xb73a0c`), re-resolved to
//! a position on every `GetTrainerSelectionIndex` — never a stored row number ([`selected_row`]).
//!
//! ## Faithful stubs (no wire data yet — kept so the ported XML runs)
//!
@@ -266,7 +272,7 @@ impl super::UiScript {
let mut model = self.model_mut();
match state {
None => {
model.trainer_selection = 0;
model.trainer_selection = None;
model.trainer_collapsed.clear();
model.trainer = None;
}
@@ -279,7 +285,8 @@ impl super::UiScript {
}
}
/// **Reset the state filter and the collapse set — one `SMSG_TRAINER_LIST` arriving.**
/// **Reset what one `SMSG_TRAINER_LIST` arriving resets — the state filter, the collapse set,
/// and the selection.**
///
/// Byte-verified (wow-re `system/ui/scratch/trainer-service-suppression.md`, decision 1128): the
/// list builder writes the filter mask itself on every packet — `0x4d75d9 mov ds:0xb73a1c,3`
@@ -289,10 +296,23 @@ impl super::UiScript {
/// lives in the saved variable `TRAINER_FILTER_*`, and the window's show handler pushes it back
/// over this reset (decision 1128; `TrainerFrame.xml`'s `BenillaTrainerFrame_ApplyFilter`).
///
/// **The selection is the same edge and the same law.** `0x4d7560`'s tail selects record 0 —
/// `0x4d7b40 xor ecx,ecx` → `0x4d7b42 call 0x4d74f0`, after the sort — and since row 0 is always
/// a group header, whose record carries the id `0xffffffff` (`0x4d7b05`), the getter's scan
/// finds the first header and answers **1**. Clearing is the same thing to every caller: the
/// stock window only ever asks `GetTrainerSelectionIndex() > 1`, which 0 and 1 both fail, so
/// both send it down `ClassTrainer_SelectFirstLearnableSkill`. Without this a re-opened trainer
/// could inherit a selection from the last visit — the same spell id, still in the list, still
/// resolving — which the reference cannot do.
///
/// The repaint path `0x4d7d40` touches **none** of the three (a `ds:0xb73a0c` census over the
/// whole image returns four references, all in the setter/getter/teardown), which is why a
/// purchase leaves the selection sitting on the service it just greyed out.
///
/// This is the **packet** edge, not the content edge — [`Self::set_trainer`] runs on every
/// snapshot change (an item template landing, a name resolving), and the reference's repaint path
/// `0x4d7d40` does not touch either mask. Reset there and a filter would evaporate mid-window.
pub fn reset_trainer_filter(&mut self, trainer_type: u32) {
pub fn reset_trainer_list_state(&mut self, trainer_type: u32) {
let mut model = self.model_mut();
// Mask 5 at a mount/"talent" trainer: available + already-known, which is what makes a
// known mount visible under its "My Talents" header at all (decision 1124's group -1).
@@ -302,6 +322,7 @@ impl super::UiScript {
[true, true, false]
};
model.trainer_collapsed.clear();
model.trainer_selection = None;
}
/// Drain the **spell ids** `BuyTrainerService` queued since the last call (the engine resolves each
@@ -446,9 +467,31 @@ fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGr
groups
}
/// The visible rows in display order (decisions 0247/1124). Per group, in order: the header — **only
/// if at least one of the group's services passes the state filter** — then, when the group is not
/// collapsed, those services. The Lua's 1-based `index` is a position in *this* list.
/// The client's whole row array, and how much of its front is **on screen** — the visible rows in
/// display order (decisions 0247/1124), then every hidden one in a **tail** behind them.
///
/// **Hiding a row is not removing it**, and that is the shape of the real array rather than a
/// convenience here. The finalizer `0x4d8410` writes only a per-record visible flag — `[+0x34] = 1`
/// at `0x4d84c6`, `= 0` at `0x4d8546` — decrements the *visible* count `ds:0xb73a18` (`0x4d8549`),
/// and then `qsort`s **all `ds:0xb73a10`** records (`0x4d857e`) with that flag as the comparator's
/// PRIMARY key (`0x4d85ce` … `0x4d8744 setne al` / `0x4d874e lea eax,[eax+eax-1]` → ±1). A hidden
/// row therefore keeps a record, a position and an index — it is simply parked behind the visible
/// ones. `GetNumTrainerServices` (`0x4d8d90`) answers with the visible count `ds:0xb73a18`, while
/// the index space every getter is bounds-checked against is the **total** `ds:0xb73a10`: the
/// thirteen service getters all route through one shared accessor gate, `0x4d89b0`'s
/// `cmp ecx, dword ptr [0xb73a10]`, and `GetTrainerServiceInfo` (`0x4d8dc0`) reaches the same gate
/// through its four siblings (`0x4d8aa2`, `0x4d8b52`, `0x4d8ba2`, `0x4d8c32`). The visible count has
/// **five references image-wide** — the finalizer seeding and decrementing it, the buy-ALL loop, and
/// `GetNumTrainerServices` — and **no getter is among them**. So an index past
/// `GetNumTrainerServices()` is a legal, resolvable, *hidden* row — which is exactly the index the
/// stock window is handed after a purchase, and reading it as out-of-range is what used to strand
/// the detail pane ([`selected_row`]). An all-boxes-off window is empty because the Lua stops
/// iterating, not because the rows stopped existing.
///
/// The tail's own ORDER is the comparator's remaining keys in the client and tree order here. It is
/// unobservable through the stock window — nothing iterates past `GetNumTrainerServices()`, and the
/// single index that ever reaches in is the selection's, resolved by identity — so the difference
/// costs nothing until something walks the tail.
///
/// **The filter and the collapse are deliberately asymmetric**, and that asymmetry is structural in
/// the client rather than incidental. The finalizer builds a per-group flag `hdr[+0x1c]` by walking
@@ -460,28 +503,36 @@ fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGr
/// (`hdr[+0x20]`), so a collapsed group keeps its header — which is what makes it re-expandable.
/// benilla had both cases keeping the header, with a test asserting it; 1124 inverted the filter
/// half.
fn rows(model: &Model) -> Vec<Row> {
fn rows(model: &Model) -> (Vec<Row>, usize) {
let Some(t) = model.trainer.as_ref() else {
return Vec::new();
return (Vec::new(), 0);
};
let mut out = Vec::new();
let mut shown = Vec::new();
let mut tail = Vec::new();
for (gi, g) in t.groups.iter().enumerate() {
let mut shown = g
let (passes, filtered): (Vec<usize>, Vec<usize>) = g
.services
.iter()
.copied()
.filter(|&si| model.trainer_filter[t.services[si].category.filter_slot()])
.peekable();
if shown.peek().is_none() {
continue;
.partition(|&si| model.trainer_filter[t.services[si].category.filter_slot()]);
if passes.is_empty() {
// The whole group is filtered away — header with it (1124's inverted half).
tail.push(Row::Header(gi));
} else {
shown.push(Row::Header(gi));
// A collapsed group keeps its header on screen and parks its services behind.
let into = if model.trainer_collapsed.contains(&g.key) {
&mut tail
} else {
&mut shown
};
into.extend(passes.into_iter().map(Row::Service));
}
out.push(Row::Header(gi));
if model.trainer_collapsed.contains(&g.key) {
continue;
}
out.extend(shown.map(Row::Service));
tail.extend(filtered.into_iter().map(Row::Service));
}
out
let visible = shown.len();
shown.extend(tail);
(shown, visible)
}
/// The service at a 1-based visible index, or `None` when that row is a **header** (or OOB / no
@@ -490,21 +541,72 @@ fn rows(model: &Model) -> Vec<Row> {
/// the same VISIBLE mapping, never a raw `services[]` position.
pub(super) fn service(model: &Model, index: usize) -> Option<&TrainerService> {
let n = index.checked_sub(1)?;
match rows(model).get(n)? {
match rows(model).0.get(n)? {
Row::Service(si) => model.trainer.as_ref()?.services.get(*si),
Row::Header(_) => None,
}
}
/// The count of visible rows (headers + unfiltered, uncollapsed services).
/// `GetNumTrainerServices`' answer — the count of rows **on screen** (headers + unfiltered,
/// uncollapsed services), which is the client's `ds:0xb73a18` and not the length of the row array
/// the indices run over ([`rows`]).
fn num_services(model: &Model) -> usize {
rows(model).len()
rows(model).1
}
/// The selected service's 1-based position in the row array ([`rows`]) — which is **past
/// `GetNumTrainerServices()` when that service is hidden**, and `None` only when nothing is selected
/// or the service has left the trainer's list altogether.
///
/// **The selection is the service, not the row it happens to sit on.** `SelectTrainerService`
/// (`0x4d8e60` → `0x4d74f0`) stores the record's **spell id** at `ds:0xb73a0c` — 0 when the index is
/// out of range, never a clamp — and this getter (`0x4d8f10` → `0x4d7520`) finds it again by linear
/// scan over the whole array, `cmp dword ptr [edi], esi` at `0x4d7543`, answering `-1` → Lua `0`
/// only when the scan runs out. Three separate things renumber the rows under a stored number — the
/// state filter, a collapse, a re-list — so an index stored on Monday is a different service on
/// Tuesday, and one merely *clamped* to the row count is the worst answer available: plausible, in
/// range, and wrong. benilla stored the number.
///
/// It is load-bearing far beyond bookkeeping, because the stock `Blizzard_TrainerUI.lua` repaints
/// its detail pane through exactly **one** door. `ClassTrainerTrainButton_OnClick` clears
/// `ClassTrainerFrame.showSkillDetails`, and `ClassTrainer_SetSelection` early-returns while that is
/// nil — so on `TRAINER_UPDATE` only the `GetTrainerSelectionIndex() > 1` **else** branch,
/// `ClassTrainer_SelectFirstLearnableSkill`, puts the flag back and rewrites the name, icon,
/// `Requires:` line and cost. Answering with a *live, visible* row number for a service that has
/// left the screen therefore takes the silent branch and leaves the pane describing the spell the
/// player just learned, under a highlight sitting on the row that slid up into its place — the
/// director's report.
///
/// The reference's own answer there is the hidden row's index, and its consequence is worth stating
/// because it is what a player sees: `> 1` is true, so the window resets the scroll, calls
/// `ClassTrainer_SetSelection` on a row nothing displays, and the update loop — which asks only
/// whether the selection is *on screen* — finds it is not and hides the detail pane. **After you
/// train a spell the reference blanks the pane and highlights nothing**; it does not advance to the
/// next spell. Answering `0` instead would select the first learnable and repaint, which is the
/// fresh-window behaviour, not the post-purchase one.
fn selected_row(model: &Model) -> Option<usize> {
let want = model.trainer_selection?;
let t = model.trainer.as_ref()?;
rows(model)
.0
.iter()
.position(|r| match r {
Row::Service(si) => t.services.get(*si).is_some_and(|s| s.spell_id == want),
Row::Header(_) => false,
})?
.checked_add(1)
}
/// Collapse (`collapse = true`) or expand a skill line by the **display index of its header row**
/// (decision 0247's `Collapse/ExpandTrainerSkillLine`). `id == 0` targets **all** groups (the
/// collapse-all button); `id > 0` resolves the header at that visible index to its skill line. A
/// non-header (or OOB) index is a no-op.
/// collapse-all button); `id > 0` resolves the header at that index to its skill line. A non-header
/// (or out-of-range) index is a no-op.
///
/// Like every other index in this surface it runs over the whole row array, so a header sitting in
/// the hidden tail resolves too ([`rows`]). That is the shape of the accessor gate the service
/// getters were carved to share (`0x4d89b0`, bounded by the total); these two bindings' own gate was
/// **not** carved, and the difference is unobservable through the stock window, which only ever
/// passes a visible header's index or `0`.
fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
if id == 0 && !collapse {
model.trainer_collapsed.clear();
@@ -517,7 +619,7 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
.map(|t| t.groups.iter().map(|g| g.key).collect())
.unwrap_or_default()
} else {
match rows(model).get(id - 1) {
match rows(model).0.get(id - 1) {
Some(Row::Header(gi)) => model
.trainer
.as_ref()
@@ -577,13 +679,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(n) = index.checked_sub(1) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let Some(row) = rows(&model).get(n).copied() else {
let Some(row) = rows(&model).0.get(n).copied() else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let t = model
.trainer
.as_ref()
.expect("a visible row ⇒ an open trainer");
.expect("a resolved row ⇒ an open trainer");
match row {
Row::Header(gi) => {
let g = &t.groups[gi];
@@ -622,13 +724,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(n) = index.checked_sub(1) else {
return Ok(Value::Nil);
};
let Some(Row::Service(si)) = rows(&model).get(n).copied() else {
let Some(Row::Service(si)) = rows(&model).0.get(n).copied() else {
return Ok(Value::Nil);
};
let t = model
.trainer
.as_ref()
.expect("a visible row ⇒ an open trainer");
.expect("a resolved row ⇒ an open trainer");
Ok(Value::String(
lua.create_string(&t.services[si].group_name)?,
))
@@ -835,41 +937,55 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SelectTrainerService(index) — set the engine-held selection (1-based filtered; 0/OOB clears it).
// SelectTrainerService(index) — select the SERVICE at that row, by its spell id. An index out
// of range clears the selection (`0x4d74f0`'s `cmp ecx, ds:0xb73a10` / `mov [0xb73a0c], 0` —
// cleared, never clamped); [`selected_row`] is why the id and not the row number.
//
// A HEADER row clears here too, where the reference would store the header record's own id,
// `0xffffffff` (`0x4d7b05`), and find the FIRST header again on the way back out — answering 1
// rather than 0. The two are the same answer to the only question the stock window asks of this
// number (`GetTrainerSelectionIndex() > 1`), and nothing else reads it.
g.set(
"SelectTrainerService",
lua.create_function(|lua, index: u32| {
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let count = num_services(&model) as u32;
model.trainer_selection = if index >= 1 && index <= count {
index
} else {
0
};
model.trainer_selection = service(&model, index).map(|s| s.spell_id);
Ok(())
})?,
)?;
// GetTrainerSelectionIndex() → the selected 1-based filtered row, or 0 if nothing is selected
// (the ref reads it as a plain number: `GetTrainerSelectionIndex() > 1`). Clamped to the current
// filtered count so a selection left over from a previous filter/trainer reads as 0.
// GetTrainerSelectionIndex() → the selected service's current 1-based row — **past
// `GetNumTrainerServices()` while that service is hidden**, and 0 only when nothing is selected
// or the service has left the list entirely ([`selected_row`] has the law and what rides on it).
g.set(
"GetTrainerSelectionIndex",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let count = num_services(&model) as u32;
let sel = model.trainer_selection;
Ok(i64::from(if sel >= 1 && sel <= count { sel } else { 0 }))
Ok(selected_row(&model).map_or(0i64, |r| r as i64))
})?,
)?;
// BuyTrainerService(index) — queue the selected filtered row's SPELL ID for purchase (the app
// sends CMSG_TRAINER_BUY_SPELL). Out of range → ignored.
// BuyTrainerService(index) — queue that row's SPELL ID for purchase (the app sends
// CMSG_TRAINER_BUY_SPELL). A header row, an out-of-range index, or an ALREADY-KNOWN row is
// silently ignored: the single-row path `0x4d89d0` resolves through the same total-bounded
// accessor gate as every getter, then refuses on the state byte — `0x4d89e1 cmp esi,ebx; je`
// (no record) and `0x4d89e5 cmp byte ptr [esi+0x30], bl; jne` (state != 0, i.e. anything but
// `available`), both jumping to the same do-nothing exit.
//
// Deliberately NOT built: the reference's buy-ALL convenience `0x4d8a70`, the one
// visible-count-bounded loop in the whole surface (`0x4d8a71`/`0x4d8a88` read `ds:0xb73a18`).
// The split `0x4da244 dec eax; 0x4da245 jns` is SIGNED, so it is every Lua index **<= 0** that
// lands there — `BuyTrainerService(0)` buys the player's entire visible list in one call. Here
// index 0 is a no-op instead. Nothing in the stock window passes a non-positive index, and a
// verb that spends the whole purse on a typo is not one to reproduce on the strength of that.
g.set(
"BuyTrainerService",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(spell_id) = service(&model, index).map(|s| s.spell_id) {
let buyable = service(&model, index)
.filter(|s| s.category == TrainerServiceCategory::Available)
.map(|s| s.spell_id);
if let Some(spell_id) = buyable {
model.trainer_buys.push(spell_id);
}
Ok(())
+128 -7
View File
@@ -211,9 +211,24 @@ fn state_filter_takes_a_groups_header_with_its_last_service() {
s.run("SetTrainerServiceTypeFilter('unavailable', 0)")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert!(s
.eval::<bool>("return GetTrainerServiceInfo(1) == nil")
.unwrap());
// …but the *rows* are still there behind it, and the getters still serve them (2231). This line
// asserted `GetTrainerServiceInfo(1) == nil` until the accessor gate was carved: the single one
// every service getter shares, `0x4d89b0`, bounds against the TOTAL `ds:0xb73a10`, and the
// visible count `ds:0xb73a18` has exactly five references image-wide — the finalizer seeding and
// decrementing it, the buy-ALL loop, and `GetNumTrainerServices`. No getter reads it. An empty
// window is empty because the Lua stops iterating, not because the rows stopped existing.
assert_eq!(
s.eval::<String>("return (GetTrainerServiceInfo(1))")
.unwrap(),
"Arms",
"row 1 is still the Arms header, now in the hidden tail"
);
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0,
"and nothing is selected, which is a different question from what row 1 holds"
);
}
/// Collapse is the *asymmetric* case, and deliberately so: it hides a group's services but keeps the
@@ -290,18 +305,31 @@ fn collapse_survives_a_content_update_and_resets_on_close() {
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
/// A buy queues the row's **spell id**, and only a green row can be bought — the single-row path
/// `0x4d89d0` resolves the index through the shared total-bounded gate and then refuses on the state
/// byte (`0x4d89e5 cmp byte ptr [esi+0x30], bl; jne`), so red and gray rows are silently dropped on
/// the floor rather than sent to the server (2231). This used to queue whatever the index resolved
/// to, which is how a `used` row could still put a `CMSG_TRAINER_BUY_SPELL` on the wire.
#[test]
fn buy_queues_the_selected_services_spell_id_headers_no_op() {
fn buy_queues_an_available_services_spell_id_and_refuses_every_other_row() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Row 5 is Bloodrage (spell 285). Buying it queues its spell id, not its index.
s.run("BuyTrainerService(5)").unwrap();
assert_eq!(s.take_trainer_buys(), vec![285]);
// Row 2 is Heroic Strike (spell 78, available). Buying it queues its spell id, not its index.
s.run("BuyTrainerService(2)").unwrap();
assert_eq!(s.take_trainer_buys(), vec![78]);
assert!(s.take_trainer_buys().is_empty(), "drained");
// Buying a HEADER row (index 1) queues nothing.
s.run("BuyTrainerService(1)").unwrap();
assert!(s.take_trainer_buys().is_empty(), "a header is not buyable");
// Nor does a gray row (Cleave, already known) or a red one (Bloodrage, gated).
s.run("BuyTrainerService(3)").unwrap();
s.run("BuyTrainerService(5)").unwrap();
assert!(
s.take_trainer_buys().is_empty(),
"only state byte 0 — available — reaches the wire"
);
}
#[test]
@@ -402,6 +430,99 @@ fn unresolved_skill_line_is_dropped() {
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
/// **The selection is the service, never the row it sits on** — and a hidden service keeps a row.
///
/// `SelectTrainerService` stores the record's spell id (`ds:0xb73a0c`, `0x4d74f0`) and
/// `GetTrainerSelectionIndex` finds it again by scanning the **whole** array (`0x4d7520`, bounded by
/// the total `ds:0xb73a10`, not the visible `ds:0xb73a18`). Filtering and collapsing only clear a
/// record's visible flag `[+0x34]` and sort it into a tail, so the answer for an off-screen
/// selection is an index *past* `GetNumTrainerServices()` — never a clamp, and never a live row
/// number belonging to some other service. benilla stored the row number and clamped it, which is
/// the director's "the info and img below don't update" report: see [`super::selected_row`].
#[test]
fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
let sel = |s: &mut UiScript| s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap();
let shown = |s: &mut UiScript| s.eval::<i64>("return GetNumTrainerServices()").unwrap();
let at = |s: &mut UiScript, i: i64| {
s.eval::<String>(&format!("return (GetTrainerServiceInfo({i})) or ''"))
.unwrap()
};
// Bloodrage sits at row 5 of `[H:Arms, Heroic Strike, Cleave, H:Fury, Bloodrage]`.
s.run("SelectTrainerService(5)").unwrap();
assert_eq!(sel(&mut s), 5);
assert_eq!(at(&mut s, 5), "Bloodrage");
// Hide the already-known state: Cleave leaves and everything under it slides up one. The row
// number 5 now names nothing on screen; the selection is still Bloodrage, at row 4.
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
assert_eq!(at(&mut s, 4), "Bloodrage");
assert_eq!(
sel(&mut s),
4,
"it followed its service, it did not stay put"
);
// Fold Bloodrage's group away. It is off screen but not gone: its record goes to the tail, so
// the answer is an index past the visible count — which is what sends the stock window down its
// "the selection is not on screen" path instead of silently repainting someone else's row.
s.run("CollapseTrainerSkillLine(3)").unwrap();
let folded = sel(&mut s);
assert!(
folded > shown(&mut s),
"a folded-away selection reads past the visible count, not as row {folded} of {}",
shown(&mut s)
);
s.run("ExpandTrainerSkillLine(3)").unwrap();
assert_eq!(sel(&mut s), 4, "and back on screen when the group unfolds");
// He learns it. The re-list brings it back gray and the filter hides it — the tail again, never
// the row number of whatever moved up into its place.
let mut learned = trainer();
learned.services[2].category = TrainerServiceCategory::Used;
s.set_trainer(Some(learned));
assert!(
sel(&mut s) > shown(&mut s),
"the learned spell is off screen, not at some live row"
);
// A service that has left the trainer's list altogether is the one case that reads 0.
let mut shorter = trainer();
shorter.services.remove(2);
s.set_trainer(Some(shorter));
assert_eq!(sel(&mut s), 0);
// A header row is not a selection, and neither is a row past the end.
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(1)").unwrap();
assert_eq!(sel(&mut s), 0, "row 1 is the Arms header");
}
/// A new `SMSG_TRAINER_LIST` resets the selection with the rest of the builder's state — `0x4d7560`
/// selects record 0 after the sort (`0x4d7b40 xor ecx,ecx` → `0x4d7b42 call 0x4d74f0`), which is
/// always a group header and so reads back as 1. Clearing is the same answer to the only question
/// the stock window asks (`GetTrainerSelectionIndex() > 1`, which 0 and 1 both fail); what matters
/// is that a re-opened trainer cannot inherit the last visit's selection.
#[test]
fn a_new_list_packet_clears_the_selection() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(5)").unwrap();
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
5
);
s.reset_trainer_list_state(0);
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0,
"the same spell is still in the list, and it is still not selected"
);
}
#[test]
fn clearing_empties_and_resets_selection() {
let mut s = UiScript::new().unwrap();