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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user