gear rides the rig, water walking answers the keys, and a jitter meter

- an attached model's frame rides its wearer's rig: composed as bone-offset-plus-world-root every frame, a helm at Elwynn's ~9500-yard coordinates lands on a 0.98 mm f32 grid (exactly 2^-10 yd) and snaps whole steps against a body that moves exactly — the plumed helm, both pauldrons, the sword and the shield, 0.83 px of hop at a fully zoomed-in close-up; each attachment now holds one rigid palette frame composed in the rig's own space, the world position added back camera-relative in the vertex stage, so nothing on the path is ever a big-plus-small sum — the property the reference keeps by injecting its view matrix once, at the root of the attachment tree
- a rider composes through the rig's own frame, not the unit's: `joints_root` re-points at the saddle's seat anchor the moment a character mounts, so reading the host entity's transform would have placed a mounted rider's helm and weapon by the gap between its feet and its saddle — pinned by a test whose seat is offset far enough that the wrong frame misses by 6 yards
- the water-walk surface is GROUND, not a post-step clamp: stepping onto water with Levitate up froze the last land velocity in — the mover classified every over-water frame airborne (liquid is queried, never swept) while the clamp reported grounded, so the body ran on at full speed through every key release; the reference has no water-walking code path at all — the flag just ORs the liquid layers into the same swept query that finds terrain — so the surface now feeds the same grounded classification the colliders do, and the clamp stays as the resolve half
- `WOW_JITTER`, a motion-jitter meter: the camera, the unit root and the pose sampled every frame as first AND second differences — Δ² tells a slow idle from per-frame noise at two orders' distance — with every reading also in pixels at the subject's own distance, because the same millimetre is 0.03 px across a courtyard and 0.8 px pressed against the camera

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:10:29 +07:00
co-authored by Claude Fable 5
parent 10874da37f
commit 69bcd3bb6b
12 changed files with 1199 additions and 71 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock,
ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
JitterMeterPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
ProbeResizePlugin, RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin,
};
use scenarios::GlueScreen;
@@ -0,0 +1,360 @@
//! The **motion-jitter meter** (`WOW_JITTER=<name-substr>[,<start_s>]`) — the instrument that turns
//! "that NPC looks shaky up close" into a number, and separates the three things a screenshot
//! conflates: the camera moving, the unit's root moving, and the *pose* moving.
//!
//! A rendered bone's screen position is `pose · root · (origin − camera)`. Each of those three
//! terms is sampled here every frame and reported as its own **first and second differences**:
//!
//! - **Δ (first difference)** is motion. A slow idle has one; so does noise. On its own it cannot
//! tell them apart, which is why "the model moved 0.4 mm this frame" is not a finding.
//! - **Δ² (second difference)** is *curvature per frame*, and that is the discriminator. Smooth
//! motion at 60 Hz has almost none — a 2 s idle of amplitude `A` carries `A·ω²·dt² ≈ A/3600`,
//! i.e. tens of microns for a centimetre-scale sway — while uncorrelated per-frame noise of
//! amplitude `n` shows up at `≈2n`. Two orders of magnitude apart, from the same series.
//! - **Δ(v) = Δ²/dt²** separates *arithmetic* noise from **judder**: if the pose is smooth in time
//! but the frame deltas are not, `Δ` is ragged while the velocity `Δ/dt` is clean. That is the
//! whole difference between "the numbers are wrong" and "the numbers are right and arriving at
//! uneven times", and it is invisible to any instrument that does not log `dt` beside the pose.
//!
//! Everything is reported in **millimetres and in pixels at the subject's own distance**, because
//! a world-space wobble is only a defect in proportion to how many pixels it covers: the same
//! 1 mm is 0.03 px across a courtyard and 0.6 px pressed against the camera, which is exactly why
//! this class of report always arrives as "…when I'm zoomed right in".
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
//! WOW_PROBE_CHAT=".go xyz -9481.53 76.10 56.57 0" \
//! WOW_PROBE_CAM="-82.55,0,0@18" WOW_JITTER="guard,22" WOW_PROBE_EXIT_AT=40 cargo run -q -p benilla
//! ```
//!
//! One `JIT` line per frame, TSV, so a series is read by a script rather than by eye. The columns
//! are named in the `JIT#` header the meter prints when it arms.
use bevy::camera::Projection;
use bevy::prelude::*;
use benilla_world::rig_anim::RigPose;
use benilla_world::view::WorldCamera;
use super::ProbeClock;
use crate::names::NameCache;
use crate::net::{Guid, NetEntity, SelfPlayer};
/// `WOW_JITTER`'s parsed knobs plus the two-frame history the differences are taken over.
#[derive(Resource)]
struct JitterMeter {
/// Lower-cased substring of the subject's name; empty = the nearest rigged non-self unit.
want: String,
/// Wall-clock second the meter arms (the world has to have streamed in first).
at: f32,
/// Whose history [`Self::prev`] holds — a different entity restarts the series.
subject: Option<Entity>,
/// Per-bone model-space translations, last frame and the one before.
prev: Vec<Vec3>,
prev2: Vec<Vec3>,
/// The same two-frame window on the unit's root and on the camera, world space.
prev_root: Vec3,
prev2_root: Vec3,
prev_cam: Vec3,
prev2_cam: Vec3,
/// The consumer **anchors'** world translations (held items, helm, shoulders, cape — the
/// entity lane 0974 left in absolute space), same two-frame window.
prev_anc: Vec<Vec3>,
prev2_anc: Vec<Vec3>,
/// The **rider** frames the vertex stage actually places an attached model with (1609): the
/// row-0 translation, in the host's rig frame. The A/B against `prev_anc` above — same
/// attachment, same frame, one measured in absolute world space and one in the rig's.
prev_rid: Vec<Vec3>,
prev2_rid: Vec<Vec3>,
/// Frames of history held (`< 2` means Δ² is not defined yet).
depth: u32,
}
pub(crate) struct JitterMeterPlugin;
impl Plugin for JitterMeterPlugin {
fn build(&self, app: &mut App) {
let raw = std::env::var("WOW_JITTER").unwrap_or_default();
// `<substr>`, `<substr>,<start>`, or a bare `<start>` — the subject is optional because
// "the nearest rigged thing that isn't me" is the whole selection at a 1.6 yd close-up.
let (want, at) = match raw.rsplit_once(',') {
Some((n, t)) => (n.trim(), t.trim().parse::<f32>().unwrap_or(0.0)),
None => match raw.trim().parse::<f32>() {
Ok(t) => ("", t),
Err(_) => (raw.trim(), 0.0),
},
};
app.insert_resource(JitterMeter {
want: want.to_lowercase(),
at,
subject: None,
prev: Vec::new(),
prev2: Vec::new(),
prev_root: Vec3::ZERO,
prev2_root: Vec3::ZERO,
prev_cam: Vec3::ZERO,
prev2_cam: Vec3::ZERO,
prev_anc: Vec::new(),
prev2_anc: Vec::new(),
prev_rid: Vec::new(),
prev2_rid: Vec::new(),
depth: 0,
})
// `Last`, so every writer that can still move the subject this frame — the pose evaluator,
// the rig finalize, transform propagation, the camera seat — has already run. Sampling in
// `Update` would measure last frame's world against this frame's pose (the 1398 seam).
.add_systems(Last, sample_jitter);
}
}
/// What the meter reads per candidate subject: identity, name key, world pose, composed rig.
type SubjectQuery = (
Entity,
&'static Guid,
&'static NetEntity,
&'static GlobalTransform,
&'static RigPose,
);
/// One `JIT` line per frame for the subject: the three terms' Δ and Δ², in mm and in pixels.
#[allow(clippy::too_many_arguments)] // one param per term the reading has to separate
fn sample_jitter(
mut meter: ResMut<JitterMeter>,
time: ProbeClock,
names: Res<NameCache>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
window: Query<&Window>,
subjects: Query<SubjectQuery, Without<SelfPlayer>>,
globals: Query<&GlobalTransform>,
kids: Query<&Children>,
meshes: Query<(), With<Mesh3d>>,
attach: Query<&crate::entities::BoneAttach>,
riders: Query<&benilla_world::rig_rider::RigRider>,
palettes: Res<benilla_world::rig_palette::RigPalettes>,
) {
let now = time.elapsed_secs();
if now < meter.at {
return;
}
let (Ok((cam_g, projection)), Ok(window)) = (cam.single(), window.single()) else {
return;
};
let cam_pos = cam_g.translation();
// Nearest match to the camera — with no substring, nearest rigged unit outright.
let want = meter.want.clone();
let Some((entity, guid, net, tf, rig)) = subjects
.iter()
.filter(|(_, guid, _, _, _)| {
want.is_empty()
|| names
.peek(guid.0)
.is_some_and(|n| n.to_lowercase().contains(&want))
})
.min_by(|a, b| {
let d = |g: &GlobalTransform| g.translation().distance_squared(cam_pos);
d(a.3).total_cmp(&d(b.3))
})
else {
return;
};
let root = tf.translation();
let dist = root.distance(cam_pos).max(1.0e-3);
// Pixels per yard AT THE SUBJECT: the vertical half-frame subtends `dist·tan(fovy/2)` yards.
let fovy = match projection {
Projection::Perspective(p) => p.fov,
_ => return,
};
let px_per_yd = (window.physical_height() as f32 * 0.5) / (dist * (fovy * 0.5).tan());
// The rig composes in MODEL space; the root's scale is what carries it onto the screen.
let scale = tf.scale().max_element();
let bones: Vec<Vec3> = rig
.model
.iter()
.map(|m| Vec3::from(m.translation))
.collect();
// The anchors are ordinary scene-graph entities in ABSOLUTE world space — the seam 0974
// named and did not close. Their translations are read exactly as the renderer reads them.
let ancs: Vec<Vec3> = rig
.anchors
.iter()
.map(|&(_, e)| globals.get(e).map_or(Vec3::ZERO, |g| g.translation()))
.collect();
// The rider frames of every attached model hanging off THIS unit, in host-rig space. Their
// origins are reported too — a rider is only exact while its origin holds still.
let rids: Vec<(Vec3, Vec3)> = riders
.iter()
.filter(|r| r.host == entity)
.filter_map(|r| palettes.rider_placement(r.slot))
.collect();
let dt = time.delta_secs().max(1.0e-6);
// A new subject (or a bone count that changed under us — a re-skin) restarts the series.
if meter.subject != Some(entity)
|| meter.prev.len() != bones.len()
|| meter.prev_anc.len() != ancs.len()
|| meter.prev_rid.len() != rids.len()
{
let name = names.peek(guid.0).unwrap_or("?").to_string();
println!(
"JIT# t=<s> dt=<ms> dist=<yd> pxyd=<px/yd> | camd1 camd2 (mm) | rootd1 rootd2 (mm) \
| bone=<max bone> d1 d2 (mm) d1px d2px | anc=<n> d1 d2 (mm) d1px d2px | \
ancstep xyz (mm) | rider=<n> d1 d2 (mm) d1px d2px | window={}x{} subject={name:?} \
guid={:#x} display={:?} bones={} scale={scale:.3}",
window.physical_width(),
window.physical_height(),
guid.0,
net.display_id,
bones.len(),
);
// Name the anchors once: bone id, and how much GEOMETRY hangs under each — the number
// that says whether a jittering anchor is a held weapon or a bookkeeping node.
let mut roster = Vec::new();
for (i, &(bone, e)) in rig.anchors.iter().enumerate() {
let mut n = 0usize;
let mut stack = vec![e];
while let Some(x) = stack.pop() {
n += usize::from(meshes.contains(x));
if let Ok(cs) = kids.get(x) {
stack.extend(cs.iter());
}
}
roster.push(format!("{i}:bone{bone}/{n}mesh"));
}
println!("JIT@ anchors [{}]", roster.join(" "));
println!(
"JIT@ riders [{}]",
riders
.iter()
.filter(|r| r.host == entity)
.map(|r| format!("slot{}@bone{}", r.slot, r.bone))
.collect::<Vec<_>>()
.join(" ")
);
if let Ok(a) = attach.get(entity) {
let mut pts: Vec<String> = a
.points
.iter()
.map(|(slot, (bone, _))| format!("slot{slot}=bone{bone}"))
.collect();
pts.sort();
println!("JIT@ attach points [{}]", pts.join(" "));
}
meter.subject = Some(entity);
meter.prev = bones;
meter.prev_anc = ancs;
meter.prev2_anc = Vec::new();
meter.prev_rid = rids.iter().map(|&(_, t)| t).collect();
meter.prev2_rid = Vec::new();
meter.prev2 = Vec::new();
meter.prev_root = root;
meter.prev2_root = Vec3::ZERO;
meter.prev_cam = cam_pos;
meter.prev2_cam = Vec3::ZERO;
meter.depth = 1;
return;
}
if meter.depth < 2 {
meter.prev2 = std::mem::take(&mut meter.prev);
meter.prev = bones;
meter.prev2_anc = std::mem::take(&mut meter.prev_anc);
meter.prev_anc = ancs;
meter.prev2_rid = std::mem::take(&mut meter.prev_rid);
meter.prev_rid = rids.iter().map(|&(_, t)| t).collect();
meter.prev2_root = meter.prev_root;
meter.prev_root = root;
meter.prev2_cam = meter.prev_cam;
meter.prev_cam = cam_pos;
meter.depth = 2;
return;
}
// Δ and Δ² per bone, in model space; the largest bone is the one the eye is reading.
let (mut d2, mut worst) = (0.0f32, 0usize);
for (i, &p) in bones.iter().enumerate() {
let c = (p - 2.0 * meter.prev[i] + meter.prev2[i]).length();
if c > d2 {
d2 = c;
worst = i;
}
}
let bd1 = (bones[worst] - meter.prev[worst]).length();
// Same two differences on the anchors, and the WORST AXIS of the worst one: the f32 grid an
// absolute coordinate lands on is per-axis, so at ~9.5 k yards on one axis and ~60 on another
// the staircase is one-dimensional — which is the fingerprint, not an aside.
let (mut ad2, mut aworst) = (0.0f32, 0usize);
for (i, &p) in ancs.iter().enumerate() {
let c = (p - 2.0 * meter.prev_anc[i] + meter.prev2_anc[i]).length();
if c > ad2 {
ad2 = c;
aworst = i;
}
}
let ad1 = if ancs.is_empty() {
0.0
} else {
(ancs[aworst] - meter.prev_anc[aworst]).length()
};
// The same two differences on the RIDER rows — the number the fix has to move.
let (mut rd2, mut rworst) = (0.0f32, 0usize);
for (i, &(_, t)) in rids.iter().enumerate() {
let c = (t - 2.0 * meter.prev_rid[i] + meter.prev2_rid[i]).length();
if c > rd2 {
rd2 = c;
rworst = i;
}
}
let rd1 = if rids.is_empty() {
0.0
} else {
(rids[rworst].1 - meter.prev_rid[rworst]).length()
};
let astep = if ancs.is_empty() {
Vec3::ZERO
} else {
(ancs[aworst] - meter.prev_anc[aworst]).abs()
};
let cam_d1 = (cam_pos - meter.prev_cam).length();
let cam_d2 = (cam_pos - 2.0 * meter.prev_cam + meter.prev2_cam).length();
let root_d1 = (root - meter.prev_root).length();
let root_d2 = (root - 2.0 * meter.prev_root + meter.prev2_root).length();
// Δ² over dt² is the velocity change the frame actually applied — the term that stays put
// when a smooth pose is merely sampled at uneven times, and blows up when it is not smooth.
let dvel = d2 * scale / (dt * dt);
let mm = |v: f32| v * scale * 1000.0;
println!(
"JIT\t{now:.4}\t{:.3}\t{dist:.3}\t{px_per_yd:.1}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{worst}\t\
{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.1}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t\
{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}",
dt * 1000.0,
cam_d1 * 1000.0,
cam_d2 * 1000.0,
root_d1 * 1000.0,
root_d2 * 1000.0,
mm(bd1),
mm(d2),
bd1 * scale * px_per_yd,
d2 * scale * px_per_yd,
dvel * 1000.0,
aworst,
ad1 * 1000.0,
ad2 * 1000.0,
ad1 * px_per_yd,
ad2 * px_per_yd,
astep.x * 1000.0,
astep.y * 1000.0,
astep.z * 1000.0,
rworst,
rd1 * 1000.0,
rd2 * 1000.0,
rd1 * px_per_yd,
rd2 * px_per_yd,
);
meter.prev2 = std::mem::replace(&mut meter.prev, bones);
meter.prev2_anc = std::mem::replace(&mut meter.prev_anc, ancs);
meter.prev2_rid =
std::mem::replace(&mut meter.prev_rid, rids.iter().map(|&(_, t)| t).collect());
meter.prev2_root = std::mem::replace(&mut meter.prev_root, root);
meter.prev2_cam = std::mem::replace(&mut meter.prev_cam, cam_pos);
}
@@ -40,6 +40,12 @@ pub(crate) use ground_census::GroundCensusPlugin;
mod visual_census;
pub(crate) use visual_census::UnitVisualsPlugin;
/// The motion-jitter meter — per frame, the camera / root / pose terms of a subject's rendered
/// position as first *and second* differences, in mm and in pixels at its own distance: the
/// reader that separates "the pose is noisy" from "the pose is smooth and the frames are not".
mod jitter;
pub(crate) use jitter::JitterMeterPlugin;
/// The dress census — per-player "what did the wire ask for, what did we resolve, what is actually
/// hanging off the skeleton": the three things a screenshot of a geared character conflates, and
/// the reader that turns "my show-helm preference is ignored" into a counted contradiction (B123,
+9
View File
@@ -202,6 +202,15 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_UNIT_VISUALS").is_ok() {
app.add_plugins(crate::capture::UnitVisualsPlugin);
}
// The motion-jitter meter: `WOW_JITTER=<name-substr>[,<start_s>]` prints one line per
// frame for the nearest matching unit — the camera, root and pose terms of its
// rendered position, each as a first AND second difference, in mm and in pixels at
// its own distance. Δ alone cannot tell a slow idle from noise; Δ² can, and Δ²/dt²
// says whether a ragged Δ is bad arithmetic or merely uneven frame times (see
// `capture::JitterMeterPlugin`).
if std::env::var("WOW_JITTER").is_ok() {
app.add_plugins(crate::capture::JitterMeterPlugin);
}
// The dress census: `WOW_DRESS_CENSUS=<secs>[,<every>]` prints one line per streamed
// PLAYER — what the wire asked for (`PLAYER_FLAGS`' hide bits), what we resolved
// (helm/cloak display ids) and what is actually attached — plus a `contradictions=`
@@ -130,12 +130,13 @@ pub(in crate::entities) fn attach_held_items(
// writes (`entities::attach`), and the held effects' draw-order rung is measured in the
// world yards that scale produces.
Option<&Transform>,
// The WEARER's rig, for its instance slot: an item's parts carry it in their tag so the
// wearer's body tint reaches its helm, shoulders and held items (decision 0812 — the
// reference's attached models inherit the parent CM2's computed colours, `0x714000`). Never
// used to SKIN those parts: they draw the static mesh, and the vertex stage's slot read is
// gated on the mesh's own joint attributes. The one exception is an item that rigs itself
// (0841) — it carries its own slot instead, and inherits the tint up the `ParentModel` chain.
// The WEARER's rig, for its instance slot — the fallback an attached model's parts carry
// in their tag when they have no slot of their own, so the wearer's body tint still
// reaches them (decision 0812 — the reference's attached models inherit the parent CM2's
// computed colours, `0x714000`). An item that owns a slot carries its own instead and
// inherits the tint up the `ParentModel` chain: the `welds_billboard` joint rig (0841),
// and — since 1609 — every ordinary attached model, which rides one rigid palette frame
// composed in this rig's frame rather than an absolute world matrix.
Option<&benilla_world::rig_palette::RigSkin>,
// The wearer's pose buffer: the attach joint spawns on first demand from the composed
// pose (`RigPose::anchor_for`, decision 1355) — a weapon equipped in combat seats at the
@@ -145,8 +146,12 @@ pub(in crate::entities) fn attach_held_items(
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
mut seats: SeatWriters,
// An item model normally spawns no rig — but a display that welds geometry to a billboard bone
// has no correct rigid placement, so its spawn allocates a palette slot of its own (0841).
// The MOVE path re-seats a rider in place: an attach-point change is the one edit that leaves
// the item's entity alive while changing the bone its palette frame is composed from (1609).
mut movers: Query<&mut benilla_world::rig_rider::RigRider>,
// Every attached model with a skeleton allocates a palette slot of its own: one rigid frame in
// the wearer's rig frame (1609), or — for a display that welds geometry to a billboard bone,
// which has no correct rigid placement at all — its own camera-replaced joint rig (0841).
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(held) = held else {
@@ -224,6 +229,14 @@ pub(in crate::entities) fn attach_held_items(
commands
.entity(root)
.insert(Transform::from_translation(offset));
// The rider's frame is composed from (bone, offset), so the move has
// to carry it — otherwise a sheathed sword keeps drawing at the hand
// it was drawn from while its entity, its glow and its emitters all
// travel to the hip (1609).
if let Ok(mut rider) = movers.get_mut(root) {
rider.bone = bone;
rider.local = offset;
}
seats.reseat(root, bone, offset - old);
debug!(
"held move: unit {entity} display {} → attach {} (bone {bone})",
@@ -390,15 +403,58 @@ fn spawn_slot(
);
Some(slot)
});
// The instance slot every part below carries in its `MeshTag`. Normally the WEARER's — that is
// what puts a tinted body's colour on its helm, shoulders and held items (decision 0812). A
// rigged item must carry its OWN, because the vertex stage indexes the palette with the same
// field; the wearer's tint reaches it through the `ParentModel` chain instead, which is the
// reference's own route for an attached model's colours (`0x714000`, `aura_visual`).
let rig_slot = match item_rig {
Some(slot) => slot,
None => ctx.rig_slot,
// **The rider** (decision 1609) — the other, and now ordinary, reason an attached model owns a
// palette slot. It is not the item's skeleton: an item rests at bind pose, so every row of it
// is the same placement matrix, and the lane writes that ONE frame — composed in the WEARER's
// rig frame, with the wearer's world position riding the slot's `rig_origin`.
//
// The picture is unchanged by construction (bind pose ⇒ `row = F` ⇒ the static mesh's own
// placement); what changes is that the placement is never an absolute f32 world coordinate.
// Built as a `~9500 + ~1` sum every frame, it landed on the 0.98 mm ULP grid of the large map
// axis and the helm snapped a whole ULP sideways every other frame while the body under it
// moved exactly — the jitter the director reported on the Goldshire guard. See
// [`benilla_world::rig_rider`] for the measurement and the residuals.
//
// A boneless display has no skinned twin to draw and keeps the old route; so does a palette
// with no room, which is the same graceful fallback the `welds_billboard` lane already had.
let rider = match item_rig {
Some(_) => None,
// A slot only earns its keep if something will actually SKIN through it: a display with
// bones but no skinned twin on any drawn part (a card-only model) would take a slot that
// no vertex ever indexes, and keep the absolute placement anyway.
None => (!dm.skeleton.joints.is_empty()
&& parts
.iter()
.any(|p| p.billboard.is_none() && p.skinned_mesh.is_some()))
.then(|| {
benilla_world::rig_palette::RigSkin::allocate_bones(
palettes,
dm.skeleton.joints.len() as u32,
Handle::default(),
)
})
.flatten()
.map(|skin| {
let slot = skin.slot;
commands.entity(root).insert((
skin,
benilla_world::rig_rider::RigRider {
host: entity,
bone,
local: offset,
slot,
},
));
slot
}),
};
// The instance slot every part below carries in its `MeshTag`. The WEARER's only when the item
// has no palette slot of its own — that is what puts a tinted body's colour on a boneless
// helm (decision 0812). An item WITH a slot must carry its own, because the vertex stage
// indexes the palette with the same field; the wearer's tint reaches it through the
// `ParentModel` chain instead, which is the reference's own route for an attached model's
// colours (`0x714000`, `aura_visual::chained_tint`).
let rig_slot = item_rig.or(rider).unwrap_or(ctx.rig_slot);
// Billboard batches (the torch's glow card) collected for the world-root card spawn
// below — as plain children they'd render at the item root (the grip), not the
// authored pivot (the torch head). Decision 0153.
@@ -415,11 +471,11 @@ fn spawn_slot(
let set = item_fade_set(part);
let effective = PartFade::resolve(joined, &set);
let (init_mat, tag_alpha) = effective.seed(&set, now);
// A rigged item draws the SKINNED twin — every part of it, not only the welded batch:
// the model rests at bind pose, so each non-billboard bone's palette row is the
// identity and the other batches land pixel-for-pixel where the static mesh had them.
// The rig's absence (or a form that never built one) falls straight back to `mesh`.
let skinned = item_rig.and(part.skinned_mesh.as_ref());
// An item with a palette slot draws the SKINNED twin — every part of it, not only a
// welded batch: the model rests at bind pose, so each non-billboard bone's palette row
// is the placement matrix itself and the batches land pixel-for-pixel where the static
// mesh had them. Slotless (boneless display, or a full palette) falls back to `mesh`.
let skinned = item_rig.or(rider).and(part.skinned_mesh.as_ref());
let mut child = parent.spawn((
Mesh3d(skinned.unwrap_or(&part.mesh).clone()),
MeshMaterial3d(init_mat),
@@ -446,7 +502,10 @@ fn spawn_slot(
if skinned.is_some() {
// The palette replaces this part's world matrix outright, so its model-local
// `Aabb` below is not the volume it draws in — the same reason the effect lane's
// skinned parts opt out (`spell_fx`). Seven models' worth of always-drawn parts.
// skinned parts opt out (`spell_fx`). It also keeps the reference's own rule that
// a scene object is elected ONCE, at the body root, never per batch (1473) — which
// is what a rider's part would otherwise start doing, since the skinned twin keeps
// main-world data and so earns the `calculate_bounds` the static twin never had.
child.insert((
benilla_world::rig_palette::RigPart(root),
bevy::camera::visibility::NoFrustumCulling,
@@ -747,7 +806,20 @@ mod tests {
/// bone (decision 0841), and run the attach. Returns the part tags, the wearer's own instance
/// slot, the item root's palette slot (`None` when it spawned no rig), and the mesh the part
/// actually drew with.
fn attach_a_shoulder(welded: bool) -> (Vec<u32>, u16, Option<u16>, Option<Handle<Mesh>>) {
/// What the shoulder harness reports: the spawned parts' tags, the wearer's own slot, the
/// item's slot (the one that is not the wearer's), the mesh it drew, and — the fact that
/// separates 1609's rider from 0841's joint rig on the same shape — the [`RigRider`] the item
/// root carries, if any.
struct Attached {
tags: Vec<u32>,
wearer_slot: u16,
item_rig: Option<u16>,
mesh: Option<Handle<Mesh>>,
rider: Option<benilla_world::rig_rider::RigRider>,
joints: usize,
}
fn attach_a_shoulder(welded: bool) -> Attached {
const KIND: ItemModelKind = ItemModelKind::ShoulderLeft;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
@@ -833,11 +905,29 @@ mod tests {
.iter(app.world())
.map(|m| m.0.clone())
.next();
(tags, wearer_slot, item_rig, mesh)
let rider = app
.world_mut()
.query::<&benilla_world::rig_rider::RigRider>()
.iter(app.world())
.next()
.copied();
let joints = app
.world_mut()
.query::<&benilla_world::billboard::BillboardJointRig>()
.iter(app.world())
.count();
Attached {
tags,
wearer_slot,
item_rig,
mesh,
rider,
joints,
}
}
/// The welded case, named for the assertion that reads it.
fn attach_a_welded_shoulder() -> (Vec<u32>, u16, Option<u16>, Option<Handle<Mesh>>) {
fn attach_a_welded_shoulder() -> Attached {
attach_a_shoulder(true)
}
@@ -953,31 +1043,65 @@ mod tests {
/// is the standing guard that the arc stays absent in our own basis.
#[test]
fn a_welded_billboard_item_spawns_its_own_rig() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_welded_shoulder();
let item_slot = item_rig.expect("the welded display allocates a palette slot");
assert!(wearer_slot >= 1, "the wearer really has a rig of its own");
let a = attach_a_welded_shoulder();
let item_slot = a
.item_rig
.expect("the welded display allocates a palette slot");
assert!(a.wearer_slot >= 1, "the wearer really has a rig of its own");
assert_ne!(
item_slot, wearer_slot,
item_slot, a.wearer_slot,
"the item's palette is not the wearer's"
);
assert_eq!(tags.len(), 1);
assert_eq!(a.tags.len(), 1);
assert_eq!(
benilla_world::mesh_tag::rig_of(tags[0]),
benilla_world::mesh_tag::rig_of(a.tags[0]),
item_slot,
"the part indexes the ITEM's palette, not the body's"
);
assert_eq!(mesh, Some(skinned_handle()), "…and draws the skinned twin");
assert_eq!(
a.mesh,
Some(skinned_handle()),
"…and draws the skinned twin"
);
// The welded lane keeps its own camera-replaced JOINT rig — it is not a rigid frame, and
// that is exactly why 1609's rider cannot serve it (see `rig_rider`'s residuals).
assert_eq!(a.joints, 1, "the welded item runs a billboard joint rig");
assert!(a.rider.is_none(), "…and is not a rider");
}
/// The counter-anchor, on the same harness: an ordinary item — the 9684 models that weld
/// nothing — spawns no rig, keeps the wearer's slot (0812's tint route) and keeps the static
/// mesh. If the gate ever widened, this is what would start allocating a palette per torch.
/// **The rider (decision 1609).** The counter-anchor, on the same harness: an ordinary item —
/// the 9684 models that weld nothing — now takes a palette slot too, but a different KIND of
/// one. It is one rigid frame composed in the wearer's rig frame, not a joint rig, and the
/// four things that makes true are asserted together because any one alone is a silent no-op:
/// the part draws the **skinned** twin (a static mesh is never skinned), it carries its OWN
/// slot rather than the wearer's (the vertex stage indexes the palette with that field), the
/// root holds a [`RigRider`] naming the wearer and the attach bone, and no joint rig was built.
///
/// Until 1609 this test asserted the opposite — no slot, the wearer's tag, the static mesh —
/// because the ordinary lane placed an item through its entity's absolute `GlobalTransform`.
/// At Elwynn's ~9481 yards that sum lands on an 0.98 mm f32 grid and is rebuilt every frame,
/// so a standing guard's helm hopped a whole grid step every other frame while the body under
/// it moved exactly. The static-mesh route is what this test now forbids coming back.
#[test]
fn an_ordinary_item_still_spawns_no_rig() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_shoulder(false);
assert!(item_rig.is_none(), "no palette slot for a rigid item");
assert_eq!(benilla_world::mesh_tag::rig_of(tags[0]), wearer_slot);
assert_eq!(mesh, Some(Handle::default()), "the static mesh, as before");
fn an_ordinary_item_rides_one_rigid_frame_in_its_wearers_rig() {
let a = attach_a_shoulder(false);
let item_slot = a.item_rig.expect("an ordinary item takes a rider slot");
assert_ne!(item_slot, a.wearer_slot, "its palette is not the wearer's");
assert_eq!(
benilla_world::mesh_tag::rig_of(a.tags[0]),
item_slot,
"the part indexes the RIDER's palette"
);
assert_eq!(
a.mesh,
Some(skinned_handle()),
"…and draws the skinned twin, or the slot would place nothing"
);
let rider = a.rider.expect("the item root carries the rider");
assert_eq!(rider.slot, item_slot);
assert_eq!(rider.bone, 3, "composed from the wearer's attach bone");
assert_eq!(rider.local, Vec3::ZERO, "…at the attachment point's offset");
assert_eq!(a.joints, 0, "a rigid item builds no joint rig");
}
/// **What a booth can see of an equipped item** (decision 0822, `#bugs` B118's paper-doll half).
@@ -1287,7 +1411,21 @@ mod tests {
(8, ItemModelKind::ShoulderRight, true),
] {
let mut dm = empty_display();
dm.parts = Some(vec![part(false)]);
let mut p = part(false);
// A real item model has a skeleton and a skinned twin, so it takes a rider slot
// (1609) — which is what makes the sheath swap's rider re-seat below observable.
p.skinned_mesh = Some(skinned_handle());
dm.parts = Some(vec![p]);
dm.skeleton = benilla_assets::ModelSkeleton {
joints: vec![benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
parent_arm: None,
}],
spine_bone: None,
head_bone: None,
};
if effects {
dm.emitters = vec![benilla_assets::ModelEmitter {
def: benilla_world::testing::plain_particle_def(),
@@ -1422,6 +1560,19 @@ mod tests {
.find(|r| r.offset.distance(BACK_AT) < 1e-5)
.map(|r| r.bone);
assert_eq!(seat, Some(2), "the rider's cached seat followed the move");
// **And the palette rider followed it too** (decision 1609). The MOVE is the one edit that
// keeps the item's entity alive while changing the bone its frame is composed from, so the
// rider is the one thing a re-parent can silently leave behind: the sword would go on
// drawing at the hand it was stowed from while its entity, its glow and its emitters all
// travelled to the back.
let rider = app
.world()
.entity(weapon)
.get::<benilla_world::rig_rider::RigRider>()
.expect("the weapon rides a rider frame");
assert_eq!(rider.bone, 2, "the rider is composed from the sheath bone");
assert_eq!(rider.local, BACK_AT, "…at the sheath point's offset");
assert_eq!(rider.host, wearer);
}
/// The other half of the per-slot diff: a slot whose item genuinely CHANGED is rebuilt (its old
+21 -7
View File
@@ -1426,13 +1426,27 @@ fn control(
} else {
// The kinematic mover step — walk/fall physics + the step-down snap (decisions
// 0009/0182/0190); the mechanism lives in [`mover`].
// **Water walking** (decision 0866): hand the mover the liquid surface as a floor. The
// `!swimming` half is the reference's own gate — the water-walk arm at `0x63160d` is
// skipped when `MOVEFLAG_SWIMMING` is set (`0x631617`) — so granting the aura to a
// submerged caster does not eject them; they surface onto the water on the way out.
// (This branch is already the non-swimming one, so `swimming` is false here; the
// condition is written out because the gate is the mechanism, not an accident of
// control flow.)
// **Water walking** (decisions 0866 + 1611): hand the mover the liquid surface, which
// it treats as ordinary ground — the classify sees it, the grounded arm runs, and the
// clamp finalises Y ([`mover::step`], where the *why* of both halves lives). In the
// reference this is not a floor that gets handed anywhere: `MOVEFLAG_WATERWALKING` ORs
// the ADT liquid layers into the walk trace's class mask (`0x63162e`), so the surface
// simply *is* geometry. Passing it down is our stand-in for that, because liquid is
// queried rather than swept here.
//
// The `!swimming` half is the reference's own gate — the arm at `0x63160d` is skipped
// when `MOVEFLAG_SWIMMING` is set (`0x631617`) — so granting the aura to a submerged
// caster does not eject them; they surface onto the water on the way out. (This branch
// is already the non-swimming one, so `swimming` is false here; the condition is
// written out because the gate is the mechanism, not an accident of control flow.)
//
// **The arm's third gate is NOT implemented, and it is the way back INTO the water**:
// `0x6315f0` also requires `pitch > -37.0°` (`[0x80dfe8]`; the emitted `jne` reads ZF,
// so `==` is excluded), and its complement in `SetPitch 0x7c6f70` elects a
// zero-velocity `StartFalling` when a standing, non-falling water-walker looks down
// past the same angle. Both need the mover pitch (`CMovement+0x20`) live on land,
// which here is [`Player::swim_pitch`] — steered only while swimming today. Named in
// decision 1611 rather than half-built.
let water_floor = (player.modes.water_walking && !swimming)
.then_some(surface_y)
.flatten();
+141 -14
View File
@@ -99,14 +99,42 @@ pub(super) fn step(
} else {
0.0
};
let ground_reach = hover_offset
+ if player.airborne_since.is_some() {
LAND_PROBE
} else {
GROUND_PROBE
};
let base_reach = if player.airborne_since.is_some() {
LAND_PROBE
} else {
GROUND_PROBE
};
let ground_reach = hover_offset + base_reach;
let classify = probe_down(center, ground_reach);
let on_walkable = classify.as_ref().is_some_and(|h| h.normal1.y >= GROUND_COS);
// **Water walking: the liquid surface is GROUND, and the classify above has to see it**
// (decision 1611, correcting 0866). In the reference the surface is not a special case at all
// — `MOVEFLAG_WATERWALKING` ORs the two ADT liquid layers into the walk trace's *class mask*
// (`0x63162e or edi, 0x30000`, wow-re `moveflag-family.md` §2.2), so the same swept query that
// finds terrain finds the water, and the walk resolver stands on it at its raw Z with no
// water-specific code anywhere (`0x6367b0` contains zero references to the bit).
//
// Liquid is not a collider on our side — it is queried, not swept — so `probe_down` cannot see
// it. Asking the probe's own question against the surface plane is what puts it back in the
// classification. 0866 answered it only *after* the step, as a position clamp, and that is
// exactly what B322's lost-control bug was: with no collider under a water-walker every frame
// classified **airborne**, so the grounded arm's `horiz_vel = input_horiz` never ran again —
// and an airborne frame's horizontal is *frozen* momentum. Walk off a quay at a run and the
// last grounded frame's full 7 yd/s is what freezes: the body keeps running, at running speed,
// through every turn and through the keys being released, while the clamp reports `grounded`
// at the end of each frame. (The `length_squared() < 0.01` air nudge below is not even
// reached; it only bites for a body that arrives at a standstill.) 0866's live proof arrived
// by *teleport*, at rest, with no input — the one case that cannot show any of this — and 0866
// named the gap itself: "the water-walk walk-on case is untested headlessly".
//
// The reach is [`base_reach`] and deliberately **not** `ground_reach`: our hover climb is
// collider-driven and never lifts a body off water, so the clamp below keeps a hovering
// water-walker's feet exactly at the surface. The two tests are the same question and must
// stay the same answer — disagreeing is the whole of this bug. (Whether the reference floats a
// hovering water-walker a yard *above* the surface is a real open question, with a byte lead:
// see decision 1611.) A negative difference — the feet already under the surface — counts as
// support too: that is a body penetrating solid geometry, and the clamp resolves it upward.
let on_water = water_floor.is_some_and(|s| center.y - half_h.y - s <= base_reach);
// Who we stand on (frame start); the end-of-frame snap probe below refreshes it post-move.
let mut ground_entity = if on_walkable {
classify.map(|h| h.entity)
@@ -146,7 +174,8 @@ pub(super) fn step(
// failure 0209's atomic commit was built to make impossible. The ride is re-earned from the
// certification every frame it continues, so this can hold nothing up that has not just proved
// it can be climbed.
let on_floor = !held && (on_walkable || player.steep_support) && player.vel_y <= 0.0;
let on_floor =
!held && (on_walkable || on_water || player.steep_support) && player.vel_y <= 0.0;
// The wedged rest (decision 0211) stands until real ground takes over or the support
// vanishes — we walked off the funnel wall into open air, which resumes a normal fresh fall.
@@ -315,13 +344,20 @@ pub(super) fn step(
}
}
// **Water walking: the liquid surface IS the floor** (decision 0866). `water_floor` is the
// surface Y the caller resolved, and it is `Some` only while the mode is granted AND we are not
// already swimming — the reference's own gate, read at `0x631617` (`test eax,0x200000; jne`)
// right after the water-walk test: a caster who is already submerged keeps swimming, and only
// surfaces onto the water once out of it. Liquid is not a collider here (it is queried, not
// swept), so it cannot come out of the probes above; it lands as a floor clamp instead — the
// body may not sink past it, and resting on it is being grounded, which ends any arc.
// **The water floor, resolved** — the second half of the `on_water` classify above, and the
// depenetration a swept solid would have done for free: the body may not end the frame under
// the surface. It still has to run after the slide, because the slide and the fall are what
// can carry the feet through: a body dropping onto the water crosses more than [`LAND_PROBE`]
// in a frame, so the landing frame classifies airborne, falls, and is caught here — exactly
// how a collider landing resolves. On an ordinary on-water frame the classify has already said
// grounded and the slide kept Y, so this is a no-op that re-states it.
//
// `water_floor` is `Some` only while the mode is granted AND we are not already swimming — the
// reference's own gate at `0x631617` (`test eax,0x200000; jne`), whose *reason* is worth
// keeping: the swim path traces the same two liquid layers into a different container with the
// plane **negated** (`0x6320fe fchs`), a bound above rather than a floor below, so the two are
// mutually exclusive by construction and the same triangles can never enter the solver twice
// with opposite orientation (wow-re `moveflag-family.md` §2.4).
if let Some(surface) = water_floor {
let feet = center.y - half_h.y;
if feet <= surface {
@@ -1853,6 +1889,97 @@ mod tests {
assert!(out.dot(n) >= -1e-6);
}
/// **B322, the lost-control half** (decision 1611): a body walking off land onto water with
/// `MOVEFLAG_WATERWALKING` up must keep steering and must stop when the keys are released.
///
/// Before 1611 it did neither, and the reason was structural rather than numeric: the liquid
/// surface was applied only as a position clamp *after* the step, so the classify — which
/// looks for colliders and finds nothing over open water — called every water-walking frame
/// **airborne**, and an airborne frame's horizontal is frozen momentum — so the last *grounded*
/// frame's velocity, taken on the land behind you, is what the body carries out over the water
/// for good. This fixture measured it before the fix: 60 frames east at 7 yd/s put the body at
/// x = 6.0; 30 frames pressing **north** moved it another 3.5 yd **east** and 0.0 north; 30
/// frames with **no keys at all** moved it 3.5 yd east again. The director's words: *"when you
/// step onto over water with it it won't let you control the char anymore, just keeps
/// running."*
///
/// The three observables, in one drive: it reaches the water at all, a new direction actually
/// turns it, and releasing the keys stops it dead. The control that must not change is the
/// height — the feet stay on the surface throughout, which is what 0866 shipped and measured.
#[test]
fn a_water_walker_steers_and_stops_instead_of_coasting() {
const SURFACE: f32 = 0.0;
const SPEED: f32 = 7.0;
// Land out to x = 0, flush with the water; open water beyond it, with no collider at all.
const QUAY: [(f32, f32); 2] = [(-3.0, SURFACE), (0.0, SURFACE)];
let (east, north, idle) = world_from_profile(&QUAY)
.world_mut()
.run_system_once(|world: benilla_world::collision::WorldCollision| {
let capsule = player_capsule();
let mut time = Time::default();
time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
let mut player = Player {
modes: super::super::state::MoveModes {
water_walking: true,
..Default::default()
},
pos: Vec3::new(-1.0, SURFACE, 0.0),
..Default::default()
};
let drive = |player: &mut Player, dir: Vec3, frames: usize| {
for _ in 0..frames {
step(
player,
&time,
&world,
&capsule,
dir != Vec3::ZERO,
dir,
SPEED,
false,
Some(SURFACE),
);
}
player.pos
};
let east = drive(&mut player, Vec3::X, 60);
let north = drive(&mut player, Vec3::Z, 30);
let idle = drive(&mut player, Vec3::ZERO, 30);
(east, north, idle)
})
.unwrap();
// Walked off the quay and out over open water, on the surface.
assert!(
east.x > 1.0,
"the walk onto the water must actually travel: {east:?}"
);
for (label, p) in [("east", east), ("north", north), ("idle", idle)] {
assert!(
(p.y - SURFACE).abs() < 1.0e-3,
"the feet must stay on the liquid surface ({label}): {p:?}"
);
}
// Steering: a new direction moves the body that way, and the abandoned one stops growing.
assert!(
north.z - east.z > 1.0,
"a water-walker must steer: z {} -> {}",
east.z,
north.z
);
assert!(
(north.x - east.x).abs() < 0.1,
"the abandoned direction must stop advancing: x {} -> {} (the frozen air-nudge drift)",
east.x,
north.x
);
// Release: dead stop, no coast. This is the assertion the report is about.
assert!(
idle.distance(north) < 1.0e-3,
"released keys must stop a water-walker dead: {north:?} -> {idle:?}"
);
}
#[test]
fn a_hovering_mover_refuses_the_jump_and_leaves_no_trace() {
// `0x7c623a` — HOVER is the FIRST test in `CMovement::Jump 0x7c6230`, and the keyboard
+19 -5
View File
@@ -308,8 +308,8 @@ pub(super) struct MoveSpeed {
/// - [`feather_fall`](Self::feather_fall) — terminal fall speed becomes [`FEATHER_TERMINAL_VELOCITY`]
/// ([`super::mover::step`]).
/// - [`hover`](Self::hover) — ground contact rises by [`HOVER_HEIGHT`] ([`super::mover::step`]).
/// - [`water_walking`](Self::water_walking) — the liquid surface is walkable ground, and the swim
/// latch cannot arm ([`super::swim`]).
/// - [`water_walking`](Self::water_walking) — the liquid surface becomes ordinary ground, in the
/// classify and in the resolve alike ([`super::mover::step`]).
/// - [`levitating`](Self::levitating) — the swim/depth decision is suppressed entirely
/// ([`super::swim::update_swimming`]).
///
@@ -327,15 +327,29 @@ pub(crate) struct MoveModes {
/// a separate `UNIT_FIELD_FLAGS` gate ([`crate::player::UNIT_FLAG_STUNNED`]).
pub(crate) rooted: bool,
/// Water-walking (`SMSG_MOVE_WATER_WALK`, `SPELL_AURA_WATER_WALK` — Water Walking, Levitate, and
/// the ghost form): the liquid surface counts as walkable ground, so we stand on it instead of
/// sinking, and the swim latch cannot arm underneath us.
/// the ghost form): the liquid surface counts as ordinary walkable ground, so we stand on it
/// instead of sinking ([`super::mover::step`] — the classify and the clamp, decision 1611).
///
/// **It does NOT stop the swim latch arming**, and the claim that it did stood here until
/// decision 1611 read the bytes: `0x6030c0`'s only mode test is `test ah,4` — LEVITATING
/// (`0x400`) — and it never looks at `0x10000000` at all. The exclusion runs the other way and
/// lives one layer down, in the trace-mask arm at `0x631617`: *swimming* turns water-walking
/// off, not the reverse. So a swimmer who gains the aura keeps swimming, by construction.
pub(crate) water_walking: bool,
/// Feather fall (`SMSG_MOVE_FEATHER_FALL`, `SPELL_AURA_FEATHER_FALL` — Slow Fall, Levitate): the
/// fall integrator's terminal velocity drops to [`FEATHER_TERMINAL_VELOCITY`]. Nothing else
/// changes — the arc, the flags and the landing report are the ordinary ones.
pub(crate) feather_fall: bool,
/// Hover (`SMSG_MOVE_SET_HOVER`, `SPELL_AURA_HOVER` — Levitate): ground contact sits
/// [`HOVER_HEIGHT`] above the surface, so the body floats and walks along a yard up.
/// [`HOVER_HEIGHT`] above the surface, so the body floats and walks along a yard up. It also
/// refuses the jump outright — the first test in `CMovement::Jump 0x7c6230`, live for the
/// keyboard press ([`super::mover::step`], and the swim breach in [`super::control`]).
///
/// **Composed with [`water_walking`](Self::water_walking), those two gates leave a swimmer
/// with no way to the surface** (B322's first symptom, decision 1611): water-walking is off
/// while SWIMMING, and SWIMMING only clears on the depth compare or on a breach that HOVER
/// refuses. Both gates are individually VERIFIED; the *composition* is inferred, and it is the
/// one part of the Levitate lane still open.
pub(crate) hover: bool,
/// **Free flight** (`MOVEFLAG_LEVITATING`, GM `.cheat fly` — decision 0726). The one mode that
/// arrives *unhandshaked*, merged out of a server-authored move ([`super::wire_in`]).
+11 -1
View File
@@ -285,7 +285,17 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// the conflation that ran the body panes' item effects at half speed (decision 1559, B312). The
/// alternative was passing a `Res<PaneRate>`-shaped opinion down into the engine's own sim, which
/// puts the game's knob inside the renderer to keep a count flat.
const CEILING: usize = 162;
/// And 162 → 163: `rig_rider::RigRider`, a PUBLISH of the same shape as `rig_anim::AnimParked`
/// and `particles::ViewThrottled` above — a component the ENGINE reads and the GAME writes.
/// Placing an attached model's vertices in its wearer's rig frame rather than in absolute f32
/// world space is machinery (the whole lane is `rig_rider` + the palette's row writer, and the
/// reason is arithmetic: at Elwynn's ~9481 yards the absolute sum lands on an 0.98 mm grid and is
/// rebuilt every frame). *Which* bone a helm hangs from, and what a sheath swap does to that, is
/// policy, and it is `entities::equipment`'s, in the game — the same M2 attachment table that
/// decides where a weapon is drawn versus stowed. There is no engine-side way to know it: the
/// engine sees a scene-graph child, and a child is not an attachment. The alternative was passing
/// the game's attach-slot table down into the renderer to keep a count flat (decision 1609).
const CEILING: usize = 163;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
+1
View File
@@ -160,6 +160,7 @@ pub mod particles;
pub mod ribbons;
pub mod rig_anim;
pub mod rig_palette;
pub mod rig_rider;
pub mod schedule;
mod shaders;
pub mod sky;
+124 -1
View File
@@ -447,6 +447,58 @@ impl RigPalettes {
}
}
/// **The rider write** (decision 1609): fill every row of `slot` with ONE rigid frame, and
/// publish the world position it is measured from.
///
/// An attached model rests at bind pose, where a palette row `world_from_joint × ibp` is the
/// placement matrix for every joint (`F × bind_j × bind_j⁻¹`) — so the whole model is one
/// frame repeated, and no inverse-bindpose list is consulted or needed. `frame` arrives in the
/// HOST rig's frame, at rig-sized magnitudes; `origin` is the host's world position, which the
/// vertex stage adds back camera-relative. The point of the whole lane is that the two are
/// never summed in f32 on the CPU (see [`crate::rig_rider`]).
///
/// **Idempotent, deliberately.** Riders are re-derived every frame from the host's pose, so a
/// standing unit would otherwise dirty its rows — and pay an upload — for a value that has not
/// moved. The compare is bit equality, not an epsilon, exactly as the camera's own no-op write
/// gate is (decision 1362): a real sub-epsilon change must still land.
pub(crate) fn write_rider(&mut self, slot: u16, frame: Affine3A, origin: Vec3) {
let s = slot as usize;
let (Some(&base), Some(&len)) = (self.table.get(s), self.slot_len.get(s)) else {
return;
};
if len == 0 {
return;
}
let (m3, t) = (frame.matrix3, frame.translation);
let want = [
[m3.x_axis.x, m3.y_axis.x, m3.z_axis.x, t.x],
[m3.x_axis.y, m3.y_axis.y, m3.z_axis.y, t.y],
[m3.x_axis.z, m3.y_axis.z, m3.z_axis.z, t.z],
];
let r0 = 3 * base as usize;
let origin_word = self.origins.get(s).copied();
let unchanged = self.rows[r0..r0 + 3] == want
&& origin_word == Some([origin.x, origin.y, origin.z, 0.0]);
if unchanged {
return;
}
self.cost_rows += len;
self.set_origin(slot, origin);
let wm = self.bone_watermark();
let rows = rows_make_mut(
&mut self.rows,
wm,
&mut self.cost_copies,
&mut self.cost_copy_us,
);
for b in 0..len as usize {
let r = 3 * (base as usize + b);
rows[r..r + 3].copy_from_slice(&want);
}
let mirrored = self.mirrored.get(s).copied().unwrap_or(false);
self.dirty.push((base, len, mirrored));
}
/// Flag a rig's rows to ALSO reach the booth mirror buffers ([`RigPaletteMirrors`]): the
/// booth spawners call it for their rigs — whose materials bind a studio light buffer, not
/// the shared one — right after allocation.
@@ -489,6 +541,26 @@ impl RigPalettes {
)
}
/// A rider slot's placement **as the vertex stage reads it** (decision 1609): its
/// `(rig_origin, row-0 translation)` pair, unsummed. The instrument read — `WOW_JITTER`
/// measures the row's own motion against the anchor's absolute world translation, and the
/// whole point of the lane is that only the first of those two is on an f32 grid coarse
/// enough to see. Summing them here would destroy exactly the thing being measured, which is
/// why this returns the pair and [`Self::world_palette`] (a picker read, not a precision one)
/// does not.
pub fn rider_placement(&self, slot: u16) -> Option<(Vec3, Vec3)> {
let s = slot as usize;
if *self.slot_len.get(s)? == 0 {
return None;
}
let r = 3 * *self.table.get(s)? as usize;
let o = self.origins.get(s)?;
Some((
Vec3::new(o[0], o[1], o[2]),
Vec3::new(self.rows[r][3], self.rows[r + 1][3], self.rows[r + 2][3]),
))
}
/// How many live rigs have a COMPUTED palette (their first bone's first row is non-zero — a
/// freshly allocated or never-computed rig reads all-zero rows). The FPS probe prints it:
/// allocation without computation renders origin-collapsed rigs, and no other probe number
@@ -938,7 +1010,11 @@ pub fn plugin(app: &mut App) {
.add_systems(Update, census_rig_palettes)
.add_systems(
PostUpdate,
(compute_rig_palettes, publish_rig_palettes)
(
compute_rig_palettes,
crate::rig_rider::write_rig_riders,
publish_rig_palettes,
)
.chain()
// After the last joint-world writer: propagation, then the billboard joint pass
// (which rewrites joint GlobalTransforms in place — see its module doc).
@@ -1114,6 +1190,53 @@ mod tests {
assert!(p.alloc(1).is_some());
}
/// **The rider write's two contracts** (decision 1609): one frame reaches EVERY row of the
/// slot — which is what makes an attached model's bind-pose palette equal its static
/// placement — and re-writing the same frame is a no-op, so a standing unit's five
/// attachments cost five compares a frame and never an upload.
#[test]
fn a_rider_frame_fills_every_row_and_re_writing_it_is_free() {
let mut p = RigPalettes::default();
let skin = RigSkin::allocate_bones(&mut p, 4, Handle::default()).unwrap();
let origin = Vec3::new(-9464.31, 62.17, 56.91);
let frame = Affine3A::from_rotation_translation(
Quat::from_rotation_y(0.7),
Vec3::new(0.3, 1.4, -0.2),
);
p.dirty.clear();
p.write_rider(skin.slot, frame, origin);
assert_eq!(p.dirty.len(), 1, "the first write dirties the range");
assert_eq!(p.dirty[0].1, 4, "and dirties ALL of it, not just row 0");
// Every row carries the same frame: a bind-pose model's `world_from_joint × ibp` is the
// placement itself for every joint, so the whole model is one matrix repeated. Read back
// through the picker's own reconstruction, which is the only public row reader.
let pal = p.world_palette(skin.slot, 4).unwrap();
let want = Mat4::from(Affine3A::from_translation(origin) * frame);
for (b, m) in pal.iter().enumerate() {
assert!(m.abs_diff_eq(want, 1e-2), "row {b}: {m:?} vs {want:?}");
}
// And the unsummed pair the vertex stage actually reads.
let (o, t) = p.rider_placement(skin.slot).unwrap();
assert_eq!(o, origin);
assert!(t.abs_diff_eq(Vec3::from(frame.translation), 1e-6));
// Idempotent: the standing-still case must not reach the upload.
p.dirty.clear();
p.write_rider(skin.slot, frame, origin);
assert!(
p.dirty.is_empty(),
"an unchanged rider frame must not dirty its range"
);
// A real move still lands — bit equality, not an epsilon (the 1362 gate's rule).
p.write_rider(
skin.slot,
Affine3A::from_translation(Vec3::new(0.3, 1.4, -0.2 + 1.0e-6)) * frame,
origin,
);
assert_eq!(p.dirty.len(), 1, "a sub-millimetre move is still a move");
}
/// The picker's read side puts the slot's ORIGIN back on (decision 0974) — rows are stored
/// rig-relative, so a reconstruction that forgot the origin would ray-test every skinned unit
/// against a copy of itself sitting at the map origin.
+313
View File
@@ -0,0 +1,313 @@
//! **The rider lane** (decision 1609): an attached model — a helm, a pauldron, a held weapon, a
//! shield — placed by a palette frame measured in its WEARER's rig frame, so its position is
//! never an absolute f32 world coordinate that gets recomputed every frame.
//!
//! ## Why an attached model needs its own lane at all
//!
//! Decision 0974 moved the model lane camera-relative: a rig's palette rows are composed with the
//! root's translation zeroed, the row's world position rides a per-slot `rig_origin`, and the
//! vertex stage adds it back as `frame·v + (origin − camera)`. A standing unit's origin is
//! **constant**, so its body is exact — the one f32 rounding left in it never moves.
//!
//! Its attachments were not. They are ordinary scene-graph entities: the attach anchor's
//! `GlobalTransform` is `joints_root_world × bone_local`, a ~1-yard quantity added to a ~9500-yard
//! one, recomputed every frame. WoW's coordinates run to ±17066 yd and an f32 in `[8192, 16384)`
//! has an ULP of exactly `2⁻¹⁰ yd = 0.9766 mm`, so the sum lands on a 0.98 mm grid **on the large
//! axis** while the bone under it moves smoothly across it. The rendered helm therefore holds
//! still for a few frames and then snaps one whole ULP sideways, for ever, while the body it sits
//! on moves exactly. Measured on the Goldshire Stormwind Guard (`WOW_JITTER`, 2698 frames): the
//! anchors' per-frame Δ along that axis was **exactly 0 or exactly one ULP and nothing between**
//! (1835 / 839 frames), their second difference was exactly one ULP on 53 % of frames, and the
//! body bones' was 0.0094 mm — a factor of 104. At the reported close-up that is 0.83 px of hop
//! every other frame on the helm, both pauldrons, the sword and the shield. 0974 named this
//! residual and did not take it; this is taking it.
//!
//! ## The mechanism
//!
//! An attached model rests at **bind pose** — the item lane arms no `AnimationPlayer` and no
//! global-sequence drive, which is why it draws a static mesh today. At bind pose every palette
//! row of it is the *same* matrix: a row is `world_from_joint × inverse_bindpose`, and
//! `world_from_joint = F × bind_model_j` with `inverse_bindpose_j = bind_model_j⁻¹`, so every row
//! collapses to the placement `F` itself. So the whole model needs **one frame**, written into
//! each of its rows, and the picture it draws is identical to the static mesh's — the change is
//! precision and nothing else.
//!
//! And `F` is available exactly: `F = host_basis × pose.model[bone] × T(offset)`, where
//! `host_basis` is the host's own frame with its translation zeroed — the same rig-relative basis
//! 0974 composes the body in. Every factor is rig-sized, so `F` costs ~1e-7 yd, and the slot's
//! `rig_origin` carries the host's world position through to the vertex stage, where it is added
//! camera-relative. Nothing on the path is ever a big-plus-small sum.
//!
//! **The entity keeps its absolute `GlobalTransform`.** That is the whole reason this is a
//! *palette* lane and not a rebase of the scene graph: collision, picking, the item glow, the
//! bowstring and the booth mirrors all read an attached model's world position and all keep
//! reading exactly what they read before. Only the rendered vertices change route.
//!
//! **Named residuals** (they keep the absolute route, and the staircase with it): billboard cards
//! (spawned at world root by decision 0153), item glow instances (children of the item root with
//! their own attachment offsets), the engine-drawn bowstring/fishing line, and the seven
//! `welds_billboard` displays whose 0841/0854 joint rig is camera-replaced per frame and so is not
//! a bind-pose rigid frame.
use bevy::prelude::*;
use crate::rig_anim::RigPose;
use crate::rig_palette::{rebase_origin, RigPalettes};
/// An attached model whose palette slot is placed from a bone of `host`'s rig, in `host`'s own rig
/// frame (decision 1609). Lives on the attached model's root, beside the [`crate::rig_palette::RigSkin`]
/// that owns the slot — so the slot is freed, and the rider forgotten, on the same despawn edge.
#[derive(Component, Clone, Copy)]
pub struct RigRider {
/// The rig this model hangs off.
pub host: Entity,
/// The host bone it hangs from. Attachment points never sit on a camera-faced bone (the same
/// fact `RigPose::posed_point` rests on), so the composed `pose.model` frame — which carries
/// the `flags & 0x7` arm but not the world pass's billboard replacement — is the right input.
pub bone: u16,
/// The attachment point's own offset from that bone, model space.
pub local: Vec3,
/// The palette slot the frame is written into; every row in it takes the same value.
pub slot: u16,
}
/// Write every rider's frame. Ordered with the palette's own writers (see
/// [`crate::rig_palette::plugin`]): after propagation and the billboard joint pass, so the host's
/// `GlobalTransform` and composed pose are both this frame's, and before the publish.
///
/// A host that is missing, has no pose, or does not carry the named bone leaves the rider's rows
/// alone — they hold the last frame written, which is what a torn-down joint gets in the entity
/// lane too. [`RigPalettes::write_rider`] is idempotent, so a standing unit's riders cost one
/// compare each and never reach the upload.
pub(crate) fn write_rig_riders(
riders: Query<&RigRider>,
poses: Query<&RigPose>,
frames: Query<&GlobalTransform>,
mut palettes: ResMut<RigPalettes>,
) {
for rider in &riders {
let Ok(pose) = poses.get(rider.host) else {
continue;
};
// **`joints_root`, never the host entity.** `pose.model` is model space *relative to the
// rig's own frame*, and that frame is only the unit itself in the ordinary case: a mounted
// rider's is its SEAT anchor and a terrain-conformed model's is its conform node
// (`entities::mount`, `entities::attach`). Composing through the unit's own transform
// instead would place a mounted character's helm and weapon by the gap between its feet
// and its saddle. This is the same frame `finalize_rig_worlds` writes the body's rows
// from and the same one `RigPose::posed_point` demands, so the body and everything hanging
// off it stay in ONE frame — which is the property the whole lane exists to keep.
let Ok(root_g) = frames.get(pose.joints_root) else {
continue;
};
let Some(bone) = pose.model.get(rider.bone as usize) else {
continue;
};
let origin = rebase_origin(root_g.translation());
let mut basis = root_g.affine();
basis.translation -= bevy::math::Vec3A::from(origin);
palettes.write_rider(
rider.slot,
basis * *bone * bevy::math::Affine3A::from_translation(rider.local),
origin,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rig_palette::RigSkin;
use bevy::math::{Affine3A, DVec3};
/// **The rig's frame is `joints_root`'s, not the host entity's** — the one distinction that
/// separates a helm sitting on a head from one sitting a saddle's height below it.
///
/// `RigPose::model` is model space measured from the rig's own frame, and that frame is the
/// unit itself only in the ordinary case: `entities::mount` re-points `joints_root` at the
/// SEAT anchor when a character mounts, and `entities::attach` at the conform node for a
/// terrain-tilted model. Both leave the unit's own `GlobalTransform` where it always was, so
/// composing a rider through it is wrong by exactly the seat offset — and wrong only while
/// mounted, which is the shape of bug that ships.
#[test]
fn a_rider_composes_through_the_rigs_own_frame_not_the_units() {
let mut app = App::new();
app.init_resource::<RigPalettes>();
// The seat: a yard and a half up and turned 90° — a mount's saddle, exaggerated so a
// composition through the wrong frame cannot land near the right answer by luck.
const SEAT: Vec3 = Vec3::new(1.0, 1.5, -2.0);
let seat = app
.world_mut()
.spawn(GlobalTransform::from(Transform {
translation: SEAT,
rotation: Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
scale: Vec3::ONE,
}))
.id();
// The unit stands somewhere else entirely, as it does while mounted.
let unit = app
.world_mut()
.spawn(GlobalTransform::from(Transform::from_translation(
Vec3::new(-5.0, 0.0, 7.0),
)))
.id();
let mut pose = crate::testing::test_rig_pose(seat, &[Vec3::ZERO, Vec3::new(0.0, 0.4, 0.0)]);
pose.compose();
app.world_mut().entity_mut(unit).insert(pose);
let skin = {
let mut palettes = app.world_mut().resource_mut::<RigPalettes>();
RigSkin::allocate_bones(&mut palettes, 1, Handle::default()).unwrap()
};
let slot = skin.slot;
let local = Vec3::new(0.05, 0.0, 0.02);
app.world_mut().spawn((
skin,
RigRider {
host: unit,
bone: 1,
local,
slot,
},
));
app.add_systems(Update, write_rig_riders);
app.update();
let (origin, row) = app
.world()
.resource::<RigPalettes>()
.rider_placement(slot)
.expect("the rider was written");
// The vertex stage adds the two back together; the pair is what it reads.
let placed = origin + row;
let seat_g = *app.world().entity(seat).get::<GlobalTransform>().unwrap();
let want = seat_g.affine()
* Affine3A::from_translation(Vec3::new(0.0, 0.4, 0.0))
* Affine3A::from_translation(local);
assert!(
placed.abs_diff_eq(Vec3::from(want.translation), 1.0e-5),
"the rider must sit on the SEAT's bone: {placed:?} vs {:?}",
want.translation
);
assert_eq!(origin, SEAT, "…and be measured from the rig's own frame");
// The failure this forbids: composing through the unit's own transform instead.
let unit_g = *app.world().entity(unit).get::<GlobalTransform>().unwrap();
let wrong = unit_g.affine()
* Affine3A::from_translation(Vec3::new(0.0, 0.4, 0.0))
* Affine3A::from_translation(local);
assert!(
placed.distance(Vec3::from(wrong.translation)) > 1.0,
"the two frames must be far apart, or this test proves nothing"
);
}
/// **The pin** (decision 1609), in the shape decision 0974 set for the body: score both
/// routes' *rendered motion* against an f64 oracle of the identical chain, at Goldshire, with
/// real glam f32 arithmetic — not a model of it.
///
/// The subject is one attachment on a slow idle: a bone 1.9 yd up the model swinging ±0.02 rad
/// with a 4 s period, so it travels about a millimetre a frame — the regime the report came
/// from, and the one where an f32 ULP is a large fraction of the real motion.
///
/// What separates the two routes is the SECOND difference. The chain's own curvature is
/// `A·ω²·dt²`, tens of microns here; the absolute route adds the ULP grid of a ~9481-yard
/// coordinate (`2⁻¹⁰ yd = 0.977 mm`) on top, because the rendered position holds still for
/// several frames and then hops one whole grid step. That is the staircase the director saw on
/// the helm, and it is what this test forbids coming back.
///
/// The premise is asserted, not assumed (0974's own caution, which cost it a first version
/// that measured nothing): if a future edit leaves the chain moving along a *small* world
/// axis, the absolute route stops spending an ULP and the comparison silently proves nothing.
#[test]
fn a_riders_frame_does_not_re_round_an_attachment_at_world_scale() {
// Bevy-space Goldshire, from the report: |z| ≈ 9481.5 puts the ULP at exactly 2⁻¹⁰ yd.
const HOST: Vec3 = Vec3::new(-76.1, 58.47, 9481.53);
const ULP: f32 = 1.0 / 1024.0;
const FRAMES: usize = 240;
const DT: f64 = 1.0 / 60.0;
// The M2 attachment point's own offset from the bone — an ordinary rig-sized quantity.
let offset = Vec3::new(0.02, 0.03, 0.04);
// The bone swings about X, so its tip travels in y/z — and bevy z IS the ~9.5 k axis here.
let theta = |f: usize| 0.02 * (std::f64::consts::TAU * (f as f64 * DT) / 4.0).sin();
let (mut abs, mut rel, mut oracle) = (Vec::new(), Vec::new(), Vec::new());
for f in 0..FRAMES {
let t = theta(f);
let bone = Affine3A::from_rotation_x(t as f32)
* Affine3A::from_translation(Vec3::new(0.0, 1.9, 0.0));
let local = Affine3A::from_translation(offset);
// The route the scene graph takes: the host's world affine × the bone × the offset,
// every product landing back in absolute f32 world space.
let host = Affine3A::from_translation(HOST);
abs.push(Vec3::from((host * bone * local).translation));
// The rider's route: the same chain in the host's own frame (translation zeroed). The
// world position rides the slot's `rig_origin` and is added camera-relative, so it
// contributes nothing to the MOTION measured here.
let basis = Affine3A::from_translation(Vec3::ZERO);
rel.push(Vec3::from((basis * bone * local).translation));
// f64 truth for the same chain, measured from the host.
let (s, c) = (t.sin(), t.cos());
let tip = DVec3::new(0.0, 1.9 * c, 1.9 * s);
let off = DVec3::new(offset.x as f64, offset.y as f64, offset.z as f64);
let rot_off = DVec3::new(off.x, off.y * c - off.z * s, off.y * s + off.z * c);
oracle.push(tip + rot_off);
}
// Frame-to-frame second differences, in yards.
let d2 = |v: &[Vec3]| {
(2..v.len())
.map(|i| (v[i] - 2.0 * v[i - 1] + v[i - 2]).length())
.fold(0.0f32, f32::max)
};
let (abs_d2, rel_d2) = (d2(&abs), d2(&rel));
let true_d2 = (2..oracle.len())
.map(|i| (oracle[i] - 2.0 * oracle[i - 1] + oracle[i - 2]).length())
.fold(0.0f64, f64::max) as f32;
// The premise: this chain really does exercise the large world axis, so the absolute route
// really is spending a whole ULP. Without this, a chain that drifted onto a small axis
// would make the comparison below pass while measuring nothing.
let travel = (1..abs.len())
.map(|i| (abs[i].z - abs[i - 1].z).abs())
.fold(0.0f32, f32::max);
assert!(
travel > 0.5 * ULP,
"premise: the chain must move along the ~9.5 k axis (moved {travel} yd/frame)"
);
assert!(
abs_d2 > 0.8 * ULP,
"premise: the absolute route must spend an f32 ULP of the world coordinate \
(Δ² = {abs_d2} yd, ULP = {ULP})"
);
// The verdict: the rider's frame carries the chain's own curvature and nothing else, while
// the absolute route carries a whole grid step on top of it — two orders apart.
assert!(
rel_d2 < 2.0 * true_d2 + 1.0e-6,
"the rider frame must add nothing to the chain's own curvature \
(Δ² = {rel_d2} yd vs a true {true_d2} yd)"
);
assert!(
abs_d2 > 20.0 * rel_d2,
"the two routes must not be comparable: absolute Δ² = {abs_d2} yd, \
rider Δ² = {rel_d2} yd"
);
// And the rider's frame agrees with f64 truth at rig scale, where the absolute route
// cannot: its answer is a ~9481-yard number and its error is that number's ULP.
let rel_err = rel
.iter()
.zip(&oracle)
.map(|(v, o)| (v.as_dvec3() - *o).length())
.fold(0.0f64, f64::max);
let abs_err = abs
.iter()
.zip(&oracle)
.map(|(v, o)| ((v.as_dvec3() - DVec3::new(-76.1, 58.47, 9481.53)) - *o).length())
.fold(0.0f64, f64::max);
assert!(rel_err < 1.0e-6, "rider frame vs f64 oracle: {rel_err} yd");
assert!(
abs_err > 20.0 * rel_err,
"absolute route vs f64 oracle: {abs_err} yd — expected the world coordinate's ULP"
);
}
}