/target, /assist and /follow, and right click opens a clam

- /target and /assist find units by name like the original, and /follow walks you behind your target
- right click opens clams and lockboxes, and the tooltip promises it
- the freeze thaws clean, poses release instead of sticking, and you drop to neutral standing before it grips
- unit frame art layers over the bars like the original

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:02:44 +07:00
co-authored by Claude Fable 5
parent 2f40b6a9a0
commit 94de741cb1
36 changed files with 2086 additions and 204 deletions
+72 -60
View File
@@ -85,13 +85,14 @@
//! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk
//! `ModelAlphas` already does for alpha.
//!
//! **Proc 11 ships too** (decision 0889): the **freeze**. Its param is a playback *rate* written onto
//! the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the mount's bone 0,
//! the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates saved in the node
//! and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose holds exactly where
//! it was, *including* a cast one-shot that had just been armed, which is why the caster never gets
//! to raise their hand. [`apply_aura_anim_rate`] is that leg, and the reference's save/restore is
//! [`AnimRateFreeze`].
//! **Proc 11 ships too** (decisions 0889 + 0891): the **freeze**. Its param is a playback *rate*
//! written onto the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the
//! mount's bone 0, the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates
//! saved in the node and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose
//! holds exactly where it was, *including* a cast one-shot that had just been armed, which is why the
//! caster never gets to raise their hand. [`apply_aura_anim_rate`] is that leg — and it expresses
//! rate 0 as a **pause**, not as a speed, because the reference's rate belongs to a bone while a speed
//! here belongs to a clip that other code copies from ([`AnimRateFreeze`] carries the story).
//!
//! Types 2, 7 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, the
//! "Glowy (Red)" 13) and have **no verified mechanism** — they fall through [`node_for`]'s single `_`
@@ -319,33 +320,50 @@ fn tint_probe() -> Option<[u8; 3]> {
})
}
/// On a rig whose clocks a proc-11 aura is holding: the playback speeds we took over, so the release
/// can hand them back — the reference's own `+0x60`/`+0x64`/`+0x68` (`0x6201d0` saves the three bone
/// rates before overwriting them; `0x6203e0` writes them back when the node expires with the aura).
/// Marker: this rig's clocks are being held by a proc-11 aura, so the release knows to let them go.
///
/// Keyed by animation node because that is what a speed belongs to here: the reference saves per
/// *bone* because a rate is a bone-clock property there, and ours is per armed clip.
/// It carries **nothing**, and that is the fix for the first shipped version's bug. That one stored
/// the speed of every clip it took over and wrote the values back on release — the reference's own
/// `+0x60`/`+0x64`/`+0x68` save/restore, transliterated one level too low. It cannot work here,
/// because in the reference a rate belongs to a *bone* (one value, saved once, and clips armed later
/// never carry one) while ours would have to belong to a *clip*, and clips get armed under the freeze
/// carrying values copied from other, already-frozen clips. `transplant_up` is exactly that: moving a
/// one-shot to the torso overlay copies the source clip's speed, which under the freeze was `0`, so
/// the overlay was saved at `0`, restored to `0`, and never advanced again — a cast pose welded to the
/// upper body for the rest of the session, surviving every later cast (it never *finished*, so the
/// overlay never released). Pausing owns no value and so cannot poison one.
#[derive(Component, Default, Debug)]
pub(crate) struct AnimRateFreeze {
saved: Vec<(AnimationNodeIndex, f32)>,
}
pub(crate) struct AnimRateFreeze;
/// Hold every unit whose head proc-11 node says so at that node's rate — its own rig and its mount's
/// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and hand the speeds back
/// when the aura goes.
/// Hold the clocks of every unit whose head proc-11 node says so — its own rig and its mount's
/// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and let them go when the aura
/// does.
///
/// **A rate of 0 is expressed as a PAUSE, not as `set_speed(0.0)`.** They are the same picture on
/// screen (Bevy evaluates a paused animation at its frozen seek, exactly as `0x712910`'s `bias` rebase
/// holds the reference's current frame) but not the same object: a pause is a bit the clock reads,
/// where a speed is a *value* other code copies. `transplant_up` copies a clip's speed when it moves a
/// one-shot to the torso overlay, and that is how the first version welded a cast pose to the upper
/// body permanently — see [`AnimRateFreeze`] and decision 0891. Nothing here reads or writes a speed, so nothing can
/// inherit the freeze and outlive it.
///
/// **Reasserted every frame, and that is the faithful shape, not a belt-and-braces clamp.** The
/// reference's rate lives on the bone (`+0xb0`, the factor `timebase.md` §2's clock multiplies its
/// window by) and **arming an animation does not touch it**: op4 `0x7121a0`'s success leg writes the
/// track index and the cursors and leaves `+0xb0` alone (only its disarm leg and `SetBoneAnimSpeed
/// 0x712910` write it). So "rate 0 until the aura is reaped" is literally the reference's state
/// across every re-arm in between, and re-writing it each frame is how a per-clip speed says the
/// same thing.
/// across every re-arm in between, and re-pausing each frame is how a per-clip flag says the same
/// thing — including for clips armed *under* the freeze, which the reference freezes by construction.
///
/// It runs **after the driver**, so this frame's arms are already in — which is the whole Ice Block
/// observable: the cast one-shot is armed and then frozen at the frame it was on, so the caster never
/// gets to raise their hand (`0x712910` re-bases `bias` to preserve the current frame, so rate 0
/// holds the pose exactly where it stood).
/// gets to raise their hand.
///
/// **A non-zero rate does nothing** (and only kit 1744's `8947848.0` is one — a packed grey that
/// landed in the rate column, on Stoned / Petrification / Thadius Spawn). Our clock is f32 seconds
/// where the reference's is integer milliseconds with a modulo, so a rate that large is noise there
/// and different noise here; there is no honest transliteration, and inventing one would be worse than
/// saying so. Recorded, not special-cased.
///
/// What it deliberately does **not** touch: attached effect models (the ice block's own
/// `icebarrier_state.mdx` keeps shimmering — the reference writes the unit's two models only) and
@@ -356,65 +374,59 @@ pub(crate) fn apply_aura_anim_rate(
&AuraNodes,
Option<&crate::entities::mount::MountChild>,
)>,
mut rigs: Query<(&mut AnimationPlayer, Option<&mut AnimRateFreeze>)>,
mut rigs: Query<(&mut AnimationPlayer, Has<AnimRateFreeze>)>,
mut commands: Commands,
) {
for (entity, nodes, mount) in &units {
let rate = nodes.head_anim_rate();
let frozen = nodes.head_anim_rate() == Some(0.0);
for rig in [Some(entity), mount.map(|m| m.0)].into_iter().flatten() {
let Ok((mut player, freeze)) = rigs.get_mut(rig) else {
let Ok((mut player, held)) = rigs.get_mut(rig) else {
continue;
};
match (rate, freeze) {
(Some(rate), Some(mut freeze)) => hold(&mut player, &mut freeze, rate),
(Some(rate), None) => {
let mut fresh = AnimRateFreeze::default();
hold(&mut player, &mut fresh, rate);
trace_clock_edge("freeze", rig, rate, &fresh);
commands.entity(rig).try_insert(fresh);
}
(None, Some(freeze)) => {
for (node, speed) in &freeze.saved {
if let Some(anim) = player.animation_mut(*node) {
anim.set_speed(*speed);
}
match (frozen, held) {
(true, _) => {
for (_, anim) in player.playing_animations_mut() {
anim.pause();
}
trace_clock_edge("thaw", rig, 1.0, &freeze);
if !held {
trace_clock_edge("freeze", rig, &player);
commands.entity(rig).try_insert(AnimRateFreeze);
}
}
(false, true) => {
// Resume everything: the set we paused is "whatever was playing", and it grew
// every frame the freeze held. Nothing else on a unit rig pauses an animation
// (the portrait booth does, on its own rigs), so there is no third party's pause
// to trample.
for (_, anim) in player.playing_animations_mut() {
anim.resume();
}
trace_clock_edge("thaw", rig, &player);
commands.entity(rig).try_remove::<AnimRateFreeze>();
}
(None, None) => {}
(false, false) => {}
}
}
}
}
/// Take every playing animation's clock to `rate`, remembering the speed each one was running at the
/// first time we touch it — a clip armed *during* the freeze is saved on its own first frame, so the
/// release hands back what the driver meant rather than a blanket 1.0.
fn hold(player: &mut AnimationPlayer, freeze: &mut AnimRateFreeze, rate: f32) {
for (node, anim) in player.playing_animations_mut() {
if !freeze.saved.iter().any(|(n, _)| n == node) {
freeze.saved.push((*node, anim.speed()));
}
anim.set_speed(rate);
}
}
/// The freeze's own instrument (`WOW_MOVE_TRACE`, tag `aur`): one line per rig at each edge, naming
/// the clips it took over and the speeds it is holding — the difference between "the clocks stopped"
/// and "nothing was playing anyway", which no count of *absent* anim events can tell apart.
fn trace_clock_edge(what: &str, rig: Entity, rate: f32, freeze: &AnimRateFreeze) {
/// the clips it took over and the speed each is *left* running at — the difference between "the clocks
/// stopped" and "nothing was playing anyway", which no count of *absent* anim events can tell apart.
///
/// It prints the speeds precisely because the first version's bug was invisible without them: a `@0`
/// on the thaw line is a clip that will never advance again.
fn trace_clock_edge(what: &str, rig: Entity, player: &AnimationPlayer) {
if !crate::dbg_trace::enabled_for("aur") {
return;
}
let clips: Vec<String> = freeze
.saved
.iter()
.map(|(n, s)| format!("{}@{s}", n.index()))
let clips: Vec<String> = player
.playing_animations()
.map(|(n, a)| format!("{}@{}", n.index(), a.speed()))
.collect();
crate::dbg_trace::line(
"aur",
&format!("{what} rig={rig} rate={rate} clips=[{}]", clips.join(" ")),
&format!("{what} rig={rig} clips=[{}]", clips.join(" ")),
);
}
+82 -44
View File
@@ -773,39 +773,41 @@ fn app_with_freeze() -> App {
/// A rig mid-animation: two clips playing at the speeds the driver picked for them (a gait scaled to
/// the mover's speed, and a cast one-shot at 1.0 — the pair Ice Block catches).
fn rig_playing(speeds: [(u32, f32); 2]) -> AnimationPlayer {
fn rig_playing(speeds: &[(u32, f32)]) -> AnimationPlayer {
let mut player = AnimationPlayer::default();
for (node, speed) in speeds {
player
.play(AnimationNodeIndex::new(node as usize))
.set_speed(speed);
.play(AnimationNodeIndex::new(*node as usize))
.set_speed(*speed);
}
player
}
fn speed_of(app: &App, rig: Entity, node: u32) -> f32 {
app.world()
/// `(paused, speed)` of one armed clip — the two things the freeze is judged on: the clock is held,
/// and the *value* other code copies is untouched.
fn clip(app: &App, rig: Entity, node: u32) -> (bool, f32) {
let a = app
.world()
.entity(rig)
.get::<AnimationPlayer>()
.expect("the rig kept its player")
.animation(AnimationNodeIndex::new(node as usize))
.expect("the clip is still armed")
.speed()
.expect("the clip is still armed");
(a.is_paused(), a.speed())
}
/// **The director's retest, at the mechanism.** Ice Block's state kit carries proc 11 at rate 0, so
/// the unit's clocks stop dead: the run cycle it was mid-stride in and the cast one-shot that had
/// just been armed both hold the frame they were on — which is why the caster never gets to raise
/// their hand. Dropping the aura hands each clip back the speed the driver had given it, not a
/// blanket 1.0.
/// their hand. Dropping the aura lets both go again, at the speeds the driver gave them.
#[test]
fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() {
fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
let mut app = app_with_freeze();
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
rig_playing([(4, 1.35), (54, 1.0)]),
rig_playing(&[(4, 1.35), (54, 1.0)]),
))
.id();
app.update();
@@ -814,15 +816,16 @@ fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() {
assert_eq!(n.head_anim_rate(), Some(0.0), "kit 3709's proc 11 is 0");
// 9074175 = 0x8A75FF — the ice-blue the same kit tints the body with, beside the freeze.
assert_eq!(n.head_tint(), Some([0x8A, 0x75, 0xFF]), "kit 3709's proc 1");
assert_eq!(speed_of(&app, unit, 4), 0.0, "the gait stops mid-stride");
assert_eq!(speed_of(&app, unit, 54), 0.0, "so does the cast one-shot");
assert!(clip(&app, unit, 4).0, "the gait stops mid-stride");
assert!(clip(&app, unit, 54).0, "so does the cast one-shot");
// The SPEEDS are untouched, which is the whole point: nothing that copies a clip's rate
// (`transplant_up` does, to move a one-shot onto the torso overlay) can inherit the freeze.
assert_eq!(clip(&app, unit, 4).1, 1.35);
assert_eq!(clip(&app, unit, 54).1, 1.0);
// A second frame changes nothing — the hold is idempotent, and it must not re-save the zero it
// wrote itself (that is what would make the release hand back 0.0 forever).
app.update();
assert_eq!(speed_of(&app, unit, 4), 0.0);
app.update(); // idempotent
// The aura leaves the slots: the reference writes the saved rates back (`0x6203e0`).
// The aura leaves the slots.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
@@ -836,41 +839,53 @@ fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() {
.unwrap()
.rate
.is_empty());
assert_eq!(
speed_of(&app, unit, 4),
1.35,
"the gait's own rate, restored"
);
assert_eq!(speed_of(&app, unit, 54), 1.0);
assert!(!clip(&app, unit, 4).0, "running again");
assert!(!clip(&app, unit, 54).0);
assert_eq!(clip(&app, unit, 4).1, 1.35, "at its own rate");
assert!(
app.world().entity(unit).get::<AnimRateFreeze>().is_none(),
"the release drops the save with it"
"the release drops the marker with it"
);
}
/// A clip armed *during* the freeze is saved on the frame we first touch it, so the release hands
/// back what the driver meant for it rather than the frozen 0.
/// **The bug the first shipped version had, pinned so it cannot come back.** A one-shot moved to the
/// torso overlay under the freeze (`transplant_up` copies the source clip's speed and seek) used to
/// be saved at the frozen `0`, restored to `0`, and left welded to the upper body for the rest of the
/// session — surviving every later cast, because a clip that never advances never *finishes* and so
/// never releases the overlay. Pausing carries no value to copy: the transplant inherits the driver's
/// real speed, and the thaw sets it running.
#[test]
fn a_clip_armed_under_the_freeze_is_saved_before_it_is_held() {
fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() {
let mut app = app_with_freeze();
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
rig_playing([(4, 1.35), (54, 1.0)]),
rig_playing(&[(4, 1.35), (54, 1.0)]),
))
.id();
app.update();
// The driver arms Stand at 1.0 a frame later (the root wiped the direction bits).
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimationPlayer>()
// `transplant_up`'s own shape: read the frozen source clip, arm the torso twin from it.
let speed = clip(&app, unit, 54).1;
assert_eq!(speed, 1.0, "the source's speed is not the freeze");
let seek = app
.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.play(AnimationNodeIndex::new(0))
.set_speed(1.0);
.animation(AnimationNodeIndex::new(54))
.unwrap()
.seek_time();
{
let mut ent = app.world_mut().entity_mut(unit);
let mut player = ent.get_mut::<AnimationPlayer>().unwrap();
let upper = player.play(AnimationNodeIndex::new(156));
upper.seek_to(seek);
upper.set_speed(speed);
}
app.update();
assert_eq!(speed_of(&app, unit, 0), 0.0, "held the frame it arrived on");
assert!(clip(&app, unit, 156).0, "held on arrival");
app.world_mut()
.entity_mut(unit)
@@ -878,7 +893,12 @@ fn a_clip_armed_under_the_freeze_is_saved_before_it_is_held() {
95, 0,
)])));
app.update();
assert_eq!(speed_of(&app, unit, 0), 1.0);
assert!(!clip(&app, unit, 156).0);
assert_eq!(
clip(&app, unit, 156).1,
1.0,
"the overlay runs out and releases instead of welding a cast pose on"
);
}
/// The mount comes with the rider: `0x6201d0` writes `[unit+0xdc]` before it writes `[unit+0xd8]`,
@@ -888,20 +908,20 @@ fn the_freeze_reaches_the_mount_body() {
let mut app = app_with_freeze();
let mount = app
.world_mut()
.spawn(rig_playing([(5, 2.0), (91, 1.0)]))
.spawn(rig_playing(&[(5, 2.0), (91, 1.0)]))
.id();
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])),
rig_playing([(91, 1.0), (54, 1.0)]),
rig_playing(&[(91, 1.0), (54, 1.0)]),
crate::entities::mount::MountChild(mount),
))
.id();
app.update();
assert_eq!(speed_of(&app, unit, 91), 0.0, "the rider");
assert_eq!(speed_of(&app, mount, 5), 0.0, "and the mount under them");
assert!(clip(&app, unit, 91).0, "the rider");
assert!(clip(&app, mount, 5).0, "and the mount under them");
app.world_mut()
.entity_mut(unit)
@@ -909,7 +929,8 @@ fn the_freeze_reaches_the_mount_body() {
95, 0,
)])));
app.update();
assert_eq!(speed_of(&app, mount, 5), 2.0);
assert!(!clip(&app, mount, 5).0);
assert_eq!(clip(&app, mount, 5).1, 2.0);
}
/// An aura with no proc 11 — Stealth — leaves the clocks entirely alone. The freeze is the kit's,
@@ -921,10 +942,27 @@ fn an_aura_without_the_proc_never_touches_a_clock() {
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])),
rig_playing([(4, 1.35), (54, 1.0)]),
rig_playing(&[(4, 1.35), (54, 1.0)]),
))
.id();
app.update();
assert_eq!(speed_of(&app, unit, 4), 1.35);
assert!(!clip(&app, unit, 4).0);
assert!(app.world().entity(unit).get::<AnimRateFreeze>().is_none());
}
/// Kit 1744's `8947848.0` is a rate our f32-seconds clock cannot express (the reference's is integer
/// ms with a modulo, where it is noise rather than a freeze). It installs a node — the data is read,
/// not dropped — and the apply leaves the clocks alone rather than inventing a look for it.
#[test]
fn a_non_zero_rate_installs_a_node_and_holds_nothing() {
let mut n = AuraNodes::new(1.0);
install(&mut n, 17624, &[AuraNode::AnimRate(8_947_848.0)], 0.0);
assert_eq!(n.head_anim_rate(), Some(8_947_848.0));
let mut app = app_with_freeze();
let unit = app.world_mut().spawn(rig_playing(&[(4, 1.35)])).id();
app.world_mut().entity_mut(unit).insert(n);
app.update();
assert!(!clip(&app, unit, 4).0);
assert_eq!(clip(&app, unit, 4).1, 1.35);
}
+9
View File
@@ -521,6 +521,14 @@ pub(crate) struct AnimDriver {
/// The gait id currently targeted while in [`Mode::Gait`] (so we cross-fade only on a change). `None`
/// forces a fresh selection (first eval, or after a Special state exits).
gait: Option<u16>,
/// The movement flags the **base slot** was last armed for — the reference has no per-one-shot
/// latch, so "the movement state changed" means *since the base last took a request*, never
/// "since this one-shot started" (decision 0894). The distinction is invisible until a one-shot
/// and a movement change land on the SAME frame: Ice Block's root wipes the direction bits in
/// the very frame the cast one-shot arrives, so an arm-time comparison sees no edge at all and
/// the cast keeps bone 0 — where the reference's next base request overwrites it and leaves the
/// character neutral.
gait_flags: u32,
/// The unit's **client-side sheath state** — the mirror of the client's committed CUR cache
/// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a
/// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from
@@ -661,6 +669,7 @@ impl Default for AnimDriver {
Self {
mode: Mode::Gait,
gait: None,
gait_flags: 0,
sheath_cur: None,
sheath_byte: None,
sheath_swap: None,
+40 -17
View File
@@ -845,11 +845,7 @@ pub(super) fn drive_animations(
if let Some((c, repeat)) = picked {
play_clip(&mut tr, &mut player, c, repeat, 1.0);
}
drv.mode = Mode::Swing {
id,
flags: mv.flags,
under: special,
};
drv.mode = Mode::Swing { id, under: special };
drv.gait = None;
base_played = true;
played_oneshot = Some(id);
@@ -1044,7 +1040,7 @@ pub(super) fn drive_animations(
drv.gait = None; // recompute a fresh gait next frame
}
}
Mode::Swing { id, flags, under } => {
Mode::Swing { id, under } => {
if special != under {
// The state this one-shot replaced CHANGED — the client's next event play
// supersedes it: a fresh jump/pose entry (`None → Some`), the FALLINGFAR
@@ -1125,25 +1121,47 @@ pub(super) fn drive_animations(
);
drv.mode = Mode::Looping(sp);
}
} else if oneshot_finished(&player, anims, id, catalog) || mv.flags != flags {
// Finished — or a movement-flag change: the client's locomotion re-arm lands
// on the change and blindly overwrites bone 0, one-shot or not (the same
// re-arm decision 0280 named for the un-finishable looping kit clip, and
} else if oneshot_finished(&player, anims, id, catalog)
|| mv.flags != drv.gait_flags
{
// Finished — or a movement-flag change: the client's base re-arm lands on the
// change and blindly overwrites bone 0, one-shot or not (the same re-arm
// decision 0280 named for the un-finishable looping kit clip, and
// Mode::Land's flag-change re-pick). Holding the clip out instead slides the
// post-shot runner over the ground on straight legs (director-observed vs
// ref). An EDGE, not a level — the split-boneless masked fallback enters
// here already moving, and steady flags must let that clip play out.
if mv.flags != flags {
//
// Against `drv.gait_flags` — what the **base** was last armed for — not this
// one-shot's own arm-time `flags` (decision 0894). The reference keeps no
// per-one-shot latch: a movement-state change requests the base and bone 0
// takes it, whenever the one-shot happened to start. Ice Block is the case that
// separates them — its root wipes the direction bits in the SAME frame the cast
// one-shot arrives, so the arm-time compare sees no edge ever and the cast held
// bone 0 for the whole block; against the base's flags the edge is still there,
// Stand overwrites the cast, and the character is neutral when the freeze lands.
if mv.flags != drv.gait_flags {
// The locomotion re-arm is a normal PlayAnimation — the deferred-combat
// cache clears with it (decision 0406; a finished clip instead had its
// cache consumed by the injection above, before this machine ran).
drv.deferred = None;
// …and it is a LOCOMOTION request, so a still-playing CAST/COMBAT clip
// transplants up to the key-bone instead of being overwritten (decision
// 0878). A *finished* clip has nothing to move: the client's descriptor
// probe reports a completed slot as id −1 (`0x5fe1f0` reads the completion
// latch, not the armed record), so the transplant predicates never see it.
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
// …and **if** the re-arm resolves to a LOCOMOTION id, a still-playing
// CAST/COMBAT clip transplants up to the key-bone instead of being
// overwritten (decision 0878; the client's gate is `0x5fee80` on the
// *requested* id, `0x5fe912`). A *finished* clip has nothing to move: the
// client's descriptor probe reports a completed slot as id −1 (`0x5fe1f0`
// reads the completion latch, not the armed record), so the transplant
// predicates never see it.
//
// The gate was missing here, and Ice Block is what it costs (decision
// 0894): a stun's root wipes the direction bits, so the flag change re-arms
// to **Stand(0)** — not locomotion — and the reference *overwrites* the
// cast on bone 0, leaving the character fully neutral for the freeze to
// catch. Transplanting unconditionally moved it to the torso instead and
// froze an arm out.
if select::gait_is_locomotion(&mv, walk) {
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
}
}
drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame
@@ -1377,6 +1395,11 @@ pub(super) fn drive_animations(
} else {
drv.gait = Some(target); // no clip (bind pose) — record the target so we don't churn
}
// The base is now arm-consistent with this movement state — every branch above
// leaves `drv.gait == Some(target)`. A one-shot that displaces it later reads
// this, not its own arm-time flags, to know whether the movement state has
// moved on since (decision 0894).
drv.gait_flags = mv.flags;
// Leaving the ranged idle (moving, sheath change, cancel) drops the hold —
// the next shooting session opens with a fresh nock.
if ranged_load.is_none() {
@@ -649,7 +649,6 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
drv(&app, spin_last).mode,
super::super::select::Mode::Swing {
id: 16,
flags: 0,
under: None,
},
"the first arrival holds the body"
@@ -662,7 +661,6 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
drv(&app, swing_last).mode,
super::super::select::Mode::Swing {
id: 57,
flags: 0,
under: None,
},
"the spin holds the body through the later swing"
@@ -712,7 +710,6 @@ fn deferred_combat_clip_plays_once_the_body_frees() {
drv.mode,
super::super::select::Mode::Swing {
id: 16,
flags: 0,
under: None,
},
"the parked swing armed"
@@ -779,7 +776,6 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
mode(&app),
super::super::select::Mode::Swing {
id: 16,
flags: 0,
under: None,
},
"standing swing holds the base track"
@@ -1331,7 +1327,6 @@ fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() {
drv(&app, unit).mode,
super::super::select::Mode::Swing {
id: 54,
flags: move_flags::FALLING,
under: Some(super::super::select::Special::Jump),
},
"the cast replaces the hang full-body — never dropped, never masked"
@@ -2114,3 +2109,100 @@ fn a_melee_to_ranged_toggle_stows_both_hands_before_it_reaches_for_the_bow() {
"the ceremony must end with the pin dropped, leaving the committed state (saw {seen:?})"
);
}
/// **Ice Block, at the mechanism (decision 0894).** The stun's root wipes the direction bits in the
/// SAME frame the cast one-shot arrives, so the one-shot's own arm-time flags already read ROOT and
/// an arm-time comparison sees no movement edge — ever. The base's flags still hold the run it was
/// armed for, so the edge is there, the re-arm resolves to **Stand(0)** (not locomotion → no
/// transplant), and Stand overwrites the cast on bone 0: the character is fully neutral, with
/// nothing on the torso, by the time the freeze catches it. Before this the cast held bone 0 for the
/// whole block, or rode up to the torso and froze an arm out.
#[test]
fn a_root_landing_with_the_cast_returns_the_body_to_neutral() {
let mut app = app();
let unit = jumper(&mut app);
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
speed: 7.0,
..Default::default()
});
app.update();
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
let overlay = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.map(|o| o.id)
};
assert_eq!(gait(&app), Some(5), "running");
// One frame: the root lands (direction bits wiped) AND the cast one-shot arrives.
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::ROOT,
..Default::default()
});
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 54,
seq: 1,
});
app.update();
// The cast takes bone 0 and the base re-arm displaces it inside the same frame — the reference's
// one-slot last-writer-wins, which is exactly what the base's flags (still FORWARD) unlock.
assert_eq!(
mode(&app),
super::super::select::Mode::Gait,
"the base request wins bone 0 back"
);
assert!(
overlay(&app).is_none(),
"no torso transplant: Stand(0) is not a locomotion request"
);
app.update();
assert_eq!(gait(&app), Some(0), "fully neutral standing");
assert!(overlay(&app).is_none(), "and nothing left on the torso");
}
/// The control that keeps 0878 honest: the transplant still fires when the re-arm really *is*
/// locomotion. A cast fired standing, then the player runs — the legs take Run and the cast keeps
/// going on the torso, which is the whole of R-B.
#[test]
fn a_run_starting_under_a_cast_still_transplants_it_up() {
let mut app = app();
let unit = jumper(&mut app);
app.update(); // settle: Stand
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 54,
seq: 1,
});
app.update();
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
let overlay = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.map(|o| o.id)
};
assert!(matches!(
mode(&app),
super::super::select::Mode::Swing { id: 54, .. }
));
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
speed: 7.0,
..Default::default()
});
app.update();
assert_eq!(
overlay(&app),
Some(54),
"a locomotion re-arm moves the cast to the torso instead of cutting it"
);
}
+29 -12
View File
@@ -405,18 +405,16 @@ pub(super) enum Mode {
/// *edge* exits — the FALLINGFAR latch's Fall (played ONCE, at the latch edge
/// `0x61a820` — 0864's per-tick re-assert was §5-refuted, decision 0868; a clip armed
/// after the latch holds like any other) and the `0x602c60` land pick at touchdown. Grounded
/// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and, like
/// [`Mode::Land`], any movement-flag change from `flags` (captured at play time) re-picks
/// immediately: the client's locomotion re-arm overwrites bone 0 blindly on the change (the
/// same re-arm decision 0280 named), so a shot fired standing yields to the run the instant
/// the player moves instead of sliding the clip out. The *masked* route never enters here —
/// it plays as an overlay beside `Mode`, leaving the base machine (this enum) running
/// underneath.
Swing {
id: u16,
flags: u32,
under: Option<Special>,
},
/// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and any
/// movement-flag change since the **base** was last armed
/// ([`AnimDriver::gait_flags`](crate::creature_anim::AnimDriver)) re-picks immediately: the
/// client's base re-arm overwrites bone 0 blindly on the change (the same re-arm decision 0280
/// named), so a shot fired standing yields to the run the instant the player moves instead of
/// sliding the clip out. It carried its own arm-time flags until 0894, which made the edge
/// invisible whenever the one-shot and the movement change landed on the same frame. The
/// *masked* route never enters here — it plays as an overlay beside `Mode`, leaving the base
/// machine (this enum) running underneath.
Swing { id: u16, under: Option<Special> },
}
/// The gait an idle/moving unit should play in [`Mode::Gait`], most-specific id first with fallbacks so a
@@ -975,6 +973,25 @@ pub(super) fn is_locomotion(id: u16) -> bool {
RATE_SCALED.contains(&id)
}
/// Whether the gait this movement state resolves to is a **locomotion** id — i.e. whether a
/// movement-flag re-arm is the transplant's `0x5fee80` request or an ordinary bone-0 overwrite.
///
/// The re-arm at a flag change has no id in hand yet (the gait is recomputed the following frame),
/// so this asks [`gait_candidates`] what it *will* pick. `ready`/`ranged_load` are passed `None`
/// deliberately and it changes no answer: both are **standing** idles that locomotion outranks in
/// the cascade, so a moving unit picks its locomotion id whether or not they are set, and a still
/// one picks something that is not locomotion either way.
///
/// This is the gate Ice Block turns on. A stun's root wipes the direction bits, the flag change
/// re-arms, and the pick is **Stand(0)** — *not* locomotion — so the cast one-shot on bone 0 is
/// **overwritten** rather than transplanted onto the torso: the character goes fully neutral before
/// the freeze catches it (decision 0894, the director's reading of the reference client).
pub(super) fn gait_is_locomotion(state: &MovementState, walk_speed: f32) -> bool {
gait_candidates(state, walk_speed, None, None)
.first()
.is_some_and(|id| is_locomotion(*id))
}
/// The playback rate for a clip given the unit's live speed (wow-5875-re `0x5fe2f0`): `speed / moveSpeed`
/// for a rate-scaled locomotion clip, else 1×. (The client also divides by the model's scale magnitude,
/// 1.0 for an unscaled character.) Guarded on `moveSpeed > 0`.
+32
View File
@@ -249,6 +249,9 @@ mod tests {
use super::*;
use super::test_template as info;
use benilla_protocol::messages::{
ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER,
};
use crossbeam_channel::TryRecvError;
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
@@ -291,6 +294,35 @@ mod tests {
assert!(items.take_fresh().is_empty(), "a drain empties the queue");
}
/// The one predicate both halves of the right-click-to-open affordance ask
/// (`ItemInfo::openable`): the tooltip's green line and the `CMSG_OPEN_ITEM` fork. Its whole
/// subtlety is the two sub-gates — a `LockID` template stays shut until the INSTANCE carries
/// UNLOCKED, and a wrapper template needs the instance's WRAPPED bit — so a template-only
/// view (instance flags 0) can never be openable.
#[test]
fn openable_is_the_lootable_bit_behind_its_lock_and_gift_sub_gates() {
let plain = |flags: u32, lock_id: u32| {
let mut t = info("Small Barnacled Clam");
t.flags = flags;
t.lock_id = lock_id;
t
};
// The clam: LOOTABLE, no lock — openable outright, instance flags irrelevant.
assert!(plain(ITEM_FLAG_LOOTABLE, 0).openable(0));
// An ordinary item is never openable, however its instance is flagged.
assert!(!plain(0, 0).openable(ITEM_DYNFLAG_UNLOCKED | ITEM_DYNFLAG_WRAPPED));
// A junkbox: LOOTABLE but locked — shut until the instance says UNLOCKED.
assert!(!plain(ITEM_FLAG_LOOTABLE, 7).openable(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 7).openable(ITEM_DYNFLAG_UNLOCKED));
// Gift wrap: the WRAPPER template opens only while the instance is still WRAPPED.
assert!(!plain(ITEM_FLAG_WRAPPER, 0).openable(0));
assert!(plain(ITEM_FLAG_WRAPPER, 0).openable(ITEM_DYNFLAG_WRAPPED));
// The wrapped arm ignores the lock gate entirely — it is the sibling `||`, not a nested
// case: a wrapped gift of a locked box opens as the gift.
assert!(plain(ITEM_FLAG_WRAPPER | ITEM_FLAG_LOOTABLE, 7).openable(ITEM_DYNFLAG_WRAPPED));
}
#[test]
fn negative_template_answer_is_cached() {
let (cmds, rx) = commands();
+6
View File
@@ -600,6 +600,12 @@ pub(crate) enum ClientCommand {
spell_index: u8,
go_target: Option<u64>,
},
/// Open a bag item (`CMSG_OPEN_ITEM`, same bag addressing) — the drain's fork for an
/// *openable* click (`ItemInfo::openable`): a clam, an unlocked lockbox, a wrapped gift. The
/// server answers `SMSG_LOOT_RESPONSE` on the **item's own guid**, so the ordinary loot feed
/// opens a window over a thing in the bag; a wrapped gift instead swaps entry in place and
/// sends no window. Refusals arrive as `InventoryFailure` (still locked, dead, flying).
OpenItem { bag_index: u8, slot: u8 },
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an
/// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back
/// as `InventoryFailure` events onto the UI error line.
+1
View File
@@ -663,6 +663,7 @@ fn writer_loop(
spell_index,
go_target,
} => w.use_item(bag_index, slot, spell_index, go_target),
ClientCommand::OpenItem { bag_index, slot } => w.open_item(bag_index, slot),
ClientCommand::AutoEquipItem { bag_index, slot } => {
w.auto_equip_item(bag_index, slot)
}
+23 -1
View File
@@ -31,6 +31,8 @@ use crate::ui_script::PointerOverUi;
mod arc;
mod camera;
mod follow;
mod gait;
// The land-here affordance (free-fly's other half). `pub(crate)` for its `LandHere` message, which
// the debug panel's button writes.
@@ -54,11 +56,14 @@ mod wire_in;
// render-alpha field, so the unit-lane material-alpha compose (`entities::apply_unit_mat_alpha`)
// orders itself before it and lets that documented override stand.
pub(crate) use camera::apply_self_model_fade;
// `/follow` (decision 0890): chat asks with the message, `crate::target` resolves the subject into
// the state, and this module owns the motion.
use camera::{
apply_zoom_scroll, run_look_session, seat_camera, CameraProbe, FlyCam, LookButton,
CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, CAM_PIVOT_FALLBACK,
};
pub(crate) use camera::{head_height, CameraControl, CameraPivot, WorldCamera, CAM_NEAR};
pub(crate) use follow::{FollowRequest, FollowState};
// The shared avatar state + movement constants live in [`state`]; the private re-imports below are
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
use state::{
@@ -125,6 +130,7 @@ impl Plugin for PlayerPlugin {
if let Some(cam) = probe_cam::from_env() {
app.insert_resource(cam);
}
follow::plugin(app);
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
// The world camera renders only when the world can be seen (decision 0540): in world,
// or under the opaque loading screen (whose covered render is what compiles the
@@ -204,6 +210,17 @@ impl Plugin for PlayerPlugin {
.in_set(WorldStage::Input)
.before(control),
)
// `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward
// input immediately BEFORE the controller, which folds the flag into its forward axis.
// The player's own turn input therefore runs after us and wins, which is exactly what
// makes the turn-away cancel reachable.
.add_systems(
Update,
follow::steer_follow
.in_set(WorldStage::Input)
.before(control)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The self-avatar zoom-in fade rides the same `MeshTag`/material channel as the interior
// classifier + the appear/despawn fades, so it must run *after* both to win the frame while
// fading (and yield to them otherwise). It also writes `Visibility` (the first-person
@@ -625,8 +642,13 @@ fn control(
// and the bit survives — and the client emits MSG_MOVE_STOP with S still held. The other order
// (autorun, then S) destroys the bit at key-down and walks you backward. Same two keys, two
// outcomes; that asymmetry is the whole shape of the feature.
// `/follow` enters as the FORWARD term, not a fifth source (decision 0890): the reference's
// follow pushes the very same move-forward bit `0x100000` the W key does, through the same
// setter, so it nets against a held S and diagonals with a strafe exactly like a held W. It
// rides the HELD state and not the key-DOWN edge, so it never trips the autorun cancel set
// above — which is right: synthesized input is not a keypress.
let fwd_axis = state::forward_axis(
keys_pressed(KeyCode::KeyW),
keys_pressed(KeyCode::KeyW) || player.follow_forward,
keys_pressed(KeyCode::KeyS),
both_buttons,
autorun,
+342
View File
@@ -0,0 +1,342 @@
//! `/follow` — the auto-follow movement mode (decision 0890).
//!
//! The finding that shapes this module: **follow synthesizes keyboard input.** It owns no
//! translation of its own. Every state change in the reference funnels through the movement
//! singleton's setter (`0x60e790` sets the move-forward bit `0x100000`, `0x60e7f0` clears it) — the
//! same setter the MoveForward keybinding drives, one of ~40 stubs in the keybinding command table
//! at `0x513de0`-`0x514273`. So follow is "hold W for me, and steer", and the right implementation
//! reuses the controller wholesale rather than growing a second mover (wow-re
//! `object-layer/scratch/targeting-by-name.md` PART D, §5-cross-checked).
//!
//! It also sends **nothing on the wire** — corroborated against vmangos, which has no follow opcode
//! in 1.12.1. The server only ever sees the ordinary movement stream our synthesized input produces.
//!
//! ## The law
//!
//! - **Facing is steered, never snapped** (`0x6103d0`/`0x6108xx`): turn toward the followee at
//! [`TURN_RATE`] = π rad/s (180°/s), clamped to `min(remaining, rate × elapsed)`, and stop turning
//! inside a [`TURN_DEADZONE`] of 0.001 rad. That rate limit is why follow *reads* like a character
//! running rather than a camera snapping.
//! - **Beeline, re-aimed every tick** (`0x610e40`): the guid and both positions are re-resolved every
//! tick and nothing is cached. No path, no spline — `0x670630` is a *getter* over the click-to-move
//! order, which follow reads and never writes.
//! - **It never reads the followee's speed** (VERIFIED negative, whole-graph scan). You move at
//! whatever a held forward key yields for *you*, which is why follow falls behind a faster runner.
//! - **A hysteresis band, not a stop distance** — see [`should_move`]. Arrive is inclusive, resume is
//! one-directional. The gap between the two is why follow visibly starts and stops rather than
//! juddering on a single threshold.
//!
//! ## The one INFERRED rule
//!
//! The reference cancels follow when the player turns ~180° away (band 160°-220°), but wow-re could
//! not trace *what that angle is measured against* — it is explicitly flagged unresolved. Measuring
//! it against the live bearing alone cannot be right: starting a follow while facing away from the
//! followee would then cancel on the first tick, before the steer had a chance to turn us.
//!
//! So this module latches [`FollowState::aligned`] the first time the facing comes inside the band
//! and only cancels *after* that. Observable behaviour: starting a follow facing any direction works
//! and turns you around; deliberately turning around once you are following drops it. That matches
//! what the band is plainly *for*, and is marked INFERRED until the reference angle is pinned.
use std::f32::consts::PI;
use bevy::prelude::*;
use crate::net::GuidIndex;
use super::state::{MoveSpeed, Player};
/// Follow's facing-turn rate, rad/s — `0xc4d93c`, seeded `0x40490fdb` = π at `0x6111f9`. Its own
/// constant, distinct from the keyboard turn rate.
const TURN_RATE: f32 = PI;
/// Inside this many radians of the bearing, follow stops turning (`0x6108bb` → `0x60e920`).
const TURN_DEADZONE: f32 = 0.001;
/// The speed the distance thresholds are normalised by — `.rdata 0x80c4d0` = 7.0, which is also
/// vanilla's base run speed, so at normal speed the band is exactly 3.0 / 4.5 yd.
const SPEED_NORM: f32 = 7.0;
/// The base stop distance — `.rdata 0x80c4c0` = 3.0, a compile-time constant (its three writers are
/// CRT dynamic initializers behind `_initterm`), **not** a cvar.
const STOP_DISTANCE: f32 = 3.0;
/// Resume is 1.5× the stop distance, and its speed scale is floored at 1.0 — so the band never
/// closes below 3.0 / 4.5 yd however slowly you are moving.
const RESUME_FACTOR: f32 = 1.5;
/// How far off the bearing the facing must sit for a deliberate turn to drop the follow. The
/// reference's band is 160°-220°; wrapped into `[0, π]` that is "at least 160° off".
const CANCEL_ANGLE: f32 = 160.0 * PI / 180.0;
/// Who we are following, and the hysteresis latch — the reference's followed-guid pair `0xc4d980`
/// (armed `0x6111c9`, cleared `0x60fc1e`) plus the state its band implies.
#[derive(Resource, Default)]
pub(crate) struct FollowState {
/// The followee's guid, or `None` when not following.
pub(crate) guid: Option<u64>,
/// Whether the synthesized forward input is currently held. The band's latch: which threshold
/// applies depends on which side we are already on.
moving: bool,
/// Whether the facing has come inside [`CANCEL_ANGLE`] at least once since the follow began —
/// the guard on the INFERRED turn-away cancel (see the module header).
aligned: bool,
/// `WOW_FOLLOW_TRACE` bookkeeping: when the last trace line went out, and where we were.
/// `None` until a tick has seeded it.
traced_at: f64,
traced_pos: Option<Vec3>,
}
impl FollowState {
/// Begin following `guid`, from a clean band.
pub(crate) fn start(&mut self, guid: u64) {
self.guid = Some(guid);
self.moving = false;
self.aligned = false;
self.traced_at = 0.0;
self.traced_pos = None;
}
/// Stop following. Returns whether we actually were.
pub(crate) fn stop(&mut self) -> bool {
self.moving = false;
self.aligned = false;
self.guid.take().is_some()
}
}
/// `/follow [name]` — start following. `None` is the bare form (`FollowUnit("target")`), whose
/// subject is the current selection; a name resolves **players only** and additionally through the
/// reference's filter mode 2 (`CanAssist` + alive), which is [`crate::target`]'s to apply.
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct FollowRequest {
pub(crate) name: Option<String>,
}
/// The distance at which follow **arrives** and lets go of the key (`0x610ad2`-`0x610b1b`):
/// `(speed / 7.0) × 3.0`, tested inclusively (`test ah,0x41; jp` — the `<=` edge).
fn arrive_distance(speed: f32) -> f32 {
(speed / SPEED_NORM) * STOP_DISTANCE
}
/// The distance at which a stopped follow **resumes** (`0x610bc4`-`0x610c2b`):
/// `3.0 × 1.5 × max(speed / 7.0, 1.0)` = 4.5 yd at normal run speed. Only consulted while stopped.
fn resume_distance(speed: f32) -> f32 {
STOP_DISTANCE * RESUME_FACTOR * (speed / SPEED_NORM).max(1.0)
}
/// Should the synthesized forward key be held this tick? The band, as a pure function of which side
/// we are already on — the whole reason follow starts and stops instead of juddering.
fn should_move(was_moving: bool, distance: f32, speed: f32) -> bool {
if was_moving {
// Arrive is inclusive, so we keep going only while strictly beyond it.
distance > arrive_distance(speed)
} else {
distance >= resume_distance(speed)
}
}
/// Wrap an angle into `(-π, π]`.
fn wrap_pi(a: f32) -> f32 {
let t = std::f32::consts::TAU;
let x = (a + PI).rem_euclid(t);
x - PI
}
/// The `face_yaw` that points at a horizontal delta in **Bevy** space.
///
/// Derived from the controller's own forward vector rather than from a coordinate convention:
/// `control` builds `move_fwd = Quat::from_rotation_y(face_yaw) * NEG_Z`, which expands to
/// `(-sin y, 0, -cos y)`. Solving `(-sin y, -cos y) ∝ (dx, dz)` gives `y = atan2(-dx, -dz)`. Tied to
/// the expression it must agree with, so it cannot drift out of sign with it.
fn bearing_to(delta: Vec3) -> f32 {
(-delta.x).atan2(-delta.z)
}
/// Turn `face` toward `bearing` by at most this tick's budget, or leave it alone inside the
/// deadzone. The reference's `min(remaining, rate × elapsed)` clamp.
fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
let remaining = wrap_pi(bearing - face);
if remaining.abs() <= TURN_DEADZONE {
return face;
}
let budget = TURN_RATE * dt;
face + remaining.clamp(-budget, budget)
}
/// Drive the follow: re-resolve the followee, steer the facing, and decide whether the synthesized
/// forward input is held this tick. Runs immediately **before** `control`, which reads
/// [`Player::follow_forward`] as one more term of its forward axis — the reference's shape exactly,
/// where follow pushes the same move-forward bit the W key does.
///
/// The player's own turn input runs *after* this in the same frame and therefore wins; that is what
/// makes the turn-away cancel reachable at all.
pub(super) fn steer_follow(
time: Res<Time>,
mut follow: ResMut<FollowState>,
mut player: ResMut<Player>,
speed: Res<MoveSpeed>,
index: Res<GuidIndex>,
transforms: Query<&Transform>,
) {
player.follow_forward = false;
let Some(guid) = follow.guid else { return };
// Re-resolved every tick, nothing cached — a followee that streams out ends the follow
// (`0x610e40` → `0x6106e7`).
let Some(target) = index
.0
.get(&guid)
.and_then(|e| transforms.get(*e).ok())
.map(|t| t.translation)
else {
info!("follow: the followee is gone — follow ends");
follow.stop();
return;
};
let delta = target - player.pos;
let flat = Vec3::new(delta.x, 0.0, delta.z);
let distance = flat.length();
if distance < f32::EPSILON {
return;
}
let bearing = bearing_to(flat);
// The INFERRED turn-away cancel (module header): only armed once the facing has come inside the
// band, so beginning a follow while facing away turns us around instead of cancelling.
let off_bearing = wrap_pi(bearing - player.face_yaw).abs();
if off_bearing < CANCEL_ANGLE {
follow.aligned = true;
} else if follow.aligned {
info!("follow: turned away from the followee — follow ends");
follow.stop();
return;
}
player.face_yaw = steer(player.face_yaw, bearing, time.delta_secs());
let moving = should_move(follow.moving, distance, speed.value);
if moving != follow.moving {
info!(
"follow: {} at {distance:.1} yd (arrive {:.1}, resume {:.1}, speed {:.1})",
if moving { "running" } else { "arrived" },
arrive_distance(speed.value),
resume_distance(speed.value),
speed.value,
);
}
// `WOW_FOLLOW_TRACE=1` — the field instrument for "follow won't catch up / overshoots": one
// line a second carrying the closing distance and the ground we actually covered, so the
// travel rate is a measured number rather than an end-to-end guess (decision 0404: timing and
// feel are measured, never eyeballed). Gated because this one IS per-frame.
if follow_trace_on() {
let now = time.elapsed_secs_f64();
if now - follow.traced_at >= 1.0 {
// Quiet until a tick has seeded a reference point: an instrument whose opening line is
// garbage is worse than one that says nothing for a second.
if let Some(from) = follow.traced_pos {
let elapsed = now - follow.traced_at;
let moved = player.pos.distance(from);
info!(
"follow-trace: {distance:.1} yd to go, covered {moved:.1} yd in {elapsed:.2} s \
({:.1} yd/s), moving={moving}",
moved / elapsed as f32,
);
}
follow.traced_at = now;
follow.traced_pos = Some(player.pos);
}
}
follow.moving = moving;
player.follow_forward = moving;
}
/// `WOW_FOLLOW_TRACE=1` — see [`steer_follow`]. One `OnceLock` read per tick when unset.
fn follow_trace_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_FOLLOW_TRACE").is_some())
}
/// Register the follow mode. The steer runs in the input stage just before `control`, so the flag
/// and the facing it writes are what the controller reads this same frame.
pub(super) fn plugin(app: &mut App) {
app.init_resource::<FollowState>()
.add_message::<FollowRequest>();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_band_is_three_and_four_point_five_yards_at_normal_speed() {
assert_eq!(arrive_distance(7.0), 3.0);
assert_eq!(resume_distance(7.0), 4.5);
}
#[test]
fn resume_scales_with_speed_but_never_below_the_base_band() {
// Above base speed both thresholds stretch…
assert_eq!(arrive_distance(14.0), 6.0);
assert_eq!(resume_distance(14.0), 9.0);
// …below it, arrive shrinks but resume is FLOORED at the 1.0 scale (the `max` in `0x610bfd`),
// so the band can never invert.
assert!(arrive_distance(3.5) < 3.0);
assert_eq!(resume_distance(3.5), 4.5);
}
#[test]
fn the_hysteresis_band_latches_on_both_edges() {
// Moving: keep going until strictly inside arrive (which is inclusive, so 3.0 stops).
assert!(should_move(true, 3.001, 7.0));
assert!(!should_move(true, 3.0, 7.0), "arrive is inclusive");
// Stopped: nothing happens until resume, so the gap between 3.0 and 4.5 is dead in BOTH
// directions — that gap is the whole point of the band.
assert!(!should_move(false, 4.0, 7.0));
assert!(should_move(false, 4.5, 7.0));
}
#[test]
fn bearing_agrees_with_the_controllers_own_forward_vector() {
// The invariant that keeps this from drifting out of sign with `control`.
for (dx, dz) in [
(0.0, -1.0),
(1.0, 0.0),
(0.0, 1.0),
(-1.0, 0.0),
(3.0, -4.0),
] {
let delta = Vec3::new(dx, 0.0, dz);
let yaw = bearing_to(delta);
let fwd = Quat::from_rotation_y(yaw) * Vec3::NEG_Z;
let want = delta.normalize();
assert!(
(fwd.x - want.x).abs() < 1e-5 && (fwd.z - want.z).abs() < 1e-5,
"yaw {yaw} should point at ({dx}, {dz}), got ({}, {})",
fwd.x,
fwd.z
);
}
}
#[test]
fn the_turn_is_rate_limited_and_has_a_deadzone() {
// A quarter turn at 180°/s takes 0.5 s, so one 0.1 s tick covers 18°, not the whole 90°.
let after = steer(0.0, PI / 2.0, 0.1);
assert!(
(after - TURN_RATE * 0.1).abs() < 1e-6,
"clamped to the budget"
);
// Within the budget it lands exactly on the bearing rather than overshooting.
assert!((steer(0.0, 0.05, 1.0) - 0.05).abs() < 1e-6);
// Inside the deadzone it does not move at all.
assert_eq!(steer(1.0, 1.0 + TURN_DEADZONE / 2.0, 1.0), 1.0);
}
#[test]
fn steering_takes_the_short_way_round() {
// Bearing just past -π from a facing just under π: the turn must be a small positive step,
// not a near-full sweep back the other way.
let after = steer(PI - 0.05, -PI + 0.05, 1.0);
assert!(
wrap_pi(after - (PI - 0.05)) > 0.0,
"should wrap forward across ±π"
);
}
}
+6
View File
@@ -379,6 +379,12 @@ pub(crate) struct Player {
/// places the other forward sources do — the direction vector, the wire flags, the swim
/// amounts, and the turn-rate's "am I translating" test.
pub(super) autorun: bool,
/// **`/follow` is holding the forward key this frame** (decision 0890). Not a mode of its own:
/// the reference's follow owns no translation and simply pushes the same move-forward bit the W
/// key does (`0x60e790`), so this folds into [`forward_axis`] beside `autorun` and the
/// both-button run rather than driving the mover itself. Rewritten every frame by
/// [`super::follow::steer_follow`], which runs just before the controller reads it.
pub(super) follow_forward: bool,
/// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen.
pub(crate) detached: bool,
/// Feet position in **Bevy** coords (converted to raw WoW only when sending to the server).
+533
View File
@@ -0,0 +1,533 @@
//! Selection **by name** — the shared resolver behind `/target` and `/assist` (decision 0886).
//!
//! The reference has ONE resolver, `0x493aa0`, parameterised per caller; `/target`, `/assist` and
//! `/follow` are the same search with different arguments (wow-re
//! `object-layer/scratch/targeting-by-name.md`, §5-cross-checked):
//!
//! | binding | typemask | filter mode | exact-only |
//! |---|---|---|---|
//! | `TargetByName 0x489d60` | 8 = UNIT (creatures **and** players) | 0 | Lua arg #2, never supplied |
//! | `AssistByName 0x489c40` | 0x10 = **PLAYER only** | 0 | hardcoded 0 |
//! | `FollowByName 0x489ec0` | 0x10 = PLAYER only | 2 (`CanAssist` + alive) | Lua arg #2 |
//!
//! **Filter mode 0 is the whole point of the shape**: it underflows `0x493e40`'s jump table
//! (`dec eax; cmp eax,3; ja`) into the default arm `mov eax,1; ret 4` — *accept unconditionally*. So
//! by-name selection has **no** range limit, **no** facing cone, **no** dead/reaction/attackability
//! test, **no** scene-attach visibility gate, and **no** self-exclusion. It is a far wider net than
//! TAB ([`super::scan`]), which shares none of those defaults. `/target <your own name>` self-targets
//! in the real client, and so does it here.
//!
//! ## The comparison — two tiers, both case-insensitive
//!
//! 1. **Whole-string** (`0x64a4c0` → CRT `0x414310`, which folds both operands in its own bytes).
//! 2. **Longest common PREFIX** (`0x493cb6`-`0x493cf6`), anchored at index 0 — a prefix, never a
//! substring. Live whenever the second Lua argument is absent, which is every shipped call site,
//! so `/target Rag` really does select "Ragnaros". The running best length starts at **1**, so one
//! folded character of overlap is enough to enter the ranking — and a query sharing no first
//! letter with anything matches nothing.
//!
//! Within one prefix-length class the tie-break is the **strictly nearest** 3D centre-to-centre
//! dist² (the reference's `fcomp`/`jp` edge is a strict `<`, so ties keep the incumbent).
//!
//! ## The one deliberate deviation — and why
//!
//! In the reference a tier-1 whole-string hit **returns 0 from the walk callback, terminating the
//! enumeration** (`0x493ca2`, stopped at `0x4683b7`). Two consequences fall out, both
//! enumeration-order dependent: among several identically-named creatures the client takes
//! *whichever the ClntObjMgr table walk reaches first* — never the nearest — and a near "Bobby"
//! recorded before a far "Bob" beats it outright for the query `Bob`.
//!
//! **We do not reproduce the early-out**, and since 0886 was written the order it depends on has
//! been derived, so the choice is now made against a known reference rather than an unknown one.
//! Table #1 is an intrusive doubly-linked list (link field at `obj+0x38`, written by the ctor at
//! `0x4650c2`) whose insert is a **tail append** (`0x4646c3`-`0x4646d4`), so the walk runs
//! **oldest-linked first** — and "linked", not "spawned": a unit that streams out and back in is
//! re-appended to the tail. The reference's tier-1 pick is therefore *the same-named candidate that
//! has been continuously known longest*, which no client-side heuristic reproduces without modelling
//! the list itself.
//!
//! [`Rank::beats`] is instead a **total order** — exact beats any prefix, then longer prefix, then
//! strictly nearest — which is deterministic and picks the nearest of several same-named kobolds.
//! wow-re's note calls the reference behaviour here "the single behaviour most likely to look like a
//! bug in a reimplementation". Reproducing it is now *possible* (stamp each net entity with a
//! monotonic link sequence, re-stamped on stream-in, and order the walk by it); it is not done
//! because nearest is the better answer, not because the order is unknown. This is the one place to
//! change if that judgement is ever reversed.
//!
//! ## Not carried
//!
//! The reference pre-scans the **party** (`0x493b20`) and **raid** (`0x493b9d`) rosters before the
//! object walk, matching group members against the guid-keyed name cache at a sentinel distance of
//! 2.5e9 — so a groupmate with no streamed object is still selectable. benilla cannot hold a
//! guid-only selection at all yet ([`Selection::target`] is an `Entity`; the same gap already
//! no-ops `TargetUnit("partyN")` on an out-of-range member, see [`super::click`]), so the pre-scans
//! wait on that slice rather than being half-built here.
use std::cmp::Ordering;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::guid;
use crate::names::NameCache;
use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use super::{scan, Selection};
/// Which guid families a search accepts — the reference's `edx` typemask argument.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NameSearch {
/// Typemask 8: creatures **and** players (a player object carries the UNIT bit). `/target`.
AnyUnit,
/// Typemask 0x10: players only. `/assist` — so `/assist <creature name>` cannot resolve, while
/// assisting a *targeted* creature can (the no-argument form never goes through the resolver).
PlayerOnly,
}
impl NameSearch {
fn accepts(self, guid_val: u64) -> bool {
match self {
Self::AnyUnit => guid::is_player(guid_val) || guid::is_creature_or_pet(guid_val),
Self::PlayerOnly => guid::is_player(guid_val),
}
}
}
/// `/target <name>` — resolve and select (`TargetByName`).
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct TargetByNameRequest {
pub(crate) name: String,
}
/// `/assist [name]` — select the basis unit's own target (`AssistByName` / `AssistUnit("target")`).
/// `None` is the bare form, whose basis is the current selection.
#[derive(bevy::ecs::message::Message, Clone, Debug)]
pub(crate) struct AssistRequest {
pub(crate) name: Option<String>,
}
/// How well one candidate answered the query. Ordered by [`Rank::beats`].
#[derive(Clone, Copy, Debug, PartialEq)]
struct Rank {
/// A tier-1 whole-string (case-insensitive) match.
exact: bool,
/// Folded common-prefix length, capped by both strings — the reference's
/// `matchLen = searchCursor - ctx.name`.
prefix: usize,
/// 3D centre-to-centre squared distance from the active player.
dist2: f32,
}
impl Rank {
/// Strictly better than the incumbent? Exact beats any prefix; then the longer prefix; then the
/// strictly nearer. Ties keep the incumbent, and a NaN distance never displaces — both edges the
/// reference's `fcomp` + `test ah,5` + `jp` reject makes.
fn beats(self, other: Rank) -> bool {
match (self.exact, other.exact) {
(true, false) => true,
(false, true) => false,
_ => match self.prefix.cmp(&other.prefix) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => self.dist2 < other.dist2,
},
}
}
}
/// The folded common-prefix length of `query` and `name` — the reference's lockstep walk
/// (`0x493cc0`-`0x493cee`) through the ASCII case fold `0x41089b`, stopping at whichever string ends
/// first or at the first folded mismatch.
fn common_prefix_len(query: &str, name: &str) -> usize {
query
.bytes()
.zip(name.bytes())
.take_while(|(q, n)| q.eq_ignore_ascii_case(n))
.count()
}
/// Score one candidate against the query, or `None` when it does not match at all. The reference's
/// running best length seeds at **1**, so a zero-length overlap is not a match.
fn rank(query: &str, name: &str, dist2: f32) -> Option<Rank> {
if query.eq_ignore_ascii_case(name) {
return Some(Rank {
exact: true,
prefix: query.len(),
dist2,
});
}
let prefix = common_prefix_len(query, name);
(prefix >= 1).then_some(Rank {
exact: false,
prefix,
dist2,
})
}
/// Everything a by-name search reads. Shared by the `/target` and `/assist` drains.
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct ByNameScan<'w, 's> {
/// Every known unit INCLUDING our own avatar — the reference has no self-exclusion on this path.
units: Query<
'w,
's,
(
Entity,
&'static Guid,
&'static Transform,
Option<&'static ObjectStore>,
),
>,
self_q: Query<
'w,
's,
(
Entity,
&'static Guid,
&'static Transform,
Option<&'static ObjectStore>,
),
With<SelfPlayer>,
>,
/// Read-only: a by-name sweep must never fire a name query per candidate. A unit whose name has
/// not been cached yet is simply not matchable — the same limit the reference has, whose own
/// name getter reads an already-resolved string.
names: Res<'w, NameCache>,
/// Only [`Filter::AssistableAlive`] reads these — the reaction ladder behind `CanAssist`.
factions: Option<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
}
/// The per-candidate filter the reference hands `0x493e40` as its mode argument.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Filter {
/// **Mode 0** — and the shape is the point: `0-1` underflows the `cmp eax,3; ja` bound, so the
/// jump table is skipped entirely for `mov eax,1; ret 4`. Accept unconditionally. `/target`,
/// `/assist`.
AcceptAll,
/// **Mode 2** (`0x493eca`) — `CanAssist 0x6066f0` **and** `UNIT_FIELD_HEALTH > 0`. `/follow`
/// resolves only to living, assistable candidates, so `/follow <an enemy player>` finds nothing.
AssistableAlive,
}
impl ByNameScan<'_, '_> {
/// The active player's position — the distance origin (the reference returns `(0,0)` outright
/// when the active player object does not resolve, `0x493ae0`).
fn origin(&self) -> Option<Vec3> {
self.self_q
.single()
.ok()
.map(|(_, _, tf, _)| tf.translation)
}
/// The reference's mode-2 arm (`0x493eca`): `CanAssist` — the reaction ladder at **≥ 4**
/// (friendly; wow-re `w2d1` `CanAssist_Unit 0x6066f0`), the mirror of [`scan::can_attack`]'s
/// `≤ 3` — **and** alive.
fn assistable_alive(&self, store: Option<&ObjectStore>) -> bool {
if store.is_some_and(|s| s.0.unit_is_dead()) {
return false;
}
let self_store = self.self_q.single().ok().and_then(|(_, _, _, s)| s);
super::ring_reaction(
self.factions.as_deref(),
&self.reputations,
store,
self_store,
) >= 4
}
/// Resolve a name to a unit, or `None`. See the module header for the full law.
///
/// Every call logs its verdict under the `by-name:` prefix — one line per typed command, so a
/// "`/target` won't take that mob" report is diagnosable from the log rather than re-guessed
/// (the `WOW_TAB_TRACE` instrument's shape, ungated because the cost is per-command, not
/// per-frame). The line names how many candidates were eligible, which won, and on what.
fn resolve(&self, query: &str, search: NameSearch, filter: Filter) -> Option<(Entity, u64)> {
let query = query.trim();
if query.is_empty() {
return None;
}
let Some(origin) = self.origin() else {
info!("by-name: \"{query}\" — no active player object; nothing resolves");
return None;
};
let mut considered = 0usize;
let mut nameless = 0usize;
let mut best: Option<(Entity, u64, Rank, String)> = None;
for (entity, guid, tf, store) in &self.units {
if !search.accepts(guid.0) {
continue;
}
if filter == Filter::AssistableAlive && !self.assistable_alive(store) {
continue;
}
let Some(name) = self.names.peek(guid.0) else {
nameless += 1;
continue;
};
considered += 1;
let dist2 = tf.translation.distance_squared(origin);
let Some(r) = rank(query, name, dist2) else {
continue;
};
if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) {
best = Some((entity, guid.0, r, name.to_string()));
}
}
match &best {
Some((_, guid, r, name)) => info!(
"by-name: \"{query}\" ({search:?}) -> \"{name}\" guid {guid:#x} \
({}, prefix {}, {:.1} yd) over {considered} named candidates ({nameless} unnamed)",
if r.exact { "exact" } else { "prefix" },
r.prefix,
r.dist2.sqrt(),
),
None => info!(
"by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \
({nameless} unnamed); target left untouched"
),
}
best.map(|(e, g, _, _)| (e, g))
}
}
/// Drain `/target <name>`: resolve, then commit through the shared SetSelection path so a by-name
/// switch obeys the same stop → select → re-swing law a click does ([`scan::commit`]).
///
/// A miss leaves the current target **untouched** — verified: neither failure edge in `0x489db4`
/// calls `SetSelection`, so `/target nosuchname` never clears what you had. The reference does emit
/// a game message there (ids `0x127` name-not-found / `0xb8` empty name), but the id→string table is
/// runtime-populated BSS and wow-re could not statically recover the text, so we say nothing rather
/// than invent a line. That silence is the known deviation on this path.
pub(super) fn target_by_name_requests(
mut requests: MessageReader<TargetByNameRequest>,
scan_params: ByNameScan,
mut commit: SelectCommit,
) {
for request in requests.read() {
let Some((entity, guid)) =
scan_params.resolve(&request.name, NameSearch::AnyUnit, Filter::AcceptAll)
else {
continue;
};
commit.commit(entity, guid);
}
}
/// Drain `/assist [name]`: find the **basis** unit, read its `UNIT_FIELD_TARGET` (+0x28 off the unit
/// block — wow-re proved the offset arithmetic against HEALTH and DYNAMIC_FLAGS on the same line),
/// and select whatever it is pointing at.
///
/// The bare form is `AssistUnit("target")` in the reference — assist whoever you have selected,
/// creature or player. The named form resolves **players only**. A basis with no target is a
/// completely silent no-op (verified: the shared tail bails before any send).
///
/// **The `assistAttack` leg is correctly absent.** With that CVar (`0xb4d8f8`) non-zero the
/// reference's tail also opens the swing on the newly-selected target (`0x5ecb70` →
/// `CMSG_ATTACKSWING`) — but its **registered default is `"0"`** (VERIFIED at the registration
/// bytes: `0x48fc50`, default-value slot `[ebp+0xc]` = `0x82e570` = `"0"`, int-parsed into
/// `[cvar+0x28]` by `0x63e127`). So stock `/assist` selects and does **not** swing, which is exactly
/// what this does. The leg becomes reachable only if benilla ever grows the CVar itself.
pub(super) fn assist_requests(
mut requests: MessageReader<AssistRequest>,
scan_params: ByNameScan,
index: Res<GuidIndex>,
mut commit: SelectCommit,
) {
for request in requests.read() {
// The basis: a named PLAYER, or — bare — whatever is currently selected.
let basis = match &request.name {
Some(name) => scan_params
.resolve(name, NameSearch::PlayerOnly, Filter::AcceptAll)
.map(|(e, _)| e),
None => commit.selection.target,
};
let Some(basis) = basis else {
info!("by-name: /assist — no basis unit; nothing to assist");
continue;
};
// Its own target. A zero/absent guid is the silent no-op — the reference's shared tail
// bails here before any send.
let Some(guid) = commit
.stores
.get(basis)
.ok()
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
else {
info!("by-name: /assist — the basis unit is targeting nothing; silent no-op");
continue;
};
// "Select if it resolves" (`0x489a40`): only a streamed unit can be selected — the
// reference's group-roster fallback for an unstreamed guid needs the guid-only selection
// benilla does not have yet (see the module header).
let Some(entity) = index.0.get(&guid).copied() else {
info!(
"by-name: /assist — the basis is targeting guid {guid:#x}, which is not streamed"
);
continue;
};
info!("by-name: /assist -> the basis unit's target, guid {guid:#x}");
commit.commit(entity, guid);
}
}
/// Drain `/follow [name]`: resolve the subject and hand it to [`crate::player`], which owns the
/// motion (decision 0890). Nothing goes on the wire — follow is client-side movement only.
///
/// The two forms differ in more than the argument. A **name** goes through the resolver with the
/// reference's filter mode 2 — players only, and only ones that are **alive and assistable** — so
/// `/follow <an enemy>` finds nothing. The **bare** form is `FollowUnit("target")`, a unit-token
/// resolve that never touches the by-name path at all, so it follows whatever you have selected,
/// creature or player, with no such filter.
pub(super) fn follow_requests(
mut requests: MessageReader<crate::player::FollowRequest>,
scan_params: ByNameScan,
selection: Res<Selection>,
mut follow: ResMut<crate::player::FollowState>,
) {
for request in requests.read() {
let resolved = match &request.name {
Some(name) => scan_params
.resolve(name, NameSearch::PlayerOnly, Filter::AssistableAlive)
.map(|(_, guid)| guid),
None => selection.guid,
};
match resolved {
Some(guid) => {
info!("follow: now following guid {guid:#x}");
follow.start(guid);
}
None => {
// The reference's `if GetSlashCmdTarget(msg)` guard and the resolver's own miss both
// land here: nothing to follow, and whatever we were following is left alone.
info!("follow: nothing to follow");
}
}
}
}
/// The shared commit tail — everything [`scan::commit`] needs, bundled, so both drains route a
/// resolved unit through the one SetSelection path (dedup + the stop → select → re-swing law).
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct SelectCommit<'w, 's> {
selection: ResMut<'w, Selection>,
net: Res<'w, NetCommands>,
me: Query<
'w,
's,
(
&'static Guid,
Option<&'static ObjectStore>,
Has<crate::creature_anim::Engaged>,
),
With<SelfPlayer>,
>,
stores: Query<'w, 's, &'static ObjectStore>,
factions: Option<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
}
impl SelectCommit<'_, '_> {
fn commit(&mut self, entity: Entity, guid: u64) {
let me = self.me.single().ok();
// The new-target classification `scan::commit` expects from its callers (it does not
// re-derive one) — the same `can_attack` the cursor and the TAB scan pass.
let attackable = scan::can_attack(
self.stores.get(entity).ok(),
self.factions.as_deref(),
&self.reputations,
me.and_then(|(_, store, _)| store),
);
scan::commit(
&mut self.selection,
&self.net,
entity,
guid,
me.is_some_and(|(_, _, engaged)| engaged),
me.map(|(g, _, _)| g.0),
attackable,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn exact(dist2: f32) -> Rank {
Rank {
exact: true,
prefix: 3,
dist2,
}
}
fn prefix(prefix: usize, dist2: f32) -> Rank {
Rank {
exact: false,
prefix,
dist2,
}
}
#[test]
fn prefix_length_folds_case_and_stops_at_the_shorter_string() {
assert_eq!(common_prefix_len("rag", "Ragnaros"), 3);
assert_eq!(common_prefix_len("RAG", "ragnaros"), 3);
assert_eq!(
common_prefix_len("Ragnarosx", "Ragnaros"),
8,
"capped by the candidate"
);
assert_eq!(
common_prefix_len("zzz", "Ragnaros"),
0,
"no shared first letter"
);
}
#[test]
fn a_match_needs_one_folded_character() {
// The reference seeds its best length at 1, so zero overlap is not a match at all.
assert!(rank("zzz", "Ragnaros", 1.0).is_none());
assert_eq!(rank("rag", "Ragnaros", 1.0).map(|r| r.prefix), Some(3));
}
#[test]
fn whole_string_match_is_case_insensitive_and_ranks_exact() {
let r = rank("kobold vermin", "Kobold Vermin", 9.0).expect("matches");
assert!(r.exact, "tier 1 is case-insensitive whole-string");
// A multi-word creature name is the case that made the whole-argument trim load-bearing.
}
#[test]
fn exact_beats_any_prefix_and_nearest_breaks_the_tie() {
// Our deliberate deviation: the reference's enumeration-order early-out would let a nearer
// "Bobby" beat a farther "Bob"; we rank exact first, unconditionally.
assert!(exact(100.0).beats(prefix(3, 1.0)));
assert!(!prefix(3, 1.0).beats(exact(100.0)));
// Among exact matches — several identically-named kobolds — the nearest wins.
assert!(exact(4.0).beats(exact(9.0)));
assert!(!exact(9.0).beats(exact(4.0)));
}
#[test]
fn longer_prefix_beats_nearer_shorter_one() {
// The reference's `jg -> accept` edge: a longer prefix wins outright, distance irrelevant.
assert!(prefix(5, 900.0).beats(prefix(3, 1.0)));
assert!(!prefix(3, 1.0).beats(prefix(5, 900.0)));
// Equal prefix length falls through to strictly-nearest.
assert!(prefix(3, 4.0).beats(prefix(3, 9.0)));
}
#[test]
fn ties_keep_the_incumbent_and_nan_never_displaces() {
// The reference's `jp` reject edge makes the accept a strict `<`.
assert!(!prefix(3, 4.0).beats(prefix(3, 4.0)));
assert!(!exact(4.0).beats(exact(4.0)));
assert!(!prefix(3, f32::NAN).beats(prefix(3, 4.0)));
}
}
+19
View File
@@ -44,6 +44,7 @@ use crate::interact::{InspectMode, WorldClick, WorldRightClick};
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::schedule::WorldStage;
mod by_name;
mod click;
mod cursor_mode;
mod flash;
@@ -63,6 +64,9 @@ pub(crate) use flash::CombatFlash;
// The action layer's "attack pressed with no target" request — the nearest-enemy auto-acquire
// (`scan`) answers it with the same core TAB uses.
pub(crate) use scan::{can_attack, AttackNearestRequest};
// The chat layer's by-name selection asks (`/target`, `/assist` — decision 0886), answered by the
// shared resolver the reference parameterises per caller.
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
// The byte-verified reaction decode + its faction catalog, reused by the unit-frame feed to tint the
// target's name plate (`TargetFrame_CheckFaction`) the same way the selection ring colours itself.
// `duel_rung` is the diagnostic face of the same walk, for `/reaction` (decision 0637).
@@ -171,6 +175,8 @@ impl Plugin for TargetPlugin {
.init_resource::<CombatFlash>()
.init_resource::<scan::TabHistory>()
.add_message::<AttackNearestRequest>()
.add_message::<TargetByNameRequest>()
.add_message::<AssistRequest>()
.add_systems(
Startup,
(
@@ -211,6 +217,19 @@ impl Plugin for TargetPlugin {
click::act_on_right_click,
click::clear_target_requests,
click::target_unit_requests,
// The chat layer's by-name asks (decision 0886), beside the UI's token asks:
// both are non-mouse selection writers committing through `scan::commit`.
// The chat layer's by-name asks, grouped as one chained element (the outer
// chain is at Bevy's 20-tuple limit): `/target` and `/assist` commit through
// `scan::commit` like every other non-mouse selection writer, and `/follow`
// resolves its subject for `crate::player` to move (decision 0890) without
// touching `Selection` at all.
(
by_name::target_by_name_requests,
by_name::assist_requests,
by_name::follow_requests,
)
.chain(),
scan::auto_acquire_attacker,
scan::tab_target,
scan::acquire_and_attack,
+10 -1
View File
@@ -71,6 +71,9 @@ pub(super) enum SlashIndex {
LootFfa,
LootRoundRobin,
LootMaster,
Target,
Assist,
Follow,
}
impl SlashIndex {
@@ -105,13 +108,16 @@ impl SlashIndex {
Self::LootFfa => "LOOT_FFA",
Self::LootRoundRobin => "LOOT_ROUNDROBIN",
Self::LootMaster => "LOOT_MASTER",
Self::Target => "TARGET",
Self::Assist => "ASSIST",
Self::Follow => "FOLLOW",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 28] = [
const ALL: [Self; 31] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -140,6 +146,9 @@ impl SlashIndex {
Self::LootFfa,
Self::LootRoundRobin,
Self::LootMaster,
Self::Target,
Self::Assist,
Self::Follow,
];
}
+47 -9
View File
@@ -122,15 +122,23 @@ pub(super) struct ChatProbes<'w, 's> {
guids: Res<'w, crate::net::GuidIndex>,
}
/// The two setters `DoEmote` drives besides the packet, bundled (the drain is at Bevy's 16-param
/// ceiling): the **posture** (`EmoteSpecProc == 1` → `SetStandState`) and the **stow**
/// (`SetSheatheState(0, SNAP)`, unconditional on every emote that passes the gates — wow-re
/// `sheath-policy.md` §1, site `0x5ef630`). Both are queues into their subsystem's one setter,
/// never applied here.
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
/// bundled (the drain is at Bevy's 16-param ceiling).
///
/// - `stand`/`sheath` — the two setters `DoEmote` drives besides the packet: the **posture**
/// (`EmoteSpecProc == 1` → `SetStandState`) and the **stow** (`SetSheatheState(0, SNAP)`,
/// unconditional on every emote that passes the gates — wow-re `sheath-policy.md` §1, site
/// `0x5ef630`).
/// - `target`/`assist` — the by-name selection asks (decision 0886), answered by
/// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click
/// does. Chat never writes [`Selection`] itself.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct EmoteOut<'w> {
pub(super) struct ChatOut<'w> {
stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
assist: MessageWriter<'w, crate::target::AssistRequest>,
follow: MessageWriter<'w, crate::player::FollowRequest>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
@@ -156,7 +164,7 @@ pub(super) fn drain_chat_input(
mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>,
// The command table (decision 0881) — the reference's own aliases, resolved at boot.
table: Res<super::commands::SlashCommands>,
mut emote_out: EmoteOut,
mut chat_out: ChatOut,
probes: ChatProbes,
) {
let ChatProbes {
@@ -495,6 +503,36 @@ pub(super) fn drain_chat_input(
ParsedChat::Forfeit => {
script.queue_duel_request(benilla_ui::script::DuelRequest::Cancel);
}
// The by-name selection pair (decision 0886). Both hand the name to `crate::target`'s
// shared resolver — the reference's own `0x493aa0`, parameterised per caller — so the
// commit goes through the one SetSelection path a click, TAB and `TargetUnit` share.
//
// A bare `/target` reproduces `GetSlashCmdTarget`'s fallback (your current target's
// name, iff it is a player) and is therefore a no-op re-select; a bare `/target` with a
// creature selected resolves to nothing, exactly as the reference's `if
// GetSlashCmdTarget(msg)` guard does.
ParsedChat::Target { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
chat_out
.target
.write(crate::target::TargetByNameRequest { name });
}
}
// A bare `/assist` is the ref's `AssistUnit("target")` — assist whoever is selected,
// creature or player — so it passes `None` straight through rather than taking the
// player-name fallback. The ref reaches the same unit by resolving its NAME first
// (`AssistByName`), which can pick a different same-named player; going by the live
// selection is the same intent without that ambiguity (recorded in 0886).
ParsedChat::Assist { name } => {
chat_out.assist.write(crate::target::AssistRequest { name });
}
// `/follow` (decision 0890) — the subject is resolved by `crate::target` and the motion
// is `crate::player`'s; chat only carries the ask. Bare follows the current selection.
ParsedChat::Follow { name } => {
chat_out.follow.write(crate::player::FollowRequest { name });
}
// The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` — one line
// over the same binding the popup row calls, so it enters the same intent queue
// (decision 0646 §3; the `/duel` reasoning above, verbatim).
@@ -565,7 +603,7 @@ pub(super) fn drain_chat_input(
// sheathes in the reference too. The anim layer's one setter owns the idempotency
// refusal, so an already-stowed body costs nothing.
if let Ok((entity, _, _)) = self_player.single() {
emote_out.sheath.write(crate::creature_anim::SheathRequest {
chat_out.sheath.write(crate::creature_anim::SheathRequest {
entity,
state: 0,
ceremony: false,
@@ -577,7 +615,7 @@ pub(super) fn drain_chat_input(
// the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the
// local commit until the echo lands, and runs the sit-stow rider.
if let Some(state) = posture {
emote_out
chat_out
.stand
.write(crate::player::StandStateRequest { state: state as u8 });
}
+42 -5
View File
@@ -159,16 +159,42 @@ pub(in crate::ui_chat) enum ParsedChat {
/// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu
/// surface is exercisable serverless.
PartyTest { arg: String },
/// `/target [name]` — select by name (`TargetByName`, decision 0886). Resolves creatures AND
/// players, case-insensitively, by whole name or longest common prefix, with no range, cone,
/// liveness or hostility gate at all. `None` = bare, which the ref's `GetSlashCmdTarget` turns
/// into your current target's name when that target is a player (a no-op re-select), and into
/// nothing otherwise.
Target { name: Option<String> },
/// `/assist [name]` — select whatever the basis unit is targeting. A named basis resolves
/// **players only** (typemask 0x10); bare assists your current target, creature or player.
Assist { name: Option<String> },
/// `/follow [name]` — auto-follow (decision 0890). A named subject resolves players only, and
/// only living assistable ones (the ref's filter mode 2); bare follows the current selection,
/// creature or player. Client-side movement — nothing goes on the wire.
Follow { name: Option<String> },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a non-empty argument
/// is the name (first word — 1.12 names carry no spaces); empty defers to the dispatch's
/// player-target fallback.
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658) — the argument grammar
/// **thirteen** shipped commands share, `/target` and `/assist` among them.
///
/// Its first line is a trim, not a tokenizer: `gsub(msg, "(%s*)(.*[^%s]+)(%s*)", "%2", 1)` keeps
/// **everything between the outer whitespace**, internal spaces included. This used to take the
/// first word instead, on the reasoning that 1.12 player names carry no spaces — true, but
/// `/target` resolves *creature* names too, and "Kobold Vermin" is the common case. `args` reaches
/// us already trimmed ([`parse_line`]), which is exactly what that gsub computes.
///
/// Empty defers to the caller's fallback (a bare party command acts on the selected PLAYER —
/// `target_player_name` in [`super`], the ref's `UnitIsPlayer("target")` branch).
///
/// **Not carried: the unit-token expansion.** The ref's tail expands `player`, `target`,
/// `^party[1-4]` and `^raid[0-9]` through `UnitName` before returning, so `/assist party1` means
/// "that member's name". benilla feeds only the `player` and `target` tokens to the VM's unit
/// store, so the roster half has nothing to resolve against yet; the tokens would pass through as
/// literal names. Named in decision 0886 as a gap rather than half-built here.
fn slash_target_name(args: &str) -> Option<String> {
let name = args.split_whitespace().next().unwrap_or("");
(!name.is_empty()).then(|| name.to_string())
(!args.is_empty()).then(|| args.to_string())
}
/// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev
@@ -271,6 +297,17 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
S::Duel => ParsedChat::Duel {
name: slash_target_name(args),
},
// The by-name selection pair (decision 0886). Both take the whole trimmed argument —
// `/target Kobold Vermin` is one name, not a name plus junk.
S::Target => ParsedChat::Target {
name: slash_target_name(args),
},
S::Assist => ParsedChat::Assist {
name: slash_target_name(args),
},
S::Follow => ParsedChat::Follow {
name: slash_target_name(args),
},
S::DuelCancel => ParsedChat::Forfeit,
S::Pvp => ParsedChat::Pvp,
// The social verbs (decision 0668). The argument is passed WHOLE: `/who`'s is a filter
+48 -8
View File
@@ -602,20 +602,60 @@ fn real_alias_table_resolves_the_shipped_commands() {
"{line} resolves to an emote"
);
}
// …and the four names the reference has NO command for, which the DBC-name match used to
// accept. `/follow` is the sharp one: it fired a text emote where the real client's
// `SlashCmdList["FOLLOW"]` follows your target (benilla has no follow yet, so it is honestly
// unknown rather than wrongly an emote).
for line in ["/follow", "/joke", "/puzzle", "/attackmytarget"] {
// …and the three names the reference has NO command for, which the DBC-name match used to
// accept as emotes.
for line in ["/joke", "/puzzle", "/attackmytarget"] {
assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}");
}
// `/follow` was the sharp one in that class: the DBC-name match fired a text emote where the
// real client's `SlashCmdList["FOLLOW"]` follows your target. 0881 made it honestly unknown;
// 0890 makes it the real command, over all three shipped aliases (SLASH_FOLLOW1-6 → `/f`,
// `/follow`, `/fol`).
for line in ["/follow", "/f", "/fol"] {
assert_eq!(
parse_line(line),
ParsedChat::Follow { name: None },
"{line}"
);
}
assert_eq!(
parse_line("/follow Probeone"),
ParsedChat::Follow {
name: Some("Probeone".into())
}
);
// A command whose handler benilla does not register answers like any unknown command.
assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown);
// The by-name selection pair (decision 0886) — every shipped alias, and the whole-argument
// grammar that makes a multi-word creature name ONE name. `/tar` and `/a` are the short forms
// the shipped strings carry (SLASH_TARGET2/4, SLASH_ASSIST1/3).
assert_eq!(
parse_line("/target Kobold Vermin"),
ParsedChat::Target {
name: Some("Kobold Vermin".into())
},
"the argument is trimmed WHOLE — `GetSlashCmdTarget`'s gsub, not a first-word split"
);
assert_eq!(
parse_line("/tar Hogger "),
ParsedChat::Target {
name: Some("Hogger".into())
}
);
assert_eq!(parse_line("/target"), ParsedChat::Target { name: None });
assert_eq!(
parse_line("/a Bob"),
ParsedChat::Assist {
name: Some("Bob".into())
}
);
assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None });
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **55 distinct
// aliases** across the 28 registered `SlashCmdList` indices.
assert_eq!(table.counts(), (55, 225), "(slash, emote) aliases");
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **62 distinct
// aliases** across the 31 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`).
assert_eq!(table.counts(), (62, 225), "(slash, emote) aliases");
}
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
+34
View File
@@ -209,6 +209,40 @@ pub(super) fn drain_container_uses(
}
continue;
}
// The openable fork: a clam, an unlocked lockbox, a wrapped gift. `CMSG_OPEN_ITEM`, not
// `CMSG_USE_ITEM` — the server's `HandleOpenItemOpcode` is the only handler that answers
// with `SendLoot(item guid, LOOT_CORPSE)`, i.e. a loot window over a thing in your bag;
// sending USE_ITEM instead casts the item's (absent) on-use spell and nothing happens,
// which is exactly the "no way to open clams" symptom. The predicate is
// `ItemInfo::openable` — literally the same call the tooltip's `<Right Click to Open>`
// line makes, so the promise and the packet cannot drift apart.
//
// It sits ABOVE the readable fork because the reference's own tooltip law makes the two
// mutually exclusive with **openable winning** (wow-re `tooltip-content-law.md`
// §1-OPENABLE, the `jmp` past the READABLE test): a wrapped gift that also carries letter
// text opens, it does not read. Below the merchant/bank intercepts, which outrank every
// ordinary click.
let openable = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items))
.and_then(|guid| {
let obj = items.object(guid)?;
let inst_flags = obj.item_flags().unwrap_or(0);
let entry = obj.object_entry()?;
items
.template(entry, guid, &commands)?
.openable(inst_flags)
.then_some(guid)
});
if let Some(guid) = openable {
debug!("ui_items: open item {guid:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
});
continue;
}
// The readable fork: an item INSTANCE carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made
// permanent letter) opens the reader instead of a use — client-side, no permission
// packet (vmangos' `CMSG_READ_ITEM` handler gates on the *template*'s PageText, which is
@@ -53,3 +53,94 @@ fn the_whole_shipped_manifest_loads_without_errors() {
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
/// meant to cap a unit frame must sit at a strictly HIGHER frame level than that frame's status
/// bars — because frame level is the only key term above the draw layer, and the layer would
/// otherwise lift an ARTWORK bar fill straight over BACKGROUND art.
///
/// Decision 0884 pinned the layer as bucket-wide and above the frame. Two files had been getting
/// this right for the wrong reason — `UnitFrames.xml` and `PartyFrame.xml` both *declared* the
/// TextureFrame after the bars and leaned on the retired key's insertion-order tie-break — so both
/// inverted the instant the key was corrected, and both reached the director's screen. The
/// reference spends a real frame level on this in both of its own spellings (`TargetFrame.lua`
/// l.32-34's explicit `SetFrameLevel(textureFrame-1)`, `PlayerFrame.xml` l.50-52's two anonymous
/// nesting frames); benilla now uses the first, everywhere.
///
/// Name-driven on purpose, so a *new* copy of the idiom is covered the day it is written rather
/// than when someone remembers to test it. A geometric sweep was run once over this same UI —
/// every substantially-overlapping cross-frame quad pair at one `(strata, level)`, i.e. every pair
/// the draw layer alone orders — and found no third instance; the surviving 45 pairs were all
/// benign (action-bar and bag-slot chrome adjacency, text over art, and a bar fill over its own
/// frame's trough, which is the intended order). It is not kept as a gate: frozen, that list would
/// churn on every action-bar edit, and a noisy gate is a gate nobody reads.
#[test]
fn every_texture_frame_outranks_its_status_bars() {
use benilla_ui::order::unpack;
let mut s = benilla_ui::script::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:#?}");
// Every unit-frame family painting at once: the frames hide themselves without a unit, and a
// hidden frame emits no quads to read a level from.
for unit in ["player", "target", "party1", "party2", "party3", "party4"] {
s.set_unit(
unit,
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Someone".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..benilla_ui::script::UnitState::default()
}),
);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
s.resolve();
// owner frame name → its (strata, level), read off the packed draw key the renderer sorts by.
let mut level_of: std::collections::BTreeMap<String, (u8, u16)> =
std::collections::BTreeMap::new();
for q in s.extract() {
if let Some(name) = s.quad_owner_name(q.target) {
let p = unpack(q.z);
level_of.insert(name, (p.strata, p.level));
}
}
let mut checked = 0;
for (texture_frame, &(tf_strata, tf_level)) in &level_of {
let Some(base) = texture_frame.strip_suffix("TextureFrame") else {
continue;
};
for suffix in ["HealthBar", "PowerBar", "ManaBar"] {
let bar = format!("{base}{suffix}");
let Some(&(bar_strata, bar_level)) = level_of.get(&bar) else {
continue;
};
assert_eq!(
bar_strata, tf_strata,
"{bar} and {texture_frame} must share a strata for the level to decide"
);
assert!(
tf_level > bar_level,
"{texture_frame} (level {tf_level}) must outrank {bar} (level {bar_level}): \
tied, the draw layer lifts the bar's ARTWORK fill over the frame's BACKGROUND art"
);
checked += 1;
}
}
// Player + target (health/power) and four party members (health/mana) — never let this pass by
// matching nothing, which is exactly how a renamed frame would silently retire the check.
assert!(
checked >= 12,
"only {checked} texture-frame/bar pairs checked — the name sweep found nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1055,3 +1055,145 @@ fn target_frame_border_follows_the_classification_law() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The recessed-bar look, asserted in the draw list: the ring art's metal well-edges must paint
/// **over** the health/power fills, and the name/level text over both.
///
/// This is the shape decision 0884 broke. Our transcription used to get the ring over the bars by
/// *declaring* `$parentTextureFrame` after the StatusBars, leaning on the old draw key's
/// insertion-order tie-break between siblings at one `(strata, level)`. 0884 pinned the real law —
/// the draw **layer** is bucket-wide and outranks the frame — so the bars' ARTWORK fills started
/// painting over the TextureFrame's BACKGROUND ring art and the frames read as pasted-on slabs.
/// The fix is the reference's own mechanism (`TargetFrame.lua` l.32-34): push the bars one frame
/// level *below* the texture frame, where no layer can lift them back over it.
#[test]
fn the_ring_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Onemage".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.resolve();
let quads = s.extract();
// The ring art: `UI-TargetingFrame` exactly — not the `-LevelBackground` / `-Elite` siblings.
let ring = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-TargetingFrame"))
})
.expect("the player frame's ring art");
// The bar fills (both bars share the texture; take the highest z of the two).
let fill = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-StatusBar"))
})
.map(|q| q.z)
.max()
.expect("a bar fill");
let name = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Onemage"))
.expect("the name text");
assert!(
ring.z > fill,
"the ring art must paint OVER the bar fills (ring z={:#x}, fill z={fill:#x})",
ring.z
);
assert!(
name.z > fill,
"the name text must paint OVER the bar fills (name z={:#x}, fill z={fill:#x})",
name.z
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The same recessed-bar law for the PARTY member frames, which carry an independent copy of the
/// idiom (`PartyFrame.xml`'s `$parentTextureFrame` over `$parentHealthBar`/`$parentManaBar`).
///
/// Split from the player/target test on purpose: the two files reach the same look through separate
/// OnLoads, so one green assertion says nothing about the other. This is the copy 0884 broke in
/// silence — nobody was in a party when the director reported the player frame.
#[test]
fn the_party_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot test's prefix (`loot_tests.rs`): PartyFrame's inline <Script> reads
// StaticPopupDialogs, which UiPanels.xml defines, and its per-member dropdown OnLoad walks the
// whole popup kit.
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
// BENILLA_POWER_COLORS (the shared power-colour table) lives in UnitFrames.xml; the party
// frames' Update reads it. The player/target frames it also defines stay hidden here — no unit.
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "PartyFrame.xml");
s.set_unit(
"party1",
Some(UnitState {
exists: true,
name: Some("Onepriest".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
s.resolve();
let quads = s.extract();
// Scoped to member frame 1 by owner name, so a stray UI-StatusBar from any other frame in the
// manifest can never stand in for the bar under test.
let mine = |q: &benilla_ui::script::ExtractedQuad| {
s.quad_owner_name(q.target)
.is_some_and(|n| n.starts_with("PartyMemberFrame1"))
};
let art = quads
.iter()
.find(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-PartyFrame"))
})
.expect("the party member frame art");
let fill = quads
.iter()
.filter(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-StatusBar"))
})
.map(|q| q.z)
.max()
.expect("a bar fill");
assert!(
art.z > fill,
"the party art must paint OVER the bar fills (art z={:#x}, fill z={fill:#x})",
art.z
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}