Files
benilla-octo/crates/benilla-world/src/particles/emit.rs
T
SamandClaude Fable 5.1 7e3a67a02e the unit frames are the reference's own, and the water has motes in it
- the player, party, target and pet frames are the reference's own files (`BuffFrame`, `UnitFrame`, `CombatFeedback`, `PlayerFrame`, `PartyFrame`, `TargetFrame`, `PetFrame`, in the reference's toc order) — our 2741-line `UnitFrames.xml` and our `PartyFrame.xml` are deleted; four engine verbs built for them (`PartialPlayTime`, `NoPlayTime`, `UnitPlayerControlled`, `UnitIsCivilian`, each answering 1/nil), the whole-UI load bracketed in the counted sound-suppression scope `0x48fbf0` self-brackets (the reference fires a `PlaySound` during load and throws it away), and `GetBillingTimeRested` read off `SMSG_AUTH_RESPONSE`'s third billing field rather than hardcoded; the differences the stock files bring are pinned rather than papered — `PlayerFrame` never hides, an unresolved name is BLANK not "Unknown", the target frame has no numerals at all (a bar hover pops the unit tooltip instead)
- what the swap surfaced, each a hard break under the stock files: `UnitBuff`/`UnitDebuff` returned the Classic Era ten-value tuple, name first, where 1.12 returns two and three values TEXTURE first (`0x519500`/`0x5198f0`) — a raise twice per `UNIT_AURA` on any debuffed target, and all 184 corpus call sites bind the 1.12 shape; the power events are named per resource (`UNIT_MANA`/`RAGE`/`FOCUS`/`ENERGY`/`HAPPINESS` and their MAX twins) and we fired the Era pair, so no mana, rage or energy bar could live-update; `SetRaidTarget` is the engine verb and `SetRaidTargetIcon` the FrameXML toggle wrapper — we had registered the wrapper's name on the engine's body, so every raid mark in the unit popup raised
- the underwater drift cloud: the reference draws a fixed 4000-mote field whenever the camera eye is inside a liquid — not a particle system, nothing spawns, ages or dies; a population scattered once into a 30 yd camera-relative cube, advected by the camera's own motion plus a gust and wrapped, so the field stands still in the world; benilla had the murk and nothing in it; a dry frame commits no draw, and the submerged one is bounded by the reference's own 666-quad cap (633–644 of 4000 measured live, which is the expected survivor count of a uniform cube through the 90° cone — an independent check on population, distribution and cone at once)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:04:32 +07:00

328 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The **emission shape kernel** and its RNG — where one birth's position + velocity direction
//! come from, byte-verified against the reference's three vtable type-spawn generators (wow-re
//! `part-shape-kernels.md`). Split from the emitter module face (`particles.rs`) purely along
//! this concern; the laws and cites live on [`emit_local`] itself.
use benilla_formats::ParticleEmitterDef;
use bevy::prelude::*;
/// xorshift32 — a dependency-free PRNG for particle jitter (visual only; determinism not required).
pub(super) fn next_u32(state: &mut u32) -> u32 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*state = x;
x
}
/// A uniform random `f32` in `[0, 1)`.
pub(crate) fn rand01(state: &mut u32) -> f32 {
(next_u32(state) >> 8) as f32 / (1u32 << 24) as f32
}
/// A symmetric random `f32` in `(−1, 1)` — the reference's `S11` draw (its RNG builds ±spans with
/// a ×2.0 constant; every emission distribution below is plain uniform, wow-re
/// `part-shape-kernels.md` §4 — we mirror the distributions, not the bit stream).
pub(super) fn rand_s11(state: &mut u32) -> f32 {
rand01(state) * 2.0 - 1.0
}
/// The **emission shape kernel** (wow-re `part-shape-kernels.md`, byte-verified off the three
/// vtable type-spawn generators): one birth's position + unit velocity direction, in the emitter's
/// local WoW frame (Z up, origin at the emitter record's `position`). The caller applies the speed
/// roll and the space-mode transform.
///
/// - **Plane** (`0x7b8890`): position uniform in the ±½·area rectangle — **local x takes
/// `areaLength`, local y takes `areaWidth`** (wow-re `part-shape-kernels.md` §4, VERIFIED:
/// `p = (r2·A_x·0.5, r1·A_y·0.5, 0)` with `A_x = rt+0x290 = EmissionAreaLength`,
/// `A_y = rt+0x294 = EmissionAreaWidth`). The pairing is only observable on an **anisotropic**
/// rectangle, which is why it stayed wrong through 0563/0566 — every emitter checked until
/// Gressil's blade smoke authored a square area. Direction: a cone around +Z with **symmetric**
/// angles θ = S11·verticalRange, φ = S11·horizontalRange (the reference draws ±range, not
/// [0, range] — a one-sided cone tilted every flame the same way).
/// - **Sphere** (`0x7b8d70`): radius uniform in [areaLength, areaWidth] (= min/max radius for this
/// shape), position on the shell at latitude S11·verticalRange / longitude S11·horizontalRange;
/// direction radial outward (flag `0x4000` ⇒ straight +Z instead).
/// - **Spline** (`0x7b9500`): born ON the authored arc-length-parameterized Bézier chain at a
/// uniform arc fraction in `[tMin, tMax]`; velocity = +Z spun about the local tangent by
/// ψ = S11·spin (zero velocity when no spin; radial-from-pivot when zSource authored), plus
/// an optional along-velocity scatter jitter — see the arm's comment.
/// - Any shape with `zSource ≠ 0`: direction is **radial from the pivot** `(0, 0, zSource)` toward
/// the birth point (fountains that arc outward from a point below the basin).
pub(super) fn emit_local(
def: &ParticleEmitterDef,
now: &benilla_formats::ParamsNow,
rng: &mut u32,
) -> (Vec3, Vec3) {
let origin = Vec3::from(def.position);
// The emitter-frame R(+Z, 90°) applied at every branch's return — see the law note at the
// tail. Per the wow-re bytes the prepend is per-EMITTER and subclass-independent ("gated
// only by has-emitters"), so the spline kernel takes it exactly like sphere/plane (the
// 0563 landing missed this branch — caught by the compensation audit).
let rot90 = |v: Vec3| Vec3::new(-v.y, v.x, v.z);
// SPLINE (`0x7b9500`, wow-re `part-shape-kernels.md` §3 — VERIFIED, incl. the scatter
// bytes): born ON the authored Bézier chain at arc fraction `t ∈ [tMin, tMax]` (the
// repurposed area fields). Velocity: radial from the zSource pivot when authored; else +Z
// rotated about the local spline tangent by ψ = S11·spin (the reference extracts the
// transposed row — a −ψ rotation — indistinguishable under the symmetric draw), with an
// optional `U01·scatter` position jitter along the velocity; else ZERO (the particle sits
// on the curve and only gravity/drag move it). A degenerate record (no parsed chain)
// falls through to the plane kernel, as before.
if let (benilla_formats::ParticleShape::Spline, Some(spline)) = (def.shape, &def.spline) {
let (t0, t1) = (
now.area_length.clamp(0.0, 1.0),
now.area_width.clamp(0.0, 1.0),
);
let t = t0 + rand01(rng) * (t1 - t0);
let mut pos = origin + Vec3::from(spline.eval(t));
let dir = if now.z_source != 0.0 {
(pos - origin - Vec3::new(0.0, 0.0, now.z_source)).normalize_or(Vec3::Z)
} else if now.vertical_range != 0.0 {
let tangent = Vec3::from(spline.tangent(t)).normalize_or(Vec3::Z);
let (s, c) = (rand_s11(rng) * now.vertical_range).sin_cos();
let dir = Vec3::Z * c + tangent.cross(Vec3::Z) * s + tangent * (tangent.z * (1.0 - c));
if now.horizontal_range != 0.0 {
pos += rand01(rng) * now.horizontal_range * dir;
}
dir
} else {
Vec3::ZERO
};
return (origin + rot90(pos - origin), rot90(dir));
}
// Birth offset in the emitter frame (before the record-position translation). A sphere
// draws ONE lat/lon unit vector serving both the shell point and (below) the radial
// velocity — the reference reuses the exact sincos pair (`0x7b8fba`), which is what keeps a
// ZERO-radius sphere (the fireball impact's plume burst: min = max = 0) spraying uniformly
// instead of collapsing every direction to the degenerate normalize fallback.
let (local, shell) = if def.shape == benilla_formats::ParticleShape::Sphere {
let r = now.area_length + rand01(rng) * (now.area_width - now.area_length).max(0.0);
let lat = rand_s11(rng) * now.vertical_range;
let lon = rand_s11(rng) * now.horizontal_range;
let (slat, clat) = lat.sin_cos();
let (slon, clon) = lon.sin_cos();
let shell = Vec3::new(clat * clon, clat * slon, slat); // unit by construction
(r * shell, Some(shell))
} else {
// x ← areaLength (rt+0x290), y ← areaWidth (rt+0x294) — the VERIFIED pairing, see the
// Plane bullet above. Both draws are iid S11, so which one feeds which axis changes the
// rectangle's ORIENTATION but not its distribution; that is the whole bug.
(
Vec3::new(
rand_s11(rng) * 0.5 * now.area_length,
rand_s11(rng) * 0.5 * now.area_width,
0.0,
),
None,
)
};
let dir = if now.z_source != 0.0 {
// Radial from the (0, 0, zSource) pivot — degenerate at the pivot itself falls back to +Z.
(local - Vec3::new(0.0, 0.0, now.z_source)).normalize_or(Vec3::Z)
} else if let Some(shell) = shell {
if def.sphere_up() {
Vec3::Z
} else {
shell // radial outward — the same unit draw as the birth point
}
} else {
let theta = rand_s11(rng) * now.vertical_range;
let phi = rand_s11(rng) * now.horizontal_range;
let (st, ct) = theta.sin_cos();
let (sp, cp) = phi.sin_cos();
Vec3::new(st * cp, st * sp, ct)
};
// The emitter-frame R(+Z, 90°) — wow-re `part-modelspace-animbone.md`, §5 byte-verified
// (`0x719114–0x719142`: axis literal (0,0,1), angle π·0.5, Rodrigues + mat4_mul into the
// per-frame emitter matrix rt+0x1fc): the reference prepends a fixed +90°-about-local-+Z
// to EVERY M2 particle emitter's bone matrix, applied to the kernel-relative vectors only
// (the record-position translation stays outside it). It is what turns the sphere kernel's
// local-XZ ring into the wheel PERPENDICULAR to a rotor bone's +X spin — the InstancePortal
// vortex swirling in place instead of tumbling edge-on — and what maps a billboard-bone
// starburst's spray plane onto the screen plane (the impact flashes). Applied at emission,
// so every consumer (anchored bake, model-space storage, child emitters) inherits it.
(origin + rot90(local), rot90(dir))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use benilla_formats::ParticleShape;
/// The kernel-test parameter set (the values the old flattened def carried).
pub(crate) fn now() -> benilla_formats::ParamsNow {
crate::testing::particle_params_now()
}
/// A minimal def for kernel tests — only the fields [`emit_local`] reads matter; the
/// sampled-parameter side is [`now`] (constant-baked into `params` for the sim tests).
pub(crate) fn def(shape: ParticleShape) -> ParticleEmitterDef {
crate::testing::particle_def(shape)
}
/// Plane births stay in the ±½·area rectangle around the record position, and the cone is
/// SYMMETRIC (θ = S11·range — wow-re `part-shape-kernels.md`'s correction of our old
/// [0, range) draw): with a wide sample, x-velocities must land on both signs. The
/// rectangle rides the R(+Z,90°) emitter frame (`emit_local`'s tail): the kernel's
/// length-along-x/width-along-y rectangle lands **width-along-x, length-along-y**. Asserted on
/// an ANISOTROPIC area (2 × 4) so the pairing is actually pinned — a square area passes either
/// way, which is exactly how the swapped pairing survived 0563/0566 (Gressil's 0.1 × 1.1 blade
/// smoke drew the 1.1 yd curtain ACROSS the blade).
#[test]
fn plane_kernel_rect_bounds_and_symmetric_cone() {
let d = def(ParticleShape::Plane);
let n = now();
let mut rng = 12345u32;
let (mut neg, mut pos) = (false, false);
let (mut max_dx, mut max_dy) = (0.0f32, 0.0f32);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &n, &mut rng);
assert!(
(p.x - 1.0).abs() <= 2.0 + 1e-4,
"x within ±½·width of position (post-R)"
);
assert!(
(p.y - 2.0).abs() <= 1.0 + 1e-4,
"y within ±½·length of position (post-R)"
);
assert_eq!(p.z, 3.0);
assert!(dir.z > 0.0, "cone around +Z stays upward for range < π/2");
max_dx = max_dx.max((p.x - 1.0).abs());
max_dy = max_dy.max((p.y - 2.0).abs());
neg |= dir.x < -1e-3;
pos |= dir.x > 1e-3;
}
assert!(neg && pos, "symmetric cone covers both x signs");
// The PIN, not just the bound: the LONG extent must land on x. A swapped
// areaLength/areaWidth pairing caps `max_dx` at ½·length = 1.0 and pushes `max_dy` past it,
// so this pair of asserts is what fails on the 0563-era kernel.
assert!(
max_dx > 1.5,
"the wide extent (½·width = 2.0) rides x post-R, reached {max_dx}"
);
assert!(
max_dy <= 1.0 + 1e-4,
"the narrow extent (½·length = 1.0) rides y post-R, reached {max_dy}"
);
}
/// Sphere births sit on a shell with radius in [areaLength, areaWidth] (min/max radius for
/// this shape) and fly radially outward through the shell point.
#[test]
fn sphere_kernel_radius_bounds_and_radial_velocity() {
let d = def(ParticleShape::Sphere);
let n = now();
let mut rng = 99u32;
for _ in 0..256 {
let (p, dir) = emit_local(&d, &n, &mut rng);
let rel = p - Vec3::new(1.0, 2.0, 3.0);
let r = rel.length();
assert!(
(2.0 - 1e-3..=4.0 + 1e-3).contains(&r),
"shell radius {r} within [min, max]"
);
assert!(
rel.normalize().dot(dir) > 0.999,
"velocity radial through the shell point"
);
}
}
/// A ZERO-radius sphere (min = max = 0 — the fireball impact's plume burst) still sprays its
/// velocities across the authored lat/lon spread: the direction is the shell unit vector
/// itself (`0x7b8fba` reuses the sincos pair), never a normalize of the (zero) birth offset.
#[test]
fn zero_radius_sphere_still_disperses() {
let d = def(ParticleShape::Sphere);
let mut n = now();
n.area_length = 0.0;
n.area_width = 0.0;
n.vertical_range = std::f32::consts::PI; // the MoltenBlast plume: full ±π latitude fan
n.horizontal_range = 0.0;
let mut rng = 4242u32;
let (mut up, mut down, mut fwd, mut back) = (false, false, false, false);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &n, &mut rng);
assert_eq!(p, Vec3::new(1.0, 2.0, 3.0), "births at the centre");
assert!((dir.length() - 1.0).abs() < 1e-4, "unit direction");
assert_eq!(
dir.x, 0.0,
"zero longitude range pins the fan to the YZ plane (the R(+Z,90°) emitter frame)"
);
up |= dir.z > 0.5;
down |= dir.z < -0.5;
fwd |= dir.y > 0.5;
back |= dir.y < -0.5;
}
assert!(
up && down && fwd && back,
"the fan covers the full vertical ring, not a single degenerate ray"
);
}
/// The SPLINE kernel (`0x7b9500`): births sit ON the authored chain (origin-composed) at
/// arc fractions inside [tMin, tMax]; zero spin ⇒ ZERO velocity; a spin range fans +Z
/// about the local tangent (here +X ⇒ the fan stays in the YZ plane), and scatter jitters
/// the position along the velocity by at most its own length.
#[test]
fn spline_kernel_births_on_the_chain() {
let x = |v: f32| [v, 0.0, 0.0];
let mut d = def(ParticleShape::Spline);
d.spline = benilla_formats::SplineData::new(vec![
x(0.0),
x(1.0),
x(2.0),
x(3.0), // one straight segment along +X
]);
let mut n = now();
n.area_length = 0.25; // tMin
n.area_width = 0.75; // tMax
n.vertical_range = 0.0;
n.z_source = 0.0;
let mut rng = 31u32;
// The R(+Z,90°) emitter frame (`emit_local`'s tail) turns the authored +X chain to +Y.
for _ in 0..64 {
let (p, dir) = emit_local(&d, &n, &mut rng);
let local = p - Vec3::new(1.0, 2.0, 3.0); // minus the record position
assert!(
(0.75 - 1e-3..=2.25 + 1e-3).contains(&local.y),
"on the chain inside [tMin, tMax] (post-R along +Y): {}",
local.y
);
assert_eq!((local.x, local.z), (0.0, 0.0));
assert_eq!(dir, Vec3::ZERO, "no spin, no zSource: the flame stands");
}
// A spin range fans +Z about the +X tangent — the kernel's YZ fan lands in XZ post-R.
n.vertical_range = 1.0;
n.horizontal_range = 0.5; // scatter
let (mut low, mut high) = (false, false);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &n, &mut rng);
assert!(dir.y.abs() < 1e-4, "fan in the XZ plane (post-R)");
assert!((dir.length() - 1.0).abs() < 1e-4);
assert!(dir.z > 0.0, "±1 rad about +Z stays upward");
low |= dir.x > 0.5;
high |= dir.x < -0.5;
// The scatter jitter is along dir, bounded by its own length.
let local = p - Vec3::new(1.0, 2.0, 3.0);
assert!(local.x.hypot(local.z) <= 0.5 + 1e-3, "jitter ≤ scatter");
}
assert!(low && high, "the fan covers both spin signs");
}
/// zSource ≠ 0 redirects any shape's velocity radially away from the (0, 0, zSource) pivot.
#[test]
fn z_source_pivots_the_velocity() {
let d = def(ParticleShape::Plane);
let mut n = now();
n.z_source = -1.0; // pivot below the emitter → births fly up-and-outward
let mut rng = 7u32;
for _ in 0..64 {
let (p, dir) = emit_local(&d, &n, &mut rng);
let rel = (p - Vec3::new(1.0, 2.0, 3.0)) - Vec3::new(0.0, 0.0, -1.0);
assert!(rel.normalize().dot(dir) > 0.999, "radial from the pivot");
}
}
}