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