From 16abf643b12c0dc958db73b2591647da9c9e430f Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 15 Sep 2026 16:58:29 +0700 Subject: [PATCH] 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 --- crates/benilla-app/src/combat_text/law.rs | 18 ++ crates/benilla-app/src/combat_text/mod.rs | 213 ++++++++++++++-- crates/benilla-app/src/creature_anim.rs | 4 +- .../src/creature_anim/spell_visual.rs | 3 +- crates/benilla-app/src/entities.rs | 4 +- crates/benilla-app/src/entities/missile.rs | 186 +++++++++++--- crates/benilla-app/src/net/apply.rs | 1 + .../benilla-app/src/net/apply/combat_log.rs | 133 ++++++++-- crates/benilla-app/src/net/apply/spells.rs | 238 ++++++++++++++++-- crates/benilla-app/src/player/camera.rs | 37 ++- crates/benilla-app/src/target/click.rs | 11 +- crates/benilla-app/src/ui_chat/tests.rs | 84 ++++--- crates/benilla-app/src/ui_script/lifecycle.rs | 199 +++++++++++++-- crates/benilla-app/src/ui_script/mod.rs | 33 ++- .../src/ui_script/trainer_tests.rs | 118 ++++++++- crates/benilla-app/src/ui_trainer/mod.rs | 14 +- crates/benilla-app/src/vplates.rs | 14 ++ crates/benilla-ui/src/script/mod.rs | 24 +- crates/benilla-ui/src/script/model.rs | 13 +- crates/benilla-ui/src/script/nameplate.rs | 36 +++ crates/benilla-ui/src/script/pointer.rs | 7 + .../benilla-ui/src/script/tests/nameplate.rs | 55 +++- crates/benilla-ui/src/script/trainer/mod.rs | 220 ++++++++++++---- crates/benilla-ui/src/script/trainer/tests.rs | 135 +++++++++- 24 files changed, 1541 insertions(+), 259 deletions(-) diff --git a/crates/benilla-app/src/combat_text/law.rs b/crates/benilla-app/src/combat_text/law.rs index 3f354a6c..3be4afcf 100644 --- a/crates/benilla-app/src/combat_text/law.rs +++ b/crates/benilla-app/src/combat_text/law.rs @@ -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); diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index 34487f5d..7a3f2c59 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -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(v: &mut Vec, mut idx: Vec) { }); } +/// 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>, + self_guid: &crate::net::SelfGuid, + stores: &Query<&crate::net::ObjectStore>, +) -> Option { + 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, + self_player: Query<(), With>, + self_guid: Res, + stores: Query<&crate::net::ObjectStore>, + gates: Res, + spells: Option>, + mut text: MessageWriter, +) { + 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::() .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::() + .add_message::() + .init_resource::() + .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::>() + .drain() + .map(|s| (s.text, s.category, s.color)) + .collect::>() + }; + + 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. diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index c77825e8..44e741a7 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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 }, /// 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, diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 06a634ca..7ca516ae 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -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 diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 5485a23d..0d21f28f 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -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::() // The projectile flight-loop edges (`crate::sound::missile` consumes them). .add_message::() + // A travelling spell's DEFERRED outcome word (`crate::combat_text`, decision 2229). + .add_message::() // The cast router's dest one-shot orders (`dest_fx`, decision 0797). .add_message::() // A live display-id swap's rebuild edge — consumed by the morph-latch replay diff --git a/crates/benilla-app/src/entities/missile.rs b/crates/benilla-app/src/entities/missile.rs index f1e535a7..13067e66 100644 --- a/crates/benilla-app/src/entities/missile.rs +++ b/crates/benilla-app/src/entities/missile.rs @@ -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 { match code { 3 => Some(2), // SPELL_MISS_DODGE → the dispatch's DODGES state @@ -320,16 +382,34 @@ fn miss_defense_state(code: u8) -> Option { } } +/// **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, - defenses: &mut MessageWriter, + 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, - impacts: &mut MessageWriter, - defenses: &mut MessageWriter, + 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>, mut sounds: MessageWriter, - mut impacts: MessageWriter, - mut defenses: MessageWriter, + mut out: ArrivalOut, mut play_seq: ResMut, ) { 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, - mut defenses: MessageWriter, + mut out: ArrivalOut, mut sounds: MessageWriter, mut play_seq: ResMut, ) { 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::() .add_message::() .add_message::() - .add_message::(); + .add_message::() + .add_message::(); 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::>() + .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}" + ); } } diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index ca308d20..405ae9fd 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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, ( diff --git a/crates/benilla-app/src/net/apply/combat_log.rs b/crates/benilla-app/src/net/apply/combat_log.rs index 291c84fa..dd1acdf7 100644 --- a/crates/benilla-app/src/net/apply/combat_log.rs +++ b/crates/benilla-app/src/net/apply/combat_log.rs @@ -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::() + .add_message::() + .add_message::() + .init_resource::() + .init_resource::(); + 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::(); + index.0.insert(10, self_e); + index.0.insert(20, victim_e); + } + app.world_mut().resource_mut::().0 = Some(10); + + app.world_mut() + .run_system_once( + move |index: Res, + self_guid: Res, + stores: Query<&mut ObjectStore>, + mut text: MessageWriter, + mut feedback: MessageWriter, + mut center: MessageWriter| { + 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::>() + .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" + ); + } +} diff --git a/crates/benilla-app/src/net/apply/spells.rs b/crates/benilla-app/src/net/apply/spells.rs index 02a74364..ac9e71f6 100644 --- a/crates/benilla-app/src/net/apply/spells.rs +++ b/crates/benilla-app/src/net/apply/spells.rs @@ -379,6 +379,9 @@ pub(super) fn spell_go( pending: &mut PendingCast, queued_melee: &mut QueuedMeleeSpell, text: &mut MessageWriter, + // 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, // 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::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + 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::(); + index.0.insert(10, self_e); + index.0.insert(20, victim_e); + } + app.world_mut().resource_mut::().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, + casting: Query<&Casting>, + mut cast_events: MessageWriter, + mut go_targets: MessageWriter, + self_guid: Res, + stores: Query<&mut ObjectStore>, + mut cast_bar: ResMut, + mut pending: ResMut, + mut queued_melee: ResMut, + mut text: MessageWriter, + mut go_lid: MessageWriter, + mut cooldowns: ResMut, + mut pet_bar: ResMut, + mut items: ResMut, + mut sheath: MessageWriter| { + 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::>() + .drain() + .map(|s| (s.text, s.category, s.color)) + .collect::>() + }; + + 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(), ( diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 7b0b6a58..e347d94b 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -946,18 +946,33 @@ impl WorldMouse { /// gesture the world already has. pub(super) fn latch_world_mouse( buttons: Res>, - // **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, + // 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, mut rig: ResMut, cameras: Query<&Camera, With>, window: Single<&Window, With>, diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 1198ed2c..f271f1f1 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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, diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 666e368b..ed5edf35 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -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::(); @@ -2749,37 +2784,6 @@ fn the_chat_cache_restore_is_finished_before_player_login() { world.init_resource::(); crate::ui_script::setup_script(&mut world); - world - .non_send_resource::() - .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, diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 52a102c0..ed65bf5b 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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::() + .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::() 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::().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::() + .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 = Default::default(); + + assert!( + told.claim(world.non_send_resource::()), + "the window: a feed fires its login one-shot at the frameless character-screen VM" + ); + assert!( + !told.claim(world.non_send_resource::()), + "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::()), + "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*. /// diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index dd5a98cf..60aebd0e 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -1294,7 +1294,38 @@ mod pointer_arbiter_tests { ); assert!( !app.world().resource::().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::().0 = Some(7); + app.world_mut() + .resource_mut::() + .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::().0, + "the camera must yield the press to the plate" + ); + // …and `bindings`' wheel branch reads this one. + assert!( + !app.world().resource::().0, + "the wheel must still reach the world over a plate" ); } diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index b23cfe9c..6453abbe 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -856,7 +856,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() { .unwrap()); assert!(s.eval::("return GetNumTrainerServices()").unwrap() < 6); - s.reset_trainer_filter(0); + s.reset_trainer_list_state(0); assert!( !s.eval::("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::("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::(&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::("return ClassTrainerSkillHighlightFrame:IsVisible()") + .unwrap(), + "nothing is highlighted either — the selection is off screen, not on Cleave" + ); + assert!( + s.eval::("return GetTrainerSelectionIndex()").unwrap() + > s.eval::("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()); +} diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index 6f75528a..9e83f151 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -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( diff --git a/crates/benilla-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index f4d2296c..7b514acd 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -433,6 +433,8 @@ struct PlateWorld<'w, 's> { camera: Query<'w, 's, (&'static Camera, &'static Transform), With>, // The cursor, for the plate-rect hover (OnEnter — the yellow name, this frame's rects). window: Query<'w, 's, &'static Window, With>, + // 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); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index bae5ea92..ba84ceec 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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 diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 01b70744..9b5b1cb2 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, pub(crate) trainer_buys: Vec, - pub(crate) trainer_selection: u32, + pub(crate) trainer_selection: Option, 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(), diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index 54e0a56b..bf20dde7 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -239,10 +239,28 @@ pub(crate) struct NamePlates { /// Completed clicks on plates, drained by the app each frame /// ([`UiScript::take_nameplate_clicks`]). clicks: Vec, + /// **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, } +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::().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. /// diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 1179dccd..418222fc 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -211,6 +211,13 @@ impl UiScript { // `` 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) }) diff --git a/crates/benilla-ui/src/script/tests/nameplate.rs b/crates/benilla-ui/src/script/tests/nameplate.rs index 90eefabf..56b110be 100644 --- a/crates/benilla-ui/src/script/tests/nameplate.rs +++ b/crates/benilla-ui/src/script/tests/nameplate.rs @@ -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::("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" + ); +} diff --git a/crates/benilla-ui/src/script/trainer/mod.rs b/crates/benilla-ui/src/script/trainer/mod.rs index 50221d2b..45c441bd 100644 --- a/crates/benilla-ui/src/script/trainer/mod.rs +++ b/crates/benilla-ui/src/script/trainer/mod.rs @@ -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