diff --git a/CMakeLists.txt b/CMakeLists.txt index b9ec4db..a8755c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,4 +271,5 @@ if(LAIGE_BUILD_TESTS) add_subdirectory(tools/detcheck) # M0-TOOL-02: laige-detcheck add_subdirectory(tools/run) # M1-HEAD-01: laige-run (+ smoke test) add_subdirectory(tools/replay) # M1-DET-03: laige-replay + add_subdirectory(tools/scene-iso) # M2-SCENE-01: laige-scene-iso (+ CLI determinism gate) endif() diff --git a/docs/README.md b/docs/README.md index ae32c9f..26b05d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -393,6 +393,19 @@ still to land. and the pass-generated button labels / input content through `declareText`) into the pass's own `SpriteBatcher` (one texture per atlas, the G-R11 monotone UI z). + - [Isometric reference scene](api/iso_reference_scene.md) — + `laige::render::buildIsoReferenceScene` / + `IsoReferenceSceneWorld` (M2-SCENE-01, the PRD §4 reference + scene for all visual and performance acceptance tests): the + deterministic seed-driven worst-case isometric scene builder + (50k overlapping sprites on stepped-terrain plateaus, 3 + parallax layers, particle emitters, a UI panel — the 1k-sprite + Small variant for fast unit tests), the versioned binary scene + format (the untrusted-input reader, the fuzz surface), the + engine-side world (load + the one-call whole-world declare, + zero allocation), the pinned 14-group draw-call plan, and the + headless `laige-scene-iso` generator CLI (the same-seed + byte-identity gate). ## Guides diff --git a/docs/api/iso_reference_scene.md b/docs/api/iso_reference_scene.md new file mode 100644 index 0000000..b8befb0 --- /dev/null +++ b/docs/api/iso_reference_scene.md @@ -0,0 +1,257 @@ +# Isometric reference scene (`laige::render` scene) + +The worst-case isometric reference scene of PRD §4 — the deterministic, +seed-driven scene every visual and performance acceptance test renders +(M2-SCENE-01; PRD §4, §8.1, §9.1 S-5; AGENTS ARCH-007/009/010, SCALE-004/005, +TEST-005, PERF-002/003, CORE-005/008, RENDER-001/003; ADR 0002). The step's +scope: a headless, seed-deterministic builder that generates the worst-case +isometric scene (50k overlapping sprites on tiled ground with stepped +heights, 3 parallax layers, particle emitters, a UI panel, and the +tile-height terrain) and emits it as data the engine can load. The full +scene FORMAT is M3; this step's versioned binary format is the M2 stand-in +(the in-memory builder + the simple serialization the tests use). + +Public header: +`src/laige-render/include/laige/render/iso_reference_scene.h` +(header-only: the builder, the format functions, and the +`IsoReferenceSceneWorld`/`loadIsoReferenceScene`/ +`declareIsoReferenceScene` templates over the SimMath backends). +Unit suite: `ctest -R scene_iso` +(`tests/laige-render/scene_iso_tests.cpp`) — pure data + batcher +bookkeeping, no GL environment required: it runs in every local tree and +in CI. The CLI's same-seed byte-identity gate is the separate +`scene_iso_cli` entry (tools/scene-iso); the parser's fuzz gate is +`fuzz_scene_iso_parse` (tools/fuzz). + +The scene is a **test fixture**, not an asset: it carries no image data +(the atlases are the scene's ids — the game/tests bind their textures), +it is presentation state only (ARCH-009 — never sim state, never in the +replay hash), and its determinism scope is the PRng's (ARCH-010): same +seed, same bytes, every build and P0 platform (the Prng + the exact +2^-16 position scale — no FMA-susceptible float composition in the +builder). The deterministic gate that pins the scope is +`IsoReferenceSceneBuilder` (same-seed byte identity, different-seed +difference, both variants) plus the `scene_iso_cli` ctest entry (two +same-seed CLI invocations per variant compared byte for byte). + +## The API + +| Symbol | Returns | Notes | +|---|---|---| +| `buildIsoReferenceScene(seed, variant)` | `IsoReferenceScene` | total function (no failure): the scene is a pure function of (seed, variant) — O(scene size) time + O(scene size) memory (two vectors), setup only | +| `serializeIsoReferenceScene(scene)` | `vector` | total function: one allocation (the output), setup only; same scene, same bytes | +| `readIsoReferenceScene(bytes)` | `Result` | the versioned, domain-validated read of an UNTRUSTED byte stream (SCALE-004): any violation fails the whole read with `MalformedInput` (nothing is partially applied) | +| `writeIsoReferenceScene(scene, path)` | `Status` | atomic temp + rename (a killed run never leaves a partial file); `IoError` on open/write/rename failure | +| `loadIsoReferenceScene(scene)` | `Result>` | the setup-path build of the scene's five engine objects; first failure wins (the failed step's error); O(scene size), setup only | +| `declareIsoReferenceScene(world, batcher, cameraPos)` | `Status` | ONE call = the whole world pass (parallax + tiles + sprites + particles); O(frame items), NO allocation; a stopped world -> `InvalidArgument` | + +The value types are plain data (copyable, wire-explicit — struct padding +never leaks into the format): `IsoRefSceneVec2` (a float pair), +`IsoRefSceneSprite` (the 14-byte wire record: position, standing step +height, atlas, blend, frame), `IsoRefSceneParallaxLayer` (67 bytes), +`IsoRefSceneEmitter` (56 bytes), `IsoRefSceneUiWidget` (23 bytes), and +`IsoReferenceScene` (the whole scene: the terrain grid + the content +vectors + the fixed arrays). + +`IsoReferenceSceneWorld` is move-only (the SpriteBatcher +precedent) and owns the scene's five engine objects plus a copy of the +scene that built it and the 4 precomputed sprite-sheet frame UVs: + +| Accessor | Notes | +|---|---| +| `valid()` | true iff the load succeeded (default / moved-from state is stopped) | +| `scene()` | the owned scene copy (M2-PERF-01 reads the scene's parameters through it) | +| `tiles()` / `parallax()` / `particles()` / `ui()` / `strings()` | the engine objects (the tilemap's accessor asserts the live state — the caller checks `valid()` first) | +| `contentFrameCount()` / `worstCaseFrameCount()` | the declared frame's minimum / worst-case size (a scrolled auto parallax layer splits into up to 4 quads — the parallax wrap contract; the batcher's capacity must cover the worst case plus the live particles) | + +## The scene contents (the worst-case composition) + +Built from one seed through three deterministic Prng substreams (terrain += 1, sprites = 2, emitters = 3 — the M0-CORE-06 pattern): + +- **FULL** (`IsoRefSceneVariant::Full`, the §8.1 budget scene): a 256 x 256 + tile grid (65 536 tiles) at the world origin, partitioned into 16 x 16-tile + plateaus — each plateau a uniform PRNG-drawn height (step x 2 world + units, step in [0, 24)) — so the terrain is a terraced, stepped surface + with 24 distinct heights in [0, 46] (the M2-ISO-01 |z| <= 2047 domain + with wide margin); 50 000 sprites at uniform-random ground positions + (each `tile + fraction`, the fraction a uniform dyadic in [0, 1) at + 2^-16 resolution — exact in both SimMath backends), each standing on its + PRNG-chosen tile (the sprite's `stepHeight` field is that tile's height + — one source of truth); 3 image parallax layers (factors 0 / 0.5 / 1.0 — + the M2-PAR-01 preset factors; 128 x 128 world units each); 8 continuous + particle emitters (rate 2/tick, 40..120 tick lives, additive orange + sparks); a 2-widget UI (one opaque panel, one "OK" button) on the 1280 x + 720 reference screen. +- **SMALL** (`IsoRefSceneVariant::Small`, the fast unit-test fixture): the + same composition scaled down (32 x 32 grid of 8 x 8-tile plateaus, 1 000 + sprites). + +Worst-case properties (why this is the reference scene): + +- 50 000 sprites over 65 536 tiles means several sprites per depth row: + the `(x + y) - z` value quantizes to at most a few thousand distinct + 32-bit keys, so the M2-SORT-01 radix sort sees a dense multi-sprite + tie band on every row (the worst-case overlap for depth sorting, + PRD §4); +- the 50k sprites + 65k tile quads + parallax quads + live particles + fill a ~116k-item frame — the batcher's frame budget exercised at its + reference size; +- the atlas-id plan (below) pins the §8.1 workload to 14 + (atlas, material, blend) groups = 14 draw calls in the world pass — + under the 30-draw-call budget, asserted by the test suite. + +## The atlas-id plan (the cross-group render order) + +The batcher's cross-group draw order is ascending (atlasId, materialId, +blend) (M2-SPRITE-01), so the scene assigns atlas ids that make group +order = painter's order (the parallax.h scene-setup convention — +background first, ground next, world objects, particles, foreground +last): + +| atlas id | content | depth layer | +|---|---|---| +| 0 | background parallax layer | -2 | +| 1 | mid parallax layer | -1 | +| 2 | ground tilemap (all tiles) | 0 | +| 3..6 | the 4 sprite atlases (Alpha / Additive) | 0 | +| 7 | the particle atlas (Additive) | 0 | +| 8 | foreground parallax layer | +1 | + += `kIsoRefSceneSpritePassGroups` = 14 groups. The UI pass is a separate +batcher (M2-UI-02) with its own ids: widget art atlas 100, font atlas +101 (unused when the pass declares without a default font — the +label-less world pass). + +## The binary format (version 1) + +A flat little-endian binary, written field by field (the explicit layout +below — struct padding never leaks in; floats are IEEE-754 binary32; +multi-byte integers little-endian; no checksum — the reader's domain +validation is the integrity contract, and the determinism check is the +generator's): + +| offset | size | field | +|---|---|---| +| 0 | 4 | version (u32; MUST be 1 for this reader) | +| 4 | 1 | variant (u8: 0 = small, 1 = full) | +| 5 | 3 | reserved (zero) | +| 8 | 8 | seed (u64) | +| 16 | 4 | originTileX (i32) | +| 20 | 4 | originTileY (i32) | +| 24 | 4 | widthTiles (i32) | +| 28 | 4 | heightTiles (i32) | +| 32 | 4 | plateauTiles (i32) | +| 36 | 4 | stepHeight (i32) | +| 40 | 4 | maxSteps (i32) | +| 44 | 4 | tileCount (u32) | +| 48 | 4 | spriteCount (u32) | +| 52 | 4 | emitterCount (u32) | +| 56 | 4 | uiWidgetCount (u32) | +| 60 | 4 | parallaxCount (u32) | +| 64 | 4 | reserved (zero) | +| 68 | 4 x tileCount | tileHeights (i32 per tile, row-major, tileX fastest) | +| ... | 14 x spriteCount | sprites: x (f32), y (f32), stepHeight (i16), atlasId (u8), blend (u8), frame (u8), reserved (u8) | +| ... | 67 x parallaxCount | parallax layers: factor (f32), center (2 f32), offset (2 f32), size (2 f32), uv (4 f32), atlasId (u32), materialId (u32), blend (u8), scrollMode (u8), enabled (u8), reserved (u8), depthLayer (i32), scrollSpeed (2 f32) | +| ... | 56 x emitterCount | emitters: originX/Y (2 f32), depth (i32), velMin/velMax (4 f32), lifeMin/lifeMax (2 u32), sizeMin/sizeMax (2 f32), tint (4 u8), fadeTicks (u32), continuousRate (u32) | +| ... | 23 x uiWidgetCount | UI widgets: kind (u8), opaque (u8), x/y/w/h (4 i32), atlasId (u32), label (u8) | + +The reader treats the bytes as untrusted input (SCALE-004): every field +is bounds- and domain-checked (the header's grid/height domains, the +sprite position/atlas/blend/frame domains, the parallax factor/size/uv/ +depthLayer domains, the emitter life/size domains, the widget kind/ +geometry domains, and the exact total length — trailing bytes are +rejected), and any violation fails the whole read with `MalformedInput`. +Versioning (ARCH-007): the reader rejects any other version with +`MalformedInput` — it never guesses; a future format version bumps +`version` and gets a new reader. The untrusted-input surface has a fuzz +target (`fuzz_scene_iso_parse`, 1000 bounded runs every commit — +TEST-005 / SCALE-005) whose corpus includes valid v1 small + full +scenes as mutate/truncate bases. + +## The engine-side world (load + declare) + +`loadIsoReferenceScene(scene)` builds, in order (first failure +wins, the world is left stopped on failure — nothing owned): + +1. the terrain `TileMap` (origin/size from the scene; layer 0; one + `rebuild` of the scene's heights; one (atlas 2, material 0, Alpha) + group for the whole ground); +2. the `ParallaxLayers` registry (the scene's 3 image layers in + declaration order — bg, mid, fg); +3. the `ParticleSystem` (the scene's emitters; the system's Prng seeded + from the scene's seed — deterministic live particles); +4. the `StringTable` (the button's interned "OK") + the `UiTree` + (the scene's 2 widgets on the 1280 x 720 reference screen; each + widget's art rect is the whole atlas — the scene assigns the + texture, M3-ASSET-01); +5. the 4 sprite-sheet frame UVs (the tight 32 x 32 sheet of 16 x 16 + texel frames — precomputed once at load, never per sprite). + +`declareIsoReferenceScene(world, batcher, cameraPos)` is the per-frame +declare (the batcher's frame window must be open — the frame protocol; +`cameraPos` is the render Vec2, the parallax's presentation input, +RENDER-006): the parallax layers (after their once-per-frame scroll +advance — the documented pacing), the terrain tiles (one group), the +sprites (the scene's array order — the RENDER-003 deterministic +insertion, each with `isoDepthKey(pos, the standing tile's height, +layer 0)` and its precomputed frame UV), and the live particles (one +additive group). First failure wins: the sub-step's error is the call's +error, and the items already declared by this call remain in the frame +(the batcher owns the frame state — the game re-declares the whole +frame every frame). + +## The CLI tool (`laige-scene-iso`) + +``` +laige-scene-iso --seed --variant --out +``` + +Builds the scene deterministically from the seed, serializes it, writes +it atomically (temp + rename), then reads the file back and verifies the +round trip (byte-identical + a clean parse — no silent failure, +CORE-008). Headless: no GL context, no window, no sim world (ARCH-003). +Exit codes: 0 = written + verified (the machine-greppable summary line +records variant, seed, byte count, shape, and the measured generation +time — CORE-001), 2 = usage / IO / round-trip failure. The step's +"< 5 s" generation budget is the tool's property (the `scene_iso_cli` +ctest entry's 60 s timeout guards it with wide margin; the absolute +number is machine-dependent — the summary line records it). The +determinism gate (`scene_iso_cli`) runs the tool twice per variant with +the same seed and compares the output files byte for byte — the +roadmap's Verify clause in executable form. + +## Performance + +- **Build**: O(scene size) time + O(scene size) memory (two vectors), + setup only; the full scene serializes to ~0.94 MB. No §8.1 + budgets.json entry (the generation budget is the tool's — the + budgets.json count is unchanged). +- **Load**: O(scene size), setup only (the tilemap's rebuild, the three + setLayer, the eight addEmitter, the two addChild, one intern, the 4 + frame-UV precomputations) — a handful of setup allocations (the + objects' own — the house pattern). +- **Declare**: O(frame items) per frame, **NO allocation** (the batcher's + storage is pre-sized; the per-sprite isoDepthKey is a handful of + integer ops; the UV rects are the world's 4 precomputed values). The + declare is one component of the §8.1 "50k sprites ... <= 2 ms CPU" + composite budget — M2-PERF-01 measures the whole frame (tile quads + included), not this step alone. The zero-allocation property is + asserted by `IsoReferenceSceneZeroAlloc` (1 000 frames, both + backends — the sanitizer trees' instrumentation is the check there). + +## Misuse warnings + +- **The scene file is a test fixture, not an asset**: it carries no + image data, and it is NOT versioned scene SAVE data (ARCH-007 applies + to the file's own format only — the M3 scene format supersedes it). +- **The parallax auto layers ADVANCE on every + `declareIsoReferenceScene` call**: declare once per frame, in the + render phase, never per sprite or in a tight retry loop (the scroll + offsets are presentation state — a double advance is a wrong scroll, + not an error the engine can detect). +- **Size the world pass's batcher to the scene's frame**: the full scene + declares ~116k items per frame (`worstCaseFrameCount()` + the live + particles); an undersized batcher drops the oldest items (the + batcher's overflow policy — silent degradation, LOG-004) and the + frame is still built. diff --git a/laige-api.json b/laige-api.json index d017034..bc6c0a1 100644 --- a/laige-api.json +++ b/laige-api.json @@ -23,6 +23,7 @@ "src/laige-render/include/laige/render/iso_depth_key.h", "src/laige-render/include/laige/render/iso_depth_table.h", "src/laige-render/include/laige/render/iso_picking.h", + "src/laige-render/include/laige/render/iso_reference_scene.h", "src/laige-render/include/laige/render/matrices.h", "src/laige-render/include/laige/render/parallax.h", "src/laige-render/include/laige/render/particle_render.h", @@ -779,6 +780,133 @@ {"name": "laige::render::IsoGridPick::ground", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 228, "signature": "Vec2 ground{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::screenToGrid", "kind": "function", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 280, "signature": "[[nodiscard]] inline IsoGridPick screenToGrid(Vec2 screen, const IsoCamera& camera, const IsoGridConfig& grid) noexcept", "summary": "The safe isometric pick (FR-2.11; the header preamble is the full contract): the NDC screen point -> the ground plane (z = 0) -> the grid cell. O(1) (the 2x2 solve + two divisions + the camera's O(1) matrix build); total for every input (the header preamble's saturation section); no allocation, no logging, no GL.", "budget": "O(1); no allocation.", "experimental": false}, {"name": "laige::render::screenToGrid", "kind": "function", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 298, "signature": "[[nodiscard]] inline IsoGridPick screenToGrid(Vec2 screen, const ProjectionView& view, const IsoGridConfig& grid) noexcept", "summary": "The ProjectionView overload (the M2-PROJ-01 base this step lands on — the header preamble): the same inverse over a hand-stored iso matrix (advanced use: e.g. a custom shear through the raw isoMatrix builder, M2-CAM-02). Precondition: view.mode == ProjectionMode::Iso (the per-mode structure assumption — the matrices.h house convention: debug assert, release undefined).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::kIsoRefSceneFormatVersion", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 314, "signature": "inline constexpr std::uint32_t kIsoRefSceneFormatVersion = 1u", "summary": "The scene file's format version (ARCH-007 — readers reject other versions; v1 is this header's layout).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSubstreamTerrain", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 318, "signature": "inline constexpr std::uint32_t kIsoRefSceneSubstreamTerrain = 1u", "summary": "The deterministic builder's PRNG substream ids (stable subsystem identifiers, the M0-CORE-06 pattern): terrain, sprites, emitters.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSubstreamSprites", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 319, "signature": "inline constexpr std::uint32_t kIsoRefSceneSubstreamSprites = 2u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSubstreamEmitters", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 320, "signature": "inline constexpr std::uint32_t kIsoRefSceneSubstreamEmitters = 3u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneFullSprites", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 324, "signature": "inline constexpr std::uint32_t kIsoRefSceneFullSprites = 50000u", "summary": "The two scene sizes (the step scope: the 50k worst-case scene and the small 1k-sprite variant for fast unit tests).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSmallSprites", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 325, "signature": "inline constexpr std::uint32_t kIsoRefSceneSmallSprites = 1000u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneFullGridTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 330, "signature": "inline constexpr std::int32_t kIsoRefSceneFullGridTiles = 256", "summary": "The terrain grids (tiles per axis): the full 256 x 256 and the small 32 x 32 (both powers of two — the plateau partition divides evenly).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSmallGridTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 331, "signature": "inline constexpr std::int32_t kIsoRefSceneSmallGridTiles = 32", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneFullPlateauTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 335, "signature": "inline constexpr std::int32_t kIsoRefSceneFullPlateauTiles = 16", "summary": "The plateau partition (tiles per plateau per axis): 16 x 16 plateaus over the full grid, 8 x 8 over the small one.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSmallPlateauTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 336, "signature": "inline constexpr std::int32_t kIsoRefSceneSmallPlateauTiles = 8", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneTerrainSteps", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 342, "signature": "inline constexpr std::int32_t kIsoRefSceneTerrainSteps = 24", "summary": "The stepped-terrain height model: each plateau sits at a PRNG-drawn step in [0, kIsoRefSceneTerrainSteps), at `kIsoRefSceneStepHeight` world units per step -> heights in [0, (steps-1) x step] = [0, 46] (inside the M2-ISO-01 |z| <= 2047 domain with wide margin).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneStepHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 343, "signature": "inline constexpr std::int32_t kIsoRefSceneStepHeight = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneParallaxLayerCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 347, "signature": "inline constexpr std::uint32_t kIsoRefSceneParallaxLayerCount = 3u", "summary": "The scene's fixed content counts (the full and small variants share them — the size differs only in the grid and the sprite count).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneEmitterCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 348, "signature": "inline constexpr std::uint32_t kIsoRefSceneEmitterCount = 8u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiWidgetCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 349, "signature": "inline constexpr std::uint32_t kIsoRefSceneUiWidgetCount = 2u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiScreenWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 353, "signature": "inline constexpr std::int32_t kIsoRefSceneUiScreenWidth = 1280", "summary": "The UI reference screen (the M2-GOLD-01 reference resolution — the world's UiTree screen rect).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiScreenHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 354, "signature": "inline constexpr std::int32_t kIsoRefSceneUiScreenHeight = 720", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiMaxWidgets", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 357, "signature": "inline constexpr std::uint32_t kIsoRefSceneUiMaxWidgets = 16u", "summary": "The world's UI budgets (scene-scale: the scene declares 2 widgets; the tree pool and event list are sized small on purpose).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiMaxEvents", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 358, "signature": "inline constexpr std::uint32_t kIsoRefSceneUiMaxEvents = 64u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSpriteSheetFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 363, "signature": "inline constexpr std::uint32_t kIsoRefSceneSpriteSheetFrames = 4u", "summary": "The sprite sheet: a 2 x 2 grid of 16 x 16 texel frames (4 frames), packed (no spacing, no border) -> a tight 32 x 32 sheet. The frame UVs are precomputed at load (4 rects), never per sprite.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSpriteSheetFrameTexels", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 364, "signature": "inline constexpr std::uint32_t kIsoRefSceneSpriteSheetFrameTexels = 16u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneBgAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 370, "signature": "inline constexpr std::uint32_t kIsoRefSceneBgAtlas = 0u", "summary": "The atlas-id plan (the header's plan section — the cross-group painter's order, parallax.h): 0 bg layer | 1 mid layer | 2 ground | 3..6 sprites | 7 particles | 8 fg layer; UI pass: 100 widget art, 101 font.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneMidAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 371, "signature": "inline constexpr std::uint32_t kIsoRefSceneMidAtlas = 1u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneGroundAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 372, "signature": "inline constexpr std::uint32_t kIsoRefSceneGroundAtlas = 2u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSpriteAtlasBase", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 373, "signature": "inline constexpr std::uint32_t kIsoRefSceneSpriteAtlasBase = 3u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSpriteAtlasCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 374, "signature": "inline constexpr std::uint32_t kIsoRefSceneSpriteAtlasCount = 4u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneParticleAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 375, "signature": "inline constexpr std::uint32_t kIsoRefSceneParticleAtlas = 7u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneFgAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 376, "signature": "inline constexpr std::uint32_t kIsoRefSceneFgAtlas = 8u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiWidgetAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 377, "signature": "inline constexpr std::uint32_t kIsoRefSceneUiWidgetAtlas = 100u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneUiFontAtlas", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 378, "signature": "inline constexpr std::uint32_t kIsoRefSceneUiFontAtlas = 101u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneSpritePassGroups", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 384, "signature": "inline constexpr std::uint32_t kIsoRefSceneSpritePassGroups = 14u", "summary": "The scene's world-pass group count: 2 (bg/mid) + 1 (ground) + 4 x 2 (sprite atlases x Alpha/Additive) + 1 (particles) + 1 (fg) = 14 (atlas, material, blend) groups = 14 instanced draw calls — the pinned draw-call property under the §8.1 30-draw-call budget.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneLabelNone", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 388, "signature": "inline constexpr std::uint8_t kIsoRefSceneLabelNone = 0u", "summary": "The UI label table (the format's fixed strings — no strings in the file, only these ids): 0 = no label, 1 = \"OK\" (the button).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoRefSceneLabelOk", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 389, "signature": "inline constexpr std::uint8_t kIsoRefSceneLabelOk = 1u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneVariant", "kind": "enum", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 392, "signature": "enum class IsoRefSceneVariant : std::uint8_t", "summary": "The scene variant (the file's u8 field; the builder's parameter).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneVariant::Small", "kind": "enumerator", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 393, "signature": "Small = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneVariant::Full", "kind": "enumerator", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 394, "signature": "Full = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneVec2", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 400, "signature": "struct IsoRefSceneVec2", "summary": "The scene file's point values (a pair of floats — the file's float32 fields; the backend conversion at load is the RENDER-006 boundary).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneVec2::x", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 401, "signature": "float x{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneVec2::y", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 402, "signature": "float y{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 410, "signature": "struct IsoRefSceneSprite", "summary": "One scene sprite (the 14-byte wire record): a ground-plane position (exact k x 2^-16 world units, in [0, grid) x [0, grid)), the standing tile's step height (the depth-key `z`), the sprite's atlas (the plan above), its blend state (0 = Alpha, 1 = Additive — the BatchMode wire values), and its sheet frame (0..3).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::x", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 411, "signature": "float x{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::y", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 412, "signature": "float y{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::stepHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 413, "signature": "std::int16_t stepHeight{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 414, "signature": "std::uint8_t atlasId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 415, "signature": "std::uint8_t blend{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::frame", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 416, "signature": "std::uint8_t frame{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneSprite::reserved", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 417, "signature": "std::uint8_t reserved{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 423, "signature": "struct IsoRefSceneParallaxLayer", "summary": "One scene parallax layer (the 67-byte wire record; image source only — the reference scene's layers are all images): the M2-PAR-01 ParallaxLayerDef's fields in file order.", "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::factor", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 424, "signature": "float factor{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::center", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 425, "signature": "IsoRefSceneVec2 center{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 426, "signature": "IsoRefSceneVec2 offset{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::size", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 427, "signature": "IsoRefSceneVec2 size{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 428, "signature": "SpriteUvRect uv{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 429, "signature": "std::uint32_t atlasId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 430, "signature": "std::uint32_t materialId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 431, "signature": "std::uint8_t blend{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::scrollMode", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 432, "signature": "std::uint8_t scrollMode{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::enabled", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 433, "signature": "std::uint8_t enabled{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::reserved", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 434, "signature": "std::uint8_t reserved{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::depthLayer", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 435, "signature": "std::int32_t depthLayer{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneParallaxLayer::scrollSpeed", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 436, "signature": "IsoRefSceneVec2 scrollSpeed{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 442, "signature": "struct IsoRefSceneEmitter", "summary": "One scene particle emitter definition (the 56-byte wire record): the M2-PART-01 ParticleEmitterDef's fields in file order (depth is the M2-ISO-01 step-height domain, i32 in the file).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::originX", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 443, "signature": "float originX{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::originY", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 444, "signature": "float originY{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::depth", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 445, "signature": "std::int32_t depth{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::velMinX", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 446, "signature": "float velMinX{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::velMinY", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 447, "signature": "float velMinY{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::velMaxX", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 448, "signature": "float velMaxX{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::velMaxY", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 449, "signature": "float velMaxY{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::lifeMin", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 450, "signature": "std::uint32_t lifeMin{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::lifeMax", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 451, "signature": "std::uint32_t lifeMax{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::sizeMin", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 452, "signature": "float sizeMin{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::sizeMax", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 453, "signature": "float sizeMax{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::tint", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 454, "signature": "std::uint8_t tint[4]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::fadeTicks", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 455, "signature": "std::uint32_t fadeTicks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneEmitter::continuousRate", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 456, "signature": "std::uint32_t continuousRate{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 462, "signature": "struct IsoRefSceneUiWidget", "summary": "One scene UI widget spec (the 23-byte wire record): the M2-UI-01 UiWidgetSpec's fields in file order (the label is the fixed label table id, not a string).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::kind", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 463, "signature": "std::uint8_t kind{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::opaque", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 464, "signature": "std::uint8_t opaque{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::x", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 465, "signature": "std::int32_t x{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::y", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 466, "signature": "std::int32_t y{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::width", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 467, "signature": "std::int32_t width{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::height", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 468, "signature": "std::int32_t height{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 469, "signature": "std::uint32_t atlasId{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoRefSceneUiWidget::label", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 470, "signature": "std::uint8_t label{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 475, "signature": "struct IsoReferenceScene", "summary": "The whole reference scene (plain values; the header's model section is the content contract). Copyable (the vectors carry the storage).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::version", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 478, "signature": "std::uint32_t version{kIsoRefSceneFormatVersion}", "summary": "The file's format version (the reader's gate — always kIsoRefSceneFormatVersion for builder output).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::variant", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 481, "signature": "IsoRefSceneVariant variant{IsoRefSceneVariant::Full}", "summary": "The variant the scene was built as (Small/Full — the shape constants below are consistent with it by construction).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::seed", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 483, "signature": "std::uint64_t seed{}", "summary": "The seed that built the scene (the determinism record, ARCH-010).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::originTileX", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 486, "signature": "std::int32_t originTileX{}", "summary": "The terrain grid (row-major heights, tileX fastest — the tilemap's flat order).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::originTileY", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 487, "signature": "std::int32_t originTileY{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::widthTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 488, "signature": "std::int32_t widthTiles{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::heightTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 489, "signature": "std::int32_t heightTiles{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::plateauTiles", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 490, "signature": "std::int32_t plateauTiles{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::stepHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 491, "signature": "std::int32_t stepHeight{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::maxSteps", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 492, "signature": "std::int32_t maxSteps{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::tileCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 493, "signature": "std::uint32_t tileCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::spriteCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 496, "signature": "std::uint32_t spriteCount{}", "summary": "The content counts (the array sizes below hold the max; the counts say how many are live).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::emitterCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 497, "signature": "std::uint32_t emitterCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::uiWidgetCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 498, "signature": "std::uint32_t uiWidgetCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::parallaxCount", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 499, "signature": "std::uint32_t parallaxCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::tileHeights", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 501, "signature": "std::vector tileHeights", "summary": "The storage (fixed max sizes; the counts bound the live range).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::sprites", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 502, "signature": "std::vector sprites", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::parallax", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 503, "signature": "IsoRefSceneParallaxLayer parallax[kIsoRefSceneParallaxLayerCount]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::emitters", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 504, "signature": "IsoRefSceneEmitter emitters[kIsoRefSceneEmitterCount]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceScene::uiWidgets", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 505, "signature": "IsoRefSceneUiWidget uiWidgets[kIsoRefSceneUiWidgetCount]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::buildIsoReferenceScene", "kind": "function", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 513, "signature": "[[nodiscard]] inline IsoReferenceScene buildIsoReferenceScene(std::uint64_t seed, IsoRefSceneVariant variant) noexcept", "summary": "The deterministic in-memory builder (the header's model section): the scene is a pure function of (seed, variant) — same seed, same scene, every build and platform (ARCH-010, the Prng scope). O(scene size) time + O(scene size) memory (two vectors), setup path.", "budget": "O(scene size); two allocations.", "experimental": false}, + {"name": "laige::render::serializeIsoReferenceScene", "kind": "function", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 814, "signature": "[[nodiscard]] inline std::vector serializeIsoReferenceScene( const IsoReferenceScene& scene) noexcept", "summary": "The scene -> versioned binary bytes (the format section): a pure, deterministic function of the scene (same scene, same bytes — the determinism test compares two serializations). One allocation (the output vector), setup path. @budget O(scene size); one allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::readIsoReferenceScene", "kind": "function", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 920, "signature": "[[nodiscard]] inline laige::Result readIsoReferenceScene(std::span bytes) noexcept", "summary": "The bytes -> scene (the format section + failure section): the versioned, domain-validated read of an UNTRUSTED byte stream (SCALE-004) — any violation fails the whole read with `MalformedInput` (nothing is partially applied). @budget O(size); two allocations (the vectors, on success only).", "budget": null, "experimental": false}, + {"name": "laige::render::writeIsoReferenceScene", "kind": "function", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1091, "signature": "[[nodiscard]] inline laige::Status writeIsoReferenceScene( const IsoReferenceScene& scene, std::string_view path) noexcept", "summary": "The scene -> file (the failure section): serialize to `.tmp` in the same directory, flush, and rename into `` (atomic — a killed run never leaves a partial file at the target, the laige-run replay-log precedent). @budget O(scene size); two allocations (the bytes + the temp name).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld", "kind": "class", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1182, "signature": "template class IsoReferenceSceneWorld", "summary": "The engine-side world of one loaded reference scene (the header's ownership section): the five engine objects the scene builds — the terrain TileMap, the ParallaxLayers registry, the Particle System, the UI UiTree, and the StringTable (the button label's interned \"OK\") — plus a copy of the scene they were built from and the 4 precomputed sprite-sheet frame UVs (the header's model section — 4 rects, never per sprite).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::IsoReferenceSceneWorld", "kind": "constructor", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1191, "signature": "IsoReferenceSceneWorld() noexcept = default", "summary": "The stopped state (default / moved-from / failed load).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::IsoReferenceSceneWorld", "kind": "constructor", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1194, "signature": "IsoReferenceSceneWorld(const IsoReferenceSceneWorld&) = delete", "summary": "Move-only (the house pattern — the moved-from world is stopped).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1195, "signature": "IsoReferenceSceneWorld& operator=(const IsoReferenceSceneWorld&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::IsoReferenceSceneWorld", "kind": "constructor", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1196, "signature": "IsoReferenceSceneWorld(IsoReferenceSceneWorld&& o) noexcept : scene_(std::move(o.scene_)), tiles_(std::move(o.tiles_)), parallax_(std::move(o.parallax_)), particles_(std::move(o.particles_)), ui_(std::move(o.ui_)), strings_(std::move(o.strings_)), frameUv_(o.frameUv_), loaded_(o.loaded_)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1207, "signature": "IsoReferenceSceneWorld& operator=(IsoReferenceSceneWorld&& o) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::valid", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1224, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True when the world holds a loaded scene (loadIsoReferenceScene succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::scene", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1229, "signature": "[[nodiscard]] const IsoReferenceScene& scene() const noexcept", "summary": "The scene this world was built from (the owned copy — the world is self-contained; M2-PERF-01/M2-AC-01 read the scene's parameters through this).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::tiles", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1236, "signature": "[[nodiscard]] TileMap& tiles() noexcept", "summary": "The terrain tilemap. The world's live-state contract: a stopped world (default / moved-from / failed load) has no tilemap — the caller checks `valid()` first (the SpriteBatcher::at assert precedent — assertions protect programmer invariants, CPP-012).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::parallax", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1241, "signature": "[[nodiscard]] ParallaxLayers& parallax() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::particles", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1244, "signature": "[[nodiscard]] ParticleSystem& particles() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::ui", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1247, "signature": "[[nodiscard]] UiTree& ui() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::strings", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1248, "signature": "[[nodiscard]] StringTable& strings() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::contentFrameCount", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1256, "signature": "[[nodiscard]] std::uint32_t contentFrameCount() const noexcept", "summary": "The world pass's declared frame, MINIMUM (the content count): the scene's sprites + tiles + 1 quad per parallax layer. A scrolled auto layer SPLITs into up to 4 quads (the parallax.h wrap contract), so the batcher's frame budget must cover the WORST case, `worstCaseFrameCount()`, PLUS the live particles (the pool's live count, bounded by its cap).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoReferenceSceneWorld::worstCaseFrameCount", "kind": "method", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1259, "signature": "[[nodiscard]] std::uint32_t worstCaseFrameCount() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::loadIsoReferenceScene", "kind": "function", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1293, "signature": "template [[nodiscard]] inline laige::Result, laige::ErrorCode> loadIsoReferenceScene(const IsoReferenceScene& scene) noexcept", "summary": "The scene -> the engine-side world (the header's ownership/failure sections): the setup-path build of the scene's five engine objects (the O(scene size) setup — the tilemap's rebuild, the three setLayer, the eight addEmitter, the two addChild, one intern, the 4 frame-UV precomputations). First failure wins: a failed step fails the whole load with that step's error (InvalidArgument / BudgetExhausted), and the returned world is the stopped state (nothing owned). @budget O(scene size); a handful of setup allocations (the objects' own — the house pattern).", "budget": null, "experimental": false}, + {"name": "laige::render::declareIsoReferenceScene", "kind": "function", "header": "src/laige-render/include/laige/render/iso_reference_scene.h", "line": 1482, "signature": "template [[nodiscard]] inline laige::Status declareIsoReferenceScene( IsoReferenceSceneWorld& world, SpriteBatcher& batcher, Vec2 cameraPos) noexcept", "summary": "The world's per-frame declare (the header's model/performance sections): ONE call = the whole world pass into the batcher — the parallax layers (after their once-per-frame scroll advance), the terrain tiles, the 50k sprites (the scene's array order — the RENDER-003 deterministic insertion), and the live particles (one additive group). O(frame items), NO ALLOCATION (the batcher's storage is pre-sized; the per-sprite isoDepthKey is integer math; the UV rects are the world's 4 precomputed values).", "budget": null, "experimental": false}, {"name": "laige::render::Vec2", "kind": "alias", "header": "src/laige-render/include/laige/render/matrices.h", "line": 130, "signature": "using Vec2 = glm::vec2", "summary": "The GLM value types of the rendering-side math API (PRD §11). GLM is pinned in deps.lock (ADR 0008) and exposed through this header only — the include-graph lint (R3) forbids every other module from including it, so the sim modules' SimMath (PRD §10.3) stays the sim-side math.", "budget": null, "experimental": false}, {"name": "laige::render::Vec3", "kind": "alias", "header": "src/laige-render/include/laige/render/matrices.h", "line": 131, "signature": "using Vec3 = glm::vec3", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::Mat4", "kind": "alias", "header": "src/laige-render/include/laige/render/matrices.h", "line": 132, "signature": "using Mat4 = glm::mat4", "summary": null, "budget": null, "experimental": false}, @@ -1001,44 +1129,44 @@ {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 396, "signature": "StringTable& operator=(const StringTable&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 400, "signature": "StringTable(StringTable&& o) noexcept : maxStrings_(o.maxStrings_), maxCodePoints_(o.maxCodePoints_), count_(o.count_), stored_(o.stored_), headers_(std::move(o.headers_)), pool_(std::move(o.pool_))", "summary": "The move (the house pattern — the moved-from table is stopped: its storage moves out with the target, and the scalars are zeroed so the moved-from table reports the stopped state).", "budget": null, "experimental": false}, {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 412, "signature": "StringTable& operator=(StringTable&& o) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::~StringTable", "kind": "destructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 418, "signature": "~StringTable() noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::valid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 421, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the table is live (create succeeded).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::intern", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 436, "signature": "[[nodiscard]] laige::Result intern(std::span codes) noexcept", "summary": "Interns `codes` (u32 code points — the header's model section): a copy lands in the pool, the handle is stable for the table's lifetime. The same bytes already interned return the SAME handle (the dedup — the identity property). An empty span interns the empty string (a 0-width text).", "budget": "O(count·len + len) (the dedup scan + the copy) — the", "experimental": false}, - {"name": "laige::render::StringTable::isValid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 441, "signature": "[[nodiscard]] bool isValid(StringRef ref) const noexcept", "summary": "True iff `ref` names a live string (false in the stopped state or for an out-of-range id). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::text", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 448, "signature": "[[nodiscard]] std::span text( StringRef ref) const noexcept", "summary": "The string's code points (a non-owning view into the pool — valid until the table is destroyed). An invalid handle reads an empty span (the total read — never UB). O(1); no allocation.", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::count", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 457, "signature": "[[nodiscard]] std::uint32_t count() const noexcept", "summary": "The live string count (0 in the stopped state). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::storedCodePoints", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 459, "signature": "[[nodiscard]] std::uint32_t storedCodePoints() const noexcept", "summary": "The total code points stored (0 in the stopped state). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::stringCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 463, "signature": "[[nodiscard]] std::uint32_t stringCapacity() const noexcept", "summary": "The create() budgets (0 in the stopped state). O(1).", "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::codePointCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 466, "signature": "[[nodiscard]] std::uint32_t codePointCapacity() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Header::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 474, "signature": "std::uint32_t offset{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::StringTable::Header::length", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 475, "signature": "std::uint32_t length{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::FontRenderMode", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 568, "signature": "enum class FontRenderMode : std::uint8_t", "summary": "The glyph path of a text item (M2-TEXT-03 — the documented scene- config key that selects the font): Bitmap is the default (the M2-TEXT-01 glyph atlas — `TextItem.font`); Sdf is the signed distance-field atlas (the M2-TEXT-03 `SdfGlyphAtlas` — `TextItem.sdfFont`): the same metrics and layout, the glyph's screen edge thresholded from the field (the header's glyph-quad section).", "budget": null, "experimental": false}, - {"name": "laige::render::FontRenderMode::Bitmap", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 570, "signature": "Bitmap = 0", "summary": "The M2-TEXT-01 bitmap glyph atlas (`TextItem.font`).", "budget": null, "experimental": false}, - {"name": "laige::render::FontRenderMode::Sdf", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 573, "signature": "Sdf = 1", "summary": "The M2-TEXT-03 signed distance-field glyph atlas (`TextItem. sdfFont`).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 578, "signature": "enum class TextAlignment : std::uint8_t", "summary": "The per-line alignment of the text block (the header's model section: the anchor semantics of TextItem.x).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment::Left", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 580, "signature": "Left = 0", "summary": "The anchor x is the block's LEFT edge (every line starts there).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment::Center", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 582, "signature": "Center = 1", "summary": "The anchor x is the block's CENTER x (each line is centered).", "budget": null, "experimental": false}, - {"name": "laige::render::TextAlignment::Right", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 584, "signature": "Right = 2", "summary": "The anchor x is the block's RIGHT edge (each line ends there).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 593, "signature": "struct TextItem", "summary": "One declared text item (M2-TEXT-02): the value the game declares for one UI text widget (S-5: declaration, not draw). A plain value — no ownership (the `font` pointer and the `text` handle are NON-OWNING: the scene's GlyphAtlas and StringTable must outlive the frame, the header's ownership section). Screen pixels, y down (the header's model section).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::text", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 596, "signature": "StringRef text{}", "summary": "The text's string-table handle (PRD §10.4 — no per-frame std::string in the path).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::font", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 599, "signature": "const GlyphAtlas* font{}", "summary": "The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must outlive the frame). Read when `mode == FontRenderMode::Bitmap`.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::mode", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 602, "signature": "FontRenderMode mode{FontRenderMode::Bitmap}", "summary": "The glyph path (M2-TEXT-03 — the documented scene-config key): Bitmap (default) draws from `font`, Sdf from `sdfFont`.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::sdfFont", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 605, "signature": "const SdfGlyphAtlas* sdfFont{}", "summary": "The SDF glyph atlas (M2-TEXT-03) — non-owning (scene-owned; must outlive the frame). Read when `mode == FontRenderMode::Sdf`.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::x", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 608, "signature": "float x{}", "summary": "The text block's anchor x, screen px (the header's model section: left/center/right edge by `alignment`).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::y", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 610, "signature": "float y{}", "summary": "The FIRST line's baseline, screen px (y down).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 614, "signature": "std::uint32_t scale{1}", "summary": "The integer scale factor (1x = the font's rasterized size — the M2-TEXT-01 convention). Domain [1, kTextMaxScale]; validated by measureText / declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::color", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 618, "signature": "SpriteTint color{}", "summary": "The text color (the M2-SPRITE-02 multiplicative tint over the white-on-alpha upload — the drawn color = color × glyph alpha). Default: white.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::alignment", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 620, "signature": "TextAlignment alignment{TextAlignment::Left}", "summary": "The per-line alignment (default: left).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::maxWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 624, "signature": "std::int32_t maxWidth{0}", "summary": "The max line width, screen px (0 = no wrap — one line). Domain 0 or [1, kTextMaxLineWidthPx]; validated by declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 629, "signature": "std::uint32_t depthKey{}", "summary": "The hand-tuned UI z (the G-R11 escape hatch — declareText sets depthOverride = true; counted + warned at build, the sprite_batcher.h G-R11 contract). The UI pass is screen-space: this is the documented order value, not a world-key workaround.", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 632, "signature": "std::uint32_t atlasId{}", "summary": "The glyph atlas's texture id (the scene's bindAtlas id — M2-SPRITE-02).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 635, "signature": "std::uint32_t materialId{}", "summary": "The material id (0 = the default material — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, - {"name": "laige::render::TextItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 638, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state (the group key's third field). Alpha is the text convention (the standard alpha blend over the UI panels).", "budget": null, "experimental": false}, - {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 898, "signature": "[[nodiscard]] inline laige::Result measureText( const GlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The rendered ink-block width of `text` in screen px at `scale` (the header's wrap model, no-wrap: the sum of the glyph advances + one space advance per inter-word gap, leading/trailing spaces excluded — exactly what the declared quads span). O(glyphs); no allocation, no logging, no GL.", "budget": null, "experimental": false}, - {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 907, "signature": "[[nodiscard]] inline laige::Result measureText( const SdfGlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The SDF overload (M2-TEXT-03): the SAME width for the same text — both paths read the identical M2-TEXT-01 metrics (the documented width-match contract, tolerance 0).", "budget": null, "experimental": false}, - {"name": "laige::render::declareText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 936, "signature": "[[nodiscard]] inline laige::Status declareText( SpriteBatcher& batcher, const StringTable& table, const TextItem& item) noexcept", "summary": "Declares `item`'s glyphs into `batcher` under its (atlasId, materialId, blend) group (the header's model + wrap + domain sections): O(glyphs), no allocation, no logging on the happy path, no GL. The batcher must be live (created, nonzero capacity) and its frame window OPEN (the frame-protocol contract, the `declareParticles` precedent): a stopped batcher fails the first add with BudgetExhausted, a closed window with InvalidArgument. A line wider than kTextMaxLineWidthPx fails InvalidArgument BEFORE the first add (nothing declared — the pre-check). An empty text declares nothing and succeeds. The first failed add fails the call with the batcher's error (the items already declared remain in the frame — the batcher owns the frame state).", "budget": null, "experimental": false}, - {"name": "laige::render::expandGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 1016, "signature": "[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( const GlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit alpha glyph atlas (M2-TEXT-01) into the white-on-alpha RGBA8 bytes the M2-SPRITE-02 `bindAtlas` uploads (see the shared implementation above — one call per font per scene load, before the one-time texture upload, RENDER-004).", "budget": null, "experimental": false}, - {"name": "laige::render::expandSdfGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 1026, "signature": "[[nodiscard]] inline laige::Status expandSdfGlyphAtlasRgba8( const SdfGlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit SDF glyph atlas (M2-TEXT-03) into the white-on- alpha RGBA8 bytes the M2-SPRITE-02 `bindSdfAtlas` uploads (the same expansion — the SDF field's value is the texture's alpha channel; the shader's SDF branch thresholds it). One call per font per scene load, before the one-time texture upload (RENDER-004).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::~StringTable", "kind": "destructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 431, "signature": "~StringTable() noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::valid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 434, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the table is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::intern", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 449, "signature": "[[nodiscard]] laige::Result intern(std::span codes) noexcept", "summary": "Interns `codes` (u32 code points — the header's model section): a copy lands in the pool, the handle is stable for the table's lifetime. The same bytes already interned return the SAME handle (the dedup — the identity property). An empty span interns the empty string (a 0-width text).", "budget": "O(count·len + len) (the dedup scan + the copy) — the", "experimental": false}, + {"name": "laige::render::StringTable::isValid", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 454, "signature": "[[nodiscard]] bool isValid(StringRef ref) const noexcept", "summary": "True iff `ref` names a live string (false in the stopped state or for an out-of-range id). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::text", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 461, "signature": "[[nodiscard]] std::span text( StringRef ref) const noexcept", "summary": "The string's code points (a non-owning view into the pool — valid until the table is destroyed). An invalid handle reads an empty span (the total read — never UB). O(1); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::count", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 470, "signature": "[[nodiscard]] std::uint32_t count() const noexcept", "summary": "The live string count (0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::storedCodePoints", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 472, "signature": "[[nodiscard]] std::uint32_t storedCodePoints() const noexcept", "summary": "The total code points stored (0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::stringCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 476, "signature": "[[nodiscard]] std::uint32_t stringCapacity() const noexcept", "summary": "The create() budgets (0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::codePointCapacity", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 479, "signature": "[[nodiscard]] std::uint32_t codePointCapacity() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Header::offset", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 487, "signature": "std::uint32_t offset{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Header::length", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 488, "signature": "std::uint32_t length{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::FontRenderMode", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 581, "signature": "enum class FontRenderMode : std::uint8_t", "summary": "The glyph path of a text item (M2-TEXT-03 — the documented scene- config key that selects the font): Bitmap is the default (the M2-TEXT-01 glyph atlas — `TextItem.font`); Sdf is the signed distance-field atlas (the M2-TEXT-03 `SdfGlyphAtlas` — `TextItem.sdfFont`): the same metrics and layout, the glyph's screen edge thresholded from the field (the header's glyph-quad section).", "budget": null, "experimental": false}, + {"name": "laige::render::FontRenderMode::Bitmap", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 583, "signature": "Bitmap = 0", "summary": "The M2-TEXT-01 bitmap glyph atlas (`TextItem.font`).", "budget": null, "experimental": false}, + {"name": "laige::render::FontRenderMode::Sdf", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 586, "signature": "Sdf = 1", "summary": "The M2-TEXT-03 signed distance-field glyph atlas (`TextItem. sdfFont`).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 591, "signature": "enum class TextAlignment : std::uint8_t", "summary": "The per-line alignment of the text block (the header's model section: the anchor semantics of TextItem.x).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment::Left", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 593, "signature": "Left = 0", "summary": "The anchor x is the block's LEFT edge (every line starts there).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment::Center", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 595, "signature": "Center = 1", "summary": "The anchor x is the block's CENTER x (each line is centered).", "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment::Right", "kind": "enumerator", "header": "src/laige-render/include/laige/render/text_items.h", "line": 597, "signature": "Right = 2", "summary": "The anchor x is the block's RIGHT edge (each line ends there).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 606, "signature": "struct TextItem", "summary": "One declared text item (M2-TEXT-02): the value the game declares for one UI text widget (S-5: declaration, not draw). A plain value — no ownership (the `font` pointer and the `text` handle are NON-OWNING: the scene's GlyphAtlas and StringTable must outlive the frame, the header's ownership section). Screen pixels, y down (the header's model section).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::text", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 609, "signature": "StringRef text{}", "summary": "The text's string-table handle (PRD §10.4 — no per-frame std::string in the path).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::font", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 612, "signature": "const GlyphAtlas* font{}", "summary": "The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must outlive the frame). Read when `mode == FontRenderMode::Bitmap`.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::mode", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 615, "signature": "FontRenderMode mode{FontRenderMode::Bitmap}", "summary": "The glyph path (M2-TEXT-03 — the documented scene-config key): Bitmap (default) draws from `font`, Sdf from `sdfFont`.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::sdfFont", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 618, "signature": "const SdfGlyphAtlas* sdfFont{}", "summary": "The SDF glyph atlas (M2-TEXT-03) — non-owning (scene-owned; must outlive the frame). Read when `mode == FontRenderMode::Sdf`.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::x", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 621, "signature": "float x{}", "summary": "The text block's anchor x, screen px (the header's model section: left/center/right edge by `alignment`).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::y", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 623, "signature": "float y{}", "summary": "The FIRST line's baseline, screen px (y down).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::scale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 627, "signature": "std::uint32_t scale{1}", "summary": "The integer scale factor (1x = the font's rasterized size — the M2-TEXT-01 convention). Domain [1, kTextMaxScale]; validated by measureText / declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::color", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 631, "signature": "SpriteTint color{}", "summary": "The text color (the M2-SPRITE-02 multiplicative tint over the white-on-alpha upload — the drawn color = color × glyph alpha). Default: white.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::alignment", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 633, "signature": "TextAlignment alignment{TextAlignment::Left}", "summary": "The per-line alignment (default: left).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::maxWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 637, "signature": "std::int32_t maxWidth{0}", "summary": "The max line width, screen px (0 = no wrap — one line). Domain 0 or [1, kTextMaxLineWidthPx]; validated by declareText (InvalidArgument, no log).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::depthKey", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 642, "signature": "std::uint32_t depthKey{}", "summary": "The hand-tuned UI z (the G-R11 escape hatch — declareText sets depthOverride = true; counted + warned at build, the sprite_batcher.h G-R11 contract). The UI pass is screen-space: this is the documented order value, not a world-key workaround.", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 645, "signature": "std::uint32_t atlasId{}", "summary": "The glyph atlas's texture id (the scene's bindAtlas id — M2-SPRITE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 648, "signature": "std::uint32_t materialId{}", "summary": "The material id (0 = the default material — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 651, "signature": "BlendMode blend{BlendMode::Alpha}", "summary": "The blend state (the group key's third field). Alpha is the text convention (the standard alpha blend over the UI panels).", "budget": null, "experimental": false}, + {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 911, "signature": "[[nodiscard]] inline laige::Result measureText( const GlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The rendered ink-block width of `text` in screen px at `scale` (the header's wrap model, no-wrap: the sum of the glyph advances + one space advance per inter-word gap, leading/trailing spaces excluded — exactly what the declared quads span). O(glyphs); no allocation, no logging, no GL.", "budget": null, "experimental": false}, + {"name": "laige::render::measureText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 920, "signature": "[[nodiscard]] inline laige::Result measureText( const SdfGlyphAtlas& font, const StringTable& table, StringRef text, std::uint32_t scale) noexcept", "summary": "The SDF overload (M2-TEXT-03): the SAME width for the same text — both paths read the identical M2-TEXT-01 metrics (the documented width-match contract, tolerance 0).", "budget": null, "experimental": false}, + {"name": "laige::render::declareText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 949, "signature": "[[nodiscard]] inline laige::Status declareText( SpriteBatcher& batcher, const StringTable& table, const TextItem& item) noexcept", "summary": "Declares `item`'s glyphs into `batcher` under its (atlasId, materialId, blend) group (the header's model + wrap + domain sections): O(glyphs), no allocation, no logging on the happy path, no GL. The batcher must be live (created, nonzero capacity) and its frame window OPEN (the frame-protocol contract, the `declareParticles` precedent): a stopped batcher fails the first add with BudgetExhausted, a closed window with InvalidArgument. A line wider than kTextMaxLineWidthPx fails InvalidArgument BEFORE the first add (nothing declared — the pre-check). An empty text declares nothing and succeeds. The first failed add fails the call with the batcher's error (the items already declared remain in the frame — the batcher owns the frame state).", "budget": null, "experimental": false}, + {"name": "laige::render::expandGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 1029, "signature": "[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( const GlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit alpha glyph atlas (M2-TEXT-01) into the white-on-alpha RGBA8 bytes the M2-SPRITE-02 `bindAtlas` uploads (see the shared implementation above — one call per font per scene load, before the one-time texture upload, RENDER-004).", "budget": null, "experimental": false}, + {"name": "laige::render::expandSdfGlyphAtlasRgba8", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 1039, "signature": "[[nodiscard]] inline laige::Status expandSdfGlyphAtlasRgba8( const SdfGlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit SDF glyph atlas (M2-TEXT-03) into the white-on- alpha RGBA8 bytes the M2-SPRITE-02 `bindSdfAtlas` uploads (the same expansion — the SDF field's value is the texture's alpha channel; the shader's SDF branch thresholds it). One call per font per scene load, before the one-time texture upload (RENDER-004).", "budget": null, "experimental": false}, {"name": "laige::render::kTileAnimMaxFrames", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 311, "signature": "inline constexpr std::uint32_t kTileAnimMaxFrames = 64", "summary": "The frame budget of ONE tile animation (M2-TILE-02): the practical tile-sheet frame count (a 16 x 4 sheet); a longer cycle is the game's split into two animations (the M3 animation editor owns the authoring). The per-animation setup storage is frameCount x sizeof(SpriteUvRect) (1 KB at the cap).", "budget": null, "experimental": false}, {"name": "laige::render::kTileMapDefaultAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 318, "signature": "inline constexpr std::uint32_t kTileMapDefaultAnimations = 8", "summary": "The tilemap's animation slot count: the default — the reference scene's handful of animated tiles + headroom for custom (the M2-SCENE-01 precedent, the parallax default-layers pattern) — and the cap: the practical tileset's full animation catalog (256 slots ~= 14 KB of setup storage).", "budget": null, "experimental": false}, {"name": "laige::render::kTileMapMaxAnimations", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 319, "signature": "inline constexpr std::uint32_t kTileMapMaxAnimations = 256", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index cd2c27e..654baea 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -424,7 +424,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. ## Reference scene & performance acceptance -- [ ] **M2-SCENE-01 · Reference isometric scene generator** +- [x] **M2-SCENE-01 · Reference isometric scene generator** - **Refs:** PRD §4 (reference scene for all visual/perf tests), §8.1 - **Depends:** M2-TILE-01, M2-SPRITE-01, M2-PART-01, M2-UI-01 - **Scope:** @@ -433,6 +433,36 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Also emits a small 1k-sprite variant for fast unit tests. - **Verify:** tool runs in < 5 s; two invocations with the same seed produce byte-identical output (tested); scene loads and batches without error. - **Size:** ~250 lines + tests + - **Shipped:** M2-SCENE-01 (`laige/render/iso_reference_scene.h`, + header-only, no GL calls): `buildIsoReferenceScene(seed, + variant)` is the deterministic worst-case scene builder (the + 50k-sprite §8.1 Full variant + the 1k-sprite Small variant — + 256x256 / 32x32 stepped-terrain grids of uniform-height + plateaus, sprites standing on their PRNG-chosen tiles at exact + 2^-16 positions, 3 preset-factor parallax layers, 8 continuous + emitters, a 2-widget UI on the 1280x720 reference screen); + same seed, same bytes, every build and P0 platform (ARCH-010 — + Prng substreams + the exact dyadic position scale, no + FMA-susceptible composition). The scene serializes to a + versioned little-endian binary (the reader treats the bytes as + untrusted input — full domain validation + exact length, + MalformedInput on any violation, ARCH-007 / SCALE-004; the + fuzz gate `fuzz_scene_iso_parse` covers the surface, + TEST-005). `loadIsoReferenceScene` builds the + engine-side world (TileMap + ParallaxLayers + ParticleSystem + + UiTree + StringTable + the 4 precomputed frame UVs); + `declareIsoReferenceScene` is the one-call whole-world pass + (O(frame items), zero allocation — the batcher's frame budget + is the §8.1 workload at reference size: the scene's 14-group + draw-call property under the 30-draw-call budget is asserted by + `IsoReferenceSceneLoadAndBatch`). `laige-scene-iso` + (tools/scene-iso) is the headless generator CLI (atomic temp + + rename write, round-trip self-check, machine-greppable summary); + the `scene_iso_cli` ctest entry runs the roadmap's Verify + clause in executable form (two same-seed invocations per + variant, byte-identical). No standalone budgets.json entry (the + generation budget is the tool's; the per-frame declare cost is + part of the composite 50k render-CPU budget, M2-PERF-01). - [ ] **M2-PERF-01 · 50k-sprite budget test (PRD §8.1)** - **Refs:** PRD §8.1 (≤ 30 draw calls; ≤ 2 ms CPU; zero per-frame alloc in batcher), §15 M2 exit diff --git a/src/laige-render/include/laige/render/iso_reference_scene.h b/src/laige-render/include/laige/render/iso_reference_scene.h new file mode 100644 index 0000000..f71eb25 --- /dev/null +++ b/src/laige-render/include/laige/render/iso_reference_scene.h @@ -0,0 +1,1526 @@ +// laige-render isometric reference scene (M2-SCENE-01): the +// worst-case isometric reference scene of PRD §4 — the deterministic, +// seed-driven scene every visual and performance acceptance test +// renders (the 50k-sprite §8.1 budget scene, the M2-PERF-01 workload, +// the M2-AC-01/M2-GOLD-01/M2-SAMPLE-01 fixture). +// +// PRD §4: "It is the reference scene for all visual and performance +// acceptance tests (worst-case overlap for depth sorting)." +// Roadmap M2-SCENE-01: a headless, seed-deterministic builder that +// emits the worst-case isometric scene — 50k overlapping sprites +// (random positions on tiled ground with stepped heights), 3 parallax +// layers, particle emitters, a UI panel, and the tile-height terrain — +// as data the engine can load (in-memory builder + simple binary +// serialization used by the tests; the full scene FORMAT is M3, the +// versioned binary format below is the M2 stand-in). ARCH-010: the +// scene is a test fixture — same seed, same bytes, every build and +// platform (presentation state only, ARCH-009 — never sim state, +// never in the replay hash). +// +// IsoRefSceneVariant Small (1k sprites) | Full (50k sprites) +// IsoRefSceneVec2 A pair of floats (the scene file's +// point values — the backend conversion +// is the RENDER-006 load boundary) +// IsoRefSceneSprite One scene sprite (position, standing +// step height, atlas, blend, frame) +// IsoRefSceneParallaxLayer One scene parallax layer (image source) +// IsoRefSceneEmitter One scene particle emitter definition +// IsoRefSceneUiWidget One scene UI widget spec +// IsoReferenceScene The whole scene (terrain grid + sprites +// + layers + emitters + UI), plain values +// buildIsoReferenceScene The deterministic in-memory builder +// serializeIsoReferenceScene The scene -> versioned binary bytes +// readIsoReferenceScene The bytes -> scene (validated) +// writeIsoReferenceScene The scene -> file (atomic temp + rename) +// IsoReferenceSceneWorld The engine-side world built from a scene +// (TileMap + ParallaxLayers + +// ParticleSystem + UiTree + StringTable) +// loadIsoReferenceScene The scene -> world (setup path) +// declareIsoReferenceScene The world's per-frame declare (one call +// = the whole world pass into a batcher) +// +// --------------------------------------------------------------------------- +// The scene contents (the worst-case composition) +// --------------------------------------------------------------------------- +// +// The scene is built from a single seed (any u64 — 0 is a valid seed) +// through three deterministic substreams (the M0-CORE-06 Prng +// substream pattern; the ids are stable subsystem identifiers): +// +// terrain (substream 1) the stepped-terrain plateau heights +// sprites (substream 2) the sprite positions, atlases, blends, frames +// emitters (substream 3) the emitter life/size variation +// +// The FULL variant (the §8.1 budget scene) is: +// +// - a 256 x 256 tile grid (65 536 tiles) at the world origin, +// partitioned into 16 x 16-tile PLATEAUS (16 x 16 = 256 plateaus): +// each plateau has a uniform integer height — `step` x 2 world +// units for a PRNG-drawn step in [0, 24) — so the terrain is a +// terraced, stepped surface (the "stepped heights" of the step +// scope) with 24 distinct height values in [0, 46] (well inside +// the M2-ISO-01 |z| <= 2047 domain); +// - 50 000 sprites at uniform-random ground positions: each sprite's +// position is `tile + fraction`, the fraction a uniform dyadic in +// [0, 1) at 2^-16 resolution (exact in both SimMath backends — +// ADR 0002), so the sprite stands on the PRNG's chosen tile and +// its depth key is `isoDepthKey(pos, the tile's height, layer 0)` +// (the scene file stores the standing tile's height with the +// sprite — one source of truth, the tile's own); +// - 3 IMAGE parallax layers (background factor 0, mid factor 0.5, +// foreground factor 1.0 — the M2-PAR-01 preset factors), 128 x 128 +// world units each, centered on the scene's ground; +// - 8 particle emitters (continuous rate 2/tick, 40..120 tick lives, +// additive orange sparks rising off the ground); +// - a 2-widget UI (one opaque panel, one button with the "OK" +// label) on the 1280 x 720 reference screen (the M2-GOLD-01 +// resolution). +// +// The SMALL variant is the same composition scaled down (a 32 x 32 +// grid of 8 x 8-tile plateaus, 1 000 sprites) — the fast unit-test +// fixture (the step scope's "small 1k-sprite variant"). +// +// Worst-case properties (why this is the reference scene): +// +// - 50 000 sprites over 65 536 tiles means several sprites per +// depth row: the (x + y) - z value quantizes to at most ~8 300 +// distinct 32-bit keys, so the M2-SORT-01 radix sort sees a dense +// multi-sprite tie band on every row (the worst-case overlap for +// depth sorting, PRD §4); +// - the 50k sprites + 65k tile quads + parallax quads + live +// particles fill a ~116k-item frame — the batcher's frame budget +// (M2-SPRITE-01) is exercised at its reference size; +// - the scene's atlas-id plan (below) keeps the §8.1 "50k visible +// sprites, 3 parallax layers, UI" workload to a fixed 14 +// (atlas, material, blend) groups = 14 draw calls in the world +// pass — comfortably under the 30-draw-call budget, with the group +// count pinned (the M2-PERF-01 gate asserts it). +// +// --------------------------------------------------------------------------- +// The atlas-id plan (the cross-group render order, parallax.h) +// --------------------------------------------------------------------------- +// +// The batcher's cross-group draw order is ascending +// (atlasId, materialId, blend) (M2-SPRITE-01), so the scene assigns +// atlas ids that make group order = painter's order (the parallax.h +// scene-setup convention — background layers first, ground next, +// world objects, particles, foreground last): +// +// 0 background parallax layer (depth layer -2) +// 1 mid parallax layer (depth layer -1) +// 2 ground tilemap (layer 0, all tiles) +// 3..6 the 4 sprite atlases (layer 0, Alpha/Additive) +// 7 the particle atlas (layer 0, Additive) +// 8 foreground parallax layer (depth layer +1) +// +// = 2 + 1 + 4 x 2 blends + 1 + 1 = 14 groups (kIsoRefSceneSprite +// PassGroups), 14 instanced draw calls at the submit stage — the +// scene's documented draw-call budget property (PRD §8.1: <= 30). +// +// The UI pass is a separate batcher (M2-UI-02) with its own ids: +// widget art atlas 100, font atlas 101 (unused when the pass declares +// without a default font — the label-less world pass). +// +// --------------------------------------------------------------------------- +// The deterministic builder (ARCH-010 scope: presentation fixture) +// --------------------------------------------------------------------------- +// +// `buildIsoReferenceScene(seed, variant)` is a PURE function of +// (seed, variant): unsigned integer PRNG arithmetic (the M0-CORE-06 +// xorshift128+ contract) plus the exact 2^-16 position scale (one +// multiply by a power of two — no FMA, no libm), so the built scene +// and its serialization are bit-identical across builds, compilers, +// and P0 platforms for the same seed (the same scope as the Prng — +// ARCH-010; the scene is presentation state, never sim state, so the +// sim determinism scope is untouched). The builder is O(scene size) +// time and O(scene size) memory (the two vectors), setup path only. +// +// --------------------------------------------------------------------------- +// The binary format (version 1; SCALE-005 / ARCH-007) +// --------------------------------------------------------------------------- +// +// The scene file is a flat little-endian binary, written field by +// field (the layout is explicit — struct padding never leaks in): +// +// offset size field +// ------ ---- ---------------------------------------------------- +// 0 4 version (u32; MUST be 1 for this reader) +// 4 1 variant (u8: 0 = small, 1 = full) +// 5 3 reserved (zero) +// 8 8 seed (u64) +// 16 4 originTileX (i32) +// 20 4 originTileY (i32) +// 24 4 widthTiles (i32) +// 28 4 heightTiles (i32) +// 32 4 plateauTiles (i32) +// 36 4 stepHeight (i32) +// 40 4 maxSteps (i32) +// 44 4 tileCount (u32) +// 48 4 spriteCount (u32) +// 52 4 emitterCount (u32) +// 56 4 uiWidgetCount (u32) +// 60 4 parallaxCount (u32) +// 64 4 reserved (zero) +// 68 4*nt tileHeights (i32 per tile, row-major, tileX fastest) +// ... 14*ns sprites: x (f32), y (f32), stepHeight (i16), atlasId +// (u8), blend (u8), frame (u8), reserved (u8) +// ... 67*np parallax layers: factor (f32), center (2 f32), +// offset (2 f32), size (2 f32), uv (4 f32), atlasId (u32), +// materialId (u32), blend (u8), scrollMode (u8), enabled +// (u8), pad (u8), depthLayer (i32), scrollSpeed (2 f32) +// ... 56*ne emitters: originX/Y (2 f32), depth (i32), velMin/ +// velMax (4 f32), lifeMin/lifeMax (2 u32), sizeMin/sizeMax +// (2 f32), tint (4 u8), fadeTicks (u32), continuousRate +// (u32) +// ... 23*nui UI widgets: kind (u8), opaque (u8), x/y/w/h (4 i32), +// atlasId (u32), label (u8) +// +// All floats are IEEE-754 binary32, little-endian. Multi-byte +// integers are little-endian. There is no hash/checksum: the reader's +// domain validation (below) is the integrity contract, and the +// determinism check (two same-seed serializations compare byte- +// identical) is the generator's. Versioning (ARCH-007): a reader +// rejects any version it does not support with `MalformedInput` — +// it never guesses; future format versions bump `version` and get a +// new reader. +// +// The reader treats the bytes as UNTRUSTED input (SCALE-004): every +// field is bounds- and domain-checked before use, the exact total +// length is required (trailing bytes are rejected), and any +// violation fails the whole read with `MalformedInput` (nothing is +// partially applied — the result is a value, nothing mutates). +// +// --------------------------------------------------------------------------- +// Ownership, threading, lifetimes (CORE-009) +// --------------------------------------------------------------------------- +// +// The scene values (`IsoReferenceScene` and its members) are plain +// data — copyable, no ownership. `IsoReferenceSceneWorld` is +// move-only (the SpriteBatcher precedent) and owns its five engine +// objects plus a copy of the scene that built it; one owner (the +// scene setup / the test), the render phase reads it through +// `declareIsoReferenceScene` (a non-mutating pass except the +// parallax scroll offsets — the M2-PAR-01 presentation state, +// advanced once per frame by the declare, the documented frame +// pacing). Everything is single-threaded per the engine's frame +// protocol (CONC-001): setup on the scene thread, declare on the +// render thread's batch stage — the same one-owner/one-phase story as +// every other M2 render object. +// +// --------------------------------------------------------------------------- +// Failure (CORE-008, API-008 — first failure wins, no log on domain +// failures; the house convention: domain validation is silent, the +// caller maps the code) +// --------------------------------------------------------------------------- +// +// buildIsoReferenceScene total function (no failure — a plain +// value; the domains are the builder's +// own constants, in domain by +// construction) +// serializeIsoReferenceScene total function (one allocation — the +// output vector, setup path) +// readIsoReferenceScene version mismatch, short/long input, or +// any field outside its domain -> +// MalformedInput (the whole read fails) +// writeIsoReferenceScene open/write/rename failure -> IoError +// loadIsoReferenceScene a failed engine create/rebuild/add +// -> that object's error (InvalidArgument +// / BudgetExhausted); the world is left +// stopped on failure (nothing owned) +// declareIsoReferenceScene a failed declare sub-step -> that step's +// error (the items already declared by +// this call remain in the frame — the +// batcher owns the frame state, the +// TileMap::declareTo precedent); a +// stopped world -> InvalidArgument +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003 — the budgets this step is PART of) +// --------------------------------------------------------------------------- +// +// build O(scene size) time + O(scene size) memory (two +// vectors), setup only; the full scene serializes to +// ~0.94 MB. The step's < 5 s generation budget is a +// tool property (docs/api/iso_reference_scene.md), +// not a §8.1 budgets.json entry. +// load O(scene size), setup only (the tilemap rebuild, +// the eight addEmitters, the two addChildren, one +// intern — all documented setup paths). +// declare O(frame items) per frame, NO ALLOCATION (the +// batcher/pool storage is pre-sized; the per-sprite +// isoDepthKey is a handful of integer ops; the UV +// rects are precomputed at load — 4 of them, not +// per sprite). The declare is one component of the +// §8.1 "50k sprites ... <= 2 ms CPU" composite +// budget — M2-PERF-01 measures the whole frame +// (tile quads included), not this step alone. +// +// Misuse warnings: +// +// - The scene file is a TEST FIXTURE, not an asset: it carries no +// image data (the atlases are the scene's ids, the game/tests +// bind their textures), and it is NOT versioned scene SAVE data +// (ARCH-007 applies to the file's own format only — the M3 scene +// format supersedes it). +// - The world's parallax auto layers ADVANCE on every +// `declareIsoReferenceScene` call: declare once per frame, in +// the render phase, never per sprite or in a tight retry loop +// (the scroll offsets are presentation state — a double advance +// is a wrong scroll, not an error the engine can detect). +// - Size the world pass's batcher to the scene's frame: the full +// scene declares ~116k items per frame (see `worldFrameCount`); +// an undersized batcher drops the oldest items (the batcher's +// overflow policy — silent degradation, LOG-004), the frame is +// still built. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/prng.h" +#include "laige/render/matrices.h" +#include "laige/render/parallax.h" +#include "laige/render/particle_render.h" +#include "laige/render/sprite_batcher.h" +#include "laige/render/sprite_frames.h" +#include "laige/render/tilemap.h" +#include "laige/render/ui_widgets.h" +#include "laige/result.h" +#include "laige/sim/particles.h" +#include "laige/sim_math.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// The scene file's format version (ARCH-007 — readers reject other +// versions; v1 is this header's layout). +inline constexpr std::uint32_t kIsoRefSceneFormatVersion = 1u; + +// The deterministic builder's PRNG substream ids (stable subsystem +// identifiers, the M0-CORE-06 pattern): terrain, sprites, emitters. +inline constexpr std::uint32_t kIsoRefSceneSubstreamTerrain = 1u; +inline constexpr std::uint32_t kIsoRefSceneSubstreamSprites = 2u; +inline constexpr std::uint32_t kIsoRefSceneSubstreamEmitters = 3u; + +// The two scene sizes (the step scope: the 50k worst-case scene and +// the small 1k-sprite variant for fast unit tests). +inline constexpr std::uint32_t kIsoRefSceneFullSprites = 50000u; +inline constexpr std::uint32_t kIsoRefSceneSmallSprites = 1000u; + +// The terrain grids (tiles per axis): the full 256 x 256 and the +// small 32 x 32 (both powers of two — the plateau partition divides +// evenly). +inline constexpr std::int32_t kIsoRefSceneFullGridTiles = 256; +inline constexpr std::int32_t kIsoRefSceneSmallGridTiles = 32; + +// The plateau partition (tiles per plateau per axis): 16 x 16 plateaus +// over the full grid, 8 x 8 over the small one. +inline constexpr std::int32_t kIsoRefSceneFullPlateauTiles = 16; +inline constexpr std::int32_t kIsoRefSceneSmallPlateauTiles = 8; + +// The stepped-terrain height model: each plateau sits at a PRNG-drawn +// step in [0, kIsoRefSceneTerrainSteps), at `kIsoRefSceneStepHeight` +// world units per step -> heights in [0, (steps-1) x step] = [0, 46] +// (inside the M2-ISO-01 |z| <= 2047 domain with wide margin). +inline constexpr std::int32_t kIsoRefSceneTerrainSteps = 24; +inline constexpr std::int32_t kIsoRefSceneStepHeight = 2; + +// The scene's fixed content counts (the full and small variants share +// them — the size differs only in the grid and the sprite count). +inline constexpr std::uint32_t kIsoRefSceneParallaxLayerCount = 3u; +inline constexpr std::uint32_t kIsoRefSceneEmitterCount = 8u; +inline constexpr std::uint32_t kIsoRefSceneUiWidgetCount = 2u; + +// The UI reference screen (the M2-GOLD-01 reference resolution — the +// world's UiTree screen rect). +inline constexpr std::int32_t kIsoRefSceneUiScreenWidth = 1280; +inline constexpr std::int32_t kIsoRefSceneUiScreenHeight = 720; +// The world's UI budgets (scene-scale: the scene declares 2 widgets; +// the tree pool and event list are sized small on purpose). +inline constexpr std::uint32_t kIsoRefSceneUiMaxWidgets = 16u; +inline constexpr std::uint32_t kIsoRefSceneUiMaxEvents = 64u; + +// The sprite sheet: a 2 x 2 grid of 16 x 16 texel frames (4 frames), +// packed (no spacing, no border) -> a tight 32 x 32 sheet. The frame +// UVs are precomputed at load (4 rects), never per sprite. +inline constexpr std::uint32_t kIsoRefSceneSpriteSheetFrames = 4u; +inline constexpr std::uint32_t kIsoRefSceneSpriteSheetFrameTexels = 16u; + +// The atlas-id plan (the header's plan section — the cross-group +// painter's order, parallax.h): +// 0 bg layer | 1 mid layer | 2 ground | 3..6 sprites | 7 particles +// | 8 fg layer; UI pass: 100 widget art, 101 font. +inline constexpr std::uint32_t kIsoRefSceneBgAtlas = 0u; +inline constexpr std::uint32_t kIsoRefSceneMidAtlas = 1u; +inline constexpr std::uint32_t kIsoRefSceneGroundAtlas = 2u; +inline constexpr std::uint32_t kIsoRefSceneSpriteAtlasBase = 3u; +inline constexpr std::uint32_t kIsoRefSceneSpriteAtlasCount = 4u; +inline constexpr std::uint32_t kIsoRefSceneParticleAtlas = 7u; +inline constexpr std::uint32_t kIsoRefSceneFgAtlas = 8u; +inline constexpr std::uint32_t kIsoRefSceneUiWidgetAtlas = 100u; +inline constexpr std::uint32_t kIsoRefSceneUiFontAtlas = 101u; + +// The scene's world-pass group count: 2 (bg/mid) + 1 (ground) + +// 4 x 2 (sprite atlases x Alpha/Additive) + 1 (particles) + 1 (fg) = +// 14 (atlas, material, blend) groups = 14 instanced draw calls — +// the pinned draw-call property under the §8.1 30-draw-call budget. +inline constexpr std::uint32_t kIsoRefSceneSpritePassGroups = 14u; + +// The UI label table (the format's fixed strings — no strings in the +// file, only these ids): 0 = no label, 1 = "OK" (the button). +inline constexpr std::uint8_t kIsoRefSceneLabelNone = 0u; +inline constexpr std::uint8_t kIsoRefSceneLabelOk = 1u; + +// The scene variant (the file's u8 field; the builder's parameter). +enum class IsoRefSceneVariant : std::uint8_t { + Small = 0, // the 1k-sprite fast unit-test fixture + Full = 1, // the 50k-sprite §8.1 worst-case scene +}; + +// The scene file's point values (a pair of floats — the file's +// float32 fields; the backend conversion at load is the RENDER-006 +// boundary). +struct IsoRefSceneVec2 { + float x{}; + float y{}; +}; + +// One scene sprite (the 14-byte wire record): a ground-plane position +// (exact k x 2^-16 world units, in [0, grid) x [0, grid)), the standing +// tile's step height (the depth-key `z`), the sprite's atlas (the plan +// above), its blend state (0 = Alpha, 1 = Additive — the BatchMode +// wire values), and its sheet frame (0..3). +struct IsoRefSceneSprite { + float x{}; + float y{}; + std::int16_t stepHeight{}; + std::uint8_t atlasId{}; + std::uint8_t blend{}; + std::uint8_t frame{}; + std::uint8_t reserved{}; +}; + +// One scene parallax layer (the 67-byte wire record; image source only +// — the reference scene's layers are all images): the M2-PAR-01 +// ParallaxLayerDef's fields in file order. +struct IsoRefSceneParallaxLayer { + float factor{}; + IsoRefSceneVec2 center{}; + IsoRefSceneVec2 offset{}; + IsoRefSceneVec2 size{}; + SpriteUvRect uv{}; + std::uint32_t atlasId{}; + std::uint32_t materialId{}; + std::uint8_t blend{}; + std::uint8_t scrollMode{}; // 0 = Manual, 1 = Auto + std::uint8_t enabled{}; + std::uint8_t reserved{}; + std::int32_t depthLayer{}; + IsoRefSceneVec2 scrollSpeed{}; +}; + +// One scene particle emitter definition (the 56-byte wire record): the +// M2-PART-01 ParticleEmitterDef's fields in file order (depth is the +// M2-ISO-01 step-height domain, i32 in the file). +struct IsoRefSceneEmitter { + float originX{}; + float originY{}; + std::int32_t depth{}; + float velMinX{}; + float velMinY{}; + float velMaxX{}; + float velMaxY{}; + std::uint32_t lifeMin{}; + std::uint32_t lifeMax{}; + float sizeMin{}; + float sizeMax{}; + std::uint8_t tint[4]{}; + std::uint32_t fadeTicks{}; + std::uint32_t continuousRate{}; +}; + +// One scene UI widget spec (the 23-byte wire record): the M2-UI-01 +// UiWidgetSpec's fields in file order (the label is the fixed label +// table id, not a string). +struct IsoRefSceneUiWidget { + std::uint8_t kind{}; // the UiWidgetKind value + std::uint8_t opaque{}; + std::int32_t x{}; + std::int32_t y{}; + std::int32_t width{}; + std::int32_t height{}; + std::uint32_t atlasId{}; + std::uint8_t label{}; +}; + +// The whole reference scene (plain values; the header's model section +// is the content contract). Copyable (the vectors carry the storage). +struct IsoReferenceScene { + // The file's format version (the reader's gate — always + // kIsoRefSceneFormatVersion for builder output). + std::uint32_t version{kIsoRefSceneFormatVersion}; + // The variant the scene was built as (Small/Full — the shape + // constants below are consistent with it by construction). + IsoRefSceneVariant variant{IsoRefSceneVariant::Full}; + // The seed that built the scene (the determinism record, ARCH-010). + std::uint64_t seed{}; + // The terrain grid (row-major heights, tileX fastest — the tilemap's + // flat order). + std::int32_t originTileX{}; + std::int32_t originTileY{}; + std::int32_t widthTiles{}; + std::int32_t heightTiles{}; + std::int32_t plateauTiles{}; + std::int32_t stepHeight{}; + std::int32_t maxSteps{}; + std::uint32_t tileCount{}; + // The content counts (the array sizes below hold the max; the + // counts say how many are live). + std::uint32_t spriteCount{}; + std::uint32_t emitterCount{}; + std::uint32_t uiWidgetCount{}; + std::uint32_t parallaxCount{}; + // The storage (fixed max sizes; the counts bound the live range). + std::vector tileHeights; + std::vector sprites; + IsoRefSceneParallaxLayer parallax[kIsoRefSceneParallaxLayerCount]{}; + IsoRefSceneEmitter emitters[kIsoRefSceneEmitterCount]{}; + IsoRefSceneUiWidget uiWidgets[kIsoRefSceneUiWidgetCount]{}; +}; + +// The deterministic in-memory builder (the header's model section): +// the scene is a pure function of (seed, variant) — same seed, same +// scene, every build and platform (ARCH-010, the Prng scope). +// O(scene size) time + O(scene size) memory (two vectors), setup path. +// @budget O(scene size); two allocations. +[[nodiscard]] inline IsoReferenceScene +buildIsoReferenceScene(std::uint64_t seed, IsoRefSceneVariant variant) noexcept { + // The variant's shape (the header's model section — the only + // variant-dependent values). + const std::int32_t gridTiles = + (variant == IsoRefSceneVariant::Full) ? kIsoRefSceneFullGridTiles + : kIsoRefSceneSmallGridTiles; + const std::int32_t plateauTiles = + (variant == IsoRefSceneVariant::Full) ? kIsoRefSceneFullPlateauTiles + : kIsoRefSceneSmallPlateauTiles; + const std::uint32_t spriteCount = + (variant == IsoRefSceneVariant::Full) ? kIsoRefSceneFullSprites + : kIsoRefSceneSmallSprites; + + IsoReferenceScene scene; + scene.variant = variant; + scene.seed = seed; + scene.originTileX = 0; + scene.originTileY = 0; + scene.widthTiles = gridTiles; + scene.heightTiles = gridTiles; + scene.plateauTiles = plateauTiles; + scene.stepHeight = kIsoRefSceneStepHeight; + scene.maxSteps = kIsoRefSceneTerrainSteps; + scene.tileCount = static_cast(gridTiles) * + static_cast(gridTiles); + scene.spriteCount = spriteCount; + scene.emitterCount = kIsoRefSceneEmitterCount; + scene.uiWidgetCount = kIsoRefSceneUiWidgetCount; + scene.parallaxCount = kIsoRefSceneParallaxLayerCount; + scene.tileHeights.resize(scene.tileCount); + scene.sprites.resize(spriteCount); + + // ----------------------------------------------------------------- + // The terrain (substream 1): one PRNG step per plateau, row-major; + // the plateau's height spreads over its tiles. + // ----------------------------------------------------------------- + laige::Prng terrain = + laige::Prng::deriveSubstream(seed, kIsoRefSceneSubstreamTerrain); + const std::uint32_t plateausPerAxis = + static_cast(gridTiles / plateauTiles); + for (std::uint32_t py = 0; py < plateausPerAxis; ++py) { + for (std::uint32_t px = 0; px < plateausPerAxis; ++px) { + const std::int32_t h = static_cast( + terrain.next_range( + 0u, static_cast(kIsoRefSceneTerrainSteps))) * + kIsoRefSceneStepHeight; + for (std::uint32_t ty = py * plateauTiles; + ty < (py + 1u) * plateauTiles; ++ty) { + for (std::uint32_t tx = px * plateauTiles; + tx < (px + 1u) * plateauTiles; ++tx) { + scene.tileHeights[ty * static_cast(gridTiles) + tx] = + h; + } + } + } + } + + // ----------------------------------------------------------------- + // The sprites (substream 2): uniform random ground positions at + // 2^-16 resolution (exact in both backends), one draw each for + // x, y, atlas, blend, frame (the 5-draw per-sprite sequence — the + // determinism contract). + // ----------------------------------------------------------------- + laige::Prng sprites = + laige::Prng::deriveSubstream(seed, kIsoRefSceneSubstreamSprites); + // The position's raw domain: [0, gridTiles x 2^16) in 2^-16 units — + // the raw value's low 16 bits are the within-tile fraction, the + // rest the tile index (floor(x) == raw >> 16 exactly). + const std::uint32_t posDomain = static_cast(gridTiles) << + 16; + constexpr float kPosUnit = 1.0f / 65536.0f; // 2^-16, exact + for (std::uint32_t i = 0; i < spriteCount; ++i) { + const std::uint32_t rawX = sprites.next_range(0u, posDomain); + const std::uint32_t rawY = sprites.next_range(0u, posDomain); + const std::uint32_t tileX = rawX >> 16; + const std::uint32_t tileY = rawY >> 16; + IsoRefSceneSprite& s = scene.sprites[i]; + // The position is exact: raw < 2^24 is float-exact, and the + // multiply by the power of two kPosUnit is exact (no FMA). + s.x = static_cast(rawX) * kPosUnit; + s.y = static_cast(rawY) * kPosUnit; + // The sprite stands on its tile (the terrain's source of truth). + s.stepHeight = static_cast( + scene.tileHeights[tileY * static_cast(gridTiles) + + tileX]); + s.atlasId = static_cast(kIsoRefSceneSpriteAtlasBase + + sprites.next_range( + 0u, kIsoRefSceneSpriteAtlasCount)); + s.blend = static_cast(sprites.next_range(0u, 2u)); + s.frame = static_cast(sprites.next_range( + 0u, kIsoRefSceneSpriteSheetFrames)); + } + + // ----------------------------------------------------------------- + // The parallax layers (fixed — variant-independent): the M2-PAR-01 + // preset factors, 128 x 128 world-unit layers centered on the + // ground (offset -64), the header's plan section's atlas ids. + // ----------------------------------------------------------------- + constexpr float kHalfLayer = 64.0f; // the layer half-extent, world units + constexpr float kScrollUnit = 1.0f / 2048.0f; // 2^-11 UV units/frame + scene.parallax[0] = IsoRefSceneParallaxLayer{ + 0.0f, // factor: fixed in world space (max parallax) + IsoRefSceneVec2{0.0f, 0.0f}, // center + IsoRefSceneVec2{-kHalfLayer, -kHalfLayer}, // offset + IsoRefSceneVec2{128.0f, 128.0f}, // size + SpriteUvRect{0.0f, 0.0f, 1.0f, 1.0f}, // uv + kIsoRefSceneBgAtlas, // atlasId + 0u, // materialId + 0u, // blend (Alpha) + 1u, // scrollMode (Auto) + 1u, // enabled + 0u, // reserved + -2, // depthLayer (background) + IsoRefSceneVec2{kScrollUnit, -kScrollUnit}}; // scrollSpeed + scene.parallax[1] = IsoRefSceneParallaxLayer{ + 0.5f, // factor: half-speed (the midground) + IsoRefSceneVec2{0.0f, 0.0f}, // center + IsoRefSceneVec2{-kHalfLayer, -kHalfLayer}, // offset + IsoRefSceneVec2{128.0f, 128.0f}, // size + SpriteUvRect{0.0f, 0.0f, 1.0f, 1.0f}, // uv + kIsoRefSceneMidAtlas, // atlasId + 0u, // materialId + 0u, // blend (Alpha) + 0u, // scrollMode (Manual) + 1u, // enabled + 0u, // reserved + -1, // depthLayer (midground) + IsoRefSceneVec2{0.0f, 0.0f}}; // scrollSpeed + scene.parallax[2] = IsoRefSceneParallaxLayer{ + 1.0f, // factor: 1:1 with the camera (screen-fixed) + IsoRefSceneVec2{0.0f, 0.0f}, // center + IsoRefSceneVec2{-kHalfLayer, -kHalfLayer}, // offset + IsoRefSceneVec2{128.0f, 128.0f}, // size + SpriteUvRect{0.0f, 0.0f, 1.0f, 1.0f}, // uv + kIsoRefSceneFgAtlas, // atlasId + 0u, // materialId + 0u, // blend (Alpha) + 1u, // scrollMode (Auto) + 1u, // enabled + 0u, // reserved + 1, // depthLayer (foreground) + IsoRefSceneVec2{-kScrollUnit, kScrollUnit}}; // scrollSpeed + + // ----------------------------------------------------------------- + // The particle emitters (substream 3 for the life/size variation): + // 8 additive orange-spark emitters at random ground tile centers, + // rate 2/tick, 40..120 tick lives, 0.1..0.49 world-unit sizes. + // ----------------------------------------------------------------- + laige::Prng emitters = + laige::Prng::deriveSubstream(seed, kIsoRefSceneSubstreamEmitters); + for (std::uint32_t i = 0; i < scene.emitterCount; ++i) { + IsoRefSceneEmitter& e = scene.emitters[i]; + // The origin: a uniform tile center (integer + 0.5, exact float). + e.originX = static_cast(emitters.next_range( + 0u, static_cast(gridTiles))) + 0.5f; + e.originY = static_cast(emitters.next_range( + 0u, static_cast(gridTiles))) + 0.5f; + e.depth = 0; + // The velocity box: a slow upward drift (world units per tick). + e.velMinX = -0.03f; + e.velMinY = -0.08f; + e.velMaxX = 0.03f; + e.velMaxY = 0.08f; + e.lifeMin = 40u; + e.lifeMax = 60u + emitters.next_range(0u, 61u); // [60, 120] + e.sizeMin = 0.1f; + // The size max, dyadic-exact (no float composition — the + // cross-platform determinism contract, ADR 0002: k x 2^-8 + 2^-2 + // rounds nowhere, FMA-independent): [0.25, 0.25 + 19 x 2^-8). + e.sizeMax = 0.25f + + static_cast(emitters.next_range(0u, 20u)) * + (1.0f / 256.0f); + e.tint[0] = 255u; + e.tint[1] = 170u; + e.tint[2] = 80u; + e.tint[3] = 255u; + e.fadeTicks = 20u; + e.continuousRate = 2u; + } + + // ----------------------------------------------------------------- + // The UI (fixed — variant-independent): one opaque panel + one + // "OK" button, top-left of the 1280 x 720 reference screen. + // ----------------------------------------------------------------- + scene.uiWidgets[0] = IsoRefSceneUiWidget{ + 0u, // kind (Panel) + 1u, // opaque + 16, 16, 480, 240, // x, y, width, height + kIsoRefSceneUiWidgetAtlas, // atlasId + kIsoRefSceneLabelNone}; // label + scene.uiWidgets[1] = IsoRefSceneUiWidget{ + 1u, // kind (Button) + 0u, // opaque + 32, 32, 160, 48, // x, y, width, height + kIsoRefSceneUiWidgetAtlas, // atlasId + kIsoRefSceneLabelOk}; // label + return scene; +} + +namespace detail { + +// The byte-wise little-endian writer helpers (the format section — +// explicit fields, struct padding never leaks into the wire). +inline void pushU8(std::vector& out, std::uint8_t v) { + out.push_back(v); +} +inline void pushU32le(std::vector& out, std::uint32_t v) { + for (int i = 0; i < 4; ++i) { + out.push_back(static_cast((v >> (8 * i)) & 0xFFu)); + } +} +inline void pushU64le(std::vector& out, std::uint64_t v) { + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((v >> (8 * i)) & 0xFFu)); + } +} +inline void pushI32le(std::vector& out, std::int32_t v) { + pushU32le(out, static_cast(v)); +} +inline void pushI16le(std::vector& out, std::int16_t v) { + const std::uint32_t u = static_cast(v); + out.push_back(static_cast(u & 0xFFu)); + out.push_back(static_cast((u >> 8) & 0xFFu)); +} +inline void pushF32(std::vector& out, float v) { + std::uint32_t bits; + static_assert(sizeof(bits) == sizeof(v)); + std::memcpy(&bits, &v, sizeof(bits)); + pushU32le(out, bits); +} + +// The byte-wise little-endian reader cursor (the format section — +// every read bounds-checks; a short input fails the whole read). +struct ByteCursor { + const std::uint8_t* p{}; + std::size_t n{}; + bool ok{true}; + + bool takeU8(std::uint8_t& v) { + if (!ok || n == 0) { + ok = false; + return false; + } + v = *p++; + --n; + return true; + } + bool takeU32(std::uint32_t& v) { + std::uint8_t b[4]{}; + for (int i = 0; i < 4; ++i) { + if (!takeU8(b[i])) return false; + } + // Explicit little-endian assembly (the format section — the + // byte order is the wire contract, not the host's). + v = static_cast(b[0]) | + (static_cast(b[1]) << 8) | + (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); + return true; + } + bool takeU64(std::uint64_t& v) { + std::uint8_t b[8]{}; + for (int i = 0; i < 8; ++i) { + if (!takeU8(b[i])) return false; + } + v = 0; + for (int i = 0; i < 8; ++i) { + v |= static_cast(b[i]) << (8 * i); + } + return true; + } + bool takeI32(std::int32_t& v) { + std::uint32_t u; + if (!takeU32(u)) return false; + v = static_cast(u); + return true; + } + bool takeI16(std::int16_t& v) { + std::uint32_t u = 0; + std::uint8_t b0{}, b1{}; + if (!takeU8(b0) || !takeU8(b1)) return false; + u = static_cast(b0) | (static_cast(b1) << 8); + v = static_cast(static_cast(u)); + return true; + } + bool takeF32(float& v) { + std::uint32_t bits; + if (!takeU32(bits)) return false; + static_assert(sizeof(bits) == sizeof(v)); + std::memcpy(&v, &bits, sizeof(v)); + return true; + } +}; + +} // namespace detail + +// The scene -> versioned binary bytes (the format section): a pure, +// deterministic function of the scene (same scene, same bytes — the +// determinism test compares two serializations). One allocation (the +// output vector), setup path. @budget O(scene size); one allocation. +[[nodiscard]] inline std::vector serializeIsoReferenceScene( + const IsoReferenceScene& scene) noexcept { + std::vector out; + // The size prefix (the format section's layout). + out.reserve(1024u + static_cast(scene.tileCount) * 4u + + static_cast(scene.spriteCount) * 14u + + static_cast(scene.parallaxCount) * 67u + + static_cast(scene.emitterCount) * 56u + + static_cast(scene.uiWidgetCount) * 23u); + using detail::pushF32; + using detail::pushI16le; + using detail::pushI32le; + using detail::pushU32le; + using detail::pushU64le; + using detail::pushU8; + pushU32le(out, scene.version); + pushU8(out, static_cast(scene.variant)); + pushU8(out, 0u); + pushU8(out, 0u); + pushU8(out, 0u); + pushU64le(out, scene.seed); + pushI32le(out, scene.originTileX); + pushI32le(out, scene.originTileY); + pushI32le(out, scene.widthTiles); + pushI32le(out, scene.heightTiles); + pushI32le(out, scene.plateauTiles); + pushI32le(out, scene.stepHeight); + pushI32le(out, scene.maxSteps); + pushU32le(out, scene.tileCount); + pushU32le(out, scene.spriteCount); + pushU32le(out, scene.emitterCount); + pushU32le(out, scene.uiWidgetCount); + pushU32le(out, scene.parallaxCount); + pushU32le(out, 0u); // reserved + for (std::uint32_t i = 0; i < scene.tileCount; ++i) { + pushI32le(out, scene.tileHeights[i]); + } + for (std::uint32_t i = 0; i < scene.spriteCount; ++i) { + const IsoRefSceneSprite& s = scene.sprites[i]; + pushF32(out, s.x); + pushF32(out, s.y); + pushI16le(out, s.stepHeight); + pushU8(out, s.atlasId); + pushU8(out, s.blend); + pushU8(out, s.frame); + pushU8(out, s.reserved); + } + for (std::uint32_t i = 0; i < scene.parallaxCount; ++i) { + const IsoRefSceneParallaxLayer& l = scene.parallax[i]; + pushF32(out, l.factor); + pushF32(out, l.center.x); + pushF32(out, l.center.y); + pushF32(out, l.offset.x); + pushF32(out, l.offset.y); + pushF32(out, l.size.x); + pushF32(out, l.size.y); + pushF32(out, l.uv.u0); + pushF32(out, l.uv.v0); + pushF32(out, l.uv.u1); + pushF32(out, l.uv.v1); + pushU32le(out, l.atlasId); + pushU32le(out, l.materialId); + pushU8(out, l.blend); + pushU8(out, l.scrollMode); + pushU8(out, l.enabled); + pushU8(out, l.reserved); + pushI32le(out, l.depthLayer); + pushF32(out, l.scrollSpeed.x); + pushF32(out, l.scrollSpeed.y); + } + for (std::uint32_t i = 0; i < scene.emitterCount; ++i) { + const IsoRefSceneEmitter& e = scene.emitters[i]; + pushF32(out, e.originX); + pushF32(out, e.originY); + pushI32le(out, e.depth); + pushF32(out, e.velMinX); + pushF32(out, e.velMinY); + pushF32(out, e.velMaxX); + pushF32(out, e.velMaxY); + pushU32le(out, e.lifeMin); + pushU32le(out, e.lifeMax); + pushF32(out, e.sizeMin); + pushF32(out, e.sizeMax); + for (int c = 0; c < 4; ++c) pushU8(out, e.tint[c]); + pushU32le(out, e.fadeTicks); + pushU32le(out, e.continuousRate); + } + for (std::uint32_t i = 0; i < scene.uiWidgetCount; ++i) { + const IsoRefSceneUiWidget& w = scene.uiWidgets[i]; + pushU8(out, w.kind); + pushU8(out, w.opaque); + pushI32le(out, w.x); + pushI32le(out, w.y); + pushI32le(out, w.width); + pushI32le(out, w.height); + pushU32le(out, w.atlasId); + pushU8(out, w.label); + } + return out; +} + +// The bytes -> scene (the format section + failure section): the +// versioned, domain-validated read of an UNTRUSTED byte stream +// (SCALE-004) — any violation fails the whole read with +// `MalformedInput` (nothing is partially applied). @budget O(size); +// two allocations (the vectors, on success only). +[[nodiscard]] inline laige::Result +readIsoReferenceScene(std::span bytes) noexcept { + using E = laige::ErrorCode; + auto bad = [] { + return laige::Result::failure(E::MalformedInput); + }; + detail::ByteCursor c{bytes.data(), bytes.size(), true}; + IsoReferenceScene scene; + + // The header (the format section's layout). + std::uint32_t version{}; + std::uint8_t variant{}; + std::uint8_t reserved[3]{}; + std::uint32_t reservedTail{}; + std::int32_t stepHeight{}, maxSteps{}; + std::uint32_t tileCount{}, spriteCount{}, emitterCount{}, uiCount{}, + parallaxCount{}; + if (!c.takeU32(version) || version != kIsoRefSceneFormatVersion || + !c.takeU8(variant) || (variant != 0u && variant != 1u) || + !c.takeU8(reserved[0]) || !c.takeU8(reserved[1]) || + !c.takeU8(reserved[2]) || !c.takeU64(scene.seed) || + !c.takeI32(scene.originTileX) || !c.takeI32(scene.originTileY) || + !c.takeI32(scene.widthTiles) || !c.takeI32(scene.heightTiles) || + !c.takeI32(scene.plateauTiles) || !c.takeI32(stepHeight) || + !c.takeI32(maxSteps) || !c.takeU32(tileCount) || + !c.takeU32(spriteCount) || !c.takeU32(emitterCount) || + !c.takeU32(uiCount) || !c.takeU32(parallaxCount) || + !c.takeU32(reservedTail)) { + return bad(); + } + scene.variant = static_cast(variant); + scene.stepHeight = stepHeight; + scene.maxSteps = maxSteps; + scene.tileCount = tileCount; + scene.spriteCount = spriteCount; + scene.emitterCount = emitterCount; + scene.uiWidgetCount = uiCount; + scene.parallaxCount = parallaxCount; + + // The grid domain (the header's model section's constants): the + // counts are consistent with the grid, the plateau divides both + // axes, and the height model stays in the M2-ISO-01 domain. + const std::uint64_t tiles = + static_cast(scene.widthTiles) * + static_cast(scene.heightTiles); + constexpr std::uint64_t kMaxTiles = + static_cast(kIsoRefSceneFullGridTiles) * + static_cast(kIsoRefSceneFullGridTiles); + if (scene.widthTiles < 1 || scene.heightTiles < 1 || + scene.plateauTiles < 1 || + scene.widthTiles % scene.plateauTiles != 0 || + scene.heightTiles % scene.plateauTiles != 0 || tiles != tileCount || + tileCount == 0 || tileCount > kMaxTiles || + stepHeight < 0 || stepHeight > 1024 || maxSteps < 1 || + maxSteps > 1024 || + static_cast(stepHeight) * + static_cast(maxSteps) > + kIsoDepthMaxStepHeight || + spriteCount > kIsoRefSceneFullSprites || + emitterCount > kIsoRefSceneEmitterCount || + uiCount > kIsoRefSceneUiWidgetCount || + parallaxCount > kIsoRefSceneParallaxLayerCount) { + return bad(); + } + + // The terrain heights (row-major, the M2-ISO-01 |z| domain). + scene.tileHeights.resize(tileCount); + for (std::uint64_t i = 0; i < tileCount; ++i) { + std::int32_t h{}; + if (!c.takeI32(h) || h < -kIsoDepthMaxStepHeight || + h > kIsoDepthMaxStepHeight) { + return bad(); + } + scene.tileHeights[i] = h; + } + + // The sprites (the 14-byte record, the header's model section). + scene.sprites.resize(spriteCount); + for (std::uint64_t i = 0; i < spriteCount; ++i) { + IsoRefSceneSprite& s = scene.sprites[i]; + if (!c.takeF32(s.x) || !c.takeF32(s.y) || !c.takeI16(s.stepHeight) || + !c.takeU8(s.atlasId) || !c.takeU8(s.blend) || !c.takeU8(s.frame) || + !c.takeU8(s.reserved) || !std::isfinite(s.x) || !std::isfinite(s.y) || + s.x < 0.0f || s.x >= static_cast(scene.widthTiles) || + s.y < 0.0f || s.y >= static_cast(scene.heightTiles) || + s.stepHeight < -kIsoDepthMaxStepHeight || + s.stepHeight > kIsoDepthMaxStepHeight || s.blend > 1u || + s.frame >= kIsoRefSceneSpriteSheetFrames) { + return bad(); + } + } + + // The parallax layers (the 67-byte record, the M2-PAR-01 domains). + for (std::uint64_t i = 0; i < parallaxCount; ++i) { + IsoRefSceneParallaxLayer& l = scene.parallax[i]; + if (!c.takeF32(l.factor) || !c.takeF32(l.center.x) || + !c.takeF32(l.center.y) || !c.takeF32(l.offset.x) || + !c.takeF32(l.offset.y) || !c.takeF32(l.size.x) || + !c.takeF32(l.size.y) || !c.takeF32(l.uv.u0) || !c.takeF32(l.uv.v0) || + !c.takeF32(l.uv.u1) || !c.takeF32(l.uv.v1) || + !c.takeU32(l.atlasId) || !c.takeU32(l.materialId) || + !c.takeU8(l.blend) || !c.takeU8(l.scrollMode) || + !c.takeU8(l.enabled) || !c.takeU8(l.reserved) || + !c.takeI32(l.depthLayer) || !c.takeF32(l.scrollSpeed.x) || + !c.takeF32(l.scrollSpeed.y) || !std::isfinite(l.factor) || + l.factor < 0.0f || l.factor > 1.0f || + !std::isfinite(l.center.x) || !std::isfinite(l.center.y) || + !std::isfinite(l.offset.x) || !std::isfinite(l.offset.y) || + !std::isfinite(l.size.x) || !std::isfinite(l.size.y) || + l.size.x <= 0.0f || l.size.y <= 0.0f || !std::isfinite(l.uv.u0) || + !std::isfinite(l.uv.v0) || !std::isfinite(l.uv.u1) || + !std::isfinite(l.uv.v1) || l.uv.u0 < 0.0f || l.uv.u1 > 1.0f || + l.uv.v0 < 0.0f || l.uv.v1 > 1.0f || !(l.uv.u0 < l.uv.u1) || + !(l.uv.v0 < l.uv.v1) || !std::isfinite(l.scrollSpeed.x) || + !std::isfinite(l.scrollSpeed.y) || l.blend > 1u || + l.scrollMode > 1u || l.enabled > 1u || + l.depthLayer < -static_cast(kIsoDepthLayerBias) || + l.depthLayer > kIsoDepthLayerMax) { + return bad(); + } + } + + // The emitters (the 56-byte record, the M2-PART-01 domains). + for (std::uint64_t i = 0; i < emitterCount; ++i) { + IsoRefSceneEmitter& e = scene.emitters[i]; + if (!c.takeF32(e.originX) || !c.takeF32(e.originY) || + !c.takeI32(e.depth) || !c.takeF32(e.velMinX) || + !c.takeF32(e.velMinY) || !c.takeF32(e.velMaxX) || + !c.takeF32(e.velMaxY) || !c.takeU32(e.lifeMin) || + !c.takeU32(e.lifeMax) || !c.takeF32(e.sizeMin) || + !c.takeF32(e.sizeMax) || !c.takeU8(e.tint[0]) || + !c.takeU8(e.tint[1]) || !c.takeU8(e.tint[2]) || !c.takeU8(e.tint[3]) || + !c.takeU32(e.fadeTicks) || !c.takeU32(e.continuousRate) || + !std::isfinite(e.originX) || !std::isfinite(e.originY) || + !std::isfinite(e.velMinX) || !std::isfinite(e.velMinY) || + !std::isfinite(e.velMaxX) || !std::isfinite(e.velMaxY) || + !std::isfinite(e.sizeMin) || !std::isfinite(e.sizeMax) || + e.depth < -kIsoDepthMaxStepHeight || e.depth > kIsoDepthMaxStepHeight || + e.lifeMin < 1u || e.lifeMax > kParticleMaxLifeTicks || + e.lifeMin > e.lifeMax || e.sizeMin < 0.0f || e.sizeMax < e.sizeMin || + e.fadeTicks > e.lifeMax) { + return bad(); + } + } + + // The UI widgets (the 23-byte record, the M2-UI-01 domains). + for (std::uint64_t i = 0; i < uiCount; ++i) { + IsoRefSceneUiWidget& w = scene.uiWidgets[i]; + if (!c.takeU8(w.kind) || !c.takeU8(w.opaque) || !c.takeI32(w.x) || + !c.takeI32(w.y) || !c.takeI32(w.width) || !c.takeI32(w.height) || + !c.takeU32(w.atlasId) || !c.takeU8(w.label) || + w.kind >= kUiWidgetKindCount || w.opaque > 1u || w.label > 1u || + w.width < 0 || w.height < 0 || + static_cast(w.width) > kUiMaxDimPx || + static_cast(w.height) > kUiMaxDimPx || + std::abs(w.x) > kUiMaxCoordPx || std::abs(w.y) > kUiMaxCoordPx) { + return bad(); + } + } + + // The exact-length contract (the format section): trailing bytes + // are rejected — a longer file is not this scene. + if (c.n != 0) return bad(); + return laige::Result::success(std::move(scene)); +} + +// The scene -> file (the failure section): serialize to `.tmp` +// in the same directory, flush, and rename into `` (atomic — +// a killed run never leaves a partial file at the target, the +// laige-run replay-log precedent). @budget O(scene size); two +// allocations (the bytes + the temp name). +[[nodiscard]] inline laige::Status writeIsoReferenceScene( + const IsoReferenceScene& scene, std::string_view path) noexcept { + const std::string target(path); + const std::string temp = target + ".tmp"; + const std::vector bytes = serializeIsoReferenceScene(scene); + std::FILE* f{}; +#if defined(_MSC_VER) + if (fopen_s(&f, temp.c_str(), "wb") != 0) { + return laige::Status::failure(laige::ErrorCode::IoError); + } +#else + f = std::fopen(temp.c_str(), "wb"); +#endif + if (f == nullptr) { + return laige::Status::failure(laige::ErrorCode::IoError); + } + const bool wrote = + (bytes.empty() || + std::fwrite(bytes.data(), 1, bytes.size(), f) == bytes.size()); + const bool flushed = std::fclose(f) == 0; + f = nullptr; + if (!wrote || !flushed) { + std::remove(temp.c_str()); + return laige::Status::failure(laige::ErrorCode::IoError); + } + if (std::rename(temp.c_str(), target.c_str()) != 0) { + std::remove(temp.c_str()); + return laige::Status::failure(laige::ErrorCode::IoError); + } + return laige::Status{}; +} + +namespace detail { + +// The float point -> the backend's Vec2 (the RENDER-006 load boundary; +// the parallax test oracle's pattern). The scene file's floats are +// the canonical values; the backend conversion is the one documented +// rounding per component. +template +inline typename laige::sim::SimMath::Vec2 +isoRefBackendVec2(float x, float y) noexcept { + if constexpr (std::is_same_v) { + return typename laige::sim::SimMath::Vec2{x, y}; + } else { + return typename laige::sim::SimMath::Vec2{ + laige::fpx16_16::fromFloat(x), laige::fpx16_16::fromFloat(y)}; + } +} + +// The integer / float -> the backend's Scalar (the same boundary): +// exact for integers (|v| <= 2047 << 16 fits the Q16.16 raw domain), +// one documented rounding for the floats. +template +inline typename laige::sim::SimMath::Scalar +isoRefScalarFromInt(std::int32_t v) noexcept { + if constexpr (std::is_same_v) { + return static_cast(v); + } else { + return laige::fpx16_16::fromInt32(v); + } +} +template +inline typename laige::sim::SimMath::Scalar +isoRefScalarFromFloat(float v) noexcept { + if constexpr (std::is_same_v) { + return v; + } else { + return laige::fpx16_16::fromFloat(v); + } +} + +} // namespace detail + +// The engine-side world of one loaded reference scene (the header's +// ownership section): the five engine objects the scene builds — +// the terrain TileMap, the ParallaxLayers registry, the Particle +// System, the UI UiTree, and the StringTable (the button label's +// interned "OK") — plus a copy of the scene they were built from +// and the 4 precomputed sprite-sheet frame UVs (the header's model +// section — 4 rects, never per sprite). +// +// Move-only (the SpriteBatcher precedent). The default-constructed / +// moved-from / failed-load state is stopped: `valid()` false, the +// accessors return the stopped objects (whose own operations fail or +// no-op per their own contracts — the tilemap's accessor ASSERTs the +// live state, the SpriteBatcher::at precedent), and +// `declareIsoReferenceScene` fails `InvalidArgument`. One owner (the +// scene setup / the test); the render phase reads it through +// `declareIsoReferenceScene` (the only mutation: the parallax scroll +// offsets — the M2-PAR-01 presentation state, the documented +// once-per-frame pacing). +template +class IsoReferenceSceneWorld { + static_assert(std::is_same_v || + std::is_same_v, + "IsoReferenceSceneWorld is templated over the SimMath " + "backends"); + + public: + // The stopped state (default / moved-from / failed load). + IsoReferenceSceneWorld() noexcept = default; + + // Move-only (the house pattern — the moved-from world is stopped). + IsoReferenceSceneWorld(const IsoReferenceSceneWorld&) = delete; + IsoReferenceSceneWorld& operator=(const IsoReferenceSceneWorld&) = delete; + IsoReferenceSceneWorld(IsoReferenceSceneWorld&& o) noexcept + : scene_(std::move(o.scene_)), + tiles_(std::move(o.tiles_)), + parallax_(std::move(o.parallax_)), + particles_(std::move(o.particles_)), + ui_(std::move(o.ui_)), + strings_(std::move(o.strings_)), + frameUv_(o.frameUv_), + loaded_(o.loaded_) { + o.loaded_ = false; + } + IsoReferenceSceneWorld& operator=(IsoReferenceSceneWorld&& o) noexcept { + if (this != &o) { + scene_ = std::move(o.scene_); + tiles_ = std::move(o.tiles_); + parallax_ = std::move(o.parallax_); + particles_ = std::move(o.particles_); + ui_ = std::move(o.ui_); + strings_ = std::move(o.strings_); + frameUv_ = o.frameUv_; + loaded_ = o.loaded_; + o.loaded_ = false; + } + return *this; + } + + // True when the world holds a loaded scene (loadIsoReferenceScene + // succeeded). + [[nodiscard]] bool valid() const noexcept { return loaded_; } + + // The scene this world was built from (the owned copy — the + // world is self-contained; M2-PERF-01/M2-AC-01 read the scene's + // parameters through this). + [[nodiscard]] const IsoReferenceScene& scene() const noexcept { + return scene_; + } + // The terrain tilemap. The world's live-state contract: a stopped + // world (default / moved-from / failed load) has no tilemap — the + // caller checks `valid()` first (the SpriteBatcher::at assert + // precedent — assertions protect programmer invariants, CPP-012). + [[nodiscard]] TileMap& tiles() noexcept { + assert(tiles_ != nullptr && + "IsoReferenceSceneWorld::tiles: the world is stopped"); + return *tiles_; + } + [[nodiscard]] ParallaxLayers& parallax() noexcept { + return parallax_; + } + [[nodiscard]] ParticleSystem& particles() noexcept { + return particles_; + } + [[nodiscard]] UiTree& ui() noexcept { return ui_; } + [[nodiscard]] StringTable& strings() noexcept { return strings_; } + + // The world pass's declared frame, MINIMUM (the content count): + // the scene's sprites + tiles + 1 quad per parallax layer. A + // scrolled auto layer SPLITs into up to 4 quads (the parallax.h + // wrap contract), so the batcher's frame budget must cover the + // WORST case, `worstCaseFrameCount()`, PLUS the live particles + // (the pool's live count, bounded by its cap). + [[nodiscard]] std::uint32_t contentFrameCount() const noexcept { + return scene_.spriteCount + scene_.tileCount + scene_.parallaxCount; + } + [[nodiscard]] std::uint32_t worstCaseFrameCount() const noexcept { + return scene_.spriteCount + scene_.tileCount + + 4u * scene_.parallaxCount; + } + + private: + // The loader's and the per-frame declare's access (the header's + // ownership section). + template + friend laige::Result, laige::ErrorCode> + loadIsoReferenceScene(const IsoReferenceScene&) noexcept; + template + friend laige::Status declareIsoReferenceScene( + IsoReferenceSceneWorld&, SpriteBatcher&, Vec2) noexcept; + + IsoReferenceScene scene_{}; + std::unique_ptr> tiles_{}; // empty <=> stopped + ParallaxLayers parallax_{}; + ParticleSystem particles_{}; + UiTree ui_{}; + StringTable strings_{}; + std::array frameUv_{}; + bool loaded_{false}; +}; + +// The scene -> the engine-side world (the header's ownership/failure +// sections): the setup-path build of the scene's five engine objects +// (the O(scene size) setup — the tilemap's rebuild, the three +// setLayer, the eight addEmitter, the two addChild, one intern, the +// 4 frame-UV precomputations). First failure wins: a failed step +// fails the whole load with that step's error (InvalidArgument / +// BudgetExhausted), and the returned world is the stopped state +// (nothing owned). @budget O(scene size); a handful of setup +// allocations (the objects' own — the house pattern). +template +[[nodiscard]] inline laige::Result, + laige::ErrorCode> +loadIsoReferenceScene(const IsoReferenceScene& scene) noexcept { + using E = laige::ErrorCode; + using World = IsoReferenceSceneWorld; + IsoReferenceSceneWorld world; + + // (1) The terrain tilemap (one (atlas, material, blend) group for + // the whole ground — the header's plan section). + typename TileMap::Options topts{}; + topts.originTileX = scene.originTileX; + topts.originTileY = scene.originTileY; + topts.widthTiles = scene.widthTiles; + topts.heightTiles = scene.heightTiles; + topts.layer = kIsoDepthGroundLayer; + topts.maxAnimations = 1u; // the scene has no animated tiles + auto tmr = TileMap::create(topts); + if (tmr.isError()) { + return laige::Result::failure(tmr.error()); + } + // The house unique_ptr idiom (gl_context.h / parallax.h): empty <=> + // stopped (TileMap has no default constructor — the create-only + // pattern). + world.tiles_ = std::make_unique>(std::move(tmr).takeValue()); + // The tile data (the scene's terrain heights — one source of truth). + std::vector tileData(scene.tileCount); + for (std::uint32_t i = 0; i < scene.tileCount; ++i) { + tileData[i].textureId = kIsoRefSceneGroundAtlas; + tileData[i].height = scene.tileHeights[i]; + tileData[i].animationId = 0u; + } + if (!world.tiles_->rebuild(tileData).ok()) { + return laige::Result::failure(E::InvalidArgument); + } + + // (2) The parallax registry (the scene's 3 image layers, in + // declaration order 0..2 — bg, mid, fg). + typename ParallaxLayers::Options plopts{}; + auto plr = ParallaxLayers::create(plopts); + if (plr.isError()) { + return laige::Result::failure(plr.error()); + } + world.parallax_ = std::move(plr).takeValue(); + for (std::uint32_t i = 0; i < scene.parallaxCount; ++i) { + const IsoRefSceneParallaxLayer& l = scene.parallax[i]; + // The layer def's points are the RENDER Vec2 (floats) — the + // parallax's presentation-space model (RENDER-006); the backend + // conversion happens inside its key computation. + ParallaxLayerDef def{}; + def.id = i; + def.source = ParallaxSource::Image; + def.factor = l.factor; + def.center = Vec2{l.center.x, l.center.y}; + def.offset = Vec2{l.offset.x, l.offset.y}; + def.size = Vec2{l.size.x, l.size.y}; + def.uv = l.uv; + def.atlasId = l.atlasId; + def.materialId = l.materialId; + def.blend = static_cast(l.blend); + def.depthLayer = l.depthLayer; + def.scrollMode = static_cast(l.scrollMode); + def.scrollSpeed = Vec2{l.scrollSpeed.x, l.scrollSpeed.y}; + def.enabled = l.enabled != 0u; + const auto sr = world.parallax_.setLayer(def); + if (!sr.ok()) { + return laige::Result::failure(sr.error()); + } + } + + // (3) The particle system (the scene's emitters; the system's Prng + // is seeded from the scene's seed — the M0-CORE-06 substream + // pattern at the engine boundary). + typename ParticleSystem::Options popts{}; + popts.maxEmitters = scene.emitterCount; + popts.seed = scene.seed; + auto psr = ParticleSystem::create(popts); + if (psr.isError()) { + return laige::Result::failure(psr.error()); + } + world.particles_ = std::move(psr).takeValue(); + for (std::uint32_t i = 0; i < scene.emitterCount; ++i) { + const IsoRefSceneEmitter& e = scene.emitters[i]; + ParticleEmitterDef ed{}; + ed.origin = detail::isoRefBackendVec2(e.originX, e.originY); + ed.depth = detail::isoRefScalarFromInt(e.depth); + ed.velMin = detail::isoRefBackendVec2(e.velMinX, e.velMinY); + ed.velMax = detail::isoRefBackendVec2(e.velMaxX, e.velMaxY); + ed.lifeMin = e.lifeMin; + ed.lifeMax = e.lifeMax; + ed.sizeMin = detail::isoRefScalarFromFloat(e.sizeMin); + ed.sizeMax = detail::isoRefScalarFromFloat(e.sizeMax); + for (int c = 0; c < 4; ++c) ed.tint[c] = e.tint[c]; + ed.fadeTicks = e.fadeTicks; + ed.continuousRate = e.continuousRate; + const auto ar = world.particles_.addEmitter(ed); + if (!ar.ok()) { + return laige::Result::failure(ar.error()); + } + } + + // (4) The string table (the button's "OK" label) + the UI tree + // (the scene's 2 widgets on the reference screen). + auto str = StringTable::create(StringTable::Options{}); + if (str.isError()) { + return laige::Result::failure(str.error()); + } + world.strings_ = std::move(str).takeValue(); + const std::uint32_t okCodes[2] = {'O', 'K'}; + const auto okRef = world.strings_.intern( + std::span(okCodes, 2)); + if (okRef.isError()) { + return laige::Result::failure(okRef.error()); + } + UiTree::Options uo{}; + uo.maxWidgets = kIsoRefSceneUiMaxWidgets; + uo.maxEvents = kIsoRefSceneUiMaxEvents; + uo.screenWidth = kIsoRefSceneUiScreenWidth; + uo.screenHeight = kIsoRefSceneUiScreenHeight; + auto utr = UiTree::create(uo); + if (utr.isError()) { + return laige::Result::failure(utr.error()); + } + world.ui_ = std::move(utr).takeValue(); + for (std::uint32_t i = 0; i < scene.uiWidgetCount; ++i) { + const IsoRefSceneUiWidget& w = scene.uiWidgets[i]; + UiWidgetSpec spec{}; + spec.kind = static_cast(w.kind); + spec.x = w.x; + spec.y = w.y; + spec.width = w.width; + spec.height = w.height; + spec.opaque = w.opaque != 0u; + spec.atlasId = w.atlasId; + // The widget's art is its whole atlas (the scene assigns the + // texture — M3-ASSET-01). + spec.image = SpriteUvRect{0.0f, 0.0f, 1.0f, 1.0f}; + if (w.label == kIsoRefSceneLabelOk) { + spec.label = okRef.value(); + } + const auto acr = world.ui_.addChild(spec); + if (!acr.ok()) { + return laige::Result::failure(acr.error()); + } + } + + // (5) The 4 precomputed sheet frame UVs (the tight 32 x 32 sheet — + // the header's model section): precomputed ONCE at load, never + // per sprite (the 50k-frame zero-allocation budget, PERF-003). + SpriteFrameLayout layout{}; + layout.frameWidth = kIsoRefSceneSpriteSheetFrameTexels; + layout.frameHeight = kIsoRefSceneSpriteSheetFrameTexels; + layout.columns = 2u; + layout.rows = 2u; + layout.frameSpacing = 0u; + layout.sheetBorder = 0u; + constexpr std::uint32_t kSheetTexels = 2u * kIsoRefSceneSpriteSheetFrameTexels; + for (std::uint32_t f = 0; f < kIsoRefSceneSpriteSheetFrames; ++f) { + const auto uv = spriteFrameUv(f, layout, kSheetTexels, kSheetTexels); + if (uv.isError()) { + return laige::Result::failure(uv.error()); + } + world.frameUv_[f] = uv.value(); + } + + world.scene_ = scene; + world.loaded_ = true; + return laige::Result::success(std::move(world)); +} + +// The world's per-frame declare (the header's model/performance +// sections): ONE call = the whole world pass into the batcher — the +// parallax layers (after their once-per-frame scroll advance), the +// terrain tiles, the 50k sprites (the scene's array order — the +// RENDER-003 deterministic insertion), and the live particles (one +// additive group). O(frame items), NO ALLOCATION (the batcher's +// storage is pre-sized; the per-sprite isoDepthKey is integer math; +// the UV rects are the world's 4 precomputed values). +// +// The batcher must be live with its frame window OPEN (the frame +// protocol, the declareParticles/TileMap::declareTo precedent); a +// stopped world fails `InvalidArgument`. First failure wins: the +// sub-step's error is the call's error, and the items already +// declared by this call remain in the frame (the batcher owns the +// frame state — the game re-declares the whole frame every frame). +// `cameraPos` is the RENDER Vec2 (the float presentation position — +// the parallax's declareTo input, RENDER-006); the sprite depth keys +// convert from the scene's floats to the backend's Vec2 inside the +// loop (the isoDepthKey's domain). +template +[[nodiscard]] inline laige::Status declareIsoReferenceScene( + IsoReferenceSceneWorld& world, SpriteBatcher& batcher, + Vec2 cameraPos) noexcept { + if (!world.valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + const IsoReferenceScene& scene = world.scene(); + // (1) The parallax layers: the auto layers' once-per-frame advance + // (the documented pacing — the header's misuse section), then + // the layer quads at the camera's presentation position. + world.parallax().advanceScrolls(); + laige::Status st = world.parallax().declareTo(batcher, cameraPos); + if (!st.ok()) return st; + // (2) The terrain tiles (one group — the plan section). + typename TileMap::DeclareOptions tileOpts{}; + st = world.tiles().declareTo(batcher, tileOpts); + if (!st.ok()) return st; + // (3) The sprites: the scene's declaration order (the array order), + // each on its standing tile's height (the auto-depth wiring). + for (std::uint32_t i = 0; i < scene.spriteCount; ++i) { + const IsoRefSceneSprite& s = scene.sprites[i]; + SpriteItem item; + item.pos = Vec2{s.x, s.y}; + item.depthKey = isoDepthKey( + detail::isoRefBackendVec2(s.x, s.y), s.stepHeight, + kIsoDepthGroundLayer); + item.uv = world.frameUv_[s.frame]; + item.frameIndex = s.frame; + item.atlasId = s.atlasId; + item.blend = static_cast(s.blend); + item.materialId = 0u; + const auto added = batcher.add(std::move(item)); + if (!added.ok()) { + return laige::Status::failure(added.error()); + } + } + // (4) The live particles (one additive group — the plan section). + ParticleDeclareOptions popts{}; + popts.atlasId = kIsoRefSceneParticleAtlas; + popts.blend = BlendMode::Additive; + return declareParticles(batcher, world.particles(), popts); +} + +} // namespace laige::render diff --git a/src/laige-render/include/laige/render/text_items.h b/src/laige-render/include/laige/render/text_items.h index f479ad9..ea1dffd 100644 --- a/src/laige-render/include/laige/render/text_items.h +++ b/src/laige-render/include/laige/render/text_items.h @@ -411,7 +411,20 @@ class StringTable { } StringTable& operator=(StringTable&& o) noexcept { if (this != &o) { - *this = StringTable(std::move(o)); + // The member moves, applied in place (the move-constructor's + // body — routing through a temporary would recurse into this + // operator again). The moved-from source is stopped (the house + // pattern). + maxStrings_ = o.maxStrings_; + maxCodePoints_ = o.maxCodePoints_; + count_ = o.count_; + stored_ = o.stored_; + headers_ = std::move(o.headers_); + pool_ = std::move(o.pool_); + o.maxStrings_ = 0; + o.maxCodePoints_ = 0; + o.count_ = 0; + o.stored_ = 0; } return *this; } diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index a887409..9a57393 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -217,7 +217,23 @@ # UI scene rendered offscreen through the M2-SPRITE-02 submit stage, # the whole frame against a CPU reference rasterizer within the # documented +/- 1 byte tolerance, the exact probe pixels) — pure -# data + batcher bookkeeping except the GL smoke. +# data + batcher bookkeeping except the GL smoke; and the +# isometric reference scene (M2-SCENE-01) — the deterministic +# worst-case scene builder + versioned binary format + the engine-side +# world (scene_iso_tests.cpp's IsoReferenceScene* suites: the +# ARCH-010 determinism contract (same seed, same bytes; different +# seeds differ), the shape constants of both variants, the sprite / +# terrain invariants (a sprite stands on its tile's height), the +# format's round trip + the untrusted-input validation (bad version, +# truncation, trailing bytes, corrupted counts/heights, empty input), +# the load + one declare frame on both SimMath backends (the PRD §8.1 +# workload at reference size: 50k sprites + 65k tiles + 3 parallax +# layers + particles, the per-batch instance counts, the in-group +# depth-key monotonicity, the 14-group draw-call property under the +# 30-draw-call budget), the 2-widget UI pass (the quads, the one +# group, the two depth overrides), and the 1 000-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 @@ -248,8 +264,11 @@ # particle_render`), the `font` entry is the M2-TEXT-01 Verify command # (`ctest -R font`), the `ui_tree` entry is the M2-UI-01 Verify # command (`ctest -R ui_tree`), and the `ui_render` entry is the -# M2-UI-02 Verify command (`ctest -R ui_render`), each selecting -# exactly its suites from the shared executable. (The unanchored `ctest -R tilemap` regex also +# M2-UI-02 Verify command (`ctest -R ui_render`), and the `scene_iso` +# entry is the M2-SCENE-01 Verify command (`ctest -R scene_iso`), each +# selecting exactly its suites from the shared executable (the CLI's +# same-seed byte-identity gate is the separate `scene_iso_cli` entry +# in tools/scene-iso). (The unanchored `ctest -R tilemap` regex also # selects the `tilemap_anim` entry — the M2-TILE-02 suites run under # the M2-TILE-01 command too; that is intended. Likewise the # unanchored `ctest -R particle` regex selects the M2-PART-01 @@ -276,7 +295,7 @@ add_executable(laige-render_tests sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp tilemap_anim_tests.cpp parallax_tests.cpp particle_render_tests.cpp font_tests.cpp text_items_tests.cpp font_sdf_tests.cpp ui_tree_tests.cpp - ui_render_tests.cpp) + ui_render_tests.cpp scene_iso_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 @@ -531,6 +550,20 @@ set_tests_properties(ui_render PROPERTIES ENVIRONMENT "LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") +# M2-SCENE-01: the step's Verify command is `ctest -R scene_iso`. Pure +# data + batcher bookkeeping (no GL calls) — runs in every local tree +# and in CI. The 50k-sprite frame (the PRD §8.1 workload at the CPU +# stage, both SimMath backends) plus the 1 000-frame zero-allocation +# loop is the entry's heaviest work; 120s is generous headroom for a +# slow CI runner. No budget gate: the step's roadmap scope has no +# standalone budgets.json entry (the scene's generation budget is the +# tool's, the per-frame declare cost is part of the composite 50k +# render-CPU budget, measured with M2-PERF-01). The CLI's +# determinism gate lives with the tool (tools/scene-iso — the +# scene_iso_cli entry). +add_test(NAME scene_iso COMMAND laige-render_tests + --gtest_filter=IsoReferenceScene*) + 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) @@ -572,7 +605,7 @@ if(LAIGE_TSAN) iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames render_counters tilemap tilemap_anim parallax particle_render font - text_items font_sdf ui_tree ui_render PROPERTIES + text_items font_sdf ui_tree ui_render scene_iso PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() @@ -679,3 +712,8 @@ set_tests_properties(font_sdf PROPERTIES TIMEOUT 120) # frame readback against the CPU reference) plus the 1 000-frame zero- # allocation declare loop. set_tests_properties(ui_render PROPERTIES TIMEOUT 120) +# The `scene_iso` entry declares the 50k-sprite §8.1 frame on both +# SimMath backends (65k tile quads + 50k sprites + parallax + +# particles per declare) and runs the 1 000-frame zero-allocation loop +# (small variant) — 120s, like the same-shaped entries. +set_tests_properties(scene_iso PROPERTIES TIMEOUT 120) diff --git a/tests/laige-render/scene_iso_tests.cpp b/tests/laige-render/scene_iso_tests.cpp new file mode 100644 index 0000000..4fd43cb --- /dev/null +++ b/tests/laige-render/scene_iso_tests.cpp @@ -0,0 +1,546 @@ +// laige-render isometric reference scene tests (M2-SCENE-01). +// +// The M2-SCENE-01 scope under test: the deterministic builder (the +// ARCH-010 contract — same seed, same bytes), the versioned binary +// format (the round trip + the untrusted-input validation, SCALE-004 / +// TEST-005), and the engine-side world (load + one declare frame per +// backend — the PRD §8.1 workload at reference size: 50k sprites, +// stepped terrain, 3 parallax layers, particles, UI; the 14-group +// draw-call property under the 30-draw-call budget) and the zero- +// allocation declare loop (PERF-003). +// +// GL policy: none of these tests create a GL context (the batcher is +// the CPU stage — the M2-SPRITE-01 precedent); they run in every P0 +// tree, including the sanitizer trees. The 50k frames are the §8.1 +// workload shape at the CPU stage; M2-PERF-01 measures the composite +// frame timing. + +#include +#include +#include +#include +#include + +#include + +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/render/iso_reference_scene.h" +#include "laige/render/ui_render.h" +#include "laige/result.h" +#include "laige/sim_math.h" + +using laige::ErrorCode; +using laige::render::BlendMode; +using laige::render::IsoRefSceneVariant; +using laige::render::IsoReferenceScene; +using laige::render::IsoReferenceSceneWorld; +using laige::render::SpriteBatch; +using laige::render::SpriteBatcher; +using laige::render::SpriteItem; +using laige::render::UiDeclareOptions; +using laige::render::Vec2; +using laige::render::buildIsoReferenceScene; +using laige::render::declareIsoReferenceScene; +using laige::render::declareUi; +using laige::render::kIsoRefSceneBgAtlas; +using laige::render::kIsoRefSceneEmitterCount; +using laige::render::kIsoRefSceneFgAtlas; +using laige::render::kIsoRefSceneFullGridTiles; +using laige::render::kIsoRefSceneFullSprites; +using laige::render::kIsoRefSceneFormatVersion; +using laige::render::kIsoRefSceneGroundAtlas; +using laige::render::kIsoRefSceneMidAtlas; +using laige::render::kIsoRefSceneParticleAtlas; +using laige::render::kIsoRefSceneSmallSprites; +using laige::render::kIsoRefSceneSpriteAtlasBase; +using laige::render::kIsoRefSceneSpriteSheetFrames; +using laige::render::kIsoRefSceneUiWidgetAtlas; +using laige::render::loadIsoReferenceScene; +using laige::render::readIsoReferenceScene; +using laige::render::serializeIsoReferenceScene; +using laige::sim::Fp32Pinned; +using laige::sim::Fpx16_16; + +namespace { + +// The test's fixed seed (the determinism contract's reference value — +// the ctest CLI gate uses the same one). +constexpr std::uint64_t kTestSeed = 424242u; + +// The scene's deep equality (the POD value types are compared field +// by field — the wire layout is the comparison contract). +bool scenesEqual(const IsoReferenceScene& a, const IsoReferenceScene& b) { + if (a.version != b.version || a.variant != b.variant || a.seed != b.seed || + a.originTileX != b.originTileX || a.originTileY != b.originTileY || + a.widthTiles != b.widthTiles || a.heightTiles != b.heightTiles || + a.plateauTiles != b.plateauTiles || a.stepHeight != b.stepHeight || + a.maxSteps != b.maxSteps || a.tileCount != b.tileCount || + a.spriteCount != b.spriteCount || a.emitterCount != b.emitterCount || + a.uiWidgetCount != b.uiWidgetCount || a.parallaxCount != b.parallaxCount) { + return false; + } + if (a.tileHeights != b.tileHeights) return false; + if (a.sprites.size() != b.sprites.size()) return false; + for (std::size_t i = 0; i < a.sprites.size(); ++i) { + const auto& s = a.sprites[i]; + const auto& t = b.sprites[i]; + if (s.x != t.x || s.y != t.y || s.stepHeight != t.stepHeight || + s.atlasId != t.atlasId || s.blend != t.blend || s.frame != t.frame || + s.reserved != t.reserved) { + return false; + } + } + for (std::size_t i = 0; i < a.parallaxCount; ++i) { + const auto& l = a.parallax[i]; + const auto& m = b.parallax[i]; + if (l.factor != m.factor || l.center.x != m.center.x || + l.center.y != m.center.y || l.offset.x != m.offset.x || + l.offset.y != m.offset.y || l.size.x != m.size.x || + l.size.y != m.size.y || l.uv.u0 != m.uv.u0 || l.uv.v0 != m.uv.v0 || + l.uv.u1 != m.uv.u1 || l.uv.v1 != m.uv.v1 || l.atlasId != m.atlasId || + l.materialId != m.materialId || l.blend != m.blend || + l.scrollMode != m.scrollMode || l.enabled != m.enabled || + l.reserved != m.reserved || l.depthLayer != m.depthLayer || + l.scrollSpeed.x != m.scrollSpeed.x || + l.scrollSpeed.y != m.scrollSpeed.y) { + return false; + } + } + for (std::size_t i = 0; i < a.emitterCount; ++i) { + const auto& e = a.emitters[i]; + const auto& f = b.emitters[i]; + if (e.originX != f.originX || e.originY != f.originY || e.depth != f.depth || + e.velMinX != f.velMinX || e.velMinY != f.velMinY || + e.velMaxX != f.velMaxX || e.velMaxY != f.velMaxY || + e.lifeMin != f.lifeMin || e.lifeMax != f.lifeMax || + e.sizeMin != f.sizeMin || e.sizeMax != f.sizeMax || + std::memcmp(e.tint, f.tint, 4) != 0 || e.fadeTicks != f.fadeTicks || + e.continuousRate != f.continuousRate) { + return false; + } + } + for (std::size_t i = 0; i < a.uiWidgetCount; ++i) { + const auto& w = a.uiWidgets[i]; + const auto& v = b.uiWidgets[i]; + if (w.kind != v.kind || w.opaque != v.opaque || w.x != v.x || w.y != v.y || + w.width != v.width || w.height != v.height || w.atlasId != v.atlasId || + w.label != v.label) { + return false; + } + } + return true; +} + +// The world pass's frame expectations for one declare after `ticks` +// particle updates and the FIRST parallax advance (the auto layers' +// both wrap coordinates are > 0 after one advance -> 4 quads each; +// the manual mid layer -> 1 quad; the parallax.h wrap contract). +constexpr std::uint32_t kFirstFrameParallaxQuads = 4u + 1u + 4u; + +} // namespace + +// --------------------------------------------------------------------------- +// The builder (ARCH-010: same seed, same scene, every build/platform) +// --------------------------------------------------------------------------- + +TEST(IsoReferenceSceneBuilder, SameSeedByteIdenticalBothVariants) { + for (IsoRefSceneVariant variant : {IsoRefSceneVariant::Small, + IsoRefSceneVariant::Full}) { + const std::vector a = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, variant)); + const std::vector b = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, variant)); + EXPECT_EQ(a, b); + // A third build (the substream sequence is a pure function of the + // seed — re-deriving the streams reproduces every draw). + const std::vector c = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, variant)); + EXPECT_EQ(a, c); + } +} + +TEST(IsoReferenceSceneBuilder, DifferentSeedsDifferBothVariants) { + for (IsoRefSceneVariant variant : {IsoRefSceneVariant::Small, + IsoRefSceneVariant::Full}) { + const std::vector a = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, variant)); + const std::vector b = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed + 1u, variant)); + EXPECT_NE(a, b); + } +} + +TEST(IsoReferenceSceneBuilder, ShapeConstantsFull) { + const IsoReferenceScene scene = buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Full); + EXPECT_EQ(scene.version, kIsoRefSceneFormatVersion); + EXPECT_EQ(scene.variant, IsoRefSceneVariant::Full); + EXPECT_EQ(scene.seed, kTestSeed); + EXPECT_EQ(scene.widthTiles, kIsoRefSceneFullGridTiles); + EXPECT_EQ(scene.heightTiles, kIsoRefSceneFullGridTiles); + EXPECT_EQ(scene.plateauTiles, 16); + EXPECT_EQ(scene.stepHeight, 2); + EXPECT_EQ(scene.maxSteps, 24); + EXPECT_EQ(scene.tileCount, 65536u); + EXPECT_EQ(scene.spriteCount, kIsoRefSceneFullSprites); + EXPECT_EQ(scene.emitterCount, kIsoRefSceneEmitterCount); + EXPECT_EQ(scene.uiWidgetCount, 2u); + EXPECT_EQ(scene.parallaxCount, 3u); + EXPECT_EQ(scene.tileHeights.size(), scene.tileCount); + EXPECT_EQ(scene.sprites.size(), scene.spriteCount); +} + +TEST(IsoReferenceSceneBuilder, ShapeConstantsSmall) { + const IsoReferenceScene scene = buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small); + EXPECT_EQ(scene.variant, IsoRefSceneVariant::Small); + EXPECT_EQ(scene.widthTiles, 32); + EXPECT_EQ(scene.heightTiles, 32); + EXPECT_EQ(scene.plateauTiles, 8); + EXPECT_EQ(scene.tileCount, 1024u); + EXPECT_EQ(scene.spriteCount, kIsoRefSceneSmallSprites); + EXPECT_EQ(scene.sprites.size(), scene.spriteCount); +} + +TEST(IsoReferenceSceneBuilder, SpriteInvariantsSmall) { + const IsoReferenceScene scene = + buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small); + constexpr int kGrid = 32; + constexpr int kMaxHeight = (24 - 1) * 2; // (maxSteps-1) x stepHeight + for (const auto& s : scene.sprites) { + // The 2^-16-resolution position (exact float, the ADR 0002 domain). + EXPECT_GE(s.x, 0.0f); + EXPECT_LT(s.x, static_cast(kGrid)); + EXPECT_GE(s.y, 0.0f); + EXPECT_LT(s.y, static_cast(kGrid)); + // The sprite stands on its tile: floor(x) = rawX >> 16 exactly + // (the position's raw domain), and its step height is that tile's. + const std::uint32_t tx = static_cast(s.x); + const std::uint32_t ty = static_cast(s.y); + EXPECT_EQ(s.stepHeight, + scene.tileHeights[ty * static_cast(kGrid) + tx]); + // The plan section's domains. + EXPECT_GE(s.atlasId, kIsoRefSceneSpriteAtlasBase); + EXPECT_LT(s.atlasId, kIsoRefSceneSpriteAtlasBase + 4u); + EXPECT_LE(s.blend, 1u); + EXPECT_LT(s.frame, kIsoRefSceneSpriteSheetFrames); + EXPECT_GE(s.stepHeight, 0); + EXPECT_LE(s.stepHeight, kMaxHeight); + EXPECT_EQ(s.stepHeight % 2, 0); // the step model: even heights + } +} + +TEST(IsoReferenceSceneBuilder, TerrainInvariantsSmall) { + const IsoReferenceScene scene = + buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small); + constexpr int kGrid = 32; + constexpr int kPlateau = 8; + constexpr int kMaxHeight = (24 - 1) * 2; + // The step model: even heights in [0, kMaxHeight]. + for (const std::int32_t h : scene.tileHeights) { + EXPECT_GE(h, 0); + EXPECT_LE(h, kMaxHeight); + EXPECT_EQ(h % 2, 0); + } + // The plateau uniformity (one height per plateau, row-major). + const std::uint32_t perAxis = static_cast(kGrid / kPlateau); + for (std::uint32_t py = 0; py < perAxis; ++py) { + for (std::uint32_t px = 0; px < perAxis; ++px) { + const std::int32_t plateauHeight = scene.tileHeights[ + (py * kPlateau) * kGrid + (px * kPlateau)]; + for (std::uint32_t ty = py * kPlateau; ty < (py + 1) * kPlateau; ++ty) { + for (std::uint32_t tx = px * kPlateau; tx < (px + 1) * kPlateau; ++tx) { + EXPECT_EQ(scene.tileHeights[ty * kGrid + tx], plateauHeight); + } + } + } + } + // The worst-case property: the terrain is stepped, not flat (a + // uniform plateau would make every sprite's depth key tie — the + // reference scene must exercise the sort's tie-breaking; 256 + // PRNG-drawn plateaus over 24 steps differ with certainty). + bool distinct = false; + for (std::uint32_t i = 1; i < scene.tileHeights.size() && !distinct; ++i) { + if (scene.tileHeights[i] != scene.tileHeights[0]) distinct = true; + } + EXPECT_TRUE(distinct); +} + +// --------------------------------------------------------------------------- +// The format (the round trip + the untrusted-input validation) +// --------------------------------------------------------------------------- + +TEST(IsoReferenceSceneFormat, RoundTripBothVariants) { + for (IsoRefSceneVariant variant : {IsoRefSceneVariant::Small, + IsoRefSceneVariant::Full}) { + const IsoReferenceScene scene = buildIsoReferenceScene(kTestSeed, variant); + const std::vector bytes = serializeIsoReferenceScene(scene); + const auto parsed = + readIsoReferenceScene(std::span(bytes)); + ASSERT_TRUE(parsed.ok()) << "variant " << static_cast(variant); + const IsoReferenceScene& back = *parsed.valueIfOk(); + EXPECT_TRUE(scenesEqual(scene, back)); + } +} + +TEST(IsoReferenceSceneFormat, BadVersionRejected) { + std::vector bytes = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small)); + bytes[0] = static_cast(kIsoRefSceneFormatVersion + 1u); + const auto parsed = + readIsoReferenceScene(std::span(bytes)); + ASSERT_TRUE(parsed.isError()); + EXPECT_EQ(parsed.error(), ErrorCode::MalformedInput); +} + +TEST(IsoReferenceSceneFormat, TruncatedRejected) { + std::vector bytes = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small)); + bytes.pop_back(); // a truncated last widget byte + const auto parsed = + readIsoReferenceScene(std::span(bytes)); + ASSERT_TRUE(parsed.isError()); + EXPECT_EQ(parsed.error(), ErrorCode::MalformedInput); +} + +TEST(IsoReferenceSceneFormat, TrailingBytesRejected) { + std::vector bytes = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small)); + bytes.push_back(0u); // the exact-length contract: extra bytes are not this scene + const auto parsed = + readIsoReferenceScene(std::span(bytes)); + ASSERT_TRUE(parsed.isError()); + EXPECT_EQ(parsed.error(), ErrorCode::MalformedInput); +} + +TEST(IsoReferenceSceneFormat, CorruptedSpriteCountRejected) { + std::vector bytes = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small)); + // The spriteCount field (offset 48, little-endian) beyond the 50k cap. + bytes[48] = 0xFFu; + bytes[49] = 0xFFu; + bytes[50] = 0xFFu; + bytes[51] = 0xFFu; + const auto parsed = + readIsoReferenceScene(std::span(bytes)); + ASSERT_TRUE(parsed.isError()); + EXPECT_EQ(parsed.error(), ErrorCode::MalformedInput); +} + +TEST(IsoReferenceSceneFormat, CorruptedHeightRejected) { + std::vector bytes = + serializeIsoReferenceScene(buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small)); + // The first tile height (offset 68, little-endian) beyond the M2-ISO-01 + // |z| domain. + bytes[68] = 0x7Fu; + bytes[69] = 0x7Fu; + bytes[70] = 0xFFu; + bytes[71] = 0xFFu; + const auto parsed = + readIsoReferenceScene(std::span(bytes)); + ASSERT_TRUE(parsed.isError()); + EXPECT_EQ(parsed.error(), ErrorCode::MalformedInput); +} + +TEST(IsoReferenceSceneFormat, EmptyInputRejected) { + const auto parsed = readIsoReferenceScene(std::span{}); + ASSERT_TRUE(parsed.isError()); + EXPECT_EQ(parsed.error(), ErrorCode::MalformedInput); +} + +// --------------------------------------------------------------------------- +// The world (load + one declare frame — both SimMath backends) +// --------------------------------------------------------------------------- + +template +void loadAndBatchVariant(IsoRefSceneVariant variant) { + const IsoReferenceScene scene = buildIsoReferenceScene(kTestSeed, variant); + auto worldResult = loadIsoReferenceScene(scene); + ASSERT_TRUE(worldResult.ok()) + << "load failed for variant " << static_cast(variant); + auto world = std::move(worldResult).takeValue(); + ASSERT_TRUE(world.valid()); + // The scene round-trips into the world (the owned copy). + EXPECT_EQ(world.scene().seed, scene.seed); + EXPECT_EQ(world.scene().spriteCount, scene.spriteCount); + + // The particle lifecycle: the emitters run 10 sim ticks (the + // M2-PART-01 once-per-tick contract), then the live count is the + // frame's particle contribution (deterministic — the system's Prng + // is seeded from the scene's seed). + for (int t = 0; t < 10; ++t) { + world.particles().update(); + } + const std::uint32_t live = + static_cast(world.particles().liveParticles().size()); + + // The first-frame parallax quads (the wrap contract): bg auto -> + // 4, mid manual -> 1, fg auto -> 4. + const std::uint32_t expectedFrame = scene.spriteCount + scene.tileCount + + kFirstFrameParallaxQuads + live; + + auto batcherResult = + SpriteBatcher::create(SpriteBatcher::Options{expectedFrame}); + ASSERT_TRUE(batcherResult.ok()); + auto batcher = std::move(batcherResult).takeValue(); + batcher.beginFrame(); + const auto st = declareIsoReferenceScene( + world, batcher, Vec2{12.5f, 7.5f}); + ASSERT_TRUE(st.ok()); + ASSERT_TRUE(batcher.build().ok()); + + EXPECT_EQ(batcher.frameCount(), expectedFrame); + EXPECT_EQ(batcher.droppedTotal(), 0u); // the capacity was exact + EXPECT_EQ(batcher.overrideCount(), 0u); // no depth overrides in the scene + + // The group expectations, derived from the scene's own data (the + // header's plan section: bg/mid/ground/sprites/particles/fg). + std::array, 4> spriteGroups{}; + for (const auto& s : scene.sprites) { + spriteGroups[s.atlasId - kIsoRefSceneSpriteAtlasBase][s.blend]++; + } + std::uint32_t expectedBatches = 1u /* ground */ + 3u /* parallax */ + + 1u /* particles */; + for (const auto& row : spriteGroups) { + for (const std::uint32_t g : row) { + if (g != 0u) ++expectedBatches; + } + } + EXPECT_EQ(batcher.batchCount(), expectedBatches); + // The PRD §8.1 draw-call budget (50k sprites, 3 parallax, UI <= 30). + EXPECT_LE(batcher.batchCount(), 30u); + + // The per-batch instance counts + the in-group depth-key monotonicity + // (M2-SORT-01: the batcher's back-to-front order). + for (const SpriteBatch& batch : batcher.batches()) { + std::uint32_t expected = 0u; + if (batch.atlasId == kIsoRefSceneBgAtlas) { + expected = 4u; + } else if (batch.atlasId == kIsoRefSceneMidAtlas) { + expected = 1u; + } else if (batch.atlasId == kIsoRefSceneFgAtlas) { + expected = 4u; + } else if (batch.atlasId == kIsoRefSceneParticleAtlas) { + expected = live; + } else if (batch.atlasId == kIsoRefSceneGroundAtlas) { + expected = scene.tileCount; + } else { + expected = spriteGroups[batch.atlasId - kIsoRefSceneSpriteAtlasBase][ + static_cast(batch.blend)]; + } + EXPECT_EQ(batch.instances.size(), expected); + std::uint32_t prev = 0u; + bool first = true; + for (const std::uint32_t slot : batch.instances) { + const SpriteItem& item = batcher.at(slot); + if (!first) { + EXPECT_LE(prev, item.depthKey); + } + prev = item.depthKey; + first = false; + } + } +} + +TEST(IsoReferenceSceneLoadAndBatch, SmallFpx16) { + loadAndBatchVariant(IsoRefSceneVariant::Small); +} +TEST(IsoReferenceSceneLoadAndBatch, SmallFp32) { + loadAndBatchVariant(IsoRefSceneVariant::Small); +} +TEST(IsoReferenceSceneLoadAndBatch, FullFpx16) { + loadAndBatchVariant(IsoRefSceneVariant::Full); +} +TEST(IsoReferenceSceneLoadAndBatch, FullFp32) { + loadAndBatchVariant(IsoRefSceneVariant::Full); +} + +// --------------------------------------------------------------------------- +// The UI pass (the scene's 2 widgets on the reference screen) +// --------------------------------------------------------------------------- + +template +void uiPassVariant() { + const IsoReferenceScene scene = + buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small); + auto worldResult = loadIsoReferenceScene(scene); + ASSERT_TRUE(worldResult.ok()); + auto world = std::move(worldResult).takeValue(); + ASSERT_TRUE(world.valid()); + + // The tree: the implicit root + the scene's 2 widgets. + EXPECT_EQ(world.ui().widgetCount(), 3u); + ASSERT_TRUE(world.ui().layout().ok()); + + // The pass WITHOUT a default font: the widget quads render (panel + // + button), the button's label text does not (the ui_render.h + // contract — a null default font skips the text pass, no error). + auto batcherResult = SpriteBatcher::create(SpriteBatcher::Options{64u}); + ASSERT_TRUE(batcherResult.ok()); + auto batcher = std::move(batcherResult).takeValue(); + batcher.beginFrame(); + UiDeclareOptions opts{}; // defaultFont = null + const auto st = declareUi(batcher, world.strings(), world.ui(), opts); + ASSERT_TRUE(st.ok()); + ASSERT_TRUE(batcher.build().ok()); + EXPECT_EQ(batcher.frameCount(), 2u); // the panel quad + the button quad + // Both quads share (atlas 100, material 0, Alpha) -> ONE batch. + EXPECT_EQ(batcher.batchCount(), 1u); + // The two depth overrides (the M2-UI-02 UI-plane keys). + EXPECT_EQ(batcher.overrideCount(), 2u); +} + +TEST(IsoReferenceSceneUiPass, SmallFpx16) { uiPassVariant(); } +TEST(IsoReferenceSceneUiPass, SmallFp32) { uiPassVariant(); } + +// --------------------------------------------------------------------------- +// The zero-allocation declare loop (PERF-003 — the §8.1 workload's +// per-frame cost at the CPU stage) +// --------------------------------------------------------------------------- + +template +void declareLoopAllocatesNothing() { + const IsoReferenceScene scene = + buildIsoReferenceScene(kTestSeed, IsoRefSceneVariant::Small); + auto worldResult = loadIsoReferenceScene(scene); + ASSERT_TRUE(worldResult.ok()); + auto world = std::move(worldResult).takeValue(); + ASSERT_TRUE(world.valid()); + for (int t = 0; t < 10; ++t) { + world.particles().update(); + } + const std::uint32_t live = + static_cast(world.particles().liveParticles().size()); + const std::uint32_t capacity = scene.spriteCount + scene.tileCount + + kFirstFrameParallaxQuads + live; + auto batcherResult = + SpriteBatcher::create(SpriteBatcher::Options{capacity}); + ASSERT_TRUE(batcherResult.ok()); + auto batcher = std::move(batcherResult).takeValue(); + + // The 1000-frame loop (the M2-SPRITE-01 zero-alloc test's shape): a + // moving camera (the parallax auto layers scroll every frame — the + // declared quads stay within the split budget, the wrap contract), + // one declare + one build per frame. The sanitizer trees no-op the + // watch (their instrumentation is the check); the clean trees + // assert zero heap allocations in the window. + laige::allocWatchArm(); + for (int frame = 0; frame < 1000; ++frame) { + batcher.beginFrame(); + const auto st = declareIsoReferenceScene( + world, batcher, Vec2{128.0f, 96.0f}); + ASSERT_TRUE(st.ok()); + ASSERT_TRUE(batcher.build().ok()); + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + if (laige::allocWatchLive()) { + EXPECT_EQ(reading.allocs, 0u); + } +} + +TEST(IsoReferenceSceneZeroAlloc, SmallFpx16) { + declareLoopAllocatesNothing(); +} +TEST(IsoReferenceSceneZeroAlloc, SmallFp32) { + declareLoopAllocatesNothing(); +} diff --git a/tests/laige-render/text_items_tests.cpp b/tests/laige-render/text_items_tests.cpp index 55afd39..230b359 100644 --- a/tests/laige-render/text_items_tests.cpp +++ b/tests/laige-render/text_items_tests.cpp @@ -335,6 +335,25 @@ TEST(TextStringTable, MoveSemantics) { EXPECT_EQ(4u, static_cast(b.text(ref).size())); } +// The move ASSIGNMENT (the M2-SCENE-01 scene loader's pattern — +// `world.strings_ = std::move(result).takeValue()`): the storage +// moves into the target, the target keeps the strings, and the +// source is stopped. +TEST(TextStringTable, MoveAssignment) { + auto r = StringTable::create(StringTable::Options{4, 16}); + ASSERT_TRUE(r.ok()); + StringTable src = std::move(r).takeValue(); + StringRef ref = internText(src, "move"); + StringTable dst; + ASSERT_FALSE(dst.valid()); + dst = std::move(src); + EXPECT_FALSE(src.valid()); // moved-from: stopped + EXPECT_TRUE(dst.valid()); + ASSERT_TRUE(dst.isValid(ref)); + EXPECT_EQ(4u, static_cast(dst.text(ref).size())); + EXPECT_EQ(1u, dst.count()); +} + // --------------------------------------------------------------------------- // measureText — the width goldens (the M2-TEXT-01 measured metrics at // 16 px: space adv 4, M adv 12, a adv 8, W adv 14, . adv 4) diff --git a/tools/fuzz/CMakeLists.txt b/tools/fuzz/CMakeLists.txt index 72ac911..7b026af 100644 --- a/tools/fuzz/CMakeLists.txt +++ b/tools/fuzz/CMakeLists.txt @@ -10,7 +10,10 @@ laige_apply_engine_policy(laige-fuzz) # M1-DET-02: the replay_parse target exercises laige-sim's replay log # parser — laige-sim joins the link (arrows only downward, PRD # §10.1); laige-core's public headers come through it (CPP-010). -target_link_libraries(laige-fuzz PRIVATE laige-sim) +# M2-SCENE-01: the scene_iso_parse target exercises laige-render's +# reference-scene file reader — laige-render joins the link (laige-sim +# comes through it, the M2-PART-02 public edge). +target_link_libraries(laige-fuzz PRIVATE laige-sim laige-render) # Bounded fuzz in every commit (PRD §14: "every commit (bounded), nightly # (long)"): 1000 deterministic inputs to the json_parse target (NFR-8.7, @@ -24,10 +27,20 @@ set_tests_properties(fuzz_json_parse PROPERTIES TIMEOUT 120) # a mutate/truncate base. add_test(NAME fuzz_replay_parse COMMAND laige-fuzz replay_parse --runs=1000) set_tests_properties(fuzz_replay_parse PROPERTIES TIMEOUT 120) +# M2-SCENE-01: the reference-scene file reader's malformed-input +# surface (SCALE-005 / ARCH-007 — the versioned binary format, +# laige/render/iso_reference_scene.h) gets the same bounded-every- +# commit gate; the corpus includes valid v1 small + full scenes as +# mutate/truncate bases (the full scene's ~0.94 MB keeps the mutate +# positions meaningful against the 50k-sprite record array). +add_test(NAME fuzz_scene_iso_parse COMMAND laige-fuzz scene_iso_parse --runs=1000) +set_tests_properties(fuzz_scene_iso_parse PROPERTIES TIMEOUT 120) if(LAIGE_TSAN) # Same fatal-race policy as the module test entries (NFR-8.2). set_tests_properties(fuzz_json_parse PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") set_tests_properties(fuzz_replay_parse PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + set_tests_properties(fuzz_scene_iso_parse PROPERTIES + ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tools/fuzz/laige-fuzz.cpp b/tools/fuzz/laige-fuzz.cpp index 7c1ad4d..2af2cac 100644 --- a/tools/fuzz/laige-fuzz.cpp +++ b/tools/fuzz/laige-fuzz.cpp @@ -5,10 +5,11 @@ // Roadmap step M0-CORE-07 landed the *minimal* runner this Verify // gate requires: `laige-fuzz json_parse --runs=1000` clean under ASan // (NFR-8.7: parsers are fuzzed in CI, bounded every commit — PRD -// §14). The `json_parse` and `replay_parse` targets are registered -// below (M1-DET-02 added the latter: the replay log parser's -// malformed-input surface, SCALE-005 / ARCH-007); later steps add -// their targets to kTargets. +// §14). The `json_parse`, `replay_parse`, and `scene_iso_parse` +// targets are registered below (M1-DET-02 added the replay log +// parser's malformed-input surface, SCALE-005 / ARCH-007; M2-SCENE-01 +// added the reference-scene file reader's, the same surface class); +// later steps add their targets to kTargets. // // Design (deterministic by construction): // - Every input is generated from laige::Prng (M0-CORE-06): a fixed @@ -36,11 +37,13 @@ #include #include #include +#include #include #include #include "laige/json.h" #include "laige/prng.h" +#include "laige/render/iso_reference_scene.h" // readIsoReferenceScene (M2-SCENE-01) #include "laige/sim/replay.h" // parseReplay (M1-DET-02: the replay parser) namespace { @@ -107,6 +110,21 @@ std::string buildValidReplayLog() { return std::string(log.begin(), log.end()); } +// Valid v1 reference scenes (M2-SCENE-01) — corpus bases for the +// mutate/truncate modes of the scene_iso_parse target (NFR-8.7: the +// parser gets real-shape input, not only random bytes). The small +// variant (1k sprites) keeps the corpus light; the full variant (50k +// sprites, ~0.94 MB) exercises mutations deep inside the 50k-sprite +// record array. Both are deterministic (same seed, the builder's +// ARCH-010 contract) — identical bytes on every platform. +std::string buildValidSceneCorpus(laige::render::IsoRefSceneVariant variant) { + const laige::render::IsoReferenceScene scene = + laige::render::buildIsoReferenceScene(424242u, variant); + const std::vector bytes = + laige::render::serializeIsoReferenceScene(scene); + return std::string(bytes.begin(), bytes.end()); +} + // Valid-document corpus: the base for mutate/truncate inputs. Kept ASCII // (explicit bytes for the one non-ASCII document) so the corpus bytes are // identical on every platform (narrow \x escapes are not portable). @@ -139,6 +157,12 @@ const std::vector kCorpus = { // mutate/truncate base (it is also fed to json_parse, where it // is simply another invalid document). buildValidReplayLog(), + // The valid v1 reference scenes (small + full) — the + // scene_iso_parse target's mutate/truncate bases (they are also + // fed to json_parse / replay_parse, where they are simply + // invalid documents). + buildValidSceneCorpus(laige::render::IsoRefSceneVariant::Small), + buildValidSceneCorpus(laige::render::IsoRefSceneVariant::Full), }; // --------------------------------------------------------------------------- @@ -165,9 +189,21 @@ void fuzzReplayParse(const std::uint8_t* data, std::size_t size) { (void)result; } +// M2-SCENE-01: the reference-scene file reader (SCALE-005 — the +// versioned binary format's untrusted-input surface). Any +// MalformedInput outcome is acceptable; only a crash (or sanitizer +// report) fails the run. +void fuzzSceneIsoParse(const std::uint8_t* data, std::size_t size) { + const laige::Result result = + laige::render::readIsoReferenceScene( + std::span(data, size)); + (void)result; +} + const FuzzTarget kTargets[] = { {"json_parse", &fuzzJsonParse}, {"replay_parse", &fuzzReplayParse}, + {"scene_iso_parse", &fuzzSceneIsoParse}, }; void printUsage() { diff --git a/tools/scene-iso/CMakeLists.txt b/tools/scene-iso/CMakeLists.txt new file mode 100644 index 0000000..3a9a3a4 --- /dev/null +++ b/tools/scene-iso/CMakeLists.txt @@ -0,0 +1,35 @@ +# laige-scene-iso (M2-SCENE-01): the reference-isometric-scene +# generator. +# +# Gated with the test suite like tools/bench and tools/fuzz: a +# library-only build does not need it. The tool builds the worst-case +# isometric reference scene (50k sprites / small 1k variant) from a +# seed, serializes it (the versioned binary format, +# laige/render/iso_reference_scene.h), and writes it — headless, no +# GL (ARCH-003: the engine objects the scene builds are the loader's +# job, exercised by the tests). + +add_executable(laige-scene-iso laige-scene-iso.cpp) +laige_apply_engine_policy(laige-scene-iso) +target_link_libraries(laige-scene-iso PRIVATE laige-render) + +# The M2-SCENE-01 Verify clause in executable form (roadmap: "two +# same-seed invocations produce byte-identical output, tested"): the +# -P script runs laige-scene-iso twice per variant (the same seed) +# and compares the two output files byte for byte. CMake-level file +# compare (no shell — cross-platform, the P0 Windows/macOS/Linux jobs). +# The 60 s timeout bounds the step's "< 5 s" generation budget with +# wide margin (the absolute number is machine-dependent; the summary +# line records the measured time — CORE-001). +add_test(NAME scene_iso_cli + COMMAND ${CMAKE_COMMAND} + -D LAIGE_SCENE_ISO=$ + -D WORKDIR=${CMAKE_BINARY_DIR}/scene-iso-check + -P ${CMAKE_CURRENT_SOURCE_DIR}/scene_iso_determinism.cmake) +set_tests_properties(scene_iso_cli PROPERTIES TIMEOUT 60) + +if(LAIGE_TSAN) + # Same first-report-fatal policy as the other test entries (NFR-8.2). + set_tests_properties(scene_iso_cli + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") +endif() diff --git a/tools/scene-iso/laige-scene-iso.cpp b/tools/scene-iso/laige-scene-iso.cpp new file mode 100644 index 0000000..cafa21c --- /dev/null +++ b/tools/scene-iso/laige-scene-iso.cpp @@ -0,0 +1,212 @@ +// laige-scene-iso (M2-SCENE-01): the reference-isometric-scene +// generator. +// +// The M2-SCENE-01 scope: deterministically generate the worst-case +// isometric reference scene from a seed (50k overlapping sprites on +// stepped terrain, 3 parallax layers, particle emitters, a UI panel) +// and emit it as data the engine can load (the versioned binary +// format, laige/render/iso_reference_scene.h — the in-memory +// builder + the simple binary serialization the tests use). The full +// scene FORMAT is M3; this tool is the M2 generator for that format. +// +// Usage: +// laige-scene-iso --seed --variant --out +// +// --seed the scene's seed (any u64; 0 is valid): +// same seed, same bytes, every build and +// platform (ARCH-010 — the determinism the +// test fixtures rely on) +// --variant full = the 50k-sprite §8.1 worst-case +// scene; small = the 1k-sprite unit-test +// variant (the step scope's fast fixture) +// --out the output file: written atomically (temp +// + rename in the same directory — a killed +// run never leaves a partial file) +// +// The tool builds the scene in memory, serializes it, writes it, then +// reads the file back and verifies the round trip (byte-identical + a +// clean parse) — no silent failure (CORE-008). +// +// Exit codes (stable for CI grepping): +// 0 the scene was written and verified (the summary line carries +// the shape + the elapsed generation time, machine-greppable) +// 2 usage, IO, or round-trip failure +// +// Headless invariants (ARCH-003): this tool links laige-render for +// the scene builder/serializer only — it creates no GL context, no +// window, no sim world (the engine objects the scene builds are the +// loader's job, the test's). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/render/iso_reference_scene.h" +#include "laige/result.h" + +namespace { + +void printUsage(std::FILE* out) { + std::fprintf(out, + "laige-scene-iso — the M2-SCENE-01 reference-scene generator\n" + "\n" + "Usage:\n" + " laige-scene-iso --seed --variant --out " + "\n" + "\n" + " --seed the scene's seed (any u64; 0 is valid)\n" + " --variant full (50k sprites) | small (1k sprites)\n" + " --out the output file (atomic temp + rename)\n" + "\n" + "The tool builds the scene deterministically from the seed, " + "serializes it (the versioned binary format, " + "laige/render/iso_reference_scene.h), writes it, and reads it " + "back to verify the round trip (byte-identical + a clean " + "parse).\n" + "\n" + "Exit codes: 0 = written + verified, 2 = usage/IO/round-trip " + "failure.\n"); +} + +std::uint64_t parseU64(std::string_view s, bool& ok) { + ok = false; + if (s.empty()) return 0; + std::uint64_t v = 0; + for (const char ch : s) { + if (ch < '0' || ch > '9') return 0; + v = v * 10u + static_cast(static_cast(ch - '0')); + } + ok = true; + return v; +} + +} // namespace + +int main(int argc, char** argv) { + using laige::render::IsoRefSceneVariant; + using laige::render::IsoReferenceScene; + using laige::render::buildIsoReferenceScene; + using laige::render::readIsoReferenceScene; + using laige::render::serializeIsoReferenceScene; + using laige::render::writeIsoReferenceScene; + + std::uint64_t seed{}; + bool hasSeed = false; + IsoRefSceneVariant variant = IsoRefSceneVariant::Full; + bool hasVariant = false; + std::string outPath; + bool hasOut = false; + + for (int i = 1; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--seed" || arg == "--variant" || arg == "--out") { + if (i + 1 >= argc) { + std::fprintf(stderr, "laige-scene-iso: %s needs a value\n", + std::string(arg).c_str()); + return 2; + } + const std::string value = argv[++i]; + if (arg == "--seed") { + bool ok{}; + seed = parseU64(value, ok); + if (!ok) { + std::fprintf(stderr, "laige-scene-iso: bad --seed value\n"); + return 2; + } + hasSeed = true; + } else if (arg == "--variant") { + if (value == "full") { + variant = IsoRefSceneVariant::Full; + } else if (value == "small") { + variant = IsoRefSceneVariant::Small; + } else { + std::fprintf(stderr, + "laige-scene-iso: --variant must be full or " + "small\n"); + return 2; + } + hasVariant = true; + } else { + outPath = value; + hasOut = true; + } + } else if (arg == "--help" || arg == "-h") { + printUsage(stdout); + return 0; + } else { + std::fprintf(stderr, "laige-scene-iso: unknown argument '%.*s'\n", + static_cast(arg.size()), arg.data()); + printUsage(stderr); + return 2; + } + } + if (!hasSeed || !hasVariant || !hasOut) { + printUsage(stderr); + return 2; + } + + const auto t0 = std::chrono::steady_clock::now(); + const IsoReferenceScene scene = buildIsoReferenceScene(seed, variant); + const std::vector bytes = serializeIsoReferenceScene(scene); + if (!writeIsoReferenceScene(scene, outPath).ok()) { + std::fprintf(stderr, "laige-scene-iso: failed to write %s\n", + outPath.c_str()); + return 2; + } + + // The round-trip self-check (CORE-008): read the file back, compare + // it byte for byte, and re-parse it (a corrupt writer or a drifting + // reader fails here, loudly). + std::vector back(bytes.size()); + std::FILE* f{}; +#if defined(_MSC_VER) + if (fopen_s(&f, outPath.c_str(), "rb") != 0) { + std::fprintf(stderr, "laige-scene-iso: failed to read back %s\n", + outPath.c_str()); + return 2; + } +#else + f = std::fopen(outPath.c_str(), "rb"); +#endif + if (f == nullptr) { + std::fprintf(stderr, "laige-scene-iso: failed to read back %s\n", + outPath.c_str()); + return 2; + } + const std::size_t got = std::fread(back.data(), 1, back.size(), f); + std::fclose(f); + if (got != back.size() || back != bytes) { + std::fprintf(stderr, + "laige-scene-iso: round-trip mismatch (serialized %zu " + "bytes, read back %zu)\n", + bytes.size(), got); + return 2; + } + const auto parsed = + readIsoReferenceScene(std::span(back)); + if (parsed.isError()) { + std::fprintf(stderr, + "laige-scene-iso: round-trip parse failed (%s)\n", + laige::errorName(parsed.error())); + return 2; + } + + const auto t1 = std::chrono::steady_clock::now(); + const std::int64_t ms = std::chrono::duration_cast( + t1 - t0) + .count(); + std::printf( + "laige-scene-iso variant=%s seed=%llu bytes=%zu sprites=%u tiles=%u " + "emitters=%u ui=%u elapsed_ms=%lld path=%s\n", + variant == IsoRefSceneVariant::Full ? "full" : "small", + static_cast(seed), bytes.size(), + scene.spriteCount, scene.tileCount, scene.emitterCount, + scene.uiWidgetCount, static_cast(ms), outPath.c_str()); + return 0; +} diff --git a/tools/scene-iso/scene_iso_determinism.cmake b/tools/scene-iso/scene_iso_determinism.cmake new file mode 100644 index 0000000..b98642d --- /dev/null +++ b/tools/scene-iso/scene_iso_determinism.cmake @@ -0,0 +1,48 @@ +# scene_iso_determinism.cmake — the M2-SCENE-01 CLI determinism gate +# (roadmap Verify: "two same-seed invocations produce byte-identical +# output, tested"). +# +# Usage: +# cmake -D LAIGE_SCENE_ISO= -D WORKDIR= -P scene_iso_determinism.cmake +# +# Runs laige-scene-iso TWICE per variant (the same seed — 424242, an +# arbitrary fixed value), then compares the two output files byte for +# byte (file READ ... HEX + string compare — no shell, cross-platform). +# Any failure is a FATAL_ERROR (ctest red). The two variants cover the +# 50k worst-case scene and the 1k-sprite unit-test fixture; a third +# property (different seed => different bytes) is asserted in-process +# by the gtest suite (IsoReferenceSceneBuilder). + +if(NOT DEFINED LAIGE_SCENE_ISO OR NOT DEFINED WORKDIR) + message(FATAL_ERROR + "scene_iso_determinism.cmake: missing LAIGE_SCENE_ISO and/or WORKDIR") +endif() + +file(MAKE_DIRECTORY ${WORKDIR}) + +set(kSeed 424242) +foreach(VARIANT full small) + set(A ${WORKDIR}/${VARIANT}-a.bin) + set(B ${WORKDIR}/${VARIANT}-b.bin) + execute_process(COMMAND ${LAIGE_SCENE_ISO} + --seed ${kSeed} --variant ${VARIANT} --out ${A} + RESULT_VARIABLE RA) + execute_process(COMMAND ${LAIGE_SCENE_ISO} + --seed ${kSeed} --variant ${VARIANT} --out ${B} + RESULT_VARIABLE RB) + if(NOT RA STREQUAL 0 OR NOT RB STREQUAL 0) + message(FATAL_ERROR + "laige-scene-iso failed for variant=${VARIANT} (exit codes ${RA} / ${RB})") + endif() + file(READ ${A} HA HEX) + file(READ ${B} HB HEX) + if(NOT HA STREQUAL HB) + message(FATAL_ERROR + "same-seed outputs differ for variant=${VARIANT} (seed ${kSeed}) — " + "ARCH-010 determinism broken") + endif() + string(LENGTH HL ${HA}) + message(STATUS + "scene-iso determinism: variant=${VARIANT} seed=${kSeed} OK " + "(${HL} hex chars identical)") +endforeach()