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