use avian3d::prelude::SpatialQuery; use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use bevy::camera::primitives::{Frustum, Sphere as CullSphere}; use bevy::camera::visibility::RenderLayers; use bevy::camera::Projection; use bevy::prelude::*; use crate::view::WorldCamera; use super::buffer::{ EffectDrawSpec, EffectFog, EffectLightOverride, EffectLighting, EffectQuads, EffectVertex, }; use super::quads::{expand_quads, CamBasis, DrawFrame}; use super::{ accumulate_emission, emit_local, next_u32, rand01, rand_s11, ChildDraw, ChildEmitter, EmitterFade, OwnerLoss, Particle, ParticleEmitter, ParticleTuning, MAX_PARTICLES, }; /// The emitter-constant inputs of one integrator step ([`integrate_particle`]). struct StepEnv { dt: f32, gravity: f32, drag: f32, anchored: bool, kill_origin: Option, /// The frame's shared follow-delta vector, stored frame (zero when unauthored). follow: Vec3, } /// One step of the verified vanilla integrator (`particle_integrate` @ `0x7b2680`): age/kill, /// the follow-delta add (before the velocity step, skipped once on a fresh particle — the /// `0x7b2744` branch), `pos += dt·v` with the gravity term on the frame's up axis /// (`pos.up −= ½·g·dt²`, `v.up −= g·dt`), then drag (`v −= min(dt·drag, 1)·v`) — and, for a /// sphere KILL-OUTBOUND emitter (`rt 0x800`, /// [`benilla_formats::ParticleEmitterDef::kill_outbound`]), the reference's tail test: the /// particle dies the frame `dot(stepVelocity, pos − origin) > 0`, where `stepVelocity` is the /// pre-gravity, pre-drag velocity (exactly the value the position update consumed — the byte /// order at `0x7b2680`) and `origin` is the emitter origin in the stored frame (the reference's /// stored coords put the emitter at zero; ours carry the anchor/bone offset, so the caller /// re-origins — same geometry). Returns false = dead. fn integrate_particle(p: &mut Particle, env: &StepEnv) -> bool { let (dt, g) = (env.dt, env.gravity); p.age += dt; // Kill at the particle's OWN birth-sampled lifetime (the emitter's lifespan channel // animates — decision 0844; the reference feeds each spawn the current value). if p.age >= p.life { return false; } // FOLLOW-DELTA (file 0x4000, wow-re `part-emitter-motion.md` §2): the shared per-frame // fraction of the emitter's motion, applied to every particle EXCEPT on its first // integrate (the reference's particle+0xd bit — set at spawn, cleared here unconsumed). if p.fresh { p.fresh = false; } else { p.pos += env.follow; } // MODEL-particle tumble (`0x7b28e0`, wow-re `part-model-particles.md`): the Rodrigues // half-angle Δquat, body-frame right-multiply, skipped below the reference's 1e-4 // threshold — then the shared linear integrator below. Quad particles carry zero. let theta = p.angvel.length(); if theta > 1e-4 { p.quat = (p.quat * Quat::from_axis_angle(p.angvel / theta, theta * dt)).normalize(); } let step_vel = p.vel; p.pos += p.vel * dt; if env.anchored { p.pos.y -= 0.5 * g * dt * dt; p.vel.y -= g * dt; } else { p.pos.z -= 0.5 * g * dt * dt; p.vel.z -= g * dt; } if env.drag != 0.0 { let f = (dt * env.drag).min(1.0); p.vel -= f * p.vel; } if let Some(origin) = env.kill_origin { if step_vel.dot(p.pos - origin) > 0.0 { return false; } } true } /// **The follow-delta fraction** (file flag `0x4000`, rt `0x40000`) — the share of the emitter's /// per-frame world translation that is added to every live particle on top of whatever its /// storage space already gives it (`speed` is `|Δ| / dt`, the yd/s the authored response is /// keyed on). Zero when unauthored, and zero for a degenerate response (equal authored speeds, /// which the reference answers by zeroing both). /// /// **The ride-vs-trail baseline is NOT here** — it is the storage space itself (decision 1585). /// wow-re `part-emitter-motion.md` §2c-B: file `0x10` SET stores the raw emitter-LOCAL pos/vel /// (`0x7b8aa5`) and the draw folds the live emitter matrix `rt+0x1fc` back in every frame /// (`0x7b3efb`) ⇒ a rigid ride, keep `1.0`, for free. CLEAR bakes pos/vel through that matrix /// into WORLD at birth (`0x7b8acf`/`0x7b8b0f`) and the draw does *not* fold `rt+0x1fc` /// (`0x7b3f48`) ⇒ world-frozen, keep `0.0`, also for free. Spending a per-frame correction term /// to *simulate* either baseline is what decision 1578 did, and it could only ever approximate /// the translation half — see 1585. /// /// This term is read independently of `0x10`: `0x7b5303` builds `rt+0x26c` and `0x7b2744` adds /// `rt+0x278` whatever `0x100` says. Over the CLEAR baseline it recovers a ride fraction — /// ArcaneShot's head glow, 0.1 @ 2.5 yd/s → 0.9 @ 16.7, riding the arrowhead while its three /// unflagged siblings hang frozen behind it. Over a SET baseline (12 emitters corpus-wide carry /// both) it genuinely leads, and we reproduce that literally. fn follow_fraction(def: &benilla_formats::ParticleEmitterDef, speed: f32) -> f32 { if !def.follow_emitter() { return 0.0; } def.follow_line().map_or(0.0, |(slope, intercept)| { (slope * speed + intercept).clamp(0.0, 1.0) }) } /// The velocity-inherit trigger (wow-re `part-emitter-motion.md` §1, `0x7b5230` bytes /// 0x7b53ce–0x7b54ca): accumulate dt; once past 1/30 s (`_DAT_0081d82c`), hold /// `oneFrameΔ · ((1/30)/accum) · scale` — zeroed while nothing is live — and reset. Between /// triggers the held value stands (births keep reading it). The exact factor carries the ×1/30 /// the first transcription sketch missed (a ~30× over-kick). fn inherit_trigger(accum: &mut f32, held: &mut Vec3, dt: f32, delta: Vec3, live: bool, scale: f32) { const INTERVAL: f32 = 1.0 / 30.0; *accum += dt; if *accum > INTERVAL { *held = if live { delta * (INTERVAL / *accum) * scale } else { Vec3::ZERO }; *accum = 0.0; } } /// The per-frame CHILD drive (wow-re `part-child-recursion.md`, VERIFIED): each child's spawn /// accumulation runs **once per live parent particle** — the reference's `0x7b5b9f` call with /// the CHILD receiver and the parent context's translation swapped to the particle's /// post-integration position — so births land at whichever particle's call tips the child's /// own `rate·dt` accumulator (volume scales with the parent's live count), composed through /// the PARENT's rotation fold (the child's record position never composes — the context /// translation is REPLACED by the particle). Child file flag 0x40 routes the parent /// PARTICLE's velocity into the child's inherit add (`(1+S11·var)·v` — for children the /// inherit vector IS the particle velocity, copied per call at `0x7b5b5e`). A burst child /// latches on its first call of the rising-edge frame. A child never self-emits ambiently. #[allow(clippy::too_many_arguments)] // the birth fold's full frame, same as the parent path fn drive_child( child: &mut ChildEmitter, now: &benilla_formats::ParamsNow, parent: &[Particle], rate: f32, emitting: bool, scale: f32, dt: f32, anchored: bool, placement: &Transform, ) { let origin = Vec3::from(child.def.position); for p in parent { accumulate_emission( child.def.burst(), rate, emitting, scale, dt, &mut child.accumulator, &mut child.gate_prev, ); while child.accumulator >= 1.0 && child.particles.len() < MAX_PARTICLES { child.accumulator -= 1.0; let (base, dir) = emit_local(&child.def, now, &mut child.rng); let local = base - origin; let speed = now.emission_speed * (1.0 + now.speed_variation * rand_s11(&mut child.rng)); // The parent particle's position is world in world mode (1585), so the child's // offset/velocity fold is rotation+scale into WORLD — no frame is divided out. let fold = |v: Vec3| { if anchored { placement.rotation * (placement.scale * wow_to_bevy(v.to_array())) } else { v } }; let mut vel = fold(dir * speed); if child.def.inherits_emitter_motion() { vel += (1.0 + now.speed_variation * rand_s11(&mut child.rng)) * p.vel; } let phase = next_u32(&mut child.rng); child.particles.push(Particle { pos: p.pos + fold(local), vel, age: 0.0, life: now.lifespan, phase, fresh: true, quat: Quat::IDENTITY, angvel: Vec3::ZERO, }); } } } /// The **draw-set gate's** scene inputs, bundled: the far-clip wall the gate bounds emitters at /// (0678), the exterior-window test a WMO interior applies to everything outside it (0786) with the /// camera's own room as its one exemption, and the per-frame portal PVS the rooms *inside* a /// building are gated by (0689/1289). One `SystemParam` because they are read together, at one /// place, and Bevy caps a system at 16 parameters — which `simulate_particles` already sits at, so /// 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*, /// *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)] pub(crate) struct Owners<'w, 's> { transforms: Query<'w, 's, &'static GlobalTransform, (Without, Without)>, 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<'_, '_> { /// The owner's live world position — read every frame, never from a stored anchor: a frozen /// emitter stops refreshing its anchor, so gating on the stored one would latch a moving owner /// out of existence the first time it crossed the wall. fn at(&self, owner: Entity) -> Option { self.transforms.get(owner).ok().map(|gt| gt.translation()) } /// Is the owner's model NOT being drawn this frame — the entity lane's draw-set term (decision /// 1409). Last propagate's verdict (one frame of lag on a hide that holds while the model is /// culled), and an owner we cannot read is not hidden: it falls through to the drain path. fn hidden(&self, owner: Entity) -> bool { matches!(self.visibility.get(owner), Ok(v) if !v.get()) } } #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct SceneGates<'w, 's> { view: Res<'w, crate::view::ViewDistance>, exterior_windows: Res<'w, crate::wmo_portal::ExteriorWindows>, camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>, portals: Query<'w, 's, &'static crate::wmo_portal::WmoPortalInstance>, } impl SceneGates<'_, '_> { /// The three per-frame values every draw-set caller needs before it can ask /// [`EmitterFade::in_draw_set`]: the far-clip wall, the built exterior gate, and the placement /// the camera is standing in. Built ONCE per walk (the gate is a handful of 6-plane frusta). pub(crate) fn scene( &self, cam: Option<(&GlobalTransform, &Projection)>, ) -> (f32, crate::exterior_cull::ExteriorGate, Option) { ( self.view.farclip, crate::exterior_cull::ExteriorGate::build(&self.exterior_windows, cam), self.camera_claim.0.map(|c| c.room.instance), ) } /// This emitter's term 5 — the live placement its rooms belong to, resolved and asked. pub(crate) fn room_admits(&self, fade: &EmitterFade) -> bool { fade.room_admitted( fade.room .as_ref() .and_then(|r| self.portals.get(r.instance).ok()), ) } } /// The **water-plane interleave** inputs ([`crate::sky_order::FAR_SIDE_BIAS`], where the /// byte story lives): the loaded liquid surfaces, the eye's submersion verdict, and the room /// components an anchor's liquid claim resolves from. One `SystemParam` for the same 16-cap /// reason as [`SceneGates`]. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct WaterInterleave<'w, 's> { water: Query<'w, 's, &'static crate::liquid::WaterChunkInfo>, /// The spatial pre-filter every classification routes through: these lanes ask per DRAW /// (every admitted emitter, every ribbon, every transparent mesh batch), and the full-walk /// `surfaces_at` at that grain was the 2026-08-03 12-fps regression (`liquid::spatial`). index: Res<'w, crate::liquid::WaterIndex>, underwater: Res<'w, crate::liquid::Underwater>, rooms: Query<'w, 's, &'static crate::wmo_portal::UnitWmoRoom>, placements: crate::liquid::RoomPlacements<'w, 's>, parents: Query<'w, 's, &'static ChildOf>, } impl WaterInterleave<'_, '_> { /// The eye's side of the water — the term that inverts EVERY verdict (`far = above XOR /// submerged`). The mesh lane's reactive classifier re-runs its full walk on the frame this /// flips; per-draw callers just read it through [`far_side_of_water_at`]. pub(crate) fn eye_submerged(&self) -> bool { self.underwater.0.any() } /// True when the loaded-surface population changed since the borrowing system last ran (the /// [`crate::liquid::WaterIndex`] rebuild edge) — any batch may have gained or lost the water /// plane under it, so the mesh lane's reactive classifier promotes the frame to a full walk. pub(crate) fn surfaces_changed(&self) -> bool { self.index.is_changed() } } /// Is this draw on the EYE's **far side** of its local water plane — the point-and-slack core. /// /// The reference splits its transparents into the above-water and below-water lists and the /// frame interleave draws the eye's far side *before* the water pass (dry eye: below is far; /// submerged: above is — `0x4836d6`). Membership is `above ⇔ d ≥ −r` against the nearest /// **admitted** surface over the point's XY (claim from the nearest ancestor with a room /// verdict; `Unknown` admits both sources, still floor-bounded per pool). **No admitted surface /// ⇒ the above list** (`+0x19c == 0 → A = 1`, wow-re `water-frame-straddle.md` §2) — the NEAR /// side for a dry eye and the FAR side for a submerged one: shore content seen from under the /// water draws before the surface, so the surface tints it (0921's correction of 0911's /// "no surface → near"). /// /// `r` is the caller's lane law: the model bound-sphere slack for emitters and ribbons /// ([`model_far_side`]), `0` for the mesh lane's no-clip-planes fallback (`A = d ≥ 0`, /// `0x7079ed`; decision 0919). pub(crate) fn far_side_of_water_at( w: &WaterInterleave, claim_seed: Option, point_world: Vec3, r: f32, ) -> bool { let wow = benilla_assets::coords::bevy_to_wow(point_world); // The spatial pre-filter first — these lanes ask per DRAW, and the full `surfaces_at` walk // at that grain was the 2026-08-03 12-fps regression (`liquid::spatial`). No candidate // surface over this XY (the dominant dry-land case) is "no admitted surface" under every // claim, so the room walk below is skipped with the scan. let candidates = w.index.over(wow[0], wow[1]); let above = if candidates.is_empty() { true } else { let mut seed = claim_seed; let mut room = None; for _ in 0..8 { let Some(e) = seed else { break }; if let Ok(rm) = w.rooms.get(e) { room = Some(rm); break; } seed = w.parents.get(e).ok().map(ChildOf::parent); } let claim = crate::liquid::unit_claim(room, &w.placements); let surfaces = candidates.iter().filter_map(|&e| w.water.get(e).ok()); match crate::liquid::surfaces_at(surfaces, wow, claim) .map(|z| wow[2] - z) .min_by(|a, b| a.abs().total_cmp(&b.abs())) { Some(d) => is_above(d, r), None => true, } }; if w.underwater.0.any() { above } else { !above } } /// The mesh lane's wrapper: the sign test at the batch's own transform (r = 0) — the /// reference's no-clip-planes fallback, `model_render::classify_water_side`'s law (0919). pub(crate) fn far_side_of_water( w: &WaterInterleave, claim_seed: Option, anchor_world: Vec3, ) -> bool { far_side_of_water_at(w, claim_seed, anchor_world, 0.0) } /// The emitter/ribbon lane: **the water side is the MODEL's** (byte-VERIFIED, wow-re /// `water-frame-straddle.md` §6 — the 0921 correction of 0911's per-emitter gloss). The type-4 /// walk dots the plane ONCE per model in its prologue — `world_matrix × bound-box centre`, /// slack `r = |matrix row 0| × header sphere radius` — and every emitter of the model reads /// that one cached boolean (`[ebp-8]` at `0x7085fa`); the ribbon leg reads the model's side-A /// verbatim (`0x7081f1`). So a shoulder flame cannot blink at the waterline: the verdict rides /// the model's deep, stable bound centre with a whole radius of slack, never the bobbing bone. /// /// `model` = the instance-root entity — the cloud ANCHOR field ("the MODEL, never the bone"), /// whose `GlobalTransform` is the instance matrix; `bound` = the asset's /// [`benilla_assets::ModelEmitter::water_bound`]. No resolvable matrix → the sign test at /// `fallback_point` (the emitter's live anchor), r = 0. pub(crate) fn model_far_side( w: &WaterInterleave, model: Option, model_gt: Option<&GlobalTransform>, bound: (Vec3, f32), fallback_point: Vec3, ) -> bool { match model_gt { Some(gt) => { let point = gt.transform_point(bound.0); let r = gt.affine().matrix3.x_axis.length() * bound.1; far_side_of_water_at(w, model, point, r) } None => far_side_of_water_at(w, model, fallback_point, 0.0), } } /// The membership law alone, pure for the unit test: `above ⇔ d ≥ −r` (the reference's /// `fcompp; test ah,0x41; jp` at `0x7084cf` — a tie at `d == −r` lands above; NaN lands below, /// which `>=` gives for free). `d` = point − surface (WoW Z), `r ≥ 0` the lane's slack. fn is_above(d: f32, r: f32) -> bool { d >= -r } /// Does a booth-layered emitter freeze this frame? The rule the body panes' item effects turn on /// (decision 1559), stated once because it was got wrong by being written inline. /// /// A booth camera that is **asleep** — `gate_booth_cameras` put its pane's window away — draws /// none of this lane, so its emitters freeze with it: pool + age held, one frame's dt on /// re-entry, no catch-up. That is the draw-set law's own shape (the reference ticks an emitter /// only inside a draw the frame performs) and it is keyed on the reference's own cull. /// /// A camera that is merely **rate-throttled** ([`super::ViewThrottled`]) is not asleep. Its scene /// is posing at full rate; `boothHalfRate` (decision 1444) is *our* pacing of the render, and a /// frame we chose to skip is not a frame the reference culled. Reading the two as one ran the /// body panes' item effects at half speed for as long as a pane was open — director report B312. /// /// A **draining** emitter never freezes either way: its pool has to run out, and freezing one /// strands it forever (the world arm's rule further down). fn booth_frozen(cam_active: bool, throttled: bool, draining: bool) -> bool { !cam_active && !throttled && !draining } /// Per-frame: emit, integrate, and expand each emitter's pool into the shared effect-quad /// stream ([`super::buffer::EffectQuads`]). #[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set pub(super) fn simulate_particles( time: Res