diff --git a/docs/README.md b/docs/README.md index feabfd4..abef6fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -299,6 +299,15 @@ still to land. documented error, never wrap) the caller uses to fill `SpriteItem.uv`/`frameIndex` — the data-driven hook M3 animation 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). ## Guides diff --git a/docs/api/tilemap.md b/docs/api/tilemap.md new file mode 100644 index 0000000..2f7192d --- /dev/null +++ b/docs/api/tilemap.md @@ -0,0 +1,199 @@ +# 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: +`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 +and in CI, and the goldens are hand-computed from the documented +formula. + +The canonical narrative home is +[`docs/concepts/coordinates.md`](../concepts/coordinates.md) §4.9 +(ARCH-008); the header preamble carries the machine-checked contract. +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). + +## 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) | +| `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) | +| `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). `TileData` is a plain value +(8 B of data + the table's height — the value the game writes on +load/edit and reads back). + +## The tile model + +One tile per cell of the **requested grid** `[originTileX, +originTileX + widthTiles) × [originTileY, originTileY + heightTiles)`: +tile `(gx, gy)` is the world cell `[gx, gx+1) × [gy, gy+1)` at the +world origin (coordinates.md §5.1 — the grid the M2-CAM-02 grid-snap +camera and the M2-ISO-03 picker resolve to), so the tilemap is +grid-locked by construction. The per-tile data (`textureId`, +`animationId`) lives in one flat pre-sized array (row-major, tileX +fastest — 8 B/tile, 400 KB at 50k tiles); the tile's **height lives in +the table alone** (one source of truth): + +- `setTile` routes the height into the table's `setTile` — the + auto-depth wiring (FR-2.6): the table stores the height and + recomputes exactly that cell's key (the M2-ISO-02 incremental + update, radius 0 — a height edit changes exactly that tile's key). + O(1), zero allocation. +- `rebuild` maps the requested heights into the table's covered + rectangle (the chunk-aligned superset — the margin stays flat + ground) and calls the table's from-scratch `rebuild` (every key + through the full M2-ISO-01 function). Property: + `rebuild(final grid) == any sequence of setTile calls reaching the + same grid` (the M2-ISO-02 property, pinned through the tilemap). + The superset margin (up to `chunkTiles − 1` tiles past a requested + edge) carries real table cells — flat unless the scene sets them — + 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). + +## `declareTo` — the static 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 +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): + +- `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); +- `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 + options'. + +**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 draw-call count is a function of the distinct +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). + +## Ownership, lifetime, threading + +- **Ownership:** one owner — the sim/scene-owner thread (the scene); + 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). +- **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). + +## Performance (DOC-004) + +| Operation | Cost | Allocations | +|---|---|---| +| `create` | O(covered cells) — the table's create + one data array | 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 | +| `declareTo` | O(tileCount) — one O(1) batcher add 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`). +- **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). + +## Example (performant) and misuse warning + +```cpp +using laige::render::TileMap; +using laige::render::TileData; +using laige::sim::Fpx16_16; + +// Scene load (sim phase, setup): +TileMap::Options o; +o.originTileX = 0; o.originTileY = 0; +o.widthTiles = 64; o.heightTiles = 64; +auto r = TileMap::create(o); +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) + +// Per frame (the render thread's cull/batch stage): +batcher.beginFrame(); +map.declareTo(batcher, TileMap::DeclareOptions{}); +// ... declare the frame's dynamic sprites ... +batcher.build(); // the tile quads sort + group with the rest +``` + +**Misuse warning:** don't hand-write the tile's depth — the key is the +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). diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index ef35e32..ed5c51f 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -302,6 +302,41 @@ 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) + +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: + +- **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. +- **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 + key (radius 0; `rebuild(final grid) == any edit sequence reaching + the same grid` — the §4.5 property). The game never writes the key + (G-R11). +- **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). +- **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. + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | @@ -312,6 +347,7 @@ and the draw submissions are observable (`SpriteDrawStats` / | 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)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -412,6 +448,9 @@ state → same keys → same order, every frame (RENDER-003). - [`api/sprite_frames.md`](../api/sprite_frames.md) — the atlas UV frame animation hook: `SpriteFrameLayout` + `spriteFrameUv` (frame index + sheet layout → the item's UV sub-rect) (M2-SPRITE-03). +- [`api/tilemap.md`](../api/tilemap.md) — the tilemap contract: + `TileMap` (chunked tile grid + the auto-depth wiring of the depth + table + the static tile-quad batch path) (M2-TILE-01). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index f5afcfe..e986b8e 100644 --- a/laige-api.json +++ b/laige-api.json @@ -26,6 +26,7 @@ "src/laige-render/include/laige/render/sprite_batcher.h", "src/laige-render/include/laige/render/sprite_frames.h", "src/laige-render/include/laige/render/sprite_renderer.h", + "src/laige-render/include/laige/render/tilemap.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", "src/laige-sim/include/laige/sim/config.h", @@ -842,6 +843,38 @@ {"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": 220, "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).", "budget": null, "experimental": false}, + {"name": "laige::render::TileMap::DeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 224, "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": 226, "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": 229, "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": 232, "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": 241, "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": 256, "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": 276, "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": 294, "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": 299, "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": 301, "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": 303, "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": 309, "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": 313, "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": 314, "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": 315, "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": 316, "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": 317, "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": 318, "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": 320, "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": 325, "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": 326, "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": 327, "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": 328, "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": 329, "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": 340, "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": 341, "signature": "std::uint32_t animationId{}", "summary": null, "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 45d23a5..e7d9c5f 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -195,7 +195,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. ## Tiles & parallax -- [ ] **M2-TILE-01 · Tilemap data + auto-depth from tile height** +- [x] **M2-TILE-01 · Tilemap data + auto-depth from tile height** - **Refs:** FR-2.6 (chunks, per-tile depth/height, auto-depth), PRD §4 (isometric staple) - **Depends:** M2-ISO-02 - **Scope:** @@ -206,16 +206,6 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R tilemap` green. - **Size:** ~250 lines + tests -- [ ] **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:** - - Tilemap layers can be assigned a parallax layer (background/mid/foreground) — parallax factors apply per M2-PAR-01. - - Tile animation: per-tile frame cycling (frame index advances per documented tick count), data-driven (animation editor control is M5). - - Unit tests: animated tile cycles frames at the documented rate; parallax offset at a given camera position golden-checked. - - **Verify:** `ctest -R tilemap_anim` green. - - **Size:** ~150 lines + tests - - [ ] **M2-PAR-01 · Parallax layers** - **Refs:** FR-2.3 (named layers, factor, offset, UV scroll, blend) - **Depends:** M2-CAM-01 @@ -226,6 +216,16 @@ 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** + - **Refs:** FR-2.6 (parallax tile layers, tile animation) + - **Depends:** M2-TILE-01, M2-PAR-01 + - **Scope:** + - Tilemap layers can be assigned a parallax layer (background/mid/foreground) — parallax factors apply per M2-PAR-01. + - Tile animation: per-tile frame cycling (frame index advances per documented tick count), data-driven (animation editor control is M5). + - Unit tests: animated tile cycles frames at the documented rate; parallax offset at a given camera position golden-checked. + - **Verify:** `ctest -R tilemap_anim` green. + - **Size:** ~150 lines + tests + ## Particles - [ ] **M2-PART-01 · Particle simulation (CPU, 2D + depth)** diff --git a/roadmap/README.md b/roadmap/README.md index fc21b39..84d923f 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 | 16 | ⬜ in progress | +| M2 | 33 | 17 | ⬜ 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** | **62** | | +| **Total** | **194** | **63** | | --- @@ -240,6 +240,7 @@ One line per completed (or split/renumbered) step. | 2026-10-04 | M2-ISO-02 | `—` | **Budget revision (CI follow-up to M2-ISO-02)** — the `iso_depthkey_rebuild` gate (PRD §8.1, mean ≤ 0.2 ms, 10k dirty cells after a terrain edit) failed the CI `Linux x64 (clang++)` reference lane repeatedly on **unchanged engine code** (the `setTile` path has not changed since the 2026-10-01 workload fix): the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at **0.194–0.267 ms**, straddling the 0.2 ms bar — a zero-margin gate whose pass/fail was decided by runner load, not engine regression (evidence: PR lane run 37192995927 attempts 1–2 — 0.194142 PASS / 0.201495 FAIL, then 0.266709 / 0.267328 FAIL on a slow shared runner; master merge-lane run 37194767648 on commit `70830a7` — 0.202146 / 0.202018 FAIL, the `Linux x64 (g++)` lane passing the same workload on the same commit). Revised per the NFR-8.1 policy ("unless the budget is revised via a PRD revision"): **PRD v0.4** §8.1 `≤ 0.2 ms` → `≤ 0.3 ms`; **`budgets.json`** `target` 0.2 → **0.3**, `measured` 0.0814067 → **0.202204** (latest recorded CI reference value, worse backend); **new baseline** `docs/benchmarks/baselines/m2-iso-depth-table-budget-rebaseline.md` (the eighth baseline, verbatim CI reports); **docs** — `docs/api/iso_depth_table.md`, `docs/api/iso_depth_key.md`, `docs/concepts/coordinates.md`, `docs/README.md`, and the M2/M5/M8 roadmap budget references updated to the revised value in the same change (DOC-003), baselines index entry added. No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain). | | 2026-10-04 | M2-SPRITE-03 | `—` | Atlas UV frame animation hook (M2-SPRITE-03 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/sprite_frames.h` (`laige::render`, public, additive): `SpriteFrameLayout` (the atlas sheet frame layout in texels — `frameWidth`/`frameHeight`, `columns`/`rows`, `frameSpacing` (the gap between adjacent frames), `sheetBorder` (the sheet-edge margin); plain value, no ownership, validated at use time) + `kSpriteFrameMaxAtlasTexels` (2^24 — the float-exact atlas domain) + `spriteFrameUv(frameIndex, layout, atlasWidth, atlasHeight) → Result` (the pure frame-index → UV sub-rect computation: O(1), zero allocation, no logging, no GL; 4 exactly-rounded float divisions — the UV corners are the exactly-rounded k/W values); **sheet model (documented)** — row-major, frame 0 at the top-left (`col = i % columns`, `row = i / columns`); frame (c, r) occupies `[border + c·(fw+spacing), +fw) × [border + r·(fh+spacing), +fh)` texels; the tight sheet is `2·border + cols·fw + (cols−1)·spacing` wide/tall (a wider atlas = slack margin, the fit check is authoritative); **v-axis** — v = 0 is the first texel row of the uploaded RGBA array (the sheet's TOP row — the M2-SPRITE-02 GL_NEAREST "texel row = floor(v·h)" contract), so frame row 0 carries the smallest v; **failure (CORE-008, API-008, first failure wins)** — the layout is caller-owned, untrusted asset metadata (SCALE-004): zero extents / atlas outside [1, 2^24] / frameIndex ≥ columns·rows (the documented OUT-OF-RANGE contract: the engine NEVER wraps silently) / the frame's rect beyond the atlas — all `InvalidArgument`, never a UV rect (the u64 overflow guard rejects an adversarial stride before the col·stride multiplication can wrap — CPP-004/SCALE-004); **exactness** — float-exact domain (atlas ≤ 2^24): every pixel coordinate < 2^24 is exactly float-representable and the invariant u1 > u0, v1 > v0 holds EXACTLY (two distinct k/W never round to the same float) — pure function, bit-identical every platform/build (presentation-only, ARCH-009/010); **the M3 hook (data-driven, ARCH-009)** — `SpriteItem` gained `frameIndex` (the declared animation frame — the caller sets it + sets `uv` to the frame's rect via `spriteFrameUv`; the batcher carries the index through untouched — `uv` is what the M2-SPRITE-02 renderer draws; M3 animation drives the frame advance on top of this same layout); **batcher delta** — `SpriteItem` +1 u32 (76 B/slot, ~136 B/capacity slot, 6.8 MB at 50k); the batcher stays pure integer bookkeeping (the index passes through untouched); **tests** — `tests/laige-render/sprite_frames_tests.cpp` (new CTest entry `sprite_frames` = the step's Verify command; 11 tests / 4 suites, no GL): `SpriteFrameUvGolden` (hand-computed UVs for documented layouts: packed 4×4 sheet, tight 82×82 margin sheet, non-square 12×8 frames with spacing, single column/row, the idempotent call), `SpriteFrameErrors` (out-of-range at count / beyond / u32 top with the no-wrap pin, zero-extent layouts, the atlas domain incl. the exact 2^24 top, the fit failures incl. the one-texel-short spacing + the exact boundary + the adversarial stride), `SpriteFrameProperty` (2 000 seeded random tight sheets vs the documented formula — the float-exact invariants + the row/col adjacency rule (exact touch packed, strict gap spaced) — + the 1 000-conversion zero-allocation proof, the iso_picking/depth_sort precedent), `SpriteFrameItemPassThrough` (the frameIndex + uv pair survives the batcher's add/build/get — the M3 entry point); **docs** — NEW `docs/api/sprite_frames.md` (the full contract + Performance per DOC-004 + the M3 hook + misuse warnings), `docs/api/sprite_batcher.md` (the SpriteItem table row + the 76 B/slot update + Related), `docs/concepts/coordinates.md` §4.8 (the UV bullet) + §5 table row (Atlas frame → UV sub-rect, shipped) + Related, `docs/README.md` API index, the module README status paragraph; `laige-api.json` regenerated (1221 symbols / 38 headers, +10 symbols / +1 header); **verification** — all six local trees warning-clean + full ctest green (build, build-clang, build-release, build-shared, build-asan, build-tsan: `ctest -R sprite_frames` + the full `laige-render_tests`); `api-real-tree`/`api-check-fresh`, `include-lint` (38 public headers), and `determinism-lint` green; **budget** — no standalone `budgets.json` entry (the per-frame conversion cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01); **compat** — additive only (no existing symbol's signature or meaning changed; the new public API is `SpriteFrameLayout`/`spriteFrameUv`/`kSpriteFrameMaxAtlasTexels` + `SpriteItem.frameIndex`); **scope note** — the implementation exceeds the roadmap's "~100 lines" sanity note for the same documented-contract reason as M2-SORT-01/M2-SPRITE-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 15/33, total 61/194 | | 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 | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 8904670..eebd039 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -362,5 +362,47 @@ memory suites require a usable OpenGL 3.3 environment and self- entry: the counters are O(1) bookkeeping and the composite 50k render-CPU budget is measured with this stage (M2-PERF-01). -The remaining M2 steps (M2-TILE-01, M2-PAR-01, text/UI, M2-PERF-01) -land in later steps. +M2-TILE-01 landed the tilemap — `laige::render::TileMap` +(public header `include/laige/render/tilemap.h`, header-only — a +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: +`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)` +loads the whole grid through the table's from-scratch `rebuild` +(`rebuild(final grid) == any edit sequence reaching the same grid` — +the M2-ISO-02 property, pinned through the tilemap). The batch path +(S-5): `declareTo(batcher, options)` declares the static tile quads +into the sprite batcher — one `SpriteItem` per tile (the tile's CENTER +pos, scale (1,1), the table's key (auto-depth — the game never writes +it, G-R11), the tile's texture as `atlasId`, the fixed-frame UV), in +the grid's row-major order (RENDER-003's deterministic insertion +order) — and the batcher's (atlas, material, blend) grouping renders +the tilemap in a BOUNDED number of draw calls: one per distinct +(textureId, material, blend) combination (FR-2.1, RENDER-001 — tiles +of one chunk sharing one texture and blend form one group). +ARCH-009: headless-buildable, presentation-only, sim-phase writes / +render-phase reads; the declare loop allocates NOTHING per frame +(FR-2.2 — the zero-allocation proof, the tests). Rejected operations +leave the tile data AND the table unchanged (the `Status` is the +failure channel — LOG-002). API contract in +[docs/api/tilemap.md](../docs/api/tilemap.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `tilemap` — +pure data + batcher bookkeeping, no GL environment required: the grid +options + flat/empty contract, the per-tile data writes / rejected +edits / scene load + the rebuild-from-scratch == incremental property, +the height → depth-table wiring (a height edit changes exactly the +edited cell's key), the hand-computed 4×4 chunk's quad positions + +depth goldens + the grouping and cross-frame determinism, the frame +protocol / failure paths / custom options / no-log happy path, and the +1000-frame zero-allocation declare loop). No standalone `budgets.json` +entry: the per-frame declare cost is part of the composite 50k +render-CPU budget, measured with M2-PERF-01. + +The remaining M2 steps (M2-PAR-01, text/UI, M2-PERF-01) land in later +steps. diff --git a/src/laige-render/include/laige/render/tilemap.h b/src/laige-render/include/laige/render/tilemap.h new file mode 100644 index 0000000..dce7e90 --- /dev/null +++ b/src/laige-render/include/laige/render/tilemap.h @@ -0,0 +1,522 @@ +// 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). +// +// TileData one tile's data (texture id, height, +// animation id) +// TileMap the chunked tile grid (owns the table) +// TileMap::DeclareOptions the per-frame declare options +// +// --------------------------------------------------------------------------- +// The model +// --------------------------------------------------------------------------- +// +// The tilemap covers a RECTANGLE of tile coordinates +// +// [originTileX, originTileX + widthTiles) x +// [originTileY, originTileY + heightTiles) +// +// of integer tile indices — the same grid the M2-ISO-02 depth key +// table holds. Tile `(gx, gy)` is the world cell `[gx, gx+1) x +// [gy, gy+1)` at the world origin (coordinates.md §5.1 — the grid the +// M2-CAM-02 grid-snap camera and the M2-ISO-03 picker resolve to), so +// the tilemap is grid-locked by construction: its cells agree with +// `isoDepthKey` at the same positions (the table's bit-identity +// contract). +// +// The tilemap OWNS one `IsoDepthKeyTable` (the same options — +// the table's covered region is the chunk-aligned SUPERSET of the +// requested grid) and stores, per tile of the REQUESTED grid, the +// remaining per-tile data in one flat pre-sized array (row-major, +// tileX fastest — the table's flat order): +// +// 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) +// +// 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 +// `setTile`/`rebuild` below, which route into the table's +// `setTile`/`rebuild` and recompute exactly that cell's key (the +// M2-ISO-02 incremental-update contract, radius 0). `tileAt` returns +// the three fields combined (the height read from the table). +// +// --------------------------------------------------------------------------- +// 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"): +// +// 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) +// depthKey the table's key for the cell (AUTO-DEPTH — the game +// never computes it, G-R11; the item's +// `depthOverride` stays false) +// atlasId the tile's textureId +// +// BOUNDED DRAW CALLS: `declareTo` declares one sprite per tile; the +// batcher's (atlas, material, blend) grouping then renders the +// tilemap in one draw call PER DISTINCT (textureId, material, blend) +// combination (FR-2.1, M2-SPRITE-01) — tiles of one chunk sharing one +// texture and blend form ONE group (one draw call per chunk group). +// The number of draw calls is a function of the distinct group keys, +// never of the tile count (RENDER-001). +// +// --------------------------------------------------------------------------- +// Determinism (RENDER-003, ARCH-010 scope — presentation-only) +// --------------------------------------------------------------------------- +// +// `declareTo` declares the tiles in the tile grid's ROW-MAJOR order +// (tileY outer, tileX fastest — the flat array's order, cache-friendly). +// 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. +// +// --------------------------------------------------------------------------- +// Ownership, lifetime, threading +// --------------------------------------------------------------------------- +// +// One owner — the sim/scene-owner thread (the scene; the M2-GL-02 +// 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 +// thread-safe by design. +// +// The per-frame `declareTo` path ALLOCATES NOTHING (FR-2.2 +// "no per-frame allocation"): the adds are O(1) batcher operations +// over pre-allocated storage (the zero-allocation proof, the tests). +// +// --------------------------------------------------------------------------- +// Failure behavior (no silent failure, CORE-008) +// --------------------------------------------------------------------------- +// +// 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. +// +// --------------------------------------------------------------------------- +// 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). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Don't hand-write the tile's depth: the key is the table's +// (auto-depth, G-R11). Setting `depthOverride` on a tile sprite +// defeats the wiring (and counts/warns through the batcher). +// - Don't declare more than the batcher's frame budget: the frame is +// bounded (M2-SPRITE-01 overflow policy — drop the oldest + warn). +// - Don't call `declareTo` on a built frame (the window is closed — +// `beginFrame` first); the precondition is checked (first failure +// wins, nothing declared). +// - `tileAt`/`depthKeyAt`/`tileHeightAt` on a tile OUTSIDE the +// requested grid is undefined behavior (program bug): check +// `covers()` when tile coordinates come from untrusted input (the +// batch path reads only the requested grid by construction — it +// declares none of the table's superset margin). +// - 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). + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/result.h" +#include "laige/render/iso_depth_key.h" +#include "laige/render/iso_depth_table.h" +#include "laige/render/matrices.h" +#include "laige/render/sprite_batcher.h" +#include "laige/sim_math.h" + +namespace laige::render { + +// One tile's data (the value the game writes on load/edit and reads +// back; plain value — no GL, no allocation). +struct TileData { + // The texture/atlas reference (the game assigns it — M3-ASSET-01). + std::uint32_t textureId{}; + // 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). + 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. +// +// Templated over the SimMath backends (the presentation.h pattern — +// the keys are backend-typed; fpx16_16 and fp32_pinned agree +// bit-for-bit on the grid-locked tile centers — dyadic, inside the +// exactness zone). +template +class TileMap { + static_assert(std::is_same_v || + std::is_same_v, + "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 + // `kIsoDepthTableDefaultMaxChunks` = 1024), `layer` (default + // `kIsoDepthGroundLayer` = 0 — a parallax tile LAYER gets its own + // tilemap with its layer value, M2-PAR-01). + using Options = IsoDepthKeyTable::Options; + + // The per-frame declare options (the fixed-frame quad model, above): + // the group-key fields for every tile sprite + the 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). + 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. + [[nodiscard]] + static Result create(const Options& options) noexcept; + + // 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. + // + // 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). + [[nodiscard]] + Status setTile(std::int32_t tileX, std::int32_t tileY, + std::uint32_t textureId, std::int32_t height, + std::uint32_t animationId) noexcept; + + // 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. + // + // Property: rebuild(final grid) == any sequence of setTile calls + // reaching the same grid (the M2-ISO-02 property, through the + // 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. + [[nodiscard]] + Status rebuild(std::span tiles) 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. + // + // 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). + [[nodiscard]] + Status declareTo(SpriteBatcher& batcher, + const DeclareOptions& options) 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; + [[nodiscard]] std::uint32_t depthKeyAt(std::int32_t tileX, + std::int32_t tileY) const noexcept; + [[nodiscard]] std::int32_t tileHeightAt(std::int32_t tileX, + std::int32_t tileY) const noexcept; + // 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. + [[nodiscard]] bool covers(std::int32_t tileX, + std::int32_t tileY) const noexcept; + + // Introspection (O(1); the table-backed values forward the table): + std::int32_t originTileX() const noexcept { return options_.originTileX; } + std::int32_t originTileY() const noexcept { return options_.originTileY; } + std::int32_t widthTiles() const noexcept { return options_.widthTiles; } + 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 requested grid's tile count (widthTiles * heightTiles). + std::size_t tileCount() const noexcept { + return static_cast(options_.widthTiles) * + static_cast(options_.heightTiles); + } + + TileMap(const TileMap&) = delete; + TileMap& operator=(const TileMap&) = delete; + TileMap(TileMap&&) noexcept = default; + TileMap& operator=(TileMap&&) noexcept = default; + ~TileMap() = default; + + private: + explicit TileMap(Options options, IsoDepthKeyTable table) noexcept + : options_(options), + table_(std::move(table)), + tiles_(std::make_unique(tileCount())) {} + + // The per-tile data slot (the requested grid; the height lives in + // the table — one source of truth). 8 B/tile. + struct TileSlot { + std::uint32_t textureId{}; + std::uint32_t animationId{}; + }; + + // The flat index of the requested-grid tile (row-major, tileX + // fastest). Precondition: covers(). + std::size_t requestedIndex(std::int32_t tileX, + std::int32_t tileY) const noexcept { + return static_cast(tileY - options_.originTileY) * + static_cast(options_.widthTiles) + + static_cast(tileX - options_.originTileX); + } + + Options options_; + IsoDepthKeyTable table_; + std::unique_ptr tiles_; +}; + +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); + if (!table.ok()) return Result::failure(table.error()); + TileMap map(options, std::move(table).takeValue()); + return Result::success(std::move(map)); +} + +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 + // 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). + if (tileX < options_.originTileX || + tileX >= options_.originTileX + options_.widthTiles || + tileY < options_.originTileY || + tileY >= options_.originTileY + options_.heightTiles || + height < -kIsoDepthMaxStepHeight || + height > kIsoDepthMaxStepHeight) { + return Status(ErrorCode::InvalidArgument); + } + // The auto-depth wiring (FR-2.6): the height goes into the table, + // which recomputes exactly this cell's key (radius 0). + const Status s = table_.setTile(tileX, tileY, height); + if (!s.ok()) return s; // unreachable (validated above) — honest channel + // The tile data is written AFTER the depth edit succeeded: a + // rejected edit leaves both the data and the table unchanged. + TileSlot& slot = tiles_[requestedIndex(tileX, tileY)]; + slot.textureId = textureId; + slot.animationId = animationId; + return Status{}; +} + +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): + for (std::size_t i = 0; i < cells; ++i) { + if (tiles[i].height < -kIsoDepthMaxStepHeight || + tiles[i].height > kIsoDepthMaxStepHeight) { + return Status(ErrorCode::InvalidArgument); + } + } + // The table's rebuild span is the COVERED (chunk-aligned superset) + // rectangle: map the requested heights into it (the superset margin + // stays flat ground — one setup-path temporary, the scene-load + // context). The requested grid lies inside the covered region, so + // the loop consumes exactly `cells` entries: + const std::size_t coveredW = static_cast( + table_.coveredTileMaxX() - table_.coveredTileMinX()); + std::vector heights(table_.coveredCellCount(), 0); + std::size_t i = 0; + for (std::int32_t ty = table_.coveredTileMinY(); + ty < table_.coveredTileMaxY(); ++ty) { + for (std::int32_t tx = table_.coveredTileMinX(); + tx < table_.coveredTileMaxX(); ++tx) { + if (covers(tx, ty)) { + heights[static_cast(ty - table_.coveredTileMinY()) * + coveredW + + static_cast(tx - table_.coveredTileMinX())] = + tiles[i].height; + ++i; + } + } + } + // The requested grid lies inside the covered region (a rectangle in + // a rectangle): the loop consumed exactly `cells` entries. + assert(i == cells); + const Status s = table_.rebuild(heights); + if (!s.ok()) return s; // unreachable (validated above) — honest channel + // The per-tile data (row-major, tileX fastest — the span's order): + for (std::size_t j = 0; j < cells; ++j) { + tiles_[j].textureId = tiles[j].textureId; + tiles_[j].animationId = tiles[j].animationId; + } + return Status{}; +} + +template +Status TileMap::declareTo(SpriteBatcher& batcher, + const DeclareOptions& options) 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 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). + 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) + 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 tile's center (the float conversion is exact — dyadic, + // |tx| <= 32766; the same point the table quantizes). + item.pos = Vec2{static_cast(tx) + 0.5f, + static_cast(ty) + 0.5f}; + // The auto-depth key (the table — the game never writes it, + // G-R11). + item.depthKey = table_.keyAt(tx, ty); + item.atlasId = tiles_[i].textureId; + ++i; + const auto r = batcher.add(item); + if (!r.ok()) return r.error(); + } + } + return Status{}; +} + +template +TileData TileMap::tileAt(std::int32_t tileX, + std::int32_t tileY) const noexcept { + // Precondition: covers() (the check-free read — the table's keyAt + // pattern; the misuse warnings above). + const std::size_t i = requestedIndex(tileX, tileY); + return TileData{tiles_[i].textureId, + table_.tileHeightAt(tileX, tileY), + tiles_[i].animationId}; +} + +template +std::uint32_t TileMap::depthKeyAt(std::int32_t tileX, + std::int32_t tileY) const noexcept { + // Precondition: covers() (the requested grid lies in the table's + // covered region — the table's keyAt contract). + return table_.keyAt(tileX, tileY); +} + +template +std::int32_t TileMap::tileHeightAt(std::int32_t tileX, + std::int32_t tileY) const noexcept { + // Precondition: covers() (the table's tileHeightAt contract). + return table_.tileHeightAt(tileX, tileY); +} + +template +bool TileMap::covers(std::int32_t tileX, + std::int32_t tileY) const noexcept { + return tileX >= options_.originTileX && + tileX < options_.originTileX + options_.widthTiles && + tileY >= options_.originTileY && + tileY < options_.originTileY + options_.heightTiles; +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 0719f3f..f2566da 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -111,7 +111,18 @@ # cap, and the texture-memory VRAM estimate gauge (binds + # re-bind replacements)) — the GL suites need a usable OpenGL 3.3 # environment (the documented environment contract: GTEST_SKIPs where -# absent). +# absent); and the 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 +# (tilemap_tests.cpp's TileMap* suites: the grid options + the +# flat/empty contract, the per-tile data writes/rejected edits/scene +# load + the "rebuild from scratch == incremental" property, the +# height -> depth-table wiring (a height edit changes exactly the +# edited cell's key), the hand-computed 4 x 4 chunk's quad +# positions/depth goldens + the (texture, material, blend) grouping +# and cross-frame determinism, the frame protocol/failure paths/custom +# options/no-log happy path, and the 1000-frame zero-allocation declare +# loop) — 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 @@ -132,9 +143,11 @@ # Verify command (`ctest -R batcher`), the `sprite_draw` entry is the # M2-SPRITE-02 Verify command (`ctest -R sprite_draw`), the # `sprite_frames` entry is the M2-SPRITE-03 Verify command -# (`ctest -R sprite_frames`), and the `render_counters` entry is the -# M2-SPRITE-04 Verify command (`ctest -R render_counters`), each -# selecting exactly its suites from the shared executable. +# (`ctest -R sprite_frames`), the `render_counters` entry is the +# M2-SPRITE-04 Verify command (`ctest -R render_counters`), and the +# `tilemap` entry is the M2-TILE-01 Verify command (`ctest -R +# tilemap`), each selecting exactly its suites from the shared +# executable. # # Environment note: the GlContextSmoke, RenderThreadOffscreen, # SpriteDraw{State,Smoke,Pipeline}, and RenderCounters{Scene,Cap, @@ -144,15 +157,15 @@ # a GL driver the suites GTEST_SKIP with the clean Status reason — the # documented environment contract (docs/api/gl_context.md), not an # engine failure. The GlContextGate/GlContextArgs/FrameClock/ -# RenderThreadHandoff/SpriteDrawCreate/RenderCountersCreate suites -# always run (they make no GL calls). +# RenderThreadHandoff/SpriteDrawCreate/RenderCountersCreate/TileMap* +# suites always run (they make no GL calls). add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp iso_depth_key_tests.cpp iso_depth_table_tests.cpp camera_tests.cpp 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) + sprite_frames_tests.cpp render_counters_tests.cpp tilemap_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 @@ -290,6 +303,14 @@ add_test(NAME sprite_frames COMMAND laige-render_tests add_test(NAME render_counters COMMAND laige-render_tests --gtest_filter=RenderCounters*) +# M2-TILE-01: the step's Verify command is `ctest -R tilemap`. 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 cost is part of +# the composite 50k render-CPU budget, measured with M2-PERF-01). +add_test(NAME tilemap COMMAND laige-render_tests + --gtest_filter=TileMap*) + 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) @@ -326,7 +347,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 PROPERTIES + render_counters tilemap PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -387,6 +408,7 @@ set_tests_properties(projection PROPERTIES TIMEOUT 60) 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) # The `iso_depth_table` and `depth_sort` entries include their budget # gates: 2 backends x (100 warm-up + 3000 measured) iterations of 10 000 # setTile calls, and 3 000 sorts of 10 000 keys respectively — a few diff --git a/tests/laige-render/tilemap_tests.cpp b/tests/laige-render/tilemap_tests.cpp new file mode 100644 index 0000000..dbc611b --- /dev/null +++ b/tests/laige-render/tilemap_tests.cpp @@ -0,0 +1,850 @@ +// laige-render tilemap tests (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, 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 goldens +// are HAND-COMPUTED from the documented formula (tile centers +// (gx + 0.5, gy + 0.5), the M2-ISO-01 key); the independent oracle is +// the M2-ISO-01 function on a tile center (the test builds its own +// centers — the iso_depth_table_tests pattern); the property test +// pins "rebuild-from-scratch == incremental result" through the +// tilemap; the zero-allocation window covers the 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/iso_depth_table.h" +#include "laige/render/sprite_batcher.h" +#include "laige/render/tilemap.h" + +#include +#include +#include +#include +#include +#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::SpriteBatcher; +using laige::render::TileData; +using laige::render::TileMap; +using laige::sim::Fp32Pinned; +using laige::sim::Fpx16_16; + +// --------------------------------------------------------------------------- +// The independent oracle: the M2-ISO-01 function on a tile center — +// the tilemap's auto-depth keys must equal it cell by cell (bit- +// identity contract). The test builds its own centers (dyadic, exact +// on both backends) — never reusing the table's code. +// --------------------------------------------------------------------------- + +template +laige::sim::SimMath::Vec2 tileCenter(std::int32_t gx, + std::int32_t gy) { + if constexpr (std::is_same_v) { + return laige::sim::SimMathFp32::Vec2{static_cast(gx) + 0.5f, + static_cast(gy) + 0.5f}; + } else { + return laige::sim::SimMathFpx16::Vec2{ + fpx16_16::fromFloat(static_cast(gx) + 0.5f), + fpx16_16::fromFloat(static_cast(gy) + 0.5f)}; + } +} + +template +std::uint32_t oracleKey(std::int32_t gx, std::int32_t gy, std::int32_t height, + std::int32_t layer = 0) { + return laige::render::isoDepthKey(tileCenter(gx, gy), + height, layer); +} + +// --------------------------------------------------------------------------- +// Log capture (the render_thread_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 iso_depth_table_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 hand-computed golden scene (the roadmap's 4 x 4 chunk): +// +// heights (row-major, tileX fastest; row = ty): +// ty=0: 0 0 1 2 +// ty=1: 0 1 2 2 +// ty=2: 1 2 2 3 +// ty=3: 2 2 3 3 +// +// textures: +// ty=0: 1 1 2 2 +// ty=1: 1 2 2 1 +// ty=2: 2 1 2 1 +// ty=3: 1 1 2 2 +// +// Hand-computed keys (the layer-0 key = 0x80000000 | (d + 2^21), where +// d = 16 * (tx + ty + 1) - 16 * h — the centers (tx + 0.5, ty + 0.5) +// make the ground sum tx + ty + 1 exact, so q = 16 * (tx + ty + 1)): +// idx (tx, ty) h d key +// 0 (0, 0) 0 16 0x80200010 +// 1 (1, 0) 0 32 0x80200020 +// 2 (2, 0) 1 32 0x80200020 +// 3 (3, 0) 2 32 0x80200020 +// 4 (0, 1) 0 32 0x80200020 +// 5 (1, 1) 1 32 0x80200020 +// 6 (2, 1) 2 32 0x80200020 +// 7 (3, 1) 2 48 0x80200030 +// 8 (0, 2) 1 32 0x80200020 +// 9 (1, 2) 2 32 0x80200020 +// 10 (2, 2) 2 48 0x80200030 +// 11 (3, 2) 3 48 0x80200030 +// 12 (0, 3) 2 32 0x80200020 +// 13 (1, 3) 2 48 0x80200030 +// 14 (2, 3) 3 48 0x80200030 +// 15 (3, 3) 3 64 0x80200040 +constexpr std::uint32_t kGoldenKeys[16] = { + 0x80200010, 0x80200020, 0x80200020, 0x80200020, + 0x80200020, 0x80200020, 0x80200020, 0x80200030, + 0x80200020, 0x80200020, 0x80200030, 0x80200030, + 0x80200020, 0x80200030, 0x80200030, 0x80200040}; +constexpr std::int32_t kGoldenHeights[16] = { + 0, 0, 1, 2, 0, 1, 2, 2, 1, 2, 2, 3, 2, 2, 3, 3}; +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). +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); + } + return tiles; +} + +// The hand-computed expected instance sequences after the batcher's +// (key, declaration-position) stable sort: the golden keys above put +// the tiles into four screen rows (d = 16, 32, 48, 64); within a row +// the declaration order (row-major, tileX fastest) is the stable tie- +// break. The sorted sequence is {0, 1, 2, 3, 4, 5, 6, 8, 9, 12, 7, +// 10, 11, 13, 14, 15}; restricting it to each texture: atlas 1's +// instances (in sorted order): {0, 1, 4, 9, 12, 7, 11, 13}; atlas +// 2's: {2, 3, 5, 6, 8, 10, 14, 15}. +constexpr std::uint32_t kGroupOneInstances[8] = {0, 1, 4, 9, 12, 7, 11, 13}; +constexpr std::uint32_t kGroupTwoInstances[8] = {2, 3, 5, 6, 8, 10, 14, 15}; + +laige::Status expectRejected(laige::Status s, laige::ErrorCode want) { + EXPECT_TRUE(s.isError()); + EXPECT_EQ(s.error(), want); + return s; +} + +} // namespace + +// --------------------------------------------------------------------------- +// TileMapCreate — the grid options and the flat/empty contract +// --------------------------------------------------------------------------- + +template +void defaultGrid() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 4; + o.heightTiles = 4; // covered: the chunk-aligned 16 x 16 superset + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + Map m = std::move(r).takeValue(); + // Introspection: + EXPECT_EQ(m.originTileX(), 0); + EXPECT_EQ(m.originTileY(), 0); + EXPECT_EQ(m.widthTiles(), 4); + EXPECT_EQ(m.heightTiles(), 4); + EXPECT_EQ(m.layer(), 0); + EXPECT_EQ(m.chunkTiles(), 16); + EXPECT_EQ(m.tileCount(), 16u); + // The requested grid (not the table's superset margin): + EXPECT_TRUE(m.covers(0, 0)); + EXPECT_TRUE(m.covers(3, 3)); + EXPECT_FALSE(m.covers(4, 0)); + EXPECT_FALSE(m.covers(0, 4)); + EXPECT_FALSE(m.covers(-1, 0)); + EXPECT_FALSE(m.covers(0, -1)); + // Flat/empty init: every tile is {textureId 0, height 0, animation + // id 0}; the flat-ground key (hand computation: center (0.5, 0.5), + // q = 16, d = 16): + const TileData t = m.tileAt(0, 0); + EXPECT_EQ(t.textureId, 0u); + EXPECT_EQ(t.height, 0); + EXPECT_EQ(t.animationId, 0u); + EXPECT_EQ(m.tileHeightAt(0, 0), 0); + EXPECT_EQ(m.depthKeyAt(0, 0), 0x80200010u); + EXPECT_EQ(m.depthKeyAt(0, 0), oracleKey(0, 0, 0)); +} + +TEST(TileMapCreate, DefaultGrid) { + defaultGrid(); + defaultGrid(); +} + +TEST(TileMapCreate, NonZeroOrigin) { + TileMap::Options o; + o.originTileX = 5; + o.originTileY = -3; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + const TileMap& m = *r.valueIfOk(); + // The requested grid [5, 9) x [-3, 1): + EXPECT_TRUE(m.covers(5, -3)); + EXPECT_TRUE(m.covers(8, -3)); + EXPECT_TRUE(m.covers(8, 0)); + EXPECT_FALSE(m.covers(9, -3)); + EXPECT_FALSE(m.covers(5, -4)); + EXPECT_FALSE(m.covers(5, 1)); +} + +TEST(TileMapCreate, RejectsInvalidOptions) { + auto expectRejected = [](laige::Result> r, + laige::ErrorCode want) { + ASSERT_TRUE(r.isError()); + EXPECT_EQ(r.error(), want); + }; + auto make = [](auto mutate) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + mutate(o); + return TileMap::create(o); + }; + expectRejected( + make([](auto& o) { o.widthTiles = 0; }), + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.heightTiles = 0; }), + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.chunkTiles = 0; }), + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.chunkTiles = 3; }), // not a power of two + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.maxChunks = 0; }), + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.layer = 512; }), // above the layer domain + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.layer = -513; }), // below the layer domain + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.originTileX = -32768; }), // below the tile domain + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.originTileX = 32767; }), // above the tile domain + laige::ErrorCode::InvalidArgument); + expectRejected( + make([](auto& o) { o.widthTiles = 32767; o.originTileX = 1; }), + laige::ErrorCode::InvalidArgument); // max tile past the domain + // The grid exceeds the chunk cap (17 x 17 needs 2 chunks at chunk 16 + // — a create-time misconfiguration, InvalidArgument; the runtime + // growth cap is the BudgetExhausted, ensureChunk): + expectRejected( + make([](auto& o) { o.widthTiles = 17; o.heightTiles = 17; o.maxChunks = 1; }), + laige::ErrorCode::InvalidArgument); +} + +// --------------------------------------------------------------------------- +// TileMapData — the per-tile data writes, the height store, the load +// --------------------------------------------------------------------------- + +TEST(TileMapData, SetTileReadWrite) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + 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()); + const TileData t = m.tileAt(2, 1); + EXPECT_EQ(t.textureId, 7u); + EXPECT_EQ(t.height, 3); + EXPECT_EQ(t.animationId, 9u); + 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: + EXPECT_EQ(m.depthKeyAt(2, 1), 0x80200010u); + EXPECT_EQ(m.depthKeyAt(2, 1), oracleKey(2, 1, 3)); + // Every other tile is still flat: + EXPECT_EQ(m.tileAt(0, 0), TileData{}); + EXPECT_EQ(m.tileHeightAt(0, 0), 0); +} + +TEST(TileMapData, RejectedEditsLeaveNoState) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // A valid base edit: + ASSERT_TRUE(m.setTile(1, 1, 5, 1, 0).ok()); + const TileData base = m.tileAt(1, 1); + const std::uint32_t baseKey = m.depthKeyAt(1, 1); + // Outside the REQUESTED grid (the superset-margin cells are table + // cells, not tiles) — rejected, no state change: + expectRejected(m.setTile(4, 0, 1, 1, 0), + laige::ErrorCode::InvalidArgument); + expectRejected(m.setTile(0, 4, 1, 1, 0), + laige::ErrorCode::InvalidArgument); + expectRejected(m.setTile(-1, 0, 1, 1, 0), + laige::ErrorCode::InvalidArgument); + expectRejected(m.setTile(0, -1, 1, 1, 0), + laige::ErrorCode::InvalidArgument); + // Out-of-domain heights (above/below the key domain): + expectRejected(m.setTile(1, 1, 5, 2048, 0), + laige::ErrorCode::InvalidArgument); + expectRejected(m.setTile(1, 1, 5, -2048, 0), + laige::ErrorCode::InvalidArgument); + // The base edit is intact (the data AND the table): + EXPECT_EQ(m.tileAt(1, 1), base); + EXPECT_EQ(m.depthKeyAt(1, 1), baseKey); + // Boundary heights inside the domain are accepted: + ASSERT_TRUE(m.setTile(1, 1, 5, 2047, 0).ok()); + ASSERT_TRUE(m.setTile(1, 1, 5, -2047, 0).ok()); + ASSERT_TRUE(m.setTile(1, 1, 5, 1, 0).ok()); // back to the base height + EXPECT_EQ(m.tileAt(1, 1), base); + EXPECT_EQ(m.depthKeyAt(1, 1), baseKey); +} + +TEST(TileMapData, RebuildSceneLoad) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + const std::vector tiles = goldenTiles(); + ASSERT_TRUE(m.rebuild(tiles).ok()); + // Every tile's data and key (the hand-computed goldens + oracle): + for (std::int32_t ty = 0; ty < 4; ++ty) { + for (std::int32_t tx = 0; tx < 4; ++tx) { + const std::size_t i = static_cast(ty) * 4 + tx; + 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(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])) + << "tile (" << tx << ", " << ty << ")"; + } + } +} + +TEST(TileMapData, RebuildValidation) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // A valid base edit (the state a rejected load must not touch): + ASSERT_TRUE(m.setTile(0, 0, 4, 2, 1).ok()); + const TileData base = m.tileAt(0, 0); + const std::uint32_t baseKey = m.depthKeyAt(0, 0); + // Wrong span sizes (the requested grid has 16 tiles): + std::vector shortSpan(15); + expectRejected(m.rebuild(shortSpan), laige::ErrorCode::InvalidArgument); + std::vector longSpan(17); + expectRejected(m.rebuild(longSpan), laige::ErrorCode::InvalidArgument); + // An out-of-domain height anywhere in the span (tile 7 = 2048): + std::vector badHeight = goldenTiles(); + badHeight[7].height = 2048; + expectRejected(m.rebuild(badHeight), laige::ErrorCode::InvalidArgument); + std::vector badHeightLow = goldenTiles(); + badHeightLow[7].height = -2048; + expectRejected(m.rebuild(badHeightLow), laige::ErrorCode::InvalidArgument); + // The base edit is intact after all the rejections: + EXPECT_EQ(m.tileAt(0, 0), base); + EXPECT_EQ(m.depthKeyAt(0, 0), baseKey); +} + +template +void rebuildEqualsIncremental() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 8; + o.heightTiles = 8; + // The "rebuild from scratch == incremental" property through the + // tilemap: one tilemap loaded with rebuild, one built up tile by + // tile with setTile — the same grid, the same keys (the M2-ISO-02 + // property; the heights are a deterministic function of position — + // no PRNG substream needed): + auto rA = Map::create(o); + ASSERT_TRUE(rA.ok()); + auto a = std::move(rA).takeValue(); + auto rB = Map::create(o); + ASSERT_TRUE(rB.ok()); + auto b = std::move(rB).takeValue(); + std::vector tiles(64); + for (std::int32_t ty = 0; ty < 8; ++ty) { + for (std::int32_t tx = 0; tx < 8; ++tx) { + 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); + ASSERT_TRUE(b.setTile(tx, ty, tiles[i].textureId, tiles[i].height, + tiles[i].animationId).ok()); + } + } + ASSERT_TRUE(a.rebuild(tiles).ok()); + for (std::int32_t ty = 0; ty < 8; ++ty) { + for (std::int32_t tx = 0; tx < 8; ++tx) { + const std::size_t i = static_cast(ty) * 8 + tx; + EXPECT_EQ(a.tileAt(tx, ty), b.tileAt(tx, ty)) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(a.tileHeightAt(tx, ty), tiles[i].height) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(a.depthKeyAt(tx, ty), b.depthKeyAt(tx, ty)) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(a.depthKeyAt(tx, ty), oracleKey(tx, ty, tiles[i].height)) + << "tile (" << tx << ", " << ty << ")"; + } + } +} + +TEST(TileMapData, RebuildEqualsIncremental) { + rebuildEqualsIncremental(); + rebuildEqualsIncremental(); +} + +// --------------------------------------------------------------------------- +// TileMapAutoDepth — the height -> depth-table wiring (M2-ISO-02 path) +// --------------------------------------------------------------------------- + +template +void heightChangeUpdatesOnlyDocumentedCell() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 8; + o.heightTiles = 8; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // A deterministic base terrain (the update-test pattern): + std::vector tiles(64); + for (std::int32_t ty = 0; ty < 8; ++ty) { + for (std::int32_t tx = 0; tx < 8; ++tx) { + const std::size_t i = static_cast(ty) * 8 + tx; + tiles[i].textureId = 1; + tiles[i].height = (3 * tx + 5 * ty) % 4; + } + } + ASSERT_TRUE(m.rebuild(tiles).ok()); + std::vector before(64); + for (std::int32_t ty = 0; ty < 8; ++ty) { + for (std::int32_t tx = 0; tx < 8; ++tx) { + before[static_cast(ty) * 8 + tx] = m.depthKeyAt(tx, ty); + } + } + // A single tile-height edit changes ONLY the documented cell: the + // radius-0 neighborhood is the edited cell itself (the M2-ISO-02 + // contract through the tilemap). Hand computation: cell (3, 4): + // center (3.5, 4.5), sum 8, q = 128, h = 2 -> d = 96 -> key + // 0x80200060: + ASSERT_TRUE(m.setTile(3, 4, 9, 2, 0).ok()); + EXPECT_EQ(m.depthKeyAt(3, 4), 0x80200060u); + EXPECT_EQ(m.depthKeyAt(3, 4), oracleKey(3, 4, 2)); + EXPECT_EQ(m.tileHeightAt(3, 4), 2); + EXPECT_EQ(m.tileAt(3, 4).textureId, 9u); + for (std::size_t i = 0; i < 64; ++i) { + const std::int32_t tx = static_cast(i % 8); + const std::int32_t ty = static_cast(i / 8); + if (tx == 3 && ty == 4) { + EXPECT_NE(m.depthKeyAt(tx, ty), before[i]); // the edited cell changed + } else { + EXPECT_EQ(m.depthKeyAt(tx, ty), before[i]) + << "cell (" << tx << ", " << ty << ")"; + } + } + // Last write wins: setting the cell back to its base height + // ((9 + 20) % 4 = 1) restores the original key: + ASSERT_TRUE(m.setTile(3, 4, 9, 1, 0).ok()); + EXPECT_EQ(m.depthKeyAt(3, 4), before[4 * 8 + 3]); +} + +TEST(TileMapAutoDepth, HeightChangeUpdatesOnlyDocumentedCell) { + heightChangeUpdatesOnlyDocumentedCell(); + heightChangeUpdatesOnlyDocumentedCell(); +} + +TEST(TileMapAutoDepth, CrossBackendKeyAgreement) { + // The tile centers are dyadic and inside the exactness zone, so both + // backends' keys are bit-equal on the same grid: + std::vector fpx16, fp32; + for (std::int32_t ty = 0; ty < 4; ++ty) { + for (std::int32_t tx = 0; tx < 4; ++tx) { + const std::size_t i = static_cast(ty) * 4 + tx; + fpx16.push_back(oracleKey(tx, ty, kGoldenHeights[i])); + fp32.push_back(oracleKey(tx, ty, kGoldenHeights[i])); + } + } + ASSERT_EQ(fpx16.size(), fp32.size()); + for (std::size_t i = 0; i < fpx16.size(); ++i) { + EXPECT_EQ(fpx16[i], fp32[i]) << "cell " << i; + } +} + +// --------------------------------------------------------------------------- +// TileMapDeclareGolden — the tile quad positions + depth goldens +// (the roadmap's 4 x 4 chunk) +// --------------------------------------------------------------------------- + +template +void quadPositionsAndDepth() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + const std::vector tiles = goldenTiles(); + ASSERT_TRUE(m.rebuild(tiles).ok()); + // The batcher: exactly the frame's 16 tiles (the frame budget is + // tight on purpose — no overflow): + SpriteBatcher::Options bo; + bo.maxSprites = 16; + 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(), 16u); + EXPECT_EQ(batcher.overrideCount(), 0u); // no depthOverride (auto-depth) + // Every declared quad: the hand-computed position + depth goldens + // (the declaration order is row-major, tileX fastest — slot == + // declaration index on this first frame): + for (std::int32_t ty = 0; ty < 4; ++ty) { + for (std::int32_t tx = 0; tx < 4; ++tx) { + const std::size_t i = static_cast(ty) * 4 + tx; + const laige::render::SpriteItem* item = batcher.get( + static_cast(i)); + ASSERT_NE(item, nullptr) << "tile (" << tx << ", " << ty << ")"; + // The tile's CENTER (the exact dyadic float): + EXPECT_EQ(item->pos.x, static_cast(tx) + 0.5f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->pos.y, static_cast(ty) + 0.5f) << "tile (" << tx << ", " << ty << ")"; + // The AUTO-DEPTH key (the table's — the hand-computed golden, + // the oracle, and the tilemap's read path all agree): + EXPECT_EQ(item->depthKey, kGoldenKeys[i]) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->depthKey, oracleKey(tx, ty, kGoldenHeights[i])) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->depthKey, m.depthKeyAt(tx, ty)) << "tile (" << tx << ", " << ty << ")"; + EXPECT_FALSE(item->depthOverride) << "tile (" << tx << ", " << ty << ")"; + // The fixed-frame quad (the default DeclareOptions): + EXPECT_EQ(item->uv.u0, 0.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->uv.v0, 0.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->uv.u1, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->uv.v1, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->frameIndex, 0u) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->rotation, 0.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->scale.x, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->scale.y, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->tint.r, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->tint.g, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->tint.b, 1.0f) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->tint.a, 1.0f) << "tile (" << tx << ", " << ty << ")"; + // The tile's texture + the group-key fields: + EXPECT_EQ(item->atlasId, kGoldenTextures[i]) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->materialId, 0u) << "tile (" << tx << ", " << ty << ")"; + EXPECT_EQ(item->blend, BlendMode::Alpha) << "tile (" << tx << ", " << ty << ")"; + } + } +} + +TEST(TileMapDeclareGolden, QuadPositionsAndDepth) { + quadPositionsAndDepth(); + quadPositionsAndDepth(); +} + +// The hand-computed grouping + determinism of the golden scene: two +// distinct texture ids -> exactly two (atlas, material, blend) groups +// (one draw call each, FR-2.1), ascending atlas order, the in-group +// instance order = the global (key, declaration-position) order +// RESTRICTED to the group (the M2-SORT-01 stable sort; RENDER-003). +template +void groupingAndDeterminism() { + using Map = TileMap; + typename Map::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = Map::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + const std::vector tiles = goldenTiles(); + ASSERT_TRUE(m.rebuild(tiles).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 16; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + auto checkFrame = [&batcher]() { + ASSERT_EQ(batcher.batchCount(), 2u); + auto batches = batcher.batches(); + // Ascending (atlas, material, blend) group order: + EXPECT_EQ(batches[0].atlasId, 1u); + EXPECT_EQ(batches[0].materialId, 0u); + EXPECT_EQ(batches[0].blend, BlendMode::Alpha); + EXPECT_EQ(batches[1].atlasId, 2u); + // The hand-computed in-group instance sequences: + ASSERT_EQ(batches[0].instances.size(), 8u); + for (std::size_t i = 0; i < 8; ++i) { + EXPECT_EQ(batches[0].instances[i], kGroupOneInstances[i]) << "instance " << i; + } + ASSERT_EQ(batches[1].instances.size(), 8u); + for (std::size_t i = 0; i < 8; ++i) { + EXPECT_EQ(batches[1].instances[i], kGroupTwoInstances[i]) << "instance " << i; + } + }; + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + checkFrame(); + // Determinism: the second frame (same tile data) is bit-identical: + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, typename Map::DeclareOptions{}).ok()); + ASSERT_TRUE(batcher.build().ok()); + checkFrame(); + std::printf("tilemap-golden: frames=2 groups=2 instances=16 status=ok\n"); +} + +TEST(TileMapDeclareGolden, GroupingAndDeterminism) { + groupingAndDeterminism(); + groupingAndDeterminism(); +} + +// --------------------------------------------------------------------------- +// TileMapDeclare — the frame protocol, the failure paths, the options +// --------------------------------------------------------------------------- + +TEST(TileMapDeclare, WindowProtocol) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + SpriteBatcher::Options bo; + bo.maxSprites = 16; + 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()); + // A built frame's window is closed: the declare is rejected and + // NOTHING is declared: + expectRejected(m.declareTo(batcher, TileMap::DeclareOptions{}), + laige::ErrorCode::InvalidArgument); + batcher.beginFrame(); + EXPECT_EQ(batcher.frameCount(), 0u); +} + +TEST(TileMapDeclare, StoppedBatcher) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + SpriteBatcher batcher; // the default (stopped) batcher: capacity 0 + expectRejected(m.declareTo(batcher, TileMap::DeclareOptions{}), + laige::ErrorCode::BudgetExhausted); + // The tilemap is unchanged (nothing was declared): + EXPECT_EQ(m.tileAt(0, 0), TileData{}); +} + +TEST(TileMapDeclare, CustomOptions) { + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + auto r = TileMap::create(o); + ASSERT_TRUE(r.ok()); + auto m = std::move(r).takeValue(); + // One texture everywhere (one group): + std::vector tiles(16); + for (auto& t : tiles) t.textureId = 3; + ASSERT_TRUE(m.rebuild(tiles).ok()); + SpriteBatcher::Options bo; + bo.maxSprites = 16; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + TileMap::DeclareOptions dopts; + dopts.materialId = 7; + dopts.blend = BlendMode::Additive; + dopts.uv = laige::render::SpriteUvRect{0.25f, 0.25f, 0.75f, 0.75f}; + batcher.beginFrame(); + ASSERT_TRUE(m.declareTo(batcher, dopts).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(batcher.frameCount(), 16u); + ASSERT_EQ(batcher.batchCount(), 1u); + const auto batch = batcher.batches()[0]; + EXPECT_EQ(batch.atlasId, 3u); + EXPECT_EQ(batch.materialId, 7u); + EXPECT_EQ(batch.blend, BlendMode::Additive); + // Every item carries the options: + for (std::uint32_t slot = 0; slot < 16; ++slot) { + const laige::render::SpriteItem* item = batcher.get(slot); + ASSERT_NE(item, nullptr); + EXPECT_EQ(item->atlasId, 3u); + EXPECT_EQ(item->materialId, 7u); + EXPECT_EQ(item->blend, BlendMode::Additive); + EXPECT_EQ(item->uv.u0, 0.25f); + EXPECT_EQ(item->uv.v0, 0.25f); + EXPECT_EQ(item->uv.u1, 0.75f); + EXPECT_EQ(item->uv.v1, 0.75f); + } +} + +TEST(TileMapDeclare, NoLogsOnHappyPath) { + MemorySink* sink = installCaptureSink(); + TileMap::Options o; + o.widthTiles = 4; + o.heightTiles = 4; + 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 edit + declare paths log + // nothing (the Status is the failure channel — LOG-002, and the + // update paths are budget-critical — LOG-003): + sink->entries.clear(); + for (std::int32_t ty = 0; ty < 4; ++ty) { + for (std::int32_t tx = 0; tx < 4; ++tx) { + ASSERT_TRUE(m.setTile(tx, ty, 1, (tx + ty) % 3, 0).ok()); + } + } + SpriteBatcher::Options bo; + bo.maxSprites = 16; + 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 edit/declare path logged " << sink->entries.size() + << " events (first: " + << (sink->entries.empty() ? "n/a" : sink->entries[0].event) << ")"; + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// TileMapZeroAlloc — the per-frame declare loop allocates nothing +// (FR-2.2) +// --------------------------------------------------------------------------- + +template +void declareLoopAllocatesNothing() { + 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) { + tiles[i].textureId = 1 + static_cast(i % 4); + tiles[i].height = static_cast((i * 3) % 5); + } + ASSERT_TRUE(m.rebuild(tiles).ok()); // setup (before the window) + SpriteBatcher::Options bo; + bo.maxSprites = 256; + auto rb = SpriteBatcher::create(bo); + ASSERT_TRUE(rb.ok()); + auto batcher = std::move(rb).takeValue(); + // Zero-allocation proof (where the watch is live — the non- + // sanitizer trees; the sanitizer runtimes own operator new): + // 1000 frames of the declare loop allocate nothing (the batcher and + // the sorter storage are pre-allocated): + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + for (std::int32_t frame = 0; frame < 1000; ++frame) { + batcher.beginFrame(); + auto s = m.declareTo(batcher, typename Map::DeclareOptions{}); + if (!s.ok()) return; + auto b = batcher.build(); + if (!b.ok()) return; + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << "1000 frames of beginFrame/declareTo/build allocated " + << reading.allocs << " heap blocks (first site: " + << (void*)reading.firstSite << ")"; + } +} + +TEST(TileMapZeroAlloc, DeclareLoopAllocatesNothing) { + declareLoopAllocatesNothing(); + declareLoopAllocatesNothing(); +}