Levitate walks on water, the doll takes a snapshot, the pose keeps time

- Levitate over water, whole: hover stacks on the water-walk surface, so the body rests exactly a yard above the waterline — the reference's own arm undoes the ground snap and descends by max(L−1.0, 0); casting it while swimming launches you clear of the water, because `SetHover(true)`'s handler calls `CMovement::Jump` before it ever sets the flag — a jump that, unlike Space's, is not refused for a hovering body; and aiming below −37° drops the liquid layers out of the walk mask, which is the reference's own way back into the water and the piece whose absence made the mode a one-way door
- walking no longer sinks a Levitator: the step-down snap's no-hit leg — right on land, where it is the frame that leaves a ledge — could not see water, since liquid is queried rather than swept, so every walking frame stepped down a cone's worth while the hover climb hauled it back; the arithmetic closes with no free parameters (descent 0.24355 yd a frame, climb 0.11667, net −0.12688 against a measured −0.12687), settling 0.88 yd under the hover line until the keys were released
- the pose advances on the PRESENTATION cadence, not the CPU loop: under vsync a frame reaches the eye only on a refresh boundary, but the measured delta absorbs the render thread's back-pressure wait, so two frames shown 16.67 ms apart get advanced 26.4 and 8.0 — the whole skeleton lurching at once, up to 91 of 117 bones past a quarter pixel in one frame; `Time<Virtual>` is re-split to a delta snapped to the estimated refresh with the remainder carried, engaging only while the stream looks vsync'd so a real stall stays visible — found once the jitter meter probed a point at each bone's flesh radius and logged the animation clock beside the wall clock, the two channels it had been blind in
- the character doll is a snapshot you edit, not a mirror you follow: the reference re-takes a model widget's copy on becoming visible, a change of dress, a manual sheath toggle or a resize — the combat auto-draw is not one of them, so a bow drawn in combat stays off the character sheet; and a portrait bake now waits for its PIPELINES too, not just its textures — off macOS a cold pipeline variant skips its batch silently, a one-shot still keeps the hole for the session, and which piece goes missing is which variants lost the race: the hair's alpha-key, the body's opaque, a glow card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:11:32 +07:00
co-authored by Claude Fable 5
parent d6953c936f
commit b344fc4938
18 changed files with 1989 additions and 121 deletions
+170 -2
View File
@@ -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<Vec3>,
prev2: Vec<Vec3>,
/// **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<Vec3>,
prev2_flesh: Vec<Vec3>,
/// This subject's per-bone flesh radii ([`flesh_radii`]) — bind data, so computed once.
radii: Vec<f32>,
/// Last frame's **animation** clock: `(node, seek_time, weight)` per playing animation.
///
/// The blind spot this closes. [`ProbeClock`] is `Time<Real>` — 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<f32> {
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<Vec3> {
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<benilla_world::rig_palette::RigPalettes>,
players: Query<&AnimationPlayer>,
// The clock the POSE advances on — `ProbeClock` above is `Time<Real>`, the wall clock, and the
// gap between the two is the whole defect (`benilla_world::frame_pace`). Logging both side by
// side is the A/B: paced, `vdt` holds the cadence while `dt` still reports what really happened.
vtime: Res<Time<bevy::time::Virtual>>,
) {
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<Vec3> = 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=<s> dt=<ms> dist=<yd> pxyd=<px/yd> | camd1 camd2 (mm) | rootd1 rootd2 (mm) \
| bone=<max bone> d1 d2 (mm) d1px d2px | anc=<n> d1 d2 (mm) d1px d2px | \
| bone=<max bone> d1 d2 (mm) d1px d2px | flesh=<bone> d1 d2 (mm) d1px d2px v(mm/s) | \
anc=<n> d1 d2 (mm) d1px d2px | \
ancstep xyz (mm) | rider=<n> d1 d2 (mm) d1px d2px | window={}x{} subject={name:?} \
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<f32> = (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;
}
+6 -1
View File
@@ -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.
@@ -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\<stem>_<Ra><S>.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,
@@ -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<u32>,
/// 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<NockLatch>,
Option<&ResolveKey>,
Option<&DressKey>,
)>,
held: Option<ResMut<ItemDisplays>>,
mut templates: ResMut<Items>,
@@ -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<HeldSlot>; 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
+1 -1
View File
@@ -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.
+8 -5
View File
@@ -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.
+23
View File
@@ -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),
+59 -41
View File
@@ -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,
)
};
+543 -41
View File
@@ -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<f32>,
) -> Option<f32> {
(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<f32>,
) -> 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<f32>,
/// 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
+140 -12
View File
@@ -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.
+68 -1
View File
@@ -210,8 +210,10 @@ pub(super) fn update_swimming(player: &mut Player, surface_y: Option<f32>, 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<f32>, 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<f32>,
}
@@ -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
+16 -1
View File
@@ -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
@@ -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,
@@ -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,
+547 -10
View File
@@ -435,6 +435,15 @@ struct Booth {
root: Entity,
target: Handle<Image>,
baked: Option<LookKey>,
/// **Body panes only** — what [`sync_body_booth`] last snapshotted, in place of `baked`.
/// A body pane is a `<PlayerModel>` widget and re-takes its model on the reference's own
/// four triggers, never on the world moving something ([`SnapKey`]).
snap: Option<SnapKey>,
/// 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<f64>,
/// **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<Image>`; 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<f64>,
/// 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<bool> = 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>) -> 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<crate::creature_anim::SheathSwapMessage>,
glows: Query<&benilla_world::model_fade::ParentModel, Added<crate::entities::ItemGlowAttached>>,
revs: Query<&ModelRevision>,
) {
let mut bumped = <bevy::platform::collections::HashSet<Entity>>::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<u16>) -> 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 `<PlayerModel>` 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<Mesh>, AssetId<WowModelMaterial>)>,
/// 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<crate::entities::DressKey>,
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<crate::entities::DressKey>,
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<Mesh>, AssetId<WowModelMaterial>)> = 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<crate::entities::mount::MountBody>>,
/// 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<crate::entities::DressKey>, 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 `<PlayerModel>` 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::<Children>();
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<BoothPanes>,
images: Res<Assets<Image>>,
warm: Res<crate::pipe_warm::WarmPass>,
// 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<crate::pipe_warm::PipeWatch>,
time: Res<Time<bevy::time::Real>>,
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<Mesh> {
Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(0xb324_0000 | u128::from(n)),
std::marker::PhantomData,
)
}
fn mat(n: u64) -> Handle<WowModelMaterial> {
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,
);
}
}
+316
View File
@@ -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<Real>` 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<Virtual>` and not `Time<Real>`.** `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<Fixed>` 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<f64>,
/// 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<Virtual>::elapsed` is rewritten to. `None` until the
/// first paced frame adopts whatever Bevy had, so the clock never jumps at start-up.
total: Option<Duration>,
/// `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<f64> {
if self.hist.len() < WINDOW {
return None;
}
let mut v: Vec<f64> = 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<Virtual>` (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<Time<Real>>,
mut virt: ResMut<Time<Virtual>>,
mut generic: ResMut<Time>,
mut pacer: ResMut<FramePacer>,
strategy: Option<Res<TimeUpdateStrategy>>,
) {
// A hand-driven clock is never a presentation cadence. The capture harness and the
// deterministic tests set `ManualInstant`/`ManualDuration` precisely so a run reproduces
// frame-for-frame; snapping their steps to a median would make the output depend on the
// machine, which is the one thing those captures exist to rule out. (With a *constant* manual
// delta the snap happens to be identity anyway — this guard is so that stays true by
// construction rather than by luck.)
let manual = matches!(
strategy.as_deref(),
Some(TimeUpdateStrategy::ManualInstant(_) | TimeUpdateStrategy::ManualDuration(_))
);
if pacer.off || manual || virt.is_paused() || virt.relative_speed_f64() != 1.0 {
pacer.total = None;
pacer.carry = 0.0;
return;
}
let paced =
Duration::from_secs_f64(pacer.pace(real.delta().as_secs_f64())).min(virt.max_delta());
// First paced frame adopts Bevy's own elapsed, so nothing jumps when this engages.
let total = pacer.total.unwrap_or(virt.elapsed()) + paced;
pacer.total = Some(total);
let ctx = *virt.context();
let wrap = virt.wrap_period();
let mut t = Time::<Virtual>::default();
*t.context_mut() = ctx;
t.set_wrap_period(wrap);
t.advance_by(total.saturating_sub(paced));
t.advance_by(paced);
*virt = t;
*generic = virt.as_generic();
}
pub fn plugin(app: &mut App) {
app.init_resource::<FramePacer>()
.add_systems(First, pace_virtual_time.after(TimeSystems));
}
#[cfg(test)]
mod tests {
use super::*;
fn pacer() -> FramePacer {
FramePacer {
off: false,
..Default::default()
}
}
/// The whole point: a long/short PAIR that sums to two refresh periods comes back out as two
/// equal periods — which is what the display actually showed — and nothing is invented.
#[test]
fn a_mis_split_pair_is_restored_to_the_cadence_the_display_showed() {
let mut p = pacer();
let period = 1.0 / 60.0;
for _ in 0..WINDOW {
p.pace(period);
}
// The measured split of two on-cadence frames: 26.4 ms then 8.0 ms (the real reading).
let a = p.pace(0.0264);
let b = p.pace(0.0080);
assert!(
(a - period).abs() < 1.0e-6,
"long half snapped to one period: {a}"
);
assert!(
(b - period).abs() < 1.0e-6,
"short half snapped to one period: {b}"
);
// And the pair still spends exactly what the wall clock measured, to within the carry.
assert!(
((a + b) - (0.0264 + 0.0080)).abs() <= period,
"no time invented or lost beyond one period of carry"
);
}
/// Long-run elapsed must track the wall clock exactly — the carry exists so that snapping is a
/// re-distribution, never a drift. A thousand jittered frames may not gain or lose a frame.
#[test]
fn snapping_redistributes_time_it_never_creates_or_destroys_it() {
let mut p = pacer();
let period = 1.0 / 60.0;
let mut raw_total = 0.0;
let mut paced_total = 0.0;
// A deterministic long/short jitter around the cadence, the shape the measurement showed.
for i in 0..1000 {
let raw = match i % 4 {
0 => period * 1.55,
1 => period * 0.45,
2 => period * 0.80,
_ => period * 1.20,
};
raw_total += raw;
paced_total += p.pace(raw);
}
assert!(
(raw_total - paced_total).abs() < period,
"drift {:.6}s over 1000 frames must stay under one period",
raw_total - paced_total
);
}
/// A genuinely dropped frame IS two periods — pacing must not flatten a real 30 Hz stretch
/// into 60 Hz, or the clock would run slow for as long as the drop lasts.
#[test]
fn a_dropped_frame_is_paced_as_two_periods_not_one() {
let mut p = pacer();
let period = 1.0 / 60.0;
for _ in 0..WINDOW {
p.pace(period);
}
let d = p.pace(2.0 * period);
assert!(
(d - 2.0 * period).abs() < 1.0e-6,
"a dropped frame keeps both refreshes: {d}"
);
}
/// A real stall is NOT smoothed away: pacing corrects a mis-split, it does not hide a hitch.
#[test]
fn a_genuine_stall_passes_through_unpaced() {
let mut p = pacer();
let period = 1.0 / 60.0;
for _ in 0..WINDOW {
p.pace(period);
}
let stall = 1.82; // the 1820 ms streaming stall the same run caught
assert_eq!(p.pace(stall), stall, "a stall is reported as it happened");
}
/// An uncapped (non-vsync) stream never engages: with no cadence there is nothing to snap to.
#[test]
fn an_uncapped_stream_is_left_alone() {
let mut p = pacer();
// Wildly varying frame times — a `PresentMode::AutoNoVsync` run.
let raws = [0.004, 0.011, 0.0031, 0.019, 0.0072, 0.0155, 0.0028, 0.0093];
for i in 0..WINDOW * 2 {
let raw = raws[i % raws.len()];
assert_eq!(p.pace(raw), raw, "no cadence ⇒ no snapping");
}
}
}
+1
View File
@@ -139,6 +139,7 @@ pub mod doodad_anim;
pub mod entity_shade;
pub mod exterior_cull;
pub mod ffx_glow;
pub mod frame_pace;
pub mod ground_fx;
pub mod instance_tint;
pub mod interact;
@@ -123,6 +123,7 @@ impl PluginGroup for WorldPlugins {
// filed in the app). Its ordering rides the billboard place set, which is why the
// lane registers itself rather than asking a caller to know that.
.add(crate::ground_fx::plugin)
.add(crate::frame_pace::plugin)
.add(crate::rig_palette::plugin)
// The rig machinery beside the palette it fills: the pose evaluator, the pose
// post-pass window, the world/palette composition, and the global-sequence channels.