instruments for motion at the glass, and a macro hovers to its name

- the jitter meter measures the palette and the pixels, not just the pose: `pal` probes the rows the vertex stage actually blends (the old bone columns read a matrix the GPU never sees), `SCREEN` carries them to viewport pixels by the shader's own arithmetic, `camrot` watches the camera's rotation where only its translation had been vouching for "bit-stable", and `WOW_JITTER_RAW` dumps one bone's series unaggregated — rotations too, since a translation can be smooth while its frames are not
- `WOW_FIXED_DT=<ms>` pins the animation advance so a screenshot burst measures the render rather than its own stall — arming on the wall clock, because during load the app runs ~385 updates a second and a pinned clock reached "90 seconds" in 14 s, photographing an empty world
- `benilla-visual flow` estimates the whole window's motion by least squares over every pixel (noise floor 0.0000 px on static regions, and its brightness-constancy limit is measured and stated: invalid on a lit deforming body), `edge` reads a silhouette's sub-pixel crossing renormalised against the lighting with an honest ±0.5 px floor, `WOW_TWIST_GAP` pins a yaw gap so the body counter-twist is exercised at all — every earlier "removing it changed nothing" had measured a pass that never ran — and `WOW_DITHER` adds the dither the frame's 8-bit output has never had, off by default because the reference's framebuffer was undithered
- hovering a MACRO action slot shows one white line, its name — the reference's macro leg renders exactly that and no more, in its "normal" white, where ours was a `_ => Ok(())` from before macros shipped; and `WOW_MSAA=8` is refused with a loud warning instead of panicking the render thread, through one parser both world-camera spawners now share

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:14:35 +07:00
co-authored by Claude Fable 5
parent 45f001bbc5
commit d1931eed1d
11 changed files with 909 additions and 18 deletions
+296 -9
View File
@@ -39,7 +39,10 @@
use bevy::camera::Projection;
use bevy::prelude::*;
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
use benilla_world::rig_anim::RigPose;
use benilla_world::rig_palette::RigSkin;
use benilla_world::view::WorldCamera;
use super::ProbeClock;
@@ -88,6 +91,34 @@ struct JitterMeter {
/// attachment, same frame, one measured in absolute world space and one in the rig's.
prev_rid: Vec<Vec3>,
prev2_rid: Vec<Vec3>,
/// **The rows the vertex stage actually blends** — the palette, probed at each bone's bind
/// position, same two-frame window.
///
/// The blind spot this closes, and it is the one that let a wrong "fixed" call stand. Every
/// other bone column here reads `RigPose::model`, the pose the compose pass produced — and
/// the GPU never reads it. It skins from the palette, which `rig_anim::compose::rig_worlds`
/// composes a SECOND time from the rig's locals through the root's own frame, folding in the
/// `flags & 0x7` arm and the billboard replacement. Anything that goes wrong between those
/// two compositions is invisible to `flesh`, so a clean `flesh` column has never been able to
/// clear the body — it only ever cleared the pose.
prev_pal: Vec<Vec3>,
prev2_pal: Vec<Vec3>,
/// **The end of the chain: where each probe lands ON SCREEN, in pixels.** Every other column
/// stops somewhere upstream — model space, rig space, world space — and each of those can be
/// spotless while the thing the eye reads is not, because the camera's ORIENTATION, the
/// projection and the viewport all sit between them and the glass. This series is the shader's
/// own arithmetic (`view_rot * (p_rig + (origin - cam))`, then clip) carried to viewport
/// pixels, so it is the only column that can be held against "I can see it move".
prev_scr: Vec<Vec2>,
prev2_scr: Vec<Vec2>,
/// The camera's ROTATION, same two-frame window. `prev_cam` above is its translation only —
/// and a camera that never moves an inch can still swing the whole scene a fraction of a pixel
/// by rolling a single ULP, which reads as "everything shimmers" and is invisible to a
/// translation probe.
prev_camrot: Quat,
prev2_camrot: Quat,
/// Each bone's BIND position (from the rig's inverse bindposes) — bind data, computed once.
binds: Vec<Vec3>,
/// Frames of history held (`< 2` means Δ² is not defined yet).
depth: u32,
}
@@ -124,6 +155,13 @@ impl Plugin for JitterMeterPlugin {
prev2_anc: Vec::new(),
prev_rid: Vec::new(),
prev2_rid: Vec::new(),
prev_pal: Vec::new(),
prev2_pal: Vec::new(),
prev_scr: Vec::new(),
prev2_scr: Vec::new(),
prev_camrot: Quat::IDENTITY,
prev2_camrot: Quat::IDENTITY,
binds: Vec::new(),
depth: 0,
})
// `Last`, so every writer that can still move the subject this frame — the pose evaluator,
@@ -181,8 +219,28 @@ type SubjectQuery = (
&'static NetEntity,
&'static GlobalTransform,
&'static RigPose,
Option<&'static RigSkin>,
Has<SelfPlayer>,
);
/// **The palette probe** — [`flesh_probes`]' twin on the rows the vertex stage blends. A row is
/// `rig_from_joint × inverse_bindpose`, so it maps BIND space, not bone-local space: probing it at
/// the bone's bind position plus [`flesh_radii`]'s offset is the same question `flesh_probes` asks
/// of `RigPose::model`, put to the numbers that actually reach the GPU. Probing it at the row's own
/// translation column instead would read a rotation as up to the model's full height of lever arm,
/// ~10x what any real vertex sees.
fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
let mut out = Vec::with_capacity(rows.len() * 3);
for (i, m) in rows.iter().enumerate() {
let bind = binds.get(i).copied().unwrap_or(Vec3::ZERO);
let r = radii.get(i).copied().unwrap_or(LEAF_R);
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
out.push(m.transform_point3(bind + axis * r));
}
}
out
}
/// 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(
@@ -191,13 +249,14 @@ fn sample_jitter(
names: Res<NameCache>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
window: Query<&Window>,
subjects: Query<SubjectQuery, Without<SelfPlayer>>,
subjects: Query<SubjectQuery>,
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>,
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
players: Query<&AnimationPlayer>,
// The clock the POSE advances on — `ProbeClock` above is `Time<Real>`, the wall clock, and the
// gap between the two is the whole defect (`benilla_world::frame_pace`). Logging both side by
@@ -214,13 +273,22 @@ fn sample_jitter(
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
// `WOW_JITTER="self"` aims the meter at the AVATAR — the director's own repro (an undressed
// character, watched from a close third person), and the one subject every other spelling of
// this filter excluded. Without it the meter could only ever answer for somebody else's body,
// which is a different entity, a different equipment lane, and a camera that is seated ON it.
let on_self = want == "self";
let Some((entity, guid, net, tf, rig, skin, _)) = subjects
.iter()
.filter(|(_, guid, _, _, _)| {
want.is_empty()
|| names
.peek(guid.0)
.is_some_and(|n| n.to_lowercase().contains(&want))
.filter(|&(_, guid, _, _, _, _, is_self)| {
if on_self {
return is_self;
}
!is_self
&& (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);
@@ -249,6 +317,80 @@ fn sample_jitter(
meter.radii = flesh_radii(&rig.parents, &rig.locals);
}
let flesh = flesh_probes(&rig.model, &meter.radii);
// The rows the vertex stage blends, probed the same way — the body's OWN ground truth. Empty
// when the subject holds no palette slot (bind-pose fallback), which reads as all-zero columns
// rather than as a clean result.
let mut bone_pos: Vec<Vec3> = Vec::new();
// The same rows' ROTATION, as the three transformed unit axes. Bone translations can be
// smooth while the frames they carry are not — a probe offset from the bone rides the
// rotation, and that is what the flesh/vertex actually follows. Differencing a unit axis
// measures the rotational motion at unit radius, directly comparable to the file's.
let mut bone_axes: Vec<[Vec3; 3]> = Vec::new();
let pal: Vec<Vec3> = match skin.and_then(|s| {
Some((
palettes.rig_rows(s.slot, s.bones() as usize)?,
ibps.get(s.ibp())?,
))
}) {
Some((rows, ibp)) => {
if meter.binds.len() != rows.len() {
meter.binds = ibp
.iter()
.take(rows.len())
.map(|m| m.inverse().transform_point3(Vec3::ZERO))
.collect();
}
let probes = palette_probes(&rows, &meter.binds, &meter.radii);
// The bones' OWN rig-relative positions (the row applied to the bind point, no probe
// offset) — the raw lane's series, and the one shape an offline compose of the same
// M2 can be differenced against point-for-point.
bone_pos = rows
.iter()
.zip(&meter.binds)
.map(|(m, &b)| m.transform_point3(b))
.collect();
bone_axes = rows
.iter()
.map(|m| {
[
m.transform_vector3(Vec3::X),
m.transform_vector3(Vec3::Y),
m.transform_vector3(Vec3::Z),
]
})
.collect();
probes
}
None => Vec::new(),
};
// **Project them.** The mirror of `wow_model.wgsl`'s vertex stage, operation for operation:
// camera-relative first (`p_rig + (origin - cam)`, never the absolute world coordinate), then
// the view rotation, then clip, then viewport. What comes out is where the eye finds that bit
// of flesh, in pixels, this frame.
let vp = Vec2::new(
window.physical_width() as f32,
window.physical_height() as f32,
);
let view_rot = Mat3::from_quat(cam_g.rotation()).transpose();
let clip_from_view = projection.get_clip_from_view();
let rig_origin = skin.and_then(|s| palettes.slot_origin(s.slot));
let scr: Vec<Vec2> = match rig_origin {
Some(origin) => {
let shift = origin - cam_pos;
pal.iter()
.map(|&p| {
let clip = clip_from_view * (view_rot * (p + shift)).extend(1.0);
if clip.w.abs() < 1.0e-6 {
return Vec2::ZERO;
}
let ndc = Vec2::new(clip.x, clip.y) / clip.w;
Vec2::new((ndc.x * 0.5 + 0.5) * vp.x, (0.5 - ndc.y * 0.5) * vp.y)
})
.collect()
}
None => Vec::new(),
};
let camrot = cam_g.rotation();
// 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
@@ -271,13 +413,17 @@ fn sample_jitter(
|| meter.prev_flesh.len() != flesh.len()
|| meter.prev_anc.len() != ancs.len()
|| meter.prev_rid.len() != rids.len()
|| meter.prev_pal.len() != pal.len()
|| meter.prev_scr.len() != scr.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 | flesh=<bone> d1 d2 (mm) d1px d2px v(mm/s) | \
anc=<n> d1 d2 (mm) d1px d2px | \
ancstep xyz (mm) | rider=<n> d1 d2 (mm) d1px d2px | window={}x{} subject={name:?} \
ancstep xyz (mm) | rider=<n> d1 d2 (mm) d1px d2px | \
pal=<bone> d1 d2 (mm) d1px d2px nbig | \
SCREEN d1med d2med d2max (px) nbig | camrot d1 d2 (urad) | window={}x{} subject={name:?} \
guid={:#x} display={:?} bones={} scale={scale:.3}",
window.physical_width(),
window.physical_height(),
@@ -326,6 +472,12 @@ fn sample_jitter(
meter.prev2_anc = Vec::new();
meter.prev_rid = rids.iter().map(|&(_, t)| t).collect();
meter.prev2_rid = Vec::new();
meter.prev_pal = pal;
meter.prev2_pal = Vec::new();
meter.prev_scr = scr;
meter.prev2_scr = Vec::new();
meter.prev_camrot = camrot;
meter.prev2_camrot = camrot;
meter.prev2 = Vec::new();
meter.prev_root = root;
meter.prev2_root = Vec3::ZERO;
@@ -343,6 +495,12 @@ fn sample_jitter(
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_pal = std::mem::take(&mut meter.prev_pal);
meter.prev_pal = pal;
meter.prev2_scr = std::mem::take(&mut meter.prev_scr);
meter.prev_scr = scr;
meter.prev2_camrot = meter.prev_camrot;
meter.prev_camrot = camrot;
meter.prev2_root = meter.prev_root;
meter.prev_root = root;
meter.prev2_cam = meter.prev_cam;
@@ -398,6 +556,48 @@ fn sample_jitter(
let fd1med = d1s.get(d1s.len() / 2).copied().unwrap_or(0.0);
let big_px = 0.25 / (scale * px_per_yd).max(1.0e-6);
let fnbig = per_bone.iter().filter(|&&c| c > big_px).count();
// The same pair on the PALETTE probes. No `scale` here, unlike the flesh columns: these rows
// are composed through the root's own frame, so the unit's scale is already in them.
let (mut pd2, mut pworst) = (0.0f32, 0usize);
let mut pal_bone = vec![0.0f32; pal.len() / 3];
for (i, &p) in pal.iter().enumerate() {
let c = (p - 2.0 * meter.prev_pal[i] + meter.prev2_pal[i]).length();
let b = &mut pal_bone[i / 3];
*b = b.max(c);
if c > pd2 {
pd2 = c;
pworst = i;
}
}
let pd1 = pal
.get(pworst)
.map_or(0.0, |&p| (p - meter.prev_pal[pworst]).length());
let pnbig = pal_bone
.iter()
.filter(|&&c| c > 0.25 / px_per_yd.max(1.0e-6))
.count();
// **In pixels, at the glass.** Per bone, the max over its three probes; then the MEDIAN bone
// (the "whole model" number the report actually asks about), the worst bone, and the count
// past a quarter pixel.
let mut scr_bone = vec![(0.0f32, 0.0f32); scr.len() / 3];
for (i, &q) in scr.iter().enumerate() {
let c = (q - 2.0 * meter.prev_scr[i] + meter.prev2_scr[i]).length();
let d = (q - meter.prev_scr[i]).length();
let b = &mut scr_bone[i / 3];
*b = (b.0.max(d), b.1.max(c));
}
let mut s1: Vec<f32> = scr_bone.iter().map(|b| b.0).collect();
let mut s2: Vec<f32> = scr_bone.iter().map(|b| b.1).collect();
s1.sort_by(f32::total_cmp);
s2.sort_by(f32::total_cmp);
let sd1med = s1.get(s1.len() / 2).copied().unwrap_or(0.0);
let sd2med = s2.get(s2.len() / 2).copied().unwrap_or(0.0);
let sd2max = s2.last().copied().unwrap_or(0.0);
let snbig = s2.iter().filter(|&&c| c > 0.25).count();
// Camera ROTATION, in microradians: Δ and Δ² of the angle between consecutive orientations.
let ang = |a: Quat, b: Quat| a.angle_between(b) * 1.0e6;
let crd1 = ang(camrot, meter.prev_camrot);
let crd2 = (ang(camrot, meter.prev_camrot) - ang(meter.prev_camrot, meter.prev2_camrot)).abs();
// The flesh probe's **velocity**, mm/s — `Δ/dt`, not `Δ`. This is the judder discriminator,
// and it is the one number the series cannot reconstruct from the others: if the pose is
// smooth in time but sampled at uneven `dt`, then `Δ ≈ v·dt` is ragged exactly as `dt` is
@@ -473,12 +673,70 @@ fn sample_jitter(
// 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;
// **`WOW_JITTER_RAW=<bone>` — one bone's series, unaggregated.** Every other column here is a
// median or a worst-of over ~350 probe points, and both of those hide the shape of a series:
// a median cannot show a staircase (the *set* stays busy while each member holds), and a
// worst-of is not one series at all — it re-picks its subject every frame, so differencing it
// is meaningless. Twice now a reading taken off those columns pointed at the wrong mechanism.
// This prints the chosen bone's palette-probe position (rig-relative yards, full f32) and its
// projected screen position (pixels), so the series can be differenced honestly downstream.
// **The two ABSOLUTE numbers the vertex stage still sums** (`wow_model.wgsl`:
// `p_cam = frame_from_local·v + (frame_origin - view.world_position)`). Everything else on the
// path is rig-sized and exact; these two are world coordinates, so at Elwynn's ~9.5 k yards
// ONE of their axes has an ULP of 2^-10 yd = 0.9766 mm while the other two sit at ~7.6e-6.
// That asymmetry is the whole fingerprint: the staircase is one-dimensional, so it is
// invisible looking ALONG the big axis and full-amplitude looking across it.
if let Some(o) = rig_origin {
println!(
"ORIGIN\t{now:.6}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}",
o.x, o.y, o.z, cam_pos.x, cam_pos.y, cam_pos.z
);
}
if raw_bone() == Some(usize::MAX) {
// `WOW_JITTER_RAW=all` — the whole skeleton, one line per bone per frame. The point is
// localisation: differenced against an offline compose of the same M2 it says WHICH bones
// carry motion the file does not, and a subtree answer names the pass that added it.
for (b, p) in bone_pos.iter().enumerate() {
let a = bone_axes.get(b).copied().unwrap_or_default();
println!(
"RAWALL\t{now:.6}\t{b}\t{:.9}\t{:.9}\t{:.9}\t{:.6}\t\
{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}",
p.x,
p.y,
p.z,
anim.first().map_or(-1.0, |a| a.1),
a[0].x,
a[0].y,
a[0].z,
a[1].x,
a[1].y,
a[1].z,
a[2].x,
a[2].y,
a[2].z
);
}
} else if let Some(b) = raw_bone() {
if let (Some(p), Some(q)) = (bone_pos.get(b), scr.get(3 * b)) {
println!(
"RAW\t{now:.6}\t{b}\t{:.9}\t{:.9}\t{:.9}\t{:.6}\t{:.6}\t{:.6}",
p.x,
p.y,
p.z,
q.x,
q.y,
anim.first().map_or(-1.0, |a| a.1)
);
}
}
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{:.1}\t{:.4}\t{:.4}\t{}\t{:.3}\t{}\t{:.4}\t{:.3}\t\
{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t\
{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}",
{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t\
{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{}\t\
{:.4}\t{:.4}\t{:.4}\t{}\t{:.2}\t{:.2}",
dt * 1000.0,
cam_d1 * 1000.0,
cam_d2 * 1000.0,
@@ -515,10 +773,25 @@ fn sample_jitter(
rd2 * 1000.0,
rd1 * px_per_yd,
rd2 * px_per_yd,
pworst / 3,
mm(pd1),
mm(pd2),
pd1 * px_per_yd,
pd2 * px_per_yd,
pnbig,
sd1med,
sd2med,
sd2max,
snbig,
crd1,
crd2,
);
meter.prev2 = std::mem::replace(&mut meter.prev, bones);
meter.prev2_flesh = std::mem::replace(&mut meter.prev_flesh, flesh);
meter.prev2_pal = std::mem::replace(&mut meter.prev_pal, pal);
meter.prev2_scr = std::mem::replace(&mut meter.prev_scr, scr);
meter.prev2_camrot = std::mem::replace(&mut meter.prev_camrot, camrot);
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());
@@ -526,3 +799,17 @@ fn sample_jitter(
meter.prev2_cam = std::mem::replace(&mut meter.prev_cam, cam_pos);
meter.prev_anim = anim;
}
/// `WOW_JITTER_RAW`'s bone — read once. Absent (or unparseable) leaves the raw lane silent, which
/// is the default: it is a debugging firehose, one line per frame on top of the `JIT` line.
fn raw_bone() -> Option<usize> {
static B: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
*B.get_or_init(|| {
std::env::var("WOW_JITTER_RAW")
.ok()
.and_then(|v| match v.trim() {
"all" => Some(usize::MAX),
n => n.parse().ok(),
})
})
}
@@ -130,6 +130,21 @@ pub(super) fn apply_body_twist(
locals: Query<&Transform>,
stores: Query<&crate::net::ObjectStore>,
) {
// `WOW_NO_TWIST=1` — stand the whole counter-twist down, and say what the gap WAS. The pass
// is inert at `yaw_gap == 0` (both shares are zero and every write is a no-op), so a bisect
// that only removes it cannot tell "the twist was innocent" from "the twist never ran"; the
// logged gap separates them.
if twist_off() {
for (_, t) in &units {
if t.yaw_gap != 0.0 {
info!(
"twist: STOOD DOWN (WOW_NO_TWIST) — yaw_gap was {:.5} rad",
t.yaw_gap
);
}
}
return;
}
for (unit, mut twist) in &mut units {
let mounted = stores
.get(unit)
@@ -218,6 +233,12 @@ pub(super) fn plugin(app: &mut App) {
);
}
/// `WOW_NO_TWIST`'s switch — read once. A bisect lever, not a shipping mode.
fn twist_off() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| std::env::var_os("WOW_NO_TWIST").is_some())
}
#[cfg(test)]
mod tests {
use super::{armed_shares, twist_shares};
+19 -1
View File
@@ -1812,7 +1812,14 @@ fn control(
// The counter-twist gap: how far the aim sits from the rendered body — the strafe
// offset while it lasts, unwinding to zero as `model_yaw` closes on `face_yaw`.
if let Some(mut twist) = twist {
twist.yaw_gap = wrap_pi(player.face_yaw - player.model_yaw);
// `WOW_TWIST_GAP=<radians>` forces the gap — the counter-twist's A/B lever. The
// pass is inert at `yaw_gap == 0` and a scripted probe cannot open a real gap
// (`WOW_PROBE_CAM` turns the model with the camera, so the measured gap is float
// noise, ~1e-6 rad), which means "removing the twist changed nothing" has never
// yet been a measurement of the twist — only of a pass that never ran. This is
// what lets it actually be exercised.
twist.yaw_gap = twist_gap_override()
.unwrap_or_else(|| wrap_pi(player.face_yaw - player.model_yaw));
}
}
@@ -1984,3 +1991,14 @@ fn control(
camera::fly_free(dt, &keys, typing, &mut rig, &mut cam, &mut cam_t);
}
}
/// `WOW_TWIST_GAP=<radians>`: pin the body counter-twist's yaw gap instead of deriving it from
/// aim-minus-model. Zero-cost when unset: one env read, once.
fn twist_gap_override() -> Option<f32> {
static G: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
*G.get_or_init(|| {
std::env::var("WOW_TWIST_GAP")
.ok()
.and_then(|v| v.trim().parse::<f32>().ok())
})
}
@@ -603,6 +603,60 @@ fn action_hover_delegates_by_kind() {
assert!(s.take_errors().is_empty());
}
/// A MACRO slot's hover is the reference's `0x52b040`: ONE white line, the macro's name (the
/// "normal" colour `0xc0cf60` = 0xffffffff, wow-re `tooltip-content-law.md`), and a slot whose
/// macro no longer exists shows no plate at all. The director's report after 1636 landed: a macro
/// on the bar had no tooltip — the arm was a pre-0983 `_ => Ok(())`.
#[test]
fn action_hover_on_a_macro_slot_shows_its_name_in_white() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_macros(MacroState {
account: vec![MacroView {
name: "spawn".into(),
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
body: ".spawn 16032".into(),
local_only: false,
}],
character: Vec::new(),
});
s.set_action(
24,
Some(ActionSlot {
kind: 0x40, // MACRO
action: 1, // macro index 1
..Default::default()
}),
);
s.set_action(
25,
Some(ActionSlot {
kind: 0x40,
action: 7, // no such macro
..Default::default()
}),
);
s.run(
r#"
local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetAction(24)
assert(tt:IsShown(), "a macro slot shows a plate")
assert(tt:NumLines() == 1, "exactly one line, got " .. tt:NumLines())
assert(TTTextLeft1:GetText() == "spawn", "the macro's name")
local r, g, b = TTTextLeft1:GetTextColor()
assert(r == 1 and g == 1 and b == 1, "white — the normal line colour, not the spell title gold")
assert(not TTTextRight1:IsShown(), "no right column")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetAction(25)
assert(tt:NumLines() == 0 and not tt:IsShown(), "a missing macro shows no plate")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
}
/// The tooltip's left-column texts in order.
fn left_lines(s: &mut UiScript) -> Vec<String> {
let n: i64 = s.eval("return TT:NumLines()").unwrap();
+26 -2
View File
@@ -604,8 +604,16 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GameTooltip:SetAction(slot) — the action-bar hover: pure delegation by payload kind
// (byte-verified: SPELL 0x00 → the spell builder, ITEM 0x80 → the item builder, MACRO 0x40 —
// no macro system yet, no tooltip).
// (`0x5322a0`, byte-verified: SPELL 0x00 → the spell builder `0x52e610`, ITEM 0x80 → the item
// builder `0x52b650`, MACRO 0x40 → `0x52b040`; wow-re `ui.md`'s SetAction dispatch).
//
// The MACRO arm is the reference's whole `0x52b040`: fetch the record (`0x4f0f40`), and either
// hide (no record) or render ONE line — the macro's NAME (`rec+0x24`) — through the
// single-coloured-line wrapper `0x5303b0` in the "normal" colour `0xc0cf60`, which is WHITE
// (`0xffffffff`, VERIFIED never rewritten — wow-re `tooltip-content-law.md` §1's colour
// table). Not the spell title's gold, and nothing about the bound spell: 1.12 has no
// `#showtooltip`. This arm was a `_ => Ok(())` left from before 0983 shipped macros, which is
// why a macro on the bar hovered to nothing (the director, 2026-08-27, after 1636).
m.set(
"SetAction",
lua.create_function(|lua, (this, slot): (Table, u32)| {
@@ -631,6 +639,22 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
let f: mlua::Function = this.get("SetItemById")?;
f.call::<()>((this.clone(), a.action))
}
0x40 => {
let h = frame_handle_of(lua, &this)?;
let name = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
// The same 1..36 index lookup `GetActionText` and the bar's icon arm
// make, so the three can never disagree about which macro a slot holds.
model.macros.get(a.action as usize).map(|m| m.name.clone())
};
fire_cleared(lua, h);
if let Some(name) = name {
append_line(lua, &this, (name, WHITE), None, false)?;
}
super::tooltip::show_or_hide_empty(lua, h);
Ok(())
}
_ => Ok(()),
}
})?,
+312
View File
@@ -199,6 +199,18 @@ fn main() -> Result<()> {
.context("series needs --at \"<x>,<y>[;<x>,<y>…]\"")?;
series(Path::new(dir), at)?;
}
"flow" => {
let [dir] = one(rest, "flow")?;
flow(Path::new(dir), opts.rect.as_deref())?;
}
"edge" => {
let [dir] = one(rest, "edge")?;
let at = opts
.at
.as_deref()
.context("edge needs --at \"<x0>,<y>,<x1>[;…]\"")?;
edge(Path::new(dir), at)?;
}
"compose-dir" => {
let [da, db] = two(rest, "compose-dir")?;
let out = opts
@@ -444,6 +456,174 @@ fn parse_at(spec: &str) -> Result<Vec<(u32, u32)>> {
}
/// Stitch every `*.png` present in both dirs into `left | right` side-by-side images under `out`.
/// **The sub-pixel motion ruler** (`flow <burst_dir> [--rect "<x>,<y>,<w>,<h>"]`).
///
/// `flicker` and `series` answer *how much* a pixel changed. Neither can answer the question a
/// "the animation ticks" report actually asks — is the thing in front of the pixel advancing
/// **evenly**? — and the obvious way to ask it is a trap: recover one silhouette edge's sub-pixel
/// position frame by frame, and the ruler is built out of 8-bit pixels, so it carries its own
/// 1/255 quantisation and a 0.2 px step cannot be separated from the tool's noise floor. That
/// ambiguity is exactly what left the first staircase reading unattributed.
///
/// So estimate the whole window's displacement at once, by least squares over every pixel in it
/// (Lucas–Kanade, the standard first-order flow solve): thousands of gradients vote on one
/// `(dx, dy)`, the 8-bit floor averages down by `√N`, and the estimate lands two orders of
/// magnitude under a pixel. A breathing body is not a rigid translation and does not need to be —
/// what is read off the series is whether the aggregate advances smoothly, and a staircase in the
/// RENDER shows up as a staircase here whatever the body underneath is doing.
///
/// Columns are the per-frame displacement, its magnitude, and the **second difference** — the
/// curvature-per-frame discriminator the world-space jitter meter uses, on this side of the glass.
/// The summary counts **stalled** frames (under a fifth of the median step): a smooth pan has
/// none, a staircase is mostly them.
fn flow(dir: &Path, rect: Option<&str>) -> Result<()> {
let names = pngs(dir)?;
if names.len() < 3 {
bail!(
"flow wants at least 3 frames, {} has {}",
dir.display(),
names.len()
);
}
let first = load(&dir.join(names.iter().next().expect("non-empty")))?;
let (w, h) = first.dimensions();
// The default window is the whole frame minus the one-pixel border the central differences
// need. A named rect is clamped out loud, never silently (the `crop` honesty rule).
let win = match rect {
Some(spec) => {
let want = parse_rect(spec)?;
let got = Rect {
x0: want.x0.min(w.saturating_sub(1)),
y0: want.y0.min(h.saturating_sub(1)),
x1: want.x1.min(w),
y1: want.y1.min(h),
};
if got.x0 >= got.x1 || got.y0 >= got.y1 {
bail!("--rect {spec:?} lies outside the {w}x{h} frame");
}
if got != want {
println!(
"flow: --rect clamped to [{}..{}, {}..{}]",
got.x0, got.x1, got.y0, got.y1
);
}
got
}
None => Rect {
x0: 0,
y0: 0,
x1: w,
y1: h,
},
};
let (ww, wh) = ((win.x1 - win.x0) as usize, (win.y1 - win.y0) as usize);
if ww < 3 || wh < 3 {
bail!("flow wants a window at least 3x3, got {ww}x{wh}");
}
println!(
"flow: {} frames, {w}x{h}, window [{}..{}, {}..{}] ({ww}x{wh} = {} px)",
names.len(),
win.x0,
win.x1,
win.y0,
win.y1,
ww * wh
);
println!(" frame dx dy |d| |d2|");
let mut prev = luma_window(&first, &win);
let mut steps: Vec<(f64, f64)> = Vec::new();
for (i, name) in names.iter().enumerate().skip(1) {
let cur = luma_window(&load(&dir.join(name))?, &win);
let Some((dx, dy)) = lucas_kanade(&prev, &cur, ww, wh) else {
println!(" {i:5} (no gradient in the window — flow is undefined here)");
prev = cur;
continue;
};
let d2 = steps
.last()
.map(|&(px, py)| (dx - px).hypot(dy - py))
.unwrap_or(f64::NAN);
println!(
" {i:5} {dx:+8.4} {dy:+8.4} {:7.4} {:8.4}",
dx.hypot(dy),
d2
);
steps.push((dx, dy));
prev = cur;
}
let mags: Vec<f64> = steps.iter().map(|&(x, y)| x.hypot(y)).collect();
let curv: Vec<f64> = steps
.windows(2)
.map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1))
.collect();
if mags.is_empty() {
return Ok(());
}
let med = |v: &mut Vec<f64>| -> f64 {
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
let med_mag = med(&mut mags.clone());
let stalled = mags.iter().filter(|m| **m < 0.2 * med_mag).count();
println!(
" --- steps: median |d| {med_mag:.4} px max |d| {:.4} stalled (<20% of median) {stalled}/{}",
mags.iter().copied().fold(0.0, f64::max),
mags.len()
);
if !curv.is_empty() {
let med_c = med(&mut curv.clone());
println!(
" --- curvature: median |d2| {med_c:.4} px max |d2| {:.4} ratio |d2|/|d| {:.3}",
curv.iter().copied().fold(0.0, f64::max),
med_c / med_mag.max(f64::MIN_POSITIVE)
);
}
Ok(())
}
/// One frame's luma over `win`, row-major — the flow solve's input (f32 is exact for 8-bit sums
/// and halves the working set against a 3200x1800 f64 buffer).
fn luma_window(img: &image::RgbImage, win: &Rect) -> Vec<f32> {
let mut out = Vec::with_capacity(((win.x1 - win.x0) * (win.y1 - win.y0)) as usize);
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let p = img.get_pixel(x, y).0;
out.push(
0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2]),
);
}
}
out
}
/// Least-squares first-order optical flow between two luma windows: the `(dx, dy)` that best
/// explains `b` as `a` shifted, solved once over every interior pixel. `None` when the window
/// carries no usable gradient structure (a flat sky — the normal matrix is singular and any
/// answer would be invented).
fn lucas_kanade(a: &[f32], b: &[f32], w: usize, h: usize) -> Option<(f64, f64)> {
let (mut sxx, mut sxy, mut syy, mut sxt, mut syt) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
for y in 1..h - 1 {
for x in 1..w - 1 {
let i = y * w + x;
let ix = 0.5 * f64::from(a[i + 1] - a[i - 1]);
let iy = 0.5 * f64::from(a[i + w] - a[i - w]);
let it = f64::from(b[i] - a[i]);
sxx += ix * ix;
sxy += ix * iy;
syy += iy * iy;
sxt += ix * it;
syt += iy * it;
}
}
let det = sxx * syy - sxy * sxy;
// Scale-relative: the normal matrix grows with the window, so an absolute epsilon would call
// a big flat window solvable and a small textured one singular.
if det <= 1e-12 * (sxx * syy).max(f64::MIN_POSITIVE) {
return None;
}
Some(((sxy * syt - syy * sxt) / det, (sxy * sxt - sxx * syt) / det))
}
fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> {
let names: BTreeSet<String> = pngs(da)?.intersection(&pngs(db)?).cloned().collect();
if names.is_empty() {
@@ -736,6 +916,121 @@ fn over_fail(m: &Metrics, fail: Option<f64>) -> bool {
}
/// Load an image as RGB (dropping any alpha — capture windows are opaque).
/// One silhouette's sub-pixel position along a scanline, per frame — the shading-independent
/// answer to "did the rendered geometry MOVE smoothly?".
///
/// Per frame and per scanline: take the row's own darkest and brightest samples, put the
/// threshold halfway between them, and linearly interpolate the crossing. Renormalising to the
/// row's own ends every frame is the whole point — a uniform brightness change (the body turning
/// into the light) moves `lo` and `hi` together and leaves the crossing where it was, while a
/// geometric shift moves it. That is the property [`flow`] lacks.
fn edge(dir: &Path, at: &str) -> Result<()> {
let names = pngs(dir)?;
if names.len() < 3 {
bail!(
"edge wants at least 3 frames, {} has {}",
dir.display(),
names.len()
);
}
let mut lines: Vec<(u32, u32, u32)> = Vec::new();
for spec in at.split(';').filter(|s| !s.trim().is_empty()) {
let p: Vec<u32> = spec
.trim()
.split(',')
.map(|v| {
v.trim()
.parse::<u32>()
.context("edge --at wants <x0>,<y>,<x1>")
})
.collect::<Result<_>>()?;
let [x0, y, x1] = p[..] else {
bail!("edge --at wants <x0>,<y>,<x1>, got {spec:?}");
};
if x1 <= x0 + 1 {
bail!("edge scanline {spec:?} needs x1 > x0+1");
}
lines.push((x0, y, x1));
}
for &(x0, y, x1) in &lines {
println!("scanline y={y} x {x0}..{x1}");
let mut pos: Vec<f64> = Vec::new();
for name in &names {
let img = load(&dir.join(name))?;
if y >= img.height() || x1 >= img.width() {
bail!(
"scanline ({x0},{y},{x1}) outside {}x{}",
img.width(),
img.height()
);
}
let row: Vec<f64> = (x0..=x1)
.map(|x| {
let p = img.get_pixel(x, y).0;
0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2])
})
.collect();
let lo = row.iter().copied().fold(f64::INFINITY, f64::min);
let hi = row.iter().copied().fold(f64::NEG_INFINITY, f64::max);
// Too little contrast to call an edge — report it rather than invent a position.
if hi - lo < 12.0 {
pos.push(f64::NAN);
continue;
}
let mid = 0.5 * (lo + hi);
let mut found = f64::NAN;
for i in 0..row.len() - 1 {
let (a, b) = (row[i] - mid, row[i + 1] - mid);
if (a <= 0.0 && b > 0.0) || (a >= 0.0 && b < 0.0) {
found = f64::from(x0) + i as f64 + a / (a - b);
break;
}
}
pos.push(found);
}
let good = pos.iter().filter(|p| p.is_finite()).count();
for (i, p) in pos.iter().enumerate().take(28) {
let d = if i == 0 { f64::NAN } else { p - pos[i - 1] };
println!(" {i:4} x={p:10.4} d={d:+8.4}");
}
let d1: Vec<f64> = pos
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.filter(|v| v.is_finite())
.collect();
let d2: Vec<f64> = pos
.windows(3)
.map(|w| (w[2] - 2.0 * w[1] + w[0]).abs())
.filter(|v| v.is_finite())
.collect();
if d1.is_empty() || d2.is_empty() {
println!(
" no usable edge on {}/{} frames",
names.len() - good,
names.len()
);
continue;
}
let med = |mut v: Vec<f64>| {
v.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
v[v.len() / 2]
};
let (m1, m2) = (med(d1.clone()), med(d2.clone()));
let mx = d1.iter().copied().fold(0.0, f64::max);
println!(
" frames with an edge {good}/{} median |d| {m1:.4} px max |d| {mx:.4} px",
names.len()
);
println!(
" median |d2| {m2:.4} px roughness |d2|/|d| {:.3} (a smooth curve at 60 Hz is <<1)",
m2 / m1.max(1e-9)
);
let stalled = d1.iter().filter(|&&v| v < 0.2 * m1).count();
println!(" stalled steps (<20% of median): {stalled}/{}", d1.len());
}
Ok(())
}
fn load(path: &Path) -> Result<image::RgbImage> {
Ok(image::open(path)
.with_context(|| format!("opening {}", path.display()))?
@@ -852,6 +1147,8 @@ fn print_usage() {
benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>] [--toggle-delta <n>]\n \
benilla-visual hotspot <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
benilla-visual series <burst_dir> --at \"<x>,<y>[;<x>,<y>…]\"\n \
benilla-visual flow <burst_dir> [--rect \"<x>,<y>,<w>,<h>\"]\n \
benilla-visual edge <burst_dir> --at \"<x0>,<y>,<x1>[;…]\"\n \
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n \
benilla-visual stat <img.png> [--rect \"<x>,<y>,<w>,<h>\"]\n\
benilla-visual crop <img.png> --rect \"<x>,<y>,<w>,<h>\" --out <crop.png> [--scale <n>] [--at \"<x>,<y>;…\"]\n\
@@ -862,6 +1159,21 @@ fn print_usage() {
surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\
series drops the averaging: one named pixel's rgb/luma per frame, to line up against the\n\
client's WOW_DEPTH log at the same coordinates.\n\
edge NOISE FLOOR: about +/-0.5 px per frame on real content. VALIDATED against a provably
smooth 0.5 deg/s camera pan over static geometry (true motion 0.28 px/frame, monotone): edge
reported steps of -0.241 +0.212 +0.998 +0.732 -0.749 — NEGATIVE steps on a monotone pan. Its
MEAN is right, its per-frame value is not. So it can only resolve discrete events well above
~1 px (the attachment staircase's 0.83-1.1 px hop clears it; a 0.1-0.2 px breathing motion does
not, and a roughness statistic computed at that scale is measuring this floor, not the render).
edge tracks a SILHOUETTE's sub-pixel position along one scanline, per frame. Unlike flow it
does not assume brightness constancy — it renormalises to the scanline's own light/dark ends
every frame, so a shaded, deforming surface (skin, cloth) whose lighting changes as it moves
still reads its GEOMETRY. flow is invalid on exactly that content: on a run where the palette
was provably 33x rougher it reported *smoother*, because Lucas-Kanade read the shading change
as motion. Use edge for a body, flow only for rigid, uniformly-lit subjects.
flow measures MOTION rather than change: one least-squares sub-pixel displacement per frame\n\
pair over the whole window, so a render that advances in steps reads as steps and one that\n\
advances smoothly reads smooth — the question a per-pixel delta cannot answer.\n\
crop cuts a window for close reading (nearest-neighbour zoom only, samples printed in\n\
SOURCE coordinates) — use it instead of sips/ffmpeg pipelines, which have minted false\n\
findings (sips --cropOffset is (y, x) and silently ignores what it can't parse).\n\
+24 -2
View File
@@ -389,6 +389,28 @@ fn prepare_textures(
}
}
/// `WOW_DITHER=1` — arm the combine's deband dither (the shader's `glow.w`).
///
/// The frame's ONE quantization to 8 bits is the surface's sRGB present-encode of the combine's
/// gamma-space output, so a smooth surface steps in 1/255. Measured on a lit bare arm the shading
/// gradient is ~1.3 levels/px; a breathing idle drifts the body ~0.11 px/frame; so a pixel needs
/// ~7 FRAMES to cross one step and the shading updates at ~8 Hz under a 60 Hz frame rate. Motion
/// large enough to clear a level every frame hides it entirely — the reported
/// small-moves-tick / big-moves-smooth split.
///
/// **Off by default because it is a divergence.** The reference's framebuffer was 8-bit and
/// undithered; this lane is byte-exact against it (0161) and dithering trades that for a smoother
/// gradient. Bevy would normally apply its own in the tonemapping pass, but `Tonemapping::None`
/// makes that node return immediately, so the `DebandDither::Enabled` our camera inherits from
/// `Camera3d` never runs — this is the only place it can live.
fn dither_armed() -> f32 {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
match *ON.get_or_init(|| std::env::var_os("WOW_DITHER").is_some()) {
true => 1.0,
false => 0.0,
}
}
/// The combine's `(x, y, z, w)` uniform for one view — the whole per-view scaling law in one
/// pure function, so it can be tested without a render world.
///
@@ -398,13 +420,13 @@ fn prepare_textures(
/// - **y/z** — the FFXDeath gate and the haze mix, both scaled by [`FfxGlow::state_scale`]: these
/// are keyed on the **viewer's own state**, which the reference paints inside the WorldFrame and
/// every UI frame composites over, so neither may reach a bake (decision 1481).
/// - **w** — reserved.
/// - **w** — the deband-dither arm ([`dither_armed`]), 0 or 1.
fn combine_uniform(zone_gain: f32, death: f32, haze: f32, glow: &FfxGlow) -> [f32; 4] {
[
zone_gain * glow.gain_scale,
death * glow.state_scale,
haze * glow.state_scale,
0.0,
dither_armed(),
]
}
+55
View File
@@ -205,7 +205,62 @@ pub(crate) fn pace_virtual_time(
*generic = virt.as_generic();
}
/// `WOW_FIXED_DT`'s pending arm — see [`plugin`].
#[derive(Resource)]
struct FixedDt {
dt: Duration,
at: std::time::Instant,
armed: bool,
}
/// Install the manual strategy once the wall clock passes the arm time, and say so in the log —
/// a run whose clock silently failed to pin would read exactly like a run that measured something.
fn arm_fixed_dt(mut cfg: ResMut<FixedDt>, mut commands: Commands) {
if cfg.armed || std::time::Instant::now() < cfg.at {
return;
}
cfg.armed = true;
commands.insert_resource(TimeUpdateStrategy::ManualDuration(cfg.dt));
info!(
"frame_pace: WOW_FIXED_DT armed — animation clock pinned to {:.4} ms/frame",
cfg.dt.as_secs_f64() * 1000.0
);
}
pub fn plugin(app: &mut App) {
// **`WOW_FIXED_DT=<ms>` — the deterministic clock**, and it exists for one question that no
// other instrument here can answer: *is the RENDERED motion even?*
//
// A per-frame screenshot burst is the only way to measure what the eye actually receives, and
// saving a 3200x1800 PNG stalls the frame to tens of milliseconds — so the burst perturbs the
// very timing it is trying to measure, and its frames sample the animation on a grid as ragged
// as the capture. Pinning the advance makes the geometry side perfectly even BY CONSTRUCTION,
// so any unevenness left in the captured pixels belongs to the render, not the clock. That is
// the whole discrimination: a body whose bones all move sub-pixel per frame can be flawless in
// world space and still step on screen, and only this pairing separates the two.
//
// It composes with the pacer rather than fighting it: `Time<Real>` drives `Time<Virtual>`, and
// [`pace_virtual_time`] stands down the moment a manual strategy is set.
// Spelled `<ms>[,<arm_after_wall_seconds>]`, default 40 s. **The delay is not a convenience,
// it is required**: a manual strategy overrides `Time<Real>`, which is the clock every probe
// trigger reads, and during load the app is not vsync-limited — it ran ~385 updates/second, so
// a pinned 16.6667 ms clock reached "90 seconds" in 14 s of wall time and the run exited before
// the character had finished streaming in. Arming on a real wall-clock `Instant` keeps the
// pinning for the part of the run that is being measured and leaves login/teleport on real time.
if let Some((ms, after)) = std::env::var("WOW_FIXED_DT").ok().and_then(|v| {
let (ms, after) = match v.split_once(',') {
Some((a, b)) => (a, b.trim().parse::<f64>().ok()?),
None => (v.as_str(), 40.0),
};
Some((ms.trim().parse::<f64>().ok().filter(|m| *m > 0.0)?, after))
}) {
app.insert_resource(FixedDt {
dt: Duration::from_secs_f64(ms / 1000.0),
at: std::time::Instant::now() + Duration::from_secs_f64(after.max(0.0)),
armed: false,
})
.add_systems(First, arm_fixed_dt.before(TimeSystems));
}
app.init_resource::<FramePacer>()
.add_systems(First, pace_virtual_time.after(TimeSystems));
}
+36 -2
View File
@@ -519,13 +519,28 @@ impl RigPalettes {
/// rig-relative (decision 0974), so the slot's origin goes back on here — the picker ray is a
/// world-space ray, and it is not a precision consumer.
pub fn world_palette(&self, slot: u16, bones: usize) -> Option<Vec<Mat4>> {
let o = *self.origins.get(slot as usize)?;
self.rows_at(slot, bones, [o[0], o[1], o[2]])
}
/// The rig's palette rows **as the vertex stage blends them** — rig-relative (decision 0974),
/// the slot origin deliberately NOT added back. [`Self::world_palette`] is the picker's
/// world-space read; this is the PRECISION one, for the same reason [`Self::rider_placement`]
/// returns its pair unsummed: re-adding the ~9 k-yard origin would stamp the very f32 grid
/// the lane exists to avoid onto the measurement, and the instrument would then read its own
/// arithmetic as the defect.
pub fn rig_rows(&self, slot: u16, bones: usize) -> Option<Vec<Mat4>> {
self.rows_at(slot, bones, [0.0; 3])
}
/// Both reads above: the rows of `slot`, with `o` added onto the translation column.
fn rows_at(&self, slot: u16, bones: usize, o: [f32; 3]) -> Option<Vec<Mat4>> {
let s = slot as usize;
let len = (*self.slot_len.get(s)? as usize).min(bones);
if len == 0 {
return None;
}
let base = self.table[s] as usize;
let o = self.origins[s];
let base = *self.table.get(s)? as usize;
Some(
(0..len)
.map(|b| {
@@ -541,6 +556,15 @@ impl RigPalettes {
)
}
/// A slot's rig ORIGIN — the world position its rows are measured from, exactly as the vertex
/// stage reads it out of `rig_origin[slot]`. The instrument pairs it with [`Self::rig_rows`] to
/// reproduce the shader's own `frame_from_local * v + (frame_origin - view.world_position)`
/// without ever forming the absolute world coordinate the lane exists to avoid.
pub fn slot_origin(&self, slot: u16) -> Option<Vec3> {
let o = self.origins.get(slot as usize)?;
Some(Vec3::new(o[0], o[1], o[2]))
}
/// 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
@@ -619,6 +643,16 @@ impl RigSkin {
self.len
}
/// The rig's inverse bindposes — the instrument read. `WOW_JITTER`'s palette term probes the
/// rows at each bone's BIND position, which is the only honest way to ask "how far does a
/// vertex bound to this bone actually move" of the numbers the GPU blends: a row's own
/// translation column is the bind-space ORIGIN's image, a lever up to the model's full height
/// away from the bone, and differencing it reads a rotation as ~10x the displacement any real
/// vertex sees.
pub fn ibp(&self) -> &Handle<SkinnedMeshInverseBindposes> {
&self.ibp
}
/// Allocate a palette rig for the collapsed lane (decision 0724): no joint entities — the
/// world pass hands composed frames to [`RigPalettes::write_rig_worlds`] instead, so the
/// entity-lane change sweep never touches this slot (empty `joints` make its `write_rig` a
+37 -2
View File
@@ -86,6 +86,41 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
// The CPU pack `0x6cb930` builds primary as `(LightParams.glow·255)<<24 | 0x5393A8`: rgb is the
// CONSTANT steel blue-gray 0x53/0x93/0xA8, and primary.w (the blur² weight) is the same zone glow
// the glow pass uses — so glow.x serves both combines and glow.y is a pure 0/1 gate.
// **Deband dither** (`glow.w`, off by default — see `ffx_glow.rs`). The frame's ONE quantization
// to 8 bits happens on `outg` below: it is a gamma-space float, and the surface's sRGB
// present-encode rounds it to a byte. A smooth surface therefore steps in 1/255, and at the
// gradients a lit character actually has (~1.3 levels/px on a bare arm) a body drifting the
// 0.11 px/frame of a breathing idle needs ~7 FRAMES to accumulate one step — so the shading
// updates at ~8 Hz on a 60 Hz display and every iso-luma contour crosses its threshold together.
// Large motions clear a level per frame and hide it completely, which is exactly the
// small-moves-tick / big-moves-smooth split the director reported.
//
// The hash is Bevy's own `screen_space_dither` (its tonemapping pass applies it and we skip that
// pass entirely — `Tonemapping::None` returns before the node does anything, so
// `DebandDither::Enabled` on our camera is dead code). It is a pure function of the PIXEL, not of
// time: a still frame stays bitwise still, while a moving gradient's contour dissolves across
// neighbouring pixels instead of snapping as one line.
//
// **This is a deliberate divergence.** The reference's framebuffer was 8-bit and undithered, so
// byte-exactness and this are mutually exclusive; that is why it is opt-in and why the default
// keeps the byte lane intact.
fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
var dither = vec3<f32>(dot(vec2<f32>(171.0, 231.0), frag_coord)).xxx;
dither = fract(dither.rgb / vec3<f32>(103.0, 71.0, 97.0));
return (dither - vec3<f32>(0.5)) / 255.0;
}
// The combine's one exit: dither (when armed) in GAMMA space — the space the present-encode
// rounds in — then the frame's single decode.
fn combine_out(outg: vec3<f32>, alpha: f32, frag_coord: vec2<f32>) -> vec4<f32> {
let dithered = clamp(
outg + screen_space_dither(frag_coord) * step(0.5, glow.w),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
return vec4<f32>(srgb_to_linear(dithered), alpha);
}
@fragment
fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// GAMMA LANE (0161): the whole frame is already gamma bytes — the blur ran on gamma (like
@@ -108,7 +143,7 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let p = clamp(4.0 * luma * (1.0 - luma), 0.0, 1.0);
let ghost_tint = vec3<f32>(83.0, 147.0, 168.0) / 255.0; // 0x5393A8
let outg = min(vec3<f32>(luma) + ghost_tint * p, vec3<f32>(1.0));
return vec4<f32>(srgb_to_linear(outg), scene.a);
return combine_out(outg, scene.a, in.position.xy);
}
// The z lane — the drunk/underwater haze: cross-fade the screen toward the ¼-res blur
// BEFORE the glow add (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls;
@@ -117,5 +152,5 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// the screen term: it stands for the same byte RT.
let hazed = mix(sg, min(bg, vec3<f32>(1.0)), glow.z);
let outg = min(hazed + glow.x * bg * bg, vec3<f32>(1.0));
return vec4<f32>(srgb_to_linear(outg), scene.a);
return combine_out(outg, scene.a, in.position.xy);
}
+29
View File
@@ -399,6 +399,35 @@ pub struct WorldCamera;
/// relative precision — and our ULP-relative bias ladder ([`crate::sky_order`]) — independent of
/// the near value. The small near is what keeps the whole waterline-crossing band (the corner-min
/// submersion probe, `liquid::detect_submersion`) inches tall instead of a yard.
/// The `$WOW_MSAA` startup knob → a sample count, shared by both world-camera spawners
/// (`benilla_world::worldview` and the app's `player::setup`) so the two can never disagree.
///
/// **8x is refused rather than passed through.** wgpu validates the sample count against the HDR
/// target's format, and the WebGPU spec guarantees only `[1, 4]` for `Rgba16Float` (this Mac's
/// adapter reports `[1, 2, 4]`). `WOW_MSAA=8` therefore panicked the render thread inside
/// `prepare_view_targets` on frame one — an A/B leg that asked for it died with a wgpu validation
/// error instead of returning an answer, which is how it was found. There is no adapter handle at
/// camera-spawn time to query, so the honest move is to fall back to 4x and say so loudly: a
/// usable knob and a legible failure beat a hard crash.
///
/// A STARTUP knob, not a live toggle: switching MSAA at runtime leaves the post-process passes
/// (glow/egui) MSAA-mismatched and freezes the view, so A/B by restarting.
pub fn msaa_from_env() -> bevy::render::view::Msaa {
use bevy::render::view::Msaa;
match std::env::var("WOW_MSAA").ok().as_deref() {
Some("off") | Some("0") | Some("1") => Msaa::Off,
Some("2") => Msaa::Sample2,
Some("8") => {
warn!(
"WOW_MSAA=8: Rgba16Float is only guaranteed [1, 4] samples and this adapter \
reports [1, 2, 4] — falling back to 4x rather than panicking the render thread"
);
Msaa::Sample4
}
_ => Msaa::Sample4,
}
}
pub const CAM_NEAR: f32 = 1.0 / 9.0;
/// The camera's vertical field of view (radians) — one constant shared by the projection
/// (the camera spawn) and every consumer that needs the near rectangle's true shape. 45°, the value the