effects ride the lift with you, and a long frame keeps you on the deck

- a rider's effects are stored in the deck's frame: standing still on the Thunder Bluff lift, a weapon enchant's streak hung four yards below the blade and through the floor, because its edges were laid down in world space while the car climbed out from under them — the reference folds the transport matrix (translate · Rz(facing), NULL on the ground) into the emitter at birth and back out at draw, so committed content rides the deck and only a boundary re-expresses it; a `RideFrame` on the rider's model, inherited down the parent-model chain the way the reference copies the transport pointer to every child each frame, one fold at birth, one at draw, one at the boundary — ribbons take it unconditionally, world-mode particle clouds take it, model-mode clouds never see it; measured on the car: streak 4.2 yd → 0.00 over a 49 yd climb, while the same rider thrown off the top streaks 0.9 → 24.8 yd on the way down
- switching away from the window while riding a lift dropped you through it: a collider moved in `Update` is invisible to `Update`'s own spatial queries until the next physics step, so on a 1 s frame the deck descended 7 yd with its broad-phase box left behind, the ground probe found nothing under the feet, and the second long frame's clamped 250 ms of gravity put them under the deck; avian's own `update_moved_collider_aabbs` now runs inside the transport chain right after the deck moves (0.03 ms p50, flat across scene density), and `WOW_STALL` injects long frames on demand, since a probe window is never throttled by the OS
- `WOW_TRAIL_CENSUS` prints each live ribbon trail's world-space spread beside its spread on the deck — the pair that separates "not riding" from "riding but not folded"; it first matched zero trails in every scene by requiring a component trails deliberately never carry, recorded rather than quietly fixed, because an instrument answering a confident zero cost an afternoon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:17:25 +07:00
co-authored by Claude Fable 5
parent 18858046f9
commit 1fb4d75acd
16 changed files with 1084 additions and 41 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ pub(crate) use probes::{
JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin,
ProbeChatPlugin, ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin,
ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin,
SchedCensusPlugin, UnitVisualsPlugin,
SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
};
use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
@@ -41,6 +41,8 @@ pub(crate) use ground_census::GroundCensusPlugin;
/// never armed and so never lifted its spawn-hide (B168).
mod lift_census;
pub(crate) use lift_census::LiftCensusPlugin;
mod trail_census;
pub(crate) use trail_census::TrailCensusPlugin;
/// The unit-visual census — per-entity "what visual did this display actually get", which is what
/// separates a debug cube (a gap of ours) from a model that legitimately draws nothing (B13,
@@ -76,6 +78,12 @@ pub(crate) use world_census::{EntityCensusPlugin, NodeProbePlugin};
mod sched_census;
pub(crate) use sched_census::SchedCensusPlugin;
/// The frame-stall injector — blocks the main loop on a schedule, reproducing on demand the one
/// thing a probe window can never do to itself: go to the background and come back with one
/// enormous frame delta. Doubles as the plain frame-delta/occlusion monitor at `WOW_STALL=0`.
mod stall;
pub(crate) use stall::StallPlugin;
/// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789).
///
/// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s",
@@ -127,6 +135,13 @@ mod tests {
"the fixture AGE runs on the clock the effect animates on, and the capture freezes \
that same clock at save time — an age, not a schedule",
),
(
"capture/probes/stall.rs",
"drive_stall",
"the clamp IS the subject: this probe reports `Time<Virtual>`'s delta beside \
`Time<Real>`'s so a stalled frame's divergence is a printed number rather than a \
remembered constant. Its own schedule is [`ProbeClock`], bound on the line above",
),
(
"capture/waterfx.rs",
"spawn / drive",
@@ -0,0 +1,237 @@
//! The **frame-stall injector** (`WOW_STALL="<ms>[,<every_s>[,<after_s>[,<frames>]]]"`) — the
//! instrument that makes "I tabbed away and came back to X" reproducible.
//!
//! A probe window cannot be tabbed away from: every scripted live probe asserts `AlwaysOnTop`
//! ([`super::ProbeFocusPlugin`], decision 0906) precisely so the OS cannot throttle it. So the one
//! class of bug that only appears *because* the window went to the background — the client running
//! for seconds at ~1 fps and then resuming — had no reproduction at all. This is that reproduction:
//! it blocks the main loop for `<ms>` on each of `<frames>` consecutive frames, every `<every_s>`
//! seconds, starting at `<after_s>`. Nothing else.
//!
//! **What backgrounding actually does, and why this shape is the faithful one.** Two independent
//! mechanisms, and only the second one bites:
//!
//! - *winit* — we never set [`bevy::winit::WinitSettings`], so we get its `Default`, which is
//! `WinitSettings::game()`: `focused_mode: Continuous`, `unfocused_mode:
//! reactive_low_power(1/60 s)` (`bevy_winit-0.18.1/src/winit_config.rs:19-22`). An unfocused
//! window therefore still targets **60 Hz**; losing focus on its own costs nothing.
//! - *macOS* — a window that is fully **covered** is throttled at the drawable: every
//! `CAMetalLayer.nextDrawable` blocks about a second, so the run continues at ~1 fps for as long
//! as it is covered (decisions 0713/0777; the director's correction in 0906 — "any covering
//! window, not just the lock screen"; 1355 measured the signature as a `p99 ≈ 1020 ms`
//! metronome). A ten-second glance at a terminal is therefore ~10 consecutive one-second frames,
//! which is why `<frames>` exists: a *single* long frame is a hitch, and a hitch is not what
//! backgrounding does.
//!
//! So the knob that reproduces a ten-second tab-away is `WOW_STALL="1000,20,60,10"`.
//!
//! **Not to be confused with `WOW_STALL_INJECT`** (`crate::perf::stall`, decisions 0713/1637),
//! whose name is one suffix away. That one fires **a single** sleep at a single instant, and it
//! exists to prove the stuck-main-thread *watchdog* fires — it is a test affordance for an
//! instrument, not a reproduction of a gameplay state. This one repeats, blocks `<frames>` in a
//! row, and carries the frame-delta/occlusion monitor, which is the whole difference: 1663 was
//! reproducible only with **consecutive** long frames (one alone loses the deck for a frame and
//! recovers; two drop the rider through it). The two should probably become one knob — flagged,
//! not done here.
//!
//! **`WOW_STALL="0"` injects nothing** and leaves only the monitor running — the "what is this
//! run's frame pacing actually doing" reader, which is how the premise above gets checked on a real
//! run instead of assumed. Every run prints, per window:
//!
//! ```text
//! STALL_WATCH t=35.0 frames=299 p50=16.7 p99=17.9 max=33.4 focused=1 occluded=0
//! STALL t=35.0 sleeping 1000 ms (1/10)
//! STALL_HIT t=36.0 injected=1000 wall_dt=1016.4 real_dt=16.7 virt_dt=16.7 clamped=0
//! ```
//!
//! **Three clocks on that line, and they are three different clocks — that is the point.**
//!
//! - `wall_dt` is [`Instant`] to [`Instant`] across the main-schedule pass: the true frame time,
//! and the clock `transport::Transport::cycle_ms` runs on (`anchor.at.elapsed()`).
//! - `real_dt` is `Time<Real>`'s delta, which under pipelined rendering is **not** the wall clock:
//! `time_system` stamps it from an `Instant` the *render world* sends over a `bounded(2)` channel
//! (`bevy_time-0.18.1/src/lib.rs:152-171`, `bevy_render-0.18.1/src/renderer/mod.rs:113`), so it
//! arrives a frame or two late — bevy's own source carries a `TODO: Figure out how to handle
//! this when using pipelined rendering` right above it.
//! - `virt_dt` is what every consumer of `Res<Time>` integrates: `real_dt` after the frame pacer,
//! clamped to `Time<Virtual>`'s stock 250 ms `max_delta`. `clamped=1` marks the frames where the
//! clamp bit.
//!
//! A `focused=`/`occluded=` transition prints its own line the moment it lands.
use core::time::Duration;
use std::time::Instant;
use bevy::prelude::*;
use bevy::time::Virtual;
use bevy::window::WindowOccluded;
use super::ProbeClock;
/// Default seconds between stall bursts, and default seconds of run before the first one — long
/// enough that login, world entry and the first streaming burst are never what is being measured.
const DEFAULT_EVERY_SECS: f32 = 20.0;
const DEFAULT_AFTER_SECS: f32 = 30.0;
/// How often the monitor prints its `STALL_WATCH` summary, seconds.
const WATCH_SECS: f32 = 5.0;
pub(crate) struct StallPlugin;
impl Plugin for StallPlugin {
fn build(&self, app: &mut App) {
let raw = std::env::var("WOW_STALL").unwrap_or_default();
let mut parts = raw
.split(',')
.map(|s| s.trim().parse::<f32>().unwrap_or(-1.0));
let ms = parts.next().filter(|v| *v >= 0.0).unwrap_or(0.0) as u64;
let every = parts
.next()
.filter(|v| *v > 0.0)
.unwrap_or(DEFAULT_EVERY_SECS);
let after = parts
.next()
.filter(|v| *v >= 0.0)
.unwrap_or(DEFAULT_AFTER_SECS);
let burst = parts.next().filter(|v| *v >= 1.0).unwrap_or(1.0) as u32;
info!(
"stall: WOW_STALL armed — {ms} ms x{burst} consecutive frames every {every} s from \
t={after} s ({})",
if ms == 0 {
"monitor only, nothing injected"
} else {
"injecting"
},
);
app.insert_resource(Stall {
ms,
every,
burst,
next_stall: after,
left: 0,
next_watch: WATCH_SECS,
pending: None,
last: None,
samples: Vec::new(),
focused: None,
occluded: false,
})
// `Last`, so the block lands at the frame boundary: the sleep is over by the time the
// next frame's `First` stamps the clocks, and it therefore shows up as that frame's
// delta — which is the shape an unscheduled/throttled frame has.
.add_systems(Last, drive_stall);
}
}
/// [`StallPlugin`] state: the knob, the schedule, and the current window's samples.
#[derive(Resource)]
struct Stall {
/// Milliseconds to block for, `0` = monitor only.
ms: u64,
/// Seconds between bursts.
every: f32,
/// Consecutive frames blocked per burst — a backgrounded window is ~1 fps for as long as it
/// is covered, so one long frame is a hitch and `burst` long frames are a tab-away.
burst: u32,
/// Wall-clock second of the next burst.
next_stall: f32,
/// Frames still to block in the burst in progress.
left: u32,
/// Wall-clock second of the next `STALL_WATCH` summary.
next_watch: f32,
/// Set on the frame that slept; read (and reported) on the frame that paid for it.
pending: Option<u64>,
/// End of the previous main-schedule pass — the true wall-clock frame delta, which under
/// pipelined rendering `Time<Real>` is not (see the module header).
last: Option<Instant>,
/// This window's true frame deltas, milliseconds.
samples: Vec<f64>,
/// Last seen window focus, `None` until the first read — so the first frame prints the state
/// rather than a transition into it.
focused: Option<bool>,
/// Last seen occlusion, from `WindowOccluded`.
occluded: bool,
}
impl Stall {
/// The `p`-th percentile of a sorted, non-empty sample window.
fn pct(sorted: &[f64], p: f64) -> f64 {
sorted[((sorted.len() - 1) as f64 * p).round() as usize]
}
}
/// Report the frame that just ran, then decide whether to block the next one.
fn drive_stall(
real: ProbeClock,
virt: Res<Time<Virtual>>,
mut stall: ResMut<Stall>,
mut occlusions: MessageReader<WindowOccluded>,
windows: Query<&Window>,
) {
let now = real.elapsed_secs();
let at = Instant::now();
let wall_ms = stall
.last
.map_or(0.0, |p| at.duration_since(p).as_secs_f64() * 1000.0);
stall.last = Some(at);
stall.samples.push(wall_ms);
// Window state, printed on transition — the premise check: a run that was occluded (or never
// focused) was measuring the OS, not us.
for o in occlusions.read() {
if o.occluded != stall.occluded {
stall.occluded = o.occluded;
println!("STALL_WINDOW t={now:.2} occluded={}", u8::from(o.occluded));
}
}
let focused = windows.iter().any(|w| w.focused);
if stall.focused != Some(focused) {
stall.focused = Some(focused);
println!("STALL_WINDOW t={now:.2} focused={}", u8::from(focused));
}
// The frame that paid for the last injection.
if let Some(injected) = stall.pending.take() {
let real_ms = real.delta_secs_f64() * 1000.0;
let virt_ms = virt.delta_secs_f64() * 1000.0;
println!(
"STALL_HIT t={now:.2} injected={injected} wall_dt={wall_ms:.1} real_dt={real_ms:.1} \
virt_dt={virt_ms:.1} clamped={}",
u8::from(real_ms - virt_ms > 1.0),
);
}
if now >= stall.next_watch {
stall.next_watch = now + WATCH_SECS;
let mut sorted = std::mem::take(&mut stall.samples);
sorted.sort_by(f64::total_cmp);
if let Some(&max) = sorted.last() {
println!(
"STALL_WATCH t={now:.2} frames={} p50={:.1} p99={:.1} max={max:.1} focused={} \
occluded={}",
sorted.len(),
Stall::pct(&sorted, 0.50),
Stall::pct(&sorted, 0.99),
u8::from(focused),
u8::from(stall.occluded),
);
}
}
if stall.ms == 0 {
return;
}
if stall.left == 0 && now >= stall.next_stall {
stall.left = stall.burst;
stall.next_stall = now + stall.every;
}
if stall.left > 0 {
let n = stall.burst - stall.left + 1;
println!(
"STALL t={now:.2} sleeping {} ms ({n}/{})",
stall.ms, stall.burst
);
stall.left -= 1;
std::thread::sleep(Duration::from_millis(stall.ms));
stall.pending = Some(stall.ms);
}
}
@@ -0,0 +1,172 @@
//! The **ribbon-trail census** (`WOW_TRAIL_CENSUS=<secs>[,<every>]`) — the instrument that turns
//! *"the weapon particles trail behind while the lift goes up"* into a line of numbers.
//!
//! A streak in a screenshot cannot say whether it is **right**. A weapon trail is *supposed* to
//! smear — that is the whole effect, and its length is `host speed × edgeLifetime` by design. The
//! bug the director reported is not "there is a streak", it is that the streak is drawn against
//! the **world** while the host is standing still on a moving **deck**. Those two look identical
//! in a still frame and are one subtraction apart in numbers:
//!
//! - **`world_dy`** — the strip's vertical extent in world space, and **the observable**. A rider
//! standing still on a moving deck must draw a streak that is *short in world space*: with the
//! ride frame the edges are stored on the deck and re-projected through its live pose, so the
//! whole strip travels rigidly with the car and never stretches. Without it the edges are frozen
//! in the world while the car climbs out from under them, and the strip grows to
//! `deck speed × edgeLifetime`. So on a **vertical** lift the reading is simply: `world_dy ≈ 0`
//! is right, `world_dy ≈ deck speed × edgeLifetime` is the bug (decision 1591's unbuilt gap,
//! 1661's fix).
//! - **`deck_ext`** — the strip's extent measured *inside the transport's frame*. **It cannot
//! differ from the world reading in Y**: `A` is `translate · Rz` (see
//! [`benilla_world::ride_frame::ride_matrix`]), and a yaw plus a translation leaves a vertical
//! extent invariant. It earns its column only on a **turning** transport — a boat swinging
//! through a bend, where the in-plane extent does differ — and stands as a self-check on a lift,
//! where it must equal the world one. (This column was first written claiming `world_dy` large
//! with `deck_dy` small was the finding; that pair is unreachable by construction, and saying so
//! here is cheaper than the next reader re-deriving it.)
//!
//! ```text
//! TRAIL_CENSUS t=85.0 trails=1 riding=1 stamped=1 deck=0xf12000104a00477a deckY=101.96 worst=0.00
//! TRAIL 168v1 bone=24 edges=25 life=0.50s vis=- | world_dy= 0.00 world_ext= 0.25 deck_ext= 0.25
//! ```
//!
//! Off a transport `deck=` reads `-` and the deck columns are the world ones, which is the control:
//! this census must not change on the ground, where a streak *should* smear behind a moving host.
//!
//! Pair it with the slot-keyed probe identity and a `.go` onto the Thunder Bluff lift:
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
//! WOW_PROBE_CHAT=".go xyz -1286.2 189.7 132.0 1" \
//! WOW_TRAIL_CENSUS="35,5" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
//! ```
//!
//! The repeat form is the point: one census says what the strip looked like at an instant, a
//! series across the lift's travel says whether the deck-relative spread *grows with the ride*.
use bevy::prelude::*;
use super::ProbeClock;
use benilla_world::ribbons::RibbonTrail;
use benilla_world::ride_frame::ride_matrix;
pub(crate) struct TrailCensusPlugin;
impl Plugin for TrailCensusPlugin {
fn build(&self, app: &mut App) {
let raw = std::env::var("WOW_TRAIL_CENSUS").unwrap_or_default();
let mut parts = raw
.split(',')
.map(|s| s.trim().parse::<f32>().unwrap_or(0.0));
let at = parts.next().filter(|v| *v > 0.0).unwrap_or(30.0);
let every = parts.next().unwrap_or(0.0);
app.insert_resource(TrailCensus { next: at, every })
.add_systems(Update, fire_trail_census);
}
}
/// [`TrailCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
#[derive(Resource)]
struct TrailCensus {
next: f32,
every: f32,
}
/// The axis-aligned extent of a point cloud — `(dx, dy, dz)`. Returns zeros for fewer than two
/// points, which is a trail that has committed nothing yet rather than a trail that is perfect.
fn extent(points: impl Iterator<Item = Vec3>) -> Vec3 {
let (mut lo, mut hi) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
let mut n = 0u32;
for p in points {
lo = lo.min(p);
hi = hi.max(p);
n += 1;
}
if n < 2 {
return Vec3::ZERO;
}
hi - lo
}
/// One line per live ribbon trail, worst deck-relative spread first — the order that puts the
/// finding at the top when there are dozens of trails in a scene.
fn fire_trail_census(
mut probe: ResMut<TrailCensus>,
time: ProbeClock,
// `ViewVisibility` is OPTIONAL and a trail never has one: a trail entity is spawned with a
// `Transform` and nothing else, deliberately (`ribbons.rs`'s `fade` note — an emitter entity
// carrying a `Visibility` would enlist in a second writer's query, decision 0025). Asking for
// it as a required component matched ZERO trails in every scene, on the ground and on a lift
// alike, and printed `trails=0` as if the client had spawned none.
trails: Query<(Entity, &RibbonTrail, Option<&ViewVisibility>)>,
// The deck's live pose. Disjoint from the trails above by construction — a transport is never
// a ribbon — and read through the same `ride_matrix` the sim folds with, so the instrument
// cannot drift from the thing it measures.
decks: Query<&GlobalTransform, Without<RibbonTrail>>,
guids: Query<&crate::net::Guid>,
// The STAMP itself, counted separately from the trails: "nothing is riding" and "something is
// riding but its trail isn't folded" are different bugs with different fixes, and a census
// that cannot tell them apart sends you to the wrong half of the system.
stamped: Query<Entity, With<benilla_world::ride_frame::RideFrame>>,
) {
let now = time.elapsed_secs();
if probe.next <= 0.0 || now < probe.next {
return;
}
probe.next = if probe.every > 0.0 {
now + probe.every
} else {
-1.0
};
let mut rows: Vec<(f32, String)> = Vec::new();
let (mut riding, mut worst) = (0u32, 0.0f32);
let (mut deck_name, mut deck_y) = ("-".to_string(), f32::NAN);
for (entity, trail, vis) in &trails {
let world: Vec<Vec3> = trail.strip_world().collect();
let w_ext = extent(world.iter().copied());
// Into the deck's frame. Off a transport this is the identity, so the two columns agree
// and the line reads as its own control.
let deck = trail
.deck()
.and_then(|d| decks.get(d).ok().map(|gt| (d, gt)));
let d_ext = match deck {
Some((_, gt)) => {
let inv = ride_matrix(gt).inverse();
extent(world.iter().map(|p| inv.transform_point3(*p)))
}
None => w_ext,
};
if let Some((d, gt)) = deck {
riding += 1;
deck_y = gt.translation().y;
deck_name = guids
.get(d)
.map_or_else(|_| format!("{d}"), |g| format!("{:#018x}", g.0));
}
worst = worst.max(w_ext.y);
let (_, edges) = trail.shape();
rows.push((
d_ext.y,
format!(
"TRAIL {entity} bone={:<3} edges={edges:<3} life={:.2}s vis={} | \
world_dy={:5.2} world_ext={:5.2} deck_ext={:5.2}",
trail.bone(),
trail.edge_lifetime(),
vis.map_or('-', |v| if v.get() { '1' } else { '0' }),
w_ext.y,
w_ext.max_element(),
d_ext.max_element(),
),
));
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
info!(
"TRAIL_CENSUS t={now:.1} trails={} riding={riding} stamped={} deck={deck_name} \
deckY={deck_y:.2} worst_world_dy={worst:.2}",
rows.len(),
stamped.iter().count()
);
for (_, line) in rows {
info!("{line}");
}
}
+18
View File
@@ -210,6 +210,24 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_LIFT_CENSUS").is_ok() {
app.add_plugins(crate::capture::LiftCensusPlugin);
}
// The frame-stall injector: `WOW_STALL="<ms>[,<every_s>[,<after_s>]]"` blocks the
// main loop on a schedule — the reproduction for "I tabbed away and came back to X",
// which no probe can otherwise stage (every scripted probe asserts `AlwaysOnTop`
// precisely so the OS cannot throttle it). `WOW_STALL=0` injects nothing and leaves
// the frame-delta/occlusion monitor, which is how the premise gets checked rather
// than assumed (see `capture::StallPlugin`).
if std::env::var("WOW_STALL").is_ok() {
app.add_plugins(crate::capture::StallPlugin);
}
// The ribbon-trail census: `WOW_TRAIL_CENSUS=<secs>[,<every>]` prints one line per live
// weapon/spell streak — its committed extent in WORLD space, which is the observable:
// a rider standing still on a moving deck must draw a SHORT streak, because its edges
// are stored on the deck and re-projected through the deck's live pose. A long one is
// a streak drawn against the world while its owner stands still (see
// `capture::TrailCensusPlugin`).
if std::env::var("WOW_TRAIL_CENSUS").is_ok() {
app.add_plugins(crate::capture::TrailCensusPlugin);
}
// The unit-visual census: `WOW_UNIT_VISUALS=<secs>[,<every>]` prints one line per
// streamed entity near the body — whether it got a debug cube, real geometry, or
// nothing at all. The instrument B13 was missing: a black slab in a screenshot cannot
+42 -7
View File
@@ -502,8 +502,18 @@ fn control(
// transport that owns it — solid cargo, 0470).
world_q: (
benilla_world::world_point::WorldPoint,
// `ColliderAabb` rides along for the **ride trace only**, and it is the column that
// separates "the deck moved" from "the deck's collider moved": avian refreshes it in
// `PhysicsSchedule` (i.e. `FixedPostUpdate`, *before* `Update`), while `tick_transports`
// writes the deck's pose *in* `Update` — so the box the broad phase prunes against is
// always one frame behind the deck it belongs to, and the down-probe's candidate set is
// whatever that stale box still overlaps.
Query<
(&Transform, &crate::net::Guid),
(
&Transform,
&crate::net::Guid,
Option<&avian3d::prelude::ColliderAabb>,
),
(
With<crate::transport::Transport>,
Without<Embodied>,
@@ -785,7 +795,7 @@ fn control(
// player rig the same way.
if let Some(ride) = player.ride.as_ref() {
match transports.get(ride.entity) {
Ok((boat, _)) => {
Ok((boat, _, _)) => {
let world = boat.translation + boat.rotation * ride.local_pos;
let yaw_now = boat.rotation.to_euler(EulerRot::YXZ).0;
let mut dyaw = yaw_now - ride.boat_yaw;
@@ -1511,7 +1521,7 @@ fn control(
// recomposes from.
let owning_transport = |mut e: Entity| {
for _ in 0..4 {
if let Ok((t, g)) = transports.get(e) {
if let Ok((t, g, _)) = transports.get(e) {
return Some((e, t, g));
}
e = child_of.get(e).ok()?.parent();
@@ -1555,15 +1565,39 @@ fn control(
.ride
.as_ref()
.and_then(|r| transports.get(r.entity).ok())
.map(|(t, _)| (t.translation, t.rotation.to_euler(EulerRot::YXZ).0));
if let (Some(ride), Some((bpos, byaw))) = (player.ride.as_ref(), boat_pose) {
.map(|(t, _, aabb)| {
(
t.translation,
t.rotation.to_euler(EulerRot::YXZ).0,
aabb.copied(),
)
});
if let (Some(ride), Some((bpos, byaw, baabb))) = (player.ride.as_ref(), boat_pose) {
let local = Quat::from_euler(EulerRot::YXZ, byaw, 0.0, 0.0)
.inverse()
.mul_vec3(feet - bpos);
// The deck's **broad-phase box**, and the gap from its underside to the feet. The
// candidate enumeration every mover probe rides
// (`SpatialQuery::aabb_intersections_with_aabb`) tests exactly this component —
// and avian refreshes it in `PhysicsSchedule` (`FixedPostUpdate`, *before*
// `Update`), while `tick_transports` writes the deck's pose *in* `Update`. So the
// box is always one frame of deck travel behind the deck, and on a long frame it
// is left behind entirely: a positive `gap` means the down-probe cannot even see
// the deck the feet are standing on, which is the difference between
// `support=deck` and `support=NONE` while `local` still reads (0, 0.05, 0).
let aabb_col = match baabb {
Some(a) => format!(
" | aabbY[{:8.2},{:8.2}] gap{:+7.2}",
a.min.y,
a.max.y,
a.min.y - feet.y,
),
None => " | aabbY[ absent]".to_string(),
};
benilla_assets::trace::line(
"ride",
&format!(
"on {:#x} deck({:8.2},{:7.2},{:8.2}) yaw{:+.3} | feet({:8.2},{:7.2},{:8.2}) local({:7.2},{:6.2},{:7.2}) | grounded={} support={} vy={:+6.2}",
"on {:#x} deck({:8.2},{:7.2},{:8.2}) yaw{:+.3} | feet({:8.2},{:7.2},{:8.2}) local({:7.2},{:6.2},{:7.2}) | grounded={} support={} vy={:+6.2}{}",
ride.guid,
bpos.x,
bpos.y,
@@ -1582,6 +1616,7 @@ fn control(
None => "NONE",
},
player.vel_y,
aabb_col,
),
);
} else if !swimming {
@@ -1599,7 +1634,7 @@ fn control(
}
}
if let Some(ride) = player.ride.as_mut() {
if let Ok((boat, _)) = transports.get(ride.entity) {
if let Ok((boat, _, _)) = transports.get(ride.entity) {
ride.local_pos = boat.compute_affine().inverse().transform_point3(feet);
ride.boat_yaw = boat.rotation.to_euler(EulerRot::YXZ).0;
}
+7
View File
@@ -956,6 +956,13 @@ impl Player {
self.ride.as_ref().map(|r| r.guid)
}
/// The transport we're standing on, as its ECS entity — what the **ride frame** is read from
/// (`benilla_world::ride_frame`, decision 1591): a rider's world-space effects are stored in
/// the deck's frame, and the deck's live pose is the transport entity's transform.
pub(crate) fn ride_entity(&self) -> Option<Entity> {
self.ride.as_ref().map(|r| r.entity)
}
/// A server-authored spline currently owns the avatar (Charge/knockback/taxi — the
/// [`super::server_ride`] state). For instruments (the taxi probe watches the flight run) and
/// the UI's `UnitOnTaxi` feed.
+10 -4
View File
@@ -120,7 +120,10 @@ pub(super) fn apply_server_moves(
self_moves: &mut MessageReader<SelfMoveMessage>,
control_msgs: &mut MessageReader<ClientControlMessage>,
self_guid: Option<u64>,
transports: &Query<(&Transform, &Guid), (With<Transport>, Without<Embodied>, Without<FlyCam>)>,
transports: &Query<
(&Transform, &Guid, Option<&avian3d::prelude::ColliderAabb>),
(With<Transport>, Without<Embodied>, Without<FlyCam>),
>,
self_pose: Option<(Vec3, f32)>,
) -> Vec<SpeedChangeMessage> {
// The control handoff (B211). Two questions, and they are NOT the same one: "is this about my
@@ -225,7 +228,7 @@ pub(super) fn apply_server_moves(
// server reads as a deboard mid-ocean.
let ride = player.ride.as_mut().expect("riding checked above");
ride.local_pos = wow_to_bevy(w.position);
if let Ok((boat, _)) = transports.get(ride.entity) {
if let Ok((boat, _, _)) = transports.get(ride.entity) {
let boat_yaw = boat.rotation.to_euler(EulerRot::YXZ).0;
ride.boat_yaw = boat_yaw;
player.pos = boat.translation + boat.rotation * ride.local_pos;
@@ -536,7 +539,10 @@ fn apply_self_move(
player: &mut Player,
cam: &mut FlyCam,
time: &Time,
transports: &Query<(&Transform, &Guid), (With<Transport>, Without<Embodied>, Without<FlyCam>)>,
transports: &Query<
(&Transform, &Guid, Option<&avian3d::prelude::ColliderAabb>),
(With<Transport>, Without<Embodied>, Without<FlyCam>),
>,
) {
let was_falling = player.move_flags & move_flags::FALLING != 0;
player.move_flags = merge_server_flags(player.move_flags, m.flags);
@@ -571,7 +577,7 @@ fn apply_self_move(
// it moved us **within** the platform frame. Re-anchor the local pose from the boat's live
// transform, or next frame's carry recomposes the stale one and undoes the snap.
if let Some(ride) = player.ride.as_mut() {
if let Ok((boat, _)) = transports.get(ride.entity) {
if let Ok((boat, _, _)) = transports.get(ride.entity) {
ride.local_pos = boat.rotation.inverse() * (player.pos - boat.translation);
ride.boat_yaw = boat.rotation.to_euler(EulerRot::YXZ).0;
}
+100 -2
View File
@@ -26,7 +26,11 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use avian3d::prelude::{Position, RigidBody, Rotation};
// avian's own public AABB/tree republish system, registered by name below. Aliased because the
// chain's concern is *when* it runs, not whose it is — and because the upstream name says what it
// does to colliders, not why this app needs it a second time each frame.
use avian3d::collider_tree::update_moved_collider_aabbs as republish_moved_collider_aabbs;
use avian3d::prelude::{Collider, Position, RigidBody, Rotation};
use benilla_formats::{
elevator_period_ms, elevator_sample, load_elevator_paths, load_taxi_path_nodes,
ElevatorKeyframe, ElevatorPaths, TaxiPathNodes, TransportSample, TransportTimetable,
@@ -36,6 +40,7 @@ use bevy::prelude::*;
use crate::go_templates::GameObjectTemplates;
use crate::net::Guid;
use benilla_assets::{LockRecover, WorldAssets};
use benilla_world::ride_frame::RideFrame;
use benilla_world::vis_chain::VisChainOnly;
use benilla_world::world_map::CurrentMap;
@@ -56,10 +61,51 @@ impl Plugin for TransportPlugin {
// After the net drain (fresh anchors/re-anchors applied), before the player's
// input stage — the boat must be at its this-frame pose before the mover reads
// the world (phase 2's platform carry rides exactly this edge).
(arm_transports, tick_transports, compose_riders)
//
// **[`republish_moved_collider_aabbs`] is the third link, and it is here because
// of a general engine truth, not a lift one** (decision 1663): *a collider moved
// in `Update` is invisible to `Update`'s own spatial queries until the next
// physics step, by up to one frame of its own travel.* avian refreshes
// `ColliderAabb`, `EnlargedAabb` and the `ColliderTrees` proxies in
// `ColliderTreeSystems::UpdateAabbs`, which sits in `PhysicsSchedule` — i.e. in
// `FixedPostUpdate`, **before** `Update` — while [`tick_transports`] writes the
// deck's pose *in* `Update`. Every `Update`-side spatial query
// (`SpatialQuery::aabb_intersections_with_aabb`, which is how the mover's
// ground/step/slide casts enumerate candidates — `benilla_world::collision`) is
// therefore pruned against the deck's **previous-frame** box.
//
// At 60 Hz that is centimetres and harmless. On one long frame — a backgrounded
// window is at least one ~1 s frame (decisions 0713/0906) — a Thunder Bluff lift
// descends 7.3 yd while its box stays put, the box ends up *above* the rider it
// is carrying, the down-probe enumerates no deck at all, and the rider is
// declared airborne on the very platform they are standing on. Two such frames in
// a row and `Time<Virtual>`'s clamped 250 ms lands a full gravity step on the
// second one: the body drops 1.2 yd through the deck and never re-earns it,
// because the deck is now above the probe. That is the reported fall-through, and
// 1663 records the measured chain.
//
// The system is avian's own, public, and read-only on `LastPhysicsTick` — it
// writes no tick resource, so running it a second time in the frame is
// idempotent, and the next physics step simply recomputes what has not moved
// since. It sweeps *every* collider, not just transports; 1663 records the
// measured per-frame cost of that sweep in a streamed outdoor scene.
(
arm_transports,
tick_transports,
republish_moved_collider_aabbs::<Collider>,
compose_riders,
)
.chain()
.after(benilla_world::schedule::WorldStage::Net)
.before(benilla_world::schedule::WorldStage::Input),
)
// …and the ride frame LAST, after the mover has decided what we are standing on this
// frame ([`crate::player::Player::ride`] is written inside `PlayerControlSet`). Its
// consumers — the particle and ribbon sims — run in `PostUpdate`, so the deferred
// insert has landed by the time anything reads it.
.add_systems(
Update,
stamp_ride_frames.after(crate::player::PlayerControlSet),
);
}
}
@@ -492,6 +538,58 @@ fn compose_riders(
}
}
/// Publish the **ride frame** onto every model standing on a transport — the reference's
/// `SetMoveBase` install (`0x617170`/`0x618970` → `0x718910` → `[CM2Model+0x17c]`), which is the
/// frame a rider's world-space effects are *stored* in ([`benilla_world::ride_frame`]).
///
/// Two riders, one component. The **body we steer** takes the mover's own platform attach
/// ([`crate::player::Player::ride_entity`] — the deck collider that grounded us), and every
/// **observed** rider takes the wire's transport tail ([`TransportRider`]). Everything hung off
/// either — a held weapon, its enchant streamer, a spell kit — inherits it through the
/// `ParentModel` chain rather than being stamped itself, which is exactly how the reference
/// propagates `[model+0x17c]` to a model's children every frame (`0x7142c1` in `m2_animate`).
///
/// Why this is a component and not a field on `Transport`: the fact is *per rider*, it changes on
/// a step, and `benilla-world` — which owns the emitters that consume it — cannot see either
/// `Player` or `TransportRider`. Decision 1591 named this seam and left it unbuilt; the director's
/// weapon-trail report on the Thunder Bluff lift is what it costs.
fn stamp_ride_frames(
mut commands: Commands,
player: Res<crate::player::Player>,
guid_index: Res<crate::net::GuidIndex>,
body: Query<Entity, With<crate::net::Embodied>>,
riders: Query<(Entity, &TransportRider)>,
stamped: Query<(Entity, &RideFrame)>,
) {
let mut want: HashMap<Entity, Entity> = HashMap::new();
if let (Ok(me), Some(deck)) = (body.single(), player.ride_entity()) {
want.insert(me, deck);
}
for (rider, on) in &riders {
// A rider whose transport isn't streamed in keeps no frame: there is no pose to store
// against, and the server despawns the pair together anyway (`compose_riders`' note).
if let Some(&deck) = guid_index.0.get(&on.transport_guid) {
want.insert(rider, deck);
}
}
// Reconcile rather than re-stamp: an unchanged rider must not touch its component at all, so
// change detection stays quiet on a deck full of passengers.
for (model, current) in &stamped {
match want.remove(&model) {
Some(deck) if deck == current.0 => {}
Some(deck) => {
commands.entity(model).insert(RideFrame(deck));
}
None => {
commands.entity(model).remove::<RideFrame>();
}
}
}
for (model, deck) in want {
commands.entity(model).insert(RideFrame(deck));
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -159,6 +159,7 @@ pub mod model_render;
pub mod modkeys;
pub mod particles;
pub mod ribbons;
pub mod ride_frame;
pub mod rig_anim;
pub mod rig_palette;
pub mod rig_rider;
+11 -1
View File
@@ -146,6 +146,15 @@ pub struct ParticleEmitter {
/// Model→world (Bevy space): `world = placement · wow_to_bevy(local)`. Carries the placement scale.
/// Static for terrain doodads; refreshed each frame from [`Self::owner`] when set.
placement: Transform,
/// **The frame this cloud's WORLD-mode particles are stored in** — world on the ground, the
/// transport's deck while the owning model rides one ([`crate::ride_frame`], decision 1591).
///
/// The `0x10` CLEAR store bakes a birth into absolute coordinates and never re-applies a live
/// matrix, which is what makes a trail. On a transport that leaves a rider's cloud behind the
/// vehicle, so the reference divides the ride matrix out at birth (`rt+0x1fc = srcMx · A⁻¹`)
/// and folds it back at draw (`0xcf5b68 = A · T · S`). MODEL-mode (`0x10` SET) emitters take
/// the verbatim-copy arm and never see `A` at all — they already ride their host rigidly.
pub(crate) ride: crate::ride_frame::StoredFrame,
/// The entity this emitter belongs to, if it follows one (a streamed creature/GameObject). Each
/// frame the emitter copies the owner's world transform into [`Self::placement`] (so it tracks a
/// moving prop) and **drains** once the owner is gone. `None` for terrain doodads, which carry
@@ -393,7 +402,7 @@ impl ParticleEmitter {
.iter()
.map(|p| {
if anchored {
p.pos
self.ride.to_world(p.pos)
} else {
self.placement
.transform_point(benilla_assets::coords::wow_to_bevy([
@@ -635,6 +644,7 @@ pub fn spawn_emitter(
ParticleEmitter {
def,
placement,
ride: crate::ride_frame::StoredFrame::default(),
owner,
on_owner_loss: frames.on_owner_loss,
draining: false,
+4 -3
View File
@@ -194,9 +194,10 @@ pub(super) fn update_model_particles(
rgba[3] *= emitter.render_alpha();
let tf = if anchored {
Transform {
// World mode: the instance transform IS the stored one (decision 1585).
translation: p.pos,
rotation: p.quat,
// World mode: the instance transform IS the stored one (decision 1585) —
// through the ride frame, which is the identity off a transport (1591).
translation: emitter.ride.to_world(p.pos),
rotation: emitter.ride.rotation() * p.quat,
scale: Vec3::splat(size * inst_scale),
}
} else {
+14 -4
View File
@@ -62,6 +62,11 @@ pub(super) struct CamBasis {
/// coordinates reach the world (the anchored/model split of [`super::Particle`]'s doc).
pub(super) struct DrawFrame {
pub(crate) anchored: bool,
/// The WORLD-mode store's **ride frame** `A` ([`crate::ride_frame`], decision 1591) — the
/// reference's `[ebp+8]` at `0x7b3d20`, folded forward on the `0x7b3f4f` leg. `None` off a
/// transport, which is every cloud on the ground, and then every fold below is the identity.
/// Model mode never reads it: `0x7b3efb` leaves `[ebp+8]` untouched.
pub(crate) ride: crate::ride_frame::StoredFrame,
/// The owning MODEL's render alpha, folded into every particle's alpha channel — the
/// reference's `emitter+0x1a8` (decision 0827). 1.0 for a model that isn't fading.
pub(crate) alpha: f32,
@@ -72,9 +77,11 @@ pub(super) struct DrawFrame {
/// re-derivation that can drift.
pub(super) fn particle_center(frame: &DrawFrame, placement: &Transform, p: &Particle) -> Vec3 {
if frame.anchored {
// WORLD mode (`0x10` CLEAR): the store IS world and the draw folds nothing — the
// reference's `0x7b3f48`, which omits `rt+0x1fc` (decision 1585, wow-re §2c-B).
p.pos
// WORLD mode (`0x10` CLEAR): the store is absolute in its OWN frame and the draw never
// folds `rt+0x1fc` back — the reference's `0x7b3f48` (decision 1585, wow-re §2c-B). The
// one matrix that does fold is the ride frame, on the `0x7b3f4f` leg: `A · T · S`. Off a
// transport `A` is NULL and the store is world, which is the `0x7b3f95` leg.
frame.ride.to_world(p.pos)
} else {
// MODEL mode (`0x10` SET): the live emitter matrix, re-applied every frame — `0x7b3efb`.
placement.transform_point(wow_to_bevy([p.pos.x, p.pos.y, p.pos.z]))
@@ -249,7 +256,9 @@ pub(super) fn expand_quads(
if def.head_tail >= 1 {
let ((u0, u1), (v0, v1)) = cell_uv(ol.tail_cell);
let vel_world = if anchored {
p.vel
// The tail streak points back along the world velocity, so the stored direction
// takes the ride frame's rotation (never its translation — this is a vector).
frame.ride.dir_to_world(p.vel)
} else {
placement.rotation * (placement.scale * wow_to_bevy(p.vel.to_array()))
};
@@ -338,6 +347,7 @@ mod tests {
let shoot = |alpha| {
let frame = DrawFrame {
anchored: true,
ride: crate::ride_frame::StoredFrame::default(),
alpha,
};
let mut out = Vec::new();
+135 -12
View File
@@ -201,8 +201,9 @@ fn drive_child(
/// the portal query joins this bundle rather than becoming a seventeenth.
/// The **owner reads** — one bundle for the two questions this sim asks of an emitter's owner
/// (a joint, a unit root, a placement submesh; never an emitter or child-draw entity, so both
/// members are provably disjoint from the `&mut GlobalTransform` writes below): *where is it* and
/// *is its model being drawn*. A `SystemParam` rather than two parameters because
/// members are provably disjoint from the `&mut GlobalTransform` writes below): *where is it*,
/// *is its model being drawn*, and *what is it standing on*. A `SystemParam` rather than three
/// parameters because
/// [`simulate_particles`] sits at Bevy's 16-param ceiling, and because the pair is one concept —
/// the owner's frame — that no caller should be able to half-answer.
#[derive(bevy::ecs::system::SystemParam)]
@@ -210,6 +211,10 @@ pub(crate) struct Owners<'w, 's> {
transforms:
Query<'w, 's, &'static GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
visibility: Query<'w, 's, &'static InheritedVisibility>,
/// Which transport (if any) the owner's MODEL is riding — the frame world-mode particles are
/// stored in ([`crate::ride_frame`], decision 1591). Joined the bundle for the same reason the
/// portal query did: [`simulate_particles`] is at the ceiling.
rides: crate::ride_frame::RideFrames<'w, 's>,
}
impl Owners<'_, '_> {
@@ -671,6 +676,7 @@ pub(super) fn simulate_particles(
}
let ParticleEmitter {
def,
ride,
placement,
owner,
on_owner_loss,
@@ -791,6 +797,66 @@ pub(super) fn simulate_particles(
// `anchor` is shadowed by the draw anchor further down.
let dump_owner = (*host).or(*anchor);
// 0a. THE RIDE FRAME (`[CM2Model+0x17c]`, decision 1591) — which transport, if any, this
// cloud's MODEL is standing on. Inherited down the `ParentModel` chain, so a held
// weapon's sparkle takes its wearer's deck without either spawn site knowing.
// A transport that has streamed out reads as no frame: that is the leave leg, and it
// hands the cloud back to the world where it stood.
let deck = anchor
.or(*owner)
.and_then(|model| owners.rides.source(model))
.and_then(|t| {
owners
.transforms
.get(t)
.ok()
.map(|gt| (t, crate::ride_frame::ride_matrix(gt)))
})
// MODEL mode takes the reference's verbatim-copy arm at birth and ignores `[ebp+8]`
// at draw (`0x7b518e`/`0x7b3ef9`): it already rides its host rigidly, so `A` would
// ride it a second time.
.filter(|_| anchored);
// Boarding and leaving RE-EXPRESS every already-live particle — position and velocity
// (`0x7187f0` → `0x7b5e60`) — rather than snapping the cloud. Riding a *moving* deck folds
// nothing: the draw's live `A` is what carries it.
if let Some(fold) = ride.retarget(deck) {
let rot = Quat::from_mat3a(&fold.matrix3);
for p in particles
.iter_mut()
.chain(children.iter_mut().flat_map(|c| &mut c.particles))
{
p.pos = fold.transform_point3(p.pos);
p.vel = rot * p.vel;
}
// The emitter-motion state is stored-frame too, and it must cross the seam with the
// cloud: the Δ that keys the follow and inherit terms is a DIFFERENCE against last
// frame's origin, so leaving a stale one behind would read the frame change itself as
// ~1300 yd of emitter motion — one frame of `fraction` pinned at 1.0, which teleports
// the whole cloud on the step onto the lift. (The reference's `0x7b5e60` re-expresses
// "already-committed state" under the new parent; whether its `rt+0x248` is inside
// that is not settled at the bytes, and reproducing a one-frame pop would be building
// past the evidence either way.)
*emitter_prev = emitter_prev.map(|prev| fold.transform_point3(prev));
*inherit_vel = rot * *inherit_vel;
}
// The emitter matrix every birth is baked through: `rt+0x1fc = srcMx · A⁻¹` (`0x7b51b0`).
// Dividing the ride frame out HERE is what makes the whole rest of this system correct
// without a single further change — births, the child fold, the emitter-motion Δ, the
// inherit velocity and the kill-outbound origin all compose off this one transform, and
// all of them want the deck's frame while riding. `placement` itself stays WORLD: the
// emission LOD, the water-plane side and the model-mode draw are all world questions.
let emit_place = match ride.matrix() {
Some(a) => {
let inv = a.inverse();
Transform {
translation: inv.transform_point3(placement.translation),
rotation: Quat::from_mat3a(&inv.matrix3) * placement.rotation,
scale: placement.scale,
}
}
None => *placement,
};
// 0b. The EMITTER-MOTION terms (wow-re `part-emitter-motion.md`, byte-verified): both
// feed off the emitter origin's one-frame live world Δ. prevPos refreshes EVERY
// frame (the reference's rt+0x248 @`0x7b5265`), so even a multi-frame inherit
@@ -799,7 +865,10 @@ pub(super) fn simulate_particles(
// anchored mode; for model mode the reference adds the raw world vector to LOCAL
// coords (a frame quirk) — we fold it into the local frame instead, since our
// world axes are Bevy's, not WoW's (translation-dominant content is equivalent).
let emitter_world = placement.transform_point(wow_to_bevy(def.position));
// In the STORED frame, deliberately: the reference's `rt+0x22c` is the translation row of
// `rt+0x1fc`, which already has `A⁻¹` folded in — so on a deck a rider standing still has
// no emitter motion at all, and the follow/inherit terms key off deck-relative speed.
let emitter_world = emit_place.transform_point(wow_to_bevy(def.position));
let emitter_delta = emitter_prev.map_or(Vec3::ZERO, |prev| emitter_world - prev);
*emitter_prev = Some(emitter_world);
// NOTE on the R(+Z,90°) emitter-frame law (`emit_local`'s tail): we apply R at EMISSION,
@@ -826,7 +895,7 @@ pub(super) fn simulate_particles(
let follow = if fraction == 0.0 || emitter_delta == Vec3::ZERO {
Vec3::ZERO
} else {
to_stored(fraction * emitter_delta, placement)
to_stored(fraction * emitter_delta, &emit_place)
};
// VELOCITY INHERIT (file 0x40): the ~30 Hz trigger holds the inherit velocity births
// read; the live gate (rt+0x64) zeroes it while nothing is live.
@@ -954,8 +1023,9 @@ pub(super) fn simulate_particles(
// re-applies the live matrix instead (`0x7b8aa5`/`0x7b3efb`).
let (mut pos, vel) = if anchored {
(
placement.transform_point(wow_to_bevy(base.to_array())),
placement.rotation * (placement.scale * wow_to_bevy((dir * speed).to_array())),
emit_place.transform_point(wow_to_bevy(base.to_array())),
emit_place.rotation
* (emit_place.scale * wow_to_bevy((dir * speed).to_array())),
)
} else {
(base, dir * speed)
@@ -967,8 +1037,14 @@ pub(super) fn simulate_particles(
// SIZE. A miss leaves the spawn position untouched. `pos` is already world here, so
// the probe reads and writes it directly.
if anchored && def.ground_snap() {
if let Some(hit) = spatial.cast_ray(pos, Dir3::NEG_Y, 20.0, true, &snap_filter) {
pos.y = pos.y - hit.distance + def.over_life.sample(0.0).size;
// The probe is a WORLD query against world geometry, so it runs on the world
// point and the answer comes back into the store (1591 — the identity off a
// transport). The drop is along world down, which a yaw-only `A` leaves alone.
let world = ride.to_world(pos);
if let Some(hit) = spatial.cast_ray(world, Dir3::NEG_Y, 20.0, true, &snap_filter) {
pos = ride.to_stored(
world.with_y(world.y - hit.distance + def.over_life.sample(0.0).size),
);
}
}
// VELOCITY INHERIT consumption (the shape kernels' closing block, gated on the
@@ -976,7 +1052,7 @@ pub(super) fn simulate_particles(
// stored frame (the block runs after the reference's space fold).
let vel = if def.inherits_emitter_motion() && *inherit_vel != Vec3::ZERO {
vel + (1.0 + now.speed_variation * rand_s11(rng))
* to_stored(*inherit_vel, placement)
* to_stored(*inherit_vel, &emit_place)
} else {
vel
};
@@ -1007,7 +1083,7 @@ pub(super) fn simulate_particles(
// yaw appended to the frame rotation in both modes.
let r90 = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
let quat = if anchored {
placement.rotation * r90
emit_place.rotation * r90
} else {
r90
};
@@ -1048,7 +1124,7 @@ pub(super) fn simulate_particles(
density * dist_lod,
dt,
anchored,
placement,
&emit_place,
);
let c_env = StepEnv {
dt,
@@ -1084,6 +1160,7 @@ pub(super) fn simulate_particles(
let frame = DrawFrame {
anchored,
alpha: *alpha,
ride: *ride,
};
let cam = CamBasis {
right: e_right,
@@ -1154,7 +1231,9 @@ pub(super) fn simulate_particles(
particles,
&frame,
placement,
emitter_world,
// The instrument's frame is the world's, so hand it the world point (the
// sim's own `emitter_world` is the STORE's origin — 1591).
ride.to_world(emitter_world),
&cam,
cam_tf,
camera,
@@ -1397,6 +1476,7 @@ mod tests {
let frame = DrawFrame {
anchored: true, // 0x10 CLEAR — the world store
alpha: 1.0,
ride: crate::ride_frame::StoredFrame::default(), // on the ground: no fold
};
// Every host pose we can think of, including ones no bone reaches.
for placement in [
@@ -1415,6 +1495,48 @@ mod tests {
}
}
/// …with exactly one exception, and it is the reason the ride frame exists (decision 1591):
/// a world-mode particle follows the **transport** its host is standing on, because its store
/// is the deck's frame and the draw re-projects through the deck's live pose (`A · T · S`,
/// `0x7b3f4f`). This is the director's Thunder Bluff report: without it a weapon's sparkle
/// stays at the height it was born and the rising lift streams it out below your feet.
#[test]
fn a_world_mode_particle_rides_the_transport_under_it() {
use crate::particles::quads::{particle_center, DrawFrame};
use bevy::math::Affine3A;
let mut ride = crate::ride_frame::StoredFrame::default();
let deck = bevy::prelude::Entity::from_raw_u32(7).expect("a valid test entity id");
let lift_low = Affine3A::from_translation(Vec3::new(-1280.0, 60.0, 185.0));
// Board at the bottom of the shaft, then ride 30 yd up.
let fold = ride
.retarget(Some((deck, lift_low)))
.expect("boarding folds");
let born_at = Vec3::new(-1279.0, 61.5, 186.0);
let p = particle(fold.transform_point3(born_at), Vec3::ZERO);
let placement = Transform::IDENTITY; // model mode's input; world mode must ignore it
let mut frame = DrawFrame {
anchored: true,
alpha: 1.0,
ride,
};
assert_eq!(
particle_center(&frame, &placement, &p),
born_at,
"boarding moved a particle that was standing still"
);
let lift_high = Affine3A::from_translation(Vec3::new(-1280.0, 90.0, 185.0));
assert!(
ride.retarget(Some((deck, lift_high))).is_none(),
"the deck MOVING must not re-express the store — only boarding and leaving do"
);
frame.ride = ride;
assert_eq!(
particle_center(&frame, &placement, &p),
born_at + Vec3::new(0.0, 30.0, 0.0),
"the particle did not ride the lift up"
);
}
/// The other half of the same law: model mode (`0x10` SET) rides its host rigidly, rotation
/// included — the carried torch. Storage space is the whole discriminator, so these two tests
/// together are the ride/trail law at the draw.
@@ -1425,6 +1547,7 @@ mod tests {
let frame = DrawFrame {
anchored: false, // 0x10 SET — the emitter-local store
alpha: 1.0,
ride: crate::ride_frame::StoredFrame::default(),
};
let moved = Transform::from_translation(Vec3::new(10.0, 0.0, 0.0));
assert_eq!(
+88 -7
View File
@@ -50,8 +50,9 @@ pub struct RibbonVerdict {
pub drawn: usize,
}
/// One committed trail edge: the vertex pair across the node, world (Bevy) space, and its birth
/// time on the shared clock.
/// One committed trail edge: the vertex pair across the node, in the trail's **stored** frame
/// ([`RibbonTrail::ride`] — world on the ground, the deck on a transport), and its birth time on
/// the shared clock.
struct Edge {
top: Vec3,
bottom: Vec3,
@@ -129,8 +130,20 @@ pub struct RibbonTrail {
/// [`crate::particles::EmitterFade`] itself takes, and with the same far-clip backstop applied
/// in [`simulate_ribbons`] for the one lane server visibility does not bound (transports).
fade: Option<crate::particles::EmitterFade>,
/// Committed edges, newest at the back. The live head (the current node) is appended at
/// render time only, so the trail always connects to the emitter between commits.
/// **The frame the committed edges are expressed in** — world on the ground, the transport's
/// deck while the owning model rides one ([`crate::ride_frame`], decision 1591).
///
/// A ribbon's whole point is that a committed edge never moves again, which is what draws a
/// streak behind a swinging weapon. On a transport that same rule streams the streak off the
/// back of the vehicle, so the reference stores the edges in the ride frame instead and
/// re-projects them through its live pose at draw — the ribbon twins of the particle folds
/// (birth `0x718dd8` → `0x7b76c0`, draw `0x70d87d` → `0x7b80c0`), and note the negative: the
/// ribbon birth has **no `0x100` gate**, so a trail is ride-framed unconditionally where a
/// particle cloud is only in world mode.
ride: crate::ride_frame::StoredFrame,
/// Committed edges, newest at the back, in [`Self::ride`]'s frame. The live head (the current
/// node) is appended at render time only, so the trail always connects to the emitter between
/// commits.
edges: VecDeque<Edge>,
accumulator: f32,
/// Seconds since spawn — the clip clock the keyed look tracks (colour/alpha/heights) sample
@@ -156,6 +169,27 @@ impl RibbonTrail {
self.def.bone
}
/// The committed strip in **world** space — the points the draw rasterizes, folded out of the
/// store's ride frame ([`Self::ride`]). For instruments: the trail census measures this
/// strip's spread inside the DECK's frame, which is exactly the observable the ride law is
/// about ("does the streak hang still relative to the floor you're standing on?").
pub fn strip_world(&self) -> impl Iterator<Item = Vec3> + '_ {
self.edges
.iter()
.flat_map(move |e| [self.ride.to_world(e.top), self.ride.to_world(e.bottom)])
}
/// The transport this trail's edges are stored against — `None` on the ground.
pub fn deck(&self) -> Option<Entity> {
self.ride.source()
}
/// The authored edge lifetime (seconds) — the trail's own length in time, which is what a
/// streak's world extent divides by to give the host speed that drew it.
pub fn edge_lifetime(&self) -> f32 {
self.def.edge_lifetime
}
/// The authored blend, and how many edges are committed right now (0 = nothing drawn yet).
pub fn shape(&self) -> (ParticleBlend, usize) {
(self.def.blend, self.edges.len())
@@ -234,6 +268,7 @@ pub fn spawn_ribbon(
seq,
alpha_src,
fade,
ride: crate::ride_frame::StoredFrame::default(),
edges: VecDeque::new(),
accumulator: 0.0,
age: 0.0,
@@ -276,6 +311,8 @@ pub(crate) fn simulate_ribbons(
// bundle `simulate_particles` reads, so the two emitter families cannot answer differently
// ([`RibbonTrail::fade`]).
gates: crate::particles::sim::SceneGates,
// Which transport (if any) each trail's MODEL is riding — the frame its edges are stored in.
rides: crate::ride_frame::RideFrames,
mut verdict: ResMut<RibbonVerdict>,
mut trails: Query<
(
@@ -308,6 +345,7 @@ pub(crate) fn simulate_ribbons(
seq,
alpha_src,
fade,
ride,
edges,
accumulator,
age,
@@ -340,6 +378,35 @@ pub(crate) fn simulate_ribbons(
}
verdict.trails += 1;
// **The ride frame, this frame** — the transport the owning MODEL is on, inherited down
// the `ParentModel` chain (an enchant streamer's model is the weapon; the weapon's parent
// is its wearer). A transport that has streamed out reads as no frame, which is the leave
// leg: the edges are handed back to the world where they stood.
let deck = alpha_src
.or(*owner)
.and_then(|model| rides.source(model))
.and_then(|t| {
transforms
.get(t)
.ok()
.map(|gt| (t, crate::ride_frame::ride_matrix(gt)))
});
// Boarding and leaving RE-EXPRESS the committed edges rather than snapping them (the
// reference's `0x7187f0` → `0x7b7bc0` on the NULL↔non-NULL edge). Riding a *moving* deck
// folds nothing at all — the draw's live `A` is what carries the strip, and touching the
// store as well would ride it twice.
if let Some(fold) = ride.retarget(deck) {
for e in edges.iter_mut() {
e.top = fold.transform_point3(e.top);
e.bottom = fold.transform_point3(e.bottom);
}
}
let (to_deck, to_world) = match ride.matrix() {
// One inverse per trail per frame, not one per edge.
Some(a) => (Some(a.inverse()), Some(a)),
None => (None, None),
};
// The **draw-set** gate: is this trail's MODEL in the frame's scene at all? A trail is one
// of its model's emitters, and the reference ticks a model's emitters inside that model's
// draw step — so a culled model's trail neither commits nor ages. FROZEN, the same shape
@@ -472,6 +539,10 @@ pub(crate) fn simulate_ribbons(
// cannot explain at all. Our old `pos.y -= 2·g·dt` was wrong twice over: constant velocity
// instead of `g·t²`, and falling instead of rising. On the Frost Trap that turned a
// 0.6–1.3 yd tuft rising off the crown into a 2–3 yd column smeared to the ground.
// The gravity axis is world up in EITHER store: `A` is `translate·Rz` (yaw only), and a
// yaw leaves `+Y` alone — so a trail sags/rises the same way on a deck as on the ground,
// with no fold. That invariance is why [`crate::ride_frame::ride_matrix`] drops
// pitch/roll rather than carrying the transport's full rotation.
for e in edges.iter_mut() {
let term = gravity_step(def.gravity, e.age, dt);
e.top.y += term;
@@ -484,6 +555,13 @@ pub(crate) fn simulate_ribbons(
// silently thins every trail below `eps` frames per second — the whole trail, at 30 fps
// with the Frost Trap's `eps` 30, is half the edges it should hold.
if let Some((node, axis)) = head {
// BIRTH fold: the node and its cross-section axis are live WORLD values (the owner's
// bone matrix); an edge is committed in the store's frame. Off a transport this is the
// identity, which is the whole world.
let (node, axis) = match to_deck {
Some(inv) => (inv.transform_point3(node), inv.transform_vector3(axis)),
None => (node, axis),
};
*accumulator += def.edges_per_second * dt;
let n = accumulator.floor().max(0.0);
*accumulator -= n;
@@ -545,9 +623,12 @@ pub(crate) fn simulate_ribbons(
// The trail's SORT anchor — the live head node (the point the material path's entity
// translation used to carry; same sort-tie flashing fix as the particle clouds).
// Draining trails anchor on their newest surviving edge.
// DRAW fold: stored → world (`0xcf5b68 = A · T · S`). The live head is already world —
// it was never committed.
let out = |p: Vec3| to_world.map_or(p, |a| a.transform_point3(p));
let anchor = head.map(|(node, _)| node).unwrap_or_else(|| {
let e = edges.back().expect("n >= 2 ⇒ edges exist while draining");
(e.top + e.bottom) * 0.5
out((e.top + e.bottom) * 0.5)
});
entity_tf.translation = anchor;
// Post-propagation frame: publish directly (see the particle sim's matching note; trail
@@ -562,8 +643,8 @@ pub(crate) fn simulate_ribbons(
}
for e in edges.iter().rev() {
pairs.push((
e.top,
e.bottom,
out(e.top),
out(e.bottom),
((now - e.born) / def.edge_lifetime).clamp(0.0, 1.0),
));
}
+229
View File
@@ -0,0 +1,229 @@
//! The **ride frame** — `[CM2Model+0x17c]`, the world matrix of the transport a model is riding.
//!
//! A world-mode particle cloud (`0x10` CLEAR) and *every* ribbon trail store their committed
//! content in absolute coordinates and never re-apply a live matrix to it. That is what makes a
//! trail: run past a torch and the smoke hangs where it was born. On a **transport** the same
//! rule would stream a rider's effects off the stern, so the reference stores them in the
//! transport's frame instead and re-projects them live at draw. The mechanism is one matrix
//! entering at exactly two sites, and they are inverses (wow-re
//! `system/models/scratch/attach-matrix-is-the-transport-matrix.md`, §5 trio + orchestrator
//! arbitration; benilla decision 1591, which named this gap and left it unbuilt):
//!
//! ```text
//! A = translate(transport.pos) · Rz(transport.facing) // 0x630ac0 — position and Z-facing only
//! = NULL for a model not riding anything
//!
//! BIRTH 0x7b5160: rt+0x1fc = srcMx · A⁻¹ (A ≠ 0 and 0x100 CLEAR; else verbatim)
//! DRAW 0x7b3d20: 0xcf5b68 = A · T · S (0x100 CLEAR, [ebp+8] ≠ 0; else T·S)
//! EDGE 0x7187f0: on A changing NULL↔non-NULL, re-express every ALREADY-LIVE particle's
//! position and velocity (0x7b5e60) and every ribbon's (0x7b7bc0)
//! ```
//!
//! So a rider's effects are *stored* on the deck and *drawn* through the deck's live pose — they
//! hang still relative to the deck, and boarding or leaving re-expresses the cloud instead of
//! snapping it. The residual over a particle's life is exactly `A(t₀)⁻¹·A(t₁)`: the **transport's**
//! motion since birth, never the host unit's own.
//!
//! **Where `A` lives.** On the MODEL, not the bone and not the emitter — and the reference copies
//! the parent's pointer down to every child model each frame (`0x7142c1`, inside `m2_animate`), so
//! a held weapon's enchant streamer inherits its wearer's transport without knowing anything about
//! it. Our model child list is [`crate::model_fade::ParentModel`], so [`RideFrames`] walks that
//! same chain — one component on the rider, inherited by everything hung off it.
use bevy::ecs::system::SystemParam;
use bevy::math::Affine3A;
use bevy::prelude::*;
use crate::model_fade::{ParentModel, MAX_MODEL_CHAIN};
/// **This model instance is riding a transport** — the reference's `[CM2Model+0x17c]`, as the
/// transport's entity rather than a copied matrix (the pose is read live, so a stored copy could
/// only go stale).
///
/// Written by the layer that knows who is standing on what (`benilla-app`'s transport module) on
/// the **rider's own model entity**; everything chained to it through [`ParentModel`] — held
/// items, their enchant glows, spell kits — inherits it through [`RideFrames`], exactly as the
/// reference's per-frame child copy does.
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub struct RideFrame(pub Entity);
/// `A = translate(pos) · Rz(facing)` from a transport's live pose — **position and Z-facing only**,
/// which is all `0x630ac0` builds it from (vtable `+0x14` position, `+0x18` Z-facing). Scale and
/// any pitch/roll are deliberately dropped: `A` is a rigid ride-frame, and its inverse is applied
/// to already-committed content, so anything non-rigid here would shear a live cloud.
///
/// Dropping pitch/roll is also what keeps gravity frame-free: world down is invariant under a yaw,
/// so a stored cloud integrates with the same `-Y` on a deck as on the ground.
pub fn ride_matrix(gt: &GlobalTransform) -> Affine3A {
let (_, rot, translation) = gt.to_scale_rotation_translation();
let yaw = rot.to_euler(EulerRot::YXZ).0;
Affine3A::from_rotation_translation(Quat::from_rotation_y(yaw), translation)
}
/// Resolve a model instance's ride frame by walking the [`ParentModel`] chain — the reference's
/// per-frame propagation of `[model+0x17c]` down the child list (`0x7142c1`), as a lookup.
///
/// Read-only and cheap: the chains are 1–3 links (unit → item → glow) and the walk stops at the
/// first [`RideFrame`], so a world that is riding nothing pays one failed component fetch per
/// emitter.
#[derive(SystemParam)]
pub struct RideFrames<'w, 's> {
chain: Query<'w, 's, (Option<&'static RideFrame>, Option<&'static ParentModel>)>,
}
/// `WOW_NO_RIDE_FRAME=1` — the **A/B lever** (the shape `WOW_NO_FRAME_PACE` and
/// `WOW_NO_RIG_REBASE` set): resolve no ride frame at all, which is the pre-1591 behaviour where a
/// rider's world-mode effects are stored against the world and stream off the back of the deck.
///
/// It earns its branch because this law is *only* observable on a moving transport: an A/B needs a
/// lift, a live login and a census on both sides, and without the lever the "before" half is a
/// second checkout and a cold build every time anyone asks. Read once — an emitter asks this
/// question every frame.
fn disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| std::env::var_os("WOW_NO_RIDE_FRAME").is_some())
}
impl RideFrames<'_, '_> {
/// The transport `instance` rides, inherited from its model chain. `None` on the ground —
/// which is every model in the world that is not standing on a boat, a zeppelin or a lift.
pub fn source(&self, instance: Entity) -> Option<Entity> {
if disabled() {
return None;
}
let mut at = instance;
for _ in 0..MAX_MODEL_CHAIN {
let Ok((own, parent)) = self.chain.get(at) else {
break;
};
if let Some(RideFrame(transport)) = own {
return Some(*transport);
}
at = parent?.0;
}
None
}
}
/// The frame an emitter's already-committed content is stored in, and the edge detector that keeps
/// it honest — the state behind the reference's `0x7187f0` re-expression.
///
/// Carries the transport's **entity** (the identity: has the frame changed?) *and* its last-known
/// **matrix** (the value: what a leave must re-express through, which must survive the transport
/// itself despawning).
#[derive(Clone, Copy, Default)]
pub struct StoredFrame(Option<(Entity, Affine3A)>);
impl StoredFrame {
/// Move the store to `now`, returning the rigid transform every already-stored **point** must
/// be re-expressed by (directions take its rotation) — `None` when the frame kept its identity,
/// which is the steady state both on the ground and while riding.
///
/// Boarding folds `A⁻¹` (world → deck), leaving folds the old `A` (deck → world), and stepping
/// from one transport straight to another does both. The reference only wires the NULL↔non-NULL
/// edges, because `SetMoveBase` clears before it sets; composing the two is the same answer for
/// the case it never sees.
pub fn retarget(&mut self, now: Option<(Entity, Affine3A)>) -> Option<Affine3A> {
let fold = match (self.0, now) {
(None, Some((_, a))) => Some(a.inverse()),
(Some((_, old)), None) => Some(old),
(Some((e0, old)), Some((e1, a))) if e0 != e1 => Some(a.inverse() * old),
_ => None,
};
self.0 = now;
fold
}
/// The live `A` stored content is expressed in — `None` in world space, where every fold in
/// this module is the identity and costs nothing.
pub fn matrix(&self) -> Option<Affine3A> {
self.0.map(|(_, a)| a)
}
/// `stored → world`: the draw fold (`0xcf5b68 = A · T · S`).
pub fn to_world(&self, p: Vec3) -> Vec3 {
self.0.map_or(p, |(_, a)| a.transform_point3(p))
}
/// `stored → world` for a **direction** (a velocity): rotation only.
pub fn dir_to_world(&self, v: Vec3) -> Vec3 {
self.0.map_or(v, |(_, a)| a.transform_vector3(v))
}
/// The transport whose frame this store is expressed in — for instruments, which need to name
/// the deck a trail is measured against.
pub fn source(&self) -> Option<Entity> {
self.0.map(|(e, _)| e)
}
/// The store's own rotation — `Rz(facing)` — for a stored ORIENTATION rather than a point
/// (a 3-D model particle's instance quat).
pub fn rotation(&self) -> Quat {
self.0
.map_or(Quat::IDENTITY, |(_, a)| Quat::from_mat3a(&a.matrix3))
}
/// `world → stored`: the birth fold (`rt+0x1fc = srcMx · A⁻¹`), for the one-off world reads
/// that have to come back (a ground-snap probe hit).
pub fn to_stored(&self, p: Vec3) -> Vec3 {
self.0.map_or(p, |(_, a)| a.inverse().transform_point3(p))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn deck(pos: Vec3, yaw: f32) -> (Entity, Affine3A) {
(
Entity::from_raw_u32(1).expect("a valid test entity id"),
Affine3A::from_rotation_translation(Quat::from_rotation_y(yaw), pos),
)
}
/// The steady states — on the ground and riding — must fold NOTHING. A re-expression that
/// fired every frame while riding would drag the cloud along with the deck twice over, which is
/// the rigid-ride bug the trail law exists to avoid.
#[test]
fn only_the_boundary_re_expresses() {
let mut f = StoredFrame::default();
assert!(f.retarget(None).is_none(), "ground → ground");
let a = deck(Vec3::new(10.0, 5.0, 0.0), 0.5);
assert!(f.retarget(Some(a)).is_some(), "board");
assert!(f.retarget(Some(a)).is_none(), "riding, deck unmoved");
// The SAME transport at a NEW pose is still the same frame: the draw's live `A` carries
// the cloud, so nothing stored may be touched. This is the whole mechanism.
let moved = (a.0, Affine3A::from_translation(Vec3::new(10.0, 40.0, 0.0)));
assert!(f.retarget(Some(moved)).is_none(), "the lift rose");
assert!(f.retarget(None).is_some(), "leave");
}
/// Boarding must leave a live particle exactly where it was in the world: the birth fold and
/// the draw fold are inverses, so `A · (A⁻¹ · p) == p`.
#[test]
fn boarding_moves_nothing_on_screen() {
let mut f = StoredFrame::default();
let world = Vec3::new(-1286.0, 76.0, 189.0);
let a = deck(Vec3::new(-1280.0, 60.0, 185.0), 1.1);
let fold = f.retarget(Some(a)).expect("boarding re-expresses");
let stored = fold.transform_point3(world);
assert!(
(f.to_world(stored) - world).length() < 1e-3,
"stored {stored:?} draws back to {:?}, not {world:?}",
f.to_world(stored)
);
}
/// …and leaving is the same identity in the other direction, so stepping off a moving lift
/// hands the cloud back to the world where it stood rather than snapping it to the origin.
#[test]
fn leaving_hands_the_cloud_back_in_place() {
let mut f = StoredFrame::default();
let a = deck(Vec3::new(-1280.0, 60.0, 185.0), 1.1);
f.retarget(Some(a));
let stored = Vec3::new(2.0, 1.5, -0.5);
let world = f.to_world(stored);
let fold = f.retarget(None).expect("leaving re-expresses");
assert!((fold.transform_point3(stored) - world).length() < 1e-3);
}
}