diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 548ce6f..4ed5746 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -4,32 +4,39 @@ # Roadmap: roadmap/M0-foundations.md, step M0-CI-01 # PRD refs: §6 (P0 platforms, AC-6.1), §14 (cadence), §8.1 (build budget) # -# Cadence (PRD §14: "one per PR, all per merge"): each pull request runs -# exactly ONE P0 OS, selected by the PR's ci:* label; the default (no -# label) is Linux. Merges to master run ALL P0 OSes via ci.yml, which is -# the "all per merge" half of the cadence. +# Cadence: the PR lane covers only the P0 OSes that cannot be verified +# locally — Windows and macOS. Linux is verified on the developer +# machine with the canonical trees from +# docs/getting-started/building.md (including the LAIGE_ASAN=ON and +# LAIGE_TSAN=ON instrumented trees), so this workflow carries no Linux +# jobs. Merges to master run the FULL matrix — every P0 OS/variant job, +# both sanitizer lanes, and all tooling jobs — via ci.yml, which is +# the "all per merge" half of the PRD §14 cadence. # -# Label selector (apply at most one per PR; if several are present the -# highest-priority label wins, so exactly one P0 OS always runs): +# Label selector (apply at most one per PR; if both are present +# ci:macos wins, so exactly one P0 OS always runs): # # ci:macos → macos-arm64 + macos-intel jobs # ci:windows → windows-msvc job -# ci:linux → linux-gcc + linux-clang jobs (also the no-label default) +# (no label) → no P0 OS build; the platform-independent tooling jobs +# (include-lint, determinism-lint, api-manifest, +# detcheck) still run on every PR. # # NOTE: the labels must be created once in the repository settings -# (GitHub does not create labels from workflows). A PR with an unknown -# or missing ci:* label simply falls back to the Linux default. +# (GitHub does not create labels from workflows). A PR with no ci:* +# label simply runs the tooling jobs. The former ci:linux label is no +# longer a selector (Linux is verified locally); a PR carrying it gets +# the no-label treatment. # # Jobs, runner images, steps, and the 10-minute budget timeout are the # same as in ci.yml (see its header for the full matrix documentation # and the Windows multi-config note). # -# Sanitizer lanes (M0-CI-02): linux-asan (LAIGE_ASAN=ON) and linux-tsan -# (LAIGE_TSAN=ON) run under the same condition as the default Linux -# jobs — i.e. on every PR that does not select another P0 OS — because -# they are part of the Linux P0 check (PRD §14: one P0 OS per PR). See -# the ci.yml header for the lane semantics (fatal sanitizer reports, -# report archiving, toolchain choice). +# Sanitizer lanes (M0-CI-02): the linux-asan (LAIGE_ASAN=ON) and +# linux-tsan (LAIGE_TSAN=ON) lanes no longer run on PRs (no Linux jobs +# in the PR lane); they run on every merge via ci.yml. See the ci.yml +# header for the lane semantics (fatal sanitizer reports, report +# archiving, toolchain choice). # # Fuzz lane (PRD §14 "every commit (bounded)"; M0-TEST-01): no separate # fuzz job — the `fuzz_json_parse` ctest entry (1000 deterministic runs @@ -48,10 +55,7 @@ # # Fork PRs: this workflow uses the pull_request event, which runs on the # merge ref with a read-only token; checkout + build + ctest need nothing -# beyond contents:read. On such PRs the artifact upload (needs -# actions:write) fails with a 403, so the upload steps carry -# continue-on-error: true — the reports then remain in the job log and -# the tee'd output instead of the artifact. +# beyond contents:read (no job in this workflow uploads artifacts). # ============================================================================ name: CI (pull request) @@ -67,189 +71,6 @@ concurrency: cancel-in-progress: true jobs: - linux-gcc: - name: Linux x64 (g++) - if: >- - !contains(github.event.pull_request.labels.*.name, 'ci:macos') && - !contains(github.event.pull_request.labels.*.name, 'ci:windows') - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Install GLFW X11 headers (laige-render) - # GLFW's X11 backend (GLFW_BUILD_WAYLAND=OFF in src/laige-render/ - # CMakeLists.txt) needs only the X11 *headers* at configure time - # (GLFW 3.5 dlopens the X11/GL libraries at runtime); the - # ubuntu-24.04 runner image ships none of them. The libegl/libgl - # packages are the RUNTIME GL stack for the headless GL smoke - # (Mesa surfaceless EGL + llvmpipe software GL; the engine and - # GLAD load them by dlopen at runtime) (M2-GL-01). - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq \ - libx11-dev libxcursor-dev libxrandr-dev \ - libxinerama-dev libxi-dev libxext-dev \ - libegl1 libgl1 libegl-mesa0 libglx-mesa0 libgl1-mesa-dri - - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ - - name: Build - run: cmake --build build -j - - name: Test (unit) - run: ctest --test-dir build --output-on-failure - - linux-clang: - name: Linux x64 (clang++) - if: >- - !contains(github.event.pull_request.labels.*.name, 'ci:macos') && - !contains(github.event.pull_request.labels.*.name, 'ci:windows') - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Install GLFW X11 headers (laige-render) - # GLFW's X11 backend (GLFW_BUILD_WAYLAND=OFF in src/laige-render/ - # CMakeLists.txt) needs only the X11 *headers* at configure time - # (GLFW 3.5 dlopens the X11/GL libraries at runtime); the - # ubuntu-24.04 runner image ships none of them. The libegl/libgl - # packages are the RUNTIME GL stack for the headless GL smoke - # (Mesa surfaceless EGL + llvmpipe software GL; the engine and - # GLAD load them by dlopen at runtime) (M2-GL-01). - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq \ - libx11-dev libxcursor-dev libxrandr-dev \ - libxinerama-dev libxi-dev libxext-dev \ - libegl1 libgl1 libegl-mesa0 libglx-mesa0 libgl1-mesa-dri - - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ - - name: Build - run: cmake --build build -j - - name: Test (unit) - run: ctest --test-dir build --output-on-failure - - linux-asan: - name: Linux x64 ASan+UBSan (clang++) - if: >- - !contains(github.event.pull_request.labels.*.name, 'ci:macos') && - !contains(github.event.pull_request.labels.*.name, 'ci:windows') - runs-on: ubuntu-24.04 - timeout-minutes: 10 - # Job-scoped token: the archive step below needs actions:write for - # PRs from the base repository (fork PR tokens stay read-only, - # whatever this declares — see header). - permissions: - contents: read - actions: write - steps: - - uses: actions/checkout@v4 - - name: Install GLFW X11 headers (laige-render) - # GLFW's X11 backend (GLFW_BUILD_WAYLAND=OFF in src/laige-render/ - # CMakeLists.txt) needs only the X11 *headers* at configure time - # (GLFW 3.5 dlopens the X11/GL libraries at runtime); the - # ubuntu-24.04 runner image ships none of them. The libegl/libgl - # packages are the RUNTIME GL stack for the headless GL smoke - # (Mesa surfaceless EGL + llvmpipe software GL; the engine and - # GLAD load them by dlopen at runtime) (M2-GL-01). - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq \ - libx11-dev libxcursor-dev libxrandr-dev \ - libxinerama-dev libxi-dev libxext-dev \ - libegl1 libgl1 libegl-mesa0 libglx-mesa0 libgl1-mesa-dri - - name: Configure (ASan+UBSan) - run: cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ -DLAIGE_ASAN=ON - - name: Build (ASan+UBSan) - run: cmake --build build-asan -j - - name: Test (unit, ASan+UBSan) - # Every sanitizer report is fatal to the test process: ASan via - # abort_on_error=1:halt_on_error=1 here, UBSan via - # -fno-sanitize-recover=all (wired by CMake). log_path keeps one - # report file per test process for the artifact upload below. - env: - ASAN_OPTIONS: "abort_on_error=1:halt_on_error=1:detect_leaks=1:log_path=${{ github.workspace }}/asan-reports/asan" - run: | - set -o pipefail - ctest --test-dir build-asan --output-on-failure 2>&1 | tee asan-ctest-output.txt - - name: Archive sanitizer reports - # continue-on-error: fork PRs run with a read-only token, so the - # upload 403s there (see header); the reports stay in the job log - # and the tee'd output. The job-level actions:write covers PRs - # from the base repository. - if: always() - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: linux-asan-reports - path: | - asan-ctest-output.txt - asan-reports/ - build-asan/Testing/Temporary/LastTest.log - - linux-tsan: - name: Linux x64 TSan (clang++) - if: >- - !contains(github.event.pull_request.labels.*.name, 'ci:macos') && - !contains(github.event.pull_request.labels.*.name, 'ci:windows') - runs-on: ubuntu-24.04 - # 30 min (not 10): TSan is the slowest lane by design (~5-15x - # instrumentation overhead), and the suite has grown past the old - # margin — the M2-ISO-01 10k-scene property suite (iso_depth_key - # entry) plus the M2-GL-02 3000-frame integration run. Evidence: - # the master merge run of PR #60 took 8.8 min (96 entries: setup + - # build ~1.7 min, the 96-entry TSan test step ~7.1 min); the - # M2-ISO-01 PR run needed ~11 min (setup + build 3.7 min on a - # slower runner, the 97-entry test step ~7.2 min — laige-sim_tests - # alone 177 s) and was CANCELLED at the 10-min cap ~50 s before - # completion. 30 min keeps a large margin for runner variance and - # the suite's continued growth without weakening the gate - # (M2-ISO-01, 2026-09-30). - timeout-minutes: 30 - # Job-scoped token: the archive step below needs actions:write (see - # the linux-asan job comment). - permissions: - contents: read - actions: write - steps: - - uses: actions/checkout@v4 - - name: Install GLFW X11 headers (laige-render) - # GLFW's X11 backend (GLFW_BUILD_WAYLAND=OFF in src/laige-render/ - # CMakeLists.txt) needs only the X11 *headers* at configure time - # (GLFW 3.5 dlopens the X11/GL libraries at runtime); the - # ubuntu-24.04 runner image ships none of them. The libegl/libgl - # packages are the RUNTIME GL stack for the headless GL smoke - # (Mesa surfaceless EGL + llvmpipe software GL; the engine and - # GLAD load them by dlopen at runtime) (M2-GL-01). - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq \ - libx11-dev libxcursor-dev libxrandr-dev \ - libxinerama-dev libxi-dev libxext-dev \ - libegl1 libgl1 libegl-mesa0 libglx-mesa0 libgl1-mesa-dri - - name: Configure (TSan) - run: cmake -S . -B build-tsan -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ -DLAIGE_TSAN=ON - - name: Build (TSan) - run: cmake --build build-tsan -j - - name: Test (unit, TSan) - # TSAN_OPTIONS=halt_on_error=1 is applied per test by tests/ - # (M0-BUILD-01), so the first data race report kills the test - # process and ctest fails. The race report is printed to stderr - # and captured by the tee below. - run: | - set -o pipefail - ctest --test-dir build-tsan --output-on-failure 2>&1 | tee tsan-ctest-output.txt - - name: Archive sanitizer reports - # continue-on-error and job-level permissions: same rationale as - # the linux-asan job (fork PRs read-only, base-repo PRs can - # upload). - if: always() - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: linux-tsan-reports - path: | - tsan-ctest-output.txt - build-tsan/Testing/Temporary/LastTest.log - windows-msvc: name: Windows x64 (MSVC 2022) if: >- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7cdf37..dd83bb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,20 +7,23 @@ # Cadence (PRD §14: "one per PR, all per merge"): # * this workflow runs on every push to the default branch (master) — # i.e. on merge — and on manual workflow_dispatch: -# ALL ten jobs run (the five P0 OS/variant jobs, the two sanitizer -# lanes (M0-CI-02), and the three platform-independent tooling jobs: -# the include-graph lint with dependency-count metric (M0-CI-03), -# the public API manifest drift check (M0-TOOL-01), the -# determinism source scan (M1-DET-01), and the determinism check -# (M0-TOOL-02)). +# ALL eleven jobs run (the seven P0 OS/variant jobs — the five P0 OS +# jobs plus the two sanitizer lanes (M0-CI-02) — and the four +# platform-independent tooling jobs: the include-graph lint with +# dependency-count metric (M0-CI-03), the public API manifest drift +# check (M0-TOOL-01), the determinism source scan (M1-DET-01), and +# the determinism check (M0-TOOL-02)). # # Fuzz lane (PRD §14 "every commit (bounded)"; M0-TEST-01): there is no # separate fuzz job — the `fuzz_json_parse` ctest entry (1000 # deterministic runs of laige-fuzz on the json_parse target) runs inside # every build job's ctest above, instrumented in the ASan tree. Seed and # nightly-long-run conventions: docs/testing.md. -# * Pull requests run exactly ONE P0 OS (label-selectable, default -# Linux) in the companion workflow .github/workflows/ci-pull.yml. +# * Pull requests run only the P0 OSes that cannot be verified +# locally — Windows and/or macOS, selected by the PR's ci:windows / +# ci:macos label (no label: no P0 OS build; Linux is verified +# locally) — in the companion workflow +# .github/workflows/ci-pull.yml. # # One job per P0 OS/variant (PRD §6). Each job runs the canonical flow from # docs/getting-started/building.md — configure → build → ctest (unit tests diff --git a/roadmap/README.md b/roadmap/README.md index 0bf6d14..abb28a7 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -245,6 +245,7 @@ One line per completed (or split/renumbered) step. | 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 | | 2026-10-07 | M2-PART-02 | `—` / PR #83 | Particle rendering through the batcher (FR-2.7 — "particles render as batched sprites"; M2-PART-02 scope, nothing else): **API** — NEW header-only `src/laige-render/include/laige/render/particle_render.h` (`laige::render`, public, additive; templated over the SimMath backends — the `presentation.h` pattern): `particleDepthToStepHeight(depth) → int32` (the sim depth value → the M2-ISO-01 integer step height: clamp to ±`kIsoDepthMaxStepHeight` (2047) FIRST — per backend, SimMath has no ordering: fpx raw integer compares, fp32 IEEE compares, NaN falls through to +2047 (presentation-only, ARCH-009) — then round nearest ties-to-even (fpx `fpx16_16::toInt32`, fp32 `std::nearbyint` in the pinned rounding mode — the two backends agree on dyadic depths; NOT `llround`, which would diverge on 0.5)) + `ParticleDeclareOptions` (the emitter set's shared render attributes: `atlasId`/`materialId`/`blend` (default `BlendMode::Additive` — the particle convention)/`uv` (default the full atlas)/`layer` (default `kIsoDepthGroundLayer` = 0)) + `kTintChannelDivisor` (255.0f) + `declareParticles(batcher, system, options) → Status` (the O(live) conversion pass: every live particle — live-array order, the deterministic tie-break (RENDER-003) — becomes one `SpriteItem`: the world position at the RENDER-006 boundary (one documented rounding per component per backend), the engine-owned `depthKey` = `isoDepthKey(p.pos, particleDepthToStepHeight(p.depth), options.layer)` (G-R11; `depthOverride` false), the square `scale (size, size)`, the tint (u8→float /255 RGB + the M2-PART-01 exact-u32 `fadeAlpha` /255), the shared `uv`/`atlasId`/`materialId`/`blend`, `frameIndex`/`rotation` 0 (no per-particle animation/rotation in this step); first failed add fails the call with the batcher's error — stopped batcher → `BudgetExhausted` (an empty system still succeeds: no adds attempted), closed window → `InvalidArgument`, frame-budget overflow → the batcher's own drop-oldest + warn policy (the conversion succeeds); O(live), no allocation, no logging, no GL); **build** — `laige-render` links `laige-sim` PUBLIC (the module stack's legal downward direction, PRD §10.1 — render MAY include sim public headers; the M2-GL-02 "no render→sim edge" note covers the frame-pipeline handoff, which stays an opaque pointer); **tests** — NEW ctest entry `particle_render` (`tests/laige-render/particle_render_tests.cpp`, 9 suites, both backends, no GL): the per-particle declaration goldens against the M2-ISO-01 oracle + the hand-computed dyadic key goldens (0x801FFFE0/0x80200000×3/0x80200020/0x80200030 — awk-verified before pinning), the depth conversion (sub-unit ties-to-even 0.5→0, 1.5→2; ±3000→±2047 saturation; the (key, live-order) in-group ordering with the A/B/F tie), the exact fade through the declaration (life 8/fade 4 → alphas 255×5/191/127/63), the multi-emitter-set group count (3 sets → 3 groups in ascending (atlas, material, blend) order — one draw call per set, RENDER-001), the failure paths, the fixed-seed determinism (identical declared (key, pos) sequence + the machine-greppable `particle-render-determinism` FNV-1a hash line; a different seed diverges), the 1 000-frame 10k-particle zero-allocation declare loop (`particle-render-zeroalloc ... allocs=0`, the alloc-watch gate), and the budget suite (below); **budget** — NEW `budgets.json` entry `particle_render_10k` (mean, ms, target 2.0, step-level — the `depth_sort_10k` precedent: no PRD revision): the 10k-particle conversion pass ONLY (the batcher's `build`/sort is the separate `depth_sort_10k` budget — `beginFrame`/`build` outside the measured window, the frame protocol keeps the phases apart), 100 warm-up + 3 000 measured declare passes, both backends (the gate is backend-complete, ADR 0002); gated on the Linux non-instrumented trees (`LAIGE_PARTICLE_RENDER_BUDGET` + `LAIGE_PARTICLE_RENDER_BUILD_TYPE`), ungated elsewhere (the sanitizer trees run the workload for its leak/race value); `measured` = **1.07798** (the worse of the two backends — fpx16_16 1.07798 / fp32_pinned 0.996543 — canonical Debug tree, n=3000) — 1.86× inside the 2.0 ms gate; **NEW baseline** `docs/benchmarks/baselines/m2-particle-render.md` (the tenth baseline, verbatim reports + cross-compiler clang -O0 run); budgets.json entry count 16 → 17 (the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin updated); **docs** — NEW `docs/api/particle_render.md` (the full contract + the DOC-004 Performance section); `docs/api/particles.md` (the render half now implemented — link + the render-phase example + Related), `docs/concepts/coordinates.md` (§4.11 narrative + the §5 conversion row Planned → **Shipped** + Related), `docs/README.md` (the API index), `src/laige-render/README.md` (the M2-PART-02 status paragraph); **build fix** — the system clang 22.1.8 → 23.1.1 upgrade surfaced `-Werror,-Wunused-but-set-global` on pre-existing sim test files: removed the dead `MemorySink* sink` globals in `tests/laige-sim/game_loop_tests.cpp` + `presentation_tests.cpp` + `system_timing_tests.cpp` (written, never read — the tests use `installCaptureSink()`'s return value) and `LAIGE_ALLOC_COUNTER`-gated the `schReadPosSum` read-back in `tests/laige-sim/scheduler_tests.cpp` (its only read site is the zero-alloc window test, which compiles out on the sanitizer clang trees); **API surface** — `laige-api.json` regenerated LAST (1410 → 1419 symbols, 41 → 42 headers); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green (build 116/116, build-clang 116/116, build-release 105/105, build-shared 116/116, build-asan 113/113, build-tsan 113/113 — the prior counts +1 each, the new `particle_render` entry; the counts above are the re-run after the sink fix); `ctest -R particle_render` green (9/9, both backends); `api-real-tree`/`api-check-fresh` (2/2), `tools/laige-include-lint` (71 source files), and `tools/laige-determinism-lint` (29 sim source files, 0 violations) green after the final `laige-api.json` regeneration; Progress Board M2 21/33, total 67/194 | +| 2026-10-08 | M0-CI-01 | `—` | **CI follow-up: the PR lane is Windows/macOS only** — the Linux P0 jobs (`linux-gcc`, `linux-clang`, `linux-asan`, `linux-tsan`) are removed from `ci-pull.yml`: Linux builds/tests are verified locally on the developer machine (the canonical trees of docs/getting-started/building.md, incl. the `LAIGE_ASAN=ON` / `LAIGE_TSAN=ON` instrumented trees), so the PR-lane label selector is now `ci:windows` / `ci:macos` only (no label = the platform-independent tooling jobs only; the former `ci:linux` label is a no-op). The merge lane (`ci.yml`) is unchanged — every merge to master still runs the full 11-job matrix (all P0 OSes, both sanitizer lanes, all tooling jobs), the "all per merge" half of the PRD §14 cadence. No engine code change — workflow headers + this log line only. | ---