diff --git a/crates/benilla-app/src/capture/probes/jitter.rs b/crates/benilla-app/src/capture/probes/jitter.rs index efb2532d..0b829b3a 100644 --- a/crates/benilla-app/src/capture/probes/jitter.rs +++ b/crates/benilla-app/src/capture/probes/jitter.rs @@ -16,6 +16,12 @@ //! whole difference between "the numbers are wrong" and "the numbers are right and arriving at //! uneven times", and it is invisible to any instrument that does not log `dt` beside the pose. //! +//! **The `mm` columns are milli-YARDS** — yards × 1000, the map unit's own thousandth. On a true +//! 0.9144 m yard the f32 ULP at Elwynn is 0.893 mm, not the 0.9766 these columns print (decision +//! 1618). The pixel columns are unaffected: they are computed from yards throughout, so the unit +//! cancels. Kept as-is rather than renamed because these columns are the comparison surface for two +//! landed records (1609, 1617) and a rescale would silently break every figure in them. +//! //! Everything is reported in **millimetres and in pixels at the subject's own distance**, because //! a world-space wobble is only a defect in proportion to how many pixels it covers: the same //! 1 mm is 0.03 px across a courtyard and 0.6 px pressed against the camera, which is exactly why @@ -52,6 +58,22 @@ struct JitterMeter { /// Per-bone model-space translations, last frame and the one before. prev: Vec, prev2: Vec, + /// **What a vertex does** — each bone's row applied to a probe point [`FLESH_R`] out along + /// each local axis, same two-frame window. A bone origin can be bitwise still while the bone + /// *spins*, and a standing idle is very nearly a pure rotation animation: differencing origins + /// measures the one channel such a clip does not use. This is the series the eye reads. + prev_flesh: Vec, + prev2_flesh: Vec, + /// This subject's per-bone flesh radii ([`flesh_radii`]) — bind data, so computed once. + radii: Vec, + /// Last frame's **animation** clock: `(node, seek_time, weight)` per playing animation. + /// + /// The blind spot this closes. [`ProbeClock`] is `Time` — the WALL clock — so every `dt` + /// this meter has ever printed, and the whole `Δ/dt` judder discriminator built on it, divides + /// by a clock the pose does not advance on. A pose stepping unevenly against a perfectly clean + /// wall delta is invisible to every other column here, and that is exactly the shape a + /// whole-skeleton twitch takes. `seek_time` is the clock the pose is actually sampled at. + prev_anim: Vec<(usize, f32, f32)>, /// The same two-frame window on the unit's root and on the camera, world space. prev_root: Vec3, prev2_root: Vec3, @@ -90,6 +112,10 @@ impl Plugin for JitterMeterPlugin { subject: None, prev: Vec::new(), prev2: Vec::new(), + prev_flesh: Vec::new(), + prev2_flesh: Vec::new(), + radii: Vec::new(), + prev_anim: Vec::new(), prev_root: Vec3::ZERO, prev2_root: Vec3::ZERO, prev_cam: Vec3::ZERO, @@ -107,6 +133,47 @@ impl Plugin for JitterMeterPlugin { } } +/// A leaf bone's assumed flesh radius, model-space yards — a fingertip or an eyelid, not a limb. +const LEAF_R: f32 = 0.05; +/// The cap on a derived radius: no bone drives geometry further out than this on a humanoid. +const MAX_R: f32 = 0.45; + +/// **Per-bone flesh radius** — how far off its own axis this bone's geometry actually sits, taken +/// as the distance to its farthest child (a bone's length is the best proxy we have here for the +/// flesh it carries), leaves falling back to [`LEAF_R`]. The child's own local translation IS that +/// distance; a standing clip animates rotation, so this is the bind offset in all but name. +/// +/// This is load-bearing, not a detail. A single radius for all 117 bones reads a 25 mrad twitch of +/// an *eyelid* bone as ~5 mm of motion, when the eyelid's own vertices sit ~0.02 yd out and move a +/// tenth of that. Sized this way, the probe answers "how far did the geometry this bone drives +/// move", which is the question, instead of "how far did a point 0.2 yd off it move", which +/// flatters every small bone in the skeleton into looking like the worst thing on the model. +fn flesh_radii(parents: &[i16], locals: &[Transform]) -> Vec { + let mut r = vec![0.0f32; parents.len()]; + for (child, &p) in parents.iter().enumerate() { + let Ok(pi) = usize::try_from(p) else { continue }; + if let Some(b) = locals.get(child) { + if let Some(slot) = r.get_mut(pi) { + *slot = slot.max(b.translation.length()); + } + } + } + r.iter().map(|&v| v.clamp(LEAF_R, MAX_R)).collect() +} + +/// The probe points for one composed rig: three per bone, one along each local axis, each at that +/// bone's own [`flesh_radii`] distance. +fn flesh_probes(model: &[bevy::math::Affine3A], radii: &[f32]) -> Vec { + let mut out = Vec::with_capacity(model.len() * 3); + for (i, m) in model.iter().enumerate() { + let r = radii.get(i).copied().unwrap_or(LEAF_R); + for axis in [Vec3::X, Vec3::Y, Vec3::Z] { + out.push(m.transform_point3(axis * r)); + } + } + out +} + /// What the meter reads per candidate subject: identity, name key, world pose, composed rig. type SubjectQuery = ( Entity, @@ -131,6 +198,11 @@ fn sample_jitter( attach: Query<&crate::entities::BoneAttach>, riders: Query<&benilla_world::rig_rider::RigRider>, palettes: 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 + // side is the A/B: paced, `vdt` holds the cadence while `dt` still reports what really happened. + vtime: Res>, ) { let now = time.elapsed_secs(); if now < meter.at { @@ -173,6 +245,10 @@ fn sample_jitter( .iter() .map(|m| Vec3::from(m.translation)) .collect(); + if meter.radii.len() != rig.model.len() { + meter.radii = flesh_radii(&rig.parents, &rig.locals); + } + let flesh = flesh_probes(&rig.model, &meter.radii); // 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 @@ -192,13 +268,15 @@ fn sample_jitter( // A new subject (or a bone count that changed under us — a re-skin) restarts the series. if meter.subject != Some(entity) || meter.prev.len() != bones.len() + || meter.prev_flesh.len() != flesh.len() || meter.prev_anc.len() != ancs.len() || meter.prev_rid.len() != rids.len() { let name = names.peek(guid.0).unwrap_or("?").to_string(); println!( "JIT# t= dt= dist= pxyd= | camd1 camd2 (mm) | rootd1 rootd2 (mm) \ - | bone= d1 d2 (mm) d1px d2px | anc= d1 d2 (mm) d1px d2px | \ + | 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:?} \ guid={:#x} display={:?} bones={} scale={scale:.3}", window.physical_width(), @@ -242,6 +320,8 @@ fn sample_jitter( } meter.subject = Some(entity); meter.prev = bones; + meter.prev_flesh = flesh; + meter.prev2_flesh = Vec::new(); meter.prev_anc = ancs; meter.prev2_anc = Vec::new(); meter.prev_rid = rids.iter().map(|&(_, t)| t).collect(); @@ -257,6 +337,8 @@ fn sample_jitter( if meter.depth < 2 { meter.prev2 = std::mem::take(&mut meter.prev); meter.prev = bones; + meter.prev2_flesh = std::mem::take(&mut meter.prev_flesh); + meter.prev_flesh = flesh; meter.prev2_anc = std::mem::take(&mut meter.prev_anc); meter.prev_anc = ancs; meter.prev2_rid = std::mem::take(&mut meter.prev_rid); @@ -279,6 +361,50 @@ fn sample_jitter( } } let bd1 = (bones[worst] - meter.prev[worst]).length(); + // The same two differences on the FLESH probes — rotation included. This is the number that + // has to be small for the model to read as still; `bd*` above is only its translation half. + let (mut fd2, mut fworst) = (0.0f32, 0usize); + // Per-BONE, max over its three probes — so the spread below counts bones, not probe points. + let mut per_bone = vec![0.0f32; flesh.len() / 3]; + for (i, &p) in flesh.iter().enumerate() { + let c = (p - 2.0 * meter.prev_flesh[i] + meter.prev2_flesh[i]).length(); + let b = &mut per_bone[i / 3]; + *b = b.max(c); + if c > fd2 { + fd2 = c; + fworst = i; + } + } + let fd1 = (flesh[fworst] - meter.prev_flesh[fworst]).length(); + // **Is it the model, or is it one bone?** The worst bone alone cannot tell those apart, and + // that is the whole question a report of "the WHOLE model shakes" asks. `fd2med` is the + // median bone's curvature and `fnbig` the count above a quarter-pixel: one twitching bone + // leaves the median at zero and the count at 1, while a model-wide shimmer lifts both. + let mut sorted = per_bone.clone(); + sorted.sort_by(f32::total_cmp); + let fd2med = sorted.get(sorted.len() / 2).copied().unwrap_or(0.0); + // The matching Δ median. `fd1`/`fd2` above are read at whichever bone is worst THIS frame, so + // they are not one series and cannot be differenced against each other; these two are. Δ going + // to zero on alternate frames is a hold-then-jump stair-step; Δ steady while Δ² alternates is + // not, and the pair is what tells those apart. + let mut d1s: Vec = (0..per_bone.len()) + .map(|b| { + (0..3) + .map(|k| (flesh[b * 3 + k] - meter.prev_flesh[b * 3 + k]).length()) + .fold(0.0f32, f32::max) + }) + .collect(); + d1s.sort_by(f32::total_cmp); + 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 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 + // while `v` itself is clean, so `Δ²` reads large (`≈ v·Δ(dt)`) purely from the timing. A pose + // that is genuinely noisy has no such alibi: its velocity is ragged too. Differencing THIS + // column is what tells the two apart, and differencing `Δ` alone never can. + let fvel = fd1 * scale / dt; // Same two differences on the anchors, and the WORST AXIS of the worst one: the f32 grid an // absolute coordinate lands on is per-axis, so at ~9.5 k yards on one axis and ~60 on another // the staircase is one-dimensional — which is the fingerprint, not an aside. @@ -314,6 +440,31 @@ fn sample_jitter( } else { (ancs[aworst] - meter.prev_anc[aworst]).abs() }; + // The animation clock, per playing node: how far the POSE's own time advanced this frame, and + // whether the set of playing animations changed under us (a replay/stop_all snaps the pose). + let anim: Vec<(usize, f32, f32)> = players + .get(entity) + .map(|p| { + let mut v: Vec<(usize, f32, f32)> = p + .playing_animations() + .map(|(n, a)| (n.index(), a.seek_time(), a.weight())) + .collect(); + v.sort_by_key(|&(n, ..)| n); + v + }) + .unwrap_or_default(); + // Δseek on the node we also had last frame; `restart` marks a node set that changed (or a seek + // that went backwards by more than a loop's worth of frame) — the pose-snap fingerprint. + let same = anim.len() == meter.prev_anim.len() + && anim.iter().zip(&meter.prev_anim).all(|(a, b)| a.0 == b.0); + let dseek = if same { + anim.iter() + .zip(&meter.prev_anim) + .map(|(a, b)| a.1 - b.1) + .fold(0.0f32, |acc, v| if v.abs() > acc.abs() { v } else { acc }) + } else { + f32::NAN + }; let cam_d1 = (cam_pos - meter.prev_cam).length(); let cam_d2 = (cam_pos - 2.0 * meter.prev_cam + meter.prev2_cam).length(); let root_d1 = (root - meter.prev_root).length(); @@ -324,7 +475,9 @@ fn sample_jitter( let mm = |v: f32| v * scale * 1000.0; println!( "JIT\t{now:.4}\t{:.3}\t{dist:.3}\t{px_per_yd:.1}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{worst}\t\ - {:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.1}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t\ + {:.4}\t{:.4}\t{:.4}\t{:.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}", dt * 1000.0, cam_d1 * 1000.0, @@ -336,6 +489,19 @@ fn sample_jitter( bd1 * scale * px_per_yd, d2 * scale * px_per_yd, dvel * 1000.0, + fworst / 3, + mm(fd1), + mm(fd2), + fd1 * scale * px_per_yd, + fd2 * scale * px_per_yd, + fvel * 1000.0, + mm(fd1med), + mm(fd2med), + fnbig, + vtime.delta_secs() * 1000.0, + anim.len(), + dseek * 1000.0, + anim.first().map_or(-1.0, |a| a.2), aworst, ad1 * 1000.0, ad2 * 1000.0, @@ -352,9 +518,11 @@ fn sample_jitter( ); meter.prev2 = std::mem::replace(&mut meter.prev, bones); + meter.prev2_flesh = std::mem::replace(&mut meter.prev_flesh, flesh); meter.prev2_anc = std::mem::replace(&mut meter.prev_anc, ancs); meter.prev2_rid = std::mem::replace(&mut meter.prev_rid, rids.iter().map(|&(_, t)| t).collect()); meter.prev2_root = std::mem::replace(&mut meter.prev_root, root); meter.prev2_cam = std::mem::replace(&mut meter.prev_cam, cam_pos); + meter.prev_anim = anim; } diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 9f7635a6..2156a461 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -58,6 +58,7 @@ use equipment::{attach_held_items, resolve_equipment}; /// `ItemVisuals` id hangs on the item's OWN attachment points — the permanent weapon glows and /// the shaman/oil enchant visuals. mod item_glow; +pub(crate) use item_glow::ItemGlowAttached; use item_glow::{attach_item_glows, ItemGlows}; /// Mounts (decision 0441): the `UNIT_FIELD_MOUNTDISPLAYID` → second-creature-visual projection — @@ -118,7 +119,11 @@ pub(crate) use attach::equip_slot; pub(crate) use equipment::ItemDisplays; pub(crate) use equipment::{BoneAttach, Equipment}; // The instruments' read of what a body is actually WEARING vs what it resolved (`WOW_DRESS_CENSUS`). -pub(crate) use equipment::{attach_id, HeldAttached, ATTACH_SLOT_NAMES}; +pub(crate) use equipment::{attach_id, DressKey, HeldAttached, ATTACH_SLOT_NAMES}; +// [`DressKey`] carries one of these per weapon slot, but nothing outside this module has to NAME +// the type to hold or compare a key — only the tests that build one do. +#[cfg(test)] +pub(crate) use equipment::ItemModelKind; /// The overhead attachment slot (`PlayerName`, id 18). The anchor of the overhead name, the /// floating combat text, and the questgiver marker alike. diff --git a/crates/benilla-app/src/entities/equipment/mod.rs b/crates/benilla-app/src/entities/equipment/mod.rs index af741a8d..99a504dd 100644 --- a/crates/benilla-app/src/entities/equipment/mod.rs +++ b/crates/benilla-app/src/entities/equipment/mod.rs @@ -38,6 +38,7 @@ use benilla_assets::m2_url; mod resolve; pub(in crate::entities) use resolve::placement; pub(super) use resolve::resolve_equipment; +pub(crate) use resolve::DressKey; mod spawn; pub(super) use spawn::attach_held_items; @@ -200,8 +201,8 @@ impl HeldSlot { /// `Item\ObjectComponents\{Weapon,Shield}\model[0]` for held items; `Shoulder\model[0]`/`model[1]` /// for the left/right pauldron (each with its own `model_texture` column); `Head\_.m2` /// for helms — per-race/sex files, prefix by race id (Hu Or Dw Ni Sc Ta Gn Tr), M/F by sex. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub(super) enum ItemModelKind { +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum ItemModelKind { Weapon, Shield, ShoulderLeft, diff --git a/crates/benilla-app/src/entities/equipment/resolve.rs b/crates/benilla-app/src/entities/equipment/resolve.rs index 529c2669..2757d61d 100644 --- a/crates/benilla-app/src/entities/equipment/resolve.rs +++ b/crates/benilla-app/src/entities/equipment/resolve.rs @@ -67,6 +67,25 @@ pub(in crate::entities) fn placement( } } +/// One held slot's enchant ids, folded to a single change-detectable number for [`DressKey`]. +/// +/// The **scan width is the glow resolver's** (`0x62ec70`'s seven `CGItem` enchant slots), so the +/// fold moves exactly when the thing it stands in for — the item's glow — could. Creatures carry +/// none: a virtual item has no enchant fields, like the synthetic item the reference's +/// `GetVirtualItem` hands its own resolver. +fn enchant_fold( + s: &benilla_protocol::messages::ObjectFields, + kind: EntityKind, + slot: usize, +) -> i32 { + if kind != EntityKind::Player { + return 0; + } + (0..7u8) + .filter_map(|j| s.player_visible_item_enchant(PLAYER_HELD_SLOTS[slot], j)) + .fold(0i32, |acc, e| acc.wrapping_mul(31).wrapping_add(e as i32)) +} + /// The nocked ammo's attach point (wow-re `nocked-ammo-cancel.md` §E2/E5, byte-verified): the /// ONE body-bone attach in the whole mechanism is HandArrow (35), fired for a **bow** once its /// BowPull event latches `[+0xd58]&0x4000` — `nock_latched` is [`NockLatch`], driven by the real @@ -116,6 +135,32 @@ pub(in crate::entities) struct ResolveKey { latched: bool, } +/// The **dress** half of the same resolve — [`ResolveKey`]'s complement, and the reason it is a +/// separate component: a model widget's duplicate is re-taken on a *dress* change and not on a +/// *placement* one (wow-re `ui/scratch/paperdoll-liveness-law.md`; `crate::portrait::SnapKey`). +/// +/// Everything here is read **before** [`placement`], which is the whole point. The three weapon +/// slots are the only ones whose very *presence* a sheath state can decide — a stowed ranged +/// weapon renders nothing at all, and so does a sheath-type-less melee weapon — so a key built +/// from `HeldItems` would move every time the player drew a bow. This one does not. +/// +/// Deliberately absent: the **nocked ammo** and the **quiver**, both sheath-derived, and neither a +/// model-event producer in the reference (`0x60ba30` reaches no queue site at all). Helm, +/// shoulders and the body composite are absent for the opposite reason — they cannot move with a +/// sheath, so the pane keys on their mirrored geometry directly. +#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct DressKey { + /// The body the widget duplicates. + pub(crate) display_id: Option, + /// Mainhand · offhand · ranged: `(ItemDisplayInfo id, model kind, ItemVisuals id)`. + pub(crate) held: [Option<(u32, ItemModelKind, i32)>; HELD_SLOTS], + /// Every weapon identity above has a model with **built parts**. Ours, not the reference's: + /// it duplicates a model the world had already finished assembling, where our item models + /// stream in. While false the pane keeps re-taking, so a weapon that lands a few frames after + /// the window opened is not frozen out of the bake. + pub(crate) held_ready: bool, +} + /// Resolve every unit's held items from its descriptor. Creatures read display/invType/sheath straight /// from the virtual-item fields; players go visible-item entry → [`crate::items::Items`] (ask-once query on /// a miss). Ensures each needed display id has a [`DisplayModel`] entry in [`ItemDisplays`] (built @@ -140,6 +185,7 @@ pub(in crate::entities) fn resolve_equipment( Option<&NockedAmmo>, Has, Option<&ResolveKey>, + Option<&DressKey>, )>, held: Option>, mut templates: ResMut, @@ -180,6 +226,7 @@ pub(in crate::entities) fn resolve_equipment( nocked, nock_latched, current_key, + current_dress, ) in &units { if !matches!(net_entity.kind, EntityKind::Unit | EntityKind::Player) { @@ -295,6 +342,9 @@ pub(in crate::entities) fn resolve_equipment( let mut slots: [Option; ATTACH_SLOTS] = [None; ATTACH_SLOTS]; let mut wielded = Wielded::default(); let mut ranged_inv_type = None; + // The DRESS, gathered as we go: what the unit *wears* in the three weapon slots, recorded + // before `placement` decides whether any of it is currently rendered ([`DressKey`]). + let mut worn: [Option<(u32, ItemModelKind, i32)>; HELD_SLOTS] = [None; HELD_SLOTS]; for slot in 0..HELD_SLOTS { // (display id, inventory type, item sheath type, class, subclass, material) per slot. let resolved: Option<(u32, u32, u8, u8, u8, u8)> = match net_entity.kind { @@ -357,15 +407,21 @@ pub(in crate::entities) fn resolve_equipment( if !char_component { continue; // wielded resolved; the model never attaches on a non-character body } - let Some(attach) = placement(slot, inv_type, item_sheath, sheath_of(slot, inv_type)) - else { - continue; - }; let kind = if inv_type == 14 { ItemModelKind::Shield } else { ItemModelKind::Weapon }; + // The dress, recorded **above** the placement gate — the one line that makes a + // widget's snapshot blind to the sheath. Third term: the slot's enchant ids, folded. + // Read straight off the descriptor rather than taken from the resolved `visual` below, + // because that one is only computed for a slot that is actually placed — and an + // enchant change is a model-event producer whether or not the weapon is drawn. + worn[slot] = Some((display, kind, enchant_fold(s, net_entity.kind, slot))); + let Some(attach) = placement(slot, inv_type, item_sheath, sheath_of(slot, inv_type)) + else { + continue; + }; ensure_item_model(&mut held, display, kind, &asset_server); // The glow (decision 0805): the display's intrinsic visual, else this weapon slot's // first enchant with one. The enchant half is **players only** — the enchant ids ride @@ -391,6 +447,23 @@ pub(in crate::entities) fn resolve_equipment( visual, }); } + // Publish the dress the moment the weapons are resolved. `held_ready` asks only about + // slots that are actually PLACED — a stowed ranged weapon has no model requested at all, + // so counting it would leave the key permanently unready and the pane permanently live, + // which is the very thing this component exists to stop. + let dress = DressKey { + display_id: net_entity.display_id, + held: worn, + held_ready: slots[..HELD_SLOTS].iter().flatten().all(|hs| { + held.models + .get(&(hs.display, hs.kind)) + .and_then(|dm| dm.parts.as_ref()) + .is_some() + }), + }; + if current_dress != Some(&dress) { + commands.entity(entity).insert(dress); + } // The nocked ammo (byte-verified `0x60ba30` + the Q-E round, wow-re // `nocked-ammo-cancel.md` §E2/E5): the ONE body-bone attach in the whole mechanism is // HandArrow (35), **bow-only** — gun/crossbow (`gunXbow` early return) and thrown diff --git a/crates/benilla-app/src/entities/item_glow.rs b/crates/benilla-app/src/entities/item_glow.rs index 76a2e6ba..834e28de 100644 --- a/crates/benilla-app/src/entities/item_glow.rs +++ b/crates/benilla-app/src/entities/item_glow.rs @@ -168,7 +168,7 @@ pub(in crate::entities) struct ItemGlow { /// keeps them: it moves the root to the new attach point (decision 0826), so a glowing weapon /// keeps its glow — and its live particles — across the draw. #[derive(Component)] -pub(super) struct ItemGlowAttached; +pub(crate) struct ItemGlowAttached; /// Spawn each pending item root's glow instances: one effect-model instance per authored slot, at /// that slot's attachment point on the item's own model. diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 882dc4d2..ae8070e0 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -555,11 +555,14 @@ pub(in crate::net) fn extrapolate_remote_units( from, vel, time.delta(), - // Default support: **no hover offset**, because a remote's own granted modes - // are not modelled yet — decision 0866 builds the family for our own mover. A - // hovering *observed* player draws a yard low until the relayed - // `MSG_MOVE_HOVER` is read into per-unit mode state; the same shape as every - // other unmodelled remote mode. And **no carried steep-support bit** (1129): + // Default support: **no hover offset and no water plane**, because a remote's + // own granted modes are not modelled yet — decision 0866 builds the family for + // our own mover. A hovering *observed* player draws a yard low until the + // relayed `MSG_MOVE_HOVER` is read into per-unit mode state, and an observed + // water-walker's dead-reckon steps down through the surface between packets + // (decision 1623's sag, unfought here because nothing climbs it back) until the + // next wire Z corrects it; the same shape as every other unmodelled remote + // mode. And **no carried steep-support bit** (1129): // it is per-frame state the dead-reckon does not keep between packets, so a // watched player descending a steep face gets the ordinary cone reach, and the // next packet's wire Z corrects whatever that misses. diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index 85225680..5d531c1e 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -79,6 +79,29 @@ pub(crate) struct PipeShared { pub(crate) covered: AtomicBool, } +impl PipeWatch { + /// **Is a pipeline still being built?** — i.e. is there a variant the render world would + /// silently DROP the draw for right now. + /// + /// Off macOS, `create_pipeline_task` spawns the build on the async pool + /// (`bevy_render` 0.18.1 `pipeline_cache.rs:855`; the `block_on` arm is `cfg`'d to + /// wasm/macOS/single-threaded), and until it settles `PipelineCache::get_render_pipeline` + /// returns `None` — at which point `SetItemPipeline` returns + /// [`RenderCommandResult::Skip`](bevy::render::render_phase::RenderCommandResult::Skip) + /// (`render_phase/mod.rs:1745`) and that batch simply **does not draw this frame**. A live + /// view redraws it the moment the pipeline lands; a one-shot bake that has already gone to + /// sleep keeps the hole forever (report B331 — see [`crate::portrait`]'s pipeline settle). + /// + /// Reading `settled` first is deliberate: the pair is published unsynchronised, so the only + /// skew this ordering can produce is a stale-low `settled` against a fresh `created` — a + /// spurious `true`, which costs one extra rendered frame. The other order could produce a + /// spurious `false`, which is a wrong still. + pub(crate) fn compiling(&self) -> bool { + let settled = self.0.settled.load(Ordering::Relaxed); + self.0.created.load(Ordering::Relaxed) > settled + } +} + pub(crate) fn plugin(app: &mut App) { let shared = Arc::new(PipeShared { created: AtomicUsize::new(0), diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 6ed0fcb0..5819e6d7 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -84,8 +84,8 @@ use state::{ MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, - STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, - WEDGE_STILL_FRAMES, + STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, + WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, }; // `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision // 0737 — residency releases the hold, not ground contact), which owns the deadline push while the @@ -1251,7 +1251,16 @@ fn control( // swimmer is a real state and their Space does nothing at all (wow-re // `fall-steep-response.md` §10). The land leg's refusal lives in [`mover::step`], // the same handler's grounded arm. - let breach = swimming && want_jump && !player.modes.hover; + // **The wire's jump** — the `Jump(force = 0)` a `SetHover(true)` owes + // ([`Player::hover_launch`], decision 1620). It differs from Space in exactly one gate and + // that gate is the point: `0x7c6236 test eax,eax; je 0x7c6243` skips the hover refusal when + // `force` is 0, so this leg jumps a body that is *already* hovering — which is every body + // that just got granted hover. The two refusals it keeps are ROOT and FALLING + // (`0x7c625c test ah,0x30`); the seed select at `0x7c6261` is shared, so the swim/land + // choice is made below by the same two take-off sites Space uses. + let wire_jump = player.take_wire_jump(); + + let breach = swimming && (want_jump && !player.modes.hover || wire_jump); if breach { player.swimming = false; } @@ -1310,11 +1319,42 @@ fn control( } } - // This frame's PRESENTED swim pitch — the persistent [`Player::swim_pitch`] while swimming + // This frame's PRESENTED swim pitch — the persistent [`Player::mover_pitch`] while swimming // (held even idle, the client's `CMovement+0x20`), except leveled by the 0499 surface // redirect when the rest-line cap bites. Feeds the body pose and the wire pitch tail (one // source — the pose and the stream can't disagree); the tail only serializes with the // SWIMMING flag, so the walking value is inert. + // **The mover pitch, set — in every mode, not just the swim one** ([`Player::mover_pitch`] + // = `CMovement+0x20`). HELD when unsteered (VERIFIED TU-B(c) — an idle floater keeps its + // pitch, never auto-levels), and steered by mouselook as a DIRECT set of the camera aim — + // **VERIFIED** (the camera-pitch §5, wow-re `swim-camera-pitch.md`, decision 0492, closing + // 0488's INTERIM and refuting the earlier no-camera-coupling census): the ref's mouse-move + // chain ends in `SetPitch 0x7c6f70`, an unconditional store — no integrator, no rate limit + // — clamped ±89° ([`MOUSELOOK_PITCH_CLAMP`], the byte constant; the ±π/2 clamp belongs to + // the unbound pitch-KEY integrator), with the velocity basis rebuilt in-call: the aim + // re-points travel the same frame, zero lag. (The ref's `fchs` negate is its own camera + // sign convention; ours maps aim-up to pitch-up already.) A left-drag camera orbit steers + // NOTHING — it moves the camera without turning the character (the walk rule at `move_fwd` + // above), so it must not bend the swim either (director-reported, 2026-07-18). + // + // It lived inside the swimming branch until decision 1616 (B322). Nothing on the ref's + // write path is swim-gated — not the mouse handler `0x514400`, not the applier `0x5103e0`, + // not the relay `0x515330`, not the enqueuer `0x6198a0`, and not `SetPitch`'s own store at + // `0x7c6f91`, which precedes the `test [esi+0x40],0x200000` that splits the two arms + // (`swim-camera-pitch.md` §7: "the mouse-look pitch push is swim-agnostic … on land too"). + // Swimming gates only the *readers* — the travel basis, the body pose, the wire tail — and + // on land the field has two more, both water walking's: the trace-mask arm's third gate + // below, and `SetPitch`'s own dive-through complement. + // + // The push is **per mouse-move, not per frame** ([`Player::aim_pitch_seen`]): the ref's + // enqueue hangs off the mouse-MOTION event `0x400500cb`, so a still mouse pushes nothing + // and the other writers of the field — the wobble, StopSwim's levelling — survive. + if mouselook && cam.pitch != player.aim_pitch_seen { + player.aim_pitch_seen = cam.pitch; + player.mover_pitch = cam + .pitch + .clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP); + } let mut swim_pitch = 0.0_f32; // The ground height the mover starts this frame at (pre-step feet Y). For a jump this is the // true takeoff height — the mover integrates one jump-tick upward *within* the step, so the @@ -1338,23 +1378,6 @@ fn control( // `advance_airborne_arc` below snapshots it like any land jump. swim::breach_step(&mut player, &time, &collide, capsule) } else if swimming { - // The swim pitch: HELD when unsteered (VERIFIED TU-B(c) — an idle floater keeps its - // pitch, never auto-levels), and steered by mouselook as a DIRECT set of the camera - // aim — **VERIFIED** (the camera-pitch §5, wow-re `swim-camera-pitch.md`, decision - // 0492, closing 0488's INTERIM and refuting the earlier no-camera-coupling census): - // the ref's mouse-move chain ends in `SetPitch 0x7c6f70`, an unconditional store — - // no integrator, no rate limit — clamped ±89° ([`MOUSELOOK_PITCH_CLAMP`], the byte - // constant; the ±π/2 clamp belongs to the unbound pitch-KEY integrator), with the - // velocity basis rebuilt in-call: the aim re-points travel the same frame, zero - // lag. (The ref's `fchs` negate is its own camera sign convention; ours maps aim-up - // to pitch-up already.) A left-drag camera orbit steers NOTHING — it moves the - // camera without turning the character (the walk rule at `move_fwd` above), so it - // must not bend the swim either (director-reported, 2026-07-18). - if mouselook { - player.swim_pitch = cam - .pitch - .clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP); - } // The drunk porpoise (B210): while swimming and moving, the pitch increments by the // wobble ×4.0 every frame (`0x60aabc–0x60ab0a`: flag `0x200000` → `pitch + // wobble·[0x80306c]`, clamped, committed via the pitch pipeline `0x60de70`). Same @@ -1362,18 +1385,18 @@ fn control( // The clamp is the callee `0x60aba0`'s FIXED ±π/2 bounds (`0x808acc`/`0x80c5e4`), // NOT the mouselook set's ±89° — the reference carries both (decision 1009 §C4). if drunk_wobble != 0.0 && translating { - player.swim_pitch = (player.swim_pitch + player.mover_pitch = (player.mover_pitch + drunk_wobble * drunk::SWIM_PITCH_WOBBLE_SCALE) .clamp(-std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2); } - swim_pitch = player.swim_pitch; + swim_pitch = player.mover_pitch; // The travel basis (`0x7c5880`, the client's swim velocity direction): the FORWARD axis // is the facing pitched by the swim pitch — `(cosP·horiz-fwd + sinP·up)` — so holding W // with the nose down dives (and aimed up, climbs — the smooth ascend, like the // ref's PitchUp+Forward); the STRAFE axis stays level. There is no vertical // thruster and Space adds nothing here (the verified basis has no separate vertical // input; Space's whole swim role is the jump-exit above). - let (sp, cp) = player.swim_pitch.sin_cos(); + let (sp, cp) = player.mover_pitch.sin_cos(); let fwd_axis = move_fwd * cp + Vec3::Y * sp; let v = fwd_axis * swim_fwd + move_right * swim_side; let dir3 = v.normalize_or_zero(); @@ -1412,7 +1435,7 @@ fn control( // `swim::cap_redirect`): when the rise capped at the rest line, the stroke went // level at full speed — present the *effective* pitch (body pose + wire tail // follow the motion, →0 pinned at the line), while the raw aim stays in - // `player.swim_pitch` so a later nose-down dives instantly. + // `player.mover_pitch` so a later nose-down dives instantly. if let Some(p) = out.surface_pitch { swim_pitch = p; } @@ -1434,22 +1457,16 @@ fn control( // simply *is* geometry. Passing it down is our stand-in for that, because liquid is // queried rather than swept here. // - // The `!swimming` half is the reference's own gate — the arm at `0x63160d` is skipped - // when `MOVEFLAG_SWIMMING` is set (`0x631617`) — so granting the aura to a submerged - // caster does not eject them; they surface onto the water on the way out. (This branch - // is already the non-swimming one, so `swimming` is false here; the condition is - // written out because the gate is the mechanism, not an accident of control flow.) - // - // **The arm's third gate is NOT implemented, and it is the way back INTO the water**: - // `0x6315f0` also requires `pitch > -37.0°` (`[0x80dfe8]`; the emitted `jne` reads ZF, - // so `==` is excluded), and its complement in `SetPitch 0x7c6f70` elects a - // zero-velocity `StartFalling` when a standing, non-falling water-walker looks down - // past the same angle. Both need the mover pitch (`CMovement+0x20`) live on land, - // which here is [`Player::swim_pitch`] — steered only while swimming today. Named in - // decision 1611 rather than half-built. - let water_floor = (player.modes.water_walking && !swimming) - .then_some(surface_y) - .flatten(); + // All three of the arm's gates live in [`mover::water_floor`], where each one's byte + // site and its consequence are written out — including the pitch gate that 1611 could + // only *name*, because the pitch was steered inside the swim branch until the hoist + // above. + let water_floor = mover::water_floor( + player.modes.water_walking, + swimming, + player.mover_pitch, + surface_y, + ); mover::step( &mut player, &time, @@ -1459,6 +1476,7 @@ fn control( dir, speed, want_jump, + wire_jump, water_floor, ) }; diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs index 59e22999..603a25ff 100644 --- a/crates/benilla-app/src/player/mover.rs +++ b/crates/benilla-app/src/player/mover.rs @@ -39,9 +39,49 @@ use super::{ move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, FOOT_CONE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, - STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, + STEP_UP_HEIGHT, TERMINAL_VELOCITY, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, + WEDGE_STILL_FRAMES, }; +/// **The trace-mask arm `0x6315f0`, as a predicate** — the liquid surface [`step`] may stand on +/// this frame, or `None` for "the water is not geometry right now". All three of the arm's gates, +/// in the order the reference tests them, and none of them ours: +/// +/// - **`water_walking`** — `0x631610 test eax,0x10000000`, the granted bit. Setting it ORs the two +/// ADT liquid layers into the walk trace's *class mask* (`0x63162e or edi,0x30000`), which is the +/// whole of what the flag does: the surface becomes ordinary swept geometry and the resolver +/// stands on it at its raw Z. `0x6367b0` contains **zero** references to the bit +/// (wow-re `moveflag-family.md` §2.1/§2.2). +/// - **`!swimming`** — `0x631617 test eax,0x200000; jne`. Granting *this* bit to a submerged caster +/// does **not** eject them, and that is the reference's behaviour too, not a gap of ours +/// (decision 1616): the swim path traces the same two liquid layers into a *different* container +/// with the plane **negated** (`0x6320fe fchs`) — a bound above, not a floor below — so the two +/// are mutually exclusive by construction and the same triangles can never enter the solver twice +/// with opposite orientation (§2.4). A swimmer surfaces onto the water on the way *out*, when the +/// depth compare `0x6030c0` drops SWIMMING in water shallower than `0.75·h − 1/36`. +/// +/// **Do not read that as "casting Levitate while swimming does nothing" — 1616 did, and it was +/// wrong** (decision 1620, the director's report from the reference client). Levitate grants +/// three auras, and the *hover* one launches you: `SetHover(true)`'s handler `0x61a620` runs +/// `CMovement::Jump(force = 0)` before it ever sets the flag, which clears SWIMMING and seeds an +/// upward take-off. Water walking is then what catches the body on the way down. The gate above +/// is real and is not the whole story, which is the shape of that mistake: a verified mechanism +/// answering a question one layer below the one being asked. +/// - **`mover_pitch > `[`WATER_WALK_PITCH_FLOOR`]** — `0x63161e`, strictly greater (the emitted +/// `jne` reads ZF, so −37.0° exactly is *excluded*). Aim below it and the layers leave the mask: +/// the surface stops being ground and the body falls through into the swim. This is the +/// reference's own way back **into** the water while water-walking. +pub(super) fn water_floor( + water_walking: bool, + swimming: bool, + mover_pitch: f32, + surface_y: Option, +) -> Option { + (water_walking && !swimming && mover_pitch > WATER_WALK_PITCH_FLOOR) + .then_some(surface_y) + .flatten() +} + /// What the step decided — read by the move-flags / wire logic that follows it in `control`. pub(super) struct Outcome { /// Settling (post-teleport world stream-in): frozen in place, gravity off. @@ -72,6 +112,7 @@ pub(super) fn step( dir: Vec3, speed: f32, want_jump: bool, + wire_jump: bool, water_floor: Option, ) -> Outcome { let dt = time.delta_secs(); @@ -127,14 +168,19 @@ pub(super) fn step( // by *teleport*, at rest, with no input — the one case that cannot show any of this — and 0866 // named the gap itself: "the water-walk walk-on case is untested headlessly". // - // The reach is [`base_reach`] and deliberately **not** `ground_reach`: our hover climb is - // collider-driven and never lifts a body off water, so the clamp below keeps a hovering - // water-walker's feet exactly at the surface. The two tests are the same question and must - // stay the same answer — disagreeing is the whole of this bug. (Whether the reference floats a - // hovering water-walker a yard *above* the surface is a real open question, with a byte lead: - // see decision 1611.) A negative difference — the feet already under the surface — counts as - // support too: that is a body penetrating solid geometry, and the clamp resolves it upward. - let on_water = water_floor.is_some_and(|s| center.y - half_h.y - s <= base_reach); + // The reach is [`ground_reach`], hover offset included, because **a hovering water-walker + // rests a full yard above the surface** — 1611 left that an open question and it is now + // **VERIFIED** (decision 1616): the walk resolver's finalize down-probe `0x636e45` calls + // `0x632ba0`, whose trace `0x632d29` is `0x631e70` — the query whose own head is the mask arm + // (`0x6320a0 call 0x6315f0`, `0x6320b1` the trace). So the distance `L` that probe reports is + // measured to the *water*, and `0x636e74`'s hover arm then leaves the body at + // `pos.z_before_snap − max(L − 1.0, 0)`: exactly 1.0 yd over whatever it found, water included, + // with no water-specific code on either side. A classify that stopped at [`base_reach`] would + // call that float airborne and drop it — the same disagreement, one rung up. + // + // A negative difference — the feet already under the surface — counts as support too: that is + // a body penetrating solid geometry, and the resolve below pushes it back out. + let on_water = water_floor.is_some_and(|s| center.y - half_h.y - s <= ground_reach); // Who we stand on (frame start); the end-of-frame snap probe below refreshes it post-move. let mut ground_entity = if on_walkable { classify.map(|h| h.entity) @@ -206,7 +252,11 @@ pub(super) fn step( // latch, no cooldown. The mounted flourish is deliberately NOT gated on hover — // `0x60dea0` diverts to MountSpecial before the command ever reaches this handler // (`player::control`'s flourish block runs upstream of this call, same order). - if want_jump && !player.modes.hover { + // …and `wire_jump` is the leg that is NOT refused: the reference's own `Jump(force = 0)` + // skips the test above (`0x7c6236 test eax,eax; je 0x7c6243`) so a `SetHover(true)` can + // launch a body that is already hovering (decision 1620). Same seed select, same commit — + // it is the *same* jump, entered through the door the wire is given. + if (want_jump && !player.modes.hover) || wire_jump { player.vel_y = JUMP_SPEED; player.wedged = false; player.steep_support = false; @@ -256,6 +306,7 @@ pub(super) fn step( time.delta(), Support { offset: hover_offset, + water: water_floor, steep: player.steep_support, }, ); @@ -328,15 +379,70 @@ pub(super) fn step( // and the wire sees a normal landing (`MSG_MOVE_FALL_LAND`) this frame, not next. let mut grounded = grounded || player.wedged; + // **The water floor, resolved** — the second half of the `on_water` classify above. It runs + // after the slide because the slide and the fall are what can carry the feet through: a body + // dropping onto the water crosses more than [`LAND_PROBE`] in a frame, so the landing frame + // classifies airborne, falls, and is caught here — exactly how a collider landing resolves. + // + // Two halves, and they are the reference's two, in its order. **Down** is `0x636e52`'s snap as + // corrected by `0x636e74`'s hover arm — `pos.z_final = pos.z_before_snap − max(L − 1.0, 0)`, + // i.e. descend only far enough to leave the hover clearance, and not at all when the floor is + // nearer than that. **Up** is never here: a rise is the rate-limited second pass just below + // (`0x636fa1`), so a hover granted over water floats up over ~0.14 s instead of popping. The + // one exception is **depenetration** — the body may not *end* the frame under the surface, + // which is not a rule of the reference's but a consequence of it not needing one: over there + // the surface is a swept solid and the sweep never lets the body through in the first place. + // + // The down half stands aside for a walkable collider: a pier deck three yards over the lake is + // the nearer floor, and in the reference both are hits in the same trace with the nearest + // winning. Depenetration stands aside for exactly one thing — **a body that is rising**. It is + // a floor, and a floor catches what falls onto it, never what is on its way up through it; the + // reference needs no such clause because over there the surface is a swept solid and the sweep + // is directional for free. Ours is a post-hoc push-out, and without the gate it would swallow + // the very launch a hover grant just seeded (decision 1620): cast Levitate a foot under the + // waterline and the `SWIM_JUMP_SPEED` take-off would be clamped flat to the surface with its + // velocity zeroed, a pop where the reference throws you clear. With `vel_y <= 0` it is still + // the water-walk *lift* it was built to be — wade in chest-deep, aura up, and this is the frame + // that puts you on top of the water, because a wader stands at rest. + // + // **Why there is no second kick for the standstill dive.** `SetPitch 0x7c6f70` fires an + // explicit zero-velocity `StartFalling` when a *standing*, non-falling water-walker aims below + // −37° (`0x7c6fb3`–`0x7c6fd3`), which reads like a mechanism we are missing. It is the + // reference compensating for its own walk resolver: `0x6367b0` early-returns when the substep's + // horizontal distance is under `2^-20`, so a body at rest never re-runs the ground query and + // would not notice the mask change until it moved. Our classify runs every frame in every + // state, so dropping the liquid layers is already the whole of the dive, standing or running. + // Building the kick as well would be a second path to the same fall. + if let Some(surface) = water_floor { + let feet = center.y - half_h.y; + let rest = surface + hover_offset; + if feet < surface && player.vel_y <= 0.0 { + center.y = surface + half_h.y; + player.vel_y = 0.0; + player.wedged = false; + grounded = true; + } else if !on_walkable && feet > rest && feet - surface <= ground_reach { + center.y = rest + half_h.y; + player.vel_y = 0.0; + player.wedged = false; + grounded = true; + } + } + // **The hover climb** (decision 0872): the snap above can only lower the body, so the *rise* to // the 1.0-yd clearance is this separate rate-limited pass — the reference's second writer at // `0x636fa1`–`0x6370f1`, which climbs toward the same clearance at [`HOVER_CLIMB_RATE`]. Without // it the grant reads as an instant pop; with it the body floats up over ~0.14 s. // (…and never while anchored: the climb is the walk resolver's own second pass, so the rooted // mover's stationary early-return skips it exactly like the snap above.) + // + // The water is one more floor to hover over (decision 1616) and the only one `probe_down` + // cannot see, so it is measured against the plane and folded in as a candidate — nearest wins, + // which is what the reference's single trace does for free. if hover_offset > 0.0 && !held && !anchored { - if let Some(h) = probe_down(center, HOVER_HEIGHT + CAPSULE_HEIGHT) { - let clearance = h.distance; + let solid = probe_down(center, HOVER_HEIGHT + CAPSULE_HEIGHT).map(|h| h.distance); + let water = water_floor.map(|s| (center.y - half_h.y - s).max(0.0)); + if let Some(clearance) = [solid, water].into_iter().flatten().reduce(f32::min) { if clearance < HOVER_HEIGHT { center.y += (HOVER_HEIGHT - clearance).min(HOVER_CLIMB_RATE * dt); player.vel_y = player.vel_y.max(0.0); // climbing, not falling @@ -344,30 +450,6 @@ pub(super) fn step( } } - // **The water floor, resolved** — the second half of the `on_water` classify above, and the - // depenetration a swept solid would have done for free: the body may not end the frame under - // the surface. It still has to run after the slide, because the slide and the fall are what - // can carry the feet through: a body dropping onto the water crosses more than [`LAND_PROBE`] - // in a frame, so the landing frame classifies airborne, falls, and is caught here — exactly - // how a collider landing resolves. On an ordinary on-water frame the classify has already said - // grounded and the slide kept Y, so this is a no-op that re-states it. - // - // `water_floor` is `Some` only while the mode is granted AND we are not already swimming — the - // reference's own gate at `0x631617` (`test eax,0x200000; jne`), whose *reason* is worth - // keeping: the swim path traces the same two liquid layers into a different container with the - // plane **negated** (`0x6320fe fchs`), a bound above rather than a floor below, so the two are - // mutually exclusive by construction and the same triangles can never enter the solver twice - // with opposite orientation (wow-re `moveflag-family.md` §2.4). - if let Some(surface) = water_floor { - let feet = center.y - half_h.y; - if feet <= surface { - center.y = surface + half_h.y; - player.vel_y = 0.0; - player.wedged = false; - grounded = true; - } - } - player.pos = center - half_h; move_trace::frame(move_trace::Frame { y_in: pre_move.y - half_h.y, @@ -405,6 +487,19 @@ pub(crate) struct Support { /// [`super::HOVER_HEIGHT`] while the mode is up and `0.0` for everyone else, which is the /// ordinary case. pub(crate) offset: f32, + /// **The liquid surface that counts as floor this frame** (Bevy Y), or `None` when the water is + /// not geometry — [`super::mover::water_floor`]'s answer, threaded down because the election + /// snap has to see it and cannot. + /// + /// Liquid is queried on our side, never swept, so `cast_body` is blind to it. The reference has + /// no such split: `MOVEFLAG_WATERWALKING` ORs the two ADT liquid layers into the walk trace's + /// class mask (`0x63162e or edi, 0x30000`), so the one trace that finds terrain finds the water + /// too and the election is over a single candidate set. Ours needs the plane handed in, and + /// **this is the third site that needed it** — 1611 put it in the classify, 1616 in the hover + /// climb, and the snap kept its own blindness until 1623 (B322). The shared shape of all three: + /// every question of the form "is there ground below me?" has to ask the plane as well as the + /// sweep, or the two disagree and the body oscillates between their answers. + pub(crate) water: Option, /// The support entering the frame is a **certified steep contact**, not a walkable floor — the /// reference's `0x4000000` (decision 1125). It is the sole gate on the step-down probe's deep /// reach; see the reach in [`grounded_step`]. @@ -645,7 +740,23 @@ pub(crate) fn grounded_step( } + surface_offset; let hit = cast(slid, Vec3::NEG_Y * reach); - let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y)))); + // **The water is one more candidate in the same election** ([`Support::water`], decision 1623). + // The sweep above cannot see liquid, so the plane is measured against the same probe: feet to + // surface, in sight only within the same `reach`, and flat (`normal.y = 1.0`) because a liquid + // surface always is. Nearest wins — which is what the reference's single masked trace does for + // free. No entity: the water is not a collider, and `GroundedStep::ground`'s `None` already + // means "keep whatever the caller believed", which is exactly right for standing on a lake. + let water = support.water.and_then(|s| { + let d = slid.y - CAPSULE_HEIGHT * 0.5 - s; + (d <= reach).then_some(d.max(0.0)) + }); + let floor = match (hit, water) { + (Some(h), Some(w)) if w < h.distance => Some((w, 1.0, None)), + (Some(h), _) => Some((h.distance, h.normal1.y, Some(h.entity))), + (None, Some(w)) => Some((w, 1.0, None)), + (None, None) => None, + }; + let snap = Some((reach, floor.map(|(d, n, _)| (d, n)))); let mut ground = None; let mut steep_support = false; // **A ride's height is earned; the snap follows ground *down*, it never undoes a climb.** @@ -701,9 +812,9 @@ pub(crate) fn grounded_step( // which is what keeps the dive gone. Seeing further and falling further are different questions, // and only the second one is the director's "instant". let cone_reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK; - if let Some(h) = hit { - let drop = (h.distance - surface_offset).max(0.0); - let walkable = h.normal1.y >= GROUND_COS; + if let Some((distance, normal_y, entity)) = floor { + let drop = (distance - surface_offset).max(0.0); + let walkable = normal_y >= GROUND_COS; // A ride's height is earned (above): only a floor higher than the ride began on may end it. if !(rode && walkable && drop >= ride_rise) { slid.y -= drop.min(cone_reach); @@ -715,7 +826,7 @@ pub(crate) fn grounded_step( // dive coming back through the cap. steep_support = true; } else if walkable { - ground = Some(h.entity); + ground = entity; } else { // **Support is earned by descending, not by touching.** Resting *on* a steep face // gives a clearance of ~0, and standing still the bound is the slack alone — so a @@ -1552,6 +1663,7 @@ mod tests { dt, Support { offset: 0.0, + water: None, steep: true, }, ); @@ -1889,6 +2001,188 @@ mod tests { assert!(out.dot(n) >= -1e-6); } + /// **B322, the swim half — the three gates of `0x6315f0`, including the one 1611 could only + /// name.** The pitch gate is what lets a water-walker back *into* the water, and it is a strict + /// `>`: the reference's `jne` reads ZF, so −37.0° exactly is on the falling side of the line. + #[test] + fn the_water_is_geometry_only_while_all_three_gates_hold() { + const SURFACE: f32 = 12.5; + let eps = 1.0e-4; + // Granted, dry-shod, aim level: the water is a floor. + assert_eq!(water_floor(true, false, 0.0, Some(SURFACE)), Some(SURFACE)); + // Aiming down, either side of the line — and *on* it. + assert_eq!( + water_floor(true, false, WATER_WALK_PITCH_FLOOR + eps, Some(SURFACE)), + Some(SURFACE), + "just shallower than -37 deg still walks on water" + ); + assert_eq!( + water_floor(true, false, WATER_WALK_PITCH_FLOOR, Some(SURFACE)), + None, + "-37.0 deg exactly is EXCLUDED — `0x63162c`'s jne reads ZF" + ); + assert_eq!( + water_floor(true, false, WATER_WALK_PITCH_FLOOR - eps, Some(SURFACE)), + None, + "aim past -37 deg and the liquid layers leave the mask: the dive" + ); + // Aiming *up* never costs the floor — the arm has no upper bound. + assert_eq!( + water_floor(true, false, 1.5, Some(SURFACE)), + Some(SURFACE), + "the arm gates the nose-down half only" + ); + // Swimming excludes it (`0x631617`), and so does not having the aura. + assert_eq!(water_floor(true, true, 0.0, Some(SURFACE)), None); + assert_eq!(water_floor(false, false, 0.0, Some(SURFACE)), None); + // No liquid under the feet is no floor, whatever the gates say. + assert_eq!(water_floor(true, false, 0.0, None), None); + } + + /// **B322, the height half** (decision 1616): Levitate is hover **and** water walking, and the + /// two compose — the body rests a full yard *above* the waterline, not with its feet in it. + /// + /// 1611 left this open. It is settled at the bytes: the walk resolver's finalize down-probe + /// (`0x636e45`) is `0x632ba0`, whose trace (`0x632d29`) is `0x631e70` — the query whose own head + /// is the mask arm (`0x6320a0 call 0x6315f0`). So the distance `L` the probe reports is measured + /// to the *water*, and `0x636e74` then leaves the body at `pos.z_before_snap − max(L − 1.0, 0)`. + /// + /// Three things at once, because they are one mechanism: the rise off the waterline is + /// **rate-limited** (the reference's second pass `0x636fa1`, not a pop), it **settles at + /// exactly** [`HOVER_HEIGHT`], and a body dropped in from above **stops there** instead of + /// sinking to the surface. + #[test] + fn a_hovering_water_walker_floats_a_yard_over_the_waterline() { + const SURFACE: f32 = 0.0; + // No land at all: open water, so nothing but the liquid plane can hold the body up. + const OPEN: [(f32, f32); 2] = [(-3.0, -40.0), (3.0, -40.0)]; + let (first, rest, dropped) = world_from_profile(&OPEN) + .world_mut() + .run_system_once(|world: benilla_world::collision::WorldCollision| { + let capsule = player_capsule(); + let mut time = Time::default(); + time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0)); + let levitate = super::super::state::MoveModes { + water_walking: true, + hover: true, + ..Default::default() + }; + let idle = |player: &mut Player, frames: usize| { + for _ in 0..frames { + step( + player, + &time, + &world, + &capsule, + false, + Vec3::ZERO, + 7.0, + false, + false, + Some(SURFACE), + ); + } + player.pos.y + }; + // Granted while standing on the water: climbs, it does not pop. + let mut player = Player { + modes: levitate, + pos: Vec3::new(0.0, SURFACE, 0.0), + ..Default::default() + }; + let first = idle(&mut player, 1); + let rest = idle(&mut player, 60); + // Dropped in from well above: the fall stops at the hover line, not the waterline. + let mut faller = Player { + modes: levitate, + pos: Vec3::new(0.0, SURFACE + 8.0, 0.0), + ..Default::default() + }; + let dropped = idle(&mut faller, 240); + (first, rest, dropped) + }) + .unwrap(); + + assert!( + first > SURFACE && first - SURFACE < HOVER_HEIGHT * 0.5, + "the rise off the waterline is rate-limited, not a pop: {first} after one frame" + ); + assert!( + (rest - (SURFACE + HOVER_HEIGHT)).abs() < 1.0e-3, + "a hovering water-walker rests exactly {HOVER_HEIGHT} yd over the surface: {rest}" + ); + assert!( + (dropped - (SURFACE + HOVER_HEIGHT)).abs() < 1.0e-3, + "a hovering water-walker dropped in from above stops on the hover line: {dropped}" + ); + } + + /// **B322, the lift** (decision 1616): the director's *"it doesn't bring you to surface when + /// used in water"*, for the case the reference actually surfaces — a body standing in liquid + /// too shallow to swim in. The surface is solid geometry over there, so a body inside it is a + /// body inside a wall, and the sweep's depenetration is what puts it on top. Ours is a queried + /// plane with no sweep, so the resolve has to say it. + #[test] + fn water_walking_lifts_a_wader_out_of_the_water() { + const SURFACE: f32 = 0.0; + // A lakebed 1.4 yd under the surface — wading depth for a human (swim starts at ~1.52). + const BED: [(f32, f32); 2] = [(-4.0, -1.4), (4.0, -1.4)]; + let (walker, levitator) = world_from_profile(&BED) + .world_mut() + .run_system_once(|world: benilla_world::collision::WorldCollision| { + let capsule = player_capsule(); + let mut time = Time::default(); + time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0)); + let run = |modes: super::super::state::MoveModes, frames: usize| { + let mut player = Player { + modes, + pos: Vec3::new(0.0, -1.4, 0.0), + ..Default::default() + }; + for _ in 0..frames { + step( + &mut player, + &time, + &world, + &capsule, + false, + Vec3::ZERO, + 7.0, + false, + false, + Some(SURFACE), + ); + } + player.pos.y + }; + let walker = run( + super::super::state::MoveModes { + water_walking: true, + ..Default::default() + }, + 30, + ); + let levitator = run( + super::super::state::MoveModes { + water_walking: true, + hover: true, + ..Default::default() + }, + 60, + ); + (walker, levitator) + }) + .unwrap(); + assert!( + (walker - SURFACE).abs() < 1.0e-3, + "water walking must lift a wader onto the surface: {walker}" + ); + assert!( + (levitator - (SURFACE + HOVER_HEIGHT)).abs() < 1.0e-3, + "…and Levitate's hover carries it the last yard: {levitator}" + ); + } + /// **B322, the lost-control half** (decision 1611): a body walking off land onto water with /// `MOVEFLAG_WATERWALKING` up must keep steering and must stop when the keys are released. /// @@ -1937,6 +2231,7 @@ mod tests { dir, SPEED, false, + false, Some(SURFACE), ); } @@ -2012,6 +2307,7 @@ mod tests { Vec3::X, 0.0, true, + false, None, ); (out.jumped, player.vel_y) @@ -2029,6 +2325,212 @@ mod tests { ); } + /// **The jump hover cannot refuse** (decision 1620, B322) — the same handler, entered through + /// the wire's door. `0x7c6236 test eax,eax; je 0x7c6243` skips the hover test above whenever + /// `force` is 0, and the sole `force = 0` call site is `0x61a62e`, inside the `SetHover(true)` + /// handler. So the body that is hovering — the one the test above proves Space cannot move — is + /// exactly the body this leg launches, because it was granted hover a moment ago. + #[test] + fn the_wire_jump_launches_the_hovering_body_the_keyboard_cannot() { + let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)]; + let (keyboard, wire) = world_from_profile(&flat) + .world_mut() + .run_system_once(|world: benilla_world::collision::WorldCollision| { + let capsule = player_capsule(); + let mut time = Time::default(); + time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0)); + let press = |want_jump: bool, wire_jump: bool| { + let mut player = Player { + modes: super::super::state::MoveModes { + hover: true, + ..Default::default() + }, + ..Default::default() + }; + let out = step( + &mut player, + &time, + &world, + &capsule, + false, + Vec3::X, + 0.0, + want_jump, + wire_jump, + None, + ); + (out.jumped, player.vel_y) + }; + (press(true, false), press(false, true)) + }) + .unwrap(); + assert_eq!( + keyboard, + (false, 0.0), + "`0x7c623a` still refuses Space while hovering" + ); + assert_eq!( + wire, + (true, JUMP_SPEED), + "`force = 0` skips that test and takes off on the land seed `0xc0fe93d8`" + ); + } + + /// **The waterline is a floor, and a floor does not catch a body on its way up** (decision + /// 1620, B322). The depenetration half of the water resolve is ours, not the reference's — over + /// there the surface is a swept solid and the sweep is directional for free — so it needs the + /// clause the sweep gets for nothing. Without it, casting Levitate a foot under the waterline + /// would have the launch clamped flat to the surface with its velocity zeroed on the very next + /// step: a pop where the reference throws you clear. + #[test] + fn the_waterline_catches_a_sinking_body_and_lets_a_launched_one_through() { + const SURFACE: f32 = 0.0; + const OPEN: [(f32, f32); 2] = [(-3.0, -40.0), (3.0, -40.0)]; + const UNDER: f32 = SURFACE - 0.5; + let (launched, at_rest) = world_from_profile(&OPEN) + .world_mut() + .run_system_once(|world: benilla_world::collision::WorldCollision| { + let capsule = player_capsule(); + let mut time = Time::default(); + time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0)); + let one_step = |vel_y: f32| { + let mut player = Player { + modes: super::super::state::MoveModes { + water_walking: true, + ..Default::default() + }, + pos: Vec3::new(0.0, UNDER, 0.0), + vel_y, + ..Default::default() + }; + step( + &mut player, + &time, + &world, + &capsule, + false, + Vec3::ZERO, + 0.0, + false, + false, + Some(SURFACE), + ); + (player.pos.y, player.vel_y) + }; + // The seed a `SetHover(true)` picks while SWIMMING (`0x7c6261` → `0xc1118c48`). + (one_step(9.096_748), one_step(0.0)) + }) + .unwrap(); + + let (up_y, up_vy) = launched; + assert!( + up_y > UNDER && up_y < SURFACE, + "the launch keeps rising under its own seed, un-snapped: {up_y}" + ); + assert!( + up_vy > 0.0, + "and keeps its velocity — a clamp to the surface would zero it: {up_vy}" + ); + + let (rest_y, rest_vy) = at_rest; + assert!( + (rest_y - SURFACE).abs() < 1.0e-3, + "a body at rest under the surface is still lifted onto it: {rest_y}" + ); + assert_eq!(rest_vy, 0.0, "and the floor takes its velocity"); + } + + /// **A Levitating walker holds its line over water, moving or still** (decision 1623, B322) — + /// the director's report: *"when I start walking it drop a bit lower and then when I stop it + /// goes up again, over land it's fine somehow."* + /// + /// The "somehow" is the whole bug. On land the election snap finds a collider and places the + /// body at its own reach; over water it found nothing, took the **step-off-a-ledge** leg + /// (`slid.y -= (reach − surface_offset).min(cone_reach)`) and walked the body down a full cone's + /// worth every frame, which the hover climb then hauled back at [`HOVER_CLIMB_RATE`]. The two + /// settle at an equilibrium — measured at `+0.117` against a `+1.000` line, i.e. **0.88 yd low** + /// — and it is speed-dependent, because `cone_reach` is `travel · STEP_SLOPE_RATIO + slack`. + /// Standing still the same leg still bit, just at the slack alone (`1/36` per frame), so the + /// idle float was climbing 0.117 and losing 0.028 forever instead of resting. + /// + /// Both are one cause and it is not the hover: **liquid is queried, never swept, so the snap's + /// `cast_body` is blind to it** — the third site to need the plane handed in after 1611's + /// classify and 1616's climb. A plain water-walker hid it, because with no hover offset the + /// descent lands under the surface and the depenetration push-out undoes it in the same frame. + #[test] + fn a_levitating_walker_holds_its_line_over_water_moving_or_still() { + const SURFACE: f32 = 0.0; + const OPEN: [(f32, f32); 2] = [(-400.0, -40.0), (400.0, -40.0)]; + let (first_rise, settled, walk_low, walk_end, stopped) = world_from_profile(&OPEN) + .world_mut() + .run_system_once(|world: benilla_world::collision::WorldCollision| { + let capsule = player_capsule(); + let mut time = Time::default(); + time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0)); + let mut player = Player { + modes: super::super::state::MoveModes { + water_walking: true, + hover: true, + ..Default::default() + }, + pos: Vec3::new(0.0, SURFACE, 0.0), + ..Default::default() + }; + let run = |player: &mut Player, moving: bool, n: usize| { + let mut low = f32::INFINITY; + for _ in 0..n { + step( + player, + &time, + &world, + &capsule, + moving, + if moving { Vec3::X } else { Vec3::ZERO }, + 7.0, + false, + false, + Some(SURFACE), + ); + low = low.min(player.pos.y); + } + low + }; + run(&mut player, false, 2); + let first_rise = player.pos.y; + run(&mut player, false, 88); + let settled = player.pos.y; + let walk_low = run(&mut player, true, 90); + let walk_end = player.pos.y; + run(&mut player, false, 60); + (first_rise, settled, walk_low, walk_end, player.pos.y) + }) + .unwrap(); + + let line = SURFACE + HOVER_HEIGHT; + // The climb is the rate and nothing else — no per-frame descent quietly eating into it. + assert!( + (first_rise - 2.0 * HOVER_CLIMB_RATE / 60.0).abs() < 1.0e-5, + "two frames of climb are exactly two frames of the rate, with nothing pulling back \ + against them: {first_rise}" + ); + assert!( + (settled - line).abs() < 1.0e-4, + "an idle Levitator rests on the hover line: {settled}" + ); + assert!( + (walk_low - line).abs() < 1.0e-4, + "and does not sag a millimetre while walking — lowest was {walk_low}, want {line}" + ); + assert!( + (walk_end - line).abs() < 1.0e-4, + "still on the line after 90 walking frames: {walk_end}" + ); + assert!( + (stopped - line).abs() < 1.0e-4, + "and stopping is not a rise, because nothing was lost: {stopped}" + ); + } + /// **The Elwynn hillside the director jump-climbed** — a 55° face (`ny = +0.574`, the normal /// the capture read off the real slope at WoW `(-9236.8, -341.9, 101.6)`) rising out of flat /// ground. Too steep to walk by a wide margin, so nothing here may ever end with the body diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index f929ddfc..d75b87cd 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -40,6 +40,25 @@ pub(super) const TURN_RATE: f32 = std::f32::consts::PI; /// 0492). NOT ±π/2 — that clamp belongs to the separate, rate-limited pitch-KEY integrator /// (`0x7c4f80`), whose keys are default-unbound and which we don't bind. pub(super) const MOUSELOOK_PITCH_CLAMP: f32 = 1.553_343; + +/// The **shallowest mover pitch that still lets water walking see the water** (radians) — +/// **VERIFIED** −37.0° = −0.6457718 (`[0x80dfe8] = 0xbf25514d`, read from the image), the third +/// gate of the trace-mask arm `0x6315f0`: +/// +/// ```text +/// 63161e d9 46 20 fld dword ptr [esi + 0x20] ; the mover pitch +/// 631621 d8 1d e8 df 80 fcomp dword ptr [0x80dfe8] ; -37.0 deg +/// 631627 df e0 / f6 c4 41 fnstsw ax ; test ah, 0x41 +/// 63162c 75 06 jne 0x631634 ; ZF form -> skip the arm +/// 63162e 81 cf 00 00 03 or edi, 0x30000 ; liquid layers 0-1 into the mask +/// ``` +/// +/// The emitted `jne` reads **ZF**, so the liquid layers are added only when `ah & 0x41 == 0`, i.e. +/// **pitch strictly greater** than −37°; at exactly −37° the arm is skipped. Aim down past it and +/// the water stops being geometry, which is how a water-walker gets back *into* the water. +/// (`SetPitch 0x7c6f70`'s own complement at `0x7c6fb3` is the standstill half of the same move — +/// see the note in [`super::mover::step`] on why we need no second kick.) Decision 1616. +pub(super) const WATER_WALK_PITCH_FLOOR: f32 = -0.645_771_8; /// Turn-rate scale while also translating (moving/strafing) — the verified `×0.75` (`flags & 0x200f`). pub(super) const TURN_RATE_MOVING: f32 = 0.75; @@ -334,7 +353,9 @@ pub(crate) struct MoveModes { /// decision 1611 read the bytes: `0x6030c0`'s only mode test is `test ah,4` — LEVITATING /// (`0x400`) — and it never looks at `0x10000000` at all. The exclusion runs the other way and /// lives one layer down, in the trace-mask arm at `0x631617`: *swimming* turns water-walking - /// off, not the reverse. So a swimmer who gains the aura keeps swimming, by construction. + /// off, not the reverse. So a swimmer who gains *this* bit keeps swimming, by construction — + /// but a swimmer who casts **Levitate** does not, because Levitate also grants + /// [`hover`](Self::hover), whose handler jumps the body out first (decision 1620). pub(crate) water_walking: bool, /// Feather fall (`SMSG_MOVE_FEATHER_FALL`, `SPELL_AURA_FEATHER_FALL` — Slow Fall, Levitate): the /// fall integrator's terminal velocity drops to [`FEATHER_TERMINAL_VELOCITY`]. Nothing else @@ -345,6 +366,9 @@ pub(crate) struct MoveModes { /// refuses the jump outright — the first test in `CMovement::Jump 0x7c6230`, live for the /// keyboard press ([`super::mover::step`], and the swim breach in [`super::control`]). /// + /// **And the grant itself jumps you** — the one granted mode that moves the body, and the whole + /// of why Levitate looks like a launch ([`Player::hover_launch`], decision 1620). + /// /// **Composed with [`water_walking`](Self::water_walking), those two gates leave a swimmer /// with no way to the surface** (B322's first symptom, decision 1611): water-walking is off /// while SWIMMING, and SWIMMING only clears on the depth compare or on a breach that HOVER @@ -663,6 +687,25 @@ pub(crate) struct Player { /// up and from the cone's descent bound coming down — neither can hold a body that has not just /// proved it is on a surface — and cleared the moment a walkable floor takes over. pub(super) steep_support: bool, + /// **A `SetHover(true)` owes a jump** — the reference's hover wire handler `0x61a620` does not + /// merely set the flag: its enable arm is `61a62e push 0; 61a630 call 0x7c6230`, i.e. + /// `CMovement::Jump(force = 0)`, and only *then* `61a646 call 0x7c7310` sets `0x40000000`. + /// The disable arm is the mirror — `61a637 call 0x7c61c0` (`StartFalling`). Neither of the + /// other two granted modes does anything of the kind: `SetWaterWalk`'s handler `0x61a3d0` is + /// four instructions around `call 0x7c7280`, and `SetFeatherFall`'s `0x61a4e0` only refreshes + /// the fall clamp. **Hover is the one mode that moves the body**, and that is the whole of why + /// Levitate (spell 1706, which grants all three) visibly launches you (decision 1620, B322). + /// + /// `force == 0` is what makes it a *different* jump from the player's: it skips + /// `0x7c623a`'s `test [ecx+0x40], 0x40000000` hover refusal — the wire path exists precisely to + /// be able to jump a unit that is already hovering (wow-re `moveflag-family.md` §4.2). It still + /// takes the ROOT|FALLING refusal at `0x7c625c`, and it still reads SWIMMING at `0x7c6261` to + /// pick the take-off seed. So this is a latch and not a direct write: the reference commits the + /// jump inside the handler because `MOVEFLAG_FALLING` then keeps its resolver off the body, + /// while our mover re-derives contact from probes every frame and would zero the velocity again + /// on the next step. Consumed at the two take-off sites in [`super::control`], where the + /// keyboard jump is consumed, so the seed is picked by the same code that picks it for Space. + pub(super) hover_launch: bool, /// The take-off vertical speed (yd/s, WoW +Z up) snapshotted when the airborne phase began — the /// client's `StartFalling` argument (`+0xa0`, constant per arc) and the `zspeed` we send in the /// jump tail: `JUMP_SPEED` for a jump, **exactly 0** for a step-off (the walk election calls @@ -703,21 +746,46 @@ pub(crate) struct Player { /// zero every depth line collapses to 0 and the avatar swims on dry land. It defaults to /// [`DEFAULT_COLLISION_HEIGHT`] and is replaced once our body's display id resolves. pub(crate) collision_height: crate::entities::CollisionHeight, - /// The **swim pitch** (radians, +up) — the client's persistent per-unit pitch (`CMovement+0x20`, - /// the swim §5's TU-B): **held** when unsteered (an idle floater keeps its pitch — never - /// auto-leveled; the only zeroing writer `0x7c6e80` fires from stop-swim/teleport, not mouse - /// release). ActiveMover by mouselook as a **DIRECT set** of the camera aim pitch, clamped - /// [`MOUSELOOK_PITCH_CLAMP`] (±89°) — **VERIFIED** (the camera-pitch §5, wow-re - /// `swim-camera-pitch.md`, decision 0492, refuting the earlier no-camera-coupling census): - /// the ref's mouse-move event chain lands in `SetPitch 0x7c6f70`, an unconditional store - /// with no integrator and no rate limit, and the basis rebuild re-aims travel in-call — + /// The **mover pitch** (radians, +up) — the client's persistent per-unit pitch + /// (`CMovement+0x20`, the swim §5's TU-B): **held** when unsteered (an idle floater keeps its + /// pitch — never auto-leveled; the only zeroing writer `0x7c6e80` fires from + /// stop-swim/teleport, not mouse release). ActiveMover by mouselook as a **DIRECT set** of the + /// camera aim pitch, clamped [`MOUSELOOK_PITCH_CLAMP`] (±89°) — **VERIFIED** (the camera-pitch + /// §5, wow-re `swim-camera-pitch.md`, decision 0492, refuting the earlier no-camera-coupling + /// census): the ref's mouse-move event chain lands in `SetPitch 0x7c6f70`, an unconditional + /// store with no integrator and no rate limit, and the basis rebuild re-aims travel in-call — /// hence zero lag. The `0x7c4f80` 0.75·turnRate integrator (clamp ±π/2) belongs to the /// PitchUp/Down keys, default-unbound in 1.12, which we don't bind. A left-drag camera /// orbit steers nothing (it doesn't turn the character, so it must not bend the swim); /// Space never touches the pitch (it is the Jump command, 0487). + /// + /// **It is not the *swim* pitch, and the name it used to carry was the bug** (decision 1616, + /// B322): `+0x20` is one field, live in every mode. The mouse path that writes it — + /// `0x514400 → 0x5103e0 → 0x515330 → 0x60de70 → 0x6198a0` — carries **no swim test** at any + /// link (`swim-camera-pitch.md` §5/§7: the unit gate `0x5145e0` is controllability only), and + /// `SetPitch`'s own store at `0x7c6f91` precedes its `test [esi+0x40],0x200000`. Swimming gates + /// only what *reads* it: the travel basis, the body pose, the wire tail. On land it has two + /// other readers, and both are water walking's — the trace-mask arm's third gate + /// (`0x63161e`, pitch > −37°) and `SetPitch`'s own dive-through (`0x7c6fb3`). Steering it only + /// inside the swim branch left both unbuildable, which is why 1611 could only name them. /// Streamed on the wire's swim tail; the body renders pitched by it while swimming fwd/back /// (TU-A's `Ry` law, see the render block in [`super::control`]). - pub(super) swim_pitch: f32, + pub(super) mover_pitch: f32, + /// The camera pitch the **last pitch event** carried — the aim value we most recently pushed + /// into [`Player::mover_pitch`]. Not state of the avatar's; state of the *input*, and it is + /// here because the reference's pitch push is **event-driven, not per-frame**: `SetPitch` is + /// called from the mouse-MOTION handler `0x514400` (input event `0x400500cb`, gated on a held + /// drag button), so a mouse that does not move enqueues nothing at all + /// (`swim-camera-pitch.md`, CADENCE). Our control system has only the already-accumulated + /// camera angle, so "the mouse moved" is spelled here as "the camera aim differs from the one + /// the last push carried". + /// + /// Which is load-bearing rather than pedantic (decision 1616): the other writers of the pitch — + /// the drunk wobble, and StopSwim's zeroing at `0x7c6e80` — are only real if a still mouse + /// leaves their value alone. Re-asserting the camera angle every frame would overwrite the + /// swim-exit levelling on the very next one, and a water-walker who left the water nose-down + /// would fall straight back through the surface. + pub(super) aim_pitch_seen: f32, /// This frame's **flag-scalar swim travel speed** (yd/s) — the directional swim/swimBack /// speed when any swim translation input is live, else 0. The swim stroke's playback-rate /// numerator — **VERIFIED** (the swim-feel §5's TU-I): `0x5fe2f0` divides `GetCurrentSpeed` @@ -765,6 +833,25 @@ pub(super) struct PlayerRide { } impl Player { + /// **Take the jump a `SetHover(true)` owes**, consuming the latch — the reference's + /// `CMovement::Jump(force = 0)` at `0x61a630`, minus the seed select and the commit, which are + /// the caller's because they are shared with Space (decision 1620). + /// + /// The two refusals are `0x7c625c test ah, 0x30` — **ROOT** (`0x1000`) and **FALLING** + /// (`0x2000`), our [`Self::airborne_since`]. The one it does *not* take is the hover refusal at + /// `0x7c623a`, which `force == 0` skips at `0x7c6236`; that is the entire reason this leg + /// exists, since the body it is about to launch was granted hover in the same breath. + /// + /// It is a take-and-clear rather than a level read because the reference's handler fires once + /// per opcode, not once per frame: a `SetHover` that lands on a rooted or already-falling body + /// is *dropped*, not deferred until the refusal lifts (`0x7c6288 xor eax,eax; ret` — the + /// refusal returns failure and nothing retries it). + pub(super) fn take_wire_jump(&mut self) -> bool { + let fire = self.hover_launch && !self.modes.rooted && self.airborne_since.is_none(); + self.hover_launch = false; + fire + } + /// Turn the avatar's **aim** by `radians` — the scripted mouse-turn's one lever /// (`capture::probe_look`, decision 0621). Writing `face_yaw` is deliberately the whole of it: /// from here on this is the identical path a real mouse-turn takes, and it is the same value @@ -1157,8 +1244,8 @@ mod autorun_tests { #[cfg(test)] mod move_mode_tests { use super::{ - incapacitated_flags, MoveModes, FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, - HOVER_HEIGHT, TERMINAL_VELOCITY, + incapacitated_flags, MoveModes, Player, FEATHER_TERMINAL_VELOCITY, GRAVITY, + HOVER_CLIMB_RATE, HOVER_HEIGHT, TERMINAL_VELOCITY, }; use crate::creature_anim::move_flags as f; use benilla_protocol::MoveMode; @@ -1346,6 +1433,47 @@ mod move_mode_tests { ); } + /// **A hover grant owes exactly one jump, and two states eat it** (decision 1620, B322) — the + /// `Jump(force = 0)` at `0x61a630`, refused by `0x7c625c test ah, 0x30` for ROOT and FALLING and + /// by nothing else. In particular NOT by hover itself: `0x7c6236`'s `force == 0` skip is the + /// whole point of the leg, and the body being launched is hovering by construction. + #[test] + fn a_hover_grant_owes_one_jump_and_only_root_or_falling_eats_it() { + let granted = || Player { + hover_launch: true, + modes: MoveModes { + hover: true, + ..Default::default() + }, + ..Default::default() + }; + + let mut p = granted(); + assert!(p.take_wire_jump(), "hover does not refuse its own grant"); + assert!( + !p.take_wire_jump(), + "one opcode, one jump — the latch is consumed, not level-triggered" + ); + + let mut rooted = granted(); + rooted.modes.rooted = true; + assert!(!rooted.take_wire_jump(), "ROOT — `0x7c625c test ah,0x30`"); + assert!( + !rooted.hover_launch, + "a refused Jump returns failure and nothing retries it (`0x7c6288 xor eax,eax`)" + ); + + let mut falling = granted(); + falling.airborne_since = Some(0.0); + assert!( + !falling.take_wire_jump(), + "FALLING — the same test's other bit" + ); + + let mut ungranted = Player::default(); + assert!(!ungranted.take_wire_jump(), "no opcode, no jump"); + } + /// **The two incapacitate suppressions take exactly their own bits** (decision 0880) — the /// difference between a pure root and a stun, which is the whole distinction B179 was drawing, /// expressed on the one word that drives both the animation and the wire. diff --git a/crates/benilla-app/src/player/swim.rs b/crates/benilla-app/src/player/swim.rs index 5263ac98..3a8cc1de 100644 --- a/crates/benilla-app/src/player/swim.rs +++ b/crates/benilla-app/src/player/swim.rs @@ -210,8 +210,10 @@ pub(super) fn update_swimming(player: &mut Player, surface_y: Option, now: if player.modes.levitating { return player.swimming; } + let was = player.swimming; let Some(surface) = surface_y else { player.swimming = false; // not in liquid + stop_pitch(player, was); return false; }; let depth = surface - player.pos.y; @@ -225,9 +227,31 @@ pub(super) fn update_swimming(player: &mut Player, surface_y: Option, now: .is_some_and(|t0| now - t0 < player.jump_zspeed / (2.0 * GRAVITY)); depth > swim_enter_depth(h) && !hop_blocked }; + stop_pitch(player, was); player.swimming } +/// **StopSwim zeroes the mover pitch** — `0x7c6e80` (`and [esi+0x40],0xffdfff3f`, then `+0x54` and +/// `+0x20` to zero), the swim-clear the depth-driven stop reaches through +/// `0x6031eb → 0x60dff0 → 0x61a070` → queue opcode `0x13` → the dispatcher arm `0x61601d`. It is +/// the *only* zeroing writer of [`Player::mover_pitch`] (`swim-camera-pitch.md` (b)): mouse release +/// never levels it, and neither does anything on land. +/// +/// Which matters now that the pitch is land-live and water walking reads it (decision 1616): swim +/// out of a river nose-down, and without this the aim you left the water with would still be +/// pointing past −37° when your feet found the surface, and the water would not hold you. +/// +/// **The breach jump deliberately does not come here.** `CMovement::Jump 0x7c6230` clears SWIMMING +/// through `0x7c61f0` (`and eax,0xfbdfffff` — `0x200000` and `0x4000000` together, plus `0x2000` +/// FALLING), which never touches `+0x20`; only the depth path routes through `0x7c6e80`. So a +/// dolphin-hop keeps its aim and a swim-out levels — and that difference is in the bytes, not a +/// convenience. +fn stop_pitch(player: &mut Player, was: bool) { + if was && !player.swimming { + player.mover_pitch = 0.0; + } +} + /// The **jump out of the water** — the takeoff frame of a jump while swimming (**VERIFIED** /// mechanism, wow-re `swim-mechanism.md` TU-B(f)+TU-F, `0x7c6230`): SWIMMING selects the take-off /// [`SWIM_JUMP_SPEED`] over the land 7.9555, then the handler clears SWIMMING and sets FALLING. @@ -273,7 +297,7 @@ pub(super) struct SwimOutcome { /// `Some` when the rest-line cap redirected the stroke (the surface-swim regime): the /// *effective* travel pitch after the redirect — what the body pose and the wire pitch tail /// present this frame (→0 pinned at the line). `None` when the cap didn't bite (free swim, - /// idle, descending). The raw camera aim stays in [`Player::swim_pitch`] untouched. + /// idle, descending). The raw camera aim stays in [`Player::mover_pitch`] untouched. pub surface_pitch: Option, } @@ -471,6 +495,49 @@ mod tests { } } + /// **StopSwim levels the mover pitch, the breach jump does not** — `0x7c6e80`'s zeroing of + /// `+0x20`, which only the depth-driven stop routes through (decision 1616, B322). It matters + /// because the pitch is land-live and water walking reads it: leave a river nose-down without + /// this and the aim you were swimming with is still past −37° when your feet reach the surface, + /// so the water refuses to hold you. + #[test] + fn leaving_the_water_by_depth_levels_the_pitch_and_a_breach_does_not() { + let deep = swim_enter_depth(HUMAN_MALE) + 1.0; + let mut player = player_at(0.0); + player.mover_pitch = -0.9; + assert!(update_swimming(&mut player, Some(deep), 0.0), "must swim"); + assert_eq!(player.mover_pitch, -0.9, "swimming holds the aim"); + // Still swimming inside the hysteresis band: nothing levels. + let band = swim_exit_depth(HUMAN_MALE) + 0.001; + assert!(update_swimming(&mut player, Some(band), 0.1)); + assert_eq!(player.mover_pitch, -0.9); + // Out through the bottom of the band — StopSwim, and the pitch goes with it. + assert!(!update_swimming(&mut player, Some(0.5), 0.2)); + assert_eq!( + player.mover_pitch, 0.0, + "`0x7c6e80` zeroes +0x20 on StopSwim" + ); + // Leaving the liquid entirely is the same stop (`inLiquid == 0`). + player.mover_pitch = -0.9; + assert!(update_swimming(&mut player, Some(deep), 1.0)); + assert!(!update_swimming(&mut player, None, 1.1)); + assert_eq!(player.mover_pitch, 0.0); + // The breach jump clears SWIMMING through `0x7c61f0`, which never touches `+0x20`: the + // caller drops the latch itself, so the next tick sees no transition and the aim survives. + player.mover_pitch = -0.9; + assert!(update_swimming(&mut player, Some(deep), 2.0)); + player.swimming = false; // what the breach arm in `player::control` does + breach_pitch_survives(&mut player, deep); + assert_eq!(player.mover_pitch, -0.9, "a dolphin-hop keeps its aim"); + } + + /// One tick of the latch after a breach has already cleared it — factored out only so the + /// assertion above reads as one line. + fn breach_pitch_survives(player: &mut Player, depth: f32) { + player.vel_y = 0.0; + update_swimming(player, Some(depth), 2.1); + } + /// The gate's three bits, named — `0x6030c0`'s `js` / `shr 3` / `shr 0xb` tests, which appear /// **twice**: once permitting entry (`0x60310b`) and once preventing the stop (`0x6031ca`). /// PET_IN_COMBAT is the one that reads like a typo and is not: the reference really does let a diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index 0c2fdffb..f23c1a1c 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -357,6 +357,21 @@ pub(super) fn apply_server_moves( player.vel_y = 0.0; player.fall_far = false; } + // **A hover grant jumps you** ([`Player::hover_launch`], decision 1620). The reference's + // handler for this very opcode is `0x61a620`, and setting the flag is its *last* act: + // `61a62c je` splits enable from disable, enable runs `CMovement::Jump(force = 0)` + // (`61a62e push 0; 61a630 call 0x7c6230`) and disable runs `StartFalling` (`61a637 call + // 0x7c61c0`), and only then does `61a646 call 0x7c7310` write `0x40000000`. Casting + // Levitate while swimming therefore launches the body clear of the water — the director's + // report against the reference client, which 1616 had recorded as faithfully inert + // because its census looked inside the resolver and the lift is a layer above it. + // + // The revoke arm needs nothing of ours: `StartFalling` sets FALLING with velocity 0, and + // dropping `hover_offset` to zero already leaves the body a yard over its floor with the + // ground probe out of reach, so the next step falls from rest by itself. + else if m.mode == MoveMode::Hover && m.apply { + player.hover_launch = true; + } let facing = player.face_yaw.rem_euclid(std::f32::consts::TAU); let _ = net_cmds.0.send(ClientCommand::MoveModeAck { guid: m.guid, @@ -550,7 +565,7 @@ fn apply_self_move( player.face_yaw = wrap_pi(player.face_yaw + dyaw); player.model_yaw = wrap_pi(player.model_yaw + dyaw); cam.yaw += dyaw; - player.swim_pitch = m.pitch; + player.mover_pitch = m.pitch; // Riding a deck: `ON_TRANSPORT` is outside the merge mask, so this packet did not deboard us — // it moved us **within** the platform frame. Re-anchor the local pose from the boat's live diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index 894a28c9..2b46dc33 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -155,10 +155,15 @@ pub(super) fn spawn_dressup_booth( root, target: image, baked: None, + snap: None, + shown: false, + show_rev: 0, wake: 0, live: false, pending: Vec::new(), pending_since: None, + pipes_settling: false, + pipes_since: None, aspect: 1.0, rigged: false, parked: false, diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 3904bb6e..a995e68a 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -513,6 +513,9 @@ pub(super) fn spawn_glue_booth( root, target: image, baked: None, + snap: None, + shown: false, + show_rev: 0, // The glue camera is scene-driven, not wake-driven: `gate_booth_cameras` keeps it // active exactly while a glue scene shows (the scene is LIVE — looping animation, // global sequences, particle emitters — so it renders continuously, unlike the stills). @@ -522,6 +525,8 @@ pub(super) fn spawn_glue_booth( live: false, pending: Vec::new(), pending_since: None, + pipes_settling: false, + pipes_since: None, aspect: 1.0, rigged: false, parked: false, diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index ba4fc439..1f710423 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -435,6 +435,15 @@ struct Booth { root: Entity, target: Handle, baked: Option, + /// **Body panes only** — what [`sync_body_booth`] last snapshotted, in place of `baked`. + /// A body pane is a `` widget and re-takes its model on the reference's own + /// four triggers, never on the world moving something ([`SnapKey`]). + snap: Option, + /// The pane was on screen last frame ([`BoothPanes`]) — the edge detector for the widget's + /// C++ show override (`0x505d00`), which re-duplicates on every hidden→visible transition. + shown: bool, + /// How many times that edge has fired. Lives in [`SnapKey::show`]. + show_rev: u32, /// Demand-render window (decision 0540): frames [`gate_booth_cameras`] still keeps this /// booth's camera active. Armed to [`BOOTH_SETTLE_FRAMES`] by every content edge (bake, /// empty, framing/yaw write); 0 with `pending` drained = the camera sleeps and the target @@ -456,6 +465,30 @@ struct Booth { /// closed window. [`gate_booth_cameras`] releases the hold at [`PENDING_LANDING_SECS`] /// with a warn — the still keeps whatever did land. pending_since: Option, + /// **This bake has not yet been drawn with every pipeline compiled** — the [`Booth::pending`] + /// law, one level further down the stack (report B331). + /// + /// `pending` asks whether a texture is resident in `Assets`; that is a *main-world* + /// question, and it is not the whole of "did this draw appear". Off macOS Bevy builds each + /// pipeline variant on the async pool, and a batch whose variant is still building is + /// **skipped** — silently, with no error and no missing asset ([`PipeWatch::compiling`] has + /// the byte references). A live view redraws it a few frames later and nobody ever sees it. + /// A one-shot portrait bake does not: [`BOOTH_SETTLE_FRAMES`] elapse, the camera sleeps, and + /// the still keeps the hole for the rest of the session — which is exactly the report + /// ("*sometimes hair missing, sometimes face, totally random*"): hair is the alpha-key + /// pipeline, the body the opaque one, the eye-glow card the additive one, and which of the + /// three had landed by the fourth frame is a race. It is invisible on macOS, where the same + /// compile `block_on`s the render thread instead: the whole class exists on the reporters' + /// machines and on no screen we can look at. Decision 1621. + /// + /// Set by [`wake_booth`]; [`gate_booth_cameras`] holds the camera awake while the cache is + /// draining and then spends one final rendered frame, exactly as it does for `pending`. + pipes_settling: bool, + /// When the current [`Booth::pipes_settling`] hold began (wall secs) — `None` while it is + /// clear. Bounded like [`Booth::pending_since`] and for the same reason: a session that keeps + /// meeting new variants (walking a city) keeps the cache non-empty, and an unbounded hold + /// would pin a 256² camera rendering behind it. + pipes_since: Option, /// The **destination pane's** aspect this booth's camera is currently framed for /// ([`framing::WowPortraitProjection::aspect`], decision 1069) — 1.0 until the UI has drawn the /// pane once, then sticky: a hidden window must not re-frame the bake back to square. @@ -579,6 +612,14 @@ pub(crate) struct StageRig; /// still, while the old unbounded hold cost a forever-rendering camera. const PENDING_LANDING_SECS: f64 = 10.0; +/// How long a [`Booth::pipes_settling`] hold may keep the camera awake (wall secs) before it is +/// spent anyway ([`Booth::pipes_since`]). The pipeline cache always drains *eventually* — every +/// build settles `Ok` or `Err` — but it is a **process-global** counter, so a session that keeps +/// meeting new variants (walking into a new city, the first cast of every spell) can hold it +/// non-empty for a long stretch. Generous, because the cost of waiting is one 256² camera and the +/// cost of releasing early is the wrong face on the screen for the rest of the session. +const PIPELINE_SETTLING_SECS: f64 = 15.0; + /// `WOW_BOOTH_LOG=1` — is the booth instrument armed? (Read once.) fn booth_log() -> bool { static LOG: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -743,6 +784,11 @@ fn wake_booth<'a>( .collect(); // A fresh hold gets a fresh clock ([`Booth::pending_since`]) — the gate stamps it. booth.pending_since = None; + // …and a fresh bake owes a pipeline settle ([`Booth::pipes_settling`]): the variants this + // bake's materials specialize to are only queued when the render world first sees them, so + // the gate cannot judge this until the settle window has otherwise drained. + booth.pipes_settling = true; + booth.pipes_since = None; } #[derive(Resource, Default)] @@ -936,6 +982,11 @@ impl Plugin for PortraitPlugin { .add_systems( Update, ( + // First: the model-changed producers this frame, so every pane below reads + // one revision. Its writes are `Commands`, so a bump reaches the panes on the + // NEXT frame — which is the reference's own latency, an event queued into + // `0x524cd0`'s per-frame drain and consumed by Lua a round-trip later. + bump_model_revision, test_bake::sync_test_portraits, sync_portraits, sync_paperdoll, @@ -1150,10 +1201,15 @@ fn setup_booths( root, target: image, baked: None, + snap: None, + shown: false, + show_rev: 0, wake: 0, live: false, pending: Vec::new(), pending_since: None, + pipes_settling: false, + pipes_since: None, aspect: 1.0, rigged: false, parked: false, @@ -1224,10 +1280,15 @@ fn setup_booths( root, target: image, baked: None, + snap: None, + shown: false, + show_rev: 0, wake: 0, live: false, pending: Vec::new(), pending_since: None, + pipes_settling: false, + pipes_since: None, aspect: 1.0, rigged: false, parked: false, @@ -1249,6 +1310,164 @@ fn test_mode(cached: &mut Option) -> bool { *cached.get_or_insert_with(|| std::env::var("WOW_PORTRAIT_TEST").is_ok_and(|s| !s.is_empty())) } +/// benilla's **`UNIT_MODEL_CHANGED`**, for the two producers that are not a change of dress +/// ([`crate::entities::DressKey`] is the rest). One counter per unit; a body pane re-takes its +/// snapshot when it moves ([`SnapKey`]). +/// +/// - **The manual sheath ceremony's own keyframe.** `SetSheatheState(…, bInstant = 0)` — which +/// only `ToggleSheath` passes, on all three legs — runs the ceremony, and the clip's `$SHL`/ +/// `$SHR` event marks the unit **unconditionally** (`0x611b60` → `0x5ffb10`, the mark at +/// `0x5ffbbe`, a function with one `ret`). So pressing Z moves an open character sheet's doll, +/// one Lua round-trip later. Every *snap* sheath change — the combat auto-draw, the stand-state +/// stow, the descriptor apply — takes `bInstant != 0` and reaches the queue only through the +/// enchant-gated `0x5eed50`, which is why drawing a bow on a mob does **not** move the doll. +/// That split is `#bugs` B324. +/// - **An item's glow instances landing** — ours, not the reference's. Its widget duplicates a +/// model the world had already finished building; our `ItemVisuals` models stream in, and a key +/// blind to their arrival would leave a permanently-glowing weapon glowing nothing in the +/// character window (the case [`LookKey`]'s effects term was added for). +#[derive(Component, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct ModelRevision(pub(crate) u32); + +/// Bump [`ModelRevision`] for every unit one of its producers fired on this frame. +fn bump_model_revision( + mut commands: Commands, + mut swaps: MessageReader, + glows: Query<&benilla_world::model_fade::ParentModel, Added>, + revs: Query<&ModelRevision>, +) { + let mut bumped = >::default(); + let units = swaps + .read() + .map(|m| m.entity) + // A glow's root is chained to the WEARER (`spawn_slot` stamps `ParentModel(wearer)`), + // which is the unit whose model just gained geometry. + .chain(glows.iter().map(|p| p.0)); + for unit in units { + if !bumped.insert(unit) { + continue; + } + let next = revs.get(unit).map_or(0, |r| r.0).wrapping_add(1); + commands.entity(unit).try_insert(ModelRevision(next)); + } +} + +/// The attach ids the **sheath lane owns** — the hand points a weapon is drawn into, the forearm a +/// shield takes, and the six sheath points plus the shield's back slot it is stowed at. A draw or +/// a stow moves an item among these, and for two kinds of item makes it vanish outright: a +/// **ranged** weapon renders nothing while stowed (`0x611770` detaches it and never re-attaches — +/// `ranged-sheath-display.md`), and so does a melee weapon whose `SheatheType` is 0. The worn +/// quiver rides `0x1a` on the same gate. +/// +/// [`SnapKey`] is blind to every one of them, which is the whole mechanism of `#bugs` B324: a +/// widget's duplicate must not notice that the world drew a weapon. +fn sheath_lane(attach: Option) -> bool { + matches!(attach, Some(0..=2 | 26..=28 | 30..=33)) +} + +/// **What a body pane re-takes its snapshot on** — the reference's re-`SetUnit` set, as content. +/// +/// A `` does not mirror the unit: it **duplicates** the unit's `CM2Model` once and +/// renders the copy (`0x5059a0` → `0x707400`), and the copy is dead to everything the world does +/// afterwards — `[dup+0x34]` holds the source only long enough to build, and is Released and +/// nulled inside `0x707400` itself (wow-re `ui/scratch/paperdoll-liveness-law.md`, §5 verified). +/// Ours mirrored the live tree every frame, so a bow drawn in combat walked straight onto the +/// character sheet. +/// +/// The copy is re-taken on exactly four things, and each is a field here: +/// +/// 1. **The pane becoming visible** ([`Self::show`]). Not a Lua event at all — `0x505d00` is the +/// widget's effective-visible SHOW override (primary vtable index 34) and re-duplicates on +/// every hidden→visible edge; index 33 `0x505ce0` destroys the model on hide. This is why +/// `PaperDollFrame_OnShow` calls no `SetUnit` and the doll still tracks your gear. +/// 2. **A change of dress** ([`Self::dress`], [`Self::stable`]) — `UNIT_MODEL_CHANGED`, whose one +/// fire site image-wide is `0x524df1`. Equipping or unequipping a visible item marks the unit +/// unconditionally (`0x5e2810` → `0x5dee30`); so do a displayId change, a helm/cloak toggle and +/// an enchant. **Nocked ammo does not** (`0x60ba30` reaches no queue site), and neither does +/// entering combat. +/// 3. **An explicit model event** ([`Self::rev`]) — the manual sheath ceremony's own `$SHL`/`$SHR` +/// keyframe, and our late-arriving item glows. See [`ModelRevision`]. +/// 4. **A resize** ([`Self::aspect_bits`]) — `DISPLAY_SIZE_CHANGED` → `RefreshUnit()`, the one +/// re-take the FrameXML does register, and the reason the widget re-snapshots its camera (1069). +#[derive(PartialEq, Eq)] +struct SnapKey { + /// A different body is a different `SetUnit`. + unit: Entity, + /// The mirrored geometry **no sheath change can move**: the unit's own body parts — which + /// carry the armour composite, and the reference shares that texture object by pointer, so a + /// re-blit is meant to reach an open doll — plus every rider and card outside + /// [`sheath_lane`]: the helm, the pauldrons, their glows and their emitters. + stable: Vec<(AssetId, AssetId)>, + /// The same cut over the effect seats [`LookKey`] keys on, for the same reason. + stable_fx: Vec<(u16, [u32; 3], usize)>, + /// The three weapon slots, resolved **above** the placement gate — the only lane whose sheath + /// state can decide whether an item exists at all, and so the one that cannot be read off the + /// mirrored tree ([`crate::entities::DressKey`]). + dress: Option, + rev: u32, + show: u32, + aspect_bits: u32, +} + +impl SnapKey { + /// Build the key for `unit`'s pane this frame. `dress`/`rev` are the unit's own components + /// (absent until its equipment first resolves, which is simply another value); `show` and + /// `aspect` are the booth's. + #[allow(clippy::too_many_arguments)] + fn build( + unit: Entity, + parts: &[&PortraitPart], + riders: &[&PortraitRider], + billboards: &[&PortraitBillboard], + effects: &[&PortraitEffects], + dress: Option, + rev: u32, + show: u32, + aspect: f32, + ) -> Self { + // Sorted, so the key cannot move for a reason as incidental as traversal order: adding or + // removing a sheath-lane child re-orders the siblings popped around it. + let mut stable: Vec<(AssetId, AssetId)> = parts + .iter() + .map(|p| (p.static_mesh.id(), p.material.id())) + .chain( + riders + .iter() + .filter(|r| !sheath_lane(r.attach)) + .map(|r| (r.static_mesh.id(), r.material.id())), + ) + .chain( + billboards + .iter() + .filter(|b| !sheath_lane(b.attach)) + .map(|b| (b.mesh.id(), b.material.id())), + ) + .collect(); + stable.sort_unstable(); + let mut stable_fx: Vec<(u16, [u32; 3], usize)> = effects + .iter() + .filter(|f| !sheath_lane(f.attach)) + .map(|e| { + ( + e.bone, + e.offset.to_array().map(f32::to_bits), + e.emitters.len(), + ) + }) + .collect(); + stable_fx.sort_unstable(); + SnapKey { + unit, + stable, + stable_fx, + dress, + rev, + show, + aspect_bits: aspect.to_bits(), + } + } +} + /// The reference's **attach reset** — does `0x47a230` detach a sub-model hanging at this M2 /// attachment id from the duplicate a model widget renders? /// @@ -1311,6 +1530,11 @@ struct DressedLook<'w, 's> { billboards: Query<'w, 's, &'static PortraitBillboard>, effects: Query<'w, 's, &'static PortraitEffects>, mounts: Query<'w, 's, (), With>, + /// The two re-snapshot inputs a body pane cannot read off the mirrored tree ([`SnapKey`]). + /// They ride here because this is already the "what does this unit look like" param, and + /// because all three body-pane systems were at Bevy's 16-parameter ceiling. + dress: Query<'w, 's, &'static crate::entities::DressKey>, + revs: Query<'w, 's, &'static ModelRevision>, } impl DressedLook<'_, '_> { @@ -1374,6 +1598,15 @@ impl DressedLook<'_, '_> { } (parts, riders, billboards, effects) } + + /// `unit`'s dress and model revision — `None`/`0` before its equipment has first resolved, + /// which is simply another key value and re-snapshots when it lands. + fn snapshot_inputs(&self, unit: Entity) -> (Option, u32) { + ( + self.dress.get(unit).ok().copied(), + self.revs.get(unit).map_or(0, |r| r.0), + ) + } } /// Each frame: for every slot, mirror the unit's **live dressed look** — its attach-spawned @@ -1661,10 +1894,16 @@ fn sync_portraits( /// 0208 §5), and the model root spins to the pane's [`PaperDollBooth::yaw`] (the ref's /// `Model:SetRotation`). /// -/// **What re-bakes.** A parts-key change respawns the posed instance and re-aims the (yaw- +/// **What re-bakes.** A [`SnapKey`] change respawns the posed instance and re-aims the (yaw- /// independent) camera; a bare yaw change only re-rotates the root — neither happens on an unchanged /// frame. The bake stands ready whether or not the window is open, but the 512² *pass* only runs /// while the pane is being drawn ([`BoothPanes`], decision 1069). +/// +/// The key is **not** the mirrored geometry. A `` duplicates the unit's model once and +/// renders a copy the world can no longer reach, and it re-takes that copy on four things — the +/// pane showing, a change of dress, an explicit model event, a resize. Mirroring live put a bow +/// drawn in combat straight onto the character sheet (`#bugs` B324); [`SnapKey`] carries the whole +/// law and its byte provenance. #[allow(clippy::too_many_arguments)] fn sync_paperdoll( mut commands: Commands, @@ -1827,6 +2066,19 @@ fn sync_body_booth( // publishes nothing, and re-framing the standing bake back to square on the way out would be a // visible pop on the way back in. let aspect = panes.0.get(slot).copied().unwrap_or(booth.aspect); + // **The show edge** — the widget's own re-take (`0x505d00`, the effective-visible SHOW + // override at primary vtable index 34, which re-duplicates the unit's model on every + // hidden→visible transition; index 33 destroys it on hide). `BoothPanes` publishes a slot + // exactly while its pane is being drawn, so its rising edge is that transition. + // + // We re-take rather than destroy-and-rebuild: the two are indistinguishable on screen (nothing + // is drawn in between) and keeping the bake avoids the re-frame pop 1069's aspect latch exists + // to prevent. + let on_screen = panes.0.contains_key(slot); + if on_screen && !booth.shown { + booth.show_rev = booth.show_rev.wrapping_add(1); + } + booth.shown = on_screen; // There is no 2D stand-in for a body pane — the bridge always points at the live target (an // empty booth just renders the dark backdrop until the unit's model attaches). let live = PortraitSource::Live(booth.target.clone()); @@ -1842,9 +2094,9 @@ fn sync_body_booth( if parts.is_empty() { // No unit / model not attached → empty the booth and forget the applied yaw (so it // re-applies on the next bake). - if booth.baked.is_some() { + if booth.snap.is_some() { commands.entity(booth.root).despawn_related::(); - booth.baked = None; + booth.snap = None; *last_pose = None; // Render the emptied stage before sleeping (decision 0540) — and the emptied stage has // no emitters left, so the pane stops being live. @@ -1860,7 +2112,18 @@ fn sync_body_booth( return; } let unit = unit.expect("unit present — parts came from its descendants"); - let key = LookKey::build(&parts, &riders, &billboards, &effects); + let (dress, rev) = look.snapshot_inputs(unit); + let key = SnapKey::build( + unit, + &parts, + &riders, + &billboards, + &effects, + dress, + rev, + booth.show_rev, + aspect, + ); let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id); // Anchors first, before any teardown — a still-loading display must not be framed from // fabricated zero bounds (see the portrait site, and `booth_anchors`). Resolved out here rather @@ -1875,7 +2138,10 @@ fn sync_body_booth( .map_or(1.0, |a| framing::pane_root_scale(a, display_aspect)); // A changed pane aspect re-runs the same path: the camera's projection depends on it, and it // only ever moves once — the first frame the window is drawn. - let parts_changed = booth.baked.as_ref() != Some(&key) || booth.aspect != aspect; + // **The snapshot compare** — the whole of `#bugs` B324. This used to be `LookKey`, the + // mirrored geometry, which moved the instant the world drew a weapon. It is now the + // reference's own re-`SetUnit` set, and a draw is not in it. + let parts_changed = booth.snap.as_ref() != Some(&key); if parts_changed { booth.aspect = aspect; let Some(anchors) = anchors_now else { @@ -1931,7 +2197,7 @@ fn sync_body_booth( }) .collect(); // Same law as the portrait bake: never latch a world-lane material into the pane. Leave - // `booth.baked` alone and retry next frame (see the portrait site for the full note). + // `booth.snap` alone and retry next frame (see the portrait site for the full note). if booth_light.pane.take_unready() { booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); return; @@ -1995,7 +2261,7 @@ fn sync_body_booth( .chain(booth_riders.iter().map(|r| &r.material)) .chain(booth_billboards.iter().map(|b| &b.material)), ); - booth.baked = Some(key); + booth.snap = Some(key); } // The model root: **yaw → rotation, plus the pane's model scale** — the widget's own // `T(pos)·R(facing)·S(s)` with `pos` at the origin (the ref's `Model:SetRotation` writes the @@ -2031,6 +2297,42 @@ fn sync_body_booth( } } +/// What one frame owes a booth's **pipeline settle** ([`Booth::pipes_settling`]) — the decision +/// alone, so the law is testable without standing a render world up around it. +#[derive(PartialEq, Eq, Debug, Clone, Copy)] +enum PipeSettle { + /// The render world is still building variants, so it is still dropping draws: keep the + /// camera awake rather than freeze a still with a hole in it. + Hold, + /// Drained. Spend the settle on one final rendered frame — the same "one more frame" a + /// landed [`Booth::pending`] texture buys, and for the same reason. + Spend, + /// Nothing owed *yet*: the cache reads idle, but this bake's own [`BOOTH_SETTLE_FRAMES`] + /// window has not drained, so the reading cannot be trusted about it. + Idle, + /// [`PIPELINE_SETTLING_SECS`] ran out — spend it anyway, loudly. + Expired, +} + +/// The settle's one rule, and the ±1-frame race it exists to survive. +/// +/// A bake's pipeline variants are queued by the **render** world, the frame after the main world +/// spawned its meshes, and `PipeWatch`'s counters cross back a frame later still. So an idle +/// reading taken at the moment of the bake is a reading of the cache *before* this bake existed — +/// which is why `Spend` waits for `wake_drained`: by the time [`BOOTH_SETTLE_FRAMES`] have gone +/// by, whatever this bake queued is counted. +fn pipe_settle(compiling: bool, wake_drained: bool, held_for: f64) -> PipeSettle { + if held_for > PIPELINE_SETTLING_SECS { + PipeSettle::Expired + } else if compiling { + PipeSettle::Hold + } else if wake_drained { + PipeSettle::Spend + } else { + PipeSettle::Idle + } +} + /// The demand-render gate (decision 0540): each booth camera is active only while its booth has /// something new to show — [`Booth::wake`] frames after a content edge, or a bake texture still /// in flight ([`Booth::pending`]) — except the booths whose content is **live**, which render @@ -2055,6 +2357,9 @@ fn gate_booth_cameras( panes: Res, images: Res>, warm: Res, + // The pipeline settle's input ([`Booth::pipes_settling`]) — is the render world still + // building variants, i.e. is it still dropping draws on the floor. + pipes: Res, time: Res>, mut cams: Query<( Entity, @@ -2104,6 +2409,36 @@ fn gate_booth_cameras( booth.wake = booth.wake.max(1); } } + // The **pipeline settle** ([`Booth::pipes_settling`]), the pending hold's twin one level + // down: a batch whose pipeline variant is still being built draws NOTHING and says + // nothing about it, so a still committed inside a compile burst keeps the hole. Judged + // only once the settle window has otherwise drained — the variants this bake needs are + // queued by the RENDER world, and these counters cross back ±1 frame, so an idle reading + // taken the frame of the bake is a reading of the frame before it. + let settling = if booth.pipes_settling { + let now = time.elapsed_secs_f64(); + let since = *booth.pipes_since.get_or_insert(now); + match pipe_settle(pipes.compiling(), booth.wake == 0, now - since) { + PipeSettle::Hold => true, + PipeSettle::Idle => false, + spent => { + if spent == PipeSettle::Expired { + warn!( + "booth {}: pipelines still compiling after \ + {PIPELINE_SETTLING_SECS:.0}s — spending the settle with the still \ + as-is", + token.as_str(), + ); + } + booth.pipes_settling = false; + booth.wake = booth.wake.max(1); + false + } + } + } else { + booth.pipes_since = None; + false + }; let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some(); // A live bake renders every frame — but only while the UI is actually drawing its pane. // (The glue screens sample their booth outside the FrameXML extract, so they publish no @@ -2114,7 +2449,8 @@ fn gate_booth_cameras( || live_scene || live_pane || booth.wake > 0 - || !booth.pending.is_empty(); + || !booth.pending.is_empty() + || settling; // Half-rate (decision 1444, [`PaneRate`]): when the live pane is the ONLY thing keeping // this camera rendering — no wake window settling a fresh bake, no pending texture hold, // no fullscreen glue scene — skip every other frame. `active` stays the LOGICAL state: @@ -2132,7 +2468,8 @@ fn gate_booth_cameras( && live_pane && !(test || warming || live_scene) && booth.wake == 0 - && booth.pending.is_empty(); + && booth.pending.is_empty() + && !settling; let throttled = paced && frames.0 % 2 == 1; let render = active && !throttled; if was_paced != paced { @@ -2153,13 +2490,15 @@ fn gate_booth_cameras( && (cam.is_active != render || active) { eprintln!( - "[booth] t={:7.2} {} active={} render={} wake={} pending={} marker={}", + "[booth] t={:7.2} {} active={} render={} wake={} pending={} settling={} \ + marker={}", time.elapsed_secs(), token.as_str(), active, render, booth.wake, booth.pending.len(), + settling, markers.contains(booth.root), ); } @@ -2258,6 +2597,7 @@ fn aim( #[cfg(test)] mod tests { use super::*; + use crate::entities::ItemModelKind; fn body_part() -> PortraitPart { PortraitPart { @@ -2427,6 +2767,160 @@ mod tests { ); } + /// A distinct mesh/material pair, so a fixture's identity is its own. + fn mesh(n: u64) -> Handle { + Handle::Uuid( + bevy::asset::uuid::Uuid::from_u128(0xb324_0000 | u128::from(n)), + std::marker::PhantomData, + ) + } + fn mat(n: u64) -> Handle { + Handle::Uuid( + bevy::asset::uuid::Uuid::from_u128(0xb324_1000 | u128::from(n)), + std::marker::PhantomData, + ) + } + + /// A rider at `attach` with its own geometry. + fn geom_rider(attach: u16, n: u64) -> PortraitRider { + PortraitRider { + static_mesh: mesh(n), + material: mat(n), + bone: 4, + offset: Vec3::ZERO, + attach: Some(attach), + } + } + + fn dress_of(held: [Option<(u32, ItemModelKind, i32)>; 3]) -> crate::entities::DressKey { + crate::entities::DressKey { + display_id: Some(57), + held, + held_ready: true, + } + } + + /// The three-slot dress of a hunter carrying a bow — unchanged by drawing it, because + /// [`crate::entities::DressKey`] is read above the placement gate. + fn hunter() -> crate::entities::DressKey { + dress_of([None, None, Some((3026, ItemModelKind::Weapon, 0))]) + } + + fn key( + riders: &[PortraitRider], + dress: crate::entities::DressKey, + rev: u32, + show: u32, + ) -> SnapKey { + let body = [body_part()]; + let body: Vec<&PortraitPart> = body.iter().collect(); + let riders: Vec<&PortraitRider> = riders.iter().collect(); + SnapKey::build( + Entity::PLACEHOLDER, + &body, + &riders, + &[], + &[], + Some(dress), + rev, + show, + 1.0, + ) + } + + /// **`#bugs` B324, the half that removes the bow.** In combat the auto-draw is a *snap* + /// (`SetSheatheState(…, bInstant != 0)`), which reaches `UNIT_MODEL_CHANGED`'s one fire site + /// only through the enchant-gated `0x5eed50` — so the reference's doll never hears about it. + /// Ours re-baked on the mirrored geometry, and a drawn bow IS mirrored geometry. + /// + /// The bow appears from nothing (a stowed ranged weapon renders nothing at all), so this is + /// not a matter of ignoring a moved rider: the key has to be blind to the hand points, and to + /// know the bow exists from somewhere else. That somewhere is the dress. + #[test] + fn drawing_a_bow_in_combat_does_not_re_snapshot_the_pane() { + use crate::entities::attach_id::HAND_LEFT; + let bow = [geom_rider(HAND_LEFT, 1)]; + let stowed = key(&[], hunter(), 0, 1); + let drawn = key(&bow, hunter(), 0, 1); + assert!( + stowed == drawn, + "the world drew the bow; the widget's duplicate must not notice" + ); + + // **The control, and the defect as a number.** The pane used to key on [`LookKey`] — the + // mirrored geometry — and that is exactly what a draw moves. Without this the test above + // asserts only that the new key ignores an input, never that the old one did not. + let body = [body_part()]; + let body: Vec<&PortraitPart> = body.iter().collect(); + let bow: Vec<&PortraitRider> = bow.iter().collect(); + assert!( + LookKey::build(&body, &[], &[], &[]) != LookKey::build(&body, &bow, &[], &[]), + "the pre-1616 key moved on the draw — which is the bug" + ); + } + + /// The melee twin: a stow does not delete the weapon, it re-parents it (0826) — same handles, + /// a hand point for a hip point. Both ends are in the sheath lane, so neither is in the key. + #[test] + fn stowing_a_sword_does_not_re_snapshot_the_pane() { + use crate::entities::attach_id::{HAND_RIGHT, HIP_MAIN}; + let sword = dress_of([Some((1234, ItemModelKind::Weapon, 0)), None, None]); + let drawn = key(&[geom_rider(HAND_RIGHT, 7)], sword, 0, 1); + let hipped = key(&[geom_rider(HIP_MAIN, 7)], sword, 0, 1); + assert!(drawn == hipped); + } + + /// …but the **manual** toggle does. `ToggleSheath` passes `bInstant = 0` on all three legs, + /// which runs the ceremony, whose `$SHL`/`$SHR` keyframe marks the unit unconditionally + /// (`0x5ffb10`, the mark at `0x5ffbbe`). benilla fires `SheathSwapMessage` at exactly that + /// keyframe, and [`bump_model_revision`] turns it into a re-take. The director confirmed the + /// observable: with the sheet open, Z moves the doll. + #[test] + fn the_manual_sheath_ceremony_re_snapshots_the_pane() { + use crate::entities::attach_id::HAND_LEFT; + let before = key(&[], hunter(), 0, 1); + let after = key(&[geom_rider(HAND_LEFT, 1)], hunter(), 1, 1); + assert!( + before != after, + "the ceremony's own keyframe is a model event" + ); + } + + /// Opening the window re-takes the model — the C++ show override `0x505d00`, not a Lua event + /// (`PaperDollFrame_OnShow` calls no `SetUnit` at all). So a weapon drawn while the sheet was + /// shut IS on the doll the next time you open it. + #[test] + fn showing_the_pane_re_snapshots_it() { + use crate::entities::attach_id::HAND_LEFT; + let open_stowed = key(&[], hunter(), 0, 1); + let reopened_drawn = key(&[geom_rider(HAND_LEFT, 1)], hunter(), 0, 2); + assert!(open_stowed != reopened_drawn); + } + + /// Equipping is `UNIT_MODEL_CHANGED`'s unconditional producer (`0x5e2810` → `0x5dee30` → + /// `0x5df119`), and it must reach the pane through the dress even when the new weapon is + /// **stowed** — where it adds no mirrored geometry whatsoever. + #[test] + fn equipping_a_stowed_weapon_still_re_snapshots_the_pane() { + let empty = key(&[], dress_of([None, None, None]), 0, 1); + let armed = key(&[], hunter(), 0, 1); + assert!( + empty != armed, + "the dress moved even though nothing is drawn" + ); + } + + /// And the gear that cannot move with a sheath is keyed straight off the mirrored tree, so a + /// helm arriving — or the armour composite re-blitting, which the reference shares by pointer + /// and means to show on an open doll — re-takes without any event plumbing. + #[test] + fn a_helm_arriving_re_snapshots_the_pane() { + use crate::entities::attach_id::HELM; + let bare = key(&[], hunter(), 0, 1); + let helmed = key(&[geom_rider(HELM, 9)], hunter(), 0, 1); + assert!(bare != helmed); + } + /// **`#bugs` B324 — the nocked arrow reached the character-window doll.** A hunter shooting in /// combat has a bow at HandLeft(2) and an arrow at HandArrow(0x23); our booths mirrored both, /// and the reference's doll can never draw the arrow: every model widget duplicates the unit's @@ -2578,4 +3072,47 @@ mod tests { "Interface\\CharacterFrame\\TemporaryPortrait.blp", ); } + + /// **A still is never committed while the render world is still building pipelines** — + /// report B331, the player's own portrait baking with the face (or the hair, or the shoulder) + /// simply absent, "totally random", on the reporter's Windows machine. + /// + /// The mechanism is not a missing asset and never shows up as one: off macOS Bevy builds each + /// pipeline variant on the async pool, and `SetItemPipeline` answers a not-yet-built variant + /// with `Skip` — the batch draws nothing, silently. Every live view redraws it a few frames + /// later; a one-shot bake sleeps after [`BOOTH_SETTLE_FRAMES`] and keeps the hole for the + /// session. Which batches lose the race is which variants were cold, which is why one bake + /// loses the hair (the alpha-key pipeline) and the next the face (the opaque one). + #[test] + fn a_bake_holds_its_camera_awake_while_pipelines_are_still_building() { + assert_eq!(pipe_settle(true, true, 0.0), PipeSettle::Hold); + assert_eq!(pipe_settle(true, false, 0.0), PipeSettle::Hold); + } + + /// **An idle cache does not spend the settle until the bake's own window has drained.** The + /// counters cross worlds a frame behind, and the variants a bake needs are only queued once + /// the render world has seen its meshes — so "the cache is idle" read on the frame of the + /// bake is a fact about the frame *before* it. Spending there would restore exactly the bug. + #[test] + fn an_idle_reading_before_the_settle_window_drains_decides_nothing() { + assert_eq!(pipe_settle(false, false, 0.0), PipeSettle::Idle); + assert_eq!(pipe_settle(false, true, 0.0), PipeSettle::Spend); + } + + /// The bound (the [`Booth::pipes_since`] doc): the cache is process-global, so a session that + /// keeps meeting new variants can hold it non-empty indefinitely. The hold is released loudly + /// rather than pinning a camera rendering for the rest of the session — the same shape, and + /// the same reasoning, as [`PENDING_LANDING_SECS`]. + #[test] + fn the_settle_is_bounded_even_while_the_cache_keeps_filling() { + assert_eq!( + pipe_settle(true, false, PIPELINE_SETTLING_SECS + 0.1), + PipeSettle::Expired, + ); + // …and the bound outranks the hold, or a busy cache would never reach it. + assert_eq!( + pipe_settle(true, true, PIPELINE_SETTLING_SECS + 0.1), + PipeSettle::Expired, + ); + } } diff --git a/crates/benilla-world/src/frame_pace.rs b/crates/benilla-world/src/frame_pace.rs new file mode 100644 index 00000000..e927510f --- /dev/null +++ b/crates/benilla-world/src/frame_pace.rs @@ -0,0 +1,316 @@ +//! **Frame pacing** — the clock we animate on follows the *presentation* cadence, not the CPU loop. +//! +//! Under `PresentMode::Fifo` (our default — `video::present_mode`) a frame is displayed only on a +//! refresh boundary, so the interval the eye receives is always an exact multiple of the monitor's +//! period. The interval we *measure* is not: Bevy stamps `Time` once per main-schedule pass, +//! and with pipelined rendering the main thread's wait for the render thread is where vsync +//! back-pressure lands. That wait moves around, so two frames shown 16.67 ms apart can be measured +//! as 26.4 ms and 8.0 ms. Their sum is right; the split is not. +//! +//! Advancing an animation by that split samples the pose at instants the display never shows: the +//! model lurches ahead by the long delta, then barely moves on the short one. It is a rigid, +//! whole-skeleton motion — every bone at once — which is exactly how it was reported ("a lot of +//! jitter on the whole model", standing still, hands off the mouse), and why it is invisible until +//! you are zoomed close enough for a fraction of a millimetre to be a pixel. +//! +//! **Measured at the Elwynn guard pin** (`.go xyz -9481.53 76.10 56.57 0`, 3200×1800, camera +//! parked, no input), 1805 frames of steady state: +//! +//! - 77 frames (4.3%) had a delta more than 3 ms off the 16.663 ms median. +//! - **27 of them were long/short PAIRS summing to an exact multiple of the refresh** — (26.4, 8.0), +//! (25.3, 8.0), (20.2, 13.2), (25.9, 7.1) — i.e. both frames were presented on cadence and only +//! the timestamps were split wrong. Just 3 were genuine dropped frames. +//! - 91% of 10-frame windows summed to within 2 ms of 166.7 ms: presentation stayed locked. +//! - The median bone's Δ tracked `dt` proportionally (`Δ/dt` constant to three digits), so the pose +//! is *correct for the time it is handed*. The time is what is wrong. +//! - Whole-skeleton events (up to 91 of 117 bones past a quarter-pixel in one frame) landed on +//! those frames **95%** of the time: `P(twitch | bad delta) = 0.26` against `0.0006` otherwise. +//! +//! **What this does.** When the recent deltas look like a vsync cadence, the raw delta is snapped to +//! the nearest whole multiple of the estimated refresh period and the remainder is carried into the +//! next frame, so the long-run elapsed time still matches the wall clock exactly — no time is +//! invented and none is lost. When the cadence does not look like vsync (uncapped, a genuine hitch, +//! a load spike) the raw delta passes through untouched: pacing must never paper over a real stall, +//! only correct a mis-split one. +//! +//! **Why `Time` and not `Time`.** `Real` is the wall clock, and the probe harness is +//! required to read it (`capture::probes::ProbeClock`, decision 0789) precisely so a hitching run +//! cannot silently under-run a knob. Pacing it would hide the defect from the instrument that found +//! it. `Virtual` is Bevy's own *adjustable* game clock and is what `advance_animations`, the +//! particle/ribbon sims and `Time` all derive from — one correction there reaches every +//! animating consumer, which is the point: this is not a rig defect, it is a clock defect. +//! +//! `WOW_NO_FRAME_PACE=1` turns it off — the A/B lever, same shape as `WOW_NO_RIG_REBASE`. A +//! hand-driven clock (`TimeUpdateStrategy::Manual*` — the capture harness, the deterministic +//! tests) is never paced: those runs exist to reproduce frame-for-frame. + +use std::collections::VecDeque; +use std::time::Duration; + +use bevy::prelude::*; +use bevy::time::{Real, TimeSystems, TimeUpdateStrategy, Virtual}; + +/// How many recent raw deltas the refresh estimate is taken over — about a third of a second at +/// 60 Hz, long enough for a median to be stable and short enough to follow a refresh-rate change. +const WINDOW: usize = 20; +/// A sample counts as "on cadence" when it is within this fraction of the median. +const TIGHT: f64 = 0.15; +/// Engage only when at least this fraction of the window is on cadence — below it we are not +/// looking at a vsync'd stream and snapping would be a lie. +const TIGHT_SHARE: f64 = 0.6; +/// How far the unspent remainder may run before we stop trying to account for the frame in whole +/// refreshes and report it as it happened. This is what keeps a genuine stall visible. +const CARRY_MAX: f64 = 1.5; +/// The largest multiple worth snapping to: past four dropped frames it is a stall, not a mis-split. +const MAX_MULT: f64 = 4.0; + +/// The pacer's running state: the cadence estimate, the unspent remainder, and the paced total. +#[derive(Resource)] +pub(crate) struct FramePacer { + /// Recent raw deltas (seconds), for the refresh-period median. + hist: VecDeque, + /// Time measured but not yet handed out, so the long-run sum is exact. Bounded by one period. + carry: f64, + /// The paced elapsed total — what `Time::elapsed` is rewritten to. `None` until the + /// first paced frame adopts whatever Bevy had, so the clock never jumps at start-up. + total: Option, + /// `WOW_NO_FRAME_PACE=1`. + off: bool, +} + +impl Default for FramePacer { + fn default() -> Self { + Self { + hist: VecDeque::with_capacity(WINDOW), + carry: 0.0, + total: None, + off: std::env::var("WOW_NO_FRAME_PACE").as_deref() == Ok("1"), + } + } +} + +impl FramePacer { + /// The refresh period this stream looks like, or `None` when it does not look vsync'd. + fn cadence(&self) -> Option { + if self.hist.len() < WINDOW { + return None; + } + let mut v: Vec = self.hist.iter().copied().collect(); + v.sort_by(f64::total_cmp); + let period = v[v.len() / 2]; + if period <= 0.0 { + return None; + } + let tight = v + .iter() + .filter(|d| (*d - period).abs() < TIGHT * period) + .count(); + (tight as f64 >= TIGHT_SHARE * v.len() as f64).then_some(period) + } + + /// Snap `raw` to the presentation cadence, carrying the remainder. Returns `raw` unchanged + /// whenever snapping would not be honest. + /// + /// **One call to this is one presented frame, so the answer is one refresh period** unless + /// frames were genuinely dropped — and that asymmetry is the whole rule. Rounding `want/period` + /// to nearest instead gets the case this exists for exactly backwards: the measured long half + /// of a mis-split pair reads 26.4 ms = 1.58 periods, rounds *up* to 2, and the frame that was + /// really shown for one refresh is paced as two. So the multiple is taken as a FLOOR, and only + /// once `want` has actually reached 1.5 periods — meaning the remainder says we are a whole + /// frame behind — is anything above 1 considered. + /// + /// The remainder is carried, not discarded, so this only ever re-distributes time: over any + /// run the paced total tracks the wall clock to within one period. When the remainder cannot be + /// worked off in whole refreshes ([`CARRY_MAX`]) the frame is not a mis-split at all — it is a + /// stall — and the raw delta passes through so the hitch stays visible. + fn pace(&mut self, raw: f64) -> f64 { + if self.hist.len() == WINDOW { + self.hist.pop_front(); + } + self.hist.push_back(raw); + let Some(period) = self.cadence() else { + self.carry = 0.0; + return raw; + }; + // Spend what the wall clock gave us plus whatever the last frame did not use. + let want = raw + self.carry; + if want < 0.0 { + // We are ahead of the wall clock by more than this frame is worth: stop inventing. + self.carry = 0.0; + return raw; + } + let mult = if want >= 1.5 * period { + (want / period).floor().min(MAX_MULT) + } else { + 1.0 + }; + let snapped = mult * period; + let carry = want - snapped; + if carry.abs() > CARRY_MAX * period { + // Not a mis-split — a real stall. Report it as it happened. + self.carry = 0.0; + return raw; + } + self.carry = carry; + snapped + } +} + +/// Rewrite `Time` (and the generic `Time` derived from it) so this frame's delta is the +/// paced one. Runs straight after Bevy's own clock tick, before anything reads a delta. +/// +/// Bevy exposes no delta setter, so the clock is rebuilt: its context and wrap period are carried +/// over, then two `advance_by` calls place `elapsed` at the paced total with `delta` as the paced +/// step. Both are O(1) — `advance_by` is plain field arithmetic. +/// +/// **Paused or time-scaled virtual time passes straight through**, untouched. `Virtual`'s cached +/// `effective_speed` is private, so a rebuilt clock cannot honour a scale correctly, and pacing a +/// clock that is deliberately not tracking wall time is meaningless anyway. +pub(crate) fn pace_virtual_time( + real: Res>, + mut virt: ResMut>, + mut generic: ResMut