diff --git a/docs/README.md b/docs/README.md index 2352f1e..311e889 100644 --- a/docs/README.md +++ b/docs/README.md @@ -325,6 +325,16 @@ still to land. background -2, midground -1, ground 0, foreground +1), and the `Tilemap` source (the M2-TILE-02 hook — the tiles are declared through the tilemap's `declareTo` overload, not this registry's). + - [Particle simulation](api/particles.md) — + `laige::ParticleSystem`: the CPU-simulated, budgeted, + pooled particle half of FR-2.7 (M2-PART-01): the bounded + pre-allocated pool (drop-on-overflow, one rate-limited warn per + tick), burst + continuous emitters, per-particle 2D position + + constant depth + velocity + life + size, the exact integer color + fade, the once-per-sim-tick `update()` (ARCH-002), and the + fixed-seed determinism contract (the 4-draw Prng contract, the + machine-greppable state hash). The render half (particles as + batched sprites) lands in M2-PART-02. ## Guides @@ -413,7 +423,8 @@ still to land. [engine.md](api/engine.md), [config.md](api/config.md), [determinism.md](api/determinism.md), - [replay.md](api/replay.md).) + [replay.md](api/replay.md), + [particles.md](api/particles.md).) ## Related diff --git a/docs/api/particles.md b/docs/api/particles.md new file mode 100644 index 0000000..a89836a --- /dev/null +++ b/docs/api/particles.md @@ -0,0 +1,258 @@ +# Particle simulation (`laige::sim` particles) + +The CPU-simulation half of FR-2.7 — "Lightweight GPU particle +system (CPU-simulated, 2D + depth), budgeted, pooled" (PRD §15 M2, +FR-2.7; AGENTS CORE-002/004/005/008, PERF-002/003/005/008, ARCH-002/ +009/010, ADR 0002). A bounded, pooled particle system updated once +per completed simulation tick: burst + continuous emitters, per- +particle 2D position + constant depth, velocity, life, size, and an +exact integer color fade. The rendering half — particles as batched +sprites (shared particle atlas, one draw call per emitter set, depth +from the particle's depth value) — is **M2-PART-02**, which consumes +this header's read-only surface (`liveParticles()`). Public header: +`src/laige-sim/include/laige/sim/particles.h` (header-only — the API +is a template over the SimMath backends, the +[`presentation.h`](../../src/laige-sim/include/laige/sim/presentation.h) +pattern). Unit suite: `ctest -R particles` +(`tests/laige-sim/particles_tests.cpp`) — pure engine math + pool +bookkeeping, no GL environment required: it runs in every local tree +and in CI, and the goldens are hand-computed (Q16.16 raws / dyadic +floats, the exact per-tick pool table, the 4-draw Prng contract). + +The particle state's place in the 2.5D model: +[`docs/concepts/coordinates.md`](../concepts/coordinates.md) §4.11 +(ARCH-008) + the §5 conversion row. + +## The API + +| Member | Returns | Notes | +|---|---|---| +| `ParticleSystem::create(options)` | `Result` | `maxParticles` in `[1, kParticlePoolMaxParticles]` (65 536; default `kParticlePoolDefaultMaxParticles` = 4096), `maxEmitters` in `[1, kParticleEmitterMaxEmitters]` (256; default `kParticleEmitterDefaultMaxEmitters` = 32), `seed` (any u64) — validates (no log), pre-allocates the pool + emitter table (setup path) | +| `ParticleSystem() = default` | the stopped state | `valid()` false; `addEmitter`/`burst` → `InvalidArgument` (no log); `update()` a no-op; `liveParticles()` empty; `stats()` zero (the RenderThread stopped-state precedent) | +| `addEmitter(def)` | `Result` | registers an emitter (setup phase); validates `def` in the documented order (first failure wins); a rejection leaves the registry unchanged + one rate-limited warn; the registry is full → `BudgetExhausted` + warn; O(1) | +| `burst(id, count)` | `Status` | spawns `count` particles from emitter `id` NOW (sim phase — the game's spawn policy); stopped or unknown id → `InvalidArgument` (no log); count 0 a no-op success; O(count) | +| `update()` | void | ONE sim tick: advance → emit → overflow report (the per-tick contract below); called exactly once per completed tick by the game's tick driver (ARCH-002); O(live + Σrates), no allocation, no logging on the happy path | +| `liveParticles()` | `span` | the render read path (M2-PART-02): the compact live prefix `[0, liveCount)` — read it after the sim phase (the M2-GL-02 cull/batch stage); O(1), never writes | +| `fadeAlpha(p)` | `uint8` | the particle's current fade alpha (0..255) — exact u32 integer arithmetic (the color-fade section below) | +| introspection | | `valid()`, `maxParticles()`, `maxEmitters()`, `seed()`, `liveCount()`, `spawnedTotal()`, `droppedTotal()`, `stats()`, `emitterCount()`, `hasEmitter(id)`, `emitterAt(id)`, `prngSeed()`, `prngStatePart1()`, `prngStatePart2()` | + +`Particle` (the per-particle state — 40 bytes on both backends): +`pos` (sim space), `depth` (constant over life — the M2-ISO-01 +depth-key input M2-PART-02 feeds the batcher), `vel` (world units per +tick, constant), `age`/`life` (sim ticks), `size` (world units), +`tint[4]` (base RGBA, u8), `fadeTicks` (the emitter's fade window). + +`ParticleEmitterDef` is a plain value (the scene config — +validated AT USE TIME by `addEmitter`): `origin` (the spawn position — +a particle spawns exactly here; no position jitter in M2-PART-01), +`depth`, `velMin`/`velMax` (the per-tick velocity box, componentwise), +`lifeMin`/`lifeMax` (sim ticks), `sizeMin`/`sizeMax` (world units), +`tint[4]` (default white), `fadeTicks` (0 = no fade), +`continuousRate` (particles PER SIMULATION TICK; 0 = burst-only — +unbounded by design: the pool budget bounds the work, overflow +drops). + +## The per-tick contract (ARCH-002) + +`update()` runs EXACTLY ONCE per completed simulation tick, driven by +the game's tick path (a game system, or the loop's tick hook — the +`TileMap::advanceAnimations` pattern). The sim never depends on the +render frame rate. In-tick order: + +1. **Advance** every currently live particle (live-array order): + `age += 1`, `pos += vel` (one SimMath vector add — ADR 0002); + `age >= life` kills (swap-removed with the last live particle; the + swapped-in particle — not yet advanced this tick — is re-examined + at the same index). +2. **Emit** from every emitter in registration order + (`continuousRate` particles each). +3. **Report** the tick's overflow: at most ONE rate-limited warn + (the pool-budget section below). + +A particle spawned during tick T's update (the continuous rate) is +live after ticks T..T+life-1 (age 0..life-1) and dies during tick +T+life's update — visible for EXACTLY `life` renders. A particle +spawned by `burst()` before tick T's update gets advanced in that +same tick (age 1 after the update) — visible for `life - 1` renders; +call `burst()` in the sim phase, never between frames. Velocity is +constant (no acceleration — out of scope); depth is constant. + +## The pool budget (FR-2.7 "budgeted, pooled", PERF-003/008) + +The pool is `maxParticles` PRE-ALLOCATED slots (the setup path — with +the emitter table, the system's only allocations). Live particles +occupy the FIRST `liveCount` slots: a death swap-removes the last +live particle into the dead slot, so the live order is spawn order +with swap removal (deterministic, cache-friendly). + +Overflow: a spawn into a full pool is **DROPPED** — counted +(`droppedTotal`, the per-tick bookkeeping) and reported with at most +ONE `particles/pool_overflow` warn per tick (fields `dropped` / +`live` / `capacity`; the LOG-004 logger rate window is a second +layer). A drop never mutates live state, never throws, never grows +the pool, and — critically for determinism — **consumes no Prng +draws**: the pool check precedes the draws, so a drop never perturbs +the stream. The `droppedTotal` gauge is the budget's observability +(DBG-008): a steadily growing `droppedTotal` means the scene wants +more particles than the budget allows (raise `maxParticles` at +create, or thin the emitters). + +## Determinism (ARCH-010, ADR 0002) + +The state after N updates is a pure function of (seed, emitter +definitions, operation sequence), per backend: + +- **fpx16_16** — bit-identical across all builds, platforms, ISAs, + and compilers (the language-standard guarantee). +- **fp32_pinned** — bit-identical across runs of the same build on the + same platform/ISA (the detcheck matrix owns cross-target claims). + +Each SUCCESSFUL spawn consumes exactly **FOUR Prng draws in a fixed +order**: `vx`, `vy`, `life`, `size`. Each uniform scalar is +`lerp(min, max, u / 2^24)` where `u = next_range(0, 2^24)` — the +Prng's 24-bit tap resolution — one documented rounding per backend +(the ADR 0002 lerp contract); `life` is an integer draw. Motion is +SimMath ops only; the fade is exact integer arithmetic. Nothing reads +platform intrinsics, addresses, or wall-clock time. The Prng state is +part of the deterministic state: `prngSeed()` / `prngStatePart1()` / +`prngStatePart2()` expose it for the World `stateHash` / replay +identity (the M1-DET-03 pattern); the unit suite pins it (a +fixed-seed replay of the same operation sequence produces a +bit-identical live set + a machine-greppable FNV-1a state hash, and +an independent `Prng` advanced by `4 × spawnedTotal` draws lands on +the system's stream state). + +## The color fade (exact integer arithmetic) + +`fadeTicks == 0`: no fade (the tint alpha is constant). Otherwise: +`remaining = life - age` (a live particle always has `age < life` — +no underflow), and + +``` +alpha = remaining >= fadeTicks ? tint.a + : tint.a * remaining / fadeTicks (u32 multiply + divide) +``` + +— the alpha ramps linearly from the fade window's start to 0 at +death. One exact multiply + divide per read; no floating point — +bit-identical on every platform (max product 255 × 2^20 < 2^32). The +fade touches the alpha channel only; the RGB channels are constant. +`fadeAlpha(p)` exposes the computed value to the render pass. + +## Failure (CORE-008, API-008) — first failure wins + +| Operation | Failure | Behavior | +|---|---|---| +| `create` | `maxParticles` ∉ [1, 2^16] or `maxEmitters` ∉ [1, 256] | `InvalidArgument` (no log) | +| `addEmitter` | stopped | `InvalidArgument` (no log) | +| `addEmitter` | registry full | `BudgetExhausted` + warn `particles/emitters_exhausted` (field `capacity`) | +| `addEmitter` | a def field out of domain | `InvalidArgument` + one rate-limited warn `particles/emitter_invalid` (fields `emitter`, `field`); the registry is unchanged | +| `burst` | stopped or unknown emitter id | `InvalidArgument` (no log); count 0 is a no-op success | + +The def validation order (first failure wins — the def is caller-owned +scene metadata, untrusted input, SCALE-004 — every field is checked): +`origin` (finite) → `depth` (finite) → `vel_min` (finite) → `vel_max` +(finite) → `vel_box` (`velMin <= velMax` per axis) → `life_min` (>= 1) +→ `life_box` (`lifeMin <= lifeMax`) → `life_max` (<= 2^20) → +`size_min` (finite, >= 0) → `size_max` (finite) → `size_box` +(`sizeMin <= sizeMax`) → `fade_ticks` (<= `lifeMax`). The happy paths +log nothing (LOG-003). + +## Ownership, lifetime, threading (CORE-009, CONC-001) + +One owner thread (the sim thread). `create` is the setup path (two +pre-allocations: the pool, the emitter table); nothing allocates +afterward — the unit suite proves a 500-tick burst/update window +allocates zero heap blocks on the loop thread (the allocation watch, +`LAIGE_ALLOC_COUNTER`; the sanitizer trees prove the same via +leak-free runs). Move-only: the move is an O(1) pointer swap and the +moved-from system is stopped (its Prng copy shares the stream position +with the live system — safe because the moved-from system is stopped +and never draws again). The render pass reads `liveParticles()` +(read-only, ARCH-009) after the sim phase: presentation reads +authoritative state and never mutates it; nothing in the render pass +writes here. + +## Performance (DOC-004) + +- **`update()`**: O(live + Σ continuousRates) — one SimMath add + one + age compare per live particle, 4 Prng draws + one slot write per + successful spawn, zero heap allocation, zero logging on the happy + path. At the worst-case 65 536 pool with 256 emitters this is a + bounded, cache-friendly sequential scan (the pool is a flat array). +- **`burst(id, n)`**: O(n) — the same per-spawn cost, plus at most one + rate-limited warn when the pool is full. +- **`addEmitter` / introspection**: O(1). +- **Memory**: `maxParticles × 40 B` + `(maxEmitters + 1) × def` + pre-allocated at create (2.5 MB at the 65 536 worst case) — nothing + after. The `Particle` layout is 40 B on both backends (no padding + surprises: two Vec2 + a Scalar + fixed-width fields). +- **Budgeting**: the pool is the budget (FR-2.7 "budgeted, pooled") — + overflow drops instead of growing; watch `droppedTotal` + (`stats()`) for scenes that exceed their particle budget. The + particle→sprite budget (particles rendered through the batcher) is + measured in M2-PART-02 against the PRD §8.1 scene budget. +- **Traps**: calling `update()` more than once per tick (double + advance — the tick driver owns the rate); `burst()` between frames + (the bursted particles get advanced by the next `update()`, so they + lose one render's worth of life — see the per-tick contract); + relying on `liveParticles()` span order for anything but + determinism (the order is spawn order with swap removal — + deterministic, but a death reshuffles; M2-PART-02 sorts by the + depth key, not the span order). + +## Example (performant) and misuse warning + +```cpp +auto r = ParticleSystem::create( + {.maxParticles = 4096, .maxEmitters = 4, .seed = 1}); +ParticleSystem system = std::move(r).takeValue(); + +// Setup phase: one continuous emitter (sparks) + one burst emitter. +laige::ParticleEmitterDef sparks; +sparks.origin = { /* sim-space Vec2 */ }; +sparks.depth = /* the emitter's depth (M2-ISO-01 key input) */; +sparks.velMin = { /* -1, -1 */ }; +sparks.velMax = { /* 1, 1 */ }; +sparks.lifeMin = 12; +sparks.lifeMax = 24; +sparks.sizeMin = /* 0.25 */; +sparks.sizeMax = /* 0.5 */; +sparks.tint = {255, 180, 60, 255}; +sparks.fadeTicks = 8; +sparks.continuousRate = 2; // 2 particles per sim tick +system.addEmitter(sparks); + +// Per completed sim tick (the tick driver calls update() once): +system.update(); + +// Render phase (M2-PART-02): read-only. +for (const auto& p : system.liveParticles()) { + // p.pos / p.depth / p.size / laige::ParticleSystem<...>::fadeAlpha(p) +} +``` + +**Misuse:** spawning from the render thread (the sim thread owns the +system — one owner, CONC-001); treating `liveCount()` as a promise to +get a slot (a spawn can be DROPPED when the pool is full — that is +the budget, not an error); expecting `burst()` particles to be visible +for `life` renders (they get advanced by the same tick's `update()` — +the per-tick contract); adding emitters during a tick (setup phase — +`addEmitter` is not part of the update path); assuming the span order +is a render order (it is a spawn order with swap removal — M2-PART-02 +sorts by the depth key). + +## Related + +- [`sprite_batcher.md`](sprite_batcher.md) — the render half + (M2-PART-02 consumes `liveParticles()`). +- [`iso_depth_key.md`](iso_depth_key.md) — the depth key the particle + `depth` feeds (M2-ISO-01). +- [`tilemap.md`](tilemap.md) — the `advanceAnimations` per-sim-tick + pattern this step's `update()` follows. +- [`presentation.md`](presentation.md) — the templated-backends + pattern (this header's shape). +- [`prng.md`](prng.md) — the Prng substream (M0-CORE-06) and the + 24-bit tap resolution. +- [`determinism`](../concepts/determinism.md) — the determinism scope + (ARCH-010) + the G-R8 enforcement. diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index 26c96ce..ff3c709 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -415,6 +415,22 @@ declared into the batcher of §4.7 (S-5) with the §4.1 keys: the tilemap's path, not this one); its `size`/`uv` fields are not validated. +### 4.11 The particles: the CPU-sim particle state (M2-PART-01) + +The particle state (`ParticleSystem::Particle`, the +[particle simulation](../api/particles.md) step) lives in SIM space: +each particle carries a 2D position `(x, y)` + a CONSTANT depth value ++ a per-tick velocity + a life (sim ticks) + a size. The per-tick +advance (`age += 1`, `pos += vel` — one SimMath vector add, ADR +0002) runs in the simulation tick (ARCH-002); the render path reads +the state read-only (`liveParticles()` — the M2-GL-02 cull/batch +stage). The particle's depth value is the §4.1 key input M2-PART-02 +feeds the batcher (particles render as batched sprites — one draw +call per emitter set, RENDER-001). The state is deterministic +(ARCH-010): a pure function of (seed, emitter definitions, operation +sequence) — fpx16_16 bit-identical across platforms/builds, +fp32_pinned same-build/same-platform. + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | @@ -427,6 +443,7 @@ declared into the batcher of §4.7 (S-5) with the §4.1 keys: | Atlas frame → UV sub-rect | animation frame index + sheet layout → UV rect | `laige-render` (`spriteFrameUv`, M2-SPRITE-03) | **Shipped (M2-SPRITE-03)** | | Tile grid → tile quads | tile data + table keys (+ the animation's frame state; the parallax layer's `worldOffset`) → declared sprites | `laige-render` (`TileMap::declareTo` — static + animated frames, the parallax tile layer overload, M2-TILE-01/02) | **Shipped (M2-TILE-01 + M2-TILE-02)** | | Camera position → parallax offset | camera (x, y) + layer def → world-space offset (the exact formula) + wrap quads | `laige-render` (`ParallaxLayers::worldOffsetAt` / `declareTo`, M2-PAR-01) | **Shipped (M2-PAR-01)** | +| Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | `laige-render` (M2-PART-02 — consumes `ParticleSystem::liveParticles()`, read-only after the sim phase) | **Planned (M2-PART-02)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -536,6 +553,10 @@ state → same keys → same order, every frame (RENDER-003). contract: `ParallaxLayers` (the named bg/mid/fg model — the offset formula, the depth-layer values, the UV scroll + exact wrap, the 2 x 2 wrap-split batch path) (M2-PAR-01). +- [`api/particles.md`](../api/particles.md) — the CPU particle + simulation contract: `ParticleSystem` (the bounded pool, the + burst + continuous emitters, the per-tick advance, the exact + integer fade, the determinism contract) (M2-PART-01). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index e310e82..00eaf42 100644 --- a/laige-api.json +++ b/laige-api.json @@ -36,6 +36,7 @@ "src/laige-sim/include/laige/sim/entity.h", "src/laige-sim/include/laige/sim/frame_budget.h", "src/laige-sim/include/laige/sim/game_loop.h", + "src/laige-sim/include/laige/sim/particles.h", "src/laige-sim/include/laige/sim/presentation.h", "src/laige-sim/include/laige/sim/profiler.h", "src/laige-sim/include/laige/sim/query.h", @@ -1214,6 +1215,74 @@ {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 461, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 462, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 463, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kParticlePoolMaxParticles", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 195, "signature": "inline constexpr std::uint32_t kParticlePoolMaxParticles = 1u << 16", "summary": "The pool capacity domain: [1, kParticlePoolMaxParticles]. 2^16 = 65 536 — headroom over the PRD §8.1 worst-case 50k-sprite scene (particle overlays on top); at 40 B/particle the worst-case pool is 2.5 MB (the setup path).", "budget": null, "experimental": false}, + {"name": "laige::kParticlePoolDefaultMaxParticles", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 199, "signature": "inline constexpr std::uint32_t kParticlePoolDefaultMaxParticles = 4096", "summary": "The default pool capacity (a typical single-screen particle budget; the reference scene's emitters fit with margin).", "budget": null, "experimental": false}, + {"name": "laige::kParticleEmitterMaxEmitters", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 203, "signature": "inline constexpr std::uint32_t kParticleEmitterMaxEmitters = 256", "summary": "The emitter registry capacity domain: [1, kParticleEmitterMaxEmitters].", "budget": null, "experimental": false}, + {"name": "laige::kParticleEmitterDefaultMaxEmitters", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 206, "signature": "inline constexpr std::uint32_t kParticleEmitterDefaultMaxEmitters = 32", "summary": "The default emitter registry capacity.", "budget": null, "experimental": false}, + {"name": "laige::kParticleMaxLifeTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 211, "signature": "inline constexpr std::uint32_t kParticleMaxLifeTicks = 1u << 20", "summary": "The particle life domain: [1, kParticleMaxLifeTicks] simulation ticks. 2^20 is roughly 4.9 h at 60 Hz — far beyond any game particle life.", "budget": null, "experimental": false}, + {"name": "laige::kParticleSampleDenominator", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 216, "signature": "inline constexpr std::uint32_t kParticleSampleDenominator = 1u << 24", "summary": "The uniform-sample resolution: the Prng's 24-bit tap (one draw resolves a [0, 1) sample to 2^-24 — the next_float01 resolution, prng.h).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterId", "kind": "alias", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 220, "signature": "using ParticleEmitterId = std::uint32_t", "summary": "The emitter id: dense from 1 (registration order); 0 is the unused sentinel.", "budget": null, "experimental": false}, + {"name": "laige::ParticleStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 226, "signature": "struct ParticleStats", "summary": "The particle system's profiler feed (DBG-008 — the M2-SPRITE-04 pattern): the gauges (live/capacity) + the counters (spawnedTotal/droppedTotal — u64: a long-running session's spawn count is not bounded by 2^32).", "budget": null, "experimental": false}, + {"name": "laige::ParticleStats::live", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 228, "signature": "std::uint32_t live{}", "summary": "The live particle count (gauge).", "budget": null, "experimental": false}, + {"name": "laige::ParticleStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 230, "signature": "std::uint32_t capacity{}", "summary": "The pool capacity (gauge — 0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::ParticleStats::spawnedTotal", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 232, "signature": "std::uint64_t spawnedTotal{}", "summary": "Total successful spawns since create (counter).", "budget": null, "experimental": false}, + {"name": "laige::ParticleStats::droppedTotal", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 234, "signature": "std::uint64_t droppedTotal{}", "summary": "Total dropped spawns (pool full) since create (counter).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 246, "signature": "template struct ParticleEmitterDef", "summary": "One emitter's spawn domain + rate (the header's model section). A plain value (no ownership); VALIDATED AT USE TIME by ParticleSystem::addEmitter (the header's failure section — first failure wins). Templated over the SimMath backends (the presentation.h pattern).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::Scalar", "kind": "alias", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 248, "signature": "using Scalar = typename sim::SimMath::Scalar", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::Vec2", "kind": "alias", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 249, "signature": "using Vec2 = typename sim::SimMath::Vec2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::origin", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 253, "signature": "Vec2 origin{}", "summary": "The spawn position (sim space; a particle spawns EXACTLY here — no position jitter in M2-PART-01).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::depth", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 256, "signature": "Scalar depth{}", "summary": "The spawn depth value (constant over the particle's life — the M2-ISO-01 depth-key input M2-PART-02 consumes).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::velMin", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 259, "signature": "Vec2 velMin{}", "summary": "The velocity box lower bound (world units per tick; component- wise).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::velMax", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 261, "signature": "Vec2 velMax{}", "summary": "The velocity box upper bound.", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::lifeMin", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 263, "signature": "std::uint32_t lifeMin{1}", "summary": "The life box lower bound (simulation ticks; >= 1).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::lifeMax", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 265, "signature": "std::uint32_t lifeMax{1}", "summary": "The life box upper bound (<= lifeMin.. kParticleMaxLifeTicks).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::sizeMin", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 267, "signature": "Scalar sizeMin{}", "summary": "The size box lower bound (world units; >= 0).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::sizeMax", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 269, "signature": "Scalar sizeMax{}", "summary": "The size box upper bound (>= sizeMin).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::tint", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 271, "signature": "std::uint8_t tint[4]{255, 255, 255, 255}", "summary": "The base tint RGBA (0..255 per channel).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::fadeTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 274, "signature": "std::uint32_t fadeTicks{0}", "summary": "The fade window (ticks; 0 = no fade; <= lifeMax — the header's color-fade section).", "budget": null, "experimental": false}, + {"name": "laige::ParticleEmitterDef::continuousRate", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 278, "signature": "std::uint32_t continuousRate{0}", "summary": "The continuous emission rate (particles PER SIMULATION TICK; 0 = burst-only emitter). Unbounded here — the pool budget bounds the work (overflow drops).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem", "kind": "class", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 291, "signature": "template class ParticleSystem", "summary": "The bounded, pooled, deterministic particle system of FR-2.7 (header preamble). Templated over the SimMath backends (the M2-TILE-01/presentation.h pattern — one instantiation per backend, factory-selected at engine init, ADR 0002). Header-only: pure engine math + pool bookkeeping, no GL, no allocation in the per-tick path.", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Scalar", "kind": "alias", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 299, "signature": "using Scalar = typename sim::SimMath::Scalar", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Vec2", "kind": "alias", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 300, "signature": "using Vec2 = typename sim::SimMath::Vec2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::M", "kind": "alias", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 301, "signature": "using M = sim::SimMath", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle", "kind": "struct", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 307, "signature": "struct Particle", "summary": "The per-particle state (40 B, both backends — the header's model section). `age`/`life` are simulation ticks; `tint` is the base RGBA; `fadeTicks` is the emitter's fade window (0 = no fade). The render path reads these read-only (M2-PART-02).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 308, "signature": "Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::depth", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 309, "signature": "Scalar depth{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::vel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 310, "signature": "Vec2 vel{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::age", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 311, "signature": "std::uint32_t age{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::life", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 312, "signature": "std::uint32_t life{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::size", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 313, "signature": "Scalar size{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::tint", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 314, "signature": "std::uint8_t tint[4]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Particle::fadeTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 315, "signature": "std::uint32_t fadeTicks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 324, "signature": "struct Options", "summary": "The create options (API-006): `maxParticles` in [1, kParticlePoolMaxParticles] (default kParticlePoolDefaultMaxParticles); `maxEmitters` in [1, kParticleEmitterMaxEmitters] (default kParticleEmitterDefaultMaxEmitters); `seed` (any u64 — 0 is a valid seed; the system's Prng substream, the M0-CORE-06 pattern).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Options::maxParticles", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 325, "signature": "std::uint32_t maxParticles{kParticlePoolDefaultMaxParticles}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Options::maxEmitters", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 326, "signature": "std::uint32_t maxEmitters{kParticleEmitterDefaultMaxEmitters}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::Options::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 327, "signature": "std::uint64_t seed{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 336, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "Creates the system (setup path — the only allocations: the pool and the emitter table). maxParticles/maxEmitters outside their domains -> InvalidArgument (no log — the M2-SPRITE-01 create precedent). The stopped state (failed create / default) follows the RenderThread precedent: `valid()` false, every operation fails or no-ops, no log.", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::ParticleSystem", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 342, "signature": "ParticleSystem() noexcept : rng_(0)", "summary": "The stopped state (default / failed create): nothing owned (pool_ null, maxParticles_ 0); the Prng is seed-0 (a stopped system never draws — the seed is inert).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::ParticleSystem", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 347, "signature": "ParticleSystem(ParticleSystem&& other) noexcept", "summary": "Move-only: O(1) pointer swap; the moved-from system is stopped (its Prng copy shares the stream position with the live system — safe: the moved-from system never draws again).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 348, "signature": "ParticleSystem& operator=(ParticleSystem&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::ParticleSystem", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 349, "signature": "ParticleSystem(const ParticleSystem&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 350, "signature": "ParticleSystem& operator=(const ParticleSystem&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::valid", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 353, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the system is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::maxParticles", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 356, "signature": "[[nodiscard]] std::uint32_t maxParticles() const noexcept", "summary": "The pool capacity (0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::maxEmitters", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 361, "signature": "[[nodiscard]] std::uint32_t maxEmitters() const noexcept", "summary": "The emitter registry capacity (0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::seed", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 367, "signature": "[[nodiscard]] std::uint64_t seed() const noexcept", "summary": "The system's Prng seed (the create Options::seed; the replay identity's part, M1-DET-03 pattern).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::liveCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 370, "signature": "[[nodiscard]] std::uint32_t liveCount() const noexcept", "summary": "The live particle count (O(1); 0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::spawnedTotal", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 375, "signature": "[[nodiscard]] std::uint64_t spawnedTotal() const noexcept", "summary": "Total successful spawns since create (O(1); u64 counter).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::droppedTotal", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 380, "signature": "[[nodiscard]] std::uint64_t droppedTotal() const noexcept", "summary": "Total dropped spawns (pool full) since create (O(1); u64 counter).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 385, "signature": "[[nodiscard]] ParticleStats stats() const noexcept", "summary": "The profiler feed (DBG-008): the gauges + the counters (O(1)).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::addEmitter", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 396, "signature": "[[nodiscard]] laige::Result addEmitter(const ParticleEmitterDef& def) noexcept", "summary": "Registers an emitter (setup phase). Validates `def` in the header's documented order (first failure wins; a rejection leaves the registry unchanged + one rate-limited warn). Returns the dense id (1-based, registration order). Stopped -> InvalidArgument (no log); registry full -> BudgetExhausted + warn. O(1).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::emitterCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 400, "signature": "[[nodiscard]] std::uint32_t emitterCount() const noexcept", "summary": "The number of registered emitters (O(1)).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::hasEmitter", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 405, "signature": "[[nodiscard]] bool hasEmitter(ParticleEmitterId id) const noexcept", "summary": "True iff `id` is a registered emitter (O(1)).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::emitterAt", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 411, "signature": "[[nodiscard]] const ParticleEmitterDef* emitterAt( ParticleEmitterId id) const noexcept", "summary": "The registered emitter's definition (O(1)); nullptr when the id is not registered (or the system is stopped).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::burst", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 422, "signature": "[[nodiscard]] laige::Status burst(ParticleEmitterId id, std::uint32_t count) noexcept", "summary": "Spawns `count` particles from emitter `id` NOW (sim phase — the game's spawn policy; the continuous rate is the automatic half, the burst is the explicit one). Stopped or unknown id -> InvalidArgument (no log); count 0 is a no-op success. O(count): one pool check + (on success) 4 Prng draws per particle — see the header's determinism section.", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::update", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 430, "signature": "void update() noexcept", "summary": "Advances the simulation by ONE tick (the header's per-tick contract: advance -> emit -> overflow report). The game's tick driver calls this exactly once per completed tick (ARCH-002). O(live + sum of rates); no allocation; no logging on the happy path. No-op in the stopped state.", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::liveParticles", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 437, "signature": "[[nodiscard]] std::span liveParticles() const noexcept", "summary": "The render read path (M2-PART-02): the live particles as a non-owning span (PERF-005) — the compact live prefix [0, liveCount) of the pool (spawn order with swap removal). Read it after the sim phase (the M2-GL-02 cull/batch stage). O(1) (the span); never writes.", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::fadeAlpha", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 444, "signature": "[[nodiscard]] static std::uint8_t fadeAlpha(const Particle& p) noexcept", "summary": "The particle's CURRENT fade alpha (0..255) — the header's color-fade section (exact u32 integer arithmetic). Precondition: `p` is live (age < life — no underflow). O(1).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::prngSeed", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 456, "signature": "[[nodiscard]] std::uint64_t prngSeed() const noexcept", "summary": "The system's Prng stream state (the replay identity's part — M1-DET-03 pattern; the state after N successful spawns is 4N draws from the seed, the header's determinism section). O(1).", "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::prngStatePart1", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 457, "signature": "[[nodiscard]] std::uint64_t prngStatePart1() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ParticleSystem::prngStatePart2", "kind": "method", "header": "src/laige-sim/include/laige/sim/particles.h", "line": 460, "signature": "[[nodiscard]] std::uint64_t prngStatePart2() const noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Position2D", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 258, "signature": "template struct Position2D", "summary": "The entity's 2D simulation-space position (the ground plane — PRD §4; the axes/units contract lands with the concepts docs). The value is the selected SimMath backend's Vec2 (ADR 0002: one template instantiation per backend, factory-selected at engine init). A data carrier (S-8): trivially copyable, no behavior — the LAIGE_COMPONENT marks below register both instantiations in the same path as user components (M1-ECS-02).", "budget": null, "experimental": false}, {"name": "laige::Position2D::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 260, "signature": "sim::SimMath::Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 263, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 2d0a405..536e212 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -228,7 +228,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. ## Particles -- [ ] **M2-PART-01 · Particle simulation (CPU, 2D + depth)** +- [x] **M2-PART-01 · Particle simulation (CPU, 2D + depth)** - **Refs:** FR-2.7 (CPU-simulated, budgeted, pooled) - **Depends:** M1-ECS-03 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index beb02ee..1d200a2 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 19 | ⬜ in progress | +| M2 | 33 | 20 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **65** | | +| **Total** | **194** | **66** | | --- @@ -243,6 +243,7 @@ One line per completed (or split/renumbered) step. | 2026-10-04 | M2-TILE-01 | `—` / PR (open) | Tilemap data + auto-depth from tile height (FR-2.6; M2-TILE-01 scope, nothing else): **API** — NEW `laige::render::TileMap` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `TileMap::Options` IS the M2-ISO-02 table's `Options`, one type, no duplicated validation) + `TileData` (the per-tile value: `textureId` — the game-assigned atlas ref, `height` — read from the owned table, `animationId` — DATA ONLY in M2, M2-TILE-02 cycles frames from it): `create(options)` (setup path: the table's create validation first-failure-wins + the pre-sized 8 B/tile data array; flat/empty init), `setTile(gx, gy, textureId, height, animationId)` (the per-tile edit: the height routes into the table's `setTile` — the AUTO-DEPTH wiring, the table recomputes exactly that cell's key, radius 0 — O(1), zero allocation), `rebuild(tiles)` (scene load: the requested grid row-major tileX fastest → the heights mapped into the table's covered rectangle → the table's from-scratch `rebuild`; property `rebuild(final grid) == any setTile sequence reaching the same grid` pinned; one setup-path temporary), `declareTo(batcher, options)` (the render path / S-5: one `SpriteItem` per tile of the requested grid — the tile's CENTER pos `(gx+0.5, gy+0.5)`, scale (1,1) (the unit quad spans the tile cell), rotation 0, the fixed-frame UV (`DeclareOptions::uv`, default the full texture), the table's key (auto-depth — `depthOverride` stays false, G-R11), the tile's texture as `atlasId`, the options' `materialId`/`blend` — in the grid's row-major order (RENDER-003: the tile's grid position is its stable identity, the FR-1.2 analog); preconditions: the batcher window open (a built frame's window is closed → `InvalidArgument`, nothing declared; a stopped batcher → `BudgetExhausted`, nothing declared); the WHOLE requested grid is declared — visible-rect culling lands with M2-PERF-01, the composite 50k budget's worst case is the full grid), `tileAt`/`depthKeyAt`/`tileHeightAt`/`covers` (the O(1) read path; `covers` = the requested grid — the table's superset margin cells are table cells, not tiles), introspection `originTileX/Y()`, `widthTiles()`, `heightTiles()`, `layer()`, `chunkTiles()`, `tileCount()`; **bounded draw calls** (FR-2.1, RENDER-001) — the batcher's (atlas, material, blend) grouping renders one tilemap in one draw call per DISTINCT (textureId, material, blend) combination: tiles of one chunk sharing one texture and blend form ONE group (one draw call per chunk group); **semantics** — the tile's height lives in the table ALONE (one source of truth; `tileAt` combines the three fields); rejected operations (tile outside the requested grid, height outside `|h| ≤ 2047`, wrong span size, any out-of-domain height in the span) leave the tile data AND the table unchanged (validated before any write; the `Status` is the failure channel — LOG-002); the happy update/declare paths log nothing (LOG-002/003 — pinned by a no-log test); ARCH-009: headless-buildable, presentation-only, sim-phase writes / render-phase reads; **tests** — NEW `tests/laige-render/tilemap_tests.cpp` (new CTest entry `tilemap` = the step's Verify command; 17 tests / 6 suites, no GL — runs in every tree): `TileMapCreate` (the grid options + the flat/empty contract, the non-zero origin, the rejected options — first failure wins, the create-time grid-over-cap → `InvalidArgument` (a misconfiguration; the runtime growth cap is the `BudgetExhausted` of `ensureChunk`)), `TileMapData` (the per-tile read/write, rejected edits leave no state (incl. the superset-margin cells — table cells, not tiles), the scene load against the hand-computed goldens, the rebuild validation (wrong size / out-of-domain height — whole-span validation), the rebuild-from-scratch == incremental property (8×8, both backends)), `TileMapAutoDepth` (the roadmap's "height change → depth table increment": a single tile-height edit changes ONLY the edited cell's key (radius 0), the hand-computed golden + the independent oracle, last-write-wins, the cross-backend key agreement on the dyadic grid-locked centers), `TileMapDeclareGolden` (the roadmap's "tile quad positions/depth for a 4×4 chunk": all 16 declared quads pinned field-by-field against the hand-computed position + depth goldens (the 16 literal keys — four screen rows with the exact tie structure), the fixed-frame fields, the (texture, material, blend) grouping — 2 texture ids → exactly 2 groups, ascending atlas order, the hand-computed in-group instance sequences (the (key, declaration-position) stable sort restricted to the group) — + the cross-frame determinism (bit-identical second frame) + the `tilemap-golden:` machine line), `TileMapDeclare` (the frame protocol — a built frame's window is closed, `InvalidArgument`, nothing declared; a stopped batcher → `BudgetExhausted`; the custom `DeclareOptions` (material/blend/uv) carried on every item; the no-log happy path), `TileMapZeroAlloc` (the 1000-frame × 256-tile `beginFrame`/`declareTo`/`build` loop allocates NOTHING under the allocation watch — FR-2.2); **docs** — NEW `docs/api/tilemap.md` (the full contract: the API table, the tile model + auto-depth, the quad model + bounded draw calls, the declaration-order determinism, ownership/lifetime/threading, the DOC-004 Performance table, the misuse warnings, a performant example), `docs/README.md` index entry, the module README status paragraph, `docs/concepts/coordinates.md` (NEW §4.9 + the §5 conversion row "Tile grid → static tile quads" + the Related link); **API surface** — `laige-api.json` regenerated (1233 → 1265 symbols, 38 → 39 headers: `TileData` + 3 fields, `TileMap` + `Options` alias + `DeclareOptions` + 3 fields, the 15 public members, the move/copy ops, the private `TileSlot` + its 2 fields — the `CellRecord`-style private nested-type convention); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, `sprites_50k_cpu` — M2-PERF-01 measures the reference scene with tile quads included); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green: build 112/112, build-clang 112/112, build-release 101/101, build-shared 112/112, build-asan 109/109, build-tsan 109/109; `ctest -R tilemap` green (17/17, both backends); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint` (68 source files), and `tools/laige-determinism-lint` (28 sim source files, 0 violations) green; Progress Board M2 17/33, total 63/194 | | 2026-10-07 | M2-PAR-01 | `—` / PR (open) | Parallax layers (FR-2.3; M2-PAR-01 scope, nothing else): **API** — NEW `laige::render::ParallaxLayers` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `parallax.h`) + `ParallaxLayerDef` (the named layer value: `id` < `maxLayers`, `source` (Image | Tilemap — the M2-TILE-02 hook), `factor` in [0, 1] (one source of truth — 0 = fixed in world space, 1 = fixed on screen), `center` (the reference camera position — where the layer sits at exactly `offset`; default (0,0)), `offset` (the world-space offset at `p = center`; default (0,0)), `size`/`uv` (Image only), `atlasId`, `tilemapId` (Tilemap only), `materialId`, `blend`, `depthLayer` (the M2-ISO-01 key layer value), `scrollMode` (Manual | Auto), `scrollSpeed` (UV units PER FRAME per axis — negative scrolls the other way), `enabled`) + `ParallaxScrollMode`/`ParallaxSource` + the named presets (`kParallaxLayerBackground/Midground/Foreground/CustomBase` = 0/1/2/3) + the depth-layer values (`kParallaxDepthLayerBackground` = -2, `kParallaxDepthLayerMidground` = -1, `kIsoDepthGroundLayer` = 0 (the ground), `kParallaxDepthLayerForeground` = +1; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] — more negative = further back); `create(options)` (`maxLayers` in [1, `kParallaxLayersMaxLayers` = 64], default `kParallaxLayersDefaultLayers` = 8 — the pre-sized slot table; no log), `setLayer(def)` (validates the def in the documented order — id → factor → center → offset → scroll_speed → size (Image) → uv (Image) → depth_layer — first failure wins; a rejection leaves the slot unchanged + one rate-limited `parallax/layer_invalid` warn (fields `layer`, `field`) — LOG-004; a success RESETS the layer's UV offset to (0, 0)), `setUvOffset(id, uv)` (any finite value — WRAPPED to [0, 1)²; unknown id → `InvalidArgument` (no log); non-finite → `InvalidArgument` + one rate-limited `parallax/uv_offset_invalid` warn), `advanceScrolls()` (the AUTO layers only — once per frame, before the declarations; O(layers), no allocation, no logging, no GL), `declareTo(batcher, cameraPos)` (the render path / S-5: declares every SET, ENABLED Image-source layer's wrap quads — the 2 x 2 split (q00, q10, q01, q11 — a quad whose range is empty — uvOffset 0 on that axis — is skipped); each quad: its world CENTER `pos`, its extent `scale`, rotation 0, the full-default tint, the def's `atlasId`/`materialId`/`blend`, and the M2-ISO-01 key of the quad's world center at the def's `depthLayer` (engine-owned — G-R11, `depthOverride` stays false); Tilemap-source layers are SKIPPED (the M2-TILE-02 hook — no log); a built frame's window is closed → `InvalidArgument` (nothing declared); a stopped registry → `InvalidArgument` (no log); the first failed add fails the call), `worldOffsetAt(id, cameraPos)` (the EXACT formula `factor * (p - center) + offset` — O(1), no allocation, no logging, no GL), introspection (`valid()`, `maxLayers()`, `layerCount()`, `has()`, `layerAt()`, `uvOffsetAt()`); **the render order** (documented — background first): WITHIN a shared (atlas, material, blend) group the key's LAYER field dominates — every background-layer quad sorts before every ground object and every foreground quad after it, whatever the quads' v (the M2-ISO-01 "layer dominates" contract); ACROSS groups the draw order is the batcher's group order (ascending (atlas, material, blend) — RENDER-003), so the scene's SET-UP assigns the parallax layers' atlas ids so the group order matches the depth order: background ids BELOW the world content's, foreground ids ABOVE it (the M2-TILE-01 texture-id convention); **the UV scroll** (auto or manual): each layer carries a CURRENT UV OFFSET in [0, 1)² — Manual via `setUvOffset`, Auto via `scrollSpeed` on `advanceScrolls()` (frames are the presentation pace — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent); the WRAP is exact at the texture boundary (`wrap(x) = x - floor(x)`: 1.0 → exactly 0.0; -0.25 → exactly 0.75 — dyadic values wrap bit-exactly, pinned); the texture's v axis (v = 0 = first uploaded texel row, the M2-SPRITE-02 contract) maps to the world +y direction; a scrolled layer renders through the 2 x 2 wrap split (a single `SpriteItem` carries ONE UV rect — no wrap): up to four quads per layer, one draw call per layer group (FR-2.1, RENDER-001); **semantics** — everything is WORLD space (PRD §4, RENDER-006 — the formula is a world-space translation); ARCH-009: headless-buildable, presentation-only (the layer state reads the camera's presentation position, never sim state — never part of replay state or the simulation state hash); one owner (the sim/scene-owner thread; the M2-GL-02 cull/batch stage owns the render-side declaration), sim-phase writes / render-phase reads (CONC-001); no per-frame allocation (FR-2.2 — the zero-allocation proof: 1000 frames × 3 layers (up to 4 quads each) + 2 sprites allocates NOTHING on the owner thread); the rejected-definition warn is the only logging (LOG-002/004 — the happy paths log nothing); **tests** — NEW `tests/laige-render/parallax_tests.cpp` (new CTest entry `parallax` = the step's Verify command; 12 tests / 6 suites, no GL — runs in every tree): `ParallaxCreate` (the create-domain edges + the stopped-state matrix), `ParallaxLayer` (the `setLayer` validation matrix — 15 rejection cases with the pinned warn fields (first failure wins), the domain edges accepted, the Tilemap-source size/uv NOT validated, state-unchanged-on-rejection, the replace + scroll-reset), `ParallaxOffset` (the EXACT offset formula — hand-computed dyadic goldens for factors 0/1/0.5/0.25 (the dyadic exactness zone, both backends agree) + factor 0.3f linearity within tolerance; the world-origin reference case (center/offset zeroed)), `ParallaxScroll` (the auto advance + the EXACT wrap at the 1.0 boundary (→ (0,0)) + the negative axis, the manual wrap (1.5 → 0.5, 1.0 → 0, -1/-2 → 0), the offset persists across frames, the non-finite rejection + the pinned warn, the unknown-id no-log), `ParallaxDeclare` (the roadmap's golden: the 4-layer + ground scene at camera (10,6) — both backends: the 5 declared quads pinned field-by-field (world centers, extents, the atlas uv mapping incl. the layer `uv` sub-rect, the hand-computed 32-bit keys — WITH the 2^21 base — against the independent M2-ISO-01 oracle), the batcher's group (draw) order (atlas 0..4 ascending), the layer-dominance orderings (bg < mid < ground < custom < fg, even when the quads' v is out of order), the cross-frame determinism (bit-identical second frame); the scrolled split — the FULL/half/un-scrolled cases (4/2/1 quads — the empty-range quads skipped; the world rects tile the rectangle exactly: the areas sum to the rectangle's area; the q00/q10/q01/q11 order pinned), the atlas SUB-RECT mapping (the layer uv (0.25,0,0.75,0.5) mapped over the quad's base range), the OFFSET layer's position, the `frameCounts` pin (6/4/3/6); the frame protocol — a built frame → `InvalidArgument`, a stopped batcher → `BudgetExhausted`, a stopped registry → `InvalidArgument` (no log), the disabled + tilemap-hook skips (no log)), `ParallaxZeroAlloc` (the 1000-frame loop under the allocation watch — 0 owner-thread allocs — FR-2.2); **docs** — NEW `docs/api/parallax.md` (the full contract: the API table, the model + the exact offset formula, the render-order section (within-group layer dominance + the across-group atlas-id convention), the UV scroll + the exact wrap + the 2 x 2 split table (world/uv per quad), ownership/lifetime/threading, the DOC-004 Performance table, the performant example + the misuse warnings), `docs/README.md` index entry, the module README status paragraph, `docs/concepts/coordinates.md` (NEW §4.10 + the §5 conversion row "Camera position → parallax offset" + the §6 render-order summary + the Related link); the stale "land with M2-PAR-01" references updated in the same change (`docs/api/iso_depth_key.md` layer field + Related, `docs/api/iso_depth_table.md`, `docs/api/tilemap.md`, the `tilemap.h` comment); **API surface** — `laige-api.json` regenerated LAST (1265 → 1315 symbols, 39 → 40 headers: the 2 enums, `ParallaxLayerDef` + 16 fields + the field-wise `operator==` (a defaulted `==` is deleted — `SpriteUvRect` has no `operator==`), `ParallaxLayers` + `Options` + 13 public members, the 6 `kParallax*` constants — the `TileSlot`-style private nested-type convention); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, `sprites_50k_cpu` — M2-PERF-01 measures the reference scene with the parallax layers included — the M2-SCENE-01 reference scene has 3 parallax layers within the 50k-sprite / ≤30-draw-call budget); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green: build 113/113, build-clang 113/113, build-release 102/102, build-shared 113/113, build-asan 110/110, build-tsan 110/110 (the prior counts +1 each — the new `parallax` entry); `ctest -R parallax` green (12/12, both backends); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint` (69 source files), and `tools/laige-determinism-lint` (28 sim source files, 0 violations) green; Progress Board M2 18/33, total 64/194 | | 2026-10-07 | M2-TILE-02 | `—` / PR (open) | Parallax tile layers + tile animation (FR-2.6; M2-TILE-02 scope, nothing else): **API** — `TileMap` (header-only, `tilemap.h`) gained: `TileAnimationDef` (`frameCount` in [1, `kTileAnimMaxFrames` = 64], `frameTicks` (the documented rate — the SIMULATION ticks per frame, ≥ 1), the tile sheet's `SpriteFrameLayout` (M2-SPRITE-03, texels)) + `kTileAnimMaxFrames`/`kTileMapDefaultAnimations` (8)/`kTileMapMaxAnimations` (256) + `Options::maxAnimations` (validated FIRST in `create`, domain [1, 256] — first failure wins, then the table's grid options) + the pre-sized animation slot table (id 0 = the STATIC sentinel; 1..maxAnimations = animation slots — the tile's `animationId` is now CONSUMED by the batch path, no longer inert data — the existing tilemap test goldens updated to static tiles); `setAnimation(id, def) → Status` (setup/config path, the parallax `setLayer` precedent: validates the id domain → frame count → tick rate → sheet extents → frameCount ≤ columns·rows → the tight sheet's float-exact domain (2^24 texels, the adversarial-layout u64 overflow guards, CPP-004) — first failure wins, no log, the slot unchanged; a success RESETS the phase and PRECOMPUTES all frame UVs (one `spriteFrameUv` per frame — the only per-animation allocation)); `advanceAnimations()` (the sim phase's per-tick call — ONCE PER SIM TICK, ARCH-002: every set animation's frame steps every `frameTicks` ticks, wrapping at `frameCount` — `frame(ticks) = (ticks / frameTicks) mod frameCount` — O(maxAnimations), zero allocation, no GL; the frame state is presentation state, ARCH-009); `hasAnimation(id)`/`animationAt(id)`/`animationFrame(id)` introspection; the `declareTo` frame fields: the STATIC tile carries `DeclareOptions::uv` + `frameIndex` 0, the ANIMATED tile its animation's CURRENT frame UV (the precomputed rect) + `frameIndex` (an animated tile whose slot is UNSET fails the declare — first failure wins, nothing declared past it); NEW `declareTo(batcher, options, layers, layerId, cameraPos) → Status` — the M2-PAR-01 Tilemap-source HOOK implemented: every quad TRANSLATED by the layer's `worldOffset(cameraPos)` (the M2-PAR-01 formula (1)), its key the M2-ISO-01 key of the TRANSLATED center at the TILEMAP's own `Options::layer` (the scene-setup convention: the layer's def `depthLayer` must equal the tilemap's `layer` — bg/mid/fg tilemaps get -2/-1/+1); the translated keys are computed per tile per frame (the camera-dependent translation is not precomputable — O(tileCount), zero allocation; the qBase + qOffset derivation is the documented upgrade path); protocol (first failure wins, nothing declared, no log): built frame / unset layer id / non-Tilemap-source layer → `InvalidArgument`, a DISABLED layer declares NOTHING (OK — the layer's documented skip); `setTile`/`rebuild` gained the `animationId ≤ maxAnimations` check (0 always valid; the WHOLE span validated before any write). No GL anywhere (pure data + batcher bookkeeping); no per-frame allocation (FR-2.2 — the frame UVs precomputed at `setAnimation`, the slot table at `create`); no standalone `budgets.json` entry (the per-frame declare + per-tick advance cost is part of the composite 50k render-CPU budget — M2-PERF-01). **Tests** — NEW ctest entry `tilemap_anim` (`tests/laige-render/tilemap_anim_tests.cpp`, 12 tests / 6 suites, both backends, no GL): `TileMapAnimSet` (the `setAnimation` validation matrix — the slot domain (0 / > maxAnimations rejected), frameCount [1, 64] (65 rejected, 64 on an 8×8 sheet accepted), frameTicks ≥ 1, the zero-sheet-extent rejections, frameCount beyond the sheet's frame count, the tight sheet beyond 2^24 (the adversarial layout), the rejected-set-leaves-no-state + phase-reset contract, no-log happy path), `TileMapAnimCycle` (the documented rate: hand-computed `frame = ticks / frameTicks mod frameCount` over two INDEPENDENT animations — the phase is per animation, not per tile), `TileMapAnimDeclare` (the hand-computed frame-UV goldens from the M2-SPRITE-03 tight-sheet formula (a 2×2 grid of 16×16 frames → 32×32 sheet), the frameIndex, the (atlas, material, blend) groups, the wrap at tick 8, and the cross-frame determinism — same animation state → bit-identical items), `TileMapAnimParallax` (the golden-verified offsets at given camera positions: factor 0.25, center (4,4), offset (1,2), camera (10,6) → offset (2.5,2.5); the hand-computed layer-(-2) key goldens (0x7FA00060/0x7FA00070 — the 2^21 bias adds into bit 21) + the independent oracle (`isoDepthKey` on the translated centers, both backends — the dyadic exactness zone) + the layer-dominance ordering (a ground probe sorts after every bg tile); camera (4,4) → 0x7FA00040, camera (14,8) → 0x7FA00078; the protocol paths (built frame / unset id / Image-source / disabled layer → nothing declared); the animated tile UNDER the translation (frame 1 UV + translated pos, frameIndex 1)), `TileMapAnimProtocol` (the `animationId` edit/load domain — out-of-range rejected, no state change; the boundary id accepted; the WHOLE span validated; the unset-slot declare failure + frameCount 0), and `TileMapAnimZeroAlloc` (1000 frames × (256 tiles + 64 parallax tiles) of `advanceAnimations` (30 ticks) + `beginFrame`/`declareTo` (standalone + parallax)/`build` under the owner-thread allocation window — 0 allocations, the dladdr site diagnostic). The existing `tilemap` suites updated to the new `animationId` semantics (static sentinel 0; the domain 0..maxAnimations). **Docs** — `docs/api/tilemap.md` (the tile animation + parallax tile layer sections, the new API rows, the failure/perf/ownership updates, the misuse warnings), `docs/api/parallax.md` + `parallax.h` comments (the hook is implemented: the tiles are declared through the tilemap's `declareTo` overload, not this registry's — the def's `depthLayer` must equal the tilemap's `Options::layer`), `docs/concepts/coordinates.md` (§4.9 the tile animation + parallax tile layer, §4.10 the Tilemap source, §5 the tile-grid → tile-quads row), `docs/README.md`, `src/laige-render/README.md`. **Verify** — all six local trees warning-clean (build 114/114, build-clang 114/114, build-release 103/103, build-shared 114/114, build-asan 111/111, build-tsan 111/111 — the prior counts +1 each, the new `tilemap_anim` entry); `ctest -R tilemap_anim` green (12/12, both backends); `ctest -R tilemap` still green (the unanchored regex also selects the `tilemap_anim` entry — intended); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint`, and `tools/laige-determinism-lint` green after the final `laige-api.json` regeneration; Progress Board M2 19/33, total 65/194 | +| 2026-10-07 | M2-PART-01 | `—` / PR (open) | Particle simulation (CPU, 2D + depth) (FR-2.7 — "CPU-simulated, budgeted, pooled"; M2-PART-01 scope, nothing else): **API** — NEW `laige::ParticleSystem` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `src/laige-sim/include/laige/sim/particles.h`) + `ParticleEmitterDef` (the spawn-domain value: `origin` (the spawn position — a particle spawns exactly here, no position jitter in M2-PART-01), `depth` (constant over life — the M2-ISO-01 depth-key input M2-PART-02 feeds the batcher), `velMin`/`velMax` (the per-tick velocity box, componentwise), `lifeMin`/`lifeMax` (sim ticks), `sizeMin`/`sizeMax` (world units), `tint[4]` (base RGBA u8, default white), `fadeTicks` (0 = no fade), `continuousRate` (particles PER SIMULATION TICK; 0 = burst-only — unbounded by design: the pool budget bounds the work, overflow drops)) + the constants `kParticlePoolMaxParticles` (2^16)/`kParticlePoolDefaultMaxParticles` (4096)/`kParticleEmitterMaxEmitters` (256)/`kParticleEmitterDefaultMaxEmitters` (32)/`kParticleMaxLifeTicks` (2^20)/`kParticleSampleDenominator` (2^24 — the Prng's 24-bit tap resolution) + `ParticleEmitterId` (dense from 1, 0 = the unused sentinel) + `ParticleStats` (the live/capacity gauges + the u64 spawnedTotal/droppedTotal counters — the DBG-008 feed); `create(options)` (`maxParticles` in [1, 2^16], `maxEmitters` in [1, 256], any u64 seed — validates (no log — the M2-SPRITE-01 create precedent), two pre-allocations: the pool (maxParticles × 40 B) + the emitter table (maxEmitters + 1 slots, index 0 unused) — the setup path, nothing allocates afterward); the public default ctor = the stopped state (`valid()` false, `addEmitter`/`burst` → `InvalidArgument` (no log), `update()` a no-op, `liveParticles()` empty, `stats()` zero) + move-only (O(1) pointer swap; moved-from = stopped — its Prng copy shares the stream position but never draws again); `addEmitter(def)` (dense id in registration order; validates in the documented order — origin → depth → vel_min → vel_max → vel_box → life_min → life_box → life_max → size_min → size_max → size_box → fade_ticks — first failure wins; a rejection leaves the registry unchanged + one rate-limited `particles/emitter_invalid` warn (fields `emitter` = the would-be id, `field`) — LOG-004; registry full → `BudgetExhausted` + `particles/emitters_exhausted` warn (field `capacity`); stopped → `InvalidArgument` (no log)); `burst(id, count)` (spawns NOW in the sim phase — the game's spawn policy; stopped or unknown id → `InvalidArgument` (no log — the M2-PAR-01 setUvOffset precedent), count 0 a no-op success; O(count)); `update()` (EXACTLY ONCE per completed sim tick — ARCH-002, the `TileMap::advanceAnimations` pattern — in-tick order: advance (live-array order: `age += 1`, `pos += vel` (one SimMath vector add — ADR 0002), `age >= life` kills via swap removal (the swapped-in particle — not yet advanced this tick — re-examined at the same index)) → emit (registration order, continuousRate each) → overflow report (at most ONE rate-limited `particles/pool_overflow` warn per tick, fields `dropped`/`live`/`capacity` — LOG-004; the logger 1s window is a second layer); O(live + Σrates), zero allocation, no logging on the happy path (LOG-003)); **lifetime semantics** — a particle emitted during tick T's update is visible for EXACTLY `life` renders (age 0..life-1, dies during tick T+life's update); a particle burst-spawned before tick T's update is advanced by that same tick (visible for life − 1 renders — the documented per-tick contract); **determinism (ARCH-010, ADR 0002)** — the state after N updates is a pure function of (seed, emitter definitions, operation sequence): each SUCCESSFUL spawn consumes exactly FOUR Prng draws in a fixed order (vx, vy, life, size) — each uniform scalar `lerp(min, max, u / 2^24)` (the Prng's 24-bit tap resolution, one documented rounding per backend), `life` an integer draw; a DROPPED spawn consumes NO draws (the pool check precedes the draws — a drop never perturbs the stream); fpx16_16 bit-identical across all builds/platforms/ISAs, fp32_pinned same-build/same-platform (the detcheck matrix owns cross-target claims); the Prng state is exposed (`prngSeed()`/`prngStatePart1()`/`prngStatePart2()`) for the World `stateHash` / replay identity (the M1-DET-03 pattern); **pool budget (FR-2.7, PERF-003/008)** — overflow = DROP (counted in `droppedTotal`, one warn per tick window) — never grows the pool, never throws, never mutates live state; the `stats()` feed is the budget's observability (DBG-008); **threading (CORE-009, CONC-001)** — one owner (the sim thread); the render pass reads `liveParticles()` (a non-owning `std::span` of the compact live prefix — spawn order with swap removal) read-only after the sim phase (the M2-GL-02 cull/batch stage, ARCH-009); **tests** — NEW `tests/laige-sim/particles_tests.cpp` (new CTest entry `particles` = the step's Verify command; 17 tests / 10 suites, both backends, no GL — runs in every tree): `ParticlesCreate` (the create domain edges (maxParticles 0 / 2^16 / 2^16+1, maxEmitters 0 / 256 / 257 — first failure wins, no log on rejection), the stopped-state matrix, move-stops-source (no log)), `ParticlesEmitter` (dense ids, the validation matrix — every rejection case with the pinned warn fields (first failure wins, incl. the fp32 NaN cases + the fpx box cases), state-unchanged-on-rejection, the registry `BudgetExhausted` with the pinned `capacity` field, the no-log happy path), `ParticlesBurst` (a degenerate def's EXACT particle fields pinned (Q16.16 raws / dyadic floats — origin/depth/vel/life/size/tint/fade), count 0 no-op, unknown-id/stopped `InvalidArgument` no log, the pool drop with the pinned `dropped`/`live`/`capacity` fields — one warn), `ParticlesUpdate` (hand-computed per-tick advance goldens both backends (vel (0.5, −0.25) from (1,2): Q16.16 raw x = 65536 + 32768k, y = 131072 − 16384k; float 1.5/2.0/2.5/3.0 & 1.75/1.5/1.25/1.0), death at EXACTLY age == life (5 renders for life 5), the swap-removal live order), `ParticlesContinuous` (the rate + the first two spawns' (vel, life, size) cross-checked against an independent `Prng` oracle — the 4-draw contract — + the pool-full one-warn-per-tick pin), `ParticlesDeterminism` (50 ticks + a burst(2,5) every 10th tick on a capacity-8 pool (drops occur): same seed → bit-identical live set (every field, both backends) + the machine-greppable `particles-determinism … fnv1a=0x…` state-hash line (FNV-1a 64, the docs/testing.md §4 KAT convention), a different seed diverges, an independent `Prng` advanced by 4 × spawnedTotal draws lands on the system's stream state (the no-draws-on-drop contract), `EXPECT_GT(droppedTotal, 0)` pinning the exercised drop path), `ParticlesFade` (the EXACT integer fade table: life 8/fade 4/tint.a 255 → age 0..7 alpha [255,255,255,255,255,191,127,63], tint.a 200 → 150/100/50 at age 5/6/7, fadeTicks 0 constant — direct `fadeAlpha` + through the system), `ParticlesPoolExhaustion` (the EXACT per-tick table — capacity 3, rate 2, life 4: live [2,3,3,3,3,3,3,3,3,3], droppedTotal [0,1,3,5,5,6,8,10,10,11], cumulative warns [0,1,2,3,3,4,5,6,6,7] — one per tick where drops occur), `ParticlesStats` (the exact `ParticleStats` feed: {4 live, 4 capacity, 8 spawned, 2 dropped} after 5 ticks), `ParticlesZeroAlloc` (500 ticks × 6 spawns + a periodic burst under the owner-thread allocation watch — 0 heap allocations + the machine-greppable `particles-zeroalloc ticks=500 allocs=0` line; the steady-state counters pinned); **docs** — NEW `docs/api/particles.md` (the full contract: the API table, the per-tick contract, the pool budget, the determinism scope, the color fade, the failure table (first failure wins), ownership/lifetime/threading, the DOC-004 Performance section, the performant example + the misuse warnings), `docs/README.md` (the API index entry + the laige-sim doc list), `src/laige-sim/README.md` (the module status paragraph), `docs/concepts/coordinates.md` (NEW §4.11 + the §5 conversion row "Particle state → sprite items" (Planned, M2-PART-02) + the Related link); **API surface** — `laige-api.json` regenerated LAST (1342 → 1410 symbols, 40 → 41 headers); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the particle→sprite budget is measured in M2-PART-02 against the composite 50k render-CPU budget (PRD §8.1); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green: build 115/115, build-clang 115/115, build-release 104/104, build-shared 115/115, build-asan 112/112, build-tsan 112/112 (the prior counts +1 each — the new `particles` entry); `ctest -R particles` green (17/17, both backends); `api-real-tree`/`api-check-fresh` (2/2), `tools/laige-include-lint` (70 source files), and `tools/laige-determinism-lint` (29 sim source files, 0 violations) green after the final `laige-api.json` regeneration; Progress Board M2 20/33, total 66/194 | --- diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 4be68d9..26edfe8 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -183,6 +183,19 @@ opt-in cached per-run report (`startBudgetReport` / surface (CTest entries `budget_report`, `laige_run_budget`; API contract in [docs/api/frame_budget.md](../docs/api/frame_budget.md)). +M2-PART-01 landed the CPU particle simulation (FR-2.7 — the +budgeted, pooled CPU-sim particle half) — `ParticleSystem`: +the bounded pre-allocated pool (drop-on-overflow, one rate-limited +warn per tick), the dense-id emitter registry (burst + continuous +emission), the once-per-sim-tick `update()` (advance → emit → +overflow report, ARCH-002), the exact integer color fade, the +fixed-seed determinism contract (the 4-draw Prng contract, the +machine-greppable state hash), and `liveParticles()` as the +render path's read-only surface (`include/laige/sim/particles.h`; +API contract in +[docs/api/particles.md](../docs/api/particles.md), tests under +[tests/laige-sim](../tests/laige-sim), CTest entry `particles`). +The render half (particles as batched sprites) lands in M2-PART-02. The editor overlay surface (M2), the detcheck matrix (M1-DET-04), and the remaining M1 steps land next; physics, input, and animation in M3. diff --git a/src/laige-sim/include/laige/sim/particles.h b/src/laige-sim/include/laige/sim/particles.h new file mode 100644 index 0000000..e327268 --- /dev/null +++ b/src/laige-sim/include/laige/sim/particles.h @@ -0,0 +1,729 @@ +// laige-sim particle simulation (M2-PART-01). +// +// FR-2.7: "Lightweight GPU particle system (CPU-simulated, 2D + +// depth), budgeted, pooled." This step is the CPU-simulation half: +// a bounded, pooled particle system updated in the simulation tick +// (deterministic, engine math only — ADR 0002). The rendering half +// (particles as batched sprites, one draw call per emitter set, +// depth from the particle depth value) is M2-PART-02, which consumes +// this header's read-only surface. +// +// ParticleEmitterDef The emitter definition (spawn +// domain + continuous rate) +// ParticleSystem The bounded pool + emitter registry +// + per-tick update +// +// --------------------------------------------------------------------------- +// The model +// --------------------------------------------------------------------------- +// +// A particle is a simulation-space 2D position plus a CONSTANT depth +// value (the 2.5D height M2-PART-02 feeds the M2-ISO-01 depth key), +// a per-tick velocity, a life in simulation ticks, a size, a base +// RGBA tint (u8 channels), and a fade window: +// +// Particle { pos, depth, vel, age, life, size, tint[4], fadeTicks } +// +// (the `ParticleSystem::Particle` struct — 40 bytes per particle on +// both backends). An emitter is a named, validated spawn domain: +// origin, depth, velocity box [velMin, velMax] (per tick), life box +// [lifeMin, lifeMax] (ticks), size box [sizeMin, sizeMax], tint, +// fade window, and `continuousRate` (particles per simulation tick; +// 0 = burst-only emitter). The system holds at most `maxEmitters` +// emitters with dense ids 1..N in registration order (id 0 is the +// unused sentinel — the M2-TILE-02 convention). +// +// --------------------------------------------------------------------------- +// The per-tick contract (ARCH-002) +// --------------------------------------------------------------------------- +// +// `update()` runs EXACTLY ONCE per completed simulation tick, driven +// by the game's tick path (a game system, or the loop's onTick hook — +// the TileMap::advanceAnimations pattern; the simulation never +// depends on the presentation frame rate). In-tick order: +// +// 1. Advance every currently live particle (live-array order): +// age += 1; pos += vel (one SimMath vector add — ADR 0002); +// age >= life kills (swap-removed with the last live particle; +// the swapped-in particle — not yet advanced this tick — is +// re-examined at the same index). +// 2. Emit from every emitter in registration order +// (continuousRate particles each). +// 3. Report the tick's overflow: at most ONE rate-limited warn. +// +// A particle spawned during tick T's update is live (in the live set) +// after ticks T..T+life-1 (age 0..life-1) and dies during tick +// T+life's update — visible for EXACTLY `life` renders. Velocity is +// constant (no acceleration — out of scope for this step); depth is +// constant over the particle's life (the emitter's depth). +// +// --------------------------------------------------------------------------- +// Determinism (ARCH-010, ADR 0002) +// --------------------------------------------------------------------------- +// +// The state after N updates is a pure function of (seed, emitter +// definitions, operation sequence), per backend: +// +// - The system owns one Prng (Options::seed). Each SUCCESSFUL spawn +// consumes exactly FOUR draws in a fixed order: vx, vy, life, +// size. Each uniform scalar is lerp(min, max, u / 2^24) where +// u = next_range(0, 2^24) — the Prng's 24-bit tap resolution +// (prng.h) — one documented rounding per backend (the ADR 0002 +// lerp contract); life is an integer next_range draw. +// - A DROPPED spawn (pool full) consumes NO draws: the pool check +// precedes the draws, so a drop never perturbs the stream. +// - Motion is SimMath ops only and the fade is exact integer +// arithmetic (below). Nothing here reads platform intrinsics, +// addresses, or wall-clock time. +// +// Backend scope (ARCH-010): fpx16_16 — bit-identical across all +// builds, platforms, ISAs, and compilers (the language-standard +// guarantee); fp32_pinned — bit-identical across runs of the same +// build on the same platform/ISA (the detcheck matrix owns +// cross-target claims). The Prng state is part of the deterministic +// state: `prngSeed()`/`prngStatePart1()`/`prngStatePart2()` expose it +// for the World stateHash / replay identity (the M1-DET-03 pattern). +// +// --------------------------------------------------------------------------- +// The pool budget (FR-2.7 "budgeted, pooled", PERF-008) +// --------------------------------------------------------------------------- +// +// The pool is `maxParticles` PRE-ALLOCATED slots (the setup path — +// with the emitter table, the system's only allocations; PERF-003: +// nothing after). Live particles occupy the FIRST `liveCount` slots +// (compact: a death swap-removes the last live particle into the dead +// slot, so the live order is spawn order with swap removal — +// deterministic). +// +// Overflow: a spawn into a full pool is DROPPED — counted +// (`droppedTotal` + per-tick bookkeeping) and reported with at most +// ONE `particles/pool_overflow` warn per tick (fields +// dropped/live/capacity; the LOG-004 logger rate window is a second +// layer). A drop never mutates live state, never throws, and never +// grows the pool — the pool is bounded by construction. +// +// --------------------------------------------------------------------------- +// The color fade (exact integer arithmetic) +// --------------------------------------------------------------------------- +// +// `fadeTicks == 0`: no fade (the tint alpha is constant). Otherwise: +// remaining = life - age (live particles always have age < life — no +// underflow), and +// +// alpha = remaining >= fadeTicks ? tint.a +// : tint.a * remaining / fadeTicks (u32 integer math) +// +// — the alpha ramps linearly from the fade window's start to 0 at +// death, one exact multiply + divide per read (no floating point — +// bit-identical on every platform). The fade touches the alpha +// channel only; the RGB channels are constant. `fadeAlpha(p)` +// exposes the computed value to the render pass. +// +// --------------------------------------------------------------------------- +// Failure (CORE-008, API-008) — first failure wins +// --------------------------------------------------------------------------- +// +// create: maxParticles outside [1, kParticlePoolMaxParticles] +// or maxEmitters outside [1, kParticleEmitterMaxEmitters] +// -> InvalidArgument (no log — the M2-SPRITE-01 create +// precedent). +// stopped: the default-constructed / moved-from / failed-create +// state: `valid()` false; `addEmitter`/`burst` -> +// InvalidArgument (no log — the stopped-state +// precedent); `update()` is a no-op; `liveParticles()` +// is empty; `stats()` reads zero. +// addEmitter: stopped -> InvalidArgument (no log); registry full -> +// BudgetExhausted + warn `particles/emitters_exhausted` +// (field capacity); a def field out of domain -> +// InvalidArgument + one rate-limited warn +// `particles/emitter_invalid` (fields emitter, field) — +// validation order: origin -> depth -> vel_min -> +// vel_max -> vel_box -> life_min -> life_box -> +// life_max -> size_min -> size_max -> size_box -> +// fade_ticks. A rejected def leaves the registry +// unchanged. +// burst: stopped or unknown emitter id -> InvalidArgument (no +// log — the M2-PAR-01 setUvOffset precedent); count 0 is +// a no-op success. +// +// The happy paths log nothing (LOG-003). +// +// --------------------------------------------------------------------------- +// Ownership, threading (CORE-009, CONC-001) +// --------------------------------------------------------------------------- +// +// One owner thread (the sim thread). `create` is the setup path (two +// pre-allocations: the pool, the emitter table); nothing allocates +// afterward (the zero-allocation proof: the tests' 500-tick +// update/burst window). Move-only (O(1) pointer swap; the moved-from +// system is stopped — its Prng copy shares the stream position with +// the live system, which is safe because the moved-from system is +// stopped and never draws again). The render pass reads +// `liveParticles()` (a NON-OWNING span — PERF-005) after the sim +// phase (the M2-GL-02 cull/batch stage): presentation reads +// authoritative state read-only (ARCH-009); nothing in the render +// pass writes here. +// +// Canonical narrative: docs/api/particles.md (the full contract); +// the particle state's place in the 2.5D model: +// docs/concepts/coordinates.md §4.11 + the §5 conversion row. + +#pragma once + +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/result.h" +#include "laige/sim_math.h" + +namespace laige { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// The pool capacity domain: [1, kParticlePoolMaxParticles]. 2^16 = +// 65 536 — headroom over the PRD §8.1 worst-case 50k-sprite scene +// (particle overlays on top); at 40 B/particle the worst-case pool +// is 2.5 MB (the setup path). +inline constexpr std::uint32_t kParticlePoolMaxParticles = 1u << 16; + +// The default pool capacity (a typical single-screen particle +// budget; the reference scene's emitters fit with margin). +inline constexpr std::uint32_t kParticlePoolDefaultMaxParticles = 4096; + +// The emitter registry capacity domain: [1, +// kParticleEmitterMaxEmitters]. +inline constexpr std::uint32_t kParticleEmitterMaxEmitters = 256; + +// The default emitter registry capacity. +inline constexpr std::uint32_t kParticleEmitterDefaultMaxEmitters = 32; + +// The particle life domain: [1, kParticleMaxLifeTicks] simulation +// ticks. 2^20 is roughly 4.9 h at 60 Hz — far beyond any game +// particle life. +inline constexpr std::uint32_t kParticleMaxLifeTicks = 1u << 20; + +// The uniform-sample resolution: the Prng's 24-bit tap (one draw +// resolves a [0, 1) sample to 2^-24 — the next_float01 resolution, +// prng.h). +inline constexpr std::uint32_t kParticleSampleDenominator = 1u << 24; + +// The emitter id: dense from 1 (registration order); 0 is the +// unused sentinel. +using ParticleEmitterId = std::uint32_t; + +// The particle system's profiler feed (DBG-008 — the M2-SPRITE-04 +// pattern): the gauges (live/capacity) + the counters +// (spawnedTotal/droppedTotal — u64: a long-running session's spawn +// count is not bounded by 2^32). +struct ParticleStats { + // The live particle count (gauge). + std::uint32_t live{}; + // The pool capacity (gauge — 0 in the stopped state). + std::uint32_t capacity{}; + // Total successful spawns since create (counter). + std::uint64_t spawnedTotal{}; + // Total dropped spawns (pool full) since create (counter). + std::uint64_t droppedTotal{}; +}; + +// --------------------------------------------------------------------------- +// The emitter definition (the validated spawn domain) +// --------------------------------------------------------------------------- + +// One emitter's spawn domain + rate (the header's model section). +// A plain value (no ownership); VALIDATED AT USE TIME by +// ParticleSystem::addEmitter (the header's failure section — first +// failure wins). Templated over the SimMath backends (the +// presentation.h pattern). +template +struct ParticleEmitterDef { + using Scalar = typename sim::SimMath::Scalar; + using Vec2 = typename sim::SimMath::Vec2; + + // The spawn position (sim space; a particle spawns EXACTLY here — + // no position jitter in M2-PART-01). + Vec2 origin{}; + // The spawn depth value (constant over the particle's life — the + // M2-ISO-01 depth-key input M2-PART-02 consumes). + Scalar depth{}; + // The velocity box lower bound (world units per tick; component- + // wise). + Vec2 velMin{}; + // The velocity box upper bound. + Vec2 velMax{}; + // The life box lower bound (simulation ticks; >= 1). + std::uint32_t lifeMin{1}; + // The life box upper bound (<= lifeMin.. kParticleMaxLifeTicks). + std::uint32_t lifeMax{1}; + // The size box lower bound (world units; >= 0). + Scalar sizeMin{}; + // The size box upper bound (>= sizeMin). + Scalar sizeMax{}; + // The base tint RGBA (0..255 per channel). + std::uint8_t tint[4]{255, 255, 255, 255}; + // The fade window (ticks; 0 = no fade; <= lifeMax — the header's + // color-fade section). + std::uint32_t fadeTicks{0}; + // The continuous emission rate (particles PER SIMULATION TICK; 0 = + // burst-only emitter). Unbounded here — the pool budget bounds the + // work (overflow drops). + std::uint32_t continuousRate{0}; +}; + +// --------------------------------------------------------------------------- +// The particle system (the bounded pool + emitter registry + update) +// --------------------------------------------------------------------------- + +// The bounded, pooled, deterministic particle system of FR-2.7 +// (header preamble). Templated over the SimMath backends (the +// M2-TILE-01/presentation.h pattern — one instantiation per +// backend, factory-selected at engine init, ADR 0002). Header-only: +// pure engine math + pool bookkeeping, no GL, no allocation in the +// per-tick path. +template +class ParticleSystem { + static_assert( + std::is_same_v || + std::is_same_v, + "ParticleSystem is templated over the SimMath backends"); + + public: + using Scalar = typename sim::SimMath::Scalar; + using Vec2 = typename sim::SimMath::Vec2; + using M = sim::SimMath; + + // The per-particle state (40 B, both backends — the header's model + // section). `age`/`life` are simulation ticks; `tint` is the base + // RGBA; `fadeTicks` is the emitter's fade window (0 = no fade). + // The render path reads these read-only (M2-PART-02). + struct Particle { + Vec2 pos{}; + Scalar depth{}; + Vec2 vel{}; + std::uint32_t age{}; + std::uint32_t life{}; + Scalar size{}; + std::uint8_t tint[4]{}; + std::uint32_t fadeTicks{}; + }; + + // The create options (API-006): `maxParticles` in [1, + // kParticlePoolMaxParticles] (default + // kParticlePoolDefaultMaxParticles); `maxEmitters` in [1, + // kParticleEmitterMaxEmitters] (default + // kParticleEmitterDefaultMaxEmitters); `seed` (any u64 — 0 is a + // valid seed; the system's Prng substream, the M0-CORE-06 pattern). + struct Options { + std::uint32_t maxParticles{kParticlePoolDefaultMaxParticles}; + std::uint32_t maxEmitters{kParticleEmitterDefaultMaxEmitters}; + std::uint64_t seed{0}; + }; + + // Creates the system (setup path — the only allocations: the pool + // and the emitter table). maxParticles/maxEmitters outside their + // domains -> InvalidArgument (no log — the M2-SPRITE-01 create + // precedent). The stopped state (failed create / default) + // follows the RenderThread precedent: `valid()` false, every + // operation fails or no-ops, no log. + [[nodiscard]] static laige::Result + create(Options options) noexcept; + + // The stopped state (default / failed create): nothing owned + // (pool_ null, maxParticles_ 0); the Prng is seed-0 (a stopped + // system never draws — the seed is inert). + ParticleSystem() noexcept : rng_(0) {} + + // Move-only: O(1) pointer swap; the moved-from system is stopped + // (its Prng copy shares the stream position with the live system — + // safe: the moved-from system never draws again). + ParticleSystem(ParticleSystem&& other) noexcept; + ParticleSystem& operator=(ParticleSystem&& other) noexcept; + ParticleSystem(const ParticleSystem&) = delete; + ParticleSystem& operator=(const ParticleSystem&) = delete; + + // True iff the system is live (create succeeded). + [[nodiscard]] bool valid() const noexcept { return maxParticles_ > 0; } + + // The pool capacity (0 in the stopped state). + [[nodiscard]] std::uint32_t maxParticles() const noexcept { + return maxParticles_; + } + + // The emitter registry capacity (0 in the stopped state). + [[nodiscard]] std::uint32_t maxEmitters() const noexcept { + return maxEmitters_; + } + + // The system's Prng seed (the create Options::seed; the replay + // identity's part, M1-DET-03 pattern). + [[nodiscard]] std::uint64_t seed() const noexcept { return rng_.seed(); } + + // The live particle count (O(1); 0 in the stopped state). + [[nodiscard]] std::uint32_t liveCount() const noexcept { + return liveCount_; + } + + // Total successful spawns since create (O(1); u64 counter). + [[nodiscard]] std::uint64_t spawnedTotal() const noexcept { + return spawnedTotal_; + } + + // Total dropped spawns (pool full) since create (O(1); u64 counter). + [[nodiscard]] std::uint64_t droppedTotal() const noexcept { + return droppedTotal_; + } + + // The profiler feed (DBG-008): the gauges + the counters (O(1)). + [[nodiscard]] ParticleStats stats() const noexcept { + return ParticleStats{liveCount_, maxParticles_, spawnedTotal_, + droppedTotal_}; + } + + // Registers an emitter (setup phase). Validates `def` in the + // header's documented order (first failure wins; a rejection leaves + // the registry unchanged + one rate-limited warn). Returns the + // dense id (1-based, registration order). Stopped -> + // InvalidArgument (no log); registry full -> BudgetExhausted + + // warn. O(1). + [[nodiscard]] laige::Result + addEmitter(const ParticleEmitterDef& def) noexcept; + + // The number of registered emitters (O(1)). + [[nodiscard]] std::uint32_t emitterCount() const noexcept { + return emitterCount_; + } + + // True iff `id` is a registered emitter (O(1)). + [[nodiscard]] bool hasEmitter(ParticleEmitterId id) const noexcept { + return maxParticles_ > 0 && id >= 1 && id <= emitterCount_; + } + + // The registered emitter's definition (O(1)); nullptr when the id + // is not registered (or the system is stopped). + [[nodiscard]] const ParticleEmitterDef* emitterAt( + ParticleEmitterId id) const noexcept { + return hasEmitter(id) ? &emitters_[id - 1] : nullptr; + } + + // Spawns `count` particles from emitter `id` NOW (sim phase — + // the game's spawn policy; the continuous rate is the automatic + // half, the burst is the explicit one). Stopped or unknown id -> + // InvalidArgument (no log); count 0 is a no-op success. + // O(count): one pool check + (on success) 4 Prng draws per particle + // — see the header's determinism section. + [[nodiscard]] laige::Status burst(ParticleEmitterId id, + std::uint32_t count) noexcept; + + // Advances the simulation by ONE tick (the header's per-tick + // contract: advance -> emit -> overflow report). The game's tick + // driver calls this exactly once per completed tick (ARCH-002). + // O(live + sum of rates); no allocation; no logging on the happy + // path. No-op in the stopped state. + void update() noexcept; + + // The render read path (M2-PART-02): the live particles as a + // non-owning span (PERF-005) — the compact live prefix + // [0, liveCount) of the pool (spawn order with swap removal). + // Read it after the sim phase (the M2-GL-02 cull/batch stage). + // O(1) (the span); never writes. + [[nodiscard]] std::span liveParticles() const noexcept { + return std::span(pool_.get(), liveCount_); + } + + // The particle's CURRENT fade alpha (0..255) — the header's + // color-fade section (exact u32 integer arithmetic). Precondition: + // `p` is live (age < life — no underflow). O(1). + [[nodiscard]] static std::uint8_t fadeAlpha(const Particle& p) noexcept { + const std::uint32_t remaining = p.life - p.age; + if (p.fadeTicks == 0 || remaining >= p.fadeTicks) { + return p.tint[3]; + } + return static_cast( + (static_cast(p.tint[3]) * remaining) / p.fadeTicks); + } + + // The system's Prng stream state (the replay identity's part — + // M1-DET-03 pattern; the state after N successful spawns is 4N + // draws from the seed, the header's determinism section). O(1). + [[nodiscard]] std::uint64_t prngSeed() const noexcept { return rng_.seed(); } + [[nodiscard]] std::uint64_t prngStatePart1() const noexcept { + return rng_.statePart1(); + } + [[nodiscard]] std::uint64_t prngStatePart2() const noexcept { + return rng_.statePart2(); + } + + private: + // create-only (the setup path: the two pre-allocations). + explicit ParticleSystem(Options options) noexcept + : pool_(new Particle[options.maxParticles]()), + emitters_(new ParticleEmitterDef[options.maxEmitters + 1]()), + rng_(options.seed), + maxParticles_(options.maxParticles), + maxEmitters_(options.maxEmitters) {} + + // Emits `count` particles from the emitter at `index` (0-based, + // emitters_ storage; called from burst/update in registration + // order). + void emitFrom(std::uint32_t index, std::uint32_t count) noexcept { + const ParticleEmitterDef& def = emitters_[index]; + for (std::uint32_t i = 0; i < count; ++i) { + spawnOne(def); + } + } + + // One spawn attempt (the header's pool-budget + determinism + // sections): a full pool drops (no draws); otherwise the fixed 4- + // draw contract (vx, vy, life, size) fills the next live slot. + void spawnOne(const ParticleEmitterDef& def) noexcept { + if (liveCount_ >= maxParticles_) { + // The budget: the pool is full — drop. The check precedes the + // Prng draws, so a drop never perturbs the stream. + ++droppedTotal_; + ++droppedSinceUpdate_; + return; + } + Particle p; + p.pos = def.origin; + p.depth = def.depth; + p.vel = Vec2{sampleScalar(def.velMin.x, def.velMax.x), + sampleScalar(def.velMin.y, def.velMax.y)}; + p.age = 0; + p.life = rng_.next_range(def.lifeMin, def.lifeMax + 1); + p.size = sampleScalar(def.sizeMin, def.sizeMax); + for (int c = 0; c < 4; ++c) { + p.tint[c] = def.tint[c]; + } + p.fadeTicks = def.fadeTicks; + pool_[liveCount_++] = p; + ++spawnedTotal_; + } + + // One uniform scalar in [lo, hi]: one 24-bit Prng tap resolved to + // [0, 1) (u / 2^24 — the tap resolution, the header's + // determinism section) and one SimMath lerp (the ADR 0002 rounding + // contract). lo == hi returns exactly lo (no stream perturbation + // beyond the documented draw). + Scalar sampleScalar(Scalar lo, Scalar hi) noexcept { + const std::uint32_t u = rng_.next_range(0, kParticleSampleDenominator); + const Scalar t = + M::div(static_cast(u), static_cast(kParticleSampleDenominator)); + return M::lerp(lo, hi, t); + } + + // The per-tick overflow report: at most ONE warn per tick window + // (the header's pool-budget section; the LOG-004 logger window is a + // second layer). + void maybeWarnOverflow() noexcept { + if (droppedSinceUpdate_ == 0 || overflowWarnedSinceUpdate_) { + return; + } + LAIGE_LOG_WARN("particles", "pool_overflow", + "Particle pool full; spawned particles dropped this tick", + laige::log::field("dropped", droppedSinceUpdate_), + laige::log::field("live", liveCount_), + laige::log::field("capacity", maxParticles_)); + overflowWarnedSinceUpdate_ = true; + } + + std::unique_ptr pool_; // maxParticles_ slots (empty <=> stopped) + // maxEmitters_ + 1 slots; index 0 unused (the id-0 sentinel). + std::unique_ptr[]> emitters_; + Prng rng_; + std::uint32_t maxParticles_{0}; + std::uint32_t maxEmitters_{0}; + std::uint32_t emitterCount_{0}; + std::uint32_t liveCount_{0}; + std::uint64_t droppedTotal_{0}; + std::uint64_t spawnedTotal_{0}; + std::uint32_t droppedSinceUpdate_{0}; + bool overflowWarnedSinceUpdate_{false}; +}; + +template +laige::Result, laige::ErrorCode> +ParticleSystem::create(Options options) noexcept { + // First failure wins (the M2-SPRITE-01 create precedent; no log). + if (options.maxParticles < 1 || + options.maxParticles > kParticlePoolMaxParticles) { + return laige::Result, laige::ErrorCode>::failure( + laige::ErrorCode::InvalidArgument); + } + if (options.maxEmitters < 1 || + options.maxEmitters > kParticleEmitterMaxEmitters) { + return laige::Result, laige::ErrorCode>::failure( + laige::ErrorCode::InvalidArgument); + } + return laige::Result, laige::ErrorCode>::success( + ParticleSystem(std::move(options))); +} + +template +ParticleSystem::ParticleSystem(ParticleSystem&& other) noexcept + : pool_(std::move(other.pool_)), + emitters_(std::move(other.emitters_)), + rng_(other.rng_), + maxParticles_(other.maxParticles_), + maxEmitters_(other.maxEmitters_), + emitterCount_(other.emitterCount_), + liveCount_(other.liveCount_), + droppedTotal_(other.droppedTotal_), + spawnedTotal_(other.spawnedTotal_), + droppedSinceUpdate_(other.droppedSinceUpdate_), + overflowWarnedSinceUpdate_(other.overflowWarnedSinceUpdate_) { + // Stop the source (the stopped-state precedent). + other.maxParticles_ = 0; + other.maxEmitters_ = 0; + other.emitterCount_ = 0; + other.liveCount_ = 0; + other.droppedTotal_ = 0; + other.spawnedTotal_ = 0; + other.droppedSinceUpdate_ = 0; + other.overflowWarnedSinceUpdate_ = false; +} + +template +ParticleSystem& +ParticleSystem::operator=(ParticleSystem&& other) noexcept { + if (this != &other) { + // Route through the move constructor (RAII cleanup of this + // system's old storage happens on the member moves). + ParticleSystem tmp(std::move(other)); + pool_ = std::move(tmp.pool_); + emitters_ = std::move(tmp.emitters_); + rng_ = tmp.rng_; + maxParticles_ = tmp.maxParticles_; + maxEmitters_ = tmp.maxEmitters_; + emitterCount_ = tmp.emitterCount_; + liveCount_ = tmp.liveCount_; + droppedTotal_ = tmp.droppedTotal_; + spawnedTotal_ = tmp.spawnedTotal_; + droppedSinceUpdate_ = tmp.droppedSinceUpdate_; + overflowWarnedSinceUpdate_ = tmp.overflowWarnedSinceUpdate_; + } + return *this; +} + +template +laige::Result +ParticleSystem::addEmitter( + const ParticleEmitterDef& def) noexcept { + if (maxParticles_ == 0) { + // The stopped state — no log (the stopped-state precedent). + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + if (emitterCount_ >= maxEmitters_) { + LAIGE_LOG_WARN("particles", "emitters_exhausted", + "Particle emitter registry full", + laige::log::field("capacity", maxEmitters_)); + return laige::Result::failure( + laige::ErrorCode::BudgetExhausted); + } + // The documented validation order (first failure wins — the + // M2-PAR-01 setLayer precedent). The def is caller-owned scene + // metadata (untrusted input, SCALE-004): every field is checked. + const Scalar kZero = Scalar{}; + const char* field = nullptr; + if (!M::isFinite(def.origin.x) || !M::isFinite(def.origin.y)) { + field = "origin"; + } else if (!M::isFinite(def.depth)) { + field = "depth"; + } else if (!M::isFinite(def.velMin.x) || !M::isFinite(def.velMin.y)) { + field = "vel_min"; + } else if (!M::isFinite(def.velMax.x) || !M::isFinite(def.velMax.y)) { + field = "vel_max"; + } else if (M::greater(def.velMin.x, def.velMax.x) || + M::greater(def.velMin.y, def.velMax.y)) { + field = "vel_box"; + } else if (def.lifeMin < 1) { + field = "life_min"; + } else if (def.lifeMin > def.lifeMax) { + field = "life_box"; + } else if (def.lifeMax > kParticleMaxLifeTicks) { + field = "life_max"; + } else if (!M::isFinite(def.sizeMin) || + !M::greaterEqual(def.sizeMin, kZero)) { + field = "size_min"; + } else if (!M::isFinite(def.sizeMax)) { + field = "size_max"; + } else if (M::greater(def.sizeMin, def.sizeMax)) { + field = "size_box"; + } else if (def.fadeTicks > def.lifeMax) { + field = "fade_ticks"; + } + if (field != nullptr) { + LAIGE_LOG_WARN("particles", "emitter_invalid", + "Rejected particle emitter definition", + laige::log::field("emitter", emitterCount_ + 1), + laige::log::field("field", field)); + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + const ParticleEmitterId id = emitterCount_ + 1; + emitters_[emitterCount_] = def; + ++emitterCount_; + return laige::Result::success(id); +} + +template +laige::Status ParticleSystem::burst(ParticleEmitterId id, + std::uint32_t count) noexcept { + if (maxParticles_ == 0) { + // The stopped state — no log (the stopped-state precedent). + return laige::Status(laige::ErrorCode::InvalidArgument); + } + if (id < 1 || id > emitterCount_) { + // Precondition failure (unknown emitter) — no log (the M2-PAR-01 + // setUvOffset precedent). + return laige::Status(laige::ErrorCode::InvalidArgument); + } + emitFrom(id - 1, count); + maybeWarnOverflow(); + return laige::Status{}; +} + +template +void ParticleSystem::update() noexcept { + if (maxParticles_ == 0) { + return; // The stopped state — no-op (the stopped-state precedent). + } + // 1. Advance the currently live particles (the header's per-tick + // contract). The kill is a swap removal (the last live particle + // moves into the dead slot); the swapped-in particle — not yet + // advanced this tick — is re-examined at the same index. + std::uint32_t i = 0; + while (i < liveCount_) { + Particle& p = pool_[i]; + ++p.age; + p.pos = M::add(p.pos, p.vel); + if (p.age >= p.life) { + --liveCount_; + pool_[i] = pool_[liveCount_]; + } else { + ++i; + } + } + // 2. Emit from every emitter in registration order. + for (std::uint32_t e = 0; e < emitterCount_; ++e) { + const std::uint32_t rate = emitters_[e].continuousRate; + if (rate != 0) { + emitFrom(e, rate); + } + } + // 3. The overflow report (at most one warn per tick) + the window + // reset. + maybeWarnOverflow(); + droppedSinceUpdate_ = 0; + overflowWarnedSinceUpdate_ = false; +} + +} // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 4bd33dd..7acd20e 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -64,20 +64,21 @@ # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, # `system_timing`, `game_loop`, `presentation`, `engine`, # `game_config`, `determinism_mode`, `replay_record`, `replay_replay`, -# `replay_diff`, `zero_alloc`, `profiler`, and `budget_report` entries -# are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, -# M1-ECS-06, M1-ECS-07, M1-SYS-01, M1-SYS-02, M1-SYS-03, M1-LOOP-01, -# M1-LOOP-02, M1-HEAD-01, M1-CFG-01, M1-DET-01, M1-DET-02, M1-DET-03, -# M1-DET-05, M1-ALLOC-01, M1-PROF-01, and M1-PROF-02 Verify commands -# (`ctest -R entity`, `ctest -R component_registry`, `ctest -R -# archetype`, `ctest -R query`, `ctest -R iter_order`, -# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`, `ctest -R -# system_registry`, `ctest -R scheduler`, `ctest -R system_timing`, -# `ctest -R game_loop`, `ctest -R presentation`, `ctest -R engine`, -# `ctest -R game_config`, `ctest -R determinism_mode`, -# `ctest -R replay_record`, `ctest -R replay_replay`, `ctest -R -# replay_diff`, `ctest -R zero_alloc`, `ctest -R profiler`, and -# `ctest -R budget_report`), selecting exactly the +# `replay_diff`, `zero_alloc`, `profiler`, `budget_report`, and +# `particles` entries are the M1-ECS-01, M1-ECS-02, M1-ECS-03, +# M1-ECS-04, M1-ECS-05, M1-ECS-06, M1-ECS-07, M1-SYS-01, M1-SYS-02, +# M1-SYS-03, M1-LOOP-01, M1-LOOP-02, M1-HEAD-01, M1-CFG-01, M1-DET-01, +# M1-DET-02, M1-DET-03, M1-DET-05, M1-ALLOC-01, M1-PROF-01, M1-PROF-02, +# and M2-PART-01 Verify commands (`ctest -R entity`, `ctest -R +# component_registry`, `ctest -R archetype`, `ctest -R query`, +# `ctest -R iter_order`, `ctest -R ecs_guardrails`, `ctest -R +# ecs_stress`, `ctest -R system_registry`, `ctest -R scheduler`, +# `ctest -R system_timing`, `ctest -R game_loop`, `ctest -R +# presentation`, `ctest -R engine`, `ctest -R game_config`, `ctest -R +# determinism_mode`, `ctest -R replay_record`, `ctest -R +# replay_replay`, `ctest -R replay_diff`, `ctest -R zero_alloc`, +# `ctest -R profiler`, `ctest -R budget_report`, and `ctest -R +# particles`), selecting exactly the # suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp @@ -97,7 +98,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp replay_diff_tests.cpp zero_alloc_tests.cpp profiler_tests.cpp - budget_report_tests.cpp) + budget_report_tests.cpp + particles_tests.cpp) # M1-ALLOC-01: the test-only allocation counter no longer carries its # own global operator new/new[] overrides — they moved into # laige-core (src/laige-core/alloc_watch.cpp, the G-R1 counting @@ -333,6 +335,19 @@ add_test(NAME budget_report COMMAND laige-sim_tests --gtest_filter=BudgetReport*) +# M2-PART-01: CPU particle simulation (FR-2.7 — the budgeted, pooled, +# deterministic CPU-sim particle half: the create domain + stopped +# state, the addEmitter validation matrix, the burst/continuous +# emission, the per-tick advance + death, the pool exhaustion table, +# the exact color fade, the fixed-seed determinism + 4-draw Prng +# contract, the stats feed, and the zero-allocation update window). +# The step's Verify command is `ctest -R particles`; this entry +# selects exactly the Particles* suites from the shared +# laige-sim_tests executable. No GL environment needed. +add_test(NAME particles + COMMAND laige-sim_tests + --gtest_filter=Particles*) + # M1-DET-01: the G-R8 trait compile-checks (the compile-time half of # the determinism guarantee). Each fixture is compiled (not linked, # not run) with the engine policy flags; the positive fixture must @@ -398,7 +413,7 @@ if(LAIGE_TSAN) system_timing game_loop presentation engine game_config determinism_mode replay_record replay_replay replay_diff zero_alloc profiler - budget_report + budget_report particles PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/particles_tests.cpp b/tests/laige-sim/particles_tests.cpp new file mode 100644 index 0000000..97b700f --- /dev/null +++ b/tests/laige-sim/particles_tests.cpp @@ -0,0 +1,1116 @@ +// M2-PART-01: CPU particle simulation tests (the step's Verify +// command: `ctest -R particles`). +// +// The ParticleSystem contract pinned against: the create +// domain + stopped state + move semantics, the addEmitter +// validation matrix (the pinned warn fields, first failure wins, +// state-unchanged-on-rejection, the registry budget), the burst +// spawn (exact particle state on a degenerate def), the per-tick +// advance + death (hand-computed Q16.16/float goldens, the swap +// removal's live order), the continuous rate, the pool exhaustion +// behavior (the exact per-tick live/dropped/warn table), the exact +// color fade (integer arithmetic), the determinism contract (fixed +// seed -> identical trajectories, the 4-draw-per-spawn Prng +// contract, the machine-greppable state hash), the stats feed, and +// the zero-allocation update window (PERF-003 — FR-2.7 "pooled"). +// +// No GL needed — runs in every tree. + +#include +#include +#include +#include +#include +#include + +#include + +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/result.h" +#include "laige/sim/particles.h" +#include "laige/sim_math.h" + +namespace { + +using laige::ParticleSystem; +using laige::ParticleEmitterDef; + +// --------------------------------------------------------------------------- +// Backend-agnostic scalar construction (dyadic test values only) +// --------------------------------------------------------------------------- + +template +using M_ = laige::sim::SimMath; + +template +typename M_::Scalar sc(std::int64_t num, std::int64_t den = 1) { + using Scalar = typename M_::Scalar; + if constexpr (std::is_same_v) { + // Exact for the dyadic test values used here. + return laige::fpx16_16{ + static_cast(num * (std::int64_t(1) << 16) / den)}; + } else { + return static_cast(static_cast(num) / + static_cast(den)); + } +} + +// A valid, non-degenerate base def (the validation matrix mutates one +// field at a time from this). +template +ParticleEmitterDef baseDef() { + using Vec2 = typename M_::Vec2; + ParticleEmitterDef d; + d.origin = Vec2{sc(1), sc(0)}; + d.depth = sc(0); + d.velMin = Vec2{sc(0), sc(0)}; + d.velMax = Vec2{sc(1), sc(1)}; + d.lifeMin = 5; + d.lifeMax = 10; + d.sizeMin = sc(1, 2); + d.sizeMax = sc(3, 2); + d.tint[0] = 255; + d.tint[1] = 255; + d.tint[2] = 255; + d.tint[3] = 255; + d.fadeTicks = 0; + d.continuousRate = 0; + return d; +} + +// A fully degenerate def (min == max everywhere): a spawn from it is +// deterministic without any Prng value (the lerp endpoints coincide). +template +ParticleEmitterDef degDef(std::int64_t ox, std::int64_t oy, + std::int64_t depth, std::int64_t vx, + std::int64_t vy, std::uint32_t life, + std::int64_t size, std::uint8_t a, + std::uint32_t fade) { + using Vec2 = typename M_::Vec2; + ParticleEmitterDef d = baseDef(); + d.origin = Vec2{sc(ox), sc(oy)}; + d.depth = sc(depth); + d.velMin = Vec2{sc(vx), sc(vy)}; + d.velMax = Vec2{sc(vx), sc(vy)}; + d.lifeMin = life; + d.lifeMax = life; + d.sizeMin = sc(size); + d.sizeMax = sc(size); + d.tint[0] = 10; + d.tint[1] = 20; + d.tint[2] = 30; + d.tint[3] = a; + d.fadeTicks = fade; + d.continuousRate = 0; + return d; +} + +// --------------------------------------------------------------------------- +// Log capture (the archetype_tests MemorySink pattern: rate limiting +// OFF — the per-tick warn budget is the system's own flag) +// --------------------------------------------------------------------------- + +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + abort(); + } + return ptr; +} + +void restoreLogger() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger::init (restore default sink) failed"; + } +} + +std::size_t countEvents(const MemorySink& sink, std::string_view subsystem, + std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.subsystem == subsystem && e.event == event) ++n; + } + return n; +} + +std::string fieldOf(const MemorySink::Entry& e, std::string_view key) { + for (const auto& [k, v] : e.fields) { + if (k == key) return v; + } + return std::string(); +} + +// --------------------------------------------------------------------------- +// The machine-greppable state hash (the docs/testing.md §4 KAT +// convention: FNV-1a 64, big-endian byte order per u64) +// --------------------------------------------------------------------------- + +std::uint64_t particlesFnv1a64(const std::uint64_t* values, std::size_t n) { + std::uint64_t h = 0xcbf29ce484222325ull; // FNV offset basis (FNV-1a spec) + for (std::size_t i = 0; i < n; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (values[i] >> shift) & 0xFFull; + h *= 0x100000001b3ull; // FNV prime (FNV-1a spec) + } + } + return h; +} + +template +std::uint64_t particlesStateHash(const ParticleSystem& sys) { + using Scalar = typename M_::Scalar; + std::vector w; + auto addScalar = [&](Scalar s) { + if constexpr (std::is_same_v) { + w.push_back(static_cast( + static_cast(s.raw))); + } else { + std::uint32_t bits; + std::memcpy(&bits, &s, sizeof(bits)); + w.push_back(bits); + } + }; + w.push_back(sys.prngSeed()); + w.push_back(sys.maxParticles()); + w.push_back(sys.maxEmitters()); + w.push_back(sys.emitterCount()); + w.push_back(sys.liveCount()); + w.push_back(sys.spawnedTotal()); + w.push_back(sys.droppedTotal()); + w.push_back(sys.prngStatePart1()); + w.push_back(sys.prngStatePart2()); + for (const auto& p : sys.liveParticles()) { + addScalar(p.pos.x); + addScalar(p.pos.y); + addScalar(p.depth); + addScalar(p.vel.x); + addScalar(p.vel.y); + addScalar(p.size); + w.push_back(p.age); + w.push_back(p.life); + w.push_back(p.fadeTicks); + w.push_back((std::uint64_t(p.tint[0]) << 24) | + (std::uint64_t(p.tint[1]) << 16) | + (std::uint64_t(p.tint[2]) << 8) | std::uint64_t(p.tint[3])); + } + return particlesFnv1a64(w.data(), w.size()); +} + +// One uniform scalar exactly as the engine samples it (the test-side +// oracle for the 4-draw contract). +template +typename M_::Scalar oracleSample(laige::Prng& oracle, + typename M_::Scalar lo, + typename M_::Scalar hi) { + using M = M_; + const std::uint32_t u = + oracle.next_range(0, laige::kParticleSampleDenominator); + const auto t = M::div(static_cast(u), + static_cast(laige::kParticleSampleDenominator)); + return M::lerp(lo, hi, t); +} + +// --------------------------------------------------------------------------- +// ParticlesCreate +// --------------------------------------------------------------------------- + +template +void createDomain() { + using Sys = ParticleSystem; + // Defaults. + auto r = Sys::create({}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + EXPECT_TRUE(sys.valid()); + EXPECT_EQ(sys.maxParticles(), laige::kParticlePoolDefaultMaxParticles); + EXPECT_EQ(sys.maxEmitters(), laige::kParticleEmitterDefaultMaxEmitters); + EXPECT_EQ(sys.seed(), 0u); + EXPECT_EQ(sys.liveCount(), 0u); + EXPECT_EQ(sys.emitterCount(), 0u); + EXPECT_EQ(sys.spawnedTotal(), 0u); + EXPECT_EQ(sys.droppedTotal(), 0u); + const auto stats = sys.stats(); + EXPECT_EQ(stats.live, 0u); + EXPECT_EQ(stats.capacity, laige::kParticlePoolDefaultMaxParticles); + EXPECT_EQ(stats.spawnedTotal, 0u); + EXPECT_EQ(stats.droppedTotal, 0u); + + // Explicit options echo. + auto r2 = Sys::create({1, 1, 0x1234}); + ASSERT_TRUE(r2.ok()); + auto sys2 = std::move(r2).takeValue(); + EXPECT_EQ(sys2.maxParticles(), 1u); + EXPECT_EQ(sys2.maxEmitters(), 1u); + EXPECT_EQ(sys2.seed(), 0x1234u); + + // The domain edges (first failure wins; no log on create rejection). + MemorySink* sink = installSink(); + EXPECT_FALSE(Sys::create({0, 1, 0}).ok()); + EXPECT_FALSE(Sys::create({laige::kParticlePoolMaxParticles + 1, 1, 0}).ok()); + EXPECT_TRUE(Sys::create({laige::kParticlePoolMaxParticles, 1, 0}).ok()); + EXPECT_FALSE(Sys::create({1, 0, 0}).ok()); + EXPECT_FALSE(Sys::create({1, laige::kParticleEmitterMaxEmitters + 1, 0}) + .ok()); + EXPECT_TRUE(Sys::create({1, laige::kParticleEmitterMaxEmitters, 0}).ok()); + EXPECT_TRUE(Sys::create({0, 0, 0}).error() == laige::ErrorCode::InvalidArgument); + EXPECT_EQ(sink->entries.size(), 0u) << "create logs nothing (rejection or success)"; + restoreLogger(); +} + +TEST(ParticlesCreate, CreateDomain) { + createDomain(); + createDomain(); +} + +template +void stoppedState() { + using Sys = ParticleSystem; + Sys stopped; + EXPECT_FALSE(stopped.valid()); + EXPECT_EQ(stopped.maxParticles(), 0u); + EXPECT_EQ(stopped.maxEmitters(), 0u); + EXPECT_EQ(stopped.liveCount(), 0u); + EXPECT_EQ(stopped.emitterCount(), 0u); + EXPECT_TRUE(stopped.liveParticles().empty()); + EXPECT_FALSE(stopped.hasEmitter(1)); + EXPECT_EQ(stopped.emitterAt(1), nullptr); + const auto stats = stopped.stats(); + EXPECT_EQ(stats.live, 0u); + EXPECT_EQ(stats.capacity, 0u); + EXPECT_EQ(stats.spawnedTotal, 0u); + EXPECT_EQ(stats.droppedTotal, 0u); + + MemorySink* sink = installSink(); + auto r = stopped.addEmitter(baseDef()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + auto st = stopped.burst(1, 1); + EXPECT_EQ(st.error(), laige::ErrorCode::InvalidArgument); + stopped.update(); // no-op (no crash) + EXPECT_EQ(sink->entries.size(), 0u) << "the stopped state logs nothing"; + restoreLogger(); +} + +TEST(ParticlesCreate, StoppedState) { + stoppedState(); + stoppedState(); +} + +template +void moveStopsSource() { + using Sys = ParticleSystem; + auto r = Sys::create({4, 2, 7}); + ASSERT_TRUE(r.ok()); + auto src = std::move(r).takeValue(); + auto d = degDef(3, 4, 5, 1, -1, 7, 2, 40, 2); + ASSERT_TRUE(src.addEmitter(d).ok()); + ASSERT_TRUE(src.burst(1, 2).ok()); + EXPECT_EQ(src.liveCount(), 2u); + + MemorySink* sink = installSink(); + Sys dst(std::move(src)); + EXPECT_TRUE(dst.valid()); + EXPECT_EQ(dst.liveCount(), 2u); + EXPECT_EQ(dst.emitterCount(), 1u); + EXPECT_EQ(dst.seed(), 7u); + EXPECT_EQ(dst.maxParticles(), 4u); + EXPECT_EQ(dst.maxEmitters(), 2u); + EXPECT_EQ(dst.stats().spawnedTotal, 2u); + + // The moved-from system is stopped (and logs nothing). + EXPECT_FALSE(src.valid()); + EXPECT_EQ(src.liveCount(), 0u); + EXPECT_EQ(src.burst(1, 1).error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(src.addEmitter(d).error(), laige::ErrorCode::InvalidArgument); + src.update(); // no-op + EXPECT_EQ(sink->entries.size(), 0u); + restoreLogger(); +} + +TEST(ParticlesCreate, MoveStopsSource) { + moveStopsSource(); + moveStopsSource(); +} + +// --------------------------------------------------------------------------- +// ParticlesEmitter +// --------------------------------------------------------------------------- + +template +void denseIds() { + using Sys = ParticleSystem; + auto r = Sys::create({8, 4, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + auto d1 = baseDef(); + auto d2 = baseDef(); + d2.continuousRate = 2; + auto id1 = sys.addEmitter(d1); + auto id2 = sys.addEmitter(d2); + ASSERT_TRUE(id1.ok()); + ASSERT_TRUE(id2.ok()); + EXPECT_EQ(*id1.valueIfOk(), 1u); + EXPECT_EQ(*id2.valueIfOk(), 2u); + EXPECT_EQ(sys.emitterCount(), 2u); + EXPECT_TRUE(sys.hasEmitter(1)); + EXPECT_TRUE(sys.hasEmitter(2)); + EXPECT_FALSE(sys.hasEmitter(0)); + EXPECT_FALSE(sys.hasEmitter(3)); + const auto* e1 = sys.emitterAt(1); + ASSERT_NE(e1, nullptr); + EXPECT_EQ(e1->origin.x, d1.origin.x); + EXPECT_EQ(e1->origin.y, d1.origin.y); + EXPECT_EQ(e1->continuousRate, 0u); + const auto* e2 = sys.emitterAt(2); + ASSERT_NE(e2, nullptr); + EXPECT_EQ(e2->continuousRate, 2u); +} + +TEST(ParticlesEmitter, DenseIds) { + denseIds(); + denseIds(); +} + +template +void validationMatrix() { + using Sys = ParticleSystem; + using Scalar = typename M_::Scalar; + constexpr bool kIsFp32 = + std::is_same_v; + + // (mutator, expected field name, applies-to-this-backend) + struct Case { + void (*mutate)(ParticleEmitterDef&); + const char* field; + bool apply; + }; + auto expectReject = [&](ParticleEmitterDef d, const char* field, + std::uint32_t emitterId) { + auto r = Sys::create({8, 4, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + MemorySink* sink = installSink(); + auto res = sys.addEmitter(d); + EXPECT_EQ(res.error(), laige::ErrorCode::InvalidArgument) + << "field " << field; + // First failure wins: exactly one warn, with the pinned fields. + EXPECT_EQ(countEvents(*sink, "particles", "emitter_invalid"), 1u) + << "field " << field; + if (!sink->entries.empty()) { + const auto& e = sink->entries.front(); + EXPECT_EQ(fieldOf(e, "field"), field); + EXPECT_EQ(fieldOf(e, "emitter"), std::to_string(emitterId)); + } + // State unchanged on rejection. + EXPECT_EQ(sys.emitterCount(), 0u); + EXPECT_FALSE(sys.hasEmitter(emitterId)); + restoreLogger(); + }; + + auto mutate = [&](void (*fn)(ParticleEmitterDef&), + const char* field, bool apply) { + if (!apply) return; + ParticleEmitterDef d = baseDef(); + fn(d); + expectReject(d, field, 1); + }; + + if constexpr (kIsFp32) { + const Scalar nan = std::numeric_limits::quiet_NaN(); + { + ParticleEmitterDef d = baseDef(); + d.origin.x = nan; + expectReject(d, "origin", 1); + } + { + ParticleEmitterDef d = baseDef(); + d.depth = nan; + expectReject(d, "depth", 1); + } + { + ParticleEmitterDef d = baseDef(); + d.velMin.x = nan; + expectReject(d, "vel_min", 1); + } + { + ParticleEmitterDef d = baseDef(); + d.velMax.y = nan; + expectReject(d, "vel_max", 1); + } + // sizeMax non-finite (sizeMin is fine — the order reaches it). + { + ParticleEmitterDef d = baseDef(); + d.sizeMax = nan; + expectReject(d, "size_max", 1); + } + // First failure wins: origin (earlier) before life_min. + { + ParticleEmitterDef d = baseDef(); + d.origin.x = nan; + d.lifeMin = 0; + expectReject(d, "origin", 1); + } + } else { + // fpx16_16 has no NaN — the box/domain cases still apply, and the + // first-failure-wins case uses vel_box (earlier than life_min). + ParticleEmitterDef d = baseDef(); + d.velMin.x = sc(2); + d.velMax.x = sc(1); + d.lifeMin = 0; + expectReject(d, "vel_box", 1); + } + + mutate([](ParticleEmitterDef& d) { + d.velMin.x = sc(1); + d.velMax.x = sc(1, 2); + }, + "vel_box", true); + mutate([](ParticleEmitterDef& d) { d.lifeMin = 0; }, "life_min", + true); + mutate([](ParticleEmitterDef& d) { + d.lifeMin = 10; + d.lifeMax = 5; + }, + "life_box", true); + mutate([](ParticleEmitterDef& d) { + d.lifeMin = 1; + d.lifeMax = laige::kParticleMaxLifeTicks + 1; + }, + "life_max", true); + mutate([](ParticleEmitterDef& d) { d.sizeMin = sc(-1); }, + "size_min", true); + mutate([](ParticleEmitterDef& d) { + d.sizeMin = sc(2); + d.sizeMax = sc(1); + }, + "size_box", true); + mutate([](ParticleEmitterDef& d) { + d.lifeMax = 5; + d.fadeTicks = 6; + }, + "fade_ticks", true); +} + +TEST(ParticlesEmitter, ValidationMatrix) { + validationMatrix(); + validationMatrix(); +} + +template +void registryExhaustion() { + using Sys = ParticleSystem; + auto r = Sys::create({8, 2, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + ASSERT_TRUE(sys.addEmitter(baseDef()).ok()); + ASSERT_TRUE(sys.addEmitter(baseDef()).ok()); + MemorySink* sink = installSink(); + auto res = sys.addEmitter(baseDef()); + EXPECT_EQ(res.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(countEvents(*sink, "particles", "emitters_exhausted"), 1u); + EXPECT_EQ(fieldOf(sink->entries.front(), "capacity"), "2"); + EXPECT_EQ(sys.emitterCount(), 2u); + restoreLogger(); +} + +TEST(ParticlesEmitter, RegistryExhaustion) { + registryExhaustion(); + registryExhaustion(); +} + +// --------------------------------------------------------------------------- +// ParticlesBurst +// --------------------------------------------------------------------------- + +template +void burstExact() { + using Sys = ParticleSystem; + auto r = Sys::create({8, 2, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + // Degenerate def: every spawn is bit-predictable. + auto d = degDef(3, 4, 5, 1, -1, 7, 2, 40, 2); + auto id = sys.addEmitter(d); + ASSERT_TRUE(id.ok()); + ASSERT_TRUE(sys.burst(*id.valueIfOk(), 3).ok()); + EXPECT_EQ(sys.liveCount(), 3u); + EXPECT_EQ(sys.spawnedTotal(), 3u); + EXPECT_EQ(sys.droppedTotal(), 0u); + + MemorySink* sink = installSink(); + auto sv = sys.burst(*id.valueIfOk(), 0); // no-op success + EXPECT_TRUE(sv.ok()); + EXPECT_EQ(sys.liveCount(), 3u); + EXPECT_EQ(sys.burst(0, 1).error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(sys.burst(2, 1).error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(sink->entries.size(), 0u) << "burst logs nothing on the happy path / unknown-id rejection"; + restoreLogger(); + + // Pin every particle field (spawn order). + const auto live = sys.liveParticles(); + ASSERT_EQ(live.size(), 3u); + for (std::size_t i = 0; i < live.size(); ++i) { + const auto& p = live[i]; + if constexpr (std::is_same_v) { + EXPECT_EQ(p.pos.x.raw, 3 * 65536) << "particle " << i; + EXPECT_EQ(p.pos.y.raw, 4 * 65536) << "particle " << i; + EXPECT_EQ(p.depth.raw, 5 * 65536) << "particle " << i; + EXPECT_EQ(p.vel.x.raw, 65536) << "particle " << i; + EXPECT_EQ(p.vel.y.raw, -65536) << "particle " << i; + EXPECT_EQ(p.size.raw, 2 * 65536) << "particle " << i; + } else { + EXPECT_EQ(p.pos.x, 3.0f) << "particle " << i; + EXPECT_EQ(p.pos.y, 4.0f) << "particle " << i; + EXPECT_EQ(p.depth, 5.0f) << "particle " << i; + EXPECT_EQ(p.vel.x, 1.0f) << "particle " << i; + EXPECT_EQ(p.vel.y, -1.0f) << "particle " << i; + EXPECT_EQ(p.size, 2.0f) << "particle " << i; + } + EXPECT_EQ(p.age, 0u) << "particle " << i; + EXPECT_EQ(p.life, 7u) << "particle " << i; + EXPECT_EQ(p.fadeTicks, 2u) << "particle " << i; + EXPECT_EQ(p.tint[0], 10u) << "particle " << i; + EXPECT_EQ(p.tint[1], 20u) << "particle " << i; + EXPECT_EQ(p.tint[2], 30u) << "particle " << i; + EXPECT_EQ(p.tint[3], 40u) << "particle " << i; + } +} + +TEST(ParticlesBurst, BurstExact) { + burstExact(); + burstExact(); +} + +template +void burstPoolDrop() { + using Sys = ParticleSystem; + auto r = Sys::create({2, 1, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + auto d = degDef(0, 0, 0, 1, 0, 10, 1, 255, 0); + auto id = sys.addEmitter(d); + ASSERT_TRUE(id.ok()); + MemorySink* sink = installSink(); + ASSERT_TRUE(sys.burst(*id.valueIfOk(), 5).ok()); + EXPECT_EQ(sys.liveCount(), 2u); + EXPECT_EQ(sys.spawnedTotal(), 2u); + EXPECT_EQ(sys.droppedTotal(), 3u); + // The burst reports its own tick's overflow: exactly one warn. + EXPECT_EQ(countEvents(*sink, "particles", "pool_overflow"), 1u); + EXPECT_EQ(fieldOf(sink->entries.front(), "dropped"), "3"); + EXPECT_EQ(fieldOf(sink->entries.front(), "live"), "2"); + EXPECT_EQ(fieldOf(sink->entries.front(), "capacity"), "2"); + restoreLogger(); +} + +TEST(ParticlesBurst, BurstPoolDrop) { + burstPoolDrop(); + burstPoolDrop(); +} + +// --------------------------------------------------------------------------- +// ParticlesUpdate +// --------------------------------------------------------------------------- + +template +void advanceAndDeath() { + using Sys = ParticleSystem; + auto r = Sys::create({8, 1, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + // vel (0.5, -0.25) per tick, life 5: the hand-computed goldens. + using Vec2 = typename M_::Vec2; + ParticleEmitterDef def = baseDef(); + def.origin = Vec2{sc(1), sc(2)}; + def.depth = sc(0); + def.velMin = Vec2{sc(1, 2), sc(-1, 4)}; + def.velMax = def.velMin; + def.lifeMin = 5; + def.lifeMax = 5; + def.sizeMin = sc(1); + def.sizeMax = sc(1); + def.fadeTicks = 0; + def.continuousRate = 0; + auto id = sys.addEmitter(def); + ASSERT_TRUE(id.ok()); + ASSERT_TRUE(sys.burst(*id.valueIfOk(), 1).ok()); + + MemorySink* sink = installSink(); + // After update k (k = 1..4): age k, pos (1 + 0.5k, 2 - 0.25k). + for (int k = 1; k <= 4; ++k) { + sys.update(); + const auto live = sys.liveParticles(); + ASSERT_EQ(live.size(), 1u) << "update " << k; + EXPECT_EQ(live[0].age, std::uint32_t(k)) << "update " << k; + if constexpr (std::is_same_v) { + // Q16.16 raw: x = 65536 + 32768k; y = 131072 - 16384k. + EXPECT_EQ(live[0].pos.x.raw, 65536 + 32768 * k) << "update " << k; + EXPECT_EQ(live[0].pos.y.raw, 131072 - 16384 * k) << "update " << k; + } else { + EXPECT_EQ(live[0].pos.x, static_cast(1) + 0.5f * k) + << "update " << k; + EXPECT_EQ(live[0].pos.y, 2.0f - 0.25f * k) << "update " << k; + } + } + // The 5th update: age 5 >= life 5 -> dead (visible for exactly 5 + // renders: age 0..4). + sys.update(); + EXPECT_EQ(sys.liveCount(), 0u); + EXPECT_EQ(sink->entries.size(), 0u) << "the happy advance path logs nothing"; + restoreLogger(); +} + +TEST(ParticlesUpdate, AdvanceAndDeath) { + advanceAndDeath(); + advanceAndDeath(); +} + +template +void swapRemoveOrder() { + using Sys = ParticleSystem; + auto r = Sys::create({8, 2, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + // Two emitters: A (life 2, origin (0,0)) and B (life 10, origin + // (9,9)). Spawn A then B -> live [A, B]. + auto a = sys.addEmitter(degDef(0, 0, 0, 1, 0, 2, 1, 255, 0)); + auto b = sys.addEmitter(degDef(9, 9, 0, 1, 0, 10, 1, 255, 0)); + ASSERT_TRUE(a.ok()); + ASSERT_TRUE(b.ok()); + ASSERT_TRUE(sys.burst(1, 1).ok()); + ASSERT_TRUE(sys.burst(2, 1).ok()); + ASSERT_EQ(sys.liveCount(), 2u); + sys.update(); // A age 1, B age 1 + ASSERT_EQ(sys.liveCount(), 2u); + sys.update(); // A age 2 -> dies (swap-removed); B age 2 + ASSERT_EQ(sys.liveCount(), 1u); + const auto live = sys.liveParticles(); + ASSERT_EQ(live.size(), 1u); + // The survivor is B, now at index 0 (the swap removed A's slot). + if constexpr (std::is_same_v) { + EXPECT_EQ(live[0].pos.x.raw, 9 * 65536 + 2 * 65536); + EXPECT_EQ(live[0].pos.y.raw, 9 * 65536); + } else { + EXPECT_EQ(live[0].pos.x, 11.0f); + EXPECT_EQ(live[0].pos.y, 9.0f); + } + EXPECT_EQ(live[0].life, 10u); +} + +TEST(ParticlesUpdate, SwapRemoveOrder) { + swapRemoveOrder(); + swapRemoveOrder(); +} + +// --------------------------------------------------------------------------- +// ParticlesContinuous +// --------------------------------------------------------------------------- + +template +void continuousRateAndOracle() { + using Sys = ParticleSystem; + using Vec2 = typename M_::Vec2; + constexpr std::uint64_t kSeed = 0x0101010101010101ull; + auto r = Sys::create({10, 1, kSeed}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + ParticleEmitterDef def = baseDef(); + def.origin = Vec2{sc(0), sc(0)}; + def.depth = sc(0); + def.velMin = Vec2{sc(0), sc(-1)}; + def.velMax = Vec2{sc(1), sc(0)}; + // life 20 (fixed): no deaths in this short scenario — the pool + // fills and overflows cleanly. + def.lifeMin = 20; + def.lifeMax = 20; + def.sizeMin = sc(1, 2); + def.sizeMax = sc(3, 2); + def.tint[0] = 1; + def.tint[1] = 2; + def.tint[2] = 3; + def.tint[3] = 255; + def.fadeTicks = 0; + def.continuousRate = 2; + auto id = sys.addEmitter(def); + ASSERT_TRUE(id.ok()); + + // Independent oracle for the first two spawns (the 4-draw contract: + // vx, vy, life, size — in order). + laige::Prng oracle(kSeed); + auto expectParticle = [&](const auto& p, const char* label) { + const auto vx = oracleSample(oracle, def.velMin.x, def.velMax.x); + const auto vy = oracleSample(oracle, def.velMin.y, def.velMax.y); + const std::uint32_t life = oracle.next_range(def.lifeMin, def.lifeMax + 1); + const auto size = oracleSample(oracle, def.sizeMin, def.sizeMax); + EXPECT_EQ(p.vel.x, vx) << label; + EXPECT_EQ(p.vel.y, vy) << label; + EXPECT_EQ(p.age, 0u) << label; + EXPECT_EQ(p.life, life) << label; + EXPECT_EQ(p.size, size) << label; + EXPECT_EQ(p.pos.x, sc(0)) << label; + EXPECT_EQ(p.pos.y, sc(0)) << label; + EXPECT_EQ(p.depth, sc(0)) << label; + EXPECT_EQ(p.tint[0], 1u) << label; + EXPECT_EQ(p.fadeTicks, 0u) << label; + }; + + sys.update(); // spawn 2 + { + const auto live = sys.liveParticles(); + ASSERT_EQ(live.size(), 2u); + expectParticle(live[0], "spawn 1"); + expectParticle(live[1], "spawn 2"); + } + sys.update(); // spawn 2 more + EXPECT_EQ(sys.liveCount(), 4u); + EXPECT_EQ(sys.droppedTotal(), 0u); + // Fill the pool (10), then overflow: exactly one warn per tick. + MemorySink* sink = installSink(); + for (int t = 0; t < 3; ++t) { + sys.update(); // t=3..5: live 6, 8, 10 + } + EXPECT_EQ(sys.liveCount(), 10u); + EXPECT_EQ(sys.droppedTotal(), 0u); + sys.update(); // t=6: live 10, 2 drops + EXPECT_EQ(sys.liveCount(), 10u); + EXPECT_EQ(sys.droppedTotal(), 2u); + EXPECT_EQ(countEvents(*sink, "particles", "pool_overflow"), 1u); + EXPECT_EQ(fieldOf(sink->entries.front(), "dropped"), "2"); + EXPECT_EQ(fieldOf(sink->entries.front(), "capacity"), "10"); + sys.update(); // t=7: 2 more drops — still ONE warn per tick + EXPECT_EQ(sys.droppedTotal(), 4u); + EXPECT_EQ(countEvents(*sink, "particles", "pool_overflow"), 2u); + restoreLogger(); +} + +TEST(ParticlesContinuous, RateAndOracle) { + continuousRateAndOracle(); + continuousRateAndOracle(); +} + +// --------------------------------------------------------------------------- +// ParticlesDeterminism +// --------------------------------------------------------------------------- + +template +void runDeterminismSequence(ParticleSystem& sys) { + // 50 ticks: one continuous emitter (rate 3, life 3..8, wide boxes) + // + a burst(2, 5) after every 10th tick (emitter 2, life 5..5, + // degenerate). Pool capacity 8 -> drops occur throughout. + using Vec2 = typename M_::Vec2; + ParticleEmitterDef c = baseDef(); + c.velMin = Vec2{sc(-1), sc(-1, 2)}; + c.velMax = Vec2{sc(2), sc(1)}; + c.lifeMin = 3; + c.lifeMax = 8; + c.sizeMin = sc(1, 4); + c.sizeMax = sc(2); + c.tint[0] = 200; + c.tint[3] = 128; + c.fadeTicks = 3; + c.continuousRate = 3; + auto rc = sys.addEmitter(c); + ASSERT_TRUE(rc.ok()); + auto rb = sys.addEmitter(degDef(7, 7, 1, 0, 1, 5, 3, 64, 0)); + ASSERT_TRUE(rb.ok()); + for (int t = 0; t < 50; ++t) { + sys.update(); + if (t % 10 == 0) { + ASSERT_TRUE(sys.burst(2, 5).ok()); + } + } +} + +template +void sameSeedSameTrajectory(const char* backendName) { + using Sys = ParticleSystem; + constexpr std::uint64_t kSeed = 0x0123456789abcdefull; + + auto ra = Sys::create({8, 4, kSeed}); + ASSERT_TRUE(ra.ok()); + auto a = std::move(ra).takeValue(); + runDeterminismSequence(a); + auto rb = Sys::create({8, 4, kSeed}); + ASSERT_TRUE(rb.ok()); + auto b = std::move(rb).takeValue(); + runDeterminismSequence(b); + + // The whole live set is bit-identical (every field). + const auto la = a.liveParticles(); + const auto lb = b.liveParticles(); + ASSERT_EQ(la.size(), lb.size()); + for (std::size_t i = 0; i < la.size(); ++i) { + EXPECT_TRUE(M_::equals(la[i].pos, lb[i].pos)) << "particle " << i; + EXPECT_TRUE(M_::equals(la[i].vel, lb[i].vel)) << "particle " << i; + EXPECT_TRUE(M_::equals(la[i].depth, lb[i].depth)) + << "particle " << i; + EXPECT_TRUE(M_::equals(la[i].size, lb[i].size)) + << "particle " << i; + EXPECT_EQ(la[i].age, lb[i].age) << "particle " << i; + EXPECT_EQ(la[i].life, lb[i].life) << "particle " << i; + EXPECT_EQ(la[i].fadeTicks, lb[i].fadeTicks) << "particle " << i; + for (int ch = 0; ch < 4; ++ch) { + EXPECT_EQ(la[i].tint[ch], lb[i].tint[ch]) << "particle " << i; + } + } + EXPECT_EQ(a.spawnedTotal(), b.spawnedTotal()); + EXPECT_EQ(a.droppedTotal(), b.droppedTotal()); + EXPECT_EQ(a.prngStatePart1(), b.prngStatePart1()); + EXPECT_EQ(a.prngStatePart2(), b.prngStatePart2()); + + // The machine-greppable state hash (the KAT convention). + const std::uint64_t hash = particlesStateHash(a); + printf("particles-determinism backend=%s seed=0x%016llx ticks=50 " + "spawns=%llu fnv1a=0x%016llx\n", + backendName, static_cast(kSeed), + static_cast(a.spawnedTotal()), + static_cast(hash)); + + // A different seed diverges (the replay-identity contract). + auto rc = Sys::create({8, 4, kSeed + 1}); + ASSERT_TRUE(rc.ok()); + auto c = std::move(rc).takeValue(); + runDeterminismSequence(c); + EXPECT_NE(particlesStateHash(c), hash); + + // The 4-draw contract: an independent Prng advanced by 4 x + // spawnedTotal draws lands on the system's stream state (a DROPPED + // spawn consumes no draws — the pool check precedes the draws). + laige::Prng oracle(kSeed); + for (std::uint32_t i = 0; i < a.spawnedTotal() * 4; ++i) { + oracle.next_u64(); + } + EXPECT_EQ(oracle.statePart1(), a.prngStatePart1()); + EXPECT_EQ(oracle.statePart2(), a.prngStatePart2()); + EXPECT_GT(a.droppedTotal(), 0u) + << "the scenario must exercise the drop path (no draws consumed)"; +} + +TEST(ParticlesDeterminism, SameSeedSameTrajectory) { + sameSeedSameTrajectory("fpx16_16"); + sameSeedSameTrajectory("fp32_pinned"); +} + +// --------------------------------------------------------------------------- +// ParticlesFade +// --------------------------------------------------------------------------- + +TEST(ParticlesFade, ExactFadeTable) { + using Particle = ParticleSystem::Particle; + // life 8, fade 4, tint.a 255: remaining = 8 - age. + Particle p; + p.life = 8; + p.fadeTicks = 4; + p.tint[0] = 9; + p.tint[1] = 9; + p.tint[2] = 9; + p.tint[3] = 255; + const std::uint8_t expected[8] = {255, 255, 255, 255, 255, 191, 127, 63}; + for (std::uint32_t age = 0; age < 8; ++age) { + p.age = age; + EXPECT_EQ(ParticleSystem::fadeAlpha(p), + expected[age]) + << "age " << age; + } + // tint.a 200: 200*3/4 = 150, 200*2/4 = 100, 200/4 = 50. + p.tint[3] = 200; + p.age = 5; + EXPECT_EQ(ParticleSystem::fadeAlpha(p), 150); + p.age = 6; + EXPECT_EQ(ParticleSystem::fadeAlpha(p), 100); + p.age = 7; + EXPECT_EQ(ParticleSystem::fadeAlpha(p), 50); + // fadeTicks 0: no fade — the alpha is constant. + p.fadeTicks = 0; + for (std::uint32_t age = 0; age < 8; ++age) { + p.age = age; + EXPECT_EQ(ParticleSystem::fadeAlpha(p), 200) + << "age " << age; + } +} + +template +void fadeThroughSystem() { + using Sys = ParticleSystem; + auto r = Sys::create({4, 1, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + auto d = degDef(0, 0, 0, 0, 0, 8, 1, 255, 4); + auto id = sys.addEmitter(d); + ASSERT_TRUE(id.ok()); + ASSERT_TRUE(sys.burst(*id.valueIfOk(), 1).ok()); + const std::uint8_t expected[8] = {255, 255, 255, 255, 255, 191, 127, 63}; + for (int k = 0; k < 8; ++k) { + const auto live = sys.liveParticles(); + ASSERT_EQ(live.size(), 1u) << "tick " << k; + EXPECT_EQ(Sys::fadeAlpha(live[0]), expected[k]) << "tick " << k; + sys.update(); // age k+1; the 8th update kills (age 8 >= life 8) + } + EXPECT_EQ(sys.liveCount(), 0u); +} + +TEST(ParticlesFade, FadeThroughSystem) { + fadeThroughSystem(); + fadeThroughSystem(); +} + +// --------------------------------------------------------------------------- +// ParticlesPoolExhaustion +// --------------------------------------------------------------------------- + +template +void exactExhaustionTable() { + using Sys = ParticleSystem; + // capacity 3, one emitter (rate 2, life 4): the hand-computed + // per-tick table (the header's per-tick contract: advance -> emit). + // A cohort emitted at tick T is live at the end of ticks T..T+3 and + // dies at the end of T+4 — the pool is full from tick 2 on (2 + // deaths per cohort tick exactly refill the dropped slots, except + // where the odd live count leaves a drop). + auto r = Sys::create({3, 1, 42}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + auto d = degDef(0, 0, 0, 1, 0, 4, 1, 255, 0); + d.continuousRate = 2; + auto id = sys.addEmitter(d); + ASSERT_TRUE(id.ok()); + const std::uint32_t liveExpected[10] = {2, 3, 3, 3, 3, 3, 3, 3, 3, 3}; + const std::uint32_t droppedExpected[10] = {0, 1, 3, 5, 5, 6, 8, 10, 10, 11}; + // CUMULATIVE warn count (one per tick where drops occur: ticks + // 2, 3, 4, 6, 7, 8, 10 — 7 total). + const std::uint32_t warnsExpected[10] = {0, 1, 2, 3, 3, 4, 5, 6, 6, 7}; + MemorySink* sink = installSink(); + for (int t = 0; t < 10; ++t) { + sys.update(); + EXPECT_EQ(sys.liveCount(), liveExpected[t]) << "tick " << (t + 1); + EXPECT_EQ(sys.droppedTotal(), droppedExpected[t]) << "tick " << (t + 1); + EXPECT_EQ(countEvents(*sink, "particles", "pool_overflow"), + warnsExpected[t]) + << "tick " << (t + 1) << " (at most one warn per tick)"; + } + restoreLogger(); +} + +TEST(ParticlesPoolExhaustion, ExactTable) { + exactExhaustionTable(); + exactExhaustionTable(); +} + +// --------------------------------------------------------------------------- +// ParticlesStats +// --------------------------------------------------------------------------- + +template +void profilerFeed() { + using Sys = ParticleSystem; + auto r = Sys::create({4, 1, 0}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + auto d = degDef(0, 0, 0, 1, 0, 3, 1, 255, 0); + d.continuousRate = 2; + auto id = sys.addEmitter(d); + ASSERT_TRUE(id.ok()); + // rate 2, life 3, capacity 4: t1 live 2 / t2 live 4 / t3 +2 drops / + // t4 the t1 cohort dies, +2 spawn -> live 4 / t5 the t2 cohort dies, + // +2 spawn -> live 4. 10 attempts, 2 drops -> 8 successful spawns. + for (int t = 0; t < 5; ++t) { + sys.update(); + } + const auto stats = sys.stats(); + EXPECT_EQ(stats.live, 4u); + EXPECT_EQ(stats.capacity, 4u); + EXPECT_EQ(stats.spawnedTotal, 8u); + EXPECT_EQ(stats.droppedTotal, 2u); +} + +TEST(ParticlesStats, ProfilerFeed) { + profilerFeed(); + profilerFeed(); +} + +// --------------------------------------------------------------------------- +// ParticlesZeroAlloc +// --------------------------------------------------------------------------- + +template +void updateLoopAllocatesNothing() { + using Sys = ParticleSystem; + // capacity 512, two continuous emitters (rates 4 + 2, life 42 — + // steady state 252 live, no drops) + a burst of 8 every 50 ticks: + // 500 ticks of the full update path allocate nothing (PERF-003). + auto r = Sys::create({512, 2, 13}); + ASSERT_TRUE(r.ok()); + auto sys = std::move(r).takeValue(); + auto e1 = sys.addEmitter(degDef(0, 0, 0, 1, 0, 42, 1, 255, 0)); + auto e2 = sys.addEmitter(degDef(1, 0, 1, -1, 1, 42, 1, 255, 0)); + ASSERT_TRUE(e1.ok()); + ASSERT_TRUE(e2.ok()); + auto runTicks = [&]() { + for (int t = 0; t < 500; ++t) { + ASSERT_TRUE(sys.burst(1, 4).ok()) << "tick " << t; + ASSERT_TRUE(sys.burst(2, 2).ok()) << "tick " << t; + sys.update(); + } + }; + if (laige::allocWatchLive()) { + laige::allocWatchArm(); // the watch's owner is THIS thread + runTicks(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << "500 ticks of burst/update allocated " << reading.allocs + << " heap blocks on the loop thread"; + printf("particles-zeroalloc ticks=500 allocs=%llu\n", + static_cast(reading.allocs)); + } else { + runTicks(); // sanitizer tree: the leak-free run covers it + } + // The counters agree with the no-drop arithmetic: 500 x 6 spawns; + // a particle bursted at tick k is live at the end of tick 500 iff + // k >= 460 (it dies at the end of tick k + life - 1 = k + 41) -> + // 41 cohorts x 6. + EXPECT_EQ(sys.spawnedTotal(), 500u * 6u); + EXPECT_EQ(sys.droppedTotal(), 0u); + EXPECT_EQ(sys.liveCount(), 41u * 6u); +} + +TEST(ParticlesZeroAlloc, UpdateLoopAllocatesNothing) { + updateLoopAllocatesNothing(); + updateLoopAllocatesNothing(); +} + +} // namespace