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