afk and dnd work, the stun swirl shows, and the world drop is back

- /afk and /dnd send instead of printing Unknown chat type: the flag sets, <AFK> shows over your head at once, the four echo lines are the client's own, and moving clears AFK under the real autoClearAFK CVar (2082, 2088)
- the stun swirl and the other state-kit visuals show — a cache the login map change cleared lost them nine runs in ten — and a state kit's animation id is the base recompute the reference runs, never a play, so the stray Stun one-shot is gone (2085)
- dropping an item on the world brings up the destroy prompt again: since the stock WorldFrame landed, the world is a frame, and the drop's gate had been written for the absence of one (2089, B380)
- the mouse no longer snaps to the window's centre when the loading cover drops on /logout; the cover hands the pointer back only where it knows it is (2090)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:23:48 +07:00
co-authored by Claude Fable 5.1
parent 6e3483c1e7
commit b81e481348
28 changed files with 2039 additions and 116 deletions
@@ -247,6 +247,7 @@ fn app_with_chains() -> App {
app.add_message::<crate::creature_anim::SpellKitFx>();
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
app.add_message::<crate::creature_anim::BaseAnimRecompute>();
// The water-plane twin map the alpha author composes with (empty here — no water in a fixture).
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
+29
View File
@@ -667,6 +667,34 @@ pub(crate) struct EmoteAnim {
pub(crate) seq: u64,
}
/// A **state kit's animation id, which is a comparison and never a play** — `PlaySpellVisualKit`'s
/// stage-2 leg (VERIFIED, wow-re `state-kit-anim-and-stun-pose.md` §1/§2; decision 2085).
///
/// `0x60edf0`'s tail has exactly one site that hands a kit's `+0x8` to the play primitive
/// (`0x60f3c5 call 0x5fe2f0`), and **stage 2 is diverted around it**: `0x60f387 jne` takes the
/// stage-2 leg, which compares the unit's currently-armed id (`0x5fdb50` — upper-body key bone
/// preferred, bone 0 as the fallback) against `kit.AnimID` and either does nothing (`je 0x60f3ca`,
/// already playing it) or runs `0x5fd9e0(unit, -1)` — a **base-animation recompute** — before
/// leaving the block for good. Both `SpellVisual` field-4 consumers hardcode stage 2 (the aura
/// watcher `0x5ff4c6` and the impact hand-off `0x61dced`), so no path in the image ever plays a
/// state kit's anim.
///
/// It is therefore an animation-**cutting** mechanism, and the cut is not incidental: Charge
/// (22911) plays `Knockdown`(121) from its impact kit and then cuts it with its own state kit's
/// `Stun`(14), because 121 ≠ 14 forces the recompute. That is also why no stun ever shows the
/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (its literal
/// outputs are enumerated in the note), so the recompute lands on `Stand`.
///
/// The driver holds the comparison because the armed id lives there. An aura **refresh** in place
/// emits nothing: the watcher `0x604d00` fires neither arm when a slot is rewritten with the same
/// id and a non-zero flags nibble, which our per-unit armed-id diff reproduces by construction.
#[derive(Message, Clone, Copy)]
pub(crate) struct BaseAnimRecompute {
pub(crate) entity: Entity,
/// The state kit's `AnimID` — the right-hand side of `0x60f390`'s compare, never played.
pub(crate) anim_id: u16,
}
/// Rear up a rider's mount (decision 0441 P2): resolved to a [`EmoteAnim`] one-shot of
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — the §5-verified
/// routing (the flourish plays on the mount model; the rider holds Mount(91) throughout).
@@ -1138,6 +1166,7 @@ impl Plugin for CreatureAnimPlugin {
.init_resource::<PlaySeq>()
.init_resource::<gesture::GestureQueue>()
.add_message::<EmoteAnim>()
.add_message::<BaseAnimRecompute>()
.add_message::<MountFlourish>()
.add_message::<WoundAnim>()
.add_message::<SheathSwapMessage>()
+42 -3
View File
@@ -35,8 +35,8 @@ use super::select::{
};
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
use super::{
find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, CastHold, DefenseAnim,
EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, SheathRequest,
find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, BaseAnimRecompute, CastHold,
DefenseAnim, EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, SheathRequest,
SheathSwapMessage, SwingImpact, SwingMessage, SwingSlowdown, Wielded, WoundAnim,
};
@@ -357,6 +357,9 @@ pub(super) fn drive_animations(
// headless test worlds that build no net seam; a missing cache reads exactly like a
// template we have not received, which is the reference's own null-record leg.
Option<Res<crate::names::NameCache>>,
// The **stage-2 base recomputes** ([`BaseAnimRecompute`]) — in the tuple for the same
// reason as its neighbours: this system sits on the 16-SystemParam ceiling.
MessageReader<BaseAnimRecompute>,
),
// The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream,
// shared by every play; [`select::msvc_rand`]).
@@ -365,7 +368,7 @@ pub(super) fn drive_animations(
// diff-only filter; see the trace block after the mode machine).
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
) {
let (emote_sounds, loot_kneel, time, names) = aux;
let (emote_sounds, loot_kneel, time, names, mut recomputes) = aux;
let dt = time.delta_secs();
// This frame's one-shot PLAY CALLS (swings + anim-emotes), gathered per unit and replayed
// in the client's call order below ([`PlaySeq`] stamps — the net drain stamps packet order,
@@ -428,6 +431,13 @@ pub(super) fn drive_animations(
.or_default()
.push((OneShotReq::Emote(e.anim_id), e.seq));
}
// This frame's **stage-2 base recomputes** — a state kit's anim id, which is never played
// ([`BaseAnimRecompute`]). Last wins: the reference makes the comparison per kit play, and two
// in one frame leave the second's verdict standing.
let mut pending_recompute: bevy::ecs::entity::EntityHashMap<u16> = default();
for r in recomputes.read() {
pending_recompute.insert(r.entity, r.anim_id);
}
// This frame's sheath requests, keyed by unit (a later request replaces an earlier — the
// client's last SetSheatheState call wins).
let mut pending_sheath: bevy::ecs::entity::EntityHashMap<SheathRequest> = default();
@@ -1214,6 +1224,35 @@ pub(super) fn drive_animations(
}
}
}
// ── The **stage-2 base recompute** (`0x60f389`–`0x60f399`, decision 2085): a state kit
// that names an animation compares it against the id the unit is already playing
// (`0x5fdb50` — the upper-body key bone if it holds one, else bone 0) and, on a
// difference, runs `0x5fd9e0(unit, -1)`. It never plays the id, and on a match it does
// nothing at all.
//
// It runs HERE, after this frame's one-shot arms, because that ordering is the whole
// observable effect: the impact kit (stage 1) plays first and the state kit (stage 2)
// then cuts it. Charge (22911) is the case that names itself — `Knockdown`(121) from kit
// 348, cut by kit 349's `Stun`(14) because 121 ≠ 14.
//
// Expressed as the re-selection, not a re-arm: clearing the gait target IS the recompute
// (decision 1655), and dropping `Mode::Swing` is what ends a one-shot. **Named residual:**
// a unit inside a Special (a pose, an airborne arc) is left alone — the reference's
// recompute re-enters the selector chain, which would land back in the same state, and
// forcing `Mode::Gait` here would replay the pose's enter clip instead.
if let Some(&want) = pending_recompute.get(&entity) {
let armed = drv.overlay.map(|ov| ov.id).or_else(|| {
tr.get_main_animation()
.and_then(|n| anims.clips.iter().find(|c| c.node == n))
.map(|c| c.anim_id)
});
if armed != Some(want) && matches!(drv.mode, Mode::Swing { .. } | Mode::Gait) {
drv.deferred = None; // a normal arm clears the cache (`0x5fe48e`)
drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame
}
}
// ── **The mode machine** — the base track's whole decision, lifted into [`mode`]
// (decision 0933). It is the one phase of this pass with a clean input boundary: a fixed
// set of already-computed facts in, the driver + player + transitions out, and none of
@@ -175,6 +175,7 @@ fn app() -> App {
.add_message::<crate::creature_anim::DefenseAnim>()
.add_message::<crate::creature_anim::SwingSlowdown>()
.add_message::<EmoteAnim>()
.add_message::<super::super::BaseAnimRecompute>()
.add_message::<WoundAnim>()
.add_message::<SheathRequest>()
.add_message::<SheathSwapMessage>();
@@ -4178,3 +4179,105 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
pending trail arm survives"
);
}
/// The **stage-2 base recompute** (decision 2085): a state kit's animation id is compared against
/// what the unit is already playing (`0x5fdb50`) and, on a difference, spent on
/// `0x5fd9e0(unit, -1)` — never played. So the recompute is an animation *cutter*, and the case
/// that names it is a spell cutting its own impact clip: Charge's kit 348 plays `Knockdown`(121),
/// kit 349 names `Stun`(14), and 121 ≠ 14 ends the Knockdown.
///
/// The match arm is the other half and is not decoration: `0x60f393 je 0x60f3ca` leaves the block
/// having done nothing at all, so a state kit naming the clip already running must not restart or
/// cut it.
mod stage_two_recompute {
use super::*;
use crate::creature_anim::Mode;
fn model() -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(121, 2, false), // Knockdown — the impact kit's one-shot
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
fn victim(app: &mut App) -> Entity {
app.world_mut()
.spawn((
model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
))
.id()
}
/// A one-shot is armed, then a state kit naming a DIFFERENT id lands: the clip ends.
#[test]
fn a_differing_state_kit_anim_cuts_the_one_shot() {
let mut app = app();
let unit = victim(&mut app);
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 121,
seq: 1,
});
app.update();
assert!(
matches!(
app.world().entity(unit).get::<AnimDriver>().unwrap().mode,
Mode::Swing { id: 121, .. }
),
"the impact kit's Knockdown holds the base slot"
);
app.world_mut()
.write_message(crate::creature_anim::BaseAnimRecompute {
entity: unit,
anim_id: 14, // Stun — what kit 349 names, and never plays
});
app.update();
assert_eq!(
app.world().entity(unit).get::<AnimDriver>().unwrap().mode,
Mode::Gait,
"121 != 14 recomputes the base, which is what ends the Knockdown"
);
}
/// …and a state kit naming the id already playing does nothing at all.
#[test]
fn a_matching_state_kit_anim_leaves_the_one_shot_alone() {
let mut app = app();
let unit = victim(&mut app);
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 121,
seq: 1,
});
app.update();
app.world_mut()
.write_message(crate::creature_anim::BaseAnimRecompute {
entity: unit,
anim_id: 121,
});
app.update();
assert!(
matches!(
app.world().entity(unit).get::<AnimDriver>().unwrap().mode,
Mode::Swing { id: 121, .. }
),
"`je 0x60f3ca` — already playing it, so the leg does nothing"
);
}
}
@@ -22,7 +22,9 @@ use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
use benilla_assets::{LockRecover, WorldAssets};
use super::{CastEvent, CastEventKind, CastHold, EmoteAnim, SpellGoTargets, WoundAnim};
use super::{
BaseAnimRecompute, CastEvent, CastEventKind, CastHold, EmoteAnim, SpellGoTargets, WoundAnim,
};
/// The client's literal missile-model fallback when the visual chain resolves to nothing
/// (`0x860c9c` — a checkerboard cube, shipped in the real MPQs; faithful, not a joke).
@@ -531,11 +533,12 @@ type KitSpawnWriters<'w> = (
MessageWriter<'w, ChainProcPlay>,
MessageWriter<'w, SpellKitShake>,
MessageWriter<'w, crate::weapon_trail::TrailArm>,
MessageWriter<'w, BaseAnimRecompute>,
);
/// The writer set one discrete kit play fans out to — bundled so [`play_kit`] threads through the
/// router's arms as one argument (each writer keeps its own system-param lifetime).
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7> {
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7, 'w8> {
oneshots: &'a mut MessageWriter<'w1, EmoteAnim>,
wounds: &'a mut MessageWriter<'w2, WoundAnim>,
sounds: &'a mut MessageWriter<'w3, SpellKitSound>,
@@ -547,6 +550,9 @@ struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7> {
/// The kit's weapon-trail ARM, if its `CharProc` slots name one — the dispatcher's type-8
/// case (2076). A latch, not a play: nothing is drawn until the unit's next animation.
trails: &'a mut MessageWriter<'w7, crate::weapon_trail::TrailArm>,
/// The **stage-2** base recompute — what a state kit's anim id spends itself on instead of
/// playing ([`BaseAnimRecompute`], decision 2085).
recomputes: &'a mut MessageWriter<'w8, BaseAnimRecompute>,
}
/// One kit played as a **discrete event** on a unit — the client's `PlaySpellVisualKit`
@@ -571,13 +577,21 @@ struct KitPlay {
sound: bool,
/// The stage this play is, for the instances' animation lifecycle ([`FxStage`]).
stage: FxStage,
/// Whether the kit tail's anim branch may lay a wound for an `AnimID ∈ [8,10]` — the
/// client's `0x60f383`: a **stage-2** (state) play runs the base recompute only and never
/// reaches the `[8,10]` test, so a state kit naming a wound anim wounds nobody (wow-re
/// `charproc-rate-override-wound-gate.md` §7 (A), decision 2063). The other exclusion there —
/// stage 4 with a zero cast time — never reaches this branch in benilla: a precast kit's anim
/// becomes the [`CastHold`], not a `play_kit` call.
wound: bool,
/// Is this the **stage-2** (state-kit) play? It is the client's `0x60f383
/// cmp [ebx+0x10],2`, and it forks the whole anim branch — which is why it is the stage and
/// not, as it was until decision 2085, a "may this lay a wound" flag.
///
/// Stage 2 is **foreclosed from the play site**: `0x60f387 jne` diverts it around the sole
/// `0x60f3c5 call 0x5fe2f0` that ever hands a kit's `AnimID` to the animation primitive, and
/// its leg leaves the block with `jmp 0x60f3ca` (VERIFIED, wow-re
/// `state-kit-anim-and-stun-pose.md` §1). So a state kit neither plays its anim nor reaches
/// the `[8,10]` wound test (the half decision 2063 already had): the id is only ever the
/// right-hand side of a comparison against what the unit is already playing, and a mismatch
/// spends itself on a base recompute ([`BaseAnimRecompute`]).
///
/// The other exclusion at `0x60f3a0` — stage 4 with a zero cast time — never reaches this
/// branch in benilla: a precast kit's anim becomes the [`CastHold`], not a `play_kit` call.
stage_2: bool,
}
impl KitPlay {
@@ -588,7 +602,7 @@ impl KitPlay {
effects: true,
sound: true,
stage: FxStage::OneShot,
wound: true,
stage_2: false,
};
}
@@ -602,12 +616,13 @@ fn play_kit(
out: &mut KitOut,
) {
if let Some(anim_id) = kit.anim_id {
if (8..=10).contains(&anim_id) {
if play.stage_2 {
// Stage 2: a COMPARISON, never a play ([`KitPlay::stage_2`]). The driver holds the
// armed id, so it makes the comparison and spends the mismatch on the recompute.
out.recomputes.write(BaseAnimRecompute { entity, anim_id });
} else if (8..=10).contains(&anim_id) {
// `0x60f3b8: push 0; call 0x60ea70` — severity 0, the kit's own id is NOT what plays.
// A state-stage play never gets here in the client ([`KitPlay::wound`]).
if play.wound {
out.wounds.write(WoundAnim { entity });
}
out.wounds.write(WoundAnim { entity });
} else {
out.oneshots.write(EmoteAnim {
entity,
@@ -722,7 +737,8 @@ fn play_impact(
|s: &VisualStages| s.state,
KitPlay {
effects: false,
wound: false,
stage_2: true,
stage: FxStage::State,
..KitPlay::DISCRETE
},
),
@@ -773,12 +789,13 @@ pub(super) fn route_cast_visuals(
};
// Disjoint field borrows: the beam writer rides `out` for the whole body while the missile and
// burst writers stay free for the GO loop below.
let (missiles, bursts, chains, shakes, trails) = (
let (missiles, bursts, chains, shakes, trails, recomputes) = (
&mut spawns.0,
&mut spawns.1,
&mut spawns.2,
&mut spawns.3,
&mut spawns.4,
&mut spawns.5,
);
let mut out = KitOut {
oneshots: &mut oneshots,
@@ -788,6 +805,7 @@ pub(super) fn route_cast_visuals(
chain: chains,
shakes,
trails,
recomputes,
};
// The `holds` query is one command-flush stale: an instant cast's START and GO drain from
@@ -1318,6 +1336,7 @@ pub(crate) fn arm_aura_state_fx(
mut fx: MessageWriter<SpellKitFx>,
mut procs: MessageWriter<AuraProc>,
mut sounds: MessageWriter<SpellKitSound>,
mut recomputes: MessageWriter<BaseAnimRecompute>,
mut armed: Local<EntityHashMap<Vec<u32>>>,
) {
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed())
@@ -1369,7 +1388,11 @@ pub(crate) fn arm_aura_state_fx(
.char_procs()
.filter_map(crate::aura_visual::node_for)
.collect();
if effects.is_empty() && nodes.is_empty() && kit.sound.is_none() {
if effects.is_empty()
&& nodes.is_empty()
&& kit.sound.is_none()
&& kit.anim_id.is_none()
{
continue; // a state kit that does nothing we model — nothing to arm or reap
}
if !effects.is_empty() {
@@ -1397,6 +1420,15 @@ pub(crate) fn arm_aura_state_fx(
if let Some(kit_sound) = kit.sound {
sounds.write(SpellKitSound::Play { entity, kit_sound });
}
// …and the ADD edge's stage-2 leg: the kit's anim id is a COMPARISON against what the
// unit is already playing, spent on a base recompute when they differ and on nothing
// when they agree ([`BaseAnimRecompute`], decision 2085). It is emitted only on this
// edge, which is the reference's own shape: an aura REFRESHED in place rewrites its
// slot with the same id and a non-zero flags nibble, and `0x604d00` fires neither of
// its two arms for that — no re-spawn, no re-arm, no recompute.
if let Some(anim_id) = kit.anim_id {
recomputes.write(BaseAnimRecompute { entity, anim_id });
}
next.push(spell_id);
}
*prev = next;
@@ -1729,6 +1761,7 @@ pub(super) fn replay_morph_kit(
mut chain: MessageWriter<ChainProcPlay>,
mut shakes: MessageWriter<SpellKitShake>,
mut trails: MessageWriter<crate::weapon_trail::TrailArm>,
mut recomputes: MessageWriter<BaseAnimRecompute>,
) {
for swap in swaps.read() {
// Consume-and-clear even when the kit resolves to nothing — the reference clears
@@ -1751,6 +1784,7 @@ pub(super) fn replay_morph_kit(
chain: &mut chain,
shakes: &mut shakes,
trails: &mut trails,
recomputes: &mut recomputes,
};
play_kit(
swap.entity,
@@ -45,6 +45,7 @@ fn app() -> App {
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
@@ -207,6 +208,7 @@ fn precast_kit_sound_rings_once_at_start() {
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
@@ -493,7 +495,8 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
app.add_message::<crate::aura_visual::AuraProc>();
app.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>();
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>();
app.init_resource::<FxLog>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables_with_paths(
HashMap::from([(
@@ -634,6 +637,7 @@ fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() {
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
@@ -741,6 +745,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
@@ -854,6 +859,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
@@ -1343,6 +1349,7 @@ fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> {
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
@@ -1535,3 +1542,187 @@ fn a_shooter_with_no_ranged_weapon_resolves_no_clip_at_all() {
"and no pull to hold"
);
}
/// **A state kit's animation id is a comparison, never a play** (decision 2085; VERIFIED wow-re
/// `state-kit-anim-and-stun-pose.md` §1): `0x60edf0`'s tail has exactly one site that hands a
/// kit's `AnimID` to the animation primitive (`0x60f3c5 call 0x5fe2f0`) and `0x60f387 jne`
/// diverts stage 2 around it. Both `SpellVisual` field-4 consumers hardcode stage 2 — the aura
/// watcher and this one, the impact hand-off `0x61dced` — so the state kit's id is only ever the
/// right-hand side of `0x60f390`'s compare, spent on a base recompute.
///
/// The subject is the real Silithus chain, because it is also the case that names the mechanism:
/// a Dredge Striker's **Charge** (22911 → visual 3783) plays `Knockdown`(121) from impact kit 348
/// and then **cuts its own Knockdown** with state kit 349's `Stun`(14), 121 ≠ 14 forcing the
/// recompute. Before this, benilla played the 14 as a second one-shot — a `Stun` pose the
/// reference never shows on anything.
#[test]
fn a_state_kits_anim_is_a_recompute_and_never_a_second_play() {
const CHARGE: u32 = 22911;
const CHARGE_VISUAL: u32 = 3783;
const IMPACT_KIT: u32 = 348;
const STATE_KIT: u32 = 349;
const KNOCKDOWN: u16 = 121;
const STUN: u16 = 14;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<KitPush>()
.add_message::<EmoteAnim>()
.add_message::<WoundAnim>()
.add_message::<SpellKitSound>()
.add_message::<SpellKitShake>()
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([(
CHARGE_VISUAL,
VisualStages {
impact: IMPACT_KIT,
state: STATE_KIT,
..Default::default()
},
)]),
HashMap::from([
(
IMPACT_KIT,
VisualKit {
anim_id: Some(KNOCKDOWN),
..Default::default()
},
),
(
STATE_KIT,
VisualKit {
anim_id: Some(STUN),
..Default::default()
},
),
]),
)));
app.insert_resource(crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(HashMap::from([(
CHARGE,
SpellDisplay {
visual: CHARGE_VISUAL,
..Default::default()
},
)])),
..crate::ui_action::Spells::empty_for_tests()
});
app.add_systems(Update, route_cast_visuals);
let caster = app.world_mut().spawn_empty().id();
let victim = app.world_mut().spawn_empty().id();
app.world_mut()
.write_message(cast_event(caster, CHARGE, CastEventKind::Go));
app.world_mut().write_message(SpellGoTargets {
caster,
spell_id: CHARGE,
hits: vec![victim],
misses: Vec::new(),
dest: None,
ammo_display_id: None,
seq: 1,
});
app.update();
let plays: Vec<(Entity, u16)> = app
.world_mut()
.resource_mut::<Messages<EmoteAnim>>()
.drain()
.map(|e| (e.entity, e.anim_id))
.collect();
assert_eq!(
plays,
vec![(victim, KNOCKDOWN)],
"the impact kit plays; the state kit must not add a second one-shot"
);
let recomputes: Vec<(Entity, u16)> = app
.world_mut()
.resource_mut::<Messages<super::BaseAnimRecompute>>()
.drain()
.map(|r| (r.entity, r.anim_id))
.collect();
assert_eq!(
recomputes,
vec![(victim, STUN)],
"the state kit's id is spent on the stage-2 recompute instead"
);
}
/// A state kit whose **whole visual is its animation id** still arms (decision 2085). The aura
/// watcher's entry test asks "does this kit do anything we model?", and the anim was missing from
/// it — the B114 shape one level over, where an effects-only test dropped Stealth's proc-only kit.
/// 15 of the shipped state kits are anim-only (`benilla-extract kitanim`), among them kit 586's
/// `Stun`(14) — Sneezing Fit, Smoke Bomb and kin.
#[test]
fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() {
use benilla_protocol::messages::ObjectFields;
const SPELL: u32 = 6902; // Sneezing Fit
const VISUAL: u32 = 517;
const STATE_KIT: u32 = 586; // anim 14, no effect slot, no sound, no CharProc
const STUN: u16 = 14;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>()
.add_message::<crate::aura_visual::AuraProc>()
.add_message::<SpellKitSound>()
.add_message::<super::BaseAnimRecompute>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([(
VISUAL,
VisualStages {
state: STATE_KIT,
..Default::default()
},
)]),
HashMap::from([(
STATE_KIT,
VisualKit {
anim_id: Some(STUN),
..Default::default()
},
)]),
)));
app.insert_resource(crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(HashMap::from([(
SPELL,
SpellDisplay {
visual: VISUAL,
..Default::default()
},
)])),
..crate::ui_action::Spells::empty_for_tests()
});
app.add_systems(Update, super::arm_aura_state_fx);
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(47, SPELL),
(95, 0x0E),
])))
.id();
app.update();
let recomputes: Vec<(Entity, u16)> = app
.world_mut()
.resource_mut::<Messages<super::BaseAnimRecompute>>()
.drain()
.map(|r| (r.entity, r.anim_id))
.collect();
assert_eq!(
recomputes,
vec![(unit, STUN)],
"an anim-only state kit is not nothing — its ADD edge owes a recompute"
);
}
+10
View File
@@ -221,6 +221,16 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1667: that host has not resolved since 2019, so shipping it makes every first launch a \
DNS failure; benilla dials the machine it is running on",
),
// The implicit AFK clear (2088) — a REAL 1.12 CVar, byte-read off its own registration
// (`0x5e24d4 push 0x82e748`, handle taken from the store AFTER the call at `0x5e24ef` into
// `[0xc4d68c]`, whose single reader `0x5eb84b` tests `[cvar+0x28]` for non-zero; wow-re
// `ui/scratch/afk-dnd-command-law.md` §10). Registered default `"1"`.
//
// It gates FIVE implicit clears, not one: any chat send whose type is not `0x14` (which is why
// `/dnd` clears AFK before marking), plus Jump, forward/back, strafe and turn
// (`0x513d36`/`0x514e23`/`0x514f0b`/`0x514fca`). With the CVar off the clear is a **total**
// no-op — no echo, no mirror write, no packet.
same("autoClearAFK", "1"),
same("MasterVolume", "1"),
same("SoundVolume", "1"),
same("MusicVolume", "0.4"),
+6 -17
View File
@@ -40,11 +40,9 @@ use bevy::prelude::*;
use crate::creature_anim::{SpellKitSound, SpellVisuals};
use crate::net::{NetEntity, ObjectStore};
use benilla_assets::m2_url;
use benilla_protocol::EntityKind;
use super::spell_fx::{attach_effect_visuals, SpellFx};
use super::{DisplayModel, ModelHandle};
use super::spell_fx::{attach_effect_visuals, ensure_model, SpellFx};
/// The client's hardcoded shard-model table (`0x870e24`, 7 entries — wow-re
/// `dynobject-visual-machine.md` Q-A1). `CharParamZero`'s decoded small int indexes it.
@@ -274,18 +272,20 @@ pub(super) fn attach_ground_fx_models(
mut commands: Commands,
time: Res<Time>,
mut instances: Query<(Entity, &mut GroundFx, Option<&mut AnimationPlayer>)>,
fx: Option<Res<SpellFx>>,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(fx) = fx else { return };
let Some(mut fx) = fx else { return };
let now = time.elapsed_secs();
for (entity, mut inst, player) in &mut instances {
if !inst.spawned {
ensure_model(&mut fx, &asset_server, &inst.path);
let Some(dm) = fx.models.get(&inst.path) else {
continue;
continue; // unreachable — just inserted
};
if !attach_effect_visuals(
&mut commands,
@@ -334,17 +334,6 @@ pub(super) fn attach_ground_fx_models(
}
}
/// Create the shared model-cache entry (the missile/kit pattern) so the M2 load starts the
/// frame the effect is armed.
fn ensure_model(fx: &mut SpellFx, asset_server: &AssetServer, path: &str) {
fx.models
.entry(path.to_string())
.or_insert_with(|| DisplayModel {
handle: ModelHandle::M2(asset_server.load(m2_url(path))),
..super::empty_shell()
});
}
#[cfg(test)]
mod tests {
use super::*;
+8 -6
View File
@@ -71,7 +71,7 @@ use crate::creature_anim::{
use benilla_assets::m2_url;
use super::equipment::ItemDisplays;
use super::spell_fx::{attach_effect_visuals, EffectHost, SpellFx};
use super::spell_fx::{attach_effect_visuals, ensure_model, EffectHost, SpellFx};
use super::{BoneAttach, DisplayModel, ModelHandle};
/// The anim-event idents that release queued missiles — the dispatcher's drain arms (`0x5ffbd0`:
@@ -596,25 +596,27 @@ pub(super) fn spawn_missiles(
pub(super) fn attach_missile_models(
mut commands: Commands,
mut missiles: Query<(Entity, &mut Missile)>,
fx: Option<Res<SpellFx>>,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
time: Res<Time>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(fx) = fx else {
let Some(mut fx) = fx else {
return;
};
for (entity, mut missile) in &mut missiles {
if missile.parts_spawned {
continue;
}
let Some(key) = &missile.path else {
let Some(key) = missile.path.clone() else {
continue; // unreachable (spawn sets parts_spawned) — but never look up a None key
};
let Some(dm) = fx.models.get(key) else {
continue; // unreachable — the spawn created the cache entry
ensure_model(&mut fx, &asset_server, &key);
let Some(dm) = fx.models.get(&key) else {
continue; // unreachable — just inserted
};
// The one shared effect-visuals body (`spell_fx::attach_effect_visuals`): the rig (the
// fireball's constant bone keys rotate its authored frame into flight-forward, its
+108 -9
View File
@@ -98,6 +98,33 @@ pub(crate) struct SpellFx {
pub(crate) models: HashMap<String, DisplayModel>,
}
/// Get-or-insert this path's cache entry, starting its M2 load on the spot — **the only way any
/// site may reach [`SpellFx::models`]**, read or write.
///
/// [`super::evict_display_caches`] clears every display cache on a `MapChange`, and its doc states
/// the contract that makes that safe: *"these caches are get-or-insert at every use site, so a
/// cleared entry rebuilds on the next spawn that wants it."* This cache was the one that broke it.
/// Its three consumers looked entries up with a bare `get` and each carried a comment calling the
/// miss unreachable — "created with the instance", "the spawn created the cache entry" — because
/// the entry *is* created with the instance, one system earlier. What none of them could see is
/// that an eviction lands **between** the two, and the entry is never re-created: only a fresh
/// `SpellKitFx::Begin` inserts, and an aura already sitting in its slots never emits another.
///
/// The map flip that does it is the ordinary one: `world_map::announce_map_change` fires on the
/// login `0 → 1`, ~0.4–1.7 s into every session, which is exactly when a body that entered the
/// world already carrying an aura is arming that aura's state kit. A **persistent** instance
/// stranded there is silent and permanent — the `PENDING_TIMEOUT` reaper is gated
/// `!inst.persistent`, so it is never spawned, never expired, and never traced. Measured at 9 runs
/// in 10 (decision 2085; bug: the stun swirl not showing).
pub(super) fn ensure_model(fx: &mut SpellFx, asset_server: &AssetServer, path: &str) {
fx.models
.entry(path.to_string())
.or_insert_with(|| DisplayModel {
handle: ModelHandle::M2(asset_server.load(m2_url(path))),
..super::empty_shell()
});
}
/// The live **per-instance tint clones**: an effect part whose M2Color RGB animates gets its own
/// material clone at attach (one cast = one phase — the doodad lane's shared-clock registry
/// [`benilla_world::doodad_anim::TintAnimMaterials`] would run every Battle Shout on one global pulse),
@@ -733,12 +760,7 @@ pub(super) fn resolve_spell_fx(
&mut commands,
&mut emitters,
);
fx.models
.entry(path.clone())
.or_insert_with(|| DisplayModel {
handle: ModelHandle::M2(asset_server.load(m2_url(path))),
..super::empty_shell()
});
ensure_model(&mut fx, &asset_server, path);
instances.push(FxInstance {
spell_id: *spell_id,
persistent: *persistent,
@@ -906,7 +928,8 @@ pub(super) fn attach_spell_fx(
Option<&mut benilla_world::rig_anim::RigPose>,
&GlobalTransform,
)>,
fx: Option<Res<SpellFx>>,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
spells: Option<Res<crate::ui_action::Spells>>,
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
@@ -915,7 +938,7 @@ pub(super) fn attach_spell_fx(
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
mut emitters: Query<&mut benilla_world::particles::ParticleEmitter>,
) {
let Some(fx) = fx else {
let Some(mut fx) = fx else {
return;
};
let now = time.elapsed_secs();
@@ -986,8 +1009,9 @@ pub(super) fn attach_spell_fx(
if !inst.persistent && inst.expires.is_none() {
inst.expires = Some(now + PENDING_TIMEOUT);
}
ensure_model(&mut fx, &asset_server, &inst.path);
let Some(dm) = fx.models.get(&inst.path) else {
return true; // cache entry pending (shouldn't happen — created with the instance)
return true; // unreachable — just inserted
};
if dm.parts.is_none() {
return true; // model still loading — spawn on a later pass
@@ -1190,6 +1214,7 @@ mod tests {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<WowModelMaterial>()
.init_asset::<benilla_assets::M2Model>()
.init_asset::<SkinnedMeshInverseBindposes>()
.init_resource::<SpellFx>()
.init_resource::<FxTintAnims>()
@@ -1390,4 +1415,78 @@ mod tests {
send(&mut app, begin(unit, SPURT, tag, "Spells\\Ring.mdx"));
assert_eq!(slots_of(&app, unit), vec![(SPURT, tag), (SPURT, tag)]);
}
/// The stun swirl that never appeared (decision 2085): a kit instance whose cache entry was
/// wiped by the login `MapChange` between its `Begin` and its spawn pass.
///
/// The fixture is the aftermath, not the race — an instance holding a path the cache no longer
/// knows, which is what a session that entered the world already carrying an aura looks like
/// ~0.4–1.7 s in. Before [`ensure_model`] guarded the read, the spawn pass returned "still
/// pending" for ever: nothing re-created the entry (only a fresh `SpellKitFx::Begin` inserts,
/// and a standing aura emits no second one), and a *persistent* instance is exempt from the
/// `PENDING_TIMEOUT` reaper, so it was never spawned, never expired and never traced.
///
/// The assertions are the two halves of that: the entry comes back, **and** the instance is
/// still there to use it — reaping it instead would make the failure quiet rather than fixed.
#[test]
fn an_evicted_cache_entry_is_rebuilt_by_the_spawn_pass() {
const PATH: &str = "Spells\\StunSwirl_State_Head.mdx";
let mut app = App::new();
app.add_plugins((
MinimalPlugins.build().disable::<bevy::time::TimePlugin>(),
AssetPlugin::default(),
))
.init_asset::<WowModelMaterial>()
.init_asset::<benilla_assets::M2Model>()
.init_asset::<bevy::mesh::skinning::SkinnedMeshInverseBindposes>()
.init_resource::<Time>()
.init_resource::<SpellFx>()
.init_resource::<FxTintAnims>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.add_systems(Update, attach_spell_fx);
let unit = app
.world_mut()
.spawn((
GlobalTransform::default(),
FxAttached {
instances: vec![FxInstance {
spell_id: 9032,
persistent: true,
class: FxClass::AuraState,
stage: FxStage::State,
tag: 0x14,
effect: 50,
path: PATH.to_string(),
root: None,
expires: None,
decaying: false,
}],
},
))
.id();
assert!(
app.world().resource::<SpellFx>().models.is_empty(),
"the fixture is a cache the eviction already cleared"
);
app.update();
assert!(
app.world().resource::<SpellFx>().models.contains_key(PATH),
"the spawn pass must get-or-insert its entry, not read and give up"
);
assert_eq!(
app.world()
.entity(unit)
.get::<FxAttached>()
.expect("the unit keeps its instance list")
.instances
.len(),
1,
"the instance must survive to use the rebuilt entry — a persistent one is never reaped \
for being pending"
);
}
}
+186 -9
View File
@@ -102,7 +102,7 @@ use bevy::input::mouse::{
};
use bevy::math::DVec2;
use bevy::prelude::*;
use bevy::window::{CursorMoved, PrimaryWindow};
use bevy::window::{CursorLeft, CursorMoved, PrimaryWindow};
use super::LoadingScreen;
@@ -118,15 +118,52 @@ pub(crate) struct CoverInput;
///
/// Re-read every covered frame, not stashed once: `bevy_winit` keeps writing the window's position
/// on every physical move regardless of whether anyone reads the `CursorMoved` message we drain,
/// so this tracks the pointer through the whole load and hands back the *current* place on the way
/// out. Without the hand-back the position would stay `None` until the player's next mouse move —
/// hover would come back dead after a teleport, which is the same defect in the other direction.
/// so this tracks the pointer through the whole load and hands it back on the way out. Without the
/// hand-back the position would stay `None` until the player's next mouse move — hover would come
/// back dead after a teleport, and the first click would land nowhere, which is the same defect in
/// the other direction.
///
/// # `set_physical_cursor_position` MOVES THE MOUSE (decision 2090)
///
/// The load-bearing fact, because the name does not say it and getting it wrong shipped a bug for
/// months: **`Window::set_physical_cursor_position` is not "tell bevy where the pointer is", it is
/// "put the pointer there".** It writes `Window.internal.physical_cursor_position`, and
/// `bevy_winit::system::changed_windows` turns *any* change of that field to a `Some` into
/// `winit_window.set_cursor_position(…)` — `CGWarpMouseCursorPosition` on macOS, a real hardware
/// warp. There is no bookkeeping-only setter; this field is bevy's cursor-warp API, and winit is
/// its only other writer.
///
/// So the hand-back cannot simply write what it stashed. It used to, and the pointer was yanked
/// across the screen on every cover drop: measured on 2026-09-08 across a live `/logout`, the
/// restore wrote `(811.9, 1242.6)` physical and the hardware cursor teleported from `(308, 588)`
/// to `(406, 621)` points in the same millisecond. The stash goes stale over exactly the frames a
/// cover is up for — a world teardown is one ~283 ms frame during which no events are pumped at
/// all — so the pointer landed wherever it had been when the world went away, which after a click
/// on the centred game menu's Logout is the middle of the window.
///
/// **The law, therefore: never write a position we do not know to be true right now.** Which is
/// [`swallow_input_under_the_cover`]'s three-way hand-back:
///
/// - Winit already has a position (the field reads `Some`) → it is fresher than anything stashed,
/// so leave it alone. This is the case a moving mouse always takes: the events queued during a
/// hitch are delivered *before* the frame that drops the cover.
/// - The pointer has left the window ([`left`](Self::left)), or the window does not have the
/// keyboard → write nothing. We have no business moving a pointer that is over somebody else's
/// window.
/// - Otherwise nothing has moved since we stashed it, so the stash *is* where the pointer is and
/// writing it back moves nothing.
#[derive(Resource, Default)]
pub(crate) struct CoveredPointer {
/// The last position seen while covered, in physical pixels.
stashed: Option<DVec2>,
/// Is the window's cursor position currently ours (blanked) rather than winit's?
blanked: bool,
/// Has winit said the pointer left the window since [`stashed`](Self::stashed) was taken?
///
/// The one thing the window's own field cannot tell us: a `None` there is either winit's
/// `CursorLeft` or our blank, and the two must not be confused — restoring the stash over the
/// first would warp the pointer back *into* the window from wherever the player took it.
left: bool,
}
/// One frame's swallow of one button plane.
@@ -220,16 +257,40 @@ fn swallow_input_under_the_cover(
screen: Res<LoadingScreen>,
mut pointer: ResMut<CoveredPointer>,
mut window: Query<&mut Window, With<PrimaryWindow>>,
mut departures: MessageReader<CursorLeft>,
mut buttons: Buttons,
mut raw: RawInput,
) {
// **Winit's last word on the pointer, read before anything below can overwrite it.** The
// window's position field already reflects the LAST cursor event of this frame (bevy_winit
// writes it as it dispatches, in order, before the update runs), so `Some` here is the live
// truth and outranks the stash. A `None` is ambiguous — winit's `CursorLeft` or our own blank —
// which is the whole reason [`CoveredPointer::left`] exists. Drained every frame, covered or
// not, so the reader never hands a covered frame a departure from two frames ago.
let departed = departures.read().count() > 0;
let here = window
.single()
.ok()
.and_then(|w| w.physical_cursor_position());
if here.is_some() {
pointer.left = false;
} else if departed {
pointer.left = true;
}
if !screen.covering() {
// The way out: hand the window its pointer back, once, on the frame the cover drops.
// The way out: hand the window its pointer back, once, on the frame the cover drops — and
// only when that hand-back is TRUE, because the write is a hardware warp (see
// [`CoveredPointer`]). Winit's own fresher answer wins; a pointer that has left the window,
// or a window that does not have the keyboard, gets nothing.
if pointer.blanked {
pointer.blanked = false;
let stashed = pointer.stashed.take();
let left = std::mem::take(&mut pointer.left);
if let Ok(mut window) = window.single_mut() {
window.set_physical_cursor_position(stashed);
if here.is_none() && !left && window.focused {
window.set_physical_cursor_position(stashed);
}
}
}
return;
@@ -242,10 +303,13 @@ fn swallow_input_under_the_cover(
// the load (see [`CoveredPointer`]); `physical_cursor_position()` already answers `None` for a
// pointer outside the window, which is the same nothing we are about to write.
if let Ok(mut window) = window.single_mut() {
if let Some(seen) = window.physical_cursor_position() {
if let Some(seen) = here {
pointer.stashed = Some(seen.as_dvec2());
}
if window.physical_cursor_position().is_some() {
// Blanking is safe where restoring is not: `changed_windows` guards its warp on the
// *clamped* getter answering `Some`, so writing `None` re-caches without touching the
// hardware pointer. Only when there is something to blank — an unconditional write
// would deref-mut `Window` every covered frame, and a spurious `Changed<Window>` is a
// surface reconfigure (`video::apply_present_mode`'s note).
window.set_physical_cursor_position(None);
}
pointer.blanked = true;
@@ -437,4 +501,117 @@ mod tests {
"the position is handed back on the frame the cover drops"
);
}
/// **The hand-back never invents a position** (decision 2090). Writing the window's cursor
/// position is a hardware warp, so the cover may only write one it knows is still true — which
/// is the difference between handing the pointer back and dragging it across the player's
/// screen. Three cases, one harness: winit's fresher answer wins; a departed pointer and an
/// unfocused window get nothing; a still pointer gets its stash.
#[test]
fn the_hand_back_never_writes_a_position_it_does_not_know_to_be_true() {
/// Cover, blank, then drop the cover under `arrange` — answering: what does the cover write
/// into the window on the way out?
fn round_trip(arrange: impl FnOnce(&mut App, Entity)) -> Option<Vec2> {
let mut app = App::new();
app.add_plugins((
bevy::input::InputPlugin,
bevy::window::WindowPlugin {
primary_window: None,
exit_condition: bevy::window::ExitCondition::DontExit,
..default()
},
))
.init_resource::<LoadingScreen>()
.init_resource::<CoveredPointer>()
.add_systems(
PreUpdate,
swallow_input_under_the_cover
.in_set(CoverInput)
.after(bevy::input::InputSystems),
);
let window = app
.world_mut()
.spawn((
Window {
resolution: bevy::window::WindowResolution::new(800, 600),
..default()
},
PrimaryWindow,
))
.id();
let put = |app: &mut App, at: Option<DVec2>| {
app.world_mut()
.entity_mut(window)
.get_mut::<Window>()
.unwrap()
.set_physical_cursor_position(at);
};
// A pointer in the window, then the cover: one covered frame stashes and blanks it.
put(&mut app, Some(DVec2::new(400.0, 300.0)));
app.world_mut().resource_mut::<LoadingScreen>().active = true;
app.update();
assert!(
app.world()
.entity(window)
.get::<Window>()
.unwrap()
.physical_cursor_position()
.is_none(),
"the cover blanked the position"
);
app.world_mut().resource_mut::<LoadingScreen>().active = false;
arrange(&mut app, window);
app.update();
app.world()
.entity(window)
.get::<Window>()
.unwrap()
.physical_cursor_position()
}
assert_eq!(
round_trip(|_, _| {}),
Some(Vec2::new(400.0, 300.0)),
"a pointer that never moved gets its stash back — writing it warps nothing, because \
that is where the pointer already is"
);
// Winit spoke first: bevy_winit writes the window's field as it dispatches the frame's
// events, so a `Some` on the drop frame is the live pointer. Overwriting it with the stash
// is exactly the warp — this is the case a moving mouse always takes, and the one that
// dragged the cursor to the middle of the window on every `/logout` before 2090.
assert_eq!(
round_trip(|app, window| {
app.world_mut()
.entity_mut(window)
.get_mut::<Window>()
.unwrap()
.set_physical_cursor_position(Some(DVec2::new(120.0, 90.0)));
}),
Some(Vec2::new(120.0, 90.0)),
"winit's fresher answer is left alone, never clobbered with the stash"
);
assert_eq!(
round_trip(|app, window| {
app.world_mut().write_message(CursorLeft { window });
}),
None,
"a pointer the player took out of the window is not dragged back into it"
);
assert_eq!(
round_trip(|app, window| {
app.world_mut()
.entity_mut(window)
.get_mut::<Window>()
.unwrap()
.focused = false;
}),
None,
"a window without the keyboard does not move the system pointer at all"
);
}
}
+18
View File
@@ -370,6 +370,8 @@ pub(crate) fn drive_nameplates(
Option<&InheritedVisibility>,
)>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
// The optimistic AFK mirror (`[0xb6e5cc]`) — the own-player `<AFK>` override, 2088.
mirror: Res<crate::ui_chat::AfkMirror>,
// The show-gate inputs (one tuple param — Bevy's 16-param ceiling).
gates: (
Res<Selection>,
@@ -533,6 +535,22 @@ pub(crate) fn drive_nameplates(
} else {
0
};
// **The own-player AFK override** (2088; wow-re `afk-dnd-command-law.md` §8, which settled
// what `overhead-name.md` had only INFERRED). The AFK slot `0x5ec9e0` — and only that slot
// — carries a pre-gate: if the subject's GUID is the active player's AND the optimistic
// mirror `[0xb6e5cc]` is non-zero, the `<AFK>` tag emits **regardless of the flag bit**
// (`0x5ec9fd`/`0x5eca04 jne 0x5eca12`, jumping past the `0x5eca0c test byte [ecx+8],0x2`).
// That is what puts `<AFK>` over your own head the instant you type `/afk`, a round trip
// before the descriptor confirms it. DND and GM have no such path — pure bit tests.
//
// Folded into `flags` rather than passed alongside, so `lines_current`'s in-place compare
// and `flag_prefix`'s build cannot disagree about it: the differential test that pins
// those two to each other keeps holding for free.
let flags = if is_self && mirror.is_afk() {
flags | 0x2
} else {
flags
};
let sub = if net.kind == EntityKind::Unit {
benilla_protocol::guid::entry(guid.0)
.and_then(|e| names.creature_subname(e))
+14 -1
View File
@@ -16,13 +16,18 @@ use crate::ui_script::UiInput;
mod ace_gate_tests;
/// The world broadcasts (`SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`) — the
/// AreaTable/ServerMessages resolve and the joined-defense-channel walk they land on.
mod away;
mod broadcast;
mod channels;
/// The combat log's chat lines (B297) — classification, chat type, and the GlobalString key each
/// combat packet's sentence is built from.
pub(crate) mod combat;
pub(crate) mod commands;
mod edit;
/// The chat edit box's send path — `SendType`, the chat-type TOKEN an addon or the stock
/// `SlashCmdList` passes to `SendChatMessage`, and its map to a wire kind. `pub(crate)` because
/// that token→wire seam is what `ui_script::chat_tests` spans: the stock file emitting the token
/// and this module resolving it are two halves that each pass alone while the join is cut (2082).
pub(crate) mod edit;
mod event;
mod feed;
mod frames;
@@ -36,6 +41,7 @@ mod settings;
#[cfg(test)]
mod tests;
pub(crate) use away::AfkMirror;
pub(crate) use broadcast::Broadcast;
/// The joined-channel roster + the `ChatChannels.dbc` catalog. Read outside this module by the
/// world-state readout ([`crate::world_state_ui`]), whose `Type == 1` gate is "has the player
@@ -54,6 +60,8 @@ pub(crate) struct UiChatPlugin;
impl Plugin for UiChatPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ChatLog>()
.init_resource::<away::AfkMirror>()
.init_resource::<away::AfkMirrorMemo>()
.init_resource::<frames::ChatWindows>()
.init_resource::<edit::ChannelState>()
.init_resource::<channels::ZoneChannelWalk>()
@@ -127,6 +135,11 @@ impl Plugin for UiChatPlugin {
.add_systems(
Update,
(
// The AFK mirror's reconcile runs FIRST in the chain, so a `/afk` typed this
// frame reads the descriptor's settled state rather than racing it (2088).
away::reconcile_afk_mirror,
// …and the four movement clears, which share the CVar and the mirror.
away::movement_clears_afk,
input::drain_chat_input,
// An addon's own line into the wire (decision 1199). AFTER the box's drain
// and in the same chain, so a `SendChatMessage` fired from a slash handler
+520
View File
@@ -0,0 +1,520 @@
//! **`/afk` and `/dnd` — the client-side command law** (decision 2088; wow-re
//! `ui/scratch/afk-dnd-command-law.md`, a §5 trio incl. one cold derivation + orchestrator
//! arbitration).
//!
//! The two commands look like a pair and are not one. `SendChatMessage 0x49f1e0` handles them
//! **asymmetrically**, and the reference's own string pool shows why: the DND keys live in the chat
//! translation unit, the AFK keys in `Player_C.h`'s.
//!
//! - **DND** (`0x49f3de`-`0x49f591`) is inline, reads `PLAYER_FLAGS` bit `0x4` **live**, and falls
//! through to the generic `CMSG_MESSAGECHAT` send.
//! - **AFK** (`0x49f4f3`-`0x49f562`) reads the client-side mirror [`AfkMirror`] — *not* the
//! descriptor bit — and returns, delegating to `CGPlayer_C::SetAFK 0x5eb740` /
//! `ClearAFK 0x5eb830`, which build their own packets.
//!
//! **The echo is OPTIMISTIC, never descriptor-driven** (§8): the print and the mirror write are
//! straight-line before the send. `0x5ee990`'s `test al,0xe` arm — the `PLAYER_FLAGS` delta arm
//! that carries AFK/DND/GM — calls only `0x6c78f0` (name invalidation) and `0x468550` between
//! `0x5ee9c0` and `0x5ee9f8`: no string resolve, no chat sink. So a client that waited for the
//! server's flag to come back before printing would be wrong, and would also print nothing at all
//! when the send is dropped.
//!
//! **All four lines are `CHAT_MSG_SYSTEM`** (`0x49a870` with `edx = 0xa`; `[0x8062e0 + 4*0xa]` =
//! 238, name slot `0xbe1550` ← `"CHAT_MSG_SYSTEM"`), whose yellow is the **engine's** own
//! (`.rdata 0x8049ae` = `ff ff 00`), not a FrameXML `ChatTypeInfo` colour. The incoming events
//! `CHAT_MSG_AFK`/`DND` (249/250) are a different thing entirely — those are *other* players'
//! auto-replies, and never these.
use bevy::prelude::*;
use super::event::{ChatEvent, ChatEventKind};
use super::feed::ChatLog;
/// **`[0xb6e5cc]` — the write-through optimistic mirror of the local player's `PLAYER_FLAGS` AFK
/// bit**, reconciled by the descriptor. Authoritative for the client's own toggle decision, never
/// for anyone else's state.
///
/// Its full 11-site census is wow-re `afk-dnd-command-law.md` §8. Written optimistically at command
/// time (`SetAFK 0x5eb7ae` ← 1, `ClearAFK 0x5eb885` ← 0), reconciled from the descriptor on every
/// local `PLAYER_FLAGS` delta (`0x5ee9f2`), re-seeded at world enter (`0x4989c0`, §9 — which is
/// **not** the `/afk` toggle, a correction that round made to `overhead-name.md`). Consumed by the
/// overhead-name AFK tag as the own-player override, and by the `/afk` toggle itself.
///
/// **`u32`, not `bool`, and never compared against 1.** The five writers store **three** truthy
/// values — `1` (`0x4989eb`, `0x5eb7ae`), `0` (`0x4989f7`, `0x5eb885`) and **`2`** (`0x5ee9f2`
/// stores `PLAYER_FLAGS & 2`). All six readers are pure non-zero tests, so the reference never
/// notices; a re-implementation that types this `bool` or tests `== 1` diverges at the first
/// descriptor-driven set. That is a stated hazard in §8, and this type is the answer to it.
///
/// **There is no DND mirror** — no global is written on the DND path, which is why [`dnd_line`]
/// takes the live descriptor bit and [`afk_line`] takes this. The observable asymmetry is real and
/// faithful: a second `/dnd` typed before the server's update arrives re-marks, while a second
/// `/afk` in the same window clears.
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AfkMirror(pub(crate) u32);
impl AfkMirror {
/// The only question anything asks of it — every one of the reference's six readers is a
/// non-zero test, so this is the whole API and `== 1` is never written anywhere.
pub(crate) fn is_afk(self) -> bool {
self.0 != 0
}
}
/// What a `/afk` or `/dnd` resolves to: the line to print (if any), the mirror's new value (AFK
/// only), and the body to put on the wire.
///
/// A struct rather than three returns because the truth table's whole content is which of them move
/// together — notably `/afk M` **while already AFK**, which prints nothing and leaves the mirror
/// alone but still sends (`0x5eb76e` jumps past both the echo and the flag write, not past the
/// packet).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AwayOutcome {
/// The `CHAT_MSG_SYSTEM` line, or `None` for the one row that prints nothing.
pub(crate) line: Option<String>,
/// The mirror's value afterwards. `None` = unchanged (every DND row, and `/afk M` while AFK).
pub(crate) mirror: Option<u32>,
/// The message body the `CMSG_MESSAGECHAT` carries — **after** the client's own default
/// substitution, which is why it is returned rather than reusing the caller's string.
pub(crate) body: String,
}
/// Resolve `/afk <msg>` against the mirror — wow-re §3/§4/§5 and the §12 truth table.
///
/// | `msg` | mirror before | line | mirror after |
/// |---|---|---|---|
/// | `""` | clear | `MARKED_AFK_MESSAGE` % `DEFAULT_AFK_MESSAGE` | 1 |
/// | `""` | set | `CLEARED_AFK` | 0 |
/// | `M` | clear | `MARKED_AFK_MESSAGE` % `M` | 1 |
/// | `M` | set | *(nothing)* | unchanged |
///
/// **The default text is the CLIENT's and is substituted BEFORE the send** (§2): the server
/// receives the literal `"Away from Keyboard"`, never an empty body, so vmangos stores that as the
/// auto-reply. `SetAFK` substitutes again for a NULL message (`0x5eb761`) — belt and braces in the
/// reference, one substitution here.
///
/// `MARKED_AFK_MESSAGE` carries **no** trailing period (`"You are now AFK: %s"`); its sibling
/// `MARKED_AFK` (the no-`%s` variant `GlobalStrings.lua` also defines) has **zero occurrences in
/// the image** and is dead — do not reach for it when the message is empty, because the default
/// substitution means it never is.
pub(crate) fn afk_line(
msg: &str,
mirror: AfkMirror,
strings: &impl Fn(&str) -> Option<String>,
) -> AwayOutcome {
let text = |key: &str| strings(key).unwrap_or_default();
if mirror.is_afk() {
if msg.is_empty() {
// The toggle off. `ClearAFK 0x5eb830` prints CLEARED_AFK unformatted and sends an
// EMPTY body — the packet still goes, it just carries nothing.
AwayOutcome {
line: Some(text("CLEARED_AFK")),
mirror: Some(0),
body: String::new(),
}
} else {
// **Re-marking while AFK is silent** — `0x5eb76e` jumps past the echo AND the mirror
// write, but not past the send: the server still learns the new auto-reply text. This
// row is the one that most tempts a re-implementer into printing something.
AwayOutcome {
line: None,
mirror: None,
body: msg.to_string(),
}
}
} else {
let body = if msg.is_empty() {
text("DEFAULT_AFK_MESSAGE")
} else {
msg.to_string()
};
AwayOutcome {
line: Some(fill_one(&text("MARKED_AFK_MESSAGE"), &body)),
mirror: Some(1),
body,
}
}
}
/// Resolve `/dnd <msg>` against the **live** `PLAYER_FLAGS` bit `0x4` — wow-re §6 and §12.
///
/// | `msg` | DND before | line |
/// |---|---|---|
/// | `""` | clear | `MARKED_DND` % `DEFAULT_DND_MESSAGE` |
/// | `""` | set | `CLEARED_DND` |
/// | `M` | clear | `MARKED_DND` % `M` |
/// | `M` | set | `MARKED_DND` % `M` — **re-prints**, unlike AFK |
///
/// `MARKED_DND` is `"You are now DND: %s."` — **the period is inside the format string**, which is
/// why it must be filled rather than composed; writing `"You are now DND: " + msg + "."` here would
/// be right in enUS and wrong in every locale that moved it.
pub(crate) fn dnd_line(
msg: &str,
is_dnd: bool,
strings: &impl Fn(&str) -> Option<String>,
) -> AwayOutcome {
let text = |key: &str| strings(key).unwrap_or_default();
if is_dnd && msg.is_empty() {
return AwayOutcome {
line: Some(text("CLEARED_DND")),
mirror: None,
body: String::new(),
};
}
let body = if msg.is_empty() {
text("DEFAULT_DND_MESSAGE")
} else {
msg.to_string()
};
AwayOutcome {
line: Some(fill_one(&text("MARKED_DND"), &body)),
mirror: None,
body,
}
}
/// The implicit AFK clear every **other** chat send carries — `0x49f3c7`/`0x49f3d6`, gated on
/// `autoClearAFK` and on the mirror being set, and skipped for chat type `0x14` alone (which is
/// why `/dnd`, being type `0x15`, clears AFK first — that is the whole reason the reference's own
/// capture shows three lines for two typed commands).
///
/// Returns the `CLEARED_AFK` line when it fires; the caller also sends the empty `0x14` packet and
/// zeroes the mirror, then goes on to send the message's own packet.
///
/// **It runs before the refusal arms, not after** — `0x49f3c7`/`0x49f3d6` sit above `0x49f592`, so
/// a ghost typing `/say` clears AFK and *then* gets `ERR_CHAT_WHILE_DEAD`. (One worker in the §5
/// round put this the other way round; the orchestrator's byte arbitration rejected it — §13.)
pub(crate) fn auto_clear_line(
mirror: AfkMirror,
auto_clear_afk: bool,
strings: &impl Fn(&str) -> Option<String>,
) -> Option<String> {
(auto_clear_afk && mirror.is_afk()).then(|| strings("CLEARED_AFK").unwrap_or_default())
}
/// Fill a one-`%s` GlobalString. `%%` is an escaped percent; a template with no `%s` comes back
/// unchanged, which is the honest answer for a locale that dropped the slot.
fn fill_one(template: &str, arg: &str) -> String {
let mut out = String::with_capacity(template.len() + arg.len());
let mut chars = template.chars().peekable();
let mut used = false;
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
match chars.next() {
Some('%') => out.push('%'),
Some('s') if !used => {
out.push_str(arg);
used = true;
}
Some(other) => {
out.push('%');
out.push(other);
}
None => out.push('%'),
}
}
out
}
/// Push one of the four lines into the chat log as `CHAT_MSG_SYSTEM`.
pub(crate) fn push_system(chat: &mut ChatLog, line: String) {
chat.push_event(ChatEvent::text_only(ChatEventKind::System, line));
}
/// The mirror's own last-seen `PLAYER_FLAGS`, so the reconcile below can diff like the reference's
/// watcher does. `None` = no local player held (logged out, or not yet in the world).
#[derive(Resource, Default)]
pub(crate) struct AfkMirrorMemo(Option<u32>);
/// **Reconcile the mirror from our own descriptor** — `0x5ee990`'s arm 1, and the world-enter
/// reseed (`0x4989c0`), which are the only two things that ever correct an optimistic write.
///
/// Its own system rather than a branch in the unit feed because that is the reference's own shape:
/// the write lives in the `PLAYER_FLAGS` delta handler, keyed on its own diff.
///
/// **The `& 0xE` gate is load-bearing, and dropping it is a real bug rather than a simplification.**
/// Arm 1 runs only when AFK, DND or GM *changed* (`0x5ee9ba a8 0e test al,0xe`), and only then
/// writes `PLAYER_FLAGS & 2` (`0x5ee9ef`/`0x5ee9f2`). A reconcile that instead wrote on *any*
/// flags delta would clobber the optimistic `1` back to `0` the first time an unrelated bit moved
/// — walk into an inn while AFK and the resting bit alone would drop your `<AFK>` a round trip
/// early. The whole point of the mirror is that it is allowed to lead the descriptor (§8: "the
/// mirror can lead the descriptor for exactly one round trip"), and this gate is what protects
/// that window.
///
/// The value written is `flags & 0x2` — so **`2`, not `1`** — which is exactly why [`AfkMirror`]
/// is a `u32` tested for non-zero and never compared against `1` (§8's stated encoding hazard).
pub(crate) fn reconcile_afk_mirror(
self_q: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
mut mirror: ResMut<AfkMirror>,
mut memo: ResMut<AfkMirrorMemo>,
) {
let Some(store) = self_q.iter().next() else {
// No local player: the world-exit half of the world-enter reseed. Forgetting the memo is
// what makes the next login re-seed rather than diff against the last body's flags.
memo.0 = None;
return;
};
let flags = store.0.player_flags();
match memo.0.replace(flags) {
// World enter (`0x4989c0`): seed from the descriptor outright, no diff to make.
None => mirror.0 = flags & 0x2,
// The delta arm: only an AFK/DND/GM move reconciles.
Some(prev) if (prev ^ flags) & 0xE != 0 => mirror.0 = flags & 0x2,
Some(_) => {}
}
}
/// The **four movement clears** — `autoClearAFK`'s other call sites (wow-re §10):
/// `Jump 0x513d36`, forward/back `0x514e23`, strafe `0x514f0b`, keyboard-turn `0x514fca`. All four
/// share one idiom — `if (OBJECT_FIELD_TYPE & TYPEMASK_PLAYER) ClearAFK(obj, 0)` sitting right
/// after the same function's stand-up call — so they are one rule here, not four.
///
/// **The press EDGE, and the keyboard's**: these sit in the movement *emitters*, which run off the
/// command, not off displacement. So mouse-look turning does not clear AFK (it never reaches
/// `0x514f50`), and neither does being knocked, feared or driven by a spline — you have to press
/// something. `binds` is the binding dispatch, so this follows a rebound key and already carries
/// the typing gate (`WASD` typed into the chat box is not movement).
///
/// Jump is in the set here where it is deliberately **out** of the follow-cancel set
/// (`player::follow`'s `move_start`, whose six commands are these minus JUMP) — two different
/// reference tables that happen to overlap, not one shared idea.
pub(crate) fn movement_clears_afk(
binds: Res<crate::bindings::BindingsState>,
cvars: Res<crate::cvars::CvarPersist>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
commands: Res<crate::net::NetCommands>,
mut chat: ResMut<ChatLog>,
mut mirror: ResMut<AfkMirror>,
) {
use crate::bindings::cmd;
if !mirror.is_afk() || cvars.stored("autoClearAFK").is_some_and(|v| v == "0") {
return;
}
// The inline-array idiom `player::follow`'s `move_start` uses — its six, plus JUMP.
let moved = [
cmd::JUMP,
cmd::MOVE_FORWARD,
cmd::MOVE_BACKWARD,
cmd::STRAFE_LEFT,
cmd::STRAFE_RIGHT,
cmd::TURN_LEFT,
cmd::TURN_RIGHT,
]
.iter()
.any(|&c| binds.just_pressed(c));
if !moved {
return;
}
let line = script
.and_then(|s| super::combat::global_string(&s, "CLEARED_AFK"))
.unwrap_or_default();
push_system(&mut chat, line);
mirror.0 = 0;
// The same empty `0x14` the command path sends — `ClearAFK` builds its own packet.
let _ = commands.0.send(crate::net::ClientCommand::Chat {
kind: crate::net::ChatKind::Afk,
target: None,
text: String::new(),
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The enUS strings, exactly as the reference install's `GlobalStrings.lua` ships them —
/// **including `MARKED_DND`'s trailing period, which lives INSIDE the format string** and is
/// the reason these are filled rather than composed.
fn strings(key: &str) -> Option<String> {
Some(
match key {
"MARKED_AFK_MESSAGE" => "You are now AFK: %s",
"CLEARED_AFK" => "You are no longer AFK.",
"MARKED_DND" => "You are now DND: %s.",
"CLEARED_DND" => "You are no longer marked DND.",
"DEFAULT_AFK_MESSAGE" => "Away from Keyboard",
"DEFAULT_DND_MESSAGE" => "Do not Disturb",
_ => return None,
}
.to_string(),
)
}
/// **wow-re `afk-dnd-command-law.md` §12, transcribed.** Every row, including the three that a
/// re-implementer gets wrong by symmetry.
#[test]
fn the_truth_table_row_for_row() {
let afk = |msg: &str, m: u32| afk_line(msg, AfkMirror(m), &strings);
let dnd = |msg: &str, is: bool| dnd_line(msg, is, &strings);
// `/afk`, not AFK → mark, with the CLIENT's default text substituted before the send.
assert_eq!(
afk("", 0),
AwayOutcome {
line: Some("You are now AFK: Away from Keyboard".into()),
mirror: Some(1),
body: "Away from Keyboard".into(),
},
"the server receives the literal default, never an empty body"
);
// `/afk`, AFK → the toggle off. The packet still goes, carrying nothing.
assert_eq!(
afk("", 1),
AwayOutcome {
line: Some("You are no longer AFK.".into()),
mirror: Some(0),
body: String::new(),
}
);
// `/afk M`, not AFK → mark with M.
assert_eq!(
afk("brb", 0),
AwayOutcome {
line: Some("You are now AFK: brb".into()),
mirror: Some(1),
body: "brb".into(),
}
);
// **`/afk M` while ALREADY AFK prints NOTHING** — `0x5eb76e` jumps past the echo and the
// mirror write, but not past the send. The row most likely to be "fixed" into a print.
assert_eq!(
afk("brb", 1),
AwayOutcome {
line: None,
mirror: None,
body: "brb".into(),
},
"silent re-mark: the server still learns the new auto-reply"
);
// `/dnd`, not DND → the period comes from the format string, not from us.
assert_eq!(
dnd("", false),
AwayOutcome {
line: Some("You are now DND: Do not Disturb.".into()),
mirror: None,
body: "Do not Disturb".into(),
}
);
assert_eq!(
dnd("", true),
AwayOutcome {
line: Some("You are no longer marked DND.".into()),
mirror: None,
body: String::new(),
}
);
assert_eq!(
dnd("busy", false),
AwayOutcome {
line: Some("You are now DND: busy.".into()),
mirror: None,
body: "busy".into(),
}
);
// **The asymmetry.** `/dnd M` while DND RE-PRINTS, where `/afk M` while AFK is silent.
// Not a bug on either side: AFK reads its optimistic mirror, DND reads the live descriptor
// bit and has no mirror at all (§8).
assert_eq!(
dnd("busy", true),
AwayOutcome {
line: Some("You are now DND: busy.".into()),
mirror: None,
body: "busy".into(),
},
"DND re-marks where AFK stays silent — the reference's own asymmetry"
);
}
/// **The director's capture, reproduced: THREE lines from TWO commands.**
///
/// `/afk` then `/dnd` prints the mark, then the implicit AFK clear, then the DND mark — because
/// `/dnd` is chat type `0x15` and the auto-clear skips only `0x14` (§10). Anyone reading the
/// reference screenshot as three typed commands would build the wrong thing.
#[test]
fn afk_then_dnd_prints_three_lines() {
let mut mirror = AfkMirror::default();
let mut out = Vec::new();
let first = afk_line("", mirror, &strings);
out.push(first.line.clone().unwrap());
mirror.0 = first.mirror.unwrap();
// `/dnd` takes the generic path first: the clear fires because the mirror is set.
let cleared = auto_clear_line(mirror, true, &strings).expect("the implicit clear fires");
out.push(cleared);
mirror.0 = 0;
// …and only THEN the DND arm, reading a now-clear AFK state — the order the reference
// runs them in (`0x49f3c7` sits above the type dispatch).
assert!(!mirror.is_afk(), "the clear really cleared it");
out.push(dnd_line("", false, &strings).line.unwrap());
assert_eq!(
out,
vec![
"You are now AFK: Away from Keyboard",
"You are no longer AFK.",
"You are now DND: Do not Disturb.",
],
"the reference capture, line for line"
);
}
/// `autoClearAFK` off is a TOTAL no-op — no echo, and (at the call site) no mirror write and no
/// packet. And a clear never fires when there is nothing to clear.
#[test]
fn the_auto_clear_is_gated_both_ways() {
assert!(
auto_clear_line(AfkMirror(1), false, &strings).is_none(),
"cvar off"
);
assert!(
auto_clear_line(AfkMirror(0), true, &strings).is_none(),
"not afk"
);
assert!(auto_clear_line(AfkMirror(1), true, &strings).is_some());
// **The descriptor-driven value is 2, not 1** (§8's encoding hazard): the reconcile stores
// `PLAYER_FLAGS & 2`. A mirror typed `bool`, or tested `== 1`, would answer "not AFK" here
// and silently stop clearing after the server's first confirmation.
assert!(
auto_clear_line(AfkMirror(2), true, &strings).is_some(),
"2 is truthy — every one of the reference's six readers is a non-zero test"
);
assert!(AfkMirror(2).is_afk());
}
/// A locale that dropped or moved the `%s` must not produce a mangled sentence, and `%%` is an
/// escaped percent. The reference `snprintf`s the shipped string verbatim; so do we.
#[test]
fn the_fill_handles_the_shapes_globalstrings_can_carry() {
assert_eq!(
fill_one("You are now AFK: %s", "brb"),
"You are now AFK: brb"
);
assert_eq!(fill_one("%s.", "x"), "x.");
assert_eq!(fill_one("no slot", "x"), "no slot");
assert_eq!(fill_one("100%% sure", "x"), "100% sure");
assert_eq!(
fill_one("%s and %s", "x"),
"x and %s",
"only the first slot is ours"
);
}
/// An install with no GlobalStrings (CI, a bare checkout) yields empty lines rather than
/// English literals — the same posture `global_string` takes everywhere else. The BODY still
/// carries the message, so the wire half is unaffected by a missing string table.
#[test]
fn a_missing_string_table_does_not_invent_english() {
let none = |_: &str| None;
let out = afk_line("brb", AfkMirror(0), &none);
assert_eq!(out.line.as_deref(), Some(""));
assert_eq!(
out.body, "brb",
"the wire half never depends on the strings"
);
}
}
+26 -4
View File
@@ -29,6 +29,8 @@ pub(crate) enum SendType {
Officer,
Battleground,
BattlegroundLeader,
Afk,
Dnd,
Channel,
}
@@ -36,10 +38,26 @@ impl SendType {
/// The chat-type TOKEN an addon passes to `SendChatMessage` (decision 1199) — the reference's
/// own `ChatTypeInfo` keys, uppercase.
///
/// `None` for a token we do not send. That is the honest answer for `"AFK"`/`"DND"` (which
/// set a flag rather than sending a line) and for anything an addon simply made up; the
/// caller reports it rather than guessing SAY, because a raid warning silently going to /say
/// is worse than one that does not go.
/// `None` for a token we do not send — anything an addon simply made up. The caller reports
/// it rather than guessing SAY, because a raid warning silently going to /say is worse than
/// one that does not go.
///
/// **`"AFK"` and `"DND"` ARE sends, and this said the opposite** — "which set a flag rather
/// than sending a line". They are `CMSG_MESSAGECHAT` types `0x14`/`0x15` like every other row
/// here, carrying the away message as their body; it is the SERVER that toggles the
/// `PLAYER_FLAGS` bit off the packet and streams it back (vmangos `ChatHandler.cpp`'s
/// `CHAT_MSG_AFK` arm → `Player::ToggleAFK`). The wire half was already built and reachable —
/// `ChatKind::Afk`/`Dnd`, `writer::chat::send_afk`/`send_dnd`, `CHAT_TYPE_AFK` — and this
/// function was the only thing standing between the stock file and it.
///
/// **What that cost, and why it is 1751's own lesson:** benilla's slash grammar has always
/// had a working `/afk` (`S::ChatAfk` → `ParsedChat::AfkDnd`). Migrating the chat window
/// (1948) put the stock `ChatFrame.lua` on the chain, and its parser claims a slash line
/// before benilla's grammar ever sees it — so `/afk` became
/// `SlashCmdList["CHAT_AFK"](msg)` → `SendChatMessage(msg, "AFK")` → here → `None`, and the
/// player got `Unknown chat type "AFK".` Migrating a window means building whatever engine
/// verb the stock file turns out to call; the verb existed, and a wrong sentence in this doc
/// comment is what kept it unreachable. Decision 2079.
pub(crate) fn from_token(token: &str) -> Option<SendType> {
Some(match token {
"SAY" => SendType::Say,
@@ -54,6 +72,8 @@ impl SendType {
"OFFICER" => SendType::Officer,
"BATTLEGROUND" => SendType::Battleground,
"BATTLEGROUND_LEADER" => SendType::BattlegroundLeader,
"AFK" => SendType::Afk,
"DND" => SendType::Dnd,
"CHANNEL" => SendType::Channel,
_ => return None,
})
@@ -74,6 +94,8 @@ impl SendType {
SendType::Officer => ChatKind::Officer,
SendType::Battleground => ChatKind::Battleground,
SendType::BattlegroundLeader => ChatKind::BattlegroundLeader,
SendType::Afk => ChatKind::Afk,
SendType::Dnd => ChatKind::Dnd,
SendType::Channel => ChatKind::Channel,
}
}
+76 -9
View File
@@ -269,13 +269,6 @@ pub(super) fn drain_chat_input(
ParsedChat::ChatList { name } => {
let _ = commands.0.send(ClientCommand::ChannelList { name });
}
ParsedChat::AfkDnd { kind, msg } => {
let _ = commands.0.send(ClientCommand::Chat {
kind,
target: None,
text: msg,
});
}
ParsedChat::Random { min, max } => {
let _ = commands.0.send(ClientCommand::RandomRoll { min, max });
}
@@ -1511,6 +1504,27 @@ pub(super) fn emote_send_eligible(
EmoteGate::Send
}
/// `PLAYER_FLAGS_DND` (`0x4`) off our own live descriptor — the DND arm's state test.
///
/// **Live, not mirrored, and that asymmetry is the reference's** (wow-re
/// `afk-dnd-command-law.md` §8): the AFK path keeps an optimistic global and the DND path keeps
/// nothing, so a second `/dnd` typed before the server's `PLAYER_FLAGS` update lands still reads
/// DND clear and re-marks, where a second `/afk` in the same window clears. Do not "fix" this into
/// a symmetric pair.
fn is_dnd(self_q: &Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>) -> bool {
self_q
.iter()
.next()
.is_some_and(|s| s.0.player_flags() & 0x4 != 0)
}
/// `autoClearAFK` — registered default `"1"` (`0x5e24d4 push 0x82e748`). Its reader tests
/// `[cvar+0x28]` for non-zero (`0x5eb84b`), and with the CVar OFF the clear is a **total** no-op:
/// no echo, no mirror write, no packet.
fn auto_clear_afk(cvars: &crate::cvars::CvarPersist) -> bool {
cvars.stored("autoClearAFK").is_none_or(|v| v != "0")
}
/// Turn an addon's `SendChatMessage` calls into sends (decision 1199).
///
/// Its own system rather than a branch inside [`drain_chat_input`], for the reason
@@ -1524,6 +1538,12 @@ pub(super) fn drain_addon_chat_sends(
commands: Res<NetCommands>,
mut chat_log: ResMut<super::feed::ChatLog>,
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
// The optimistic AFK mirror (`[0xb6e5cc]`) the `/afk` toggle reads and writes — 2088.
mut mirror: ResMut<super::away::AfkMirror>,
// `autoClearAFK`, whose registered default is `"1"` — the gate on the implicit clear.
cvars: Res<crate::cvars::CvarPersist>,
// Our own descriptor, for the DND arm's LIVE `PLAYER_FLAGS & 0x4` read (DND has no mirror).
self_q: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
) {
let Some(mut script) = script else {
return;
@@ -1553,10 +1573,57 @@ pub(super) fn drain_addon_chat_sends(
});
}
}
// ── The away commands, and the implicit clear every other send carries (2088) ────────
//
// `SendChatMessage 0x49f1e0` is not a uniform dispatcher: AFK and DND each carry their own
// arm ahead of the generic send, and EVERY other type first clears a standing AFK. The
// whole law — the four `CHAT_MSG_SYSTEM` lines, the client-side default substitution, the
// optimistic mirror — is `super::away`, off wow-re's `afk-dnd-command-law.md` §12 table.
let strings = |key: &str| crate::ui_chat::combat::global_string(&script, key);
let wire = kind.wire();
let text = match wire {
crate::net::ChatKind::Afk => {
let out = super::away::afk_line(&send.text, *mirror, &strings);
if let Some(line) = out.line {
super::away::push_system(&mut chat_log, line);
}
if let Some(v) = out.mirror {
mirror.0 = v;
}
out.body
}
crate::net::ChatKind::Dnd => {
// The live descriptor bit, not a mirror: DND has none (§8), which is what makes a
// repeated `/dnd` re-mark where a repeated `/afk` clears.
let out = super::away::dnd_line(&send.text, is_dnd(&self_q), &strings);
if let Some(line) = out.line {
super::away::push_system(&mut chat_log, line);
}
out.body
}
// Any other type: clear a standing AFK first (`0x49f3d6` skips only type `0x14`), then
// send the line's own packet. `/dnd` is type `0x15`, so it takes this path too — which
// is why the reference prints THREE lines for a typed `/afk` then `/dnd`.
_ => {
if let Some(line) =
super::away::auto_clear_line(*mirror, auto_clear_afk(&cvars), &strings)
{
super::away::push_system(&mut chat_log, line);
mirror.0 = 0;
// The empty `0x14` that tells the server, alongside the message's own packet.
let _ = commands.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Afk,
target: None,
text: String::new(),
});
}
send.text
}
};
let cmd = ClientCommand::Chat {
kind: kind.wire(),
kind: wire,
target: send.target,
text: send.text,
text,
};
if commands.0.send(cmd).is_err() {
warn!("chat: not connected; addon line dropped");
+11 -11
View File
@@ -8,7 +8,6 @@
//! No aliases live here: which strings reach which arm is [`super::super::commands`]'s table,
//! built from the shipped `GlobalStrings.lua`.
use crate::net::ChatKind;
use crate::ui_chat::commands::{Command, DevCmd, SlashCommands, SlashIndex};
/// Escape a player-typed string for embedding in a Lua double-quoted literal: backslashes and
@@ -48,8 +47,6 @@ pub(in crate::ui_chat) enum ParsedChat {
Leave { name: String },
/// `/chatlist <name>` (aliases /chatwho /chatinfo) — the member roster ask.
ChatList { name: String },
/// `/afk [msg]` / `/dnd [msg]` — the away toggles (CHAT_MSG_AFK/DND sends).
AfkDnd { kind: ChatKind, msg: String },
/// `/random [min] [max]` (aliases /rand /rnd /roll): bare = 1-100, one number = 1-N.
Random { min: u32, max: u32 },
/// `/played` — CMSG_PLAYED_TIME.
@@ -302,14 +299,17 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
// The bare joined-channel listing is P6's (it needs the list).
None => ParsedChat::Unknown,
},
S::ChatAfk => ParsedChat::AfkDnd {
kind: ChatKind::Afk,
msg: args.to_string(),
},
S::ChatDnd => ParsedChat::AfkDnd {
kind: ChatKind::Dnd,
msg: args.to_string(),
},
// **The reference's own bodies, not a second implementation** (2088). These used to build
// a bare `ClientCommand::Chat` — which was right until the away law landed, and would now
// be a SECOND `/afk` that sends the packet with no echo, no default substitution and no
// mirror write. The stock parser claims a typed `/afk` before this table ever sees it, so
// the only lines still arriving here are the ones that never touched the edit box (a
// `WOW_PROBE_CHAT` rig's), and routing them through `SlashCmdList` funnels them into the
// same `SendChatMessage` seam the player's own keystrokes take — one implementation, and
// the probe exercises the real path rather than a shadow of it. Same posture, and the same
// reasoning, as the social verbs below.
S::ChatAfk => social_body("CHAT_AFK", args),
S::ChatDnd => social_body("CHAT_DND", args),
S::Random => {
let mut nums = args
.split_whitespace()
+15 -5
View File
@@ -1,5 +1,3 @@
use crate::net::ChatKind;
use super::event::{default_color, ChatEvent, ChatEventKind as K};
use super::input::{emote_send_eligible, emote_target, EmoteGate, ParsedChat};
@@ -1005,6 +1003,7 @@ fn stub_table() -> super::commands::SlashCommands {
("SLASH_LEAVE1", "/leave"),
("SLASH_LIST_CHANNEL1", "/chatlist"),
("SLASH_CHAT_AFK1", "/afk"),
("SLASH_CHAT_DND1", "/dnd"),
("SLASH_RANDOM1", "/random"),
("SLASH_RANDOM2", "/roll"),
("SLASH_PLAYED1", "/played"),
@@ -1058,11 +1057,22 @@ fn action_commands_parse() {
name: "world".into()
}
);
// `/afk` and `/dnd` run the reference's OWN `SlashCmdList` bodies rather than building a bare
// send (2088). They used to be a `ParsedChat::AfkDnd` that went straight to the wire — correct
// until the away law landed, and a second implementation the moment it did: no echo, no
// client-side default substitution, no mirror write. The argument still rides WHOLE, which is
// what this row has always pinned.
assert_eq!(
parse_line("/afk farming"),
ParsedChat::AfkDnd {
kind: ChatKind::Afk,
msg: "farming".into(),
ParsedChat::Lua {
body: "SlashCmdList[\"CHAT_AFK\"](\"farming\")".into()
}
);
// Bare, because that is the toggle — and the empty string has to survive to the send.
assert_eq!(
parse_line("/dnd"),
ParsedChat::Lua {
body: "SlashCmdList[\"CHAT_DND\"](\"\")".into()
}
);
assert_eq!(parse_line("/roll"), ParsedChat::Random { min: 1, max: 100 });
@@ -1291,3 +1291,76 @@ fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
"shipped: and on every hover after — got {later}"
);
}
/// **`/afk` and `/dnd`, through the reference's own parser and out the far side as a SEND.**
///
/// The director's report: typing either printed `Unknown chat type "AFK".` and nothing happened —
/// no away flag, so no `<AFK>` over the head and no `<AFK>` on their chat lines, because both of
/// those surfaces were already built and were waiting on a flag that never got set.
///
/// The break was a seam, which is why this test spans it rather than testing either side. benilla's
/// own slash grammar has always had a working `/afk` (`ui_chat::input::parse`'s `S::ChatAfk`), but
/// migrating the chat window (1948) put the stock `ChatFrame.lua` on the chain and **its** parser
/// claims a slash line first — so the live path became
/// `ChatEdit_ParseText` → `SlashCmdList["CHAT_AFK"](msg)` → `SendChatMessage(msg, "AFK")` →
/// `SendType::from_token`, which answered `None` because a doc comment there asserted AFK/DND
/// "set a flag rather than sending a line". They do not: they are `CMSG_MESSAGECHAT` `0x14`/`0x15`
/// and the SERVER toggles the bit. Decision 2082.
///
/// So the assertion runs the two halves in series: the stock file really does emit the token, and
/// the token really does resolve to a wire kind. Testing either alone passes while the seam is
/// broken — which is exactly how this shipped.
#[test]
fn the_stock_afk_and_dnd_commands_reach_the_wire() {
use crate::net::ChatKind;
use crate::ui_chat::edit::SendType;
let typed = |line: &str| -> (String, String) {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input(line);
assert!(s.key_input("ENTER"), "the box consumes ENTER");
let sends = s.take_chat_sends();
assert_eq!(sends.len(), 1, "{line:?} produced one send");
(sends[0].text.clone(), sends[0].chat_type.clone())
};
// Half one — the stock `SlashCmdList` body, verbatim: `SendChatMessage(msg, "AFK")`.
let (text, token) = typed("/afk Away from Keyboard");
assert_eq!(
(text.as_str(), token.as_str()),
("Away from Keyboard", "AFK")
);
// Half two — the seam that was cut. `None` here is the reported bug.
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Afk),
"the token the stock file sends must resolve to a wire kind"
);
let (text, token) = typed("/dnd Do not Disturb");
assert_eq!((text.as_str(), token.as_str()), ("Do not Disturb", "DND"));
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Dnd)
);
// **A BARE `/afk` still sends** — with an empty body, which is the toggle. The stock body
// passes `msg` straight through, and vmangos's `CHAT_MSG_AFK` arm reads an empty message as
// "toggle" and a non-empty one as "set, and store this as the auto-reply".
let (text, token) = typed("/afk");
assert_eq!((text.as_str(), token.as_str()), ("", "AFK"));
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Afk),
"the bare toggle is a send too, not a no-op"
);
// The control: a token the reference has no send for still answers None, so the arms above
// are two specific additions and not a blanket "anything goes" that would let a typo through.
assert!(SendType::from_token("NOT_A_CHAT_TYPE").is_none());
assert!(
SendType::from_token("TEXT_EMOTE").is_none(),
"a real ChatTypeInfo key that is nonetheless not sendable stays None"
);
}
@@ -12,7 +12,9 @@
use benilla_ui::script::{ContainerSlot, ContainerState, UiScript};
use super::test_ui::{bag_open, bag_slot_button, load_ui as load_xml, BAG_UI};
use super::test_ui::{
bag_open, bag_slot_button, load_ui as load_xml, load_world_frame, world_click, BAG_UI,
};
/// A one-item, one-slot backpack holding `item_id`/`name` at `quality`, so the confirm text and
/// the wire's destroy count are exercisable end to end. Quality is what forks the driver, so it is
@@ -72,6 +74,14 @@ fn setup() -> UiScript {
load_xml(&s, r"Interface\FrameXML\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, r"Interface\FrameXML\FloatingChatFrame.xml"); // declares ChatFrameEditBox
// **The world frame, because the world drop is what raises this popup** (B380, decision
// 2089). Every test here used to click at `(-50, -50)` — off-screen, over nothing — which is
// a world click only in a house where no `WorldFrame` is loaded. The real client's is
// full-screen and mouse-enabled, and it swallowed every one of these clicks for three days
// while all ten tests stayed green. `world_click` lands on the world now, and this is the
// file that has to be there for there to be one.
load_world_frame(&s);
s.set_money(0);
s
}
@@ -91,12 +101,21 @@ fn bag_setup() -> UiScript {
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_world_frame(&s); // [`setup`]'s reason, same fixture
// …and the paper doll goes back where the reference keeps it. [`BAG_UI`] leaves
// `CharacterFrame.xml` out on purpose (its own note: a window no bag test opens), so the
// frame `PaperDollFrame.xml` declares has no hidden parent to sit inside and stands on screen
// instead — it and its model child covering the world the drop has to be clicked on. The real
// client never shows it there; hiding it restores its state, it does not dodge the fixture's.
s.run("PaperDollFrame:Hide()")
.expect("the paper doll the missing CharacterFrame would have hidden");
s.set_money(0);
s
}
/// Pick up the fixture item and click-carry it into the world — a completed LEFT CLICK (press +
/// release, both over nothing; 0218's byte-verified trigger) fires the world-drop
/// release, both on the world frame; 0218's byte-verified trigger) fires the world-drop
/// `DELETE_ITEM_CONFIRM(name, quality)` the driver listens for.
fn pick_up_and_drop_in_world(s: &mut UiScript) {
drop_in_world(s, 117, "Tough Jerky", 1);
@@ -108,12 +127,7 @@ fn drop_in_world(s: &mut UiScript, item_id: u32, name: &str, quality: u32) {
s.set_container(0, Some(one_item_backpack(item_id, name, quality)));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
// Off past every frame (the bag sits bottom-right; off-screen negative is always clear).
s.mouse_button(-50.0, -50.0, "LeftButton", true);
assert!(
s.mouse_button(-50.0, -50.0, "LeftButton", false),
"a world drop consumes the completed click"
);
world_click(s);
s.tick(0.01); // flush the queued DELETE_ITEM_CONFIRM into the driver's OnEvent
}
@@ -504,3 +518,41 @@ fn the_plain_arm_shows_no_edit_box() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The world drop, under the WHOLE shipped interface** — B380's own test (decision 2089).
///
/// The nine above build a fixture out of the files the popup needs, which is what let B380 hide:
/// a fixture is a subset of the manifest, and the file that broke this one was a file no fixture
/// had a reason to name. This one takes the manifest entire — the same load the player gets,
/// world frame and action bars and chat and all — picks an item up off the bag and clicks the
/// ground. It is the slowest test in the file and the only one that could not have been fooled.
#[test]
fn the_world_drop_survives_the_whole_shipped_interface() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.set_money(0);
s.set_container(0, Some(one_item_backpack(117, "Tough Jerky", 1)));
s.resolve();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
world_click(&mut s);
s.tick(0.01);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"clicking the ground with an item held raises the destroy confirm"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you want to destroy Tough Jerky?"
);
// …and Yes destroys it, through the stock popup's own OnAccept.
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s.cursor_item().is_none(), "the cursor is empty after Yes");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -4266,7 +4266,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
("autointeract", "click-to-move"),
("assistAttack", "assist-attack"),
("autoClearAFK", "no auto-clear of the AFK flag"),
("UnitNamePlayerGuild", "guild names over player nameplates"),
(
"UnitNamePlayerPVPTitle",
@@ -748,6 +748,78 @@ pub(super) fn click(s: &mut UiScript, name: &str, button: &str) {
s.mouse_button(x, y, button, false);
}
/// Seat the world frame in a fixture, the way the manifest does — the file the world drop's
/// click target comes from, plus the one its `OnUpdate` reads a constant out of.
///
/// `WorldFrame_OnUpdate` ticks the popups and breath bars that a hidden UI would otherwise
/// freeze, and it walks `1, MIRRORTIMER_NUMTIMERS` to do it — a global whose home is
/// `MirrorTimer.lua`, manifest entry 53. A harness that takes the world frame without it raises
/// `'for' limit must be a number` on every tick. Loading the real file rather than setting the
/// constant by hand is [`BAG_UI`]'s own rule (`PartyFrame.xml` is there for the same reason):
/// a stubbed constant passes and teaches nothing about the real load.
///
/// `StaticPopup.xml` — the other name that loop reads — is already in every fixture that calls
/// this, since the popup is what these tests are about.
pub(super) fn load_world_frame(s: &UiScript) {
load_ui(s, r"Interface\FrameXML\WorldFrame.xml");
load_ui(s, r"Interface\FrameXML\MirrorTimer.xml");
}
/// A completed left CLICK on the game world, at a point the loaded `WorldFrame` actually owns.
///
/// **The point is searched, not assumed, and that is the whole lesson of B380** (decision 2089).
/// Every world-drop fixture in the house used to click `(-50, -50)` — off-screen, where the hit
/// test answers nothing at all — which is a world click only in a house with no `WorldFrame`
/// loaded. The stock file's frame is full-screen and mouse-enabled, so from the day it joined the
/// manifest it took every real world click while those fixtures went on passing over the void.
///
/// The point cannot be a constant either, because a harness holds the windows its test needs and
/// not the ones that would hide them — `bag_setup`'s `PaperDollFrame` has no `CharacterFrame` to
/// sit inside, so it covers the middle of the screen. So: walk a coarse grid over the world
/// frame's own rect and take the first cell it owns. When it owns none, panic naming what is
/// there — a fixture with no world to click is one that would pass for the wrong reason.
pub(super) fn world_click(s: &mut UiScript) {
let (x, y) = world_point(s);
s.mouse_move(x, y);
s.mouse_button(x, y, "LeftButton", true);
assert!(
s.mouse_button(x, y, "LeftButton", false),
"a world drop consumes the completed click"
);
}
/// [`world_click`]'s search. Separate so the failure can say what it looked at.
fn world_point(s: &mut UiScript) -> (f32, f32) {
s.resolve();
let r: Vec<f32> = s
.eval(
"local f = WorldFrame \
return { f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight() }",
)
.expect(
"the fixture must load Interface\\FrameXML\\WorldFrame.xml (load_world_frame): a \
world drop is a click on THAT frame, not on nothing (decision 2089)",
);
assert_eq!(r.len(), 4, "WorldFrame: unresolved rect {r:?}");
let (steps, mut blockers) = (7, Vec::new());
for row in 0..steps {
for col in 0..steps {
let x = r[0] + r[2] * (col as f32 + 0.5) / steps as f32;
let y = r[1] + r[3] * (row as f32 + 0.5) / steps as f32;
match s.hit_test(x, y) {
Some(id) if s.is_world_frame(id) => return (x, y),
_ => blockers.push(
s.hit_test_name(x, y)
.unwrap_or_else(|| "<anonymous>".into()),
),
}
}
}
blockers.sort();
blockers.dedup();
panic!("no point on the world frame is clickable — the UI covers all {steps}x{steps} of them: {blockers:?}")
}
/// Seat one of the reference's LoadOnDemand Blizzard addons off the chain, so a harness's
/// `UIParentLoadAddOn(name)` can load it the way the app does (1957; the combat text since
/// 1964). Needs client data — the caller has already checked with `wow_data_or_skip!`.
@@ -69,6 +69,15 @@ const VERDICTS: &[(&str, Verdict)] = &[
"CursorOptions",
Open("an OUTPUT (grab/visible), not a channel — the cover has nothing to take from it"),
),
(
"CursorLeft",
Open(
"the cover READS it rather than taking it (decision 2090): a `None` in the window's \
position field is either winit's departure or the cover's own blank, and the hand-back \
must not confuse them — restoring the stash over a pointer the player took out of the \
window would warp it back in. Draining it would blind the gate to its own signal.",
),
),
// ── Not input at all ────────────────────────────────────────────────────────────────────
("ButtonState", Plumbing),
// The cursor OUTPUTS — the art and the grab mode benilla *writes*. Nothing arrives through
@@ -0,0 +1,226 @@
//! `kitanim`: census the `SpellVisualKit` **animation** column (field 2) — the half of a kit that
//! plays a clip on the unit's *own body*, as opposed to the attach-point effect models
//! (`spellvis`), the CharProcs (`charprocs`) or the camera shake (`shakecensus`).
//!
//! The scope instrument for "the spell landed on me and my character did nothing". A kit's anim is
//! the only part of a visual that can be *invisible by omission*: an effect model that fails to
//! spawn leaves a trace line, while an anim that is never asked for, or is asked for and instantly
//! overwritten, leaves nothing at all. So the population has to be read off the shipped table:
//!
//! - **by stage**, because the stage decides the lifetime. A `cast`/`impact` anim is a one-shot on
//! the caster/victim; a **`state`** anim belongs to an aura's whole life, which is a different
//! consumer entirely (`creature_anim::spell_visual::arm_aura_state_fx`'s slot watcher, not
//! `play_impact`).
//! - **anim-only kits are called out**, because they are the class an "does this kit do anything?"
//! test drops. That test is the B114 shape: the aura-state watcher used to arm only on effect
//! models, so Stealth — whose whole visual is one CharProc — showed nothing. A state kit whose
//! whole visual is an *anim* fails the same way one level over.
//! - **wound-branch ids are marked**, because 8/9/10 (`StandWound`/`CombatWound`/`CombatCritical`)
//! do not play as themselves: the client's kit player hands them to the severity-0 flinch
//! (`0x60f3b8: push 0; call 0x60ea70`), so they are a different mechanism wearing the same
//! column.
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use benilla_formats::{AnimDataCatalog, Chain, VisualStages};
/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields.
type StagePick = fn(&VisualStages) -> u32;
/// The five `SpellVisual` lifecycle stages, in field order — the label plus its column selector.
const STAGES: [(&str, StagePick); 5] = [
("precast", |s| s.precast),
("cast", |s| s.cast),
("impact", |s| s.impact),
("state", |s| s.state),
("channel", |s| s.channel),
];
/// The kit-player's wound branch: ids 8–10 are handed to the severity-0 flinch instead of being
/// played as themselves (`0x60f3b8`), so they are counted apart from the real body clips.
fn is_wound_id(id: u16) -> bool {
(8..=10).contains(&id)
}
/// One state-stage kit's row: everything that decides whether a consumer keyed on effect models
/// alone would see it.
struct StateKit {
anim: u16,
slots: usize,
world: bool,
sound: bool,
procs: usize,
spells: BTreeSet<u32>,
}
impl StateKit {
/// Nothing but the anim — the class an effects/sound/proc test drops on the floor.
fn anim_only(&self) -> bool {
self.slots == 0 && !self.world && !self.sound && self.procs == 0
}
}
/// Census the anim column: the raw table, then reachability per stage, then the two stages whose
/// consumers differ — `state` (aura lifetime) in full, `impact` (the victim's one-shot) ranked.
pub fn run(chain: &mut Chain) -> Result<()> {
let spells = benilla_formats::load_spell_catalog(chain)?;
let visuals = benilla_formats::load_spell_visual_catalog(chain)?;
// `AnimationData.dbc` is only the naming column here — a missing table degrades to bare ids
// rather than failing the census (the same optional shape every DBC consumer takes).
let anims = benilla_formats::load_anim_data_catalog(chain).ok();
let name = |id: u16| -> String {
anims
.as_ref()
.and_then(|a: &AnimDataCatalog| a.name(id))
.unwrap_or("?")
.to_string()
};
// 1. The raw table: which anim ids the shipped kits ask for at all.
let mut by_anim: BTreeMap<u16, BTreeSet<u32>> = BTreeMap::new();
for kit_id in visuals.kit_ids() {
if let Some(anim) = visuals.kit(kit_id).and_then(|k| k.anim_id) {
by_anim.entry(anim).or_default().insert(kit_id);
}
}
let kits_with_anim: usize = by_anim.values().map(BTreeSet::len).sum();
println!(
"SpellVisualKit: {} rows, {kits_with_anim} carry an animation id ({} distinct ids)",
visuals.kit_len(),
by_anim.len(),
);
println!("\nanim census (all kits):");
for (anim, kits) in &by_anim {
println!(
" anim {anim:>4} {:<20} {:>4} kit(s){}",
name(*anim),
kits.len(),
if is_wound_id(*anim) {
" [wound branch — plays the flinch, not this id]"
} else {
""
},
);
}
// 2. Reachability: a kit no spell's visual chain names is authored-but-dead, and the STAGE is
// what picks the consumer, so both are counted here.
let mut stage_kits: BTreeMap<&str, BTreeSet<u32>> = BTreeMap::new();
let mut stage_spells: BTreeMap<&str, BTreeSet<u32>> = BTreeMap::new();
let mut state: BTreeMap<u32, StateKit> = BTreeMap::new();
let mut impact: BTreeMap<u32, (u16, BTreeSet<u32>)> = BTreeMap::new();
for (spell_id, display) in spells.iter() {
let Some(stages) = visuals.stages(display.visual) else {
continue;
};
for (label, pick) in STAGES {
let kit_id = pick(stages);
let Some(kit) = visuals.kit(kit_id) else {
continue;
};
let Some(anim) = kit.anim_id else { continue };
stage_kits.entry(label).or_default().insert(kit_id);
stage_spells.entry(label).or_default().insert(spell_id);
match label {
"state" => {
state
.entry(kit_id)
.or_insert_with(|| StateKit {
anim,
slots: kit.effects().count(),
world: kit.world_effect.is_some(),
sound: kit.sound.is_some(),
procs: kit.char_procs().count(),
spells: BTreeSet::new(),
})
.spells
.insert(spell_id);
}
"impact" => {
impact
.entry(kit_id)
.or_insert_with(|| (anim, BTreeSet::new()))
.1
.insert(spell_id);
}
_ => {}
}
}
}
println!("\nreached from a live Spell.dbc visual chain, by stage:");
for (label, _) in STAGES {
println!(
" {label:8} {:>4} kit(s) {:>5} spell(s)",
stage_kits.get(label).map_or(0, BTreeSet::len),
stage_spells.get(label).map_or(0, BTreeSet::len),
);
}
// 3. The STATE stage in full — the aura-lifetime set, which is the whole reason this census
// exists. `ANIM-ONLY` marks the kits an effects/sound/proc arm test never sees.
let state_spells: usize = state
.values()
.flat_map(|k| k.spells.iter())
.collect::<BTreeSet<_>>()
.len();
let anim_only = state.values().filter(|k| k.anim_only()).count();
println!(
"\nSTATE-stage anims — the aura-lifetime set ({} kit(s), {state_spells} spell(s), \
{anim_only} ANIM-ONLY):",
state.len(),
);
let mut rows: Vec<(&u32, &StateKit)> = state.iter().collect();
rows.sort_by_key(|(id, k)| (std::cmp::Reverse(k.spells.len()), **id));
for (kit_id, k) in rows {
println!(
" kit {kit_id:<5} anim {:>4} {:<20} slots {} world {} sound {} procs {} {:>4} \
spell(s){}",
k.anim,
name(k.anim),
k.slots,
u8::from(k.world),
u8::from(k.sound),
k.procs,
k.spells.len(),
if k.anim_only() { " ANIM-ONLY" } else { "" },
);
for spell_id in k.spells.iter().take(4) {
let n = spells.get(*spell_id).map_or("", |d| d.name.as_str());
println!(" {spell_id:>6} {n}");
}
if k.spells.len() > 4 {
println!(" … and {} more", k.spells.len() - 4);
}
}
// 4. The IMPACT stage, ranked — the VICTIM's body one-shot, the other consumer. Kept to a
// ranked list rather than spelled out: the population is the whole damage table.
let impact_spells: usize = impact
.values()
.flat_map(|(_, s)| s.iter())
.collect::<BTreeSet<_>>()
.len();
println!(
"\nIMPACT-stage anims — the victim's one-shot ({} kit(s), {impact_spells} spell(s)):",
impact.len(),
);
let mut rows: Vec<(&u32, &(u16, BTreeSet<u32>))> = impact.iter().collect();
rows.sort_by_key(|(id, (_, s))| (std::cmp::Reverse(s.len()), **id));
for (kit_id, (anim, spells_hit)) in rows.iter().take(20) {
println!(
" kit {kit_id:<5} anim {anim:>4} {:<20} {:>5} spell(s){}",
name(*anim),
spells_hit.len(),
if is_wound_id(*anim) {
" [wound branch]"
} else {
""
},
);
}
if rows.len() > 20 {
println!(" … and {} more kit(s)", rows.len() - 20);
}
Ok(())
}
@@ -19,6 +19,7 @@ mod chaincensus;
mod charatlas;
mod charprocs;
mod glueextent;
mod kitanim;
mod m2dump;
mod scan;
mod shakecensus;
@@ -156,6 +157,16 @@ enum Command {
/// from a live spell, and every state-stage (aura-lifetime) proc in full. The scope instrument
/// for the aura-state CharProc system.
Charprocs,
/// Census the `SpellVisualKit` **animation** column (field 2) — the half of a kit that plays a
/// clip on the unit's own BODY, as opposed to its attach-point effect models, its CharProcs or
/// its camera shake. Which anim ids the shipped table asks for, which lifecycle stage reaches
/// each (the stage picks the consumer: a `cast`/`impact` anim is a one-shot on the
/// caster/victim, a `state` anim belongs to an aura's whole life), then the state set in full
/// and the impact set ranked. The scope instrument for "the spell landed on me and my
/// character did nothing" — an anim that is never asked for leaves no trace to grep, unlike an
/// effect model that fails to spawn. `ANIM-ONLY` marks the state kits whose whole visual is the
/// anim, the class a "does this kit do anything?" test drops (the B114 shape one level over).
Kitanim,
/// Dump an M2's collision hull as the mover collides with it: vertex/triangle counts, the
/// model-space AABB (WoW axes, Z up), and its extents — the "what does walking into this
/// actually hit" instrument (the step-up climb-vs-slide asset question, decision 0195; a
@@ -1005,6 +1016,7 @@ fn main() -> Result<()> {
Command::Shakecensus => shakecensus::shakecensus(&mut chain)?,
Command::Thudcensus => thudcensus::thudcensus(&mut chain)?,
Command::Charprocs => charprocs::run(&mut chain)?,
Command::Kitanim => kitanim::run(&mut chain)?,
}
Ok(())
+4 -3
View File
@@ -420,9 +420,10 @@ pub enum WorldPick {
}
/// A world drop: a completed left CLICK on the game world while a payload is held — press and
/// release both over no frame, no drag (the byte-verified trigger, decision 0218: the client's
/// `0x495300` runs on the WorldFrame click release only; a drag released over the world routes
/// as a drag and keeps carrying) — routed by [`Model::world_pick`] (decisions 0571 + 0574,
/// release both on the world (the world frame, or no frame at all where none is loaded — the
/// caller's `over_world`, decision 2089), no drag (the byte-verified trigger, decision 0218: the
/// client's `0x495300` runs on the WorldFrame click release only; a drag released over the world
/// routes as a drag and keeps carrying) — routed by [`Model::world_pick`] (decisions 0571 + 0574,
/// byte-verified wow-re cursor-dragdrop-payload.md §11):
///
/// - `Object`: NOTHING drops — the object leg (`0x492ce0`) keeps every real payload and
+119
View File
@@ -326,6 +326,125 @@ mod tests {
assert!(s.cursor_item().is_some(), "the payload stays held");
}
/// **The world frame IS the world** — B380 / decision 2089, the regression this file's
/// other world-drop tests were structurally blind to.
///
/// Every one of them clicks at `(-50, -50)`, where the hit test answers `None`, and the gate
/// used to be spelled `hit_id.is_none() && pressed.is_none()`. Decision 1983 put the stock
/// `WorldFrame` on the manifest — full-screen, mouse-enabled, `SetAllPoints` — so from that
/// day every click a player makes on the world hits a frame, the gate went permanently false
/// and dropping an item on the ground stopped doing anything at all. It is exactly the
/// reference's own click target for a world click (1984: "the press runs the frame's Lua
/// `OnMouseDown` (never consuming), then the `BUTTON1`/`BUTTON2` binding — which **is** the
/// world click"), so it belongs on the yes side of the predicate.
#[test]
fn a_click_on_the_world_frame_is_a_world_drop() {
let mut s = drag_script();
s.run(
r#"
WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()
heard = 0
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
// The fixture's own premise: the click really does land on the world frame.
let hit = s
.hit_test(400.0, 300.0)
.expect("the world frame is full-screen");
assert!(s.is_world_frame(hit));
s.mouse_button(400.0, 300.0, "LeftButton", true);
let consumed = s.mouse_button(400.0, 300.0, "LeftButton", false);
assert!(consumed, "a world drop consumes the completed click");
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 1, "the popup fires");
assert!(s.cursor_item().is_some(), "the payload stays held");
}
/// …and a UI frame ON TOP of the world frame is still the UI: press and release both land on
/// the plate, so no drop — the other half of [`super::over_world`]'s gate, which a predicate
/// that merely asked "is a world frame anywhere under the cursor" would get wrong.
#[test]
fn a_click_on_a_frame_above_the_world_frame_is_not_a_world_drop() {
let mut s = drag_script();
s.run(
r#"
WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()
heard = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.mouse_button(100.0, 300.0, "LeftButton", true);
let consumed = s.mouse_button(100.0, 300.0, "LeftButton", false);
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0, "no popup");
assert!(s.cursor_item().is_some());
assert!(
consumed,
"the plate ate the click — that IS the UI consuming it"
);
// The mixed pair, both ways round: a press on the plate released over the world, and a
// press on the world released over the plate. Neither is a completed world click.
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_button(600.0, 300.0, "LeftButton", false);
s.mouse_button(600.0, 300.0, "LeftButton", true);
s.mouse_button(100.0, 300.0, "LeftButton", false);
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0, "still no popup");
assert!(s.cursor_item().is_some());
}
/// A click on the world frame with an EMPTY cursor is not the UI consuming anything — the
/// return the app's arbiter would read if it ever asked. 1984: the world frame's press
/// "never consum[es]".
#[test]
fn a_world_frame_click_with_no_payload_consumes_nothing() {
let mut s = drag_script();
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
.unwrap();
s.resolve();
s.mouse_button(400.0, 300.0, "LeftButton", true);
assert!(
!s.mouse_button(400.0, 300.0, "LeftButton", false),
"a bare world click is the world's, not the interface's"
);
}
/// A world object (unit/GameObject) under the cursor suppresses the world drop entirely
/// (decisions 0571 + 0574): the reference's object-leg dispatcher (`0x492ce0`) keeps every
/// real payload and runs SELECT — no `DELETE_ITEM_CONFIRM`, payload untouched. The app
+47 -11
View File
@@ -29,10 +29,11 @@
//! frame carries no such script), and SUPPRESSES `OnClick` for that release — an un-started
//! gesture leaves the ordinary click path untouched. The **world drop**
//! ([`cursor::world_drop_click`]) fires only on a completed left CLICK over the game world:
//! press AND release both landed over no frame, no drag started (the byte-verified trigger — the
//! client's `0x495300` runs on the WorldFrame click release; a drag release routes as a drag,
//! never a click), routed by the app-fed pick ([`super::Model::world_pick`], decisions 0571 +
//! 0574 — an object pick keeps everything, terrain drops items only, nothing drops any arm).
//! press AND release both landed on **the world** ([`over_world`] — the world frame, or no frame
//! at all where none is loaded), no drag started (the byte-verified trigger — the client's
//! `0x495300` runs on the WorldFrame click release; a drag release routes as a drag, never a
//! click), routed by the app-fed pick ([`super::Model::world_pick`], decisions 0571 + 0574 — an
//! object pick keeps everything, terrain drops items only, nothing drops any arm).
//!
//! ## Division of labor with [`cursor`]
//!
@@ -127,6 +128,32 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
Ok(())
}
/// Whether `h` is the world frame — the one frame whose mouse hits belong to the 3D world
/// (decisions 1983/1984, `worldframe-widget.md`: "the hover walk probes strata 8→0 and the world
/// frame wins exactly when nothing else mouse-enabled is under the cursor; the press runs the
/// frame's Lua `OnMouseDown` (never consuming), then the `BUTTON1`/`BUTTON2` binding — which
/// **is** the world click; the release mirrors it").
fn is_world_handle(model: &Model, h: FrameHandle) -> bool {
model
.arena
.frame(h)
.is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame)
}
/// Whether a press/release target is **the world** rather than the UI — the predicate the world
/// drop is gated on, and the one thing decision 1983 silently changed the meaning of.
///
/// Before the stock `WorldFrame` joined the manifest (1983, 2026-09-04) the world was simply
/// *where no frame is*, and this was spelled `hit.is_none()` throughout. The stock file anchors a
/// mouse-enabled frame over the whole screen, so from that day every world click hit a frame and
/// the world drop stopped firing — B380 (decision 2089). The world frame IS the reference's click
/// target for a world click, so it answers **yes** here; `None` still answers yes too, because a
/// harness with no `WorldFrame.xml` loaded (most tests, an install-less checkout) has nothing
/// under the cursor at all and means the same thing by it.
fn over_world(model: &Model, target: Option<FrameHandle>) -> bool {
target.is_none_or(|h| is_world_handle(model, h))
}
/// Whether `(x, y)` falls inside one of this frame's hyperlink spans — the engine half of the
/// reference's per-span `CSimpleHyperlinkButton` children.
fn link_span_hit(model: &Model, fh: FrameHandle, x: f32, y: f32) -> bool {
@@ -160,8 +187,7 @@ impl UiScript {
model
.id_to_frame
.get(&id)
.and_then(|h| model.arena.frame(*h))
.is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame)
.is_some_and(|h| is_world_handle(&model, *h))
}
pub fn hit_test_frame(&self, x: f32, y: f32) -> Option<FrameHandle> {
@@ -388,7 +414,7 @@ impl UiScript {
/// if that frame carries no such script), and SUPPRESSES `OnClick` for this release (an
/// un-started gesture leaves the ordinary click path untouched). The **world drop**
/// ([`cursor::world_drop_click`]) fires ONLY on a completed left CLICK over the world —
/// press AND release both over no frame, no started drag (the byte-verified trigger: the
/// press AND release both [`over_world`], no started drag (the byte-verified trigger: the
/// client's `0x495300` runs on the WorldFrame click release; a drag release routes as a
/// drag, never a click — wow-re cursor-dragdrop-payload.md, and the director's report it
/// confirmed) — routed by the app-fed pick (decisions 0571 + 0574): over an object nothing
@@ -583,16 +609,21 @@ impl UiScript {
);
let release = cursor::take_drag(&mut model, button);
let started = release.as_ref().is_some_and(|r| r.started);
// The world drop: press AND release both over no frame, no drag started —
// The world drop: press AND release both over THE WORLD, no drag started —
// a completed left click on the game world (never a drag release, which just
// keeps carrying; decision 0218's byte-verified trigger). What actually drops
// is routed inside `world_drop_click` by the app-fed world pick (decisions
// 0571 + 0574): an object pick keeps everything, terrain drops items only,
// nothing drops any arm.
//
// "Over the world" is [`over_world`], not `is_none()`: since 1983 the stock
// `WorldFrame` is a real mouse-enabled frame covering the screen, so a world
// click hits IT — which is exactly what the reference's own world click is
// (1984). Spelling this as "no frame" is what B380 was (decision 2089).
let dropped = button == "LeftButton"
&& !started
&& hit_id.is_none()
&& pressed.is_none()
&& over_world(&model, hit_handle)
&& over_world(&model, pressed)
&& cursor::world_drop_click(&mut model);
let wants = format!("{button}Up");
let click = if started {
@@ -796,7 +827,12 @@ impl UiScript {
self.push_error(e);
}
}
hit_id.is_some() || world_dropped
// Whether the **UI** consumed the event. A hit on the world frame is not the UI
// consuming anything — "the press runs the frame's Lua `OnMouseDown` (never consuming),
// then the `BUTTON1`/`BUTTON2` binding" (1984) — so it answers no unless the click was
// itself the world drop. Without that clause the stock `WorldFrame` would report every
// click in the game as eaten by the interface.
hit_id.is_some_and(|id| !self.is_world_frame(id)) || world_dropped
}
/// Fire `OnDragStop` on a gesture's source — the ONE place that ends a started drag, so the