diff --git a/crates/benilla-app/src/capture/probes/jitter.rs b/crates/benilla-app/src/capture/probes/jitter.rs index 0b829b3a..25e72d07 100644 --- a/crates/benilla-app/src/capture/probes/jitter.rs +++ b/crates/benilla-app/src/capture/probes/jitter.rs @@ -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, prev2_rid: Vec, + /// **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, + prev2_pal: Vec, + /// **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, + prev2_scr: Vec, + /// 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, /// 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, ); +/// **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 { + 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, cam: Query<(&GlobalTransform, &Projection), With>, window: Query<&Window>, - subjects: Query>, + subjects: Query, globals: Query<&GlobalTransform>, kids: Query<&Children>, meshes: Query<(), With>, attach: Query<&crate::entities::BoneAttach>, riders: Query<&benilla_world::rig_rider::RigRider>, palettes: Res, + ibps: Res>, players: Query<&AnimationPlayer>, // The clock the POSE advances on — `ProbeClock` above is `Time`, 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 = 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 = 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 = 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 = 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= dt= dist= pxyd= | camd1 camd2 (mm) | rootd1 rootd2 (mm) \ | bone= d1 d2 (mm) d1px d2px | flesh= d1 d2 (mm) d1px d2px v(mm/s) | \ anc= d1 d2 (mm) d1px d2px | \ - ancstep xyz (mm) | rider= d1 d2 (mm) d1px d2px | window={}x{} subject={name:?} \ + ancstep xyz (mm) | rider= d1 d2 (mm) d1px d2px | \ + pal= 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 = scr_bone.iter().map(|b| b.0).collect(); + let mut s2: Vec = 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=` — 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 { + static B: std::sync::OnceLock> = 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(), + }) + }) +} diff --git a/crates/benilla-app/src/creature_anim/twist.rs b/crates/benilla-app/src/creature_anim/twist.rs index a8bed1ba..b2397622 100644 --- a/crates/benilla-app/src/creature_anim/twist.rs +++ b/crates/benilla-app/src/creature_anim/twist.rs @@ -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 = 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}; diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 8a28d4c8..9d5cd058 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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=` 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=`: 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 { + static G: std::sync::OnceLock> = std::sync::OnceLock::new(); + *G.get_or_init(|| { + std::env::var("WOW_TWIST_GAP") + .ok() + .and_then(|v| v.trim().parse::().ok()) + }) +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 2f26ec3e..41317931 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -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 { let n: i64 = s.eval("return TT:NumLines()").unwrap(); diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 7da65677..be537e6c 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -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::().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(()), } })?, diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index a5a6130d..cdd60d01 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -199,6 +199,18 @@ fn main() -> Result<()> { .context("series needs --at \",[;,…]\"")?; 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 \",,[;…]\"")?; + 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> { } /// Stitch every `*.png` present in both dirs into `left | right` side-by-side images under `out`. +/// **The sub-pixel motion ruler** (`flow [--rect ",,,"]`). +/// +/// `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 = steps.iter().map(|&(x, y)| x.hypot(y)).collect(); + let curv: Vec = 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 { + 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 { + 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 = pngs(da)?.intersection(&pngs(db)?).cloned().collect(); if names.is_empty() { @@ -736,6 +916,121 @@ fn over_fail(m: &Metrics, fail: Option) -> 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 = spec + .trim() + .split(',') + .map(|v| { + v.trim() + .parse::() + .context("edge --at wants ,,") + }) + .collect::>()?; + let [x0, y, x1] = p[..] else { + bail!("edge --at wants ,,, 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 = 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 = (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 = pos + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .filter(|v| v.is_finite()) + .collect(); + let d2: Vec = 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| { + 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 { Ok(image::open(path) .with_context(|| format!("opening {}", path.display()))? @@ -852,6 +1147,8 @@ fn print_usage() { benilla-visual flicker [--out ] [--fail ] [--amplify ] [--toggle-delta ]\n \ benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ benilla-visual series --at \",[;,…]\"\n \ + benilla-visual flow [--rect \",,,\"]\n \ + benilla-visual edge --at \",,[;…]\"\n \ benilla-visual compose-dir --out (side-by-side `a | b` per image)\n \ benilla-visual stat [--rect \",,,\"]\n\ benilla-visual crop --rect \",,,\" --out [--scale ] [--at \",;…\"]\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\ diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 400578df..906dd999 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -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 = 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(), ] } diff --git a/crates/benilla-world/src/frame_pace.rs b/crates/benilla-world/src/frame_pace.rs index e927510f..3c9d2623 100644 --- a/crates/benilla-world/src/frame_pace.rs +++ b/crates/benilla-world/src/frame_pace.rs @@ -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, 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=` — 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` drives `Time`, and + // [`pace_virtual_time`] stands down the moment a manual strategy is set. + // Spelled `[,]`, default 40 s. **The delay is not a convenience, + // it is required**: a manual strategy overrides `Time`, 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::().ok()?), + None => (v.as_str(), 40.0), + }; + Some((ms.trim().parse::().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::() .add_systems(First, pace_virtual_time.after(TimeSystems)); } diff --git a/crates/benilla-world/src/rig_palette.rs b/crates/benilla-world/src/rig_palette.rs index 984c2c0d..df93338b 100644 --- a/crates/benilla-world/src/rig_palette.rs +++ b/crates/benilla-world/src/rig_palette.rs @@ -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> { + 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> { + 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> { 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 { + 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 { + &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 diff --git a/crates/benilla-world/src/shaders/ffx_glow.wgsl b/crates/benilla-world/src/shaders/ffx_glow.wgsl index 96b25cf2..a5770312 100644 --- a/crates/benilla-world/src/shaders/ffx_glow.wgsl +++ b/crates/benilla-world/src/shaders/ffx_glow.wgsl @@ -86,6 +86,41 @@ fn srgb_to_linear(c: vec3) -> vec3 { // 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) -> vec3 { + var dither = vec3(dot(vec2(171.0, 231.0), frag_coord)).xxx; + dither = fract(dither.rgb / vec3(103.0, 71.0, 97.0)); + return (dither - vec3(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, alpha: f32, frag_coord: vec2) -> vec4 { + let dithered = clamp( + outg + screen_space_dither(frag_coord) * step(0.5, glow.w), + vec3(0.0), + vec3(1.0), + ); + return vec4(srgb_to_linear(dithered), alpha); +} + @fragment fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4 { // 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 { let p = clamp(4.0 * luma * (1.0 - luma), 0.0, 1.0); let ghost_tint = vec3(83.0, 147.0, 168.0) / 255.0; // 0x5393A8 let outg = min(vec3(luma) + ghost_tint * p, vec3(1.0)); - return vec4(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 { // the screen term: it stands for the same byte RT. let hazed = mix(sg, min(bg, vec3(1.0)), glow.z); let outg = min(hazed + glow.x * bg * bg, vec3(1.0)); - return vec4(srgb_to_linear(outg), scene.a); + return combine_out(outg, scene.a, in.position.xy); } diff --git a/crates/benilla-world/src/view.rs b/crates/benilla-world/src/view.rs index 2175f65d..23217307 100644 --- a/crates/benilla-world/src/view.rs +++ b/crates/benilla-world/src/view.rs @@ -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