diff --git a/docs/README.md b/docs/README.md index dd50c10..2352f1e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -302,22 +302,29 @@ still to land. drives. - [Tilemap](api/tilemap.md) — `laige::render::TileMap`: the chunked tile grid data (per - tile: texture id, depth/height, animation id — data only in M2), the - auto-depth wiring of the M2-ISO-02 depth key table (a tile's Y - height is automatically reflected in its depth key), and the static - tile-quad batch path into the sprite batcher (tiles are sprites with - a fixed frame; one tilemap renders in a bounded number of draw - calls — one per (texture, material, blend) group) (M2-TILE-01; - FR-2.6). + tile: texture id, depth/height, animation id — 0 = static, + 1..maxAnimations = the animation slot), the auto-depth wiring of + the M2-ISO-02 depth key table (a tile's Y height is automatically + reflected in its depth key), the tile-quad batch path into the + sprite batcher (tiles are sprites — one tilemap renders in a + bounded number of draw calls, one per (texture, material, blend) + group), the data-driven tile animation frame cycle (per-tile frame + cycling at the animation's documented sim-tick rate — + `setAnimation` + `advanceAnimations`; M2-TILE-02), and the parallax + tile layer declaration (a `Tilemap`-source parallax layer declares + the tilemap's quads translated by its `worldOffset`; M2-TILE-02, + the M2-PAR-01 hook) (M2-TILE-01/02; FR-2.6). - [Parallax layers](api/parallax.md) — `laige::render::ParallaxLayers`: the named background/midground/foreground layer model (M2-PAR-01; FR-2.3): the parallax factor (0..1), the EXACT world-space offset formula `factor * (p - center) + offset`, the UV scroll (auto or manual) with the exact wrap at the texture boundary (rendered through the - 2 x 2 wrap split into the sprite batcher), and the documented + 2 x 2 wrap split into the sprite batcher), the documented background-first render order (the depth-key layer values: - background -2, midground -1, ground 0, foreground +1). + 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). ## Guides diff --git a/docs/api/parallax.md b/docs/api/parallax.md index f86ed20..83e879d 100644 --- a/docs/api/parallax.md +++ b/docs/api/parallax.md @@ -24,10 +24,12 @@ The layer quads' keys are the [`iso_depth_key.md`](iso_depth_key.md) (M2-ISO-01) keys with the layer's depth-layer value (engine-owned, G-R11); the batch path declares into [`sprite_batcher.md`](sprite_batcher.md) -(M2-SPRITE-01). Tilemap-source layers (the M2-TILE-02 hook) declare -through the tilemap's path — -[`tilemap.md`](tilemap.md) — with this layer's `worldOffset` -translation and `depthLayer`. +(M2-SPRITE-01). Tilemap-source layers (M2-TILE-02) declare UNDER the +layer through the tilemap's `declareTo` overload — +[`tilemap.md`](tilemap.md) — the quads are translated by this layer's +`worldOffset`, and their keys carry the TILEMAP's own +`Options::layer` (the scene-setup convention: this def's `depthLayer` +must equal it). ## The API @@ -93,10 +95,16 @@ texel row, the M2-SPRITE-02 contract) maps to the world +y direction [coordinates.md](../concepts/coordinates.md)). The Tilemap source references a tilemap (its `tilemapId`): the -tilemap defines its own world grid, and M2-TILE-02 declares its tiles -with this layer's `worldOffset` translation and its `depthLayer`. In -M2-PAR-01 a Tilemap-source layer is DATA ONLY: `declareTo` skips it -(the hook), its `size`/`uv` fields are not validated. +tilemap defines its own world grid, and (M2-TILE-02) the tilemap +declares its tiles UNDER this layer (the +`TileMap::declareTo` overload, [`tilemap.md`](tilemap.md)): the quads +are translated by this layer's `worldOffset` (formula (1)), and their +depth keys carry the TILEMAP's own `Options::layer` (the scene-setup +convention: this def's `depthLayer` must equal it — both are the +content's layer; the declaration uses the tilemap's value). This +registry's own `declareTo` skips a Tilemap-source layer (no log — +the game declares the tiles through the tilemap's path, not this +one); its `size`/`uv` fields are not validated. ## The render order (the background-first contract) diff --git a/docs/api/tilemap.md b/docs/api/tilemap.md index cb10430..48f3c8e 100644 --- a/docs/api/tilemap.md +++ b/docs/api/tilemap.md @@ -1,16 +1,20 @@ # Tilemap (`laige::render` tilemap) The chunked tile grid data, the auto-depth wiring of the M2-ISO-02 -depth key table, and the static tile-quad batch path into the sprite -batcher (M2-TILE-01; PRD §15 M2, FR-2.6 — "Tilemap: chunks, per-tile -depth/height, auto-depth", §9.1 S-5, ARCH-009, G-R11; AGENTS RENDER-001/ -003, CORE-002/004/005/008, PERF-002/003; ADR 0002). Public header: +depth key table, the static tile-quad batch path into the sprite +batcher (M2-TILE-01), the data-driven tile animation frame cycle and +the parallax tile layer declaration (M2-TILE-02). PRD §15 M2, +FR-2.6 — "Tilemap: chunks, per-tile depth/height, auto-depth", +"tile animation", "parallax tile layers" — §9.1 S-5, ARCH-009, G-R11; +AGENTS RENDER-001/003, CORE-002/004/005/008, PERF-002/003; ADR 0002. +Public header: `src/laige-render/include/laige/render/tilemap.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 tilemap` -(`tests/laige-render/tilemap_tests.cpp`) — pure data + batcher -bookkeeping, no GL environment required: it runs in every local tree +pattern). Unit suites: `ctest -R tilemap` +(`tests/laige-render/tilemap_tests.cpp`) and `ctest -R tilemap_anim` +(`tests/laige-render/tilemap_anim_tests.cpp`) — pure data + batcher +bookkeeping, no GL environment required: they run in every local tree and in CI, and the goldens are hand-computed from the documented formula. @@ -20,32 +24,43 @@ The canonical narrative home is The per-tile key this tilemap precomputes is [`iso_depth_table.md`](iso_depth_table.md) (M2-ISO-02) on top of [`iso_depth_key.md`](iso_depth_key.md) (M2-ISO-01); the batch path -declares into [`sprite_batcher.md`](sprite_batcher.md) (M2-SPRITE-01). +declares into [`sprite_batcher.md`](sprite_batcher.md) (M2-SPRITE-01); +the frame UVs come from [`sprite_frames.md`](sprite_frames.md) +(M2-SPRITE-03); the parallax tile layer consumes +[`parallax.md`](parallax.md) (M2-PAR-01). ## The API | Member | Returns | Notes | |---|---|---| -| `TileMap::create(options)` | `Result` | validates the options (the table's create — first failure wins), pre-sizes the table chunks + the 8 B/tile data array, flat/empty init (setup path) | +| `TileMap::create(options)` | `Result` | validates `maxAnimations` first, then the table's create (first failure wins); pre-sizes the table chunks + the 8 B/tile data array + the animation slot array; flat/empty init (setup path) | | `setTile(gx, gy, textureId, height, animationId)` | `Status` | the per-tile edit: stores the tile data, routes the height into the table's `setTile` — the auto-depth update (update path) | | `rebuild(tiles)` | `Status` | scene load: stores the whole grid (the data + the heights through the table's from-scratch rebuild; setup path) | -| `declareTo(batcher, options)` | `Status` | the render path: declares the static tile quads into the batcher's frame window (the batch path) | +| `setAnimation(id, def)` | `Status` | sets/replaces the animation slot `id` (setup / config path): frame count, tick rate, sheet layout; resets the phase; precomputes the frame UVs (one setup allocation) | +| `advanceAnimations()` | `void` | the sim phase's per-tick call (ONCE PER SIM TICK): advances every set animation's frame at its documented rate (O(maxAnimations), zero allocation) | +| `declareTo(batcher, options)` | `Status` | the render path: declares the tile quads (static fixed frames + animated frame UVs) into the batcher's frame window (the batch path) | +| `declareTo(batcher, options, layers, layerId, cameraPos)` | `Status` | the parallax tile layer path: the same quads TRANSLATED by the layer's `worldOffset(cameraPos)`, the keys at the translated centers at the tilemap's own `layer` (M2-PAR-01 hook) | | `tileAt(gx, gy)` | `TileData` | the tile's data: `{textureId, height (the table's), animationId}` (render read path; O(1)) | | `depthKeyAt(gx, gy)` | `uint32_t` | the tile's current depth key (the table's — auto-depth; O(1)) | | `tileHeightAt(gx, gy)` | `int32_t` | the tile's last stored height (diagnostics view — DBG-008) | | `covers(gx, gy)` | `bool` | whether the tile lies in the requested grid (O(1)) | -| introspection | `int32_t` / `size_t` | `originTileX/Y()`, `widthTiles()`, `heightTiles()`, `layer()`, `chunkTiles()`, `tileCount()` | - -`TileMap::Options` **is** the table's `Options` (one type — no -duplicated validation): `originTileX/Y` (default 0), `widthTiles` / -`heightTiles` (required ≥ 1), `chunkTiles` (default -`kIsoDepthTableChunkTiles` = 16; a power of two ≥ 1), `maxChunks` -(default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default -`kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own -tilemap with its layer value, M2-PAR-01 — the values are documented -in [`parallax.md`](parallax.md)). `TileData` is a plain value -(8 B of data + the table's height — the value the game writes on -load/edit and reads back). +| `hasAnimation(id)` / `animationAt(id)` / `animationFrame(id)` | `bool` / `const TileAnimationDef&` / `uint32_t` | the animation introspection (the declare path's check; the debug view — DBG-008) | +| introspection | `int32_t` / `size_t` / `uint32_t` | `originTileX/Y()`, `widthTiles()`, `heightTiles()`, `layer()`, `chunkTiles()`, `maxAnimations()`, `tileCount()` | + +`TileMap::Options` extends the table's grid options with +`maxAnimations` (default `kTileMapDefaultAnimations` = 8; the domain +[1, `kTileMapMaxAnimations` = 256] — the slot count, validated FIRST, +then the table's create validates the grid options): `originTileX/Y` +(default 0), `widthTiles` / `heightTiles` (required ≥ 1), `chunkTiles` +(default `kIsoDepthTableChunkTiles` = 16; a power of two ≥ 1), +`maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` +(default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its +own tilemap with its layer value, M2-PAR-01 — the values are +documented in [`parallax.md`](parallax.md)). `TileData` is a plain +value (8 B of data + the table's height — the value the game writes on +load/edit and reads back). `TileAnimationDef` is a plain value +(`frameCount`, `frameTicks`, the [`SpriteFrameLayout`](sprite_frames.md) +of the tile sheet). ## The tile model @@ -75,12 +90,54 @@ the table alone** (one source of truth): but they are **not tiles**: the tilemap's `covers()`/data/declare path never touch them. - The data write happens AFTER the depth edit succeeded (both in - `setTile` and `rebuild` — heights validated over the whole span - before any write): a rejected operation leaves the tile data AND - the table unchanged; the returned `Status` is the failure channel - the caller handles and logs (LOG-002). + `setTile` and `rebuild` — heights + animationIds validated over the + whole span before any write): a rejected operation leaves the tile + data AND the table unchanged; the returned `Status` is the failure + channel the caller handles and logs (LOG-002). + +## Tile animation (M2-TILE-02 — the data-driven frame cycle) + +An ANIMATION is a slot of the tilemap's pre-sized animation table (ids +1..`maxAnimations`; **0 is the static sentinel** — the tile's UV is +the fixed frame). The scene SETS an animation (`setAnimation`, the +setup / config path — the parallax `setLayer` precedent): the frame +COUNT (`frameCount`, domain [1, `kTileAnimMaxFrames` = 64]), the +documented TICK RATE (`frameTicks` — the SIMULATION ticks per frame, +≥ 1), and the tile SHEET's frame layout (the M2-SPRITE-03 +`SpriteFrameLayout`, texels — the sheet is its TIGHT sheet; a padded +atlas is the M3-ASSET-01 asset system's concern). + +The frame CYCLE is the engine's per-tick advance: the scene owner +calls `advanceAnimations()` **once per sim tick** (the sim phase — +ARCH-002: the rate is per SIM tick, never per render frame; the cycle +is frame-rate-independent). Every set animation advances one tick per +call, and its frame steps forward every `frameTicks` ticks: + +``` +frame(ticks) = (ticks / frameTicks) mod frameCount +``` -## `declareTo` — the static tile-quad batch path (S-5) +The cycle WRAPS (`frameCount − 1 → 0`); the cycled frames are the +sheet's FIRST `frameCount` frames, row-major (frame 0 = the sheet's +top-left, the M2-SPRITE-03 layout). A (re)set RESETS the phase (frame +0, tick 0). All tiles of one animation share its phase (per-tile phase +offsets are the M3 animation editor's control — the roadmap's +"data-driven" scope: the editor authors the defs, the engine cycles +them). The frame's UV is PRECOMPUTED at `setAnimation` (one +`spriteFrameUv` per frame — the setup allocation); the per-frame +declare path only READS the stored UV + frame (zero allocation, no +division — FR-2.2). The animation frame state is PRESENTATION state +(ARCH-009) — never part of replay state or the simulation state hash +(the parallax scroll-offset precedent). + +The declared quad's frame fields (both declare paths): the **static** +tile carries `DeclareOptions::uv` (default (0, 0, 1, 1) = the full +tile texture) and `frameIndex` 0; the **animated** tile carries its +animation's CURRENT frame UV (the precomputed rect) and +`frameIndex` = the current frame (the M2-SPRITE-03 hook — the +batcher carries it untouched, the M3 animation renders from it). + +## `declareTo` — the tile-quad batch path (S-5) The frame pipeline's cull/batch stage calls `declareTo(batcher, options)` once per frame per tilemap: one `SpriteItem` per tile of @@ -88,17 +145,15 @@ the requested grid, in the grid's **row-major order** (tileY outer, tileX fastest — the tile's grid position is a static tile's stable identity, the FR-1.2 entity-order analog; the insertion order the M2-SORT-01 stable sort turns into the (key, insertion position) total -order — RENDER-003). Each quad (the fixed-frame model, the header -preamble): +order — RENDER-003). Each quad (the header preamble): - `pos` = the tile's **center** `(gx + 0.5, gy + 0.5)` (the same point the table quantizes; the float conversion is exact — dyadic, `|gx| ≤ 32766`); - `scale` = (1, 1) — the unit quad spans the tile's world cell; -- `rotation` = 0; `uv` = the `DeclareOptions::uv` **fixed frame** - (default (0, 0, 1, 1) = the full tile texture; per-tile UV frames — - tile-sheet frames, animated frames — are the asset/animation steps, - M2-TILE-02 / M3-ASSET-01 — `SpriteItem.uv` already carries them); +- `rotation` = 0; `uv` = the tile's **current frame**: the static + `DeclareOptions::uv` fixed frame, or the animation's frame UV + (above); - `depthKey` = the table's key for the cell (**auto-depth** — the game never computes it, G-R11; `depthOverride` stays false); - `atlasId` = the tile's `textureId`; `materialId`/`blend` = the @@ -113,11 +168,39 @@ group keys, never of the tile count. Precondition: the batcher's frame window is open (a built frame's window is closed — call `beginFrame` first; the check is in -`declareTo`, first failure wins, nothing declared on failure). On a -stopped batcher the first add fails (`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). +`declareTo`, first failure wins, nothing declared on failure). An +animated tile whose animation slot is UNSET fails the declare (first +failure wins — nothing declared past it this frame; the slot's set is +the scene's setup responsibility). On a stopped batcher the first add +fails (`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). + +## The parallax tile layer (M2-TILE-02 — the M2-PAR-01 hook) + +A parallax layer with the `Tilemap` source (the M2-PAR-01 registry) +names a tilemap; the tilemap declares its quads UNDER that layer: +`declareTo(batcher, options, layers, layerId, cameraPos)`. Every tile +quad is TRANSLATED by the layer's `worldOffset(cameraPos)` (the +M2-PAR-01 formula (1) — the layer's factor/center/offset against the +camera's presentation position, world space, RENDER-006), and its +depth key is the M2-ISO-01 key of the **translated** tile center at +the tilemap's own `layer` (`table_.layer()` — one source of truth). +**Scene-setup convention:** the layer's def `depthLayer` must equal +the tilemap's `Options::layer` (the bg/mid/fg tilemaps get layer +values −2/−1/+1, [`parallax.md`](parallax.md)); the declaration uses +the tilemap's value. The table's keys are at the untranslated +positions, so the translated keys are computed per tile per frame +(O(tileCount) `isoDepthKey` calls, zero allocation, no GL — part of +the composite 50k budget; the qBase + qOffset derivation is the +documented upgrade path if a profile ever shows it matters). The +animated/static frame handling is the same as `declareTo`. + +Protocol (first failure wins, nothing declared, no log): a built +frame's closed window, an unset layer id, and a non-Tilemap-source +layer are `InvalidArgument` (the setup mispairing is the caller's); a +**disabled** layer declares NOTHING (OK, no log — the layer's +documented skip). ## Ownership, lifetime, threading @@ -125,55 +208,65 @@ full grid). the M2-GL-02 frame pipeline's cull/batch stage owns the render-side declaration. Move-only (CORE-009). - **Storage:** pre-sized at creation (the only allocations: `create` — - one table storage + one 8 B/tile data array — and `rebuild`'s - setup-path temporary covered-height span). -- **Phases (CONC-001):** writes (`setTile`, `rebuild`) in the sim - phase; reads (`tileAt`, `depthKeyAt`, `tileHeightAt`, `covers`, - `declareTo`) in the render phase; the phases do not overlap (the - table's contract). Not thread-safe by design. -- **ARCH-009:** the tile data is presentation-side (headless-buildable - — no GL anywhere in this API); the render phase consumes it - read-only (nothing in the batch path mutates the tilemap). + one table storage + one 8 B/tile data array + the animation slot + array — `rebuild`'s setup-path temporary, and `setAnimation`'s + per-frame-UV array — the setup/config path, never the per-frame + path). +- **Phases (CONC-001):** writes (`setTile`, `rebuild`, + `setAnimation`) in the sim/config phase; the per-tick + `advanceAnimations` in the sim phase (it advances the presentation + frame state); reads (`tileAt`, `depthKeyAt`, `tileHeightAt`, + `covers`, `declareTo`) in the render phase; the phases do not + overlap (the table's contract). Not thread-safe by design. +- **ARCH-009:** the tile data and the animation frame state are + presentation-side (headless-buildable — no GL anywhere in this + API); the render phase consumes them read-only. - **No silent failure (CORE-008):** every failure path returns the - `Status`/`Result` (the caller handles and logs); the happy update - paths log nothing (the Status is the failure channel — LOG-002 — - and they are budget-critical — LOG-003). + `Status`/`Result` (the caller handles and logs); the happy + update/advance/declare paths log nothing (the Status is the failure + channel — LOG-002 — and they are budget-critical — LOG-003). ## Performance (DOC-004) | Operation | Cost | Allocations | |---|---|---| -| `create` | O(covered cells) — the table's create + one data array | setup only | +| `create` | O(covered cells) — the table's create + data array + animation slots | setup only | | `setTile` | O(1) (the table's `setTile`, radius 0 + two stores) | none | | `rebuild` | O(covered cells) (the table's from-scratch rebuild) | one setup temporary | +| `setAnimation` | O(frameCount) — the frame-UV precomputation | one setup array | +| `advanceAnimations` | O(maxAnimations) — a counter per set animation | none | | `declareTo` | O(tileCount) — one O(1) batcher add per tile | none | +| `declareTo` (parallax) | O(tileCount) — the add + one `isoDepthKey` per tile | none | | `tileAt` / `depthKeyAt` / `tileHeightAt` / `covers` | O(1) | none | -- **No per-frame allocation** (FR-2.2): the `declareTo` loop allocates - nothing (the adds are O(1) operations over the batcher's - pre-allocated storage — the zero-allocation proof, 1000 frames × - 256 tiles, the tests). -- **No standalone budget entry**: 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). -- **Budget-critical:** the `setTile`/`rebuild`/`declareTo` paths make - no GL calls, no logging, no per-call allocation (the - `iso_depthkey_rebuild` budget's per-update cost — the table's - M2-ISO-02 budget — bounds the depth half of `setTile`). +- **No per-frame allocation** (FR-2.2): the per-tick `advanceAnimations` + and the per-frame `declareTo` loops (standalone + parallax) allocate + nothing (the frame UVs are precomputed at `setAnimation`, the slot + table at `create` — the zero-allocation proof, 1000 frames × + 256 tiles + 64 parallax tiles, the tests). +- **No standalone budget entry**: the per-frame declare + per-tick + advance 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). +- **Budget-critical:** the `setTile`/`rebuild`/`declareTo`/ + `advanceAnimations` paths make no GL calls, no logging, no per-call + allocation (the `iso_depthkey_rebuild` budget's per-update cost — + the table's M2-ISO-02 budget — bounds the depth half of `setTile`). - **Common traps:** declaring more than the batcher's frame budget (the frame overflow policy applies — drop the oldest + warn); recreating the tilemap per frame (the table IS the precomputation — FR-2.2 "not recomputed per frame"); reading `tileAt`/`depthKeyAt` on a tile outside the requested grid (UB — check `covers()` for untrusted input; the batch path reads only the requested grid by - construction). + construction); calling `advanceAnimations` per RENDER frame + instead of per sim tick (the animation speed changes — ARCH-002). ## Example (performant) and misuse warning ```cpp using laige::render::TileMap; using laige::render::TileData; +using laige::render::TileAnimationDef; using laige::sim::Fpx16_16; // Scene load (sim phase, setup): @@ -185,6 +278,16 @@ auto map = std::move(r).takeValue(); std::vector tiles = loadTiles(); // the game's format map.rebuild(tiles); // the heights go into the depth table (auto-depth) +// One tile animation (4 frames, every 2 sim ticks, a 2x2 grid of 16x16 frames): +TileAnimationDef anim; +anim.frameCount = 4; anim.frameTicks = 2; +anim.layout.frameWidth = 16; anim.layout.frameHeight = 16; +anim.layout.columns = 2; anim.layout.rows = 2; +map.setAnimation(1, anim); // the tiles with animationId 1 cycle from it + +// Per sim tick: +map.advanceAnimations(); // ONCE PER SIM TICK (ARCH-002) + // Per frame (the render thread's cull/batch stage): batcher.beginFrame(); map.declareTo(batcher, TileMap::DeclareOptions{}); @@ -196,5 +299,10 @@ batcher.build(); // the tile quads sort + group with the rest table's (auto-depth, G-R11); setting `depthOverride` on a tile sprite defeats the wiring (and counts/warns through the batcher). Don't declare on a built frame (`beginFrame` first — the precondition is -checked). The animationId is DATA ONLY in M2 (M2-TILE-02 drives the -frame cycle from it — the batch path never touches it). +checked). `animationId` is the engine's frame-cycle reference: 0 = +static (the fixed frame), 1..maxAnimations = the animation slot (the +batch path CONSUMES it — M2-TILE-02); the slot's set is the scene's +setup responsibility (an animated tile whose slot is unset fails the +declare). A parallax tile LAYER's def `depthLayer` must equal the +tilemap's `Options::layer` (the scene-setup convention — the +declaration uses the tilemap's). diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index 14e09b3..26c96ce 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -302,20 +302,22 @@ the DEPTH TEST DISABLED for the pass — the 2.5D depth is engine-owned and the draw submissions are observable (`SpriteDrawStats` / `SpriteDrawTotals` — the M2-SPRITE-04 profiler feed). -### 4.9 The tilemap: static tile quads on the depth table (M2-TILE-01) +### 4.9 The tilemap: tile quads on the depth table (M2-TILE-01/02) The scene's static tile grid is `laige::render::TileMap` (`laige/render/tilemap.h`) — the data + the auto-depth wiring of §4.5 -+ the batch path of §4.7: ++ the batch path of §4.7 + the tile animation and parallax tile layer +(M2-TILE-02): - **Chunked grid data** (FR-2.6): the requested grid of tiles, per - tile a `textureId` (the atlas reference) and an `animationId` (data - only in M2 — M2-TILE-02 cycles frames from it), in one flat - pre-sized array (8 B/tile). The tile's **height lives in the owned - `IsoDepthKeyTable` alone** (one source of truth): tile `(gx, gy)` is - the world cell `[gx, gx+1) × [gy, gy+1)` (§5.1's grid at `g = 1`), - so the tilemap is grid-locked by construction. + tile a `textureId` (the atlas reference) and an `animationId` (0 = + a STATIC tile; 1..maxAnimations = the animation slot — M2-TILE-02 + cycles the frame from it), in one flat pre-sized array (8 B/tile). + The tile's **height lives in the owned `IsoDepthKeyTable` alone** + (one source of truth): tile `(gx, gy)` is the world cell + `[gx, gx+1) × [gy, gy+1)` (§5.1's grid at `g = 1`), so the tilemap + is grid-locked by construction. - **Auto-depth** (FR-2.6): a tile's Y height is automatically reflected in its depth key — `setTile`/`rebuild` route the heights into the table (§4.5), which recomputes exactly the affected cell's @@ -325,17 +327,42 @@ The scene's static tile grid is - **The batch path** (S-5): `declareTo(batcher, options)` declares one `SpriteItem` per tile — the tile's **center** `(gx + 0.5, gy + 0.5)` (the same point the table quantizes), scale (1, 1) (the unit - quad spans the tile's cell), rotation 0, the **fixed-frame** UV - (default the full tile texture), the table's key (auto-depth), the - tile's texture as `atlasId` — in the grid's row-major order - (the tile's grid position is a static tile's stable identity, the - §4.6 insertion-order analog; RENDER-003). + quad spans the tile's cell), rotation 0, the tile's **current frame** + UV (the static tile's fixed frame — default the full tile texture — + or the animation's frame UV, M2-TILE-02) + `frameIndex` (the + M2-SPRITE-03 hook), the table's key (auto-depth), the tile's + texture as `atlasId` — in the grid's row-major order (the tile's + grid position is a static tile's stable identity, the §4.6 + insertion-order analog; RENDER-003). +- **Tile animation** (M2-TILE-02): an ANIMATION is a slot of the + tilemap's pre-sized animation table — the frame COUNT, the + documented TICK RATE (`frameTicks` — the sim ticks per frame), and + the tile sheet's frame layout (the M2-SPRITE-03 layout). The scene + owner calls `advanceAnimations()` ONCE PER SIM TICK (ARCH-002 — + per sim tick, never per render frame): every set animation's frame + steps every `frameTicks` ticks, wrapping at `frameCount` + (`frame(ticks) = (ticks / frameTicks) mod frameCount`). All tiles of + one animation share its phase (per-tile offsets are the M3 editor's + control). The frame UVs are precomputed at `setAnimation` (the setup + allocation); the per-frame path only reads them (no per-frame + allocation, FR-2.2). The frame state is presentation state + (ARCH-009). - **Bounded draw calls** (FR-2.1, RENDER-001): the batcher's (atlas, material, blend) grouping renders the 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); the count is a function of the distinct group keys, never of the tile count. +- **The parallax tile layer** (M2-TILE-02, the M2-PAR-01 hook): a + parallax layer with the `Tilemap` source declares the tilemap's + quads UNDER the layer (`declareTo` overload): every quad is + TRANSLATED by the layer's `worldOffset(cameraPos)` (§4.10), and its + key is the §4.1 key of the translated center at the TILEMAP's own + `Options::layer` (the scene-setup convention: the layer's def + `depthLayer` equals it — 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). ### 4.10 The parallax layers: the named background/midground/foreground model (M2-PAR-01) @@ -378,11 +405,15 @@ declared into the batcher of §4.7 (S-5) with the §4.1 keys: renders through the 2 x 2 wrap split (up to four quads — one SpriteItem carries one UV rect, no wrap); the quad's key is the §4.1 key of the quad's world center at the layer's `depthLayer`. -- **Tilemap-source layers** (the M2-TILE-02 hook): a layer's - `source = Tilemap` declares its tilemap's tiles with this layer's - `worldOffset` translation and `depthLayer` (the tilemap's - `Options::layer`, §4.9); in M2-PAR-01 they are DATA ONLY - (`declareTo` skips them). +- **Tilemap-source layers** (M2-TILE-02): a layer's `source = Tilemap` + names a tilemap; the tilemap declares its tiles UNDER the layer + (the `TileMap::declareTo` overload, §4.9) — the quads translated by + this layer's `worldOffset`, their keys at the tilemap's own + `Options::layer` (the scene-setup convention: this def's + `depthLayer` must equal it). This registry's own `declareTo` skips + Tilemap-source layers (no log — the game declares the tiles through + the tilemap's path, not this one); its `size`/`uv` fields are not + validated. ## 5. Conversion rules (the module boundaries, RENDER-006) @@ -394,7 +425,7 @@ declared into the batcher of §4.7 (S-5) with the §4.1 keys: | Screen → world (per mode) | picking, screen↔world transforms | `laige-render` (`ProjectionView`: `worldToScreen`, `screenToWorldRay`, `screenToWorld`, M2-PROJ-01; `screenToGrid` iso grid picking, M2-ISO-03) | **Shipped (M2-PROJ-01 + M2-ISO-03)** | | World → screen (render) | sim state → NDC → pixels | camera + preset matrix (M2-CAM-01/02, M2-GL-03), `ProjectionView::worldToScreen` (M2-PROJ-01), sprite draw (M2-SPRITE-02) | **Shipped** (matrices + camera core M2-CAM-01, iso presets + grid-snap M2-CAM-02, world→screen transform M2-PROJ-01, pixels: `SpriteRenderer::submit`'s offscreen instanced draw M2-SPRITE-02) | | Atlas frame → UV sub-rect | animation frame index + sheet layout → UV rect | `laige-render` (`spriteFrameUv`, M2-SPRITE-03) | **Shipped (M2-SPRITE-03)** | -| Tile grid → static tile quads | tile data + table keys → declared sprites (fixed-frame quads) | `laige-render` (`TileMap::declareTo`, M2-TILE-01) | **Shipped (M2-TILE-01)** | +| 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)** | ### 5.1 The isometric grid picking (M2-ISO-03) diff --git a/laige-api.json b/laige-api.json index eb8a71e..e310e82 100644 --- a/laige-api.json +++ b/laige-api.json @@ -715,56 +715,56 @@ {"name": "laige::render::isoMatrix", "kind": "function", "header": "src/laige-render/include/laige/render/matrices.h", "line": 252, "signature": "[[nodiscard]] Mat4 isoMatrix(IsoAxes axes) noexcept", "summary": "The general affine oblique isometric matrix (world -> NDC) — the \"custom shear\" preset (ADR 0005). See the header preamble for the screen-space form:", "budget": "O(1); no allocation.", "experimental": false}, {"name": "laige::render::isoDimetric2To1", "kind": "function", "header": "src/laige-render/include/laige/render/matrices.h", "line": 265, "signature": "[[nodiscard]] Mat4 isoDimetric2To1(float scale) noexcept", "summary": "The 2:1 dimetric preset (ADR 0005 — the engine default): affine, 45° azimuth, the two ground axes equally foreshortened, the vertical squashed to 1/2. Per-unit ground step screen delta (±2, 1)·scale (NDC, y up); one world tile maps to a 4*scale-wide by 2*scale-tall rhombus; one world height unit maps to `scale` screen units (half the tile height).", "budget": "O(1); no allocation.", "experimental": false}, {"name": "laige::render::isoTrueIso3060", "kind": "function", "header": "src/laige-render/include/laige/render/matrices.h", "line": 278, "signature": "[[nodiscard]] Mat4 isoTrueIso3060(float scale) noexcept", "summary": "The true 30°/60° isometric preset: a true orthographic axonometric projection at 45° azimuth and elevation arcsin(1/√3) ≈ 35.264° — all three axes equally foreshortened, the ground axes at 30° to screen horizontal (hence \"30°/60°\": the tile's edges sit at 30° and 60° to horizontal), the tile a √3:1 rhombus.", "budget": "O(1); no allocation; one sqrt.", "experimental": false}, - {"name": "laige::render::kParallaxLayerBackground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 293, "signature": "inline constexpr std::uint32_t kParallaxLayerBackground = 0", "summary": "The named-layer presets (the \"named layers\" of FR-2.3): the three canonical ids — the scene's custom layers use any id >= kParallaxLayerCustomBase (and any distinct depth-layer value — \"The render order\" above). \"Named\" = the stable id (the scene config, M3, maps the id to the scene-format name — no std::string in frame data, PRD §10.4).", "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxLayerMidground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 294, "signature": "inline constexpr std::uint32_t kParallaxLayerMidground = 1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxLayerForeground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 295, "signature": "inline constexpr std::uint32_t kParallaxLayerForeground = 2", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxLayerCustomBase", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 296, "signature": "inline constexpr std::uint32_t kParallaxLayerCustomBase = 3", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxDepthLayerBackground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 303, "signature": "inline constexpr std::int32_t kParallaxDepthLayerBackground = -2", "summary": "The M2-ISO-01 key layer values of the presets (the M2-ISO-01 reserved parallax domain: background layers sort BEFORE the ground, foreground layers AFTER it — \"layer dominates\" in the key): the standard three leave every other domain value free for custom layers (more negative = further back).", "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxDepthLayerMidground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 304, "signature": "inline constexpr std::int32_t kParallaxDepthLayerMidground = -1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxDepthLayerForeground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 305, "signature": "inline constexpr std::int32_t kParallaxDepthLayerForeground = 1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxLayersDefaultLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 310, "signature": "inline constexpr std::uint32_t kParallaxLayersDefaultLayers = 8", "summary": "The registry's slot domain (API-006): [1, kParallaxLayersMaxLayers]; the default covers the PRD reference scene's 3 preset layers + custom headroom. 64 slots x ~104 B = 6.7 KB (the setup path).", "budget": null, "experimental": false}, - {"name": "laige::render::kParallaxLayersMaxLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 311, "signature": "inline constexpr std::uint32_t kParallaxLayersMaxLayers = 64", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxScrollMode", "kind": "enum", "header": "src/laige-render/include/laige/render/parallax.h", "line": 316, "signature": "enum class ParallaxScrollMode : std::uint8_t", "summary": "Manual (the caller drives the UV offset via setUvOffset) | Auto (the engine advances it by scrollSpeed per frame — advanceScrolls).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxScrollMode::Manual", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 317, "signature": "Manual", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxScrollMode::Auto", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 318, "signature": "Auto", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxSource", "kind": "enum", "header": "src/laige-render/include/laige/render/parallax.h", "line": 324, "signature": "enum class ParallaxSource : std::uint8_t", "summary": "Image (a single texture — the atlasId + the atlas sub-rect `uv`) | Tilemap (a tilemap layer — the tilemapId; declared with M2-TILE-02, data only in M2-PAR-01).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxSource::Image", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 325, "signature": "Image", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxSource::Tilemap", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 326, "signature": "Tilemap", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef", "kind": "struct", "header": "src/laige-render/include/laige/render/parallax.h", "line": 335, "signature": "struct ParallaxLayerDef", "summary": "One named layer's definition (the value the game declares through setLayer; plain value — no GL, no allocation). All fields are validated (the setLayer validation order) except `source`, `materialId`, `atlasId`, `tilemapId`, `enabled` (opaque references/flags — the batcher and the asset system own their domains).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::id", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 338, "signature": "std::uint32_t id{}", "summary": "The layer id: [0, the registry's maxLayers); the presets are the kParallaxLayer* constants.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::source", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 341, "signature": "ParallaxSource source{ParallaxSource::Image}", "summary": "Image (the default — the single-texture layer) | Tilemap (M2-TILE-02).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::factor", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 344, "signature": "float factor{}", "summary": "The parallax factor (1): [0, 1]; 0 = fixed in world space (maximum parallax), 1 = fixed on screen (no parallax).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::center", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 348, "signature": "Vec2 center{}", "summary": "The reference camera position (1): the camera position at which the layer sits at exactly `offset` (default (0, 0) — the world origin).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 351, "signature": "Vec2 offset{}", "summary": "The layer's world-space offset at p = center (1) (default (0, 0) — the content origin).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::size", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 355, "signature": "Vec2 size{1.0f, 1.0f}", "summary": "The layer rectangle's extent in world units (Image source only — both > 0; ignored for the Tilemap source, which defines its own grid).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 359, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The layer's texture sub-rect in its atlas (Image source only — the M2-SPRITE-01 UV domain: 0 <= u0 < u1 <= 1, 0 <= v0 < v1 <= 1; default the full texture).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 363, "signature": "std::uint32_t atlasId{}", "summary": "The layer's texture (atlas) reference (Image source — the batch group key's field 0; the scene's atlas-id convention, \"The render order\", orders the layer's group).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::tilemapId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 365, "signature": "std::uint32_t tilemapId{}", "summary": "The tilemap's registry id (Tilemap source only — M2-TILE-02).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 368, "signature": "std::uint32_t materialId{}", "summary": "The material reference (0 = the default material — the group key's field 1).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 370, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every layer quad (the group key's field 3).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::depthLayer", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 375, "signature": "std::int32_t depthLayer{}", "summary": "The M2-ISO-01 key layer value of the layer's quads: the presets are the kParallaxDepthLayer* constants; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] (more negative = further back).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::scrollMode", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 377, "signature": "ParallaxScrollMode scrollMode{ParallaxScrollMode::Manual}", "summary": "Manual (the default) | Auto.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::scrollSpeed", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 381, "signature": "Vec2 scrollSpeed{}", "summary": "The auto-mode advance: UV units PER FRAME per axis (any finite value — negative scrolls the opposite way; ignored in Manual mode, but still validated finite).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayerDef::enabled", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 384, "signature": "bool enabled{true}", "summary": "A disabled layer is skipped by declareTo (its state — including the scroll offset — still evolves through advanceScrolls).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers", "kind": "class", "header": "src/laige-render/include/laige/render/parallax.h", "line": 406, "signature": "template class ParallaxLayers", "summary": "The scene's parallax layer registry: the bounded slot table + the per-frame protocol (the header preamble). Templated over the SimMath backends (the M2-TILE-01 pattern): the declared quads' depth keys are the scene's backend's M2-ISO-01 keys (the cross-backend consistency contract). Header-only — pure value math + batcher bookkeeping, no GL, no allocation in the per-frame path.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/parallax.h", "line": 416, "signature": "struct Options", "summary": "The create options (API-006): `maxLayers` in [1, kParallaxLayersMaxLayers] (default kParallaxLayersDefaultLayers = 8).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::Options::maxLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 417, "signature": "std::uint32_t maxLayers{kParallaxLayersDefaultLayers}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::create", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 426, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "Creates the registry (setup path — the only allocation: the pre-sized slot table). maxLayers outside [1, kParallaxLayersMaxLayers] -> InvalidArgument (no log — the M2-SPRITE-01 create precedent). The stopped state (failed create / default) follows the RenderThread precedent: `valid()` false, every operation InvalidArgument, no log.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::ParallaxLayers", "kind": "constructor", "header": "src/laige-render/include/laige/render/parallax.h", "line": 433, "signature": "ParallaxLayers() noexcept = default", "summary": "The stopped state (default / failed create): `valid()` false, every operation InvalidArgument, no log — the RenderThread stopped-state precedent. Nothing owned (the slot table is null).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::valid", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 436, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the registry is live (create succeeded).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::maxLayers", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 439, "signature": "[[nodiscard]] std::uint32_t maxLayers() const noexcept", "summary": "The registry's slot domain (0 in the stopped state).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::layerCount", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 445, "signature": "[[nodiscard]] std::uint32_t layerCount() const noexcept", "summary": "The count of SET slots (setup-path accounting — changes only on setLayer).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::has", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 450, "signature": "[[nodiscard]] bool has(std::uint32_t id) const noexcept", "summary": "True iff `id` is a live, SET layer.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::layerAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 457, "signature": "[[nodiscard]] const ParallaxLayerDef& layerAt(std::uint32_t id) const noexcept", "summary": "The set layer's definition. Precondition: has(id) (asserted — check when the id comes from untrusted input).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::uvOffsetAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 465, "signature": "[[nodiscard]] Vec2 uvOffsetAt(std::uint32_t id) const noexcept", "summary": "The layer's CURRENT UV offset (in [0, 1)^2 — (0, 0) for an unset slot). Precondition: has(id) (asserted).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::worldOffsetAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 475, "signature": "[[nodiscard]] Vec2 worldOffsetAt(std::uint32_t id, Vec2 cameraPos) const noexcept", "summary": "The layer's world-space offset at camera position `cameraPos` (the camera's ground-plane (x, y)) — the EXACT formula (1): factor * (cameraPos - center) + offset. Pure O(1); no allocation, no logging, no GL. Precondition: has(id) (checked-free read — the M2-TILE-01 tileAt pattern; assert in debug).", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::setLayer", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 490, "signature": "[[nodiscard]] laige::Status setLayer(const ParallaxLayerDef& def) noexcept", "summary": "Registers or REPLACES the layer (setup / config path — the scene config's hot-reload of non-simulation config, FR-1.5). Validates the def (the documented order, first failure wins); a rejection leaves the slot unchanged (InvalidArgument + one rate-limited parallax/layer_invalid warn — fields layer, field). A successful (re)set RESETS the layer's UV offset to (0, 0) (the scroll restarts — the def carries no scroll state). O(1); no GL.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::setUvOffset", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 498, "signature": "[[nodiscard]] laige::Status setUvOffset(std::uint32_t id, Vec2 uvOffset) noexcept", "summary": "Sets the layer's current UV offset (any finite value — wrapped to [0, 1)^2). Works in BOTH scroll modes (a manual nudge on an Auto layer composes with the per-frame advance). Unknown layer id -> InvalidArgument (no log — precondition); non-finite value -> InvalidArgument + one rate-limited parallax/uv_offset_invalid warn; a rejection leaves the offset unchanged. O(1); no GL.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::advanceScrolls", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 508, "signature": "void advanceScrolls() noexcept", "summary": "Advances the AUTO layers' UV offsets by their scrollSpeed (the exact wrap, the header preamble) — ONCE PER FRAME, before the declarations (the caller paces it at the frame's pace; the auto speed is PER FRAME — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent). Manual layers are untouched. O(layers); no allocation, no logging, no GL. No-op in the stopped state.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 523, "signature": "[[nodiscard]] laige::Status declareTo(SpriteBatcher& batcher, Vec2 cameraPos) const noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares every SET, ENABLED Image-source layer's wrap quads into `batcher` at camera position `cameraPos` (the 2 x 2 split, the header preamble; the quads carry the def's group-key fields, rotation 0, the full-default tint, and the M2-ISO-01 key of the quad's world center at the def's depthLayer — engine-owned, G-R11). Tilemap-source layers are skipped (the M2-TILE-02 hook — no log). Declaration order: ascending layer id, then the fixed quad order (RENDER-003). Precondition: the batcher's window is open (a built frame -> InvalidArgument, nothing declared); the stopped registry -> InvalidArgument. The batcher's own failures (BudgetExhausted) propagate from the first failed add. O(layers x 4) batcher adds; NO allocation, no logging, no GL.", "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::LayerSlot::def", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 531, "signature": "ParallaxLayerDef def", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::LayerSlot::uvOffset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 532, "signature": "Vec2 uvOffset{0.0f, 0.0f}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::ParallaxLayers::LayerSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 533, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerBackground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 298, "signature": "inline constexpr std::uint32_t kParallaxLayerBackground = 0", "summary": "The named-layer presets (the \"named layers\" of FR-2.3): the three canonical ids — the scene's custom layers use any id >= kParallaxLayerCustomBase (and any distinct depth-layer value — \"The render order\" above). \"Named\" = the stable id (the scene config, M3, maps the id to the scene-format name — no std::string in frame data, PRD §10.4).", "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerMidground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 299, "signature": "inline constexpr std::uint32_t kParallaxLayerMidground = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerForeground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 300, "signature": "inline constexpr std::uint32_t kParallaxLayerForeground = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayerCustomBase", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 301, "signature": "inline constexpr std::uint32_t kParallaxLayerCustomBase = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxDepthLayerBackground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 308, "signature": "inline constexpr std::int32_t kParallaxDepthLayerBackground = -2", "summary": "The M2-ISO-01 key layer values of the presets (the M2-ISO-01 reserved parallax domain: background layers sort BEFORE the ground, foreground layers AFTER it — \"layer dominates\" in the key): the standard three leave every other domain value free for custom layers (more negative = further back).", "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxDepthLayerMidground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 309, "signature": "inline constexpr std::int32_t kParallaxDepthLayerMidground = -1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxDepthLayerForeground", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 310, "signature": "inline constexpr std::int32_t kParallaxDepthLayerForeground = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayersDefaultLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 315, "signature": "inline constexpr std::uint32_t kParallaxLayersDefaultLayers = 8", "summary": "The registry's slot domain (API-006): [1, kParallaxLayersMaxLayers]; the default covers the PRD reference scene's 3 preset layers + custom headroom. 64 slots x ~104 B = 6.7 KB (the setup path).", "budget": null, "experimental": false}, + {"name": "laige::render::kParallaxLayersMaxLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 316, "signature": "inline constexpr std::uint32_t kParallaxLayersMaxLayers = 64", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxScrollMode", "kind": "enum", "header": "src/laige-render/include/laige/render/parallax.h", "line": 321, "signature": "enum class ParallaxScrollMode : std::uint8_t", "summary": "Manual (the caller drives the UV offset via setUvOffset) | Auto (the engine advances it by scrollSpeed per frame — advanceScrolls).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxScrollMode::Manual", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 322, "signature": "Manual", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxScrollMode::Auto", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 323, "signature": "Auto", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxSource", "kind": "enum", "header": "src/laige-render/include/laige/render/parallax.h", "line": 329, "signature": "enum class ParallaxSource : std::uint8_t", "summary": "Image (a single texture — the atlasId + the atlas sub-rect `uv`) | Tilemap (a tilemap layer — the tilemapId; declared with M2-TILE-02, data only in M2-PAR-01).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxSource::Image", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 330, "signature": "Image", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxSource::Tilemap", "kind": "enumerator", "header": "src/laige-render/include/laige/render/parallax.h", "line": 331, "signature": "Tilemap", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef", "kind": "struct", "header": "src/laige-render/include/laige/render/parallax.h", "line": 340, "signature": "struct ParallaxLayerDef", "summary": "One named layer's definition (the value the game declares through setLayer; plain value — no GL, no allocation). All fields are validated (the setLayer validation order) except `source`, `materialId`, `atlasId`, `tilemapId`, `enabled` (opaque references/flags — the batcher and the asset system own their domains).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::id", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 343, "signature": "std::uint32_t id{}", "summary": "The layer id: [0, the registry's maxLayers); the presets are the kParallaxLayer* constants.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::source", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 346, "signature": "ParallaxSource source{ParallaxSource::Image}", "summary": "Image (the default — the single-texture layer) | Tilemap (M2-TILE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::factor", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 349, "signature": "float factor{}", "summary": "The parallax factor (1): [0, 1]; 0 = fixed in world space (maximum parallax), 1 = fixed on screen (no parallax).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::center", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 353, "signature": "Vec2 center{}", "summary": "The reference camera position (1): the camera position at which the layer sits at exactly `offset` (default (0, 0) — the world origin).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 356, "signature": "Vec2 offset{}", "summary": "The layer's world-space offset at p = center (1) (default (0, 0) — the content origin).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::size", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 360, "signature": "Vec2 size{1.0f, 1.0f}", "summary": "The layer rectangle's extent in world units (Image source only — both > 0; ignored for the Tilemap source, which defines its own grid).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 364, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The layer's texture sub-rect in its atlas (Image source only — the M2-SPRITE-01 UV domain: 0 <= u0 < u1 <= 1, 0 <= v0 < v1 <= 1; default the full texture).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 368, "signature": "std::uint32_t atlasId{}", "summary": "The layer's texture (atlas) reference (Image source — the batch group key's field 0; the scene's atlas-id convention, \"The render order\", orders the layer's group).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::tilemapId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 370, "signature": "std::uint32_t tilemapId{}", "summary": "The tilemap's registry id (Tilemap source only — M2-TILE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 373, "signature": "std::uint32_t materialId{}", "summary": "The material reference (0 = the default material — the group key's field 1).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 375, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every layer quad (the group key's field 3).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::depthLayer", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 380, "signature": "std::int32_t depthLayer{}", "summary": "The M2-ISO-01 key layer value of the layer's quads: the presets are the kParallaxDepthLayer* constants; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] (more negative = further back).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::scrollMode", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 382, "signature": "ParallaxScrollMode scrollMode{ParallaxScrollMode::Manual}", "summary": "Manual (the default) | Auto.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::scrollSpeed", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 386, "signature": "Vec2 scrollSpeed{}", "summary": "The auto-mode advance: UV units PER FRAME per axis (any finite value — negative scrolls the opposite way; ignored in Manual mode, but still validated finite).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayerDef::enabled", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 389, "signature": "bool enabled{true}", "summary": "A disabled layer is skipped by declareTo (its state — including the scroll offset — still evolves through advanceScrolls).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers", "kind": "class", "header": "src/laige-render/include/laige/render/parallax.h", "line": 411, "signature": "template class ParallaxLayers", "summary": "The scene's parallax layer registry: the bounded slot table + the per-frame protocol (the header preamble). Templated over the SimMath backends (the M2-TILE-01 pattern): the declared quads' depth keys are the scene's backend's M2-ISO-01 keys (the cross-backend consistency contract). Header-only — pure value math + batcher bookkeeping, no GL, no allocation in the per-frame path.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/parallax.h", "line": 421, "signature": "struct Options", "summary": "The create options (API-006): `maxLayers` in [1, kParallaxLayersMaxLayers] (default kParallaxLayersDefaultLayers = 8).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::Options::maxLayers", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 422, "signature": "std::uint32_t maxLayers{kParallaxLayersDefaultLayers}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::create", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 431, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "Creates the registry (setup path — the only allocation: the pre-sized slot table). maxLayers outside [1, kParallaxLayersMaxLayers] -> InvalidArgument (no log — the M2-SPRITE-01 create precedent). The stopped state (failed create / default) follows the RenderThread precedent: `valid()` false, every operation InvalidArgument, no log.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::ParallaxLayers", "kind": "constructor", "header": "src/laige-render/include/laige/render/parallax.h", "line": 438, "signature": "ParallaxLayers() noexcept = default", "summary": "The stopped state (default / failed create): `valid()` false, every operation InvalidArgument, no log — the RenderThread stopped-state precedent. Nothing owned (the slot table is null).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::valid", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 441, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the registry is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::maxLayers", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 444, "signature": "[[nodiscard]] std::uint32_t maxLayers() const noexcept", "summary": "The registry's slot domain (0 in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::layerCount", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 450, "signature": "[[nodiscard]] std::uint32_t layerCount() const noexcept", "summary": "The count of SET slots (setup-path accounting — changes only on setLayer).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::has", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 455, "signature": "[[nodiscard]] bool has(std::uint32_t id) const noexcept", "summary": "True iff `id` is a live, SET layer.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::layerAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 462, "signature": "[[nodiscard]] const ParallaxLayerDef& layerAt(std::uint32_t id) const noexcept", "summary": "The set layer's definition. Precondition: has(id) (asserted — check when the id comes from untrusted input).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::uvOffsetAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 470, "signature": "[[nodiscard]] Vec2 uvOffsetAt(std::uint32_t id) const noexcept", "summary": "The layer's CURRENT UV offset (in [0, 1)^2 — (0, 0) for an unset slot). Precondition: has(id) (asserted).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::worldOffsetAt", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 480, "signature": "[[nodiscard]] Vec2 worldOffsetAt(std::uint32_t id, Vec2 cameraPos) const noexcept", "summary": "The layer's world-space offset at camera position `cameraPos` (the camera's ground-plane (x, y)) — the EXACT formula (1): factor * (cameraPos - center) + offset. Pure O(1); no allocation, no logging, no GL. Precondition: has(id) (checked-free read — the M2-TILE-01 tileAt pattern; assert in debug).", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::setLayer", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 495, "signature": "[[nodiscard]] laige::Status setLayer(const ParallaxLayerDef& def) noexcept", "summary": "Registers or REPLACES the layer (setup / config path — the scene config's hot-reload of non-simulation config, FR-1.5). Validates the def (the documented order, first failure wins); a rejection leaves the slot unchanged (InvalidArgument + one rate-limited parallax/layer_invalid warn — fields layer, field). A successful (re)set RESETS the layer's UV offset to (0, 0) (the scroll restarts — the def carries no scroll state). O(1); no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::setUvOffset", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 503, "signature": "[[nodiscard]] laige::Status setUvOffset(std::uint32_t id, Vec2 uvOffset) noexcept", "summary": "Sets the layer's current UV offset (any finite value — wrapped to [0, 1)^2). Works in BOTH scroll modes (a manual nudge on an Auto layer composes with the per-frame advance). Unknown layer id -> InvalidArgument (no log — precondition); non-finite value -> InvalidArgument + one rate-limited parallax/uv_offset_invalid warn; a rejection leaves the offset unchanged. O(1); no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::advanceScrolls", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 513, "signature": "void advanceScrolls() noexcept", "summary": "Advances the AUTO layers' UV offsets by their scrollSpeed (the exact wrap, the header preamble) — ONCE PER FRAME, before the declarations (the caller paces it at the frame's pace; the auto speed is PER FRAME — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent). Manual layers are untouched. O(layers); no allocation, no logging, no GL. No-op in the stopped state.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/parallax.h", "line": 528, "signature": "[[nodiscard]] laige::Status declareTo(SpriteBatcher& batcher, Vec2 cameraPos) const noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares every SET, ENABLED Image-source layer's wrap quads into `batcher` at camera position `cameraPos` (the 2 x 2 split, the header preamble; the quads carry the def's group-key fields, rotation 0, the full-default tint, and the M2-ISO-01 key of the quad's world center at the def's depthLayer — engine-owned, G-R11). Tilemap-source layers are skipped (the M2-TILE-02 hook — no log). Declaration order: ascending layer id, then the fixed quad order (RENDER-003). Precondition: the batcher's window is open (a built frame -> InvalidArgument, nothing declared); the stopped registry -> InvalidArgument. The batcher's own failures (BudgetExhausted) propagate from the first failed add. O(layers x 4) batcher adds; NO allocation, no logging, no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::LayerSlot::def", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 536, "signature": "ParallaxLayerDef def", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::LayerSlot::uvOffset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 537, "signature": "Vec2 uvOffset{0.0f, 0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::ParallaxLayers::LayerSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 538, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode", "kind": "enum", "header": "src/laige-render/include/laige/render/projection.h", "line": 200, "signature": "enum class ProjectionMode", "summary": "The per-scene/view projection mode (FR-2.5). iso is the engine default (ADR 0005, PRD v0.2) — the default member of ProjectionView.", "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode::Iso", "kind": "enumerator", "header": "src/laige-render/include/laige/render/projection.h", "line": 201, "signature": "Iso", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode::SideView", "kind": "enumerator", "header": "src/laige-render/include/laige/render/projection.h", "line": 202, "signature": "SideView", "summary": null, "budget": null, "experimental": false}, @@ -894,38 +894,65 @@ {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 379, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 382, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteRenderer::textureMemoryBytes", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 391, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::TileData", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 185, "signature": "struct TileData", "summary": "One tile's data (the value the game writes on load/edit and reads back; plain value — no GL, no allocation).", "budget": null, "experimental": false}, - {"name": "laige::render::TileData::textureId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 187, "signature": "std::uint32_t textureId{}", "summary": "The texture/atlas reference (the game assigns it — M3-ASSET-01).", "budget": null, "experimental": false}, - {"name": "laige::render::TileData::height", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 190, "signature": "std::int32_t height{}", "summary": "The tile's step height in world units (the standing surface's elevation — the auto-depth source, the M2-ISO-01 `z` input).", "budget": null, "experimental": false}, - {"name": "laige::render::TileData::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 193, "signature": "std::uint32_t animationId{}", "summary": "The animation-table reference (DATA ONLY in M2 — M2-TILE-02 cycles frames from it; the batch path never touches it).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap", "kind": "class", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 204, "signature": "template class TileMap", "summary": "The tile grid + its depth table (M2-ISO-02) + the batch path.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::Options", "kind": "alias", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 221, "signature": "using Options = IsoDepthKeyTable::Options", "summary": "The create options — the table's options themselves (one type, passed through to the table's create — the validation is the table's, first failure wins, InvalidArgument): `originTileX/Y` (default 0), `widthTiles`/`heightTiles` (required >= 1), `chunkTiles` (default `kIsoDepthTableChunkTiles` = 16, a power of two >= 1), `maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own tilemap with its layer value, M2-PAR-01 — the values are documented in laige/render/parallax.h).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 225, "signature": "struct DeclareOptions", "summary": "The per-frame declare options (the fixed-frame quad model, above): the group-key fields for every tile sprite + the fixed UV frame.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 227, "signature": "std::uint32_t materialId{0}", "summary": "The material reference (0 = the default material).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 230, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every tile sprite (the group key's third field).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::DeclareOptions::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 233, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The fixed frame: every tile quad's UV sub-rect (default (0, 0, 1, 1) = the full tile texture).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::create", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 242, "signature": "[[nodiscard]] static Result create(const Options& options) noexcept", "summary": "Creates the tilemap (setup path — the only allocations besides rebuild's setup temporary): validates the options (the table's create — first failure wins, InvalidArgument), pre-sizes the table's initial grid chunks, and pre-sizes the per-tile data array (8 B/tile, the requested grid). Flat/empty init: every tile is {textureId 0, height 0, animationId 0} — no tile is set.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::setTile", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 257, "signature": "[[nodiscard]] Status setTile(std::int32_t tileX, std::int32_t tileY, std::uint32_t textureId, std::int32_t height, std::uint32_t animationId) noexcept", "summary": "The per-tile edit (sim phase). Stores the tile's textureId and animationId and routes the height into the table's setTile — the AUTO-DEPTH wiring: the table stores the height and recomputes exactly that cell's key (the M2-ISO-02 incremental update, radius 0; the other cells' keys are untouched). O(1), zero allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::rebuild", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 277, "signature": "[[nodiscard]] Status rebuild(std::span tiles) noexcept", "summary": "Scene load (setup path — the only place besides create that allocates: one temporary covered-height span for the table's from-scratch rebuild). Stores the whole tile grid: the per-tile data and the heights through the table's rebuild (every key through the full M2-ISO-01 function). `tiles` covers the REQUESTED grid — `widthTiles() * heightTiles()` entries, row-major, tileX fastest.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 295, "signature": "[[nodiscard]] Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options) noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares this tilemap's static tile quads into the batcher's current frame window — one SpriteItem per tile of the REQUESTED grid, in the grid's row-major order (tileY outer, tileX fastest — the preamble's quad model: the tile's center, the table's key (auto-depth), the tile's texture, the fixed frame). O(tileCount), zero allocation, no GL, no logging — the adds are O(1) batcher operations.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::tileAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 300, "signature": "[[nodiscard]] TileData tileAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "The read path (render phase, O(1), zero allocation, no GL):", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::depthKeyAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 302, "signature": "[[nodiscard]] std::uint32_t depthKeyAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::tileHeightAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 304, "signature": "[[nodiscard]] std::int32_t tileHeightAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::covers", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 310, "signature": "[[nodiscard]] bool covers(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "Whether the tile lies in the REQUESTED grid (the batch path and the tileAt/depthKeyAt/tileHeightAt preconditions). The table's covered region may extend past the requested grid (its chunk- aligned superset) — those cells are table cells, not tiles.", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::originTileX", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 314, "signature": "std::int32_t originTileX() const noexcept", "summary": "Introspection (O(1); the table-backed values forward the table):", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::originTileY", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 315, "signature": "std::int32_t originTileY() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::widthTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 316, "signature": "std::int32_t widthTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::heightTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 317, "signature": "std::int32_t heightTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::layer", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 318, "signature": "std::int32_t layer() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::chunkTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 319, "signature": "std::int32_t chunkTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::tileCount", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 321, "signature": "std::size_t tileCount() const noexcept", "summary": "The requested grid's tile count (widthTiles * heightTiles).", "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 326, "signature": "TileMap(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 327, "signature": "TileMap& operator=(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 328, "signature": "TileMap(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 329, "signature": "TileMap& operator=(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::~TileMap", "kind": "destructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 330, "signature": "~TileMap() = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileSlot::textureId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 341, "signature": "std::uint32_t textureId{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::TileMap::TileSlot::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 342, "signature": "std::uint32_t animationId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kTileAnimMaxFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 311, "signature": "inline constexpr std::uint32_t kTileAnimMaxFrames = 64", "summary": "The frame budget of ONE tile animation (M2-TILE-02): the practical tile-sheet frame count (a 16 x 4 sheet); a longer cycle is the game's split into two animations (the M3 animation editor owns the authoring). The per-animation setup storage is frameCount x sizeof(SpriteUvRect) (1 KB at the cap).", "budget": null, "experimental": false}, + {"name": "laige::render::kTileMapDefaultAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 318, "signature": "inline constexpr std::uint32_t kTileMapDefaultAnimations = 8", "summary": "The tilemap's animation slot count: the default — the reference scene's handful of animated tiles + headroom for custom (the M2-SCENE-01 precedent, the parallax default-layers pattern) — and the cap: the practical tileset's full animation catalog (256 slots ~= 14 KB of setup storage).", "budget": null, "experimental": false}, + {"name": "laige::render::kTileMapMaxAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 319, "signature": "inline constexpr std::uint32_t kTileMapMaxAnimations = 256", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileData", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 323, "signature": "struct TileData", "summary": "One tile's data (the value the game writes on load/edit and reads back; plain value — no GL, no allocation).", "budget": null, "experimental": false}, + {"name": "laige::render::TileData::textureId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 325, "signature": "std::uint32_t textureId{}", "summary": "The texture/atlas reference (the game assigns it — M3-ASSET-01).", "budget": null, "experimental": false}, + {"name": "laige::render::TileData::height", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 328, "signature": "std::int32_t height{}", "summary": "The tile's step height in world units (the standing surface's elevation — the auto-depth source, the M2-ISO-01 `z` input).", "budget": null, "experimental": false}, + {"name": "laige::render::TileData::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 333, "signature": "std::uint32_t animationId{}", "summary": "The animation reference (M2-TILE-02 — the batch path consumes it): 0 = a STATIC tile (the fixed frame); 1..maxAnimations = the animation slot the tile cycles from (its sheet texture is the tile's own textureId — the game assigns the matching texture).", "budget": null, "experimental": false}, + {"name": "laige::render::TileAnimationDef", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 340, "signature": "struct TileAnimationDef", "summary": "One tile animation's definition (M2-TILE-02 — the data-driven frame cycle; plain value — no GL, no allocation).", "budget": null, "experimental": false}, + {"name": "laige::render::TileAnimationDef::frameCount", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 344, "signature": "std::uint32_t frameCount{}", "summary": "The frames the cycle plays: the sheet's FIRST frameCount frames, row-major (frame 0 = the sheet's top-left); the domain [1, kTileAnimMaxFrames] (and <= the sheet's frame count).", "budget": null, "experimental": false}, + {"name": "laige::render::TileAnimationDef::frameTicks", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 348, "signature": "std::uint32_t frameTicks{}", "summary": "The documented rate: the SIMULATION ticks per frame (>= 1) — the frame advances every frameTicks calls to advanceAnimations (ARCH-002: per sim tick, never per render frame).", "budget": null, "experimental": false}, + {"name": "laige::render::TileAnimationDef::layout", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 351, "signature": "SpriteFrameLayout layout{}", "summary": "The tile sheet's frame layout (texels — the M2-SPRITE-03 layout: frameWidth/Height, columns/rows, frameSpacing, sheetBorder).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap", "kind": "class", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 361, "signature": "template class TileMap", "summary": "The tile grid + its depth table (M2-ISO-02) + the animation slots (M2-TILE-02) + the batch path.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 380, "signature": "struct Options", "summary": "The create options: the table's grid options + the animation slot count (first failure wins — `maxAnimations` first, then the table's create validates the grid options — all InvalidArgument): `originTileX/Y` (default 0), `widthTiles`/`heightTiles` (required >= 1), `chunkTiles` (default `kIsoDepthTableChunkTiles` = 16, a power of two >= 1), `maxChunks` (default `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own tilemap with its layer value, M2-PAR-01 — the values are documented in laige/render/parallax.h), `maxAnimations` (default `kTileMapDefaultAnimations` = 8; the domain [1, `kTileMapMaxAnimations` = 256]).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::originTileX", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 381, "signature": "std::int32_t originTileX = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::originTileY", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 382, "signature": "std::int32_t originTileY = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::widthTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 383, "signature": "std::int32_t widthTiles = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::heightTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 384, "signature": "std::int32_t heightTiles = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::chunkTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 385, "signature": "std::int32_t chunkTiles = kIsoDepthTableChunkTiles", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::maxChunks", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 386, "signature": "std::int32_t maxChunks = kIsoDepthTableDefaultMaxChunks", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::layer", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 387, "signature": "std::int32_t layer = kIsoDepthGroundLayer", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::Options::maxAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 388, "signature": "std::uint32_t maxAnimations = kTileMapDefaultAnimations", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 394, "signature": "struct DeclareOptions", "summary": "The per-frame declare options (the fixed-frame quad model, above): the group-key fields for every tile sprite + the static tiles' fixed UV frame.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 396, "signature": "std::uint32_t materialId{0}", "summary": "The material reference (0 = the default material).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 399, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state of every tile sprite (the group key's third field).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 404, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The STATIC tiles' fixed frame: every static tile quad's UV sub-rect (default (0, 0, 1, 1) = the full tile texture). ANIMATED tiles carry their animation's frame UV (the layout) — this field is ignored for them.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::create", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 416, "signature": "[[nodiscard]] static Result create(const Options& options) noexcept", "summary": "Creates the tilemap (setup path — the allocations: the table's storage + the per-tile data array + the animation slot array): validates `maxAnimations` (first), then the table's create validates the grid options (first failure wins, InvalidArgument), pre-sizes the table's initial grid chunks, the per-tile data array (8 B/tile, the requested grid), and the animation slot array (unset slots). Flat/empty init: every tile is {textureId 0, height 0, animationId 0} — no tile is set, no animation is set.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::setTile", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 432, "signature": "[[nodiscard]] Status setTile(std::int32_t tileX, std::int32_t tileY, std::uint32_t textureId, std::int32_t height, std::uint32_t animationId) noexcept", "summary": "The per-tile edit (sim phase). Stores the tile's textureId and animationId and routes the height into the table's setTile — the AUTO-DEPTH wiring: the table stores the height and recomputes exactly that cell's key (the M2-ISO-02 incremental update, radius 0; the other cells' keys are untouched). O(1), zero allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::rebuild", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 453, "signature": "[[nodiscard]] Status rebuild(std::span tiles) noexcept", "summary": "Scene load (setup path — the only place besides create that allocates: one temporary covered-height span for the table's from-scratch rebuild). Stores the whole tile grid: the per-tile data and the heights through the table's rebuild (every key through the full M2-ISO-01 function). `tiles` covers the REQUESTED grid — `widthTiles() * heightTiles()` entries, row-major, tileX fastest.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::setAnimation", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 470, "signature": "[[nodiscard]] Status setAnimation(std::uint32_t id, const TileAnimationDef& def) noexcept", "summary": "Sets or REPLACES the animation slot `id` (setup / config path — the scene config's hot-reload, the parallax setLayer precedent): the frame count, the tick rate, and the sheet layout (the header's animation section). A success RESETS the slot's phase (frame 0, tick 0) and replaces the precomputed frame-UV array (one setup allocation — the only per-animation allocation).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::advanceAnimations", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 480, "signature": "void advanceAnimations() noexcept", "summary": "The sim phase's per-tick call (ONCE PER SIM TICK — the header's animation section): advances every SET animation's tick, and steps its frame forward every frameTicks ticks (the documented rate). O(maxAnimations), zero allocation, no logging, no GL. Unset slots are skipped (no state). The presentation frame state is never part of the simulation state hash (ARCH-009).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 501, "signature": "[[nodiscard]] Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options) noexcept", "summary": "The render path (the frame pipeline's cull/batch stage): declares this tilemap's static tile quads into the batcher's current frame window — one SpriteItem per tile of the REQUESTED grid, in the grid's row-major order (tileY outer, tileX fastest — the preamble's quad model: the tile's center, the table's key (auto-depth), the tile's texture, the tile's current frame — the static fixed frame or the animation's frame UV). O(tileCount), zero allocation, no GL, no logging — the adds are O(1) batcher operations.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::declareTo", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 523, "signature": "[[nodiscard]] Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options, const ParallaxLayers& layers, std::uint32_t layerId, Vec2 cameraPos) noexcept", "summary": "The parallax tile layer declare path (M2-TILE-02 — the M2-PAR-01 Tilemap-source hook): declares this tilemap's tile quads TRANSLATED by the parallax layer `layerId`'s worldOffset (the M2-PAR-01 formula (1) — the layer's factor/center/offset against `cameraPos`, the camera's presentation position) — the header's parallax section: the depth key is the M2-ISO-01 key of the TRANSLATED tile center at the tilemap's own `layer` (the scene-setup convention: the layer's def `depthLayer` equals the tilemap's `Options::layer`; the declaration uses the tilemap's value — one source of truth). O(tileCount), zero allocation, no GL, no logging.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::tileAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 529, "signature": "[[nodiscard]] TileData tileAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "The read path (render phase, O(1), zero allocation, no GL):", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::depthKeyAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 531, "signature": "[[nodiscard]] std::uint32_t depthKeyAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::tileHeightAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 533, "signature": "[[nodiscard]] std::int32_t tileHeightAt(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::covers", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 539, "signature": "[[nodiscard]] bool covers(std::int32_t tileX, std::int32_t tileY) const noexcept", "summary": "Whether the tile lies in the REQUESTED grid (the batch path and the tileAt/depthKeyAt/tileHeightAt preconditions). The table's covered region may extend past the requested grid (its chunk- aligned superset) — those cells are table cells, not tiles.", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::originTileX", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 543, "signature": "std::int32_t originTileX() const noexcept", "summary": "Introspection (O(1); the table-backed values forward the table):", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::originTileY", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 544, "signature": "std::int32_t originTileY() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::widthTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 545, "signature": "std::int32_t widthTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::heightTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 546, "signature": "std::int32_t heightTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::layer", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 547, "signature": "std::int32_t layer() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::chunkTiles", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 548, "signature": "std::int32_t chunkTiles() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::maxAnimations", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 550, "signature": "std::uint32_t maxAnimations() const noexcept", "summary": "The animation slot count (the tilemap's `Options::maxAnimations`).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::tileCount", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 554, "signature": "std::size_t tileCount() const noexcept", "summary": "The requested grid's tile count (widthTiles * heightTiles).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::hasAnimation", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 560, "signature": "[[nodiscard]] bool hasAnimation(std::uint32_t id) const noexcept", "summary": "Whether the animation slot `id` is SET (the declare path's check; id 0 is the static sentinel — never set).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::animationAt", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 566, "signature": "[[nodiscard]] const TileAnimationDef& animationAt(std::uint32_t id) const noexcept", "summary": "The set animation's definition. Precondition: hasAnimation(id) (asserted — check when the id comes from untrusted input).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::animationFrame", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 574, "signature": "[[nodiscard]] std::uint32_t animationFrame(std::uint32_t id) const noexcept", "summary": "The set animation's CURRENT frame (0..frameCount-1) — the frame its tiles carry on the next declare (diagnostics view — DBG-008). Precondition: hasAnimation(id) (asserted).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 580, "signature": "TileMap(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 581, "signature": "TileMap& operator=(const TileMap&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileMap", "kind": "constructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 582, "signature": "TileMap(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 583, "signature": "TileMap& operator=(TileMap&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::~TileMap", "kind": "destructor", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 584, "signature": "~TileMap() = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileSlot::textureId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 598, "signature": "std::uint32_t textureId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::TileSlot::animationId", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 599, "signature": "std::uint32_t animationId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::AnimSlot::def", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 605, "signature": "TileAnimationDef def{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::AnimSlot::frame", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 606, "signature": "std::uint32_t frame{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::AnimSlot::tick", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 607, "signature": "std::uint32_t tick{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::AnimSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 608, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::AnimSlot::SpriteUvRect", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 610, "signature": "std::unique_ptr frameUv", "summary": "The precomputed frame UVs (frameCount rects — setAnimation).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 184, "signature": "inline constexpr std::uint32_t kMaxArchetypes = 256", "summary": "The engine-level cap on distinct component sets (archetypes) per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's component-set vocabulary is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypeComponents", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 189, "signature": "inline constexpr std::uint32_t kMaxArchetypeComponents = 32", "summary": "The engine-level cap on components per entity (per archetype) (CORE-005). A 33rd distinct component on one entity fails addComponent with BudgetExhausted.", "budget": null, "experimental": false}, {"name": "laige::kInitialArchetypeRows", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 194, "signature": "inline constexpr std::uint32_t kInitialArchetypeRows = 16", "summary": "Rows reserved when an archetype is created (or the world's entity capacity, when smaller). Growth doubles from here (see the header preamble, \"Reserve policy\").", "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 59d1490..2d0a405 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -216,7 +216,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R parallax` green. - **Size:** ~150 lines + tests -- [ ] **M2-TILE-02 · Parallax tile layers + tile animation** +- [x] **M2-TILE-02 · Parallax tile layers + tile animation** - **Refs:** FR-2.6 (parallax tile layers, tile animation) - **Depends:** M2-TILE-01, M2-PAR-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index ed045f0..beb02ee 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 | 18 | ⬜ in progress | +| M2 | 33 | 19 | ⬜ 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** | **64** | | +| **Total** | **194** | **65** | | --- @@ -242,6 +242,7 @@ One line per completed (or split/renumbered) step. | 2026-10-04 | M2-SPRITE-04 | `—` | Render observability + the draw-call budget (G-R2, PRD §9.3; M2-SPRITE-04 scope, nothing else): **API** — `SpriteDrawStats` gained `programChanges` (the pass's `glUseProgram` count — 1 per successful non-empty frame), `uploadBytes` (the frame's instance upload, n × 52 B), `renderTargetBytes` (the render-target size drawn, w × h × 4), and the G-R2 `drawCallCapExceeded` flag; `SpriteDrawTotals` gained the `programChanges`/`uploadBytes`/`renderTargetBytes` sums + `capExceededFrames`; `SpriteRenderer::Options` gained the configurable per-pass draw-call cap `maxDrawCalls` (domain `[1, kSpriteRendererMaxInstances]` — a frame's draw calls can never exceed its instance count; documented default `kSpriteRendererDefaultDrawCalls` = 64 = 2× the PRD §8.1 worst-case reference-scene budget of 30 draw calls) + the `maxDrawCalls()` accessor; NEW `textureMemoryBytes()` gauge (the texture-memory VRAM estimate: the bound atlases' w × h × 4 sum — updated at `bindAtlas`, a re-bind subtracts the old upload + adds the new, reads 0 in the stopped state); **G-R2 semantics** — a frame whose draw calls (== its group count) STRICTLY exceed the cap is STILL drawn (observation, never an execution gate — the G-R5 precedent): one rate-limited Warn `draw_call_cap` (fields `capacity`, `draw_calls`) on the over-cap submit, the frame's `drawCallCapExceeded` flag (the frame-graph flag M2-PROF-01 will report) + the totals' `capExceededFrames`; **contract** — a failed submit zeroes the frame counters (now enforced on all three GL failure paths: upload, query creation, draw); the atlas registry slot grew 8 B → 16 B (the width/height bookkeeping: 32 KB → 64 KB at 4096 slots); **tests** — NEW `tests/laige-render/render_counters_tests.cpp` (new CTest entry `render_counters` = the step's Verify command; 8 tests / 4 suites): `RenderCountersCreate` (no GL: the maxDrawCalls validation first-failure-wins + one Warn `options_invalid`, the default reaching the context check → `GlUnavailable`, the stopped state reads zero), `RenderCountersScene` (GL: the roadmap's known small scene — 10 sprites, 2 atlases, 2 blends → 3 groups — per-frame counters pinned EXACTLY for frame 1 {3 draws, 2 binds, 3 blend changes, 1 program change, 10 instances, 520 B upload, 65 536 B target} + frame 2 {3, 2, 2, 1, 10, 520, 65 536 — the cross-frame last-atlas/last-blend state persistence} + the since-construction totals, the empty frame counts nothing, the opt-in `PRIMITIVES_GENERATED` feed = 20), `RenderCountersCap` (GL: the warn fires AT the configured count (cap=2) with the pinned fields, the frame still drawn, the second frame repeats the warn + total, a 2-group frame AT the cap has no flag + no new warn (1 bind + 1 blend change from the persisted state), cap=3 → no warn at the cap), `RenderCountersMemory` (GL: the gauge exact — two 4×4 atlases = 128 B; a second renderer's 8×8 = 256 B, the 16×16 re-bind replacement = 1024 B); **docs** — `docs/api/sprite_renderer.md` (NEW "Render observability + the draw-call cap" section: the field table + the cap semantics; the API snippet, the create/bindAtlas/submit/frameStats bullets, the Performance section, the misuse warnings), `docs/README.md` index entry, the module README status paragraph, the roadmap box; `laige-api.json` regenerated (1233 symbols / 38 headers, +12 symbols); **verification** — all six local trees warning-clean + full ctest green: build 111/111, build-clang 111/111, build-release 100/100, build-shared 111/111, build-asan 108/108, build-tsan 108/108; the first CI TSan run reproduced a DRIVER-INTERNAL teardown data race (both race accesses inside libgallium: the `eglDestroyContext` teardown destroys the driver's internal mutex/condvar while the llvmpipe worker thread is still inside it — no engine code between the pthread frames), triggered by a query-enabled submit as the FIRST FBO operation of the frame — fixed in the test with the frame-pipeline clear-before-draw pattern (`RenderCountersScene.PrimitiveQueryFeed` clears the target before the submit — the `SpriteDrawSmoke.ThousandSpriteFrame` precedent; the clear touches no sprite-pass state, the counters are unaffected; a clear AFTER the submit or a readback quiesce does NOT fix it — the clear must precede the first draw); `api-real-tree`/`api-check-fresh`, `tools/laige-include-lint`, and `tools/laige-determinism-lint` green; **budget** — no standalone `budgets.json` entry (the counters are O(1) bookkeeping; the composite 50k render-CPU budget is measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is the 4 `SpriteDrawStats` fields + the 4 `SpriteDrawTotals` fields + `Options::maxDrawCalls` + `kSpriteRendererDefaultDrawCalls` + `maxDrawCalls()` + `textureMemoryBytes()` = 12 symbols); Progress Board M2 16/33, total 62/194 | | 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 | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index f6e6fc4..fe90d94 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -368,9 +368,9 @@ template over the SimMath backends): the chunked tile grid of FR-2.6 (chunks, per-tile depth/height, auto-depth). The tilemap OWNS one `IsoDepthKeyTable` (M2-ISO-02) — the tile's HEIGHT lives in the table alone (one source of truth), and the remaining per-tile -data (`textureId`, `animationId` — data only in M2; M2-TILE-02 drives -the frame cycle from the animation id) lives in one flat pre-sized -array (8 B/tile, the requested grid). The AUTO-DEPTH wiring: +data (`textureId`, `animationId` — 0 = a static tile; 1..maxAnimations += the animation slot, M2-TILE-02) lives in one flat pre-sized array +(8 B/tile, the requested grid). The AUTO-DEPTH wiring: `setTile(gx, gy, textureId, height, animationId)` routes the height into the table's `setTile` (the table recomputes exactly that cell's key — the M2-ISO-02 incremental update, radius 0), and `rebuild(tiles)` @@ -449,4 +449,63 @@ the composite 50k render-CPU budget, measured with M2-PERF-01 (the M2-SCENE-01 reference scene has 3 parallax layers within the 50k-sprite / ≤30-draw-call budget). +M2-TILE-02 landed the tilemap's tile animation and parallax tile +layer — extensions to `laige::render::TileMap` (same header, +header-only). **Tile animation** (data-driven frame cycling, FR-2.6): +an ANIMATION is a slot of the tilemap's pre-sized animation table +(ids 1..`maxAnimations`; 0 = the static sentinel) — `setAnimation(id, +def)` (setup/config path) sets the frame COUNT (`frameCount`, +[1, `kTileAnimMaxFrames` = 64]), the documented TICK RATE (`frameTicks` +— the SIMULATION ticks per frame), and the tile SHEET's frame layout +(the M2-SPRITE-03 `SpriteFrameLayout`, texels — the tight sheet). The +scene owner calls `advanceAnimations()` ONCE PER SIM TICK (ARCH-002 — +per sim tick, never per render frame): every set animation's frame +steps every `frameTicks` ticks, wrapping at `frameCount` +(`frame(ticks) = (ticks / frameTicks) mod frameCount`); all tiles of +one animation share its phase (per-tile offsets are the M3 editor's +control). The frame UVs are PRECOMPUTED at `setAnimation` (one setup +allocation); the per-frame declare path only READS them (FR-2.2 — no +per-frame allocation). The declared quad's frame fields: the STATIC +tile carries `DeclareOptions::uv` + `frameIndex` 0; the ANIMATED tile +carries its animation's CURRENT frame UV + `frameIndex`. The frame +state is presentation state (ARCH-009 — never in the sim state hash). +**The parallax tile layer** (the M2-PAR-01 Tilemap-source hook): +`declareTo(batcher, options, layers, layerId, cameraPos)` declares the +tilemap's quads UNDER the layer — every quad TRANSLATED by the +layer's `worldOffset(cameraPos)` (the M2-PAR-01 formula (1)), and its +key is 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` equals it — 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). +Protocol (first failure wins, nothing declared, no log): a built +frame's closed window, an unset layer id, a non-Tilemap-source layer +→ `InvalidArgument`; a DISABLED layer declares NOTHING (OK). Rejected +`setAnimation`/`setTile`/`rebuild` leave the slots/data/table +unchanged; an animated tile whose slot is UNSET fails the declare. +ARCH-009: headless-buildable, presentation-only; the per-tick +`advanceAnimations` + per-frame `declareTo` loops (standalone + +parallax) allocate NOTHING (FR-2.2 — the zero-allocation proof, the +tests). API contract in +[docs/api/tilemap.md](../docs/api/tilemap.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `tilemap_anim` +— pure data + batcher bookkeeping, no GL environment required: the +`setAnimation` validation matrix (slot domain, frame count / tick +rate / sheet domains, the tight-sheet float-exact domain with the +adversarial-layout overflow guards, the rejected-set-leaves-no-state ++ phase-reset contract, no-log happy path), the frame cycle at the +documented rate (hand-computed `frame = ticks / frameTicks mod +frameCount`), the animated/static frame fields on the declared quads +(hand-computed frame-UV goldens from the M2-SPRITE-03 tight-sheet +formula, the frameIndex, the grouping, the wrap + cross-frame +determinism), the parallax tile layer's hand-computed key goldens at +given camera positions (both backends — the dyadic exactness zone) + +the layer-dominance ordering + the protocol paths (built frame / +unset id / non-tilemap source / disabled layer) + the animated tile +under the translation, the animationId edit/load domain + the +unset-slot declare failure, and the 1000-frame advance + declare +zero-allocation loop (standalone + parallax)). No standalone +`budgets.json` entry: the per-frame declare + per-tick advance cost is +part of the composite 50k render-CPU budget, measured with M2-PERF-01. + The remaining M2 steps (text/UI, M2-PERF-01) land in later steps. diff --git a/src/laige-render/include/laige/render/parallax.h b/src/laige-render/include/laige/render/parallax.h index 8d04040..9c9f174 100644 --- a/src/laige-render/include/laige/render/parallax.h +++ b/src/laige-render/include/laige/render/parallax.h @@ -67,11 +67,16 @@ // // The Tilemap source references a tilemap (its `tilemapId` — the // game's registry id; M3-ASSET-01 owns the asset system): the tilemap -// defines its own world grid, and M2-TILE-02 declares its tiles with -// this layer's `worldOffset` translation and its `depthLayer` (the -// tilemap's Options::layer). In M2-PAR-01 a Tilemap-source layer is -// DATA ONLY: `declareTo` skips it (the hook), its `size`/`uv` fields -// are not validated. +// defines its own world grid, and the tilemap declares its tiles +// UNDER this layer (the TileMap::declareTo overload, M2-TILE-02 — +// tilemap.h): the quads are translated by this layer's `worldOffset` +// (formula (1)), and their depth keys carry the TILEMAP's own +// `Options::layer` (the scene-setup convention: this def's +// `depthLayer` must equal it — both are the content's layer; the +// declaration uses the tilemap's value). In M2-PAR-01 a Tilemap- +// source layer is DATA ONLY here: THIS registry's `declareTo` skips +// it (the hook — the game declares the tiles through the tilemap's +// path, not this one), its `size`/`uv` fields are not validated. // // --------------------------------------------------------------------------- // The render order (the documented background-first contract) @@ -251,10 +256,10 @@ // - Don't call `advanceScrolls` more than once per frame (the auto // speed is PER FRAME — the caller paces it once per frame, the // batcher's beginFrame pace). -// - Don't declare a Tilemap-source layer through `declareTo` in -// M2: it is skipped (no log — the M2-TILE-02 step implements the -// tilemap declare path on this layer's `worldOffset` + -// `depthLayer`). +// - Don't declare a Tilemap-source layer through THIS registry's +// `declareTo`: it is skipped (no log — the game declares the tiles +// through the tilemap's `declareTo` overload, M2-TILE-02 — on this +// layer's `worldOffset` + the tilemap's own `layer`). // - Don't expect the parallax factor to be "how much the layer // moves": it is the (1) coefficient — factor 1 is the SCREEN-FIXED // layer (no parallax), factor 0 the full-parallax one. The formula diff --git a/src/laige-render/include/laige/render/tilemap.h b/src/laige-render/include/laige/render/tilemap.h index aa822e1..33fbc36 100644 --- a/src/laige-render/include/laige/render/tilemap.h +++ b/src/laige-render/include/laige/render/tilemap.h @@ -1,24 +1,33 @@ -// laige-render tilemap (M2-TILE-01): the chunked tile grid data, the -// auto-depth wiring of the M2-ISO-02 depth key table, and the static -// tile-quad batch path into the sprite batcher. -// -// FR-2.6: "Tilemap: chunks, per-tile depth/height, auto-depth" — a -// chunked grid of tiles; per tile: a texture reference, a depth/height -// value, and an animation id (data only in M2 — M2-TILE-02 drives the -// frame cycle); a tile's Y height is AUTOMATICALLY reflected in its -// depth key (the wiring into M2-ISO-02). S-5 (PRD §9.1): rendering -// goes through the batcher — the tilemap's quads are declared into the -// sprite batcher as sprites with a fixed frame, never drawn directly. -// ARCH-009: the tile data is presentation-side (headless-buildable, -// sim-side in the sense that it never needs GL); the render phase -// consumes it read-only. G-R11: the tile's depth key is engine-owned -// (the table's) — game code never writes the key. RENDER-003: the -// declaration order is deterministic (the tile grid's row-major -// iteration). +// laige-render tilemap (M2-TILE-01/02): the chunked tile grid data, +// the auto-depth wiring of the M2-ISO-02 depth key table, the static +// tile-quad batch path into the sprite batcher, the data-driven tile +// animation frame cycle (M2-TILE-02), and the parallax tile layer +// declaration (M2-TILE-02, the M2-PAR-01 Tilemap-source hook). +// +// FR-2.6: "Tilemap: chunks, per-tile depth/height, auto-depth" + +// "tile animation" + "parallax tile layers" — a chunked grid of +// tiles; per tile: a texture reference, a depth/height value, and an +// animation reference; a tile's Y height is AUTOMATICALLY reflected +// in its depth key (the wiring into M2-ISO-02); an animated tile's UV +// frame CYCLES from its animation's documented tick rate (M2-TILE-02); +// a tilemap can be a PARALLAX LAYER's content (M2-TILE-02 — the +// tiles are declared under the layer's worldOffset translation, +// M2-PAR-01). S-5 (PRD §9.1): rendering goes through the batcher — +// the tilemap's quads are declared into the sprite batcher as +// sprites, never drawn directly. ARCH-009: the tile data and the +// animation frame state are presentation-side (headless-buildable, +// sim-side in the sense that they never need GL); the render phase +// consumes them read-only. G-R11: the tile's depth key is +// engine-owned (the table's / the M2-ISO-01 function's) — game code +// never writes the key. RENDER-003: the declaration order is +// deterministic (the tile grid's row-major iteration). // // TileData one tile's data (texture id, height, // animation id) -// TileMap the chunked tile grid (owns the table) +// TileAnimationDef one tile animation's definition (the frame +// count, the tick rate, the sheet layout) +// TileMap the chunked tile grid (owns the table + +// the animation slots) // TileMap::DeclareOptions the per-frame declare options // // --------------------------------------------------------------------------- @@ -47,9 +56,12 @@ // textureId the texture/atlas reference (the game assigns it — // M3-ASSET-01 owns the asset system; the batcher's // group key carries it unchanged) -// animationId the animation-table reference (DATA ONLY in M2 — -// M2-TILE-02 cycles frames from it; the batcher and the -// render never touch it) +// animationId the animation reference (M2-TILE-02 — the batch path +// CONSUMES it, below): 0 = a STATIC tile (the fixed +// frame); 1..maxAnimations = the animation slot the +// tile cycles from (the animation's sheet texture is +// the tile's own textureId — the game assigns the +// matching texture) // // The tile's HEIGHT lives in the table alone (one source of truth — // the auto-depth wiring): a height edit through the tilemap goes into @@ -62,20 +74,21 @@ // The quad model (the batch path) // --------------------------------------------------------------------------- // -// A tile renders as a SPRITE with a FIXED frame (the scope's "tiles -// are sprites with a fixed frame"): +// A tile renders as a SPRITE (the M2-SPRITE-01 quad): // // pos the tile's CENTER (gx + 0.5, gy + 0.5) — the same // point the table quantizes (the float conversion is // exact for |gx| <= 32766 — dyadic) // scale (1, 1) — the unit quad spans the tile's world cell // rotation 0 -// uv the fixed frame — the `DeclareOptions::uv` rect for -// every tile (default (0, 0, 1, 1) = the full tile -// texture); per-tile UV frames (tile-sheet frames, -// animated frames) are the asset/animation steps -// (M2-TILE-02, M3-ASSET-01 — `SpriteItem.uv` already -// carries them) +// uv the tile's CURRENT frame (M2-TILE-02): the STATIC +// tile's fixed frame (`DeclareOptions::uv` — default +// (0, 0, 1, 1) = the full tile texture); the ANIMATED +// tile's frame UV sub-rect (its animation's sheet +// frame — the `spriteFrameUv` output, M2-SPRITE-03) +// frameIndex the tile's CURRENT animation frame (0 for the static +// tile — the M2-SPRITE-03 hook the M3 animation +// drives; the batcher carries it untouched) // depthKey the table's key for the cell (AUTO-DEPTH — the game // never computes it, G-R11; the item's // `depthOverride` stays false) @@ -90,6 +103,71 @@ // never of the tile count (RENDER-001). // // --------------------------------------------------------------------------- +// Tile animation (M2-TILE-02 — the data-driven frame cycle) +// --------------------------------------------------------------------------- +// +// An ANIMATION is a slot of the tilemap's pre-sized animation table +// (`Options::maxAnimations`, domain [1, kTileMapMaxAnimations]): the +// scene SETS it (setup / config path — `setAnimation`, like the +// parallax `setLayer`): the frame COUNT, the documented TICK RATE +// (`frameTicks` — the simulation ticks per frame), and the tile +// SHEET's frame layout (the M2-SPRITE-03 `SpriteFrameLayout`: frame +// size, row/col, margins, texels). A tile is ANIMATED when its +// `animationId` is in [1, maxAnimations]; 0 is the static sentinel +// (the flat/empty default — the tile's UV is the fixed frame). +// +// The frame CYCLE is the engine's per-tick advance (the scene owner +// calls `advanceAnimations()` ONCE PER SIM TICK — the sim phase, the +// M2-GL-02 frame pipeline's sim-side pacing; ARCH-002: the rate is +// per SIM tick, never per render frame — the cycle is +// frame-rate-independent): every SET animation advances one tick per +// call, and its frame steps forward every `frameTicks` ticks: +// +// frame(ticks) = (ticks / frameTicks) mod frameCount +// +// (the slot's tick counter implements exactly this — no division per +// call). The cycle WRAPS (frameCount-1 -> 0); the cycled frames are +// the sheet's FIRST `frameCount` frames, row-major (frame 0 = the +// sheet's top-left, the M2-SPRITE-03 layout). A (re)set RESETS the +// phase (frame 0, tick 0). All tiles of one animation share its +// phase (frame 0 at the first tick; per-tile phase offsets are the +// M3 animation editor's control — the roadmap's "data-driven" scope: +// the editor authors the defs, the engine cycles them). +// +// The frame's UV is PRECOMPUTED at `setAnimation` (one `spriteFrameUv` +// per frame — the setup path): the per-frame declare path only READS +// the stored UV + frame (zero allocation, no division — FR-2.2). +// The sheet is the layout's TIGHT sheet (the M2-SPRITE-03 formula — +// derived, not a def field); a padded atlas is the M3-ASSET-01 asset +// system's concern. The frame state is PRESENTATION state (ARCH-009) +// — never part of replay state or the simulation state hash (the +// parallax scroll-offset precedent). +// +// --------------------------------------------------------------------------- +// The parallax tile layer (M2-TILE-02 — the M2-PAR-01 Tilemap-source +// hook) +// --------------------------------------------------------------------------- +// +// A parallax layer with the `Tilemap` source (the M2-PAR-01 +// registry) names a tilemap; the tilemap declares its quads UNDER +// that layer (the `declareTo` overload below): every tile quad is +// TRANSLATED by the layer's `worldOffset(cameraPos)` (the M2-PAR-01 +// formula (1) — the layer's factor/center/offset against the +// camera's presentation position, world space, RENDER-006) and its +// depth key is the M2-ISO-01 key of the TRANSLATED tile center at +// the tilemap's own `layer` (the scene-setup convention: the layer's +// def `depthLayer` equals the tilemap's `Options::layer` — the +// bg/mid/fg values, parallax.h — the tilemap is the one source of +// truth for its content's layer). +// +// The table's keys are at the UNtranslated positions, so the +// translated keys are computed per tile per frame (the translation +// is camera-dependent — not precomputable; O(tileCount) key +// computations, zero allocation, no GL — part of the composite 50k +// budget; the qBase + qOffset derivation is the documented upgrade +// path if a profile ever shows it matters). +// +// --------------------------------------------------------------------------- // Determinism (RENDER-003, ARCH-010 scope — presentation-only) // --------------------------------------------------------------------------- // @@ -98,8 +176,11 @@ // The tile's grid position is a static tile's stable identity (the // FR-1.2 entity-order analog): the declaration order is the insertion // order the M2-SORT-01 stable sort turns into the (key, insertion -// position) total order — same tile data → bit-identical batches, -// every frame and every platform. +// position) total order — same tile data + same animation state → +// bit-identical batches, every frame and every platform. The +// animation state is a deterministic function of the +// `advanceAnimations` call sequence (no RNG, no clock — the scene +// paces the ticks). // // --------------------------------------------------------------------------- // Ownership, lifetime, threading @@ -109,11 +190,14 @@ // frame pipeline's cull/batch stage owns the render-side declaration). // The tilemap is move-only (CORE-009); the storage is pre-sized at // creation (the ONLY allocations are `create` — one table storage + -// one 8 B/tile data array — and `rebuild`'s setup-path temporary, -// below). Writes (`setTile`, `rebuild`) happen in the sim phase; -// reads (`tileAt`, `depthKeyAt`, `tileHeightAt`, `covers`, -// `declareTo`) in the render phase; the phases do not overlap -// (CONC-001, the table's contract — one owner per datum). Not +// one 8 B/tile data array + the animation slot array — `rebuild`'s +// setup-path temporary, and `setAnimation`'s per-frame-UV array — +// the setup/config path, never the per-frame path). Writes (`setTile`, +// `rebuild`, `setAnimation`) happen in the sim/config phase; the +// per-tick `advanceAnimations` advances the presentation frame state +// (sim phase); reads (`tileAt`, `depthKeyAt`, `tileHeightAt`, +// `covers`, `declareTo`) in the render phase; the phases do not +// overlap (CONC-001, the table's contract — one owner per datum). Not // thread-safe by design. // // The per-frame `declareTo` path ALLOCATES NOTHING (FR-2.2 @@ -126,19 +210,45 @@ // // All failure paths return the `Status`/`Result` — the caller handles // and logs (LOG-002: the engine does not duplicate a per-call log). -// Rejected operations leave ALL state (tile data and the table) -// unchanged. +// Rejected operations leave ALL state (tile data, the table, and the +// animation slots) unchanged. +// +// setAnimation id 0 (the static sentinel) or > maxAnimations; +// frameCount outside [1, kTileAnimMaxFrames]; +// frameTicks < 1; a zero sheet extent; frameCount +// beyond the sheet's frame count; the tight sheet +// beyond the float-exact domain (2^24 texels) — +// all InvalidArgument, no log, state unchanged +// setTile/rebuild an animationId beyond maxAnimations (0 is always +// valid — the static sentinel) — InvalidArgument, +// no log, state unchanged (the WHOLE span +// validated before any write, rebuild) +// declareTo a built frame's closed window (nothing declared); +// an animated tile whose animation slot is UNSET +// (first failure wins — nothing declared past it +// this frame; the slot's set is the scene's setup +// responsibility) +// declareTo (parallax) a built frame's closed window; an unset +// layer id or a non-Tilemap-source layer +// (InvalidArgument, no log — the setup mispairing +// is the caller's); a DISABLED layer declares +// NOTHING (OK, no log — the layer's documented +// skip, the parallax declareTo's precedent) // // --------------------------------------------------------------------------- // Budget // --------------------------------------------------------------------------- // // No standalone `budgets.json` entry: the per-frame declare cost -// (O(tiles) adds + the sort) 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; `declareTo` declares the -// WHOLE requested grid — visible-rect culling lands with M2-PERF-01, -// and the budget's worst case is the full grid anyway). +// (O(tiles) adds + the sort; the parallax path's O(tiles) key +// computations) 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; `declareTo` declares the WHOLE +// requested grid — visible-rect culling lands with M2-PERF-01, and +// the budget's worst case is the full grid anyway). The per-tick +// `advanceAnimations` is O(animations) — negligible next to the +// sim tick's budget (the animations are a scene-config count, not a +// per-entity count). // // --------------------------------------------------------------------------- // Misuse warnings @@ -160,6 +270,17 @@ // - Don't recreate the tilemap per frame: the table IS the // precomputation (FR-2.2 "not recomputed per frame" — the M2-ISO-02 // anti-pattern). +// - `advanceAnimations` is ONCE PER SIM TICK: the frame rate is +// `frameTicks` sim ticks, not render frames (ARCH-002); calling it +// per render frame changes the animation speed (and is wrong under +// variable tick pacing). +// - A parallax tile LAYER's def `depthLayer` must equal the +// tilemap's `Options::layer` (the scene-setup convention — both are +// the content's layer; the declaration uses the tilemap's): the +// bg/mid/fg tilemaps get layer values -2/-1/+1 (parallax.h). +// - `DeclareOptions::uv` is the STATIC tiles' fixed frame: animated +// tiles carry their animation's frame UV (this field is ignored +// for them). #pragma once @@ -175,11 +296,28 @@ #include "laige/render/iso_depth_key.h" #include "laige/render/iso_depth_table.h" #include "laige/render/matrices.h" +#include "laige/render/parallax.h" #include "laige/render/sprite_batcher.h" +#include "laige/render/sprite_frames.h" #include "laige/sim_math.h" namespace laige::render { +// The frame budget of ONE tile animation (M2-TILE-02): the practical +// tile-sheet frame count (a 16 x 4 sheet); a longer cycle is the +// game's split into two animations (the M3 animation editor owns the +// authoring). The per-animation setup storage is frameCount x +// sizeof(SpriteUvRect) (1 KB at the cap). +inline constexpr std::uint32_t kTileAnimMaxFrames = 64; + +// The tilemap's animation slot count: the default — the reference +// scene's handful of animated tiles + headroom for custom (the +// M2-SCENE-01 precedent, the parallax default-layers pattern) — and +// the cap: the practical tileset's full animation catalog (256 slots +// ~= 14 KB of setup storage). +inline constexpr std::uint32_t kTileMapDefaultAnimations = 8; +inline constexpr std::uint32_t kTileMapMaxAnimations = 256; + // One tile's data (the value the game writes on load/edit and reads // back; plain value — no GL, no allocation). struct TileData { @@ -188,14 +326,33 @@ struct TileData { // The tile's step height in world units (the standing surface's // elevation — the auto-depth source, the M2-ISO-01 `z` input). std::int32_t height{}; - // The animation-table reference (DATA ONLY in M2 — M2-TILE-02 - // cycles frames from it; the batch path never touches it). + // The animation reference (M2-TILE-02 — the batch path consumes + // it): 0 = a STATIC tile (the fixed frame); 1..maxAnimations = the + // animation slot the tile cycles from (its sheet texture is the + // tile's own textureId — the game assigns the matching texture). std::uint32_t animationId{}; friend constexpr bool operator==(const TileData&, const TileData&) = default; }; -// The tile grid + its depth table (M2-ISO-02) + the batch path. +// One tile animation's definition (M2-TILE-02 — the data-driven frame +// cycle; plain value — no GL, no allocation). +struct TileAnimationDef { + // The frames the cycle plays: the sheet's FIRST frameCount frames, + // row-major (frame 0 = the sheet's top-left); the domain + // [1, kTileAnimMaxFrames] (and <= the sheet's frame count). + std::uint32_t frameCount{}; + // The documented rate: the SIMULATION ticks per frame (>= 1) — the + // frame advances every frameTicks calls to advanceAnimations + // (ARCH-002: per sim tick, never per render frame). + std::uint32_t frameTicks{}; + // The tile sheet's frame layout (texels — the M2-SPRITE-03 layout: + // frameWidth/Height, columns/rows, frameSpacing, sheetBorder). + SpriteFrameLayout layout{}; +}; + +// The tile grid + its depth table (M2-ISO-02) + the animation slots +// (M2-TILE-02) + the batch path. // // Templated over the SimMath backends (the presentation.h pattern — // the keys are backend-typed; fpx16_16 and fp32_pinned agree @@ -208,37 +365,54 @@ class TileMap { "TileMap is templated over the SimMath backends"); public: - // The create options — the table's options themselves (one type, - // passed through to the table's create — the validation is the - // table's, first failure wins, InvalidArgument): `originTileX/Y` - // (default 0), `widthTiles`/`heightTiles` (required >= 1), - // `chunkTiles` (default `kIsoDepthTableChunkTiles` = 16, a power of - // two >= 1), `maxChunks` (default + // The create options: the table's grid options + the animation slot + // count (first failure wins — `maxAnimations` first, then the + // table's create validates the grid options — all InvalidArgument): + // `originTileX/Y` (default 0), `widthTiles`/`heightTiles` (required + // >= 1), `chunkTiles` (default `kIsoDepthTableChunkTiles` = 16, a + // power of two >= 1), `maxChunks` (default // `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default // `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own // tilemap with its layer value, M2-PAR-01 — the values are - // documented in laige/render/parallax.h). - using Options = IsoDepthKeyTable::Options; + // documented in laige/render/parallax.h), `maxAnimations` + // (default `kTileMapDefaultAnimations` = 8; the domain + // [1, `kTileMapMaxAnimations` = 256]). + struct Options { + std::int32_t originTileX = 0; + std::int32_t originTileY = 0; + std::int32_t widthTiles = 0; // required >= 1 + std::int32_t heightTiles = 0; // required >= 1 + std::int32_t chunkTiles = kIsoDepthTableChunkTiles; + std::int32_t maxChunks = kIsoDepthTableDefaultMaxChunks; + std::int32_t layer = kIsoDepthGroundLayer; + std::uint32_t maxAnimations = kTileMapDefaultAnimations; + }; // The per-frame declare options (the fixed-frame quad model, above): - // the group-key fields for every tile sprite + the fixed UV frame. + // the group-key fields for every tile sprite + the static tiles' + // fixed UV frame. struct DeclareOptions { // The material reference (0 = the default material). std::uint32_t materialId{0}; // The blend state of every tile sprite (the group key's third // field). BlendMode blend{BlendMode::Alpha}; - // The fixed frame: every tile quad's UV sub-rect (default - // (0, 0, 1, 1) = the full tile texture). + // The STATIC tiles' fixed frame: every static tile quad's UV + // sub-rect (default (0, 0, 1, 1) = the full tile texture). + // ANIMATED tiles carry their animation's frame UV (the layout) — + // this field is ignored for them. SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}; }; - // Creates the tilemap (setup path — the only allocations besides - // rebuild's setup temporary): validates the options (the table's - // create — first failure wins, InvalidArgument), pre-sizes the - // table's initial grid chunks, and pre-sizes the per-tile data - // array (8 B/tile, the requested grid). Flat/empty init: every tile - // is {textureId 0, height 0, animationId 0} — no tile is set. + // Creates the tilemap (setup path — the allocations: the table's + // storage + the per-tile data array + the animation slot array): + // validates `maxAnimations` (first), then the table's create + // validates the grid options (first failure wins, InvalidArgument), + // pre-sizes the table's initial grid chunks, the per-tile data + // array (8 B/tile, the requested grid), and the animation slot + // array (unset slots). Flat/empty init: every tile is + // {textureId 0, height 0, animationId 0} — no tile is set, no + // animation is set. [[nodiscard]] static Result create(const Options& options) noexcept; @@ -250,10 +424,11 @@ class TileMap { // // Rejected edits (tile outside the REQUESTED grid — the superset // margin cells are table cells, not tiles; height outside the key - // domain, |h| <= kIsoDepthMaxStepHeight) leave the tile data AND - // the table UNCHANGED (validated before any write); the returned - // Status is the failure channel the caller handles and logs - // (LOG-002). + // domain, |h| <= kIsoDepthMaxStepHeight; animationId beyond + // maxAnimations — 0 is always valid, the static sentinel) leave + // the tile data AND the table UNCHANGED (validated before any + // write); the returned Status is the failure channel the caller + // handles and logs (LOG-002). [[nodiscard]] Status setTile(std::int32_t tileX, std::int32_t tileY, std::uint32_t textureId, std::int32_t height, @@ -272,30 +447,84 @@ class TileMap { // tilemap — the test pins it). // // Rejected loads (wrong span size; any height outside the key - // domain — validated over the WHOLE span before any write) leave - // the tile data AND the table UNCHANGED. + // domain; any animationId beyond maxAnimations — validated over + // the WHOLE span before any write) leave the tile data AND the + // table UNCHANGED. [[nodiscard]] Status rebuild(std::span tiles) noexcept; + // Sets or REPLACES the animation slot `id` (setup / config path — + // the scene config's hot-reload, the parallax setLayer precedent): + // the frame count, the tick rate, and the sheet layout (the header's + // animation section). A success RESETS the slot's phase (frame 0, + // tick 0) and replaces the precomputed frame-UV array (one setup + // allocation — the only per-animation allocation). + // + // Rejections (InvalidArgument, no log, slot unchanged — the header's + // failure section): id 0 (the static sentinel) or > maxAnimations; + // frameCount outside [1, kTileAnimMaxFrames]; frameTicks < 1; a + // zero sheet extent; frameCount beyond the sheet's frame count + // (columns x rows); the tight sheet beyond the float-exact domain + // (kSpriteFrameMaxAtlasTexels = 2^24 texels — the M2-SPRITE-03 + // domain, guarded against the adversarial layout's u64 overflow). + [[nodiscard]] + Status setAnimation(std::uint32_t id, const TileAnimationDef& def) + noexcept; + + // The sim phase's per-tick call (ONCE PER SIM TICK — the header's + // animation section): advances every SET animation's tick, and + // steps its frame forward every frameTicks ticks (the documented + // rate). O(maxAnimations), zero allocation, no logging, no GL. + // Unset slots are skipped (no state). The presentation frame state + // is never part of the simulation state hash (ARCH-009). + void advanceAnimations() noexcept; + // The render path (the frame pipeline's cull/batch stage): declares // this tilemap's static tile quads into the batcher's current frame // window — one SpriteItem per tile of the REQUESTED grid, in the // grid's row-major order (tileY outer, tileX fastest — the preamble's // quad model: the tile's center, the table's key (auto-depth), the - // tile's texture, the fixed frame). O(tileCount), zero allocation, - // no GL, no logging — the adds are O(1) batcher operations. + // tile's texture, the tile's current frame — the static fixed frame + // or the animation's frame UV). O(tileCount), zero allocation, no + // GL, no logging — the adds are O(1) batcher operations. // // Precondition: the batcher's frame window is open (a built frame's // window is closed — call beginFrame first; the check is here, - // first failure wins, NOTHING declared on failure). On a stopped - // batcher (capacity 0) the first add fails — 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). + // first failure wins, NOTHING declared on failure). An animated + // tile whose animation slot is UNSET fails the declare (first + // failure wins — the header's failure section); the slot's set is + // the scene's setup responsibility. On a stopped batcher + // (capacity 0) the first add fails — 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). [[nodiscard]] Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options) noexcept; + // The parallax tile layer declare path (M2-TILE-02 — the M2-PAR-01 + // Tilemap-source hook): declares this tilemap's tile quads + // TRANSLATED by the parallax layer `layerId`'s worldOffset + // (the M2-PAR-01 formula (1) — the layer's factor/center/offset + // against `cameraPos`, the camera's presentation position) — the + // header's parallax section: the depth key is the M2-ISO-01 key of + // the TRANSLATED tile center at the tilemap's own `layer` (the + // scene-setup convention: the layer's def `depthLayer` equals the + // tilemap's `Options::layer`; the declaration uses the tilemap's + // value — one source of truth). O(tileCount), zero allocation, no + // GL, no logging. + // + // Precondition: the batcher's frame window is open (checked here, + // first failure wins, nothing declared). An unset layer id or a + // non-Tilemap-source layer is InvalidArgument (no log — the setup + // mispairing is the caller's). A DISABLED layer declares NOTHING + // (OK, no log — the layer's documented skip). The animated/static + // frame handling is the same as `declareTo`. + [[nodiscard]] + Status declareTo(SpriteBatcher& batcher, const DeclareOptions& options, + const ParallaxLayers& layers, + std::uint32_t layerId, Vec2 cameraPos) noexcept; + // The read path (render phase, O(1), zero allocation, no GL): [[nodiscard]] TileData tileAt(std::int32_t tileX, std::int32_t tileY) const noexcept; @@ -317,11 +546,36 @@ class TileMap { std::int32_t heightTiles() const noexcept { return options_.heightTiles; } std::int32_t layer() const noexcept { return table_.layer(); } std::int32_t chunkTiles() const noexcept { return table_.chunkTiles(); } + // The animation slot count (the tilemap's `Options::maxAnimations`). + std::uint32_t maxAnimations() const noexcept { + return options_.maxAnimations; + } // The requested grid's tile count (widthTiles * heightTiles). std::size_t tileCount() const noexcept { return static_cast(options_.widthTiles) * static_cast(options_.heightTiles); } + // Whether the animation slot `id` is SET (the declare path's + // check; id 0 is the static sentinel — never set). + [[nodiscard]] bool hasAnimation(std::uint32_t id) const noexcept { + return id >= 1 && id <= options_.maxAnimations && anims_[id].valid; + } + // The set animation's definition. + // Precondition: hasAnimation(id) (asserted — check when the id + // comes from untrusted input). + [[nodiscard]] const TileAnimationDef& animationAt(std::uint32_t id) + const noexcept { + assert(hasAnimation(id) && "TileMap::animationAt: animation not set"); + return anims_[id].def; + } + // The set animation's CURRENT frame (0..frameCount-1) — the frame + // its tiles carry on the next declare (diagnostics view — DBG-008). + // Precondition: hasAnimation(id) (asserted). + [[nodiscard]] std::uint32_t animationFrame(std::uint32_t id) const noexcept { + assert(hasAnimation(id) && + "TileMap::animationFrame: animation not set"); + return anims_[id].frame; + } TileMap(const TileMap&) = delete; TileMap& operator=(const TileMap&) = delete; @@ -333,7 +587,10 @@ class TileMap { explicit TileMap(Options options, IsoDepthKeyTable table) noexcept : options_(options), table_(std::move(table)), - tiles_(std::make_unique(tileCount())) {} + tiles_(std::make_unique(tileCount())), + // maxAnimations + 1: the id domain is 1..maxAnimations + // (inclusive) — index 0 is the unused sentinel slot: + anims_(std::make_unique(options.maxAnimations + 1)) {} // The per-tile data slot (the requested grid; the height lives in // the table — one source of truth). 8 B/tile. @@ -342,6 +599,32 @@ class TileMap { std::uint32_t animationId{}; }; + // One animation slot (pre-sized at create; the frame-UV array is + // the setAnimation setup allocation): + struct AnimSlot { + TileAnimationDef def{}; + std::uint32_t frame{0}; // the current frame (0..frameCount-1) + std::uint32_t tick{0}; // the ticks since the frame started + bool valid{false}; + // The precomputed frame UVs (frameCount rects — setAnimation). + std::unique_ptr frameUv; + }; + + // The float -> backend conversion (the ParallaxLayers::backendVec2 + // pattern — the Fp32Pinned identity / the fpx16_16's fromFloat; + // the translated tile centers are the parallax path's key input). + typename laige::sim::SimMath::Vec2 backendVec2(float x, + float y) const + noexcept { + using M = laige::sim::SimMath; + if constexpr (std::is_same_v) { + return typename M::Vec2{x, y}; + } else { + return typename M::Vec2{laige::fpx16_16::fromFloat(x), + laige::fpx16_16::fromFloat(y)}; + } + } + // The flat index of the requested-grid tile (row-major, tileX // fastest). Precondition: covers(). std::size_t requestedIndex(std::int32_t tileX, @@ -351,19 +634,58 @@ class TileMap { static_cast(tileX - options_.originTileX); } + // The per-tile frame fields (shared by both declare paths): the + // static tile's fixed frame (`options.uv`, frameIndex 0) or the + // animated tile's animation's CURRENT frame (the precomputed frame + // UV, frameIndex = the frame). First failure wins: an animated tile + // whose animation slot is UNSET fails the declare (the header's + // failure section — the slot's set is the scene's setup + // responsibility). `i` indexes tiles_ (the requested grid's row- + // major order — the caller's loop position). + Status applyTileFrame(SpriteItem& item, const DeclareOptions& options, + std::size_t i) const noexcept { + const std::uint32_t animId = tiles_[i].animationId; + if (animId == 0) { + item.uv = options.uv; + item.frameIndex = 0; + return Status{}; + } + // animId is in [1, maxAnimations] (setTile/rebuild validate the + // domain — the index is safe): + const AnimSlot& a = anims_[animId]; + if (!a.valid) return Status(ErrorCode::InvalidArgument); + item.frameIndex = a.frame; + item.uv = a.frameUv[a.frame]; + return Status{}; + } + Options options_; IsoDepthKeyTable table_; std::unique_ptr tiles_; + std::unique_ptr anims_; }; template Result> TileMap::create(const Options& options) noexcept { - // The table's create validates the identical options (first failure - // wins — the table's documented order); the tilemap adds nothing to - // validate (no duplicated validation, CORE-004). The create path is - // the only allocation besides rebuild's setup temporary. - auto table = IsoDepthKeyTable::create(options); + // `maxAnimations` first (the tilemap's own field — first failure + // wins; both failures are InvalidArgument), then the table's create + // validates the identical grid options (its documented order). The + // create path is the setup allocation (table + tile data + + // animation slots — the constructor). + if (options.maxAnimations < 1 || + options.maxAnimations > kTileMapMaxAnimations) { + return Result::failure(ErrorCode::InvalidArgument); + } + typename IsoDepthKeyTable::Options tableOptions; + tableOptions.originTileX = options.originTileX; + tableOptions.originTileY = options.originTileY; + tableOptions.widthTiles = options.widthTiles; + tableOptions.heightTiles = options.heightTiles; + tableOptions.chunkTiles = options.chunkTiles; + tableOptions.maxChunks = options.maxChunks; + tableOptions.layer = options.layer; + auto table = IsoDepthKeyTable::create(tableOptions); if (!table.ok()) return Result::failure(table.error()); TileMap map(options, std::move(table).takeValue()); return Result::success(std::move(map)); @@ -373,16 +695,19 @@ template Status TileMap::setTile(std::int32_t tileX, std::int32_t tileY, std::uint32_t textureId, std::int32_t height, std::uint32_t animationId) noexcept { - // Boundary validation (first failure wins — both failures are + // Boundary validation (first failure wins — all failures are // InvalidArgument, so the order is unobservable): the tile lies in - // the requested grid and the height is in the key domain (the - // table's setTile contract). + // the requested grid, the height is in the key domain (the table's + // setTile contract), and the animationId is in the slot domain + // (0 = the static sentinel; 1..maxAnimations — the slot may be + // UNSET: the declare path's check, not the edit's). if (tileX < options_.originTileX || tileX >= options_.originTileX + options_.widthTiles || tileY < options_.originTileY || tileY >= options_.originTileY + options_.heightTiles || height < -kIsoDepthMaxStepHeight || - height > kIsoDepthMaxStepHeight) { + height > kIsoDepthMaxStepHeight || + animationId > options_.maxAnimations) { return Status(ErrorCode::InvalidArgument); } // The auto-depth wiring (FR-2.6): the height goes into the table, @@ -401,12 +726,13 @@ template Status TileMap::rebuild(std::span tiles) noexcept { const std::size_t cells = tileCount(); if (tiles.size() != cells) return Status(ErrorCode::InvalidArgument); - // The height domain — validated over the WHOLE span before any - // write (a rejected load leaves both the data and the table - // unchanged): + // The height + animationId domains — validated over the WHOLE span + // before any write (a rejected load leaves both the data and the + // table unchanged): for (std::size_t i = 0; i < cells; ++i) { if (tiles[i].height < -kIsoDepthMaxStepHeight || - tiles[i].height > kIsoDepthMaxStepHeight) { + tiles[i].height > kIsoDepthMaxStepHeight || + tiles[i].animationId > options_.maxAnimations) { return Status(ErrorCode::InvalidArgument); } } @@ -445,6 +771,107 @@ Status TileMap::rebuild(std::span tiles) noexcept { return Status{}; } +template +Status TileMap::setAnimation(std::uint32_t id, + const TileAnimationDef& def) noexcept { + // Boundary validation (first failure wins — all failures are + // InvalidArgument, so the order is unobservable): the id domain + // (0 is the static sentinel), the frame count / tick rate domains, + // the sheet extents, the frame count vs the sheet's frame count, + // and the tight sheet's float-exact domain (the M2-SPRITE-03 + // domain — the overflow guards below, the sprite_frames.h + // adversarial-layout precedent: the layout is untrusted metadata, + // CPP-004 / SCALE-004). + if (id == 0 || id > options_.maxAnimations) { + return Status(ErrorCode::InvalidArgument); + } + if (def.frameCount < 1 || def.frameCount > kTileAnimMaxFrames) { + return Status(ErrorCode::InvalidArgument); + } + if (def.frameTicks < 1) { + return Status(ErrorCode::InvalidArgument); + } + const SpriteFrameLayout& layout = def.layout; + if (layout.frameWidth == 0 || layout.frameHeight == 0 || + layout.columns == 0 || layout.rows == 0) { + return Status(ErrorCode::InvalidArgument); + } + // frameCount <= the sheet's frame count (columns x rows — u64: the + // product can exceed 2^32 for adversarial layouts): + const std::uint64_t sheetFrames = + static_cast(layout.columns) * layout.rows; + if (def.frameCount > sheetFrames) { + return Status(ErrorCode::InvalidArgument); + } + // The tight sheet (the M2-SPRITE-03 sheet model — derived from the + // layout; the guards bound each term to the domain BEFORE the + // multiplications — no u64 wrap, CPP-004): + if (layout.sheetBorder > kSpriteFrameMaxAtlasTexels / 2) { + return Status(ErrorCode::InvalidArgument); + } + if (layout.frameWidth > kSpriteFrameMaxAtlasTexels / layout.columns) { + return Status(ErrorCode::InvalidArgument); + } + if (layout.frameHeight > kSpriteFrameMaxAtlasTexels / layout.rows) { + return Status(ErrorCode::InvalidArgument); + } + if (layout.frameSpacing > kSpriteFrameMaxAtlasTexels / layout.columns) { + return Status(ErrorCode::InvalidArgument); + } + const std::uint64_t sheetW = + 2u * static_cast(layout.sheetBorder) + + static_cast(layout.columns) * layout.frameWidth + + static_cast(layout.columns - 1) * layout.frameSpacing; + const std::uint64_t sheetH = + 2u * static_cast(layout.sheetBorder) + + static_cast(layout.rows) * layout.frameHeight + + static_cast(layout.rows - 1) * layout.frameSpacing; + if (sheetW > kSpriteFrameMaxAtlasTexels || + sheetH > kSpriteFrameMaxAtlasTexels) { + return Status(ErrorCode::InvalidArgument); + } + // The frame UVs (the setup allocation — the only per-animation + // allocation): spriteFrameUv is the authoritative conversion; under + // the validated tight sheet it cannot fail (every frame rect fits + // the tight sheet exactly — the fit check is provably inactive) — + // the check is the honest channel for the provably dead path: + auto frameUv = std::make_unique(def.frameCount); + for (std::uint32_t f = 0; f < def.frameCount; ++f) { + const auto r = + spriteFrameUv(f, layout, static_cast(sheetW), + static_cast(sheetH)); + if (!r.ok()) return Status(ErrorCode::InvalidArgument); + frameUv[f] = *r.valueIfOk(); + } + // The (re)set: replace the slot, RESET the phase (frame 0, tick 0): + AnimSlot& slot = anims_[id]; + slot.def = def; + slot.frame = 0; + slot.tick = 0; + slot.frameUv = std::move(frameUv); + slot.valid = true; + return Status{}; +} + +template +void TileMap::advanceAnimations() noexcept { + // The sim phase's per-tick advance (the header's animation + // section): every SET animation's tick advances; its frame steps + // every frameTicks ticks (the tick counter implements + // frame = ticks / frameTicks mod frameCount without a per-call + // division; the tick never reaches frameTicks without the reset, + // so it cannot overflow). Unset slots carry no state. + for (std::uint32_t a = 1; a <= options_.maxAnimations; ++a) { + AnimSlot& s = anims_[a]; + if (!s.valid) continue; + ++s.tick; + if (s.tick == s.def.frameTicks) { + s.tick = 0; + s.frame = (s.frame + 1) % s.def.frameCount; + } + } +} + template Status TileMap::declareTo(SpriteBatcher& batcher, const DeclareOptions& options) noexcept { @@ -452,15 +879,14 @@ Status TileMap::declareTo(SpriteBatcher& batcher, // is closed — call beginFrame first (checked here; first failure // wins, nothing declared). if (batcher.frameBuilt()) return Status(ErrorCode::InvalidArgument); - // The fixed-frame tile quad (the preamble's quad model). The base - // item carries the per-call options; the per-tile fields are set in - // the loop (the add takes the item by value — the declared value is - // a copy, no aliasing of the base). + // The tile quad (the preamble's quad model). The base item carries + // the per-call options; the per-tile fields (position, key, + // texture, the current frame) are set in the loop (the add takes + // the item by value — the declared value is a copy, no aliasing of + // the base). SpriteItem item; - item.uv = options.uv; item.materialId = options.materialId; item.blend = options.blend; - item.frameIndex = 0; // static tile (M2-TILE-02 drives animation) item.rotation = 0.0f; item.scale = Vec2{1.0f, 1.0f}; // the unit quad (the tile's cell) item.depthOverride = false; // the key is the table's (auto-depth) @@ -477,6 +903,74 @@ Status TileMap::declareTo(SpriteBatcher& batcher, // G-R11). item.depthKey = table_.keyAt(tx, ty); item.atlasId = tiles_[i].textureId; + // The tile's current frame (the static fixed frame or the + // animation's frame UV — the unset-slot failure, first failure + // wins, nothing declared past it this frame): + const Status s = applyTileFrame(item, options, i); + if (!s.ok()) return s; + ++i; + const auto r = batcher.add(item); + if (!r.ok()) return r.error(); + } + } + return Status{}; +} + +template +Status TileMap::declareTo(SpriteBatcher& batcher, + const DeclareOptions& options, + const ParallaxLayers& layers, + std::uint32_t layerId, Vec2 cameraPos) + noexcept { + // The frame protocol (the batcher's window): a built frame's window + // is closed — call beginFrame first (checked here; first failure + // wins, nothing declared). + if (batcher.frameBuilt()) return Status(ErrorCode::InvalidArgument); + // The layer pairing (the setup mispairing is the caller's — no log, + // the Status is the channel): the layer is SET and is a Tilemap + // source (this tilemap is its content — the parallax.h hook). + if (!layers.has(layerId)) return Status(ErrorCode::InvalidArgument); + const ParallaxLayerDef& def = layers.layerAt(layerId); + if (def.source != ParallaxSource::Tilemap) { + return Status(ErrorCode::InvalidArgument); + } + // A DISABLED layer declares nothing (OK, no log — the layer's + // documented skip, the parallax declareTo's precedent): + if (!def.enabled) return Status{}; + // The layer's world-space translation (the M2-PAR-01 formula (1) — + // world space, RENDER-006; the camera position is the presentation + // input, ARCH-009): + const Vec2 o = layers.worldOffsetAt(layerId, cameraPos); + // The content's layer (the tilemap's — the scene-setup convention: + // the def's depthLayer equals it; one source of truth): + const std::int32_t layer = table_.layer(); + SpriteItem item; + item.materialId = options.materialId; + item.blend = options.blend; + item.rotation = 0.0f; + item.scale = Vec2{1.0f, 1.0f}; // the unit quad (the tile's cell) + item.depthOverride = false; // the key is engine-owned (G-R11) + std::size_t i = 0; + for (std::int32_t ty = options_.originTileY; + ty < options_.originTileY + options_.heightTiles; ++ty) { + for (std::int32_t tx = options_.originTileX; + tx < options_.originTileX + options_.widthTiles; ++tx) { + // The TRANSLATED tile center (the world-space translation): + item.pos = Vec2{static_cast(tx) + 0.5f + o.x, + static_cast(ty) + 0.5f + o.y}; + // The depth key of the translated center (the table's keys are + // at the untranslated positions — the camera-dependent + // translation is not precomputable; the M2-ISO-01 function at + // the tile's stored height, the tilemap's layer — G-R11): + item.depthKey = isoDepthKey( + backendVec2(item.pos.x, item.pos.y), table_.tileHeightAt(tx, ty), + layer); + item.atlasId = tiles_[i].textureId; + // The tile's current frame (the static fixed frame or the + // animation's frame UV — the unset-slot failure, first failure + // wins, nothing declared past it this frame): + const Status s = applyTileFrame(item, options, i); + if (!s.ok()) return s; ++i; const auto r = batcher.add(item); if (!r.ok()) return r.error(); diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 6954e7e..33fc94d 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -135,7 +135,28 @@ # rects (incl. the atlas sub-rect mapping), the frame protocol / # stopped / disabled / tilemap-hook paths, cross-frame determinism, # and the 1000-frame zero-allocation declare loop) — pure data + -# batcher bookkeeping: no GL environment needed. +# batcher bookkeeping: no GL environment needed; and the tilemap +# tile animation + parallax tile layer (M2-TILE-02) — the +# data-driven per-tile frame cycle (tilemap_anim_tests.cpp's +# TileMapAnim* suites: the setAnimation validation matrix (the slot +# domain, the frame count / tick rate / sheet domains, the tight-sheet +# float-exact domain with the adversarial-layout overflow guards, the +# rejected-set-leaves-no-state + phase-reset contract, no-log happy +# path), the frame cycle at the documented rate (hand-computed frame +# = ticks / frameTicks mod frameCount over independent animations), +# the animated/static frame fields on the declared quads (the +# hand-computed frame-UV goldens from the M2-SPRITE-03 tight-sheet +# formula, the frameIndex, the (atlas, material, blend) groups, the +# wrap + cross-frame determinism), the parallax tile layer declare +# path (the M2-PAR-01 Tilemap-source hook — the worldOffset +# translation + the tilemap's own depth layer: the hand-computed +# key goldens at given camera positions (both backends — the dyadic +# exactness zone) + the layer-dominance ordering + the protocol +# paths (built frame / unset id / non-tilemap source / disabled +# layer) + the animated tile under the translation), the animationId +# edit/load domain + the unset-slot declare failure, and the 1000-frame +# advance + declare zero-allocation loop (standalone + parallax)) — +# pure data + batcher bookkeeping: no GL environment needed. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -159,9 +180,12 @@ # (`ctest -R sprite_frames`), the `render_counters` entry is the # M2-SPRITE-04 Verify command (`ctest -R render_counters`), the # `tilemap` entry is the M2-TILE-01 Verify command (`ctest -R -# tilemap`), and the `parallax` entry is the M2-PAR-01 Verify command -# (`ctest -R parallax`), each selecting exactly its suites from the -# shared executable. +# tilemap`), the `tilemap_anim` entry is the M2-TILE-02 Verify command +# (`ctest -R tilemap_anim`), and the `parallax` entry is the M2-PAR-01 +# Verify command (`ctest -R parallax`), each selecting exactly its +# suites from the shared executable. (The unanchored `ctest -R tilemap` +# regex also selects the `tilemap_anim` entry — the M2-TILE-02 suites +# run under the M2-TILE-01 command too; that is intended.) # # Environment note: the GlContextSmoke, RenderThreadOffscreen, # SpriteDraw{State,Smoke,Pipeline}, and RenderCounters{Scene,Cap, @@ -172,7 +196,7 @@ # documented environment contract (docs/api/gl_context.md), not an # engine failure. The GlContextGate/GlContextArgs/FrameClock/ # RenderThreadHandoff/SpriteDrawCreate/RenderCountersCreate/TileMap*/ -# Parallax* suites always run (they make no GL calls). +# TileMapAnim*/Parallax* suites always run (they make no GL calls). add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp @@ -180,7 +204,7 @@ add_executable(laige-render_tests iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp - parallax_tests.cpp) + tilemap_anim_tests.cpp parallax_tests.cpp) laige_apply_engine_policy(laige-render_tests) target_link_libraries(laige-render_tests PRIVATE gtest_main laige-render) # The randomized suites draw through the test-only seed helper @@ -334,6 +358,15 @@ add_test(NAME tilemap COMMAND laige-render_tests add_test(NAME parallax COMMAND laige-render_tests --gtest_filter=Parallax*) +# M2-TILE-02: the step's Verify command is `ctest -R tilemap_anim`. +# Pure data + batcher bookkeeping (no GL calls) — runs in every local +# tree and in CI. No budget gate: the step's roadmap scope has no +# standalone budgets.json entry (the per-frame declare + per-tick +# advance cost is part of the composite 50k render-CPU budget, +# measured with M2-PERF-01). +add_test(NAME tilemap_anim COMMAND laige-render_tests + --gtest_filter=TileMapAnim*) + if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUDGET=1) target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_PICK_BUDGET=1) @@ -370,7 +403,7 @@ if(LAIGE_TSAN) set_tests_properties(laige-render_tests gl_context render_thread matrices iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames - render_counters tilemap parallax PROPERTIES + render_counters tilemap tilemap_anim parallax PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -432,6 +465,7 @@ set_tests_properties(iso_picking PROPERTIES TIMEOUT 60) set_tests_properties(batcher PROPERTIES TIMEOUT 60) set_tests_properties(sprite_frames PROPERTIES TIMEOUT 60) set_tests_properties(tilemap PROPERTIES TIMEOUT 60) +set_tests_properties(tilemap_anim PROPERTIES TIMEOUT 60) set_tests_properties(parallax PROPERTIES TIMEOUT 60) # The `iso_depth_table` and `depth_sort` entries include their budget # gates: 2 backends x (100 warm-up + 3000 measured) iterations of 10 000 diff --git a/tests/laige-render/tilemap_anim_tests.cpp b/tests/laige-render/tilemap_anim_tests.cpp new file mode 100644 index 0000000..5dee2b5 --- /dev/null +++ b/tests/laige-render/tilemap_anim_tests.cpp @@ -0,0 +1,1031 @@ +// laige-render tilemap animation + parallax tile layer tests +// (M2-TILE-02): the data-driven per-tile frame cycle (the documented +// tick rate), the tilemap's declare path under a parallax Tilemap- +// source layer (the M2-PAR-01 hook — the worldOffset translation + +// the tilemap's own depth layer), and the declared quad goldens — in +// laige/render/tilemap.h. +// +// Pure data + batcher bookkeeping — no GL context, no GL environment +// needed: every suite runs in every local tree and in CI. The UV +// goldens are HAND-COMPUTED from the tight-sheet formula (M2-SPRITE- +// 03: frame (col, row) of a fw x fh frame grid normalized into the +// tight sheet); the frame sequence is hand-computed from the +// documented rate (frame = ticks / frameTicks mod frameCount); the +// parallax offsets are hand-computed from the pinned M2-PAR-01 +// formula (factor * (p - center) + offset) at dyadic values; the +// depth-key goldens are hand-computed from the M2-ISO-01 formula +// ((l + 512) << 22 | (d + 2^21), d = 16 * (x + y) - 16 * h at the +// translated centers), and the independent oracle is the M2-ISO-01 +// function itself on a world point (the tilemap test pattern — the +// test builds its own points, never reusing the tilemap's code). The +// zero-allocation window covers the per-tick advance + per-frame +// declare loop (FR-2.2 "no per-frame allocation" — the sanitizer +// trees run the same workload shapes leak-free instead, +// methodology §4). +// +// No budget gate: the step's roadmap scope has no standalone +// budgets.json entry (the per-frame declare cost is part of the +// composite 50k render-CPU budget — M2-PERF-01). + +#include "laige/render/iso_depth_key.h" +#include "laige/render/parallax.h" +#include "laige/render/sprite_batcher.h" +#include "laige/render/tilemap.h" + +#include +#include +#include +#if !defined(_MSC_VER) +#include // dladdr (the alloc-site module/symbol, POSIX) +#endif +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim_math.h" + +namespace { + +using laige::fpx16_16; +using laige::render::BlendMode; +using laige::render::ParallaxLayerDef; +using laige::render::ParallaxLayers; +using laige::render::ParallaxSource; +using laige::render::SpriteBatcher; +using laige::render::SpriteItem; +using laige::render::SpriteUvRect; +using laige::render::TileAnimationDef; +using laige::render::TileData; +using laige::render::TileMap; +using laige::render::kParallaxDepthLayerBackground; +using laige::sim::Fp32Pinned; +using laige::sim::Fpx16_16; + +// --------------------------------------------------------------------------- +// The independent oracle: the M2-ISO-01 function on a (possibly +// parallax-translated) world point — the tilemap's declared keys must +// equal it (the dyadic points are in the exactness zone of both +// backends — the bit-identity contract). The test converts the float +// point into the backend's Vec2 itself (never reusing the tilemap's +// conversion). +// --------------------------------------------------------------------------- + +template +laige::sim::SimMath::Vec2 backendVec2(float x, float y) { + using M = laige::sim::SimMath; + if constexpr (std::is_same_v) { + return typename M::Vec2{x, y}; + } else { + return typename M::Vec2{fpx16_16::fromFloat(x), fpx16_16::fromFloat(y)}; + } +} + +template +std::uint32_t oracleKeyAt(float x, float y, std::int32_t height, + std::int32_t layer) { + return laige::render::isoDepthKey(backendVec2(x, y), + height, layer); +} + +// The hand-computed 4-frame UV goldens (the 2 x 2 grid of 16 x 16 +// frames, tight sheet 32 x 32 — the M2-SPRITE-03 formula, frame 0 at +// the top-left): +// frame 0: (0, 0, 0.5, 0.5) +// frame 1: (0.5, 0, 1, 0.5) +// frame 2: (0, 0.5, 0.5, 1) +// frame 3: (0.5, 0.5, 1, 1) +void expectUv(const SpriteUvRect& uv, float u0, float v0, float u1, float v1, + const std::string& ctx) { + EXPECT_EQ(uv.u0, u0) << ctx; + EXPECT_EQ(uv.v0, v0) << ctx; + EXPECT_EQ(uv.u1, u1) << ctx; + EXPECT_EQ(uv.v1, v1) << ctx; +} + +// A 4-frame animation on a 2 x 2 grid of 8 x 8 frames (the validation +// / cycle scene's def — the tight sheet is 16 x 16): +TileAnimationDef fourFrames() { + TileAnimationDef def; + def.frameCount = 4; + def.frameTicks = 2; + def.layout.frameWidth = 8; + def.layout.frameHeight = 8; + def.layout.columns = 2; + def.layout.rows = 2; + return def; +} + +// --------------------------------------------------------------------------- +// Rejection pin (the tilemap_tests pattern) +// --------------------------------------------------------------------------- + +void expectRejected(const laige::Status& st, laige::ErrorCode code) { + EXPECT_TRUE(st.isError()) << "expected failure, got ok"; + if (st.isError()) { + EXPECT_EQ(st.error(), code); + } +} + +// --------------------------------------------------------------------------- +// Log capture (the tilemap_tests MemorySink pattern) +// --------------------------------------------------------------------------- + +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + }; + + 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; + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +// Installs a fresh capture sink (heap-owned by the logger) with rate +// limiting OFF (the tilemap_tests pattern). +MemorySink* installCaptureSink() { + 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"; + } +} + +// --------------------------------------------------------------------------- +// The 2 x 2 parallax scene (the golden / protocol suites): the +// tilemap (layer -2, the bg tile layer — terrain (0,0)=0, (1,0)=1, +// (0,1)=0, (1,1)=2, all texture 5) + the registry + the Tilemap- +// source layer (factor 0.25, center (4, 4), offset (1, 2), the bg +// preset). TileMap is non-default-constructible (move-only, the +// M2-TILE-01 pattern), so each test builds its scene inline. +// --------------------------------------------------------------------------- + +template +ParallaxLayerDef bgTilemapLayerDef() { + ParallaxLayerDef def; + def.id = 0; // the bg preset + def.source = ParallaxSource::Tilemap; + def.factor = 0.25f; + def.center = laige::render::Vec2{4.0f, 4.0f}; + def.offset = laige::render::Vec2{1.0f, 2.0f}; + def.tilemapId = 7; + def.depthLayer = kParallaxDepthLayerBackground; // == the tilemap's layer + return def; +} + +template +laige::Result> bgTilemap() { + typename TileMap::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + o.layer = kParallaxDepthLayerBackground; // the bg tile layer + return TileMap::create(o); +} + +// --------------------------------------------------------------------------- +// TileMapAnimSet — the setAnimation validation (first failure wins, +// the slot unchanged on rejection) +// --------------------------------------------------------------------------- + +template +void setValidation() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + ASSERT_EQ(m.maxAnimations(), 8u); // the default + + const TileAnimationDef good = fourFrames(); + // The id domain (0 = the static sentinel; 1..maxAnimations): + expectRejected(m.setAnimation(0, good), laige::ErrorCode::InvalidArgument); + expectRejected(m.setAnimation(9, good), laige::ErrorCode::InvalidArgument); + // The frame-count domain [1, kTileAnimMaxFrames]: + TileAnimationDef zeroFrames = good; + zeroFrames.frameCount = 0; + expectRejected(m.setAnimation(1, zeroFrames), + laige::ErrorCode::InvalidArgument); + TileAnimationDef tooManyFrames = good; + tooManyFrames.frameCount = 65; + expectRejected(m.setAnimation(1, tooManyFrames), + laige::ErrorCode::InvalidArgument); + // 64 frames on an 8 x 8 sheet — the accepted boundary: + TileAnimationDef maxFrames = good; + maxFrames.frameCount = 64; + maxFrames.layout.columns = 8; + maxFrames.layout.rows = 8; + ASSERT_TRUE(m.setAnimation(2, maxFrames).ok()); + // The tick-rate domain (>= 1): + TileAnimationDef noTicks = good; + noTicks.frameTicks = 0; + expectRejected(m.setAnimation(1, noTicks), + laige::ErrorCode::InvalidArgument); + // The sheet extents (all four > 0): + TileAnimationDef zeroFw = good; + zeroFw.layout.frameWidth = 0; + expectRejected(m.setAnimation(1, zeroFw), laige::ErrorCode::InvalidArgument); + TileAnimationDef zeroFh = good; + zeroFh.layout.frameHeight = 0; + expectRejected(m.setAnimation(1, zeroFh), laige::ErrorCode::InvalidArgument); + TileAnimationDef zeroCols = good; + zeroCols.layout.columns = 0; + expectRejected(m.setAnimation(1, zeroCols), laige::ErrorCode::InvalidArgument); + TileAnimationDef zeroRows = good; + zeroRows.layout.rows = 0; + expectRejected(m.setAnimation(1, zeroRows), laige::ErrorCode::InvalidArgument); + // frameCount beyond the sheet's frame count (2 x 2 = 4 frames): + TileAnimationDef beyondSheet = good; + beyondSheet.frameCount = 5; + expectRejected(m.setAnimation(1, beyondSheet), + laige::ErrorCode::InvalidArgument); + // The tight sheet beyond the float-exact domain (the adversarial + // layout — the u64 overflow guard: sheetW = 2 + 200000000 > 2^24): + TileAnimationDef wide; + wide.frameCount = 1; + wide.frameTicks = 1; + wide.layout.frameWidth = 200000000; + wide.layout.frameHeight = 1; + wide.layout.columns = 1; + wide.layout.rows = 1; + wide.layout.sheetBorder = 1; + expectRejected(m.setAnimation(3, wide), laige::ErrorCode::InvalidArgument); + // A rejected set leaves the slot unchanged: + ASSERT_TRUE(m.setAnimation(5, good).ok()); + m.advanceAnimations(); // tick 1 < frameTicks 2 — frame stays 0 + expectRejected(m.setAnimation(5, noTicks), laige::ErrorCode::InvalidArgument); + ASSERT_TRUE(m.hasAnimation(5)); + const TileAnimationDef& d5 = m.animationAt(5); + EXPECT_EQ(d5.frameCount, good.frameCount); + EXPECT_EQ(d5.frameTicks, good.frameTicks); + EXPECT_EQ(d5.layout.frameWidth, good.layout.frameWidth); + EXPECT_EQ(d5.layout.frameHeight, good.layout.frameHeight); + EXPECT_EQ(d5.layout.columns, good.layout.columns); + EXPECT_EQ(d5.layout.rows, good.layout.rows); + EXPECT_EQ(d5.layout.frameSpacing, good.layout.frameSpacing); + EXPECT_EQ(d5.layout.sheetBorder, good.layout.sheetBorder); + EXPECT_EQ(m.animationFrame(5), 0u); + // Unset slots (and the sentinel): + EXPECT_FALSE(m.hasAnimation(0)); + EXPECT_FALSE(m.hasAnimation(1)); + // The boundary id: + ASSERT_TRUE(m.setAnimation(m.maxAnimations(), good).ok()); + ASSERT_TRUE(m.hasAnimation(m.maxAnimations())); + // A replace RESETS the phase (frame 0, tick 0): + ASSERT_TRUE(m.setAnimation(5, good).ok()); + m.advanceAnimations(); + m.advanceAnimations(); // 2 ticks / 2 ticks per frame = frame 1 + EXPECT_EQ(m.animationFrame(5), 1u); + ASSERT_TRUE(m.setAnimation(5, good).ok()); + EXPECT_EQ(m.animationFrame(5), 0u); +} + +TEST(TileMapAnimSet, Validation) { + setValidation(); + setValidation(); +} + +TEST(TileMapAnimSet, NoLogsOnHappyPath) { + MemorySink* sink = installCaptureSink(); + TileMap::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // The create's table events (iso_depth_table_created, the chunk + // creation) are captured; the happy animation/declare paths log + // nothing (the Status is the failure channel — LOG-002, and the + // per-tick advance is a sim-phase hot path — LOG-003): + sink->entries.clear(); + ASSERT_TRUE(m.setAnimation(1, fourFrames()).ok()); + ASSERT_TRUE(m.setTile(0, 0, 1, 0, 1).ok()); + m.advanceAnimations(); + SpriteBatcher::Options bo; + bo.maxSprites = 4; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, TileMap::DeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + EXPECT_EQ(sink->entries.size(), 0u) + << "happy setAnimation/advance/declare path logged " + << sink->entries.size() << " events (first: " + << (sink->entries.empty() ? "n/a" : sink->entries[0].event) << ")"; + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// TileMapAnimCycle — the frame cycle at the documented rate +// --------------------------------------------------------------------------- + +template +void cycleRate() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 1; + o.heightTiles = 1; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + // Two independent animations (the phase is per animation, not per + // tile): + ASSERT_TRUE(m.setAnimation(1, fourFrames()).ok()); // 4 frames / 2 ticks + TileAnimationDef three; + three.frameCount = 3; + three.frameTicks = 1; + three.layout.frameWidth = 8; + three.layout.frameHeight = 8; + three.layout.columns = 3; + three.layout.rows = 1; + ASSERT_TRUE(m.setAnimation(2, three).ok()); // 3 frames / 1 tick + // The documented rate: frame(ticks) = (ticks / frameTicks) mod + // frameCount — hand-computed over 11 ticks: + // anim 1 (4 / 2): 0 0 1 1 2 2 3 3 0 0 1 + // anim 2 (3 / 1): 0 1 2 0 1 2 0 1 2 0 1 + for (std::uint32_t n = 0; n <= 10; ++n) { + if (n > 0) m.advanceAnimations(); + EXPECT_EQ(m.animationFrame(1), (n / 2) % 4) << "tick " << n; + EXPECT_EQ(m.animationFrame(2), n % 3) << "tick " << n; + } + // The unset slot: the advance is a no-op (no state, no crash): + m.advanceAnimations(); + EXPECT_FALSE(m.hasAnimation(3)); +} + +TEST(TileMapAnimCycle, DocumentedRate) { + cycleRate(); + cycleRate(); +} + +// --------------------------------------------------------------------------- +// TileMapAnimDeclare — the animated/static frame fields on the +// declared quads (the UV goldens, the frameIndex, the groups) +// --------------------------------------------------------------------------- + +template +void frameUvGoldens() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + // The scene (row-major): (0,0) static texture 1, (1,0) animated + // texture 2, (0,1) static texture 1, (1,1) animated texture 3 + // (flat ground — the auto-depth keys are the M2-TILE-01 goldens): + ASSERT_TRUE(m.setTile(0, 0, 1, 0, 0).ok()); + ASSERT_TRUE(m.setTile(1, 0, 2, 0, 1).ok()); + ASSERT_TRUE(m.setTile(0, 1, 1, 0, 0).ok()); + ASSERT_TRUE(m.setTile(1, 1, 3, 0, 1).ok()); + TileAnimationDef anim; + anim.frameCount = 4; + anim.frameTicks = 2; + anim.layout.frameWidth = 16; + anim.layout.frameHeight = 16; + anim.layout.columns = 2; + anim.layout.rows = 2; // the tight sheet is 32 x 32 + ASSERT_TRUE(m.setAnimation(1, anim).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 8; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(batcher.frameCount(), 4u); + // Frame 0 (tick 0 — the fresh phase). Hand-computed goldens (the + // layer-0 key = 0x80000000 | (d + 2^21), d = 16 * (tx + ty + 1)): + // slot 0 (0,0): pos (0.5, 0.5), key 0x80200010, STATIC: + // uv (0, 0, 1, 1), frameIndex 0, atlas 1 + // slot 1 (1,0): pos (1.5, 0.5), key 0x80200020, ANIMATED: + // uv frame 0 (0, 0, 0.5, 0.5), frameIndex 0, atlas 2 + // slot 2 (0,1): pos (0.5, 1.5), key 0x80200020, STATIC: + // uv (0, 0, 1, 1), frameIndex 0, atlas 1 + // slot 3 (1,1): pos (1.5, 1.5), key 0x80200030, ANIMATED: + // uv frame 0 (0, 0, 0.5, 0.5), frameIndex 0, atlas 3 + struct Expect { + float x, y; + std::uint32_t key; + bool animated; + std::uint32_t atlas; + }; + constexpr Expect kExp[4] = { + {0.5f, 0.5f, 0x80200010u, false, 1u}, + {1.5f, 0.5f, 0x80200020u, true, 2u}, + {0.5f, 1.5f, 0x80200020u, false, 1u}, + {1.5f, 1.5f, 0x80200030u, true, 3u}}; + for (int i = 0; i < 4; ++i) { + const SpriteItem* item = batcher.get(static_cast(i)); + ASSERT_NE(item, nullptr) << "tile " << i; + EXPECT_EQ(item->pos.x, kExp[i].x) << "tile " << i; + EXPECT_EQ(item->pos.y, kExp[i].y) << "tile " << i; + EXPECT_EQ(item->depthKey, kExp[i].key) << "tile " << i; + EXPECT_EQ(item->depthKey, + oracleKeyAt(kExp[i].x, kExp[i].y, 0, 0)) + << "tile " << i; + EXPECT_FALSE(item->depthOverride) << "tile " << i; + EXPECT_EQ(item->atlasId, kExp[i].atlas) << "tile " << i; + EXPECT_EQ(item->frameIndex, 0u) << "tile " << i; + if (kExp[i].animated) { + expectUv(item->uv, 0.0f, 0.0f, 0.5f, 0.5f, "tile " + std::to_string(i)); + } else { + expectUv(item->uv, 0.0f, 0.0f, 1.0f, 1.0f, "tile " + std::to_string(i)); + } + } + // The (atlas, material, blend) groups: 3 (atlas 1/2/3), ascending; + // the atlas-1 group's instances in sorted key order [0, 2]: + ASSERT_EQ(batcher.batchCount(), 3u); + const auto& batches = batcher.batches(); + EXPECT_EQ(batches[0].atlasId, 1u); + EXPECT_EQ(batches[0].instances.size(), 2u); + EXPECT_EQ(batches[0].instances[0], 0u); + EXPECT_EQ(batches[0].instances[1], 2u); + EXPECT_EQ(batches[1].atlasId, 2u); + EXPECT_EQ(batches[1].instances.size(), 1u); + EXPECT_EQ(batches[1].instances[0], 1u); + EXPECT_EQ(batches[2].atlasId, 3u); + EXPECT_EQ(batches[2].instances.size(), 1u); + EXPECT_EQ(batches[2].instances[0], 3u); +} + +TEST(TileMapAnimDeclare, FrameUvGoldens) { + frameUvGoldens(); + frameUvGoldens(); +} + +template +void frameWrapAndDeterminism() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + ASSERT_TRUE(m.setTile(0, 0, 1, 0, 0).ok()); + ASSERT_TRUE(m.setTile(1, 0, 2, 0, 1).ok()); + ASSERT_TRUE(m.setTile(0, 1, 1, 0, 0).ok()); + ASSERT_TRUE(m.setTile(1, 1, 3, 0, 1).ok()); + TileAnimationDef anim; + anim.frameCount = 4; + anim.frameTicks = 2; + anim.layout.frameWidth = 16; + anim.layout.frameHeight = 16; + anim.layout.columns = 2; + anim.layout.rows = 2; + ASSERT_TRUE(m.setAnimation(1, anim).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 8; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + // One declare: the animated tile (slot 1) — the pointer; the null + // checks live in the test (ASSERTs are void-returning, not allowed + // in a value-returning lambda): + auto declareAnimated = [&]() -> const SpriteItem* { + batcher.beginFrame(); + if (!m.declareTo(batcher, typename Map::DeclareOptions{}).ok()) { + return nullptr; + } + if (!batcher.build().ok()) return nullptr; + return batcher.get(1); + }; + // The 4-frame / 2-tick cycle: the frame steps every 2 ticks and + // WRAPS at frameCount (tick 0 → frame 0, tick 2 → frame 1, tick 4 + // → frame 2, tick 6 → frame 3, tick 8 → frame 0, tick 10 → frame + // 1). The UV goldens are per frame (the tight-sheet 2 x 2 grid of + // 16 x 16 frames — the M2-SPRITE-03 formula): + struct Expect { + float u0, v0, u1, v1; + }; + constexpr Expect kFrameUv[4] = { + {0.0f, 0.0f, 0.5f, 0.5f}, + {0.5f, 0.0f, 1.0f, 0.5f}, + {0.0f, 0.5f, 0.5f, 1.0f}, + {0.5f, 0.5f, 1.0f, 1.0f}}; + const SpriteItem* p0 = declareAnimated(); // tick 0: frame 0 + ASSERT_NE(p0, nullptr); + EXPECT_EQ(p0->frameIndex, 0u); + expectUv(p0->uv, 0.0f, 0.0f, 0.5f, 0.5f, "tick 0"); + for (int step = 1; step <= 5; ++step) { + m.advanceAnimations(); + m.advanceAnimations(); // one frame (2 ticks) + const SpriteItem* p = declareAnimated(); + ASSERT_NE(p, nullptr) << "step " << step; + const std::uint32_t frame = static_cast(step % 4); + EXPECT_EQ(p->frameIndex, frame) << "step " << step; + expectUv(p->uv, kFrameUv[frame].u0, kFrameUv[frame].v0, + kFrameUv[frame].u1, kFrameUv[frame].v1, "step " + std::to_string(step)); + } + // Cross-frame determinism: the same animation state declares + // BIT-IDENTICAL items (same data + same frame state → same batch): + auto snapshot = [&]() { + batcher.beginFrame(); + if (!m.declareTo(batcher, typename Map::DeclareOptions{}).ok()) { + return std::vector{}; + } + if (!batcher.build().ok()) return std::vector{}; + std::vector items(4); + for (std::uint32_t i = 0; i < 4; ++i) { + const SpriteItem* it = batcher.get(i); + if (it == nullptr) return std::vector{}; + items[i] = *it; + } + return items; + }; + const std::vector a = snapshot(); + ASSERT_EQ(a.size(), 4u); + const std::vector b = snapshot(); + ASSERT_EQ(b.size(), 4u); + for (std::uint32_t i = 0; i < 4; ++i) { + EXPECT_EQ(a[i].pos.x, b[i].pos.x) << "tile " << i; + EXPECT_EQ(a[i].pos.y, b[i].pos.y) << "tile " << i; + EXPECT_EQ(a[i].depthKey, b[i].depthKey) << "tile " << i; + // SpriteUvRect carries no operator== (the M2-SPRITE-01 value + // type) — the fields: + EXPECT_EQ(a[i].uv.u0, b[i].uv.u0) << "tile " << i; + EXPECT_EQ(a[i].uv.v0, b[i].uv.v0) << "tile " << i; + EXPECT_EQ(a[i].uv.u1, b[i].uv.u1) << "tile " << i; + EXPECT_EQ(a[i].uv.v1, b[i].uv.v1) << "tile " << i; + EXPECT_EQ(a[i].frameIndex, b[i].frameIndex) << "tile " << i; + EXPECT_EQ(a[i].atlasId, b[i].atlasId) << "tile " << i; + } +} + +TEST(TileMapAnimDeclare, FrameWrapAndDeterminism) { + frameWrapAndDeterminism(); + frameWrapAndDeterminism(); +} + +// --------------------------------------------------------------------------- +// TileMapAnimParallax — the parallax tile layer: the worldOffset +// translation goldens at given camera positions (the M2-PAR-01 hook) +// --------------------------------------------------------------------------- + +// The scene setup (each test builds it inline — TileMap is +// move-only, no default constructor, the M2-TILE-01 pattern): the +// bgTilemap() tilemap + the terrain + the registry/layer + the +// 8-slot batcher. +template +bool bgTerrain(TileMap& m) { + return m.setTile(0, 0, 5, 0, 0).ok() && m.setTile(1, 0, 5, 1, 0).ok() && + m.setTile(0, 1, 5, 0, 0).ok() && m.setTile(1, 1, 5, 2, 0).ok(); +} + +template +ParallaxLayers bgLayers() { + typename ParallaxLayers::Options lo; + auto rl = ParallaxLayers::create(lo); + if (!rl.ok()) { + ADD_FAILURE() << "parallax registry create failed"; + return ParallaxLayers(); // the stopped state + } + ParallaxLayers layers = std::move(rl).takeValue(); + if (!layers.setLayer(bgTilemapLayerDef()).ok()) { + ADD_FAILURE() << "bg layer set failed"; + return ParallaxLayers(); // the stopped state + } + return layers; +} + +template +void goldenOffsets() { + using Map = TileMap; + auto r = bgTilemap(); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + ASSERT_TRUE(bgTerrain(m)); + ParallaxLayers layers = bgLayers(); + SpriteBatcher::Options bo; + bo.maxSprites = 8; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + SpriteBatcher batcher = std::move(rb).takeValue(); + // The ground probe (layer 0) — the layer-dominance check: + auto declareFrame = [&](laige::render::Vec2 cameraPos) { + batcher.beginFrame(); + SpriteItem ground; + ground.pos = laige::render::Vec2{0.5f, 0.5f}; + ground.depthKey = oracleKeyAt(0.5f, 0.5f, 0, 0); + ground.atlasId = 99; + ASSERT_TRUE(batcher.add(ground).ok()); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}, layers, 0, + cameraPos).ok()); + ASSERT_TRUE(batcher.build().ok()); + }; + // Camera (10, 6): offset = 0.25 * (10 - 4, 6 - 4) + (1, 2) + // = 0.25 * (6, 2) + (1, 2) = (2.5, 2.5) + declareFrame(laige::render::Vec2{10.0f, 6.0f}); + // Hand-computed keys (l = -2: (510 << 22) | (d + 2^21); d = + // 16 * (x + y) - 16 * h at the TRANSLATED centers; the 2^21 bias + // adds into bit 21, as in the ground-layer golden 0x80200010): + // (0,0) → (3, 3): d = 96 - 0 = 96 → 0x7FA00060 + // (1,0) → (4, 3): d = 112 - 16 = 96 → 0x7FA00060 + // (0,1) → (3, 4): d = 112 - 0 = 112 → 0x7FA00070 + // (1,1) → (4, 4): d = 128 - 32 = 96 → 0x7FA00060 + struct Expect { + float x, y; + std::uint32_t key; + std::int32_t h; + }; + constexpr Expect kExp[4] = { + {3.0f, 3.0f, 0x7FA00060u, 0}, + {4.0f, 3.0f, 0x7FA00060u, 1}, + {3.0f, 4.0f, 0x7FA00070u, 0}, + {4.0f, 4.0f, 0x7FA00060u, 2}}; + ASSERT_EQ(batcher.frameCount(), 5u); + for (int i = 0; i < 4; ++i) { + const SpriteItem* item = batcher.get(1u + static_cast(i)); + ASSERT_NE(item, nullptr) << "tile " << i; + EXPECT_EQ(item->pos.x, kExp[i].x) << "tile " << i; + EXPECT_EQ(item->pos.y, kExp[i].y) << "tile " << i; + EXPECT_EQ(item->depthKey, kExp[i].key) << "tile " << i; + EXPECT_EQ(item->depthKey, + oracleKeyAt(kExp[i].x, kExp[i].y, kExp[i].h, -2)) + << "tile " << i; + EXPECT_EQ(item->atlasId, 5u) << "tile " << i; + EXPECT_FALSE(item->depthOverride) << "tile " << i; + } + // The ground probe (layer 0) sorts AFTER every bg tile — the layer + // field dominates the key (the documented background-first order): + const SpriteItem* g = batcher.get(0); + ASSERT_NE(g, nullptr); + EXPECT_EQ(g->depthKey, 0x80200010u); + for (int i = 0; i < 4; ++i) { + EXPECT_LT(batcher.get(1u + static_cast(i))->depthKey, + g->depthKey) + << "tile " << i; + } +} + +TEST(TileMapAnimParallax, GoldenOffsets) { + goldenOffsets(); + goldenOffsets(); +} + +template +void offsetFollowsCamera() { + using Map = TileMap; + auto r = bgTilemap(); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + ASSERT_TRUE(bgTerrain(m)); + ParallaxLayers layers = bgLayers(); + SpriteBatcher::Options bo; + bo.maxSprites = 8; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + SpriteBatcher batcher = std::move(rb).takeValue(); + // Camera (4, 4) = the layer's center: offset (1, 2) — tile (0,0) + // at (1.5, 2.5): + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}, layers, 0, + laige::render::Vec2{4.0f, 4.0f}).ok()); + ASSERT_TRUE(batcher.build().ok()); + const SpriteItem* t00 = batcher.get(0); + ASSERT_NE(t00, nullptr); + EXPECT_EQ(t00->pos.x, 1.5f); + EXPECT_EQ(t00->pos.y, 2.5f); + // d = 16 * 4 - 0 = 64 → (510 << 22) | (2^21 + 64) = 0x7FA00040: + EXPECT_EQ(t00->depthKey, 0x7FA00040u); + EXPECT_EQ(t00->depthKey, oracleKeyAt(1.5f, 2.5f, 0, -2)); + // Camera (14, 8): offset = 0.25 * (10, 4) + (1, 2) = (3.5, 3) — + // tile (0,0) at (4, 3.5): + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}, layers, 0, + laige::render::Vec2{14.0f, 8.0f}).ok()); + ASSERT_TRUE(batcher.build().ok()); + const SpriteItem* t01 = batcher.get(0); + ASSERT_NE(t01, nullptr); + EXPECT_EQ(t01->pos.x, 4.0f); + EXPECT_EQ(t01->pos.y, 3.5f); + // d = 16 * 7.5 - 0 = 120 → (510 << 22) | (2^21 + 120) = 0x7FA00078: + EXPECT_EQ(t01->depthKey, 0x7FA00078u); + EXPECT_EQ(t01->depthKey, oracleKeyAt(4.0f, 3.5f, 0, -2)); +} + +TEST(TileMapAnimParallax, OffsetFollowsCamera) { + offsetFollowsCamera(); + offsetFollowsCamera(); +} + +TEST(TileMapAnimParallax, Protocol) { + using Map = TileMap; + auto r = bgTilemap(); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + ASSERT_TRUE(bgTerrain(m)); + ParallaxLayers layers = bgLayers(); + SpriteBatcher::Options bo; + bo.maxSprites = 8; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + SpriteBatcher batcher = std::move(rb).takeValue(); + const laige::render::Vec2 p{10.0f, 6.0f}; + const typename Map::DeclareOptions opts{}; + // A built frame's window is closed — rejected, nothing declared: + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, opts, layers, 0, p).ok()); + ASSERT_TRUE(batcher.build().ok()); + expectRejected(m.declareTo(batcher, opts, layers, 0, p), + laige::ErrorCode::InvalidArgument); + batcher.beginFrame(); + EXPECT_EQ(batcher.frameCount(), 0u); + // An unset layer id: + expectRejected(m.declareTo(batcher, opts, layers, 1, p), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(batcher.frameCount(), 0u); + // A non-Tilemap-source layer (the Image def on id 1): + laige::render::ParallaxLayerDef img; + img.id = 1; + img.factor = 0.5f; + img.size = laige::render::Vec2{8.0f, 8.0f}; + img.atlasId = 50; + ASSERT_TRUE(layers.setLayer(img).ok()); + expectRejected(m.declareTo(batcher, opts, layers, 1, p), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(batcher.frameCount(), 0u); + // A DISABLED Tilemap layer: nothing declared (OK, no log — the + // layer's documented skip): + laige::render::ParallaxLayerDef off = bgTilemapLayerDef(); + off.enabled = false; + ASSERT_TRUE(layers.setLayer(off).ok()); + ASSERT_TRUE(m.declareTo(batcher, opts, layers, 0, p).ok()); + EXPECT_EQ(batcher.frameCount(), 0u); +} + +template +void animatedUnderParallax() { + using Map = TileMap; + auto r = bgTilemap(); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + ASSERT_TRUE(bgTerrain(m)); + ParallaxLayers layers = bgLayers(); + SpriteBatcher::Options bo; + bo.maxSprites = 8; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + SpriteBatcher batcher = std::move(rb).takeValue(); + // The (0,0) tile cycles from animation 1 (4 frames / 2 ticks, the + // 16 x 16 frame grid — the tight sheet 32 x 32): + ASSERT_TRUE(m.setTile(0, 0, 5, 0, 1).ok()); + TileAnimationDef anim; + anim.frameCount = 4; + anim.frameTicks = 2; + anim.layout.frameWidth = 16; + anim.layout.frameHeight = 16; + anim.layout.columns = 2; + anim.layout.rows = 2; + ASSERT_TRUE(m.setAnimation(1, anim).ok()); + m.advanceAnimations(); + m.advanceAnimations(); // tick 2: frame 1 + // Camera (10, 6): offset (2.5, 2.5) — tile (0,0) at (3, 3): + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}, layers, 0, + laige::render::Vec2{10.0f, 6.0f}).ok()); + ASSERT_TRUE(batcher.build().ok()); + const SpriteItem* t00 = batcher.get(0); + ASSERT_NE(t00, nullptr); + EXPECT_EQ(t00->pos.x, 3.0f); + EXPECT_EQ(t00->pos.y, 3.0f); + EXPECT_EQ(t00->depthKey, 0x7FA00060u); + EXPECT_EQ(t00->depthKey, oracleKeyAt(3.0f, 3.0f, 0, -2)); + // The ANIMATED frame under the parallax translation: frame 1's UV + // (the precomputed rect), frameIndex 1: + expectUv(t00->uv, 0.5f, 0.0f, 1.0f, 0.5f, "animated tile (0,0)"); + EXPECT_EQ(t00->frameIndex, 1u); + EXPECT_EQ(t00->atlasId, 5u); + // The static sibling (1,0): the fixed frame (0, 0, 1, 1), + // frameIndex 0: + const SpriteItem* t10 = batcher.get(1); + ASSERT_NE(t10, nullptr); + expectUv(t10->uv, 0.0f, 0.0f, 1.0f, 1.0f, "static tile (1,0)"); + EXPECT_EQ(t10->frameIndex, 0u); +} + +TEST(TileMapAnimParallax, AnimatedUnderParallax) { + animatedUnderParallax(); + animatedUnderParallax(); +} + +// --------------------------------------------------------------------------- +// TileMapAnimProtocol — the animationId domain on the edit/load paths +// + the unset-slot declare failure +// --------------------------------------------------------------------------- + +TEST(TileMapAnimProtocol, EditAnimationDomain) { + TileMap::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // Out-of-range id (9 > maxAnimations 8) — rejected, no state change: + expectRejected(m.setTile(0, 0, 1, 0, 9), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(m.tileAt(0, 0), TileData{}); + // The boundary id (8) and the sentinel (0) are accepted: + ASSERT_TRUE(m.setTile(0, 0, 1, 0, 8).ok()); + EXPECT_EQ(m.tileAt(0, 0).animationId, 8u); + ASSERT_TRUE(m.setTile(0, 0, 1, 0, 0).ok()); + EXPECT_EQ(m.tileAt(0, 0).animationId, 0u); + // Rebuild: an out-of-range id ANYWHERE in the span — rejected, the + // whole span validated before any write: + ASSERT_TRUE(m.setTile(1, 1, 2, 3, 0).ok()); + const TileData base = m.tileAt(1, 1); + const std::uint32_t baseKey = m.depthKeyAt(1, 1); + std::vector tiles(4); + tiles[3].animationId = 9; // tile (1, 1) + expectRejected(m.rebuild(tiles), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(m.tileAt(1, 1), base); + EXPECT_EQ(m.depthKeyAt(1, 1), baseKey); + tiles[3].animationId = 8; + ASSERT_TRUE(m.rebuild(tiles).ok()); + EXPECT_EQ(m.tileAt(1, 1), (TileData{0, 0, 8})); +} + +TEST(TileMapAnimProtocol, DeclareUnsetAnimation) { + TileMap::Options o; + o.widthTiles = 2; + o.heightTiles = 2; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // (0,0) is animated (slot 1) but the slot is UNSET — first failure + // wins, and (0,0) is the first tile in row-major order: nothing + // declared: + ASSERT_TRUE(m.setTile(0, 0, 1, 0, 1).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 4; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + batcher.beginFrame(); + expectRejected( + m.declareTo(batcher, TileMap::DeclareOptions{}), + laige::ErrorCode::InvalidArgument); + EXPECT_EQ(batcher.frameCount(), 0u); + // The slot's set is the scene's setup responsibility: + ASSERT_TRUE(m.setAnimation(1, fourFrames()).ok()); + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, TileMap::DeclareOptions{}).ok()); + EXPECT_EQ(batcher.frameCount(), 4u); +} + +// --------------------------------------------------------------------------- +// TileMapAnimZeroAlloc — the per-tick advance + per-frame declare loop +// (standalone + parallax) allocates nothing (FR-2.2) +// --------------------------------------------------------------------------- + +// The first offending site, resolved to its module + symbol when the +// platform provides dladdr (POSIX — the macOS/Linux lanes): the +// actionable context for the failure (LOG-002, FR-12.3). Raw address +// elsewhere (the Windows lane — MSVC has no dladdr). +std::string describeTileAnimAllocSite(const void* site) { +#if defined(_MSC_VER) + return "(site)) + ">"; +#else + Dl_info info; + if (site != nullptr && dladdr(site, &info) != 0 && + info.dli_fname != nullptr) { + std::string out = info.dli_fname; + if (info.dli_sname != nullptr) { + out += " +"; + out += info.dli_sname; + } + out += " (addr "; + out += std::to_string(reinterpret_cast(site)); + out += ")"; + return out; + } + return "(site)) + ">"; +#endif +} + +template +void advanceAndDeclareLoopAllocatesNothing() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 16; + o.heightTiles = 16; // 256 tiles per frame (the chunk size) + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + std::vector tiles(256); + for (std::size_t i = 0; i < 256; ++i) { + const std::int32_t tx = static_cast(i % 16); + const std::int32_t ty = static_cast(i / 16); + tiles[i].textureId = 1; + tiles[i].height = static_cast((i * 3) % 5); + tiles[i].animationId = ((tx + ty) % 3 == 0) ? 1u : 0u; // ~86 animated + } + ASSERT_TRUE(m.rebuild(tiles).ok()); // setup (before the window) + ASSERT_TRUE(m.setAnimation(1, fourFrames()).ok()); + // The parallax tile layer: a second 8 x 8 tilemap (the bg layer) + + // the Tilemap-source layer (factor 0.5, offset (2, 1), Manual): + typename Map::Options po; + po.widthTiles = 8; + po.heightTiles = 8; + po.layer = kParallaxDepthLayerBackground; + auto rp = Map::create(po); + ASSERT_TRUE(rp.ok()); + auto pm = std::move(rp).takeValue(); + typename ParallaxLayers::Options lo; + auto rl = ParallaxLayers::create(lo); + ASSERT_TRUE(rl.ok()); + auto layers = std::move(rl).takeValue(); + ParallaxLayerDef def; + def.id = 0; + def.source = ParallaxSource::Tilemap; + def.factor = 0.5f; + def.offset = laige::render::Vec2{2.0f, 1.0f}; + def.tilemapId = 1; + def.depthLayer = kParallaxDepthLayerBackground; + ASSERT_TRUE(layers.setLayer(def).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 256 + 64; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + auto runFrames = [&]() { + for (std::int32_t frame = 0; frame < 1000; ++frame) { + // The sim ticks (30 per frame — the advance is the sim phase): + for (int t = 0; t < 30; ++t) { + m.advanceAnimations(); + pm.advanceAnimations(); + } + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}).ok()) + << "frame " << frame; + ASSERT_TRUE(pm.declareTo(batcher, typename Map::DeclareOptions{}, layers, + 0, laige::render::Vec2{10.0f, 6.0f}).ok()) + << "frame " << frame; + ASSERT_TRUE(batcher.build().ok()) << "frame " << frame; + } + }; + // Zero-allocation proof (where the watch is live — the non- + // sanitizer trees; the sanitizer runtimes own operator new): + // 1000 frames of the advance + declare loop allocate nothing (the + // animation UVs are precomputed at setAnimation, the slot table at + // create — FR-2.2 "no per-frame allocation"). + // + // The window's owner is THIS thread: the watch counts owner-thread + // allocations only (the attribution contract, alloc_watch.h). The + // GL/GLFW suites earlier in this binary load the macOS graphics + // framework chain, and its background framework threads (QuartzCore + // / SkyLight — e.g. the WindowServer datagram dispatch, observed in + // CI) do their own heap work in parallel with our loop; that is + // not the loop's work and is not counted here. + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + runFrames(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << "1000 frames of advanceAnimations/beginFrame/declareTo/build " + << "allocated " << reading.allocs + << " heap blocks on the loop thread (first site: " + << describeTileAnimAllocSite(reading.firstSite) << ")"; + } +} + +TEST(TileMapAnimZeroAlloc, LoopAllocatesNothing) { + advanceAndDeclareLoopAllocatesNothing(); + advanceAndDeclareLoopAllocatesNothing(); +} + +} // namespace diff --git a/tests/laige-render/tilemap_tests.cpp b/tests/laige-render/tilemap_tests.cpp index 3017763..d44da09 100644 --- a/tests/laige-render/tilemap_tests.cpp +++ b/tests/laige-render/tilemap_tests.cpp @@ -172,13 +172,14 @@ constexpr std::uint32_t kGoldenTextures[16] = { 1, 1, 2, 2, 1, 2, 2, 1, 2, 1, 2, 1, 1, 1, 2, 2}; // The golden scene's 16 tiles as a TileData span (row-major, tileX -// fastest; animation id = the index). +// fastest; every tile static — the animation cycle has its own +// suite, tests/laige-render/tilemap_anim_tests.cpp). std::vector goldenTiles() { std::vector tiles(16); for (std::size_t i = 0; i < 16; ++i) { tiles[i].textureId = kGoldenTextures[i]; tiles[i].height = kGoldenHeights[i]; - tiles[i].animationId = static_cast(i); + tiles[i].animationId = 0; // static (the M2-TILE-02 sentinel) } return tiles; } @@ -327,12 +328,13 @@ TEST(TileMapData, SetTileReadWrite) { auto r = TileMap::create(o); ASSERT_TRUE(r.ok()); auto m = std::move(r).takeValue(); - // The edit: texture 7, height 3, animation 9: - ASSERT_TRUE(m.setTile(2, 1, 7, 3, 9).ok()); + // The edit: texture 7, height 3, animation 7 (the slot domain is + // 0..maxAnimations — 0 = the static sentinel, M2-TILE-02): + ASSERT_TRUE(m.setTile(2, 1, 7, 3, 7).ok()); const TileData t = m.tileAt(2, 1); EXPECT_EQ(t.textureId, 7u); EXPECT_EQ(t.height, 3); - EXPECT_EQ(t.animationId, 9u); + EXPECT_EQ(t.animationId, 7u); EXPECT_EQ(m.tileHeightAt(2, 1), 3); // The auto-depth key: hand computation — center (2.5, 1.5), sum 4, // q = 64, d = 64 - 48 = 16: @@ -396,7 +398,7 @@ TEST(TileMapData, RebuildSceneLoad) { const TileData t = m.tileAt(tx, ty); EXPECT_EQ(t.textureId, kGoldenTextures[i]) << "tile (" << tx << ", " << ty << ")"; EXPECT_EQ(t.height, kGoldenHeights[i]) << "tile (" << tx << ", " << ty << ")"; - EXPECT_EQ(t.animationId, i) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(t.animationId, 0u) << "tile (" << tx << ", " << ty << ")"; EXPECT_EQ(m.tileHeightAt(tx, ty), kGoldenHeights[i]) << "tile (" << tx << ", " << ty << ")"; EXPECT_EQ(m.depthKeyAt(tx, ty), kGoldenKeys[i]) << "tile (" << tx << ", " << ty << ")"; EXPECT_EQ(m.depthKeyAt(tx, ty), oracleKey(tx, ty, kGoldenHeights[i])) @@ -456,7 +458,9 @@ void rebuildEqualsIncremental() { const std::size_t i = static_cast(ty) * 8 + tx; tiles[i].textureId = 10 + static_cast(tx + ty); tiles[i].height = (3 * tx + 5 * ty) % 7; - tiles[i].animationId = 100 + static_cast(tx * 8 + ty); + // The animationId domain is 0..maxAnimations (the M2-TILE-02 + // slot domain — 0 = the static sentinel): + tiles[i].animationId = static_cast((tx + ty) % 3); ASSERT_TRUE(b.setTile(tx, ty, tiles[i].textureId, tiles[i].height, tiles[i].animationId).ok()); }