diff --git a/docs/README.md b/docs/README.md index 03c5453..c2589d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -353,6 +353,15 @@ still to land. the deterministic shelf packing (same font + options → identical atlas bytes), the missing-glyph fallback contract (never a crash, never silent), and the zero-allocation O(1) `glyph(code)` lookup. + - [Text items](api/text_items.md) — + `laige::render::TextItem` / `declareText` (M2-TEXT-02, the + text-item half of FR-2.8): the scene's string table (interned u32 + code points — PRD §10.4, no per-frame `std::string`), screen-space + layout (the first line's baseline anchor, the integer scale + [1, 16], the exact word-wrap model), one batched quad per ink + glyph in the UI pass (the white-on-alpha upload through + M2-SPRITE-02 `bindAtlas`, the hand-typed UI z through + `depthOverride`), and the O(glyphs) zero-allocation declare pass. ## Guides diff --git a/docs/api/font.md b/docs/api/font.md index 2a28650..840f1e2 100644 --- a/docs/api/font.md +++ b/docs/api/font.md @@ -5,9 +5,10 @@ The bitmap half of FR-2.8 — "Text rendering: ... bitmap (P0)" are rasterized **once, at scene set-up**, into a fixed-size 8-bit alpha glyph atlas + per-glyph metrics (advance, bearing, line height). There is **no runtime re-rasterization**: after `create`, `glyph(code)` is a -pure O(1) table lookup. The text widget / layout pass lands in -M2-TEXT-02; the SDF path (P1) lands in M2-TEXT-03 on top of the same -vendored rasterizer (ADR 0009). +pure O(1) table lookup. The text-item / layout pass ships in +M2-TEXT-02 ([`text_items.md`](text_items.md)); the SDF path (P1) +lands in M2-TEXT-03 on top of the same vendored rasterizer +(ADR 0009). Public header: `src/laige-render/include/laige/render/font.h` @@ -112,9 +113,10 @@ UV rect for the M2-SPRITE-02 upload is `(atlasX/W, atlasY/H, (atlasX+width)/W, (atlasY+height)/H)` (the M2-SPRITE-03 v-axis convention: v = 0 = first uploaded texel row). -The atlas bytes are 8-bit alpha; the M2-TEXT-02 pass uploads them as a +The atlas bytes are 8-bit alpha; the text-item pass +([`text_items.md`](text_items.md), M2-TEXT-02) uploads them as a white-on-alpha RGBA8 atlas through the M2-SPRITE-02 `bindAtlas` -path. +path (`expandGlyphAtlasRgba8`). ## Missing glyphs (the documented fallback) @@ -167,8 +169,9 @@ A failed create returns the error; no atlas is produced. `GlyphAtlas` owns its atlas bytes + slot table (move-only, the `SpriteBatcher` precedent). One owner (the scene set-up thread); the -render phase reads it (the M2-GL-02 cull/batch stage uploads the atlas -+ declares text quads — M2-TEXT-02). The font bytes are a +render phase reads it (the M2-GL-02 cull/batch stage uploads the +atlas + declares text quads — [`text_items.md`](text_items.md), +M2-TEXT-02). The font bytes are a **non-owning span**: the caller owns them (the game's asset pipeline — the M3 asset system); they are read only during `create` and may be freed immediately after. Presentation-only (ARCH-009): the atlas is @@ -190,8 +193,9 @@ anywhere in this API. blocks). The returned pointer is valid until the atlas is moved/destroyed (the slot table is never reallocated after create). - No `budgets.json` entry: the atlas is built once at set-up, not per - frame — the per-frame text cost lands with M2-TEXT-02 (part of the - composite 50k render-CPU budget, M2-PERF-01). + frame — the per-frame text cost ships with + [`text_items.md`](text_items.md) (M2-TEXT-02; part of the composite + 50k render-CPU budget, M2-PERF-01). **Common trap:** rasterizing in a per-frame code path. The atlas is a set-up artifact — `create` per frame re-parses and re-rasterizes the @@ -202,16 +206,18 @@ this reason). ```cpp // Scene set-up (once): read the font file (the asset pipeline owns -// the bytes), build the atlas, upload it (M2-TEXT-02), and keep the -// GlyphAtlas for the frame pipeline's declaration stage. +// the bytes), build the atlas, upload it +// (expandGlyphAtlasRgba8 — M2-TEXT-02), and keep the GlyphAtlas for +// the frame pipeline's declaration stage. std::vector fontBytes = readAsset("fonts/vera.ttf"); auto atlas = laige::render::GlyphAtlas::create(fontBytes, laige::render::GlyphAtlas::Options{}); if (atlas.isError()) { /* handle: the font asset is invalid */ } fontBytes.clear(); // safe: the atlas owns a copy of the bitmap -// Per frame (M2-TEXT-02): one quad per drawn glyph — -// metrics = atlas.glyph(code); quad uv = (atlasX/W, atlasY/H, -// (atlasX+width)/W, (atlasY+height)/H); advance pen by advance * scale. +// Per frame (M2-TEXT-02): declareText (text_items.h) turns the +// string into one quad per drawn glyph — metrics = atlas.glyph(code), +// quad uv = (atlasX/W, atlasY/H, (atlasX+width)/W, (atlasY+height)/H), +// pen advances by advance * scale. ``` ## Misuse warnings @@ -228,10 +234,12 @@ fontBytes.clear(); // safe: the atlas owns a copy of the bitmap ## Related -- [`sprite_batcher.md`](sprite_batcher.md) — the batcher the M2-TEXT-02 - text quads are declared into. +- [`text_items.md`](text_items.md) — the text-item / layout pass + (M2-TEXT-02) that consumes this atlas. +- [`sprite_batcher.md`](sprite_batcher.md) — the batcher the text + quads are declared into. - [`sprite_frames.md`](sprite_frames.md) — the atlas UV conventions (the v-axis, the float-exact domain). -- [`particle_render.md`](particle_render.md) — the sibling render-phase - declaration pass (the pattern M2-TEXT-02 follows). +- [`particle_render.md`](particle_render.md) — the sibling + render-phase declaration pass (the pattern the text pass follows). - ADR 0009 — the stb_truetype vendoring (the dependency this step adds). diff --git a/docs/api/text_items.md b/docs/api/text_items.md new file mode 100644 index 0000000..b900ee8 --- /dev/null +++ b/docs/api/text_items.md @@ -0,0 +1,306 @@ +# Text items — the UI-pass text half of FR-2.8 (`laige::render` text_items) + +The text-item half of FR-2.8 — "Text rendering: ... bitmap (P0)" +(M2-TEXT-02): `TextItem` declares a block of text into the sprite +batcher as **batched glyph quads in the UI pass** (screen space, y +down). The text bytes come from a **string table** (interned u32 code +points — PRD §10.4: no per-frame `std::string` in the path), the glyph +metrics come from the [bitmap font atlas](font.md) +(M2-TEXT-01), and each glyph with ink becomes one `SpriteItem` in the +batcher (M2-SPRITE-02). The text renders as ordinary instanced sprite +work — one draw call per (atlas, material, blend) group +(RENDER-001), no separate text pipeline. The SDF path (P1) lands in +M2-TEXT-03 on top of the same vendored rasterizer (ADR 0009). + +Public header: +`src/laige-render/include/laige/render/text_items.h` (header-only — +no new `.cpp`, no new GL calls; the GL side is the existing +`SpriteRenderer::submit`). Unit suite: `ctest -R text_items` +(`tests/laige-render/text_items_tests.cpp`): the StringTable +contract, the width-measurement goldens (measured against the +committed test font — see +`tests/laige-render/assets/README.md`), the exact wrap model, the +per-glyph quad goldens, the group + order contract, the failure +paths, the 1 000-frame zero-allocation declare loop, and the +offscreen GL smoke (the white-on-alpha upload through the M2-SPRITE-02 +submit stage — needs a usable OpenGL 3.3 environment and `GTEST_SKIP`s +where absent). + +## The API + +```cpp +// The interned string handle (a small value; never a pointer). +struct StringRef { std::uint32_t id{0}; }; + +// The scene's string table (PRD §10.4). Move-only; the stopped state +// is the default constructor (create failed / moved-from). +class StringTable { + public: + struct Options { + std::uint32_t maxStrings{kStringTableDefaultStrings}; // 4096 + std::uint32_t maxCodePoints{kStringTableDefaultCodePoints}; // 16384 + }; + [[nodiscard]] static laige::Result + create(Options options) noexcept; + // The interning (the setup path, never per frame): returns the + // handle of the FIRST interned string with the same bytes (the + // identity property). BudgetExhausted (+ one rate-limited Warn + // `string_table/table_full`) when a pool bound is exceeded. + [[nodiscard]] laige::Result + intern(std::span codes) noexcept; + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] bool isValid(StringRef ref) const noexcept; + [[nodiscard]] std::span text(StringRef ref) const noexcept; + [[nodiscard]] std::uint32_t count() const noexcept; + [[nodiscard]] std::uint32_t storedCodePoints() const noexcept; + [[nodiscard]] std::uint32_t stringCapacity() const noexcept; + [[nodiscard]] std::uint32_t codePointCapacity() const noexcept; +}; + +enum class TextAlignment : std::uint8_t { Left, Center, Right }; + +// One text block for the UI pass (screen pixels, y down). +struct TextItem { + StringRef text{}; // the string-table handle + const GlyphAtlas* font{}; // non-owning (scene-owned) + float x{}; // the block's anchor x (by alignment) + float y{}; // the FIRST line's baseline + std::uint32_t scale{1}; // integer scale, [1, 16] + SpriteTint color{}; // default white (M2-SPRITE-02 tint) + TextAlignment alignment{TextAlignment::Left}; + std::int32_t maxWidth{0}; // 0 = no wrap; else [1, 2^20] + std::uint32_t depthKey{}; // the hand-tuned UI z (G-R11) + std::uint32_t atlasId{}; // the scene's bindAtlas id + std::uint32_t materialId{}; // 0 = the default material + BlendMode blend{BlendMode::Alpha}; // the text convention +}; + +// The no-wrap ink-block width in screen px (O(glyphs)). +[[nodiscard]] laige::Result measureText( + const GlyphAtlas& font, const StringTable& table, StringRef text, + std::uint32_t scale) noexcept; + +// The declaration (O(glyphs) quads into the batcher; the frame +// protocol is the batcher's — see the section below). +[[nodiscard]] laige::Status declareText( + SpriteBatcher& batcher, const StringTable& table, TextItem item) noexcept; + +// The 8-bit alpha atlas -> white-on-alpha RGBA8 upload bytes +// (the M2-SPRITE-02 `bindAtlas` input; the setup path). +[[nodiscard]] laige::Status expandGlyphAtlasRgba8( + const GlyphAtlas& atlas, std::span rgba) noexcept; +``` + +Constants: `kStringTableMaxStrings` / `kStringTableMaxCodePoints` +(both `2^24`), `kStringTableDefaultStrings` (4096), +`kStringTableDefaultCodePoints` (16384), `kTextMaxScale` (16), +`kTextMaxLineWidthPx` (`2^20` px — a 20-bit screen dimension), +`kTextSpaceCode` (U+0020, the wrap separator). + +## The string table (PRD §10.4) + +`StringTable` stores the game's text as **u32 code points** (one per +code point — the `glyph(code)` domain, M2-TEXT-01; UTF-8/UTF-16 +sources convert to this once, at string load, never per frame). +`create` makes exactly two allocations (a header table + a code-point +pool, sized by the budgets); `intern` is **idempotent** — the same +bytes return the first interned string's handle (the identity +property the layout pass relies on: shared strings share one pool +range). The pools are bounded: a string count over `maxStrings` or a +pool over `maxCodePoints` is `BudgetExhausted` with one rate-limited +Warn `string_table/table_full` (fields `limit` = `"strings"` / +`"code_points"`, `capacity`). A stopped table (default-constructed, +moved-from) rejects `intern` with `InvalidArgument`, no log, and +`text` reads empty — the handle is invalid. + +The text is a **UNICODE code-point sequence**; the font's range +(default Latin-1, M2-TEXT-01) decides what renders — out-of-range +codes fall back to the space glyph (M2-TEXT-01's documented +contract), never to a re-rasterization. + +## The model (documented units) + +All layout values are **SCREEN PIXELS, screen space, y DOWN** (the UI +pass — the M2-UI-02 identity screen matrix maps them to NDC; nothing +world-space leaks in, RENDER-006). `TextItem.scale` is an **INTEGER** +scale factor in [1, `kTextMaxScale`] (the M2-TEXT-01 "integer scale" +convention): every metric is multiplied by the scale — exact, no +fractional-pixel rasterization — so every layout coordinate is an +exact integer before the one presentation-only float conversion to +the `SpriteItem` fields (ARCH-009). + +- `y` is always the **FIRST LINE'S BASELINE** (screen px, y down). +- `x` is, by `alignment`, the text block's **LEFT edge** (`Left`), + **CENTER x** (`Center`), or **RIGHT edge** (`Right`). Each line is + aligned independently against the anchor (the standard paragraph + model — every line's own width is centered / right-justified, not + the longest line's). +- **Line step:** `lineHeight() * scale` px (M2-TEXT-01's line height — + `lineHeight() == fontSize()` by the stbtt pixel-height convention). + +## The wrap model (exact, hand-testable) + +With `maxWidth == 0` the text is one line (no wrap). Otherwise the +layout walks the codes greedily: + +- **Line width = ink-block width**: the sum of the glyphs' scaled + advances, plus ONE collapsed space advance per inter-word gap + (consecutive spaces count once). Leading and trailing spaces carry + no width. +- **Word wrap:** a following word is appended when + `lineW + space + wordW ≤ maxWidth`; otherwise it breaks to the next + line. +- **Character split:** a single word WIDER than `maxWidth` is split + greedily into the longest prefixes that fit. A single character + wider than `maxWidth` takes its own line (the line is wider than + `maxWidth` — the documented oversized behavior; nothing is ever + dropped or clipped). +- The walk is a TOTAL function on a live font: empty and + all-whitespace texts lay out zero lines (declare nothing). + +## The glyph quads (the M2-SPRITE-02 quad model) + +One `SpriteItem` per **glyph with ink** (`width > 0 && height > 0` +after the M2-TEXT-01 fallback — a space declares no quad): + +- `pos` = the ink rect's center: left = lineX0 + pen + `bearingX·scale`, + top = baseline − `bearingY·scale`, size = `width·scale × height·scale` + (the batcher's centered-quad model: corner (−0.5,−0.5) → uv u0/v0). +- `uv` = the glyph's atlas cell over the atlas: + (`atlasX/W, atlasY/H, (atlasX+w)/W, (atlasY+h)/H`) — the M2-TEXT-01 + oracle rect. The atlas's row 0 is its TOP (the shelf packing), and + the quad's top corner maps to v0, so the glyph draws upright with no + flip (the M2-SPRITE-02 v-axis convention). +- `scale` = the ink size; `rotation` = 0; `frameIndex` = 0; `tint` = + the item's color (the white-on-alpha upload makes the drawn color + `color × glyph alpha`); `atlasId` / `materialId` / `blend` are the + item's fields (the group key, FR-2.1 — one draw call per group). +- `depthOverride = true` on every text quad: the UI pass is + screen-space, so the hand-tuned `depthKey` IS the documented order + value (the G-R11 escape hatch — counted + warned at build, the + sprite_batcher.h contract). + +## The frame protocol (the batcher's) + +`declareText` appends the item's quads to the batcher's open frame +window (the `beginFrame`/`add`/`build` protocol — M2-SPRITE-01). +Before the first quad is appended, a **pre-check walk** lays out every +line: any line wider than `kTextMaxLineWidthPx` (the screen domain +guard) fails the call `InvalidArgument` with **nothing declared** +(the window is untouched). After that, the emit walk re-walks the same +lines and appends one quad per ink glyph; a batcher failure (stopped +→ `BudgetExhausted`, closed window → `InvalidArgument`, overflow → +the batcher's drop-oldest + Warn, PERF-008) ends the call — already +declared quads stay in the frame (the `declareParticles` precedent). + +`measureText(font, table, ref, scale)` is the no-wrap width query +(O(glyphs), zero alloc): the ink-block width of one line at the scale. +It is the layout half's cheap probe (UI hit-testing, menu sizing). + +## The white-on-alpha upload (M2-SPRITE-02) + +`expandGlyphAtlasRgba8(atlas, rgba)` fills a caller-owned `W·H·4` byte +buffer with `(255, 255, 255, alpha)` for every texel — the glyph atlas +as a **white-on-alpha RGBA8** texture. Call it once per font per +scene (the setup path), then `SpriteRenderer::bindAtlas(id, W, H, +rgba)` (M2-SPRITE-02). The shader's `texture(uAtlas, uv) * tint` then +draws the glyph at `color × alpha` (the alpha blend, the text +convention). Wrong-size span or a stopped atlas → `InvalidArgument`, +nothing written. + +## Ownership, threading (CORE-009, CONC-001) + +- `StringTable` owns its two pools (move-only; the moved-from table is + stopped — `valid()` false). The scene set-up thread creates + + interns; the render phase reads it (`text`, `isValid` — read-only, + no mutation after set-up). +- `TextItem.font` is a **non-owning pointer** to the scene's + `GlyphAtlas` (M2-TEXT-01) — it must outlive the frame. +- `declareText` mutates only the batcher (the frame window) and reads + the table + atlas. One owner thread per batcher, as always + (M2-SPRITE-01). +- Presentation-only (ARCH-009): text is never sim state, never in the + sim hash or replay. No GL calls anywhere in this API (the GL is the + M2-SPRITE-02 submit stage). + +## Performance (DOC-004) + +- `StringTable::create` (set-up, once per scene): 2 allocations + (`maxStrings · 8 B` headers + `maxCodePoints · 4 B` pool — 64 KB at + the defaults). `intern` (set-up, per unique string): O(count · len) + memcmp dedup scan + O(len) copy — the setup path, never per frame. + Zero alloc after the pool is full (the bounded-failure path). +- `measureText` / `declareText` (render declare path, per text item + per frame): **O(glyphs)**, zero allocation, zero logging, zero GL — + integer sums over the string's codes + one quad append per ink + glyph. The zero-alloc test: 1 000 frames of a wrapped 2x-scale + declare = 0 heap blocks. +- No `budgets.json` entry: the per-frame text cost is part of the + composite 50k render-CPU budget, measured with M2-PERF-01. + +**Common trap:** interning or measuring per frame. `intern` and the +dedup scan are set-up work (PRD §10.4) — the frame path is the handle +plus the O(glyphs) walk. A per-frame `std::string` in a UI item is +exactly the allocation this module exists to remove. + +## Determinism (ARCH-010) + +The layout is a pure function of (string, font metrics, anchor, +scale, alignment, maxWidth) — exact integer arithmetic (the scaled +advances are integers; the only float is the final presentation +conversion). Same string + same atlas → same quads, on every +platform. The wrap model, the alignment offsets, and the quad fields +are all hand-computed goldens in the unit suite. + +## Example (performant path) + +```cpp +// Scene set-up (once): intern the UI strings, build the atlas, upload +// it (M2-SPRITE-02), and keep the StringTable for the frame pipeline. +laige::render::StringTable strings = + laige::render::StringTable::create({}); // the defaults +std::vector hello{'H', 'e', 'l', 'l', 'o'}; +auto r = strings.intern(hello); // the handle, reused every frame +auto atlas = laige::render::GlyphAtlas::create(fontBytes, + laige::render::GlyphAtlas::Options{}); +std::vector rgba; +rgba.resize(1024u * 1024u * 4u); +laige::render::expandGlyphAtlasRgba8(atlas, rgba); +renderer.bindAtlas(/*id*/, 1024, 1024, rgba); + +// Per frame (the UI declaration pass): one TextItem per visible block. +laige::render::TextItem item; +item.text = r.value(); +item.font = &atlas; +item.x = 8.0f; item.y = 24.0f; // the first line's baseline +item.scale = 1; +item.color = {1.0f, 1.0f, 1.0f, 1.0f}; +item.maxWidth = 220; // wrap at 220 px +item.atlasId = /*the scene's atlas id*/; +laige::render::declareText(batcher, strings, item); // then build() +``` + +## Misuse warnings + +- Do not intern per frame (set-up path — PRD §10.4). +- Do not free the font atlas before the frame's `submit` (the + `TextItem.font` span is non-owning). +- Do not expect `maxWidth` to clip: an oversized single character + takes its own wider line (the documented behavior). +- Do not reuse a `StringRef` from a different table (handles are + table-local; the invalid-handle read is empty, by contract). +- Do not set `scale` outside [1, 16] or `maxWidth` outside 0 / + [1, `kTextMaxLineWidthPx`] (InvalidArgument, nothing declared). + +## Related + +- [`font.md`](font.md) — the glyph atlas this pass consumes + (M2-TEXT-01). +- [`sprite_batcher.md`](sprite_batcher.md) — the batcher the text + quads are declared into. +- [`sprite_renderer.md`](sprite_renderer.md) — the submit stage the + quads render through (the white-on-alpha blend, M2-SPRITE-02). +- [`particle_render.md`](particle_render.md) — the sibling + render-phase declaration pass (the same batcher pattern). +- `docs/concepts/coordinates.md` §4.12 + §5 — the text coordinate + row (screen space, y down). diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index 87ad8e9..40d6fa0 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -435,6 +435,23 @@ deterministic sequence) — fpx16_16 bit-identical across platforms/builds, fp32_pinned same-build/same-platform. +### 4.12 The text: screen-space UI quads (M2-TEXT-02) + +The text items ([`text_items.md`](../api/text_items.md), the UI-pass +text half of FR-2.8) live in **SCREEN space, y DOWN** — the UI pass's +coordinate system, not world or sim space. The anchor is +`TextItem.x` / `TextItem.y` (the first line's baseline, by the item's +alignment), the line step is `lineHeight() * scale` px (M2-TEXT-01), +and `scale` is an INTEGER factor in [1, 16] (every metric is multiplied +by the scale — exact, no fractional-pixel rasterization; the only +float is the final presentation conversion, ARCH-009). Each glyph with +ink becomes one `SpriteItem` in the batcher (the M2-SPRITE-02 quad +model: centered quad, the atlas cell's uv rect, the hand-tuned UI z +through `depthOverride` — the G-R11 escape hatch). Text is +presentation-only (ARCH-009): never sim state, never in the sim hash +or replay; the string bytes come from the scene's string table +(PRD §10.4 — no per-frame `std::string`). + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | @@ -448,6 +465,7 @@ fp32_pinned same-build/same-platform. | Tile grid → tile quads | tile data + table keys (+ the animation's frame state; the parallax layer's `worldOffset`) → declared sprites | `laige-render` (`TileMap::declareTo` — static + animated frames, the parallax tile layer overload, M2-TILE-01/02) | **Shipped (M2-TILE-01 + M2-TILE-02)** | | Camera position → parallax offset | camera (x, y) + layer def → world-space offset (the exact formula) + wrap quads | `laige-render` (`ParallaxLayers::worldOffsetAt` / `declareTo`, M2-PAR-01) | **Shipped (M2-PAR-01)** | | Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | `laige-render` (`declareParticles` / `particleDepthToStepHeight` — consumes `ParticleSystem::liveParticles()`, read-only after the sim phase, M2-PART-02) | **Shipped (M2-PART-02)** | +| Text item → glyph quads | string handle + metrics + anchor (screen px, y down) → declared glyph quads (one per ink glyph; the UI z through `depthOverride`) | `laige-render` (`measureText` / `declareText` — consumes `GlyphAtlas::glyph` + the string table, M2-TEXT-02) | **Shipped (M2-TEXT-02)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -566,6 +584,10 @@ state → same keys → same order, every frame (RENDER-003). particle → sprite pass — the engine-owned depth key from the particle's depth, the exact fade through the tint, one draw call per emitter set) (M2-PART-02). +- [`api/text_items.md`](../api/text_items.md) — the text-item + contract: `declareText` (the O(glyphs) string → glyph-quad pass — + screen space y down, the exact wrap model, the white-on-alpha + upload) (M2-TEXT-02). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index 9497eef..d2d7323 100644 --- a/laige-api.json +++ b/laige-api.json @@ -29,6 +29,7 @@ "src/laige-render/include/laige/render/sprite_batcher.h", "src/laige-render/include/laige/render/sprite_frames.h", "src/laige-render/include/laige/render/sprite_renderer.h", + "src/laige-render/include/laige/render/text_items.h", "src/laige-render/include/laige/render/tilemap.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", @@ -509,49 +510,49 @@ {"name": "laige::render::DepthSort::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/depth_sort.h", "line": 261, "signature": "DepthSort& operator=(const DepthSort&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::DepthSort::DepthSort", "kind": "constructor", "header": "src/laige-render/include/laige/render/depth_sort.h", "line": 267, "signature": "DepthSort(DepthSort&& other) noexcept", "summary": "Explicit move (the raw buffer pointers make the defaulted move unsafe for the moved-from object — it would dangle into the destination, the IsoDepthKeyTable pattern): the owner moves with the storage; the source becomes empty (buffers null, count 0, capacity 0 — CORE-009).", "budget": null, "experimental": false}, {"name": "laige::render::DepthSort::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/depth_sort.h", "line": 268, "signature": "DepthSort& operator=(DepthSort&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kFontMaxAtlasDimension", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 145, "signature": "inline constexpr std::uint32_t kFontMaxAtlasDimension = 4096", "summary": "Named constants (CORE-005).", "budget": null, "experimental": false}, - {"name": "laige::render::kFontMaxFontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 150, "signature": "inline constexpr std::uint32_t kFontMaxFontSize = 1024", "summary": "The configured pixel-height domain: [1, kFontMaxFontSize] px at 1x. 1024 px is beyond any practical UI text size; the rasterizer's float domain (the pixel box stays < 2^31 for every unitsPerEm) is never approached.", "budget": null, "experimental": false}, - {"name": "laige::render::kFontMaxGlyphCode", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 155, "signature": "inline constexpr std::uint32_t kFontMaxGlyphCode = 0x10FFFF", "summary": "The glyph-range domain: codepoints up to U+10FFFF (the Unicode ceiling); the RANGE SIZE is bounded by kFontMaxGlyphCount (a full 1.1M-codepoint range would rasterize minutes of set-up work — the documented budget guardrail, PERF-008).", "budget": null, "experimental": false}, - {"name": "laige::render::kFontMaxGlyphCount", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 156, "signature": "inline constexpr std::uint32_t kFontMaxGlyphCount = 8192", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kFontDefaultAtlasWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 160, "signature": "inline constexpr std::uint32_t kFontDefaultAtlasWidth = 1024", "summary": "The defaults (the Latin-1 set, the FR-2.8 P0 scope: 32..126 printable ASCII + 160..255 the Latin-1 supplement; 127..159 C1 controls rasterize to the font's .notdef — harmless, deterministic).", "budget": null, "experimental": false}, - {"name": "laige::render::kFontDefaultAtlasHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 161, "signature": "inline constexpr std::uint32_t kFontDefaultAtlasHeight = 1024", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kFontDefaultFontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 162, "signature": "inline constexpr std::uint32_t kFontDefaultFontSize = 16", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kFontDefaultGlyphFirst", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 163, "signature": "inline constexpr std::uint32_t kFontDefaultGlyphFirst = 32", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kFontDefaultGlyphLast", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 164, "signature": "inline constexpr std::uint32_t kFontDefaultGlyphLast = 255", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::kFontFallbackCode", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 166, "signature": "inline constexpr std::uint32_t kFontFallbackCode = 32", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics", "kind": "struct", "header": "src/laige-render/include/laige/render/font.h", "line": 171, "signature": "struct GlyphMetrics", "summary": "Per-glyph metrics + the atlas cell origin (px at 1x, the header preamble). Plain value: no ownership, no lifetime beyond the atlas that stores it.", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::advance", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 172, "signature": "std::int32_t advance{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::bearingX", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 173, "signature": "std::int32_t bearingX{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::bearingY", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 174, "signature": "std::int32_t bearingY{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::width", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 175, "signature": "std::int32_t width{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::height", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 176, "signature": "std::int32_t height{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::atlasX", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 177, "signature": "std::uint32_t atlasX{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::atlasY", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 178, "signature": "std::uint32_t atlasY{0}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphMetrics::operator==", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 182, "signature": "[[nodiscard]] bool operator==(const GlyphMetrics& other) const noexcept", "summary": "Field-wise equality (the deterministic-comparison test needs it; the house idiom — no synthesized ==, the SpriteUvRect precedent).", "budget": null, "experimental": false}, - {"name": "laige::render::kFontEmptyGlyphMetrics", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 192, "signature": "inline constexpr GlyphMetrics kFontEmptyGlyphMetrics{}", "summary": "The zero metric: the out-of-range fallback when the configured glyph range does not include the fallback code (the header preamble).", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas", "kind": "class", "header": "src/laige-render/include/laige/render/font.h", "line": 195, "signature": "class GlyphAtlas", "summary": "The configured bitmap glyph atlas (M2-TEXT-01).", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/font.h", "line": 199, "signature": "struct Options", "summary": "The create options (API-006): see the header preamble for the documented units.", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::Options::atlasWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 200, "signature": "std::uint32_t atlasWidth{kFontDefaultAtlasWidth}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::Options::atlasHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 201, "signature": "std::uint32_t atlasHeight{kFontDefaultAtlasHeight}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::Options::fontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 202, "signature": "std::uint32_t fontSize{kFontDefaultFontSize}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::Options::glyphFirst", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 203, "signature": "std::uint32_t glyphFirst{kFontDefaultGlyphFirst}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::Options::glyphLast", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 204, "signature": "std::uint32_t glyphLast{kFontDefaultGlyphLast}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::create", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 221, "signature": "[[nodiscard]] static laige::Result create(std::span font, Options options) noexcept", "summary": "Rasterizes the configured glyph range of `font` into the atlas (set-up path — the only allocations: the atlas bytes + the slot table). Validation order, first failure wins: 1. atlasWidth/atlasHeight in [1, kFontMaxAtlasDimension], fontSize in [1, kFontMaxFontSize], glyphFirst ≤ glyphLast ≤ kFontMaxGlyphCode, (glyphLast − glyphFirst + 1) ≤ kFontMaxGlyphCount (InvalidArgument, no log — the M2-SPRITE-01 create precedent); 2. the font bytes: a non-font / truncated / corrupt file → MalformedInput + one Error `font/font_invalid`; 3. a glyph whose cell does not fit the fixed atlas → BudgetExhausted + one Error `font/atlas_full` (fields: the codepoint, its cell size, the atlas size). A failed create returns the error; no atlas is produced.", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::GlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font.h", "line": 227, "signature": "GlyphAtlas() noexcept = default", "summary": "The stopped state (default / moved-from): `valid()` false, `glyph()` returns null, `atlas()` empty, introspection zero — the RenderThread stopped-state precedent. Nothing owned.", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::GlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font.h", "line": 229, "signature": "GlyphAtlas(const GlyphAtlas&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 230, "signature": "GlyphAtlas& operator=(const GlyphAtlas&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::GlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font.h", "line": 231, "signature": "GlyphAtlas(GlyphAtlas&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 232, "signature": "GlyphAtlas& operator=(GlyphAtlas&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::~GlyphAtlas", "kind": "destructor", "header": "src/laige-render/include/laige/render/font.h", "line": 233, "signature": "~GlyphAtlas() noexcept = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::valid", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 236, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the atlas is live (create succeeded).", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::atlasWidth", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 239, "signature": "[[nodiscard]] std::uint32_t atlasWidth() const noexcept", "summary": "The atlas dimensions (texels; zero in the stopped state).", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::atlasHeight", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 240, "signature": "[[nodiscard]] std::uint32_t atlasHeight() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::fontSize", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 243, "signature": "[[nodiscard]] std::uint32_t fontSize() const noexcept", "summary": "The configured pixel height, px at 1x.", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::lineHeight", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 247, "signature": "[[nodiscard]] std::uint32_t lineHeight() const noexcept", "summary": "The line height, px at 1x: `fontSize()` (the stbtt pixel-height convention — the header preamble).", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::glyphFirst", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 250, "signature": "[[nodiscard]] std::uint32_t glyphFirst() const noexcept", "summary": "The configured glyph range (inclusive).", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::glyphLast", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 251, "signature": "[[nodiscard]] std::uint32_t glyphLast() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::atlas", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 257, "signature": "[[nodiscard]] std::span atlas() const noexcept", "summary": "The atlas bytes: 8-bit alpha, row-major, `atlasWidth() × atlasHeight()` texels (empty in the stopped state). The render read path: the M2-TEXT-02 pass uploads this as a white-on-alpha RGBA8 atlas (the M2-SPRITE-02 `bindAtlas` contract). O(1); never writes.", "budget": null, "experimental": false}, - {"name": "laige::render::GlyphAtlas::glyph", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 267, "signature": "[[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept", "summary": "The metrics for `code` (the header preamble: a total function — null only in the stopped state; the in-range slot, else the documented fallback). O(1); no allocation, no logging, no GL. The returned pointer is valid until this atlas is moved/destroyed (the slot table is member storage, never reallocated after create).", "budget": null, "experimental": false}, + {"name": "laige::render::kFontMaxAtlasDimension", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 147, "signature": "inline constexpr std::uint32_t kFontMaxAtlasDimension = 4096", "summary": "Named constants (CORE-005).", "budget": null, "experimental": false}, + {"name": "laige::render::kFontMaxFontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 152, "signature": "inline constexpr std::uint32_t kFontMaxFontSize = 1024", "summary": "The configured pixel-height domain: [1, kFontMaxFontSize] px at 1x. 1024 px is beyond any practical UI text size; the rasterizer's float domain (the pixel box stays < 2^31 for every unitsPerEm) is never approached.", "budget": null, "experimental": false}, + {"name": "laige::render::kFontMaxGlyphCode", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 157, "signature": "inline constexpr std::uint32_t kFontMaxGlyphCode = 0x10FFFF", "summary": "The glyph-range domain: codepoints up to U+10FFFF (the Unicode ceiling); the RANGE SIZE is bounded by kFontMaxGlyphCount (a full 1.1M-codepoint range would rasterize minutes of set-up work — the documented budget guardrail, PERF-008).", "budget": null, "experimental": false}, + {"name": "laige::render::kFontMaxGlyphCount", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 158, "signature": "inline constexpr std::uint32_t kFontMaxGlyphCount = 8192", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kFontDefaultAtlasWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 162, "signature": "inline constexpr std::uint32_t kFontDefaultAtlasWidth = 1024", "summary": "The defaults (the Latin-1 set, the FR-2.8 P0 scope: 32..126 printable ASCII + 160..255 the Latin-1 supplement; 127..159 C1 controls rasterize to the font's .notdef — harmless, deterministic).", "budget": null, "experimental": false}, + {"name": "laige::render::kFontDefaultAtlasHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 163, "signature": "inline constexpr std::uint32_t kFontDefaultAtlasHeight = 1024", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kFontDefaultFontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 164, "signature": "inline constexpr std::uint32_t kFontDefaultFontSize = 16", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kFontDefaultGlyphFirst", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 165, "signature": "inline constexpr std::uint32_t kFontDefaultGlyphFirst = 32", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kFontDefaultGlyphLast", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 166, "signature": "inline constexpr std::uint32_t kFontDefaultGlyphLast = 255", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kFontFallbackCode", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 168, "signature": "inline constexpr std::uint32_t kFontFallbackCode = 32", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics", "kind": "struct", "header": "src/laige-render/include/laige/render/font.h", "line": 173, "signature": "struct GlyphMetrics", "summary": "Per-glyph metrics + the atlas cell origin (px at 1x, the header preamble). Plain value: no ownership, no lifetime beyond the atlas that stores it.", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::advance", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 174, "signature": "std::int32_t advance{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::bearingX", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 175, "signature": "std::int32_t bearingX{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::bearingY", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 176, "signature": "std::int32_t bearingY{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::width", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 177, "signature": "std::int32_t width{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::height", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 178, "signature": "std::int32_t height{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::atlasX", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 179, "signature": "std::uint32_t atlasX{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::atlasY", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 180, "signature": "std::uint32_t atlasY{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphMetrics::operator==", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 184, "signature": "[[nodiscard]] bool operator==(const GlyphMetrics& other) const noexcept", "summary": "Field-wise equality (the deterministic-comparison test needs it; the house idiom — no synthesized ==, the SpriteUvRect precedent).", "budget": null, "experimental": false}, + {"name": "laige::render::kFontEmptyGlyphMetrics", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 194, "signature": "inline constexpr GlyphMetrics kFontEmptyGlyphMetrics{}", "summary": "The zero metric: the out-of-range fallback when the configured glyph range does not include the fallback code (the header preamble).", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas", "kind": "class", "header": "src/laige-render/include/laige/render/font.h", "line": 197, "signature": "class GlyphAtlas", "summary": "The configured bitmap glyph atlas (M2-TEXT-01).", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/font.h", "line": 201, "signature": "struct Options", "summary": "The create options (API-006): see the header preamble for the documented units.", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::Options::atlasWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 202, "signature": "std::uint32_t atlasWidth{kFontDefaultAtlasWidth}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::Options::atlasHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 203, "signature": "std::uint32_t atlasHeight{kFontDefaultAtlasHeight}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::Options::fontSize", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 204, "signature": "std::uint32_t fontSize{kFontDefaultFontSize}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::Options::glyphFirst", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 205, "signature": "std::uint32_t glyphFirst{kFontDefaultGlyphFirst}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::Options::glyphLast", "kind": "variable", "header": "src/laige-render/include/laige/render/font.h", "line": 206, "signature": "std::uint32_t glyphLast{kFontDefaultGlyphLast}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::create", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 223, "signature": "[[nodiscard]] static laige::Result create(std::span font, Options options) noexcept", "summary": "Rasterizes the configured glyph range of `font` into the atlas (set-up path — the only allocations: the atlas bytes + the slot table). Validation order, first failure wins: 1. atlasWidth/atlasHeight in [1, kFontMaxAtlasDimension], fontSize in [1, kFontMaxFontSize], glyphFirst ≤ glyphLast ≤ kFontMaxGlyphCode, (glyphLast − glyphFirst + 1) ≤ kFontMaxGlyphCount (InvalidArgument, no log — the M2-SPRITE-01 create precedent); 2. the font bytes: a non-font / truncated / corrupt file → MalformedInput + one Error `font/font_invalid`; 3. a glyph whose cell does not fit the fixed atlas → BudgetExhausted + one Error `font/atlas_full` (fields: the codepoint, its cell size, the atlas size). A failed create returns the error; no atlas is produced.", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::GlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font.h", "line": 229, "signature": "GlyphAtlas() noexcept = default", "summary": "The stopped state (default / moved-from): `valid()` false, `glyph()` returns null, `atlas()` empty, introspection zero — the RenderThread stopped-state precedent. Nothing owned.", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::GlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font.h", "line": 231, "signature": "GlyphAtlas(const GlyphAtlas&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 232, "signature": "GlyphAtlas& operator=(const GlyphAtlas&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::GlyphAtlas", "kind": "constructor", "header": "src/laige-render/include/laige/render/font.h", "line": 233, "signature": "GlyphAtlas(GlyphAtlas&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 234, "signature": "GlyphAtlas& operator=(GlyphAtlas&&) noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::~GlyphAtlas", "kind": "destructor", "header": "src/laige-render/include/laige/render/font.h", "line": 235, "signature": "~GlyphAtlas() noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::valid", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 238, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the atlas is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::atlasWidth", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 241, "signature": "[[nodiscard]] std::uint32_t atlasWidth() const noexcept", "summary": "The atlas dimensions (texels; zero in the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::atlasHeight", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 242, "signature": "[[nodiscard]] std::uint32_t atlasHeight() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::fontSize", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 245, "signature": "[[nodiscard]] std::uint32_t fontSize() const noexcept", "summary": "The configured pixel height, px at 1x.", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::lineHeight", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 249, "signature": "[[nodiscard]] std::uint32_t lineHeight() const noexcept", "summary": "The line height, px at 1x: `fontSize()` (the stbtt pixel-height convention — the header preamble).", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::glyphFirst", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 252, "signature": "[[nodiscard]] std::uint32_t glyphFirst() const noexcept", "summary": "The configured glyph range (inclusive).", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::glyphLast", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 253, "signature": "[[nodiscard]] std::uint32_t glyphLast() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::atlas", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 259, "signature": "[[nodiscard]] std::span atlas() const noexcept", "summary": "The atlas bytes: 8-bit alpha, row-major, `atlasWidth() × atlasHeight()` texels (empty in the stopped state). The render read path: the text_items.h (M2-TEXT-02) uploads this as a white-on-alpha RGBA8 atlas (the M2-SPRITE-02 `bindAtlas` contract). O(1); never writes.", "budget": null, "experimental": false}, + {"name": "laige::render::GlyphAtlas::glyph", "kind": "method", "header": "src/laige-render/include/laige/render/font.h", "line": 269, "signature": "[[nodiscard]] const GlyphMetrics* glyph(std::uint32_t code) const noexcept", "summary": "The metrics for `code` (the header preamble: a total function — null only in the stopped state; the in-range slot, else the documented fallback). O(1); no allocation, no logging, no GL. The returned pointer is valid until this atlas is moved/destroyed (the slot table is member storage, never reallocated after create).", "budget": null, "experimental": false}, {"name": "laige::render::FrameDescriptor", "kind": "struct", "header": "src/laige-render/include/laige/render/frame_pipeline.h", "line": 261, "signature": "struct FrameDescriptor", "summary": "One presentation frame's handed-off data (PRD §10.2: the frame descriptor of the lock-free handoff). A 32-byte POD — one plain copy per publish (PERF-005: no pointer graph, no allocation).", "budget": null, "experimental": false}, {"name": "laige::render::FrameDescriptor::frameIndex", "kind": "variable", "header": "src/laige-render/include/laige/render/frame_pipeline.h", "line": 264, "signature": "std::uint64_t frameIndex{}", "summary": "The producer's 1-based frame counter (see the header ownership section).", "budget": null, "experimental": false}, {"name": "laige::render::FrameDescriptor::simTick", "kind": "variable", "header": "src/laige-render/include/laige/render/frame_pipeline.h", "line": 267, "signature": "std::uint64_t simTick{}", "summary": "The last completed sim tick at publish time (0 before the first tick — the GameLoop's currentTick() value).", "budget": null, "experimental": false}, @@ -949,6 +950,56 @@ {"name": "laige::render::SpriteRenderer::frameStats", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 379, "signature": "[[nodiscard]] SpriteDrawStats frameStats() const noexcept", "summary": "The per-frame counters of the last SUCCESSFUL submit (a failed submit reads zero — it zeroes them). O(1).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteRenderer::totals", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 382, "signature": "[[nodiscard]] SpriteDrawTotals totals() const noexcept", "summary": "The since-construction totals (successful submits only). O(1).", "budget": null, "experimental": false}, {"name": "laige::render::SpriteRenderer::textureMemoryBytes", "kind": "method", "header": "src/laige-render/include/laige/render/sprite_renderer.h", "line": 391, "signature": "[[nodiscard]] std::uint64_t textureMemoryBytes() const noexcept", "summary": "The VRAM estimate of the atlas registry (M2-SPRITE-04, the RENDER-001 texture-memory feed): the sum of width * height * 4 bytes over the BOUND atlases (GL_RGBA8 — the upload's byte count). Updated at bindAtlas (a re-bind replaces: the old texture's bytes are subtracted, the new ones added); reads 0 in the stopped state. A GAUGE (not a per-frame counter) — it changes only on the set-up/asset path, never per frame. O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableMaxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 288, "signature": "inline constexpr std::uint32_t kStringTableMaxStrings = 1u << 24", "summary": "The string-table pool domains: [1, 2^24] each (16 777 216 strings / code points — 64 MiB of pool at the ceiling, far beyond any UI string budget; the 24-bit width is the index domain of the header table).", "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableMaxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 289, "signature": "inline constexpr std::uint32_t kStringTableMaxCodePoints = 1u << 24", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableDefaultStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 292, "signature": "inline constexpr std::uint32_t kStringTableDefaultStrings = 4096", "summary": "The documented defaults (a typical UI scene: a few hundred distinct strings, a few thousand code points — 64 KiB of pool).", "budget": null, "experimental": false}, + {"name": "laige::render::kStringTableDefaultCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 293, "signature": "inline constexpr std::uint32_t kStringTableDefaultCodePoints = 16384", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kTextMaxScale", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 300, "signature": "inline constexpr std::uint32_t kTextMaxScale = 16", "summary": "The integer scale domain (the M2-TEXT-01 \"integer scale\" convention): [1, kTextMaxScale]. 16 × the 1024 px font ceiling is a 16 384 px line height — beyond any practical UI text (the layout math is int64; the 2^20 px line-width domain below is the representability guard).", "budget": null, "experimental": false}, + {"name": "laige::render::kTextMaxLineWidthPx", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 307, "signature": "inline constexpr std::uint32_t kTextMaxLineWidthPx = 1u << 20", "summary": "The line-width / max-width domain in screen px: [1, 2^20]. A 20-bit screen dimension — far beyond any UI panel, and < 2^24 so every layout coordinate inside the domain is exact in float (the M2-SPRITE-03 float-exact domain). A line wider than this is unrepresentable (the header's screen-domain section).", "budget": null, "experimental": false}, + {"name": "laige::render::kTextSpaceCode", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 311, "signature": "inline constexpr std::uint32_t kTextSpaceCode = 0x20", "summary": "The ONLY word separator in the wrap model (U+0020 SPACE — the header's wrap section; U+00A0 and friends are ordinary characters).", "budget": null, "experimental": false}, + {"name": "laige::render::StringRef", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 322, "signature": "struct StringRef", "summary": "A stable handle into one `StringTable` (the engine-owned string storage). Plain value: the id is dense from 0, stable for the table's lifetime (the table never moves, frees, or reallocates after create — CORE-007). No generation counter is needed (nothing is ever destroyed — the pool is fixed at create).", "budget": null, "experimental": false}, + {"name": "laige::render::StringRef::id", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 323, "signature": "std::uint32_t id{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable", "kind": "class", "header": "src/laige-render/include/laige/render/text_items.h", "line": 334, "signature": "class StringTable", "summary": "The fixed pool of interned u32 code-point strings (the header's model + failure + performance sections). Move-only (CORE-009); the default state is stopped (nothing owned; `intern` fails, `text` reads empty).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/text_items.h", "line": 340, "signature": "struct Options", "summary": "The table budget (API-006): both fields in [1, kStringTableMax*] (validated at create — first failure wins, InvalidArgument, no allocation). `maxCodePoints` bounds the TOTAL code points stored (across all strings); `maxStrings` the distinct string count.", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Options::maxStrings", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 341, "signature": "std::uint32_t maxStrings{kStringTableDefaultStrings}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::Options::maxCodePoints", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 342, "signature": "std::uint32_t maxCodePoints{kStringTableDefaultCodePoints}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 347, "signature": "StringTable() noexcept = default", "summary": "The stopped state (default / moved-from): valid() false, intern -> InvalidArgument, text reads empty, introspection zero.", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::create", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 352, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "The set-up path (scene load): two allocations (the 8 B header per string + the 4 B pool per code point — the header's performance section).", "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::StringTable", "kind": "constructor", "header": "src/laige-render/include/laige/render/text_items.h", "line": 355, "signature": "StringTable(const StringTable&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::StringTable::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/text_items.h", "line": 356, "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": 360, "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": 372, "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": 378, "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": 381, "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": 396, "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": 401, "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": 408, "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": 417, "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": 419, "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": 423, "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": 426, "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": 434, "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": 435, "signature": "std::uint32_t length{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::TextAlignment", "kind": "enum", "header": "src/laige-render/include/laige/render/text_items.h", "line": 523, "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": 525, "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": 527, "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": 529, "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": 538, "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": 541, "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": 544, "signature": "const GlyphAtlas* font{}", "summary": "The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must outlive the frame).", "budget": null, "experimental": false}, + {"name": "laige::render::TextItem::x", "kind": "variable", "header": "src/laige-render/include/laige/render/text_items.h", "line": 547, "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": 549, "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": 553, "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": 557, "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": 559, "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": 563, "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": 568, "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": 571, "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": 574, "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": 577, "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": 684, "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::declareText", "kind": "function", "header": "src/laige-render/include/laige/render/text_items.h", "line": 717, "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": 852, "signature": "[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( const GlyphAtlas& atlas, std::span rgba) noexcept", "summary": "Expands the 8-bit alpha glyph atlas into the white-on-alpha RGBA8 bytes the M2-SPRITE-02 `bindAtlas` uploads (one call per font per scene load — the SETUP/asset path, before the one-time texture upload, RENDER-004). Every texel becomes (255, 255, 255, alpha): the M2-SPRITE-02 shader's multiplicative tint then makes the drawn color = tint × glyph alpha (the header's glyph-quad section — the standard text coloring). O(W·H); no allocation (the caller owns the span — one W·H·4-byte buffer at set-up), no logging, no GL.", "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 5211285..4590de3 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -274,7 +274,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. committed Vera test font, the deterministic-atlas bytes, the fallback matrix, the 1000-lookup zero-alloc window). -- [ ] **M2-TEXT-02 · Text items in the UI pass** +- [x] **M2-TEXT-02 · Text items in the UI pass** - **Refs:** FR-2.8 (text widget, screen-space pass) - **Depends:** M2-TEXT-01, M2-SPRITE-02 - **Scope:** @@ -283,6 +283,27 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Unit tests: width measurement golden-checked for known strings; wrap at max-width exact; empty/oversized string behavior documented. - **Verify:** `ctest -R text_items` green. - **Size:** ~200 lines + tests + - **Shipped:** M2-TEXT-02 (`laige/render/text_items.h`, + header-only, no GL calls): the scene's `StringTable` (interned + u32 code points, two budgeted allocations, idempotent `intern`, + `BudgetExhausted` + one rate-limited Warn `string_table/table_full` + at the pool bounds) feeds `TextItem` (first line's baseline + anchor, the integer scale in [1, 16], left/center/right per-line + alignment, `maxWidth` word wrap with the exact collapsed-space / + character-split / oversized-character model) into one batched + `SpriteItem` per ink glyph (the centered quad at the ink center, + the atlas cell's uv rect, `depthOverride = true` with the + hand-typed UI z — the G-R11 escape hatch); one (atlas, material, + blend) group = one instanced draw call (RENDER-001). + `measureText` is the no-wrap O(glyphs) width probe; + `expandGlyphAtlasRgba8` fills the white-on-alpha RGBA8 upload + (the M2-SPRITE-02 `bindAtlas` input). A failed `declareText` + declares nothing (the pre-check walks every line first). The + declare path is O(glyphs), zero allocation, zero logging, zero + GL (the `text_items` suite: StringTable contract, width + goldens against the committed Vera test font, the exact wrap + model, the per-glyph quad goldens, the failure paths, the + 1000-frame zero-alloc window, the offscreen GL smoke). - [ ] **M2-TEXT-03 · SDF font rendering (P1)** - **Refs:** FR-2.8 (SDF — P1, placed here: it is a text/render feature next to the bitmap path; PRD §7 P1 = M6–M7), PRD §11 (stb_truetype) diff --git a/roadmap/README.md b/roadmap/README.md index 7883c1c..7a9c2e9 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 22 | ⬜ in progress | +| M2 | 33 | 23 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **68** | | +| **Total** | **194** | **69** | | --- @@ -249,6 +249,8 @@ One line per completed (or split/renumbered) step. | 2026-10-08 | M2-TEXT-01 | `—` / PR (open) | Font rasterization — bitmap (FR-2.8 bitmap P0; M2-TEXT-01 scope, nothing else): **vendoring** — `stb_truetype.h` pinned from nothings/stb commit `2c980bb…` (2026-08-01 — stb has no release tags; the pinned commit is the API reference: no `stbtt_GetFontOffsetForData`, no per-codepoint convenience wrappers — the glyph-index + metrics surface is the API), tree-hash-pinned in `deps.lock` (5 vendored deps; ADR 0009 — DEP-003 justification, FreeType / hand-rolled / baked-atlas alternatives all rejected); **API** — NEW `laige::render::GlyphAtlas` (public header `include/laige/render/font.h`, implementation `font.cpp`; the `laige-stb` static target compiles the vendor header in one impl TU under the plain-compiler policy — DEP-005): `GlyphAtlas::create(fontBytes, Options)` — validate options first (atlas dims [1, 4096], fontSize [1, 1024], range ≤ 8192 codes, first ≤ last ≤ 0x10FFFF — first failure wins, no log), then the untrusted-font boundary (engine-side header + table-directory guard BEFORE any stb call — stbtt's directory scan is not bounds-checked — then `GetFontOffsetForIndex`/`InitFont`; failure → `MalformedInput` + one structured Error `font/font_invalid`), then rasterize every codepoint of the range into the fixed 8-bit alpha atlas (deterministic shelf packing, ascending codepoint, cells are exactly the ink bitmap, empty glyphs take no space) — a glyph wider/taller than the atlas, or a full shelf → `BudgetExhausted` + one Error `font/atlas_full`; success → one Info `font/atlas_created`; move-only, stopped default state (nothing owned, `glyph` → nullptr); `GlyphMetrics` (advance, bearingX/Y, width/height, atlasX/Y — all px at 1x, `lineHeight() == fontSize()` by the stbtt pixel-height convention, advance = lround(advanceUnits × scale)); `glyph(code)` total + O(1) + zero-alloc + never logs: in-range → the slot (unknown codepoints → the font's `.notdef`), out-of-range → the U+0020 space slot (else the zero metric when the range excludes the space); **tests** — `tests/laige-render` CTest entry `font` (the step's Verify command; pure engine data, no GL — runs in every local tree + CI; `LAIGE_TEST_FONT_PATH` env var on the entry + the unfiltered entry + the TSan block): the validation matrix (no log), the untrusted-font boundary (empty / short / directory-cut / non-font / degenerate vertical metrics — exactly one structured Error each), the stopped state, the capacity guard (one Error), the introspection + one-Info contract, the move semantics, the metric goldens MEASURED against the committed test font (`tests/laige-render/assets/vera.ttf` — Bitstream Vera Sans, OFL 1.1; the assets/ README documents provenance + license), the `.notdef` fallback (both C1 probes → identical shape, separate cells), the out-of-range fallback (space slot / zero metric), the atlas invariants (every cell inside the fixed atlas — caught a real packing bug: wrapped cells' metrics pointed at the stale pre-wrap cursor), the deterministic-atlas contract (same font + options → identical bytes + metrics; the machine-greppable `font-atlas-determinism` fnv1a line — NO pinned cross-platform hash: presentation-only float, ARCH-009, the scope is in-process/same-build, unlike the M2-PROJ-01/ISO-03 transform scope), the 1000-lookup zero-allocation window; **budget** — no `budgets.json` entry (the atlas is built once at set-up, not per frame — the per-frame text cost lands with M2-TEXT-02, part of the composite 50k render-CPU budget, M2-PERF-01; the entry count stays 17); **docs** (DOC-006/007) — ADR 0009 (`docs/decisions/` + index), `docs/api/font.md` (the full contract incl. the DOC-004 Performance section), `deps/README.md` (the stb row + the missing GLM row fixed), `docs/README.md` API index, `src/laige-render/README.md` status, `tests/laige-render/assets/README.md`; **local verification** — the canonical `build` tree warning-clean, `ctest -R font` green (20/20) + full ctest 117/117 (prior 116 + the new `font` entry); `tools/laige-include-lint` OK (74 files, 5/10 vendored deps, 16 allowed vendored edges), `tools/laige-determinism-lint` OK; `laige-api.json` regenerated LAST (1419 → 1462 symbols, 42 → 43 headers) + `api-real-tree` green; the five remaining trees verified after the change (prior counts + 1 each); Progress Board M2 22/33 (total 68/194) | | 2026-10-08 | M2-PART-02 | `—` | **Budget revision (CI follow-up to M2-PART-02)** — the `particle_render_10k` gate (step-level, mean ≤ 2.0 ms, 10 000-particle declare pass, the `particle_render` ctest entry) failed the CI `Linux x64 (clang++)` reference lane on **every merge to master since M2-PART-02 landed**, on unchanged engine code: the reference lane (Clang 18.1.3, CMake Debug, ubuntu-24.04 shared runner) measures the workload at **1.91–2.68 ms**, consistently above the 2.0 ms bar (evidence — merge-lane runs 37780159655 on `bdb323b`: 2.67853 / 1.88209 ms; 37782008880 on `2f2c5a9`: 2.62795 / 1.94712 ms; 37850579155 on `4f81866`: 1.91031 / 2.07786 ms — fpx16_16 / fp32_pinned means — while the `Linux x64 (g++)` lane passed the same gated workload on the same commit `4f81866`); the 2.0 ms bar was calibrated from local-machine runs (1.07798 ms canonical g++ -O0, 1.18578 ms local Clang 23.1.1 -O0) and never verified with margin on the CI reference toolchain — the M2-ISO-02 rebaseline lesson; **revision** per methodology §5: **`budgets.json`** `target` 2.0 → **3.0** (44% above the latest recorded reference-lane value 2.07786, 12.0% above the worst observed reference-lane mean 2.67853), `measured` 1.07798 → **2.07786** (latest recorded CI reference value, worse backend, run 37850579155); **new baseline** `docs/benchmarks/baselines/m2-particle-render-budget-rebaseline.md` (the eleventh baseline, verbatim CI reports) + the baselines index entry; **docs** — `docs/api/particle_render.md` (the Budget bullet, linked to the new baseline) and the `src/laige-render/README.md` M2-PART-02 paragraph updated to the revised values in the same change (DOC-003). Step-level budget — no PRD revision (the `depth_sort_10k` precedent). No engine code, workload, or test change — budget + docs only (methodology §1: no regression occurred; this is a calibration of the gate's margin on the reference toolchain) | +| 2026-10-10 | M2-TEXT-02 | `—` / PR (open) | Text items in the UI pass (FR-2.8 — the text-item half of the bitmap P0 text; M2-TEXT-02 scope, nothing else): **API** — NEW header-only `laige::render` text module (public header `include/laige/render/text_items.h`, no new `.cpp`, no GL calls): `StringTable` (the scene's text store — PRD §10.4, no per-frame `std::string` in the path): u32 code points in a fixed pool (two budgeted allocations at `create` — header table + code-point pool; defaults 4096 strings / 16 384 code points, both capped at 2^24 via `kStringTableMax*`), `intern` idempotent (same bytes return the first interned handle — the layout pass's shared-string identity), pool-bound `BudgetExhausted` + one rate-limited Warn `string_table/table_full` (fields `limit` = `"strings"`/`"code_points"`, `capacity`), stopped table rejects with `InvalidArgument` no log, move-only with the moved-from table stopped (explicit move zeroes the source); `TextItem` (the block: `StringRef` text, non-owning `GlyphAtlas*`, anchor `x`/`y` = the first line's baseline, integer `scale` in [1, 16], `SpriteTint` color, per-line `TextAlignment` left/center/right, `maxWidth` in 0 or [1, 2^20] px, the hand-typed UI z `depthKey`, atlasId/materialId/blend); `measureText` the no-wrap O(glyphs) width probe; `declareText` the declaration — the shared `detail::nextLine` walk (word wrap, one collapsed space per inter-word gap, the character split for over-wide words, the over-wide single character taking its own line, leading/trailing spaces excluded, all widths at the scale) used TWICE: the pre-check (any line wider than `kTextMaxLineWidthPx` fails the call `InvalidArgument` with NOTHING declared) then the emit (one `SpriteItem` per ink glyph — the centered quad at the ink center, `scale` = the ink size, the atlas cell's uv rect `(atlasX/W, atlasY/H, (atlasX+w)/W, (atlasY+h)/H)`, `depthOverride = true` (the G-R11 escape hatch, counted + warned at build); a first failed `batcher.add` fails the call, declared items stay); `expandGlyphAtlasRgba8` fills the caller's W·H·4 span with the white-on-alpha RGBA8 upload (the M2-SPRITE-02 `bindAtlas` input); **tests** — `tests/laige-render` CTest entry `text_items` (the step's Verify command; 19 tests / 6 suites; `LAIGE_TEST_FONT_PATH` env var on the entry + the unfiltered entry + BOTH sanitizer blocks): `TextStringTable` (create validation no-log, intern identity + dedup, the two pool bounds + the cumulative warn count, stopped/moved-from, the zero-alloc create), `TextMeasure` (width goldens against the committed Vera test font — measured advances at the runtime metrics, no-wrap), `TextLayout` (the exact wrap model: word wrap, the collapsed spaces, the character split, the over-wide character, empty/whitespace, alignment offsets), `TextDeclare` (the per-glyph quad goldens — pos/scale/uv/depthOverride against hand-computed values, the group + order contract, the failure paths — null/stopped font, invalid handle, scale/maxWidth domain, the screen-domain pre-check declares nothing, the batcher overflow drop-oldest), `TextZeroAlloc` (1 000 frames of a wrapped 2x-scale declare = 0 heap blocks, machine-greppable line), `TextSmoke` (the offscreen GL smoke — the white-on-alpha upload through the real M2-SPRITE-02 submit stage: a 64² frame, the M + a glyphs at hand-computed positions, the exact pixel checks — opaque texels → (255,255,255,255), transparent texels → the clear, semi-transparent texels → rgb exactly the atlas alpha + alpha within ±1 of the documented SRC_ALPHA/ONE_MINUS_SRC_ALPHA blend on the alpha channel; `GTEST_SKIP` on no GL, as the sprite_draw smoke); **budget** — no `budgets.json` entry (the per-frame text cost is part of the composite 50k render-CPU budget, M2-PERF-01; the entry count stays 17); **docs** (DOC-006/007) — NEW `docs/api/text_items.md` (the full contract incl. the DOC-004 Performance section), `docs/api/font.md` (the M2-TEXT-02 references updated to shipped + cross-links), `src/laige-render/include/laige/render/font.h` (the header comments), `docs/concepts/coordinates.md` (§4.12 the text coordinate section + the §5 conversion row + the Related link), `docs/README.md` (the API index row), `src/laige-render/README.md` (the status paragraph); **local verification** — the canonical `build` tree warning-clean, `ctest -R text_items` green (19/19) + full ctest 117/118 with only `api-real-tree` pending the manifest regen; `laige-api.json` regenerated LAST; `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; the five remaining trees verified (prior counts + 1 each); Progress Board M2 23/33 (total 69/194) | + --- ## 6. Global invariants that apply to *every* step diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 94d3309..891f574 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -591,9 +591,9 @@ boundary is the `laige-stb` static library — the single 0009, the plain-compiler policy of the vendor impl TUs — DEP-005); the engine TU (`font.cpp`) never sees the stb internals beyond the narrow include. No GL calls anywhere; the atlas is presentation -state (ARCH-009) and lands in the M2-TEXT-02 UI pass as a -white-on-alpha RGBA8 upload through the M2-SPRITE-02 `bindAtlas` -path. API contract in +state (ARCH-009); the M2-TEXT-02 text pass uploads it as a +white-on-alpha RGBA8 atlas through the M2-SPRITE-02 `bindAtlas` +path (the next paragraph). API contract in [docs/api/font.md](../docs/api/font.md), tests under [tests/laige-render](../tests/laige-render) (CTest entry `font` — pure engine data, no GL environment required: the validation matrix, @@ -610,5 +610,42 @@ standalone `budgets.json` entry: the atlas is built once at set-up, not per frame — the per-frame text cost lands with M2-TEXT-02 (part of the composite 50k render-CPU budget, M2-PERF-01). -The remaining M2 steps (text items/UI, M2-SCENE-01, M2-PERF-01) land +M2-TEXT-02 landed the text items — the text-item half of FR-2.8: +`laige::render::TextItem` + `declareText` (public header-only +`include/laige/render/text_items.h`) declares a block of text into +the batcher as batched glyph quads in the UI pass (screen space, y +down). The string bytes come from the scene's `StringTable` +(interned u32 code points — PRD §10.4, no per-frame `std::string` in +the path); `StringTable::create` makes two budgeted allocations +(defaults: 4096 strings / 16384 code points, both bounded at 2^24) +and `intern` is idempotent (same bytes, same handle) with the +`BudgetExhausted` + one rate-limited Warn `string_table/table_full` +when a pool bound is exceeded. Layout is exact integers: the first +line's baseline anchor, the integer `scale` in [1, 16] (every metric +× scale), the `maxWidth` word-wrap model (one collapsed space per +inter-word gap; an over-wide word splits by character; an over-wide +single character takes its own line), each line aligned +independently (left/center/right). Each glyph with ink becomes one +`SpriteItem` (the centered quad at the ink center, the atlas cell's +uv rect, `depthOverride = true` with the hand-typed UI z — the G-R11 +escape hatch), one (atlas, material, blend) group = one instanced +draw call (RENDER-001). `measureText` is the no-wrap O(glyphs) +width probe; `expandGlyphAtlasRgba8` fills the caller's `W·H·4` +buffer with the white-on-alpha RGBA8 upload bytes (the setup path, +once per font). A failed `declareText` declares nothing (the +pre-check walks every line first); a batcher failure stops the call +(the `declareParticles` precedent). The declare path is O(glyphs), +zero allocation, zero logging, zero GL (the 1000-frame zero-alloc +window in the test suite). API contract in +[docs/api/text_items.md](../docs/api/text_items.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `text_items` +— the StringTable contract, the width-measurement goldens against +the committed Vera test font, the exact wrap model, the per-glyph +quad goldens, the group + order contract, the failure paths, the +1000-frame zero-allocation declare loop, and the offscreen GL smoke +that needs a usable OpenGL 3.3 environment and `GTEST_SKIP`s where +absent). No standalone `budgets.json` entry: the per-frame text cost +is part of the composite 50k render-CPU budget (M2-PERF-01). + +The remaining M2 steps (the UI pass, M2-SCENE-01, M2-PERF-01) land in later steps. diff --git a/src/laige-render/include/laige/render/font.h b/src/laige-render/include/laige/render/font.h index 19fc98a..218fdad 100644 --- a/src/laige-render/include/laige/render/font.h +++ b/src/laige-render/include/laige/render/font.h @@ -8,7 +8,8 @@ // bearing, line height). There is NO runtime re-rasterization: after // `create`, `glyph(code)` is a pure table lookup. The SDF path (P1) // lands in M2-TEXT-03 on top of the same vendored rasterizer (ADR -// 0009); the text widget / layout pass lands in M2-TEXT-02. +// 0009); the text-item / layout pass ships in text_items.h +// (M2-TEXT-02). // // GlyphAtlas The configured glyph atlas (create + lookup) // GlyphMetrics Per-glyph metrics + atlas cell origin @@ -23,7 +24,7 @@ // convention: the font's ascent + descent scaled to exactly that // height). To render at an integer scale s, multiply every metric by // s (exact for integer s); the atlas bitmap itself is then drawn at -// s× its texel size (the M2-TEXT-02/M2-SPRITE-02 sprite `scale`). +// s× its texel size (the text_items.h/M2-SPRITE-02 sprite `scale`). // // `GlyphAtlas::create(font, options)`: // @@ -117,14 +118,15 @@ // // `GlyphAtlas` owns its atlas bytes and slot table (move-only, the // `SpriteBatcher` precedent); one owner (the scene set-up thread); the -// render phase reads it (the M2-GL-02 cull/batch stage uploads the atlas -// + declares text quads — M2-TEXT-02). Presentation-only (ARCH-009): +// render phase reads it (the M2-GL-02 cull/batch stage uploads the +// atlas + declares text quads — text_items.h, M2-TEXT-02). +// Presentation-only (ARCH-009): // the atlas is never part of the sim state hash or replay state. No GL // calls anywhere in this header. // // Canonical narrative: docs/api/font.md (the full contract). // The text feature's place in the 2.5D model: -// docs/concepts/coordinates.md (the M2-TEXT-02 row, planned). +// docs/concepts/coordinates.md (§4.12 — the text row, shipped). #pragma once @@ -252,8 +254,8 @@ class GlyphAtlas { // The atlas bytes: 8-bit alpha, row-major, `atlasWidth() × atlasHeight()` // texels (empty in the stopped state). The render read path: the - // M2-TEXT-02 pass uploads this as a white-on-alpha RGBA8 atlas - // (the M2-SPRITE-02 `bindAtlas` contract). O(1); never writes. + // text_items.h (M2-TEXT-02) uploads this as a white-on-alpha RGBA8 + // atlas (the M2-SPRITE-02 `bindAtlas` contract). O(1); never writes. [[nodiscard]] std::span atlas() const noexcept { return atlas_; } diff --git a/src/laige-render/include/laige/render/text_items.h b/src/laige-render/include/laige/render/text_items.h new file mode 100644 index 0000000..d850b26 --- /dev/null +++ b/src/laige-render/include/laige/render/text_items.h @@ -0,0 +1,875 @@ +// laige-render text items (M2-TEXT-02): the text half of FR-2.8 — +// screen-space text items for the UI pass. +// +// FR-2.8: "Text rendering: ... bitmap font atlas (P0) ...; UI is a +// separate render pass, screen-space." The P0 bitmap half (M2-TEXT-01) +// owns the font rasterization (`laige/render/font.h` — the +// `GlyphAtlas`). This header is the UI pass's text side: the +// `StringTable` (PRD §10.4 — strings live in an engine-owned table with +// `StringRef` handles; no per-frame `std::string` in the path), the +// `TextItem` value (string, font, scale, color, alignment, max-width +// wrap), the O(glyphs) width measurement, and the `declareText` pass +// that turns one `TextItem` into batched glyph quads in the UI pass's +// sprite batcher (S-5: rendering goes through the batcher — text is +// never drawn directly). The M2-UI-01 widget tree declares its text +// widgets through this same pass; the M2-UI-02 render pass submits the +// UI batcher through the M2-SPRITE-02 `SpriteRenderer` (identity +// screen-space matrix — RENDER-006). +// +// StringRef The string-table handle (PRD §10.4) +// StringTable The engine-owned interned string storage +// TextAlignment The per-line alignment (left/center/right) +// TextItem One declared text item (UI pass, screen px) +// measureText The O(glyphs) width measurement +// declareText The O(glyphs) text -> glyph-quads pass +// expandGlyphAtlasRgba8 The 8-bit alpha atlas -> white-on-alpha +// RGBA8 upload bytes (the M2-SPRITE-02 +// `bindAtlas` input) +// +// --------------------------------------------------------------------------- +// The model (documented units) +// --------------------------------------------------------------------------- +// +// All layout values are SCREEN PIXELS, screen space, y DOWN (the UI +// pass's coordinate system — the M2-UI-02 identity screen matrix maps +// (x, y) to NDC; nothing world-space leaks in, RENDER-006). The font's +// metrics are px at 1x (M2-TEXT-01); `TextItem.scale` is an INTEGER +// scale factor in [1, kTextMaxScale] (the M2-TEXT-01 "integer scale" +// convention: every metric is multiplied by the scale — exact, no +// fractional-pixel rasterization), so every layout coordinate is an +// exact integer before the one presentation-only float conversion to +// the `SpriteItem` fields (ARCH-009). +// +// The text is a sequence of UNICODE CODE POINTS (one u32 per code +// point — the `glyph(code)` domain, M2-TEXT-01; UTF-8/UTF-16 sources +// are converted to this representation once, at string load, never +// per frame — PRD §10.4). `StringTable` stores exactly that: u32 code +// points in a fixed pool. +// +// The text block's ANCHOR is `TextItem.x` / `TextItem.y`: +// +// - `y` is always the FIRST LINE'S BASELINE (screen px, y down). +// - `x` is, by `TextItem.alignment`: the text block's LEFT edge +// (`Left`), its CENTER x (`Center`), or its RIGHT edge (`Right`). +// Each line is aligned independently against that anchor (the +// standard paragraph model — every line's own width is centered / +// right-justified, not the longest line's). +// +// Line step: `fontSize() * scale` px (the M2-TEXT-01 line height — +// `lineHeight() == fontSize()` by the stbtt pixel-height convention). +// +// --------------------------------------------------------------------------- +// The glyph quads (the M2-SPRITE-02 quad model) +// --------------------------------------------------------------------------- +// +// One `SpriteItem` per GLYPH WITH INK (width > 0 && height > 0 at the +// scale — the empty glyph (the space, the zero-ink fallback) declares +// NOTHING but its advance still applies to the pen). The quad: +// +// pen walks the line in code-point order; a glyph's pen +// advance is `glyph->advance * scale` (exact integer); +// ink rect [pen + bearingX·scale, pen + bearingX·scale + +// width·scale) × [baseline − bearingY·scale, baseline − +// bearingY·scale + height·scale) — screen px, y down +// (bearingY > 0 = the ink's top edge is ABOVE the +// baseline — the M2-TEXT-01 bearing convention; a +// descender has bearingY < 0 and hangs below it); +// quad the M2-SPRITE-02 centered-quad model: +// `pos = (inkLeft + w/2, inkTop + h/2)`, +// `scale = (w, h)` — the quad's ±0.5·scale spans exactly +// the ink rect; +// uv the glyph cell's documented rect (M2-TEXT-01): +// (atlasX/W, atlasY/H, (atlasX+w)/W, (atlasY+h)/H) — the +// exact-float domain of M2-SPRITE-03 (atlas ≤ 2^24 +// texels; u1 > u0, v1 > v0 exact); +// tint the item's color (SpriteTint — the M2-SPRITE-02 +// multiplicative tint: the white-on-alpha upload makes +// the drawn color = tint × glyph alpha, the standard +// text coloring); +// depthKey the item's 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: there is no isoDepthKey +// in this pass — manual UI z is the documented order +// value, not a workaround for a world key. +// rotation 0 (per-glyph rotation is not a text feature in M2), +// frameIndex 0 (glyph animation is not a feature in M2 — the SDF +// pass of M2-TEXT-03 changes the shader, not this hook). +// +// Declaration order is the ROW-MAJOR order (line by line, code point by +// code point): the UI pass's deterministic order (RENDER-003) — all +// glyphs of one item share the same depthKey, so the batcher's stable +// sort keeps this exact declaration order within the group. +// +// --------------------------------------------------------------------------- +// The wrap model (word wrap at max width) +// --------------------------------------------------------------------------- +// +// `TextItem.maxWidth` is 0 (NO wrap — the whole text is one line) or a +// width in [1, kTextMaxLineWidthPx] screen px. When wrapping is on: +// +// - a WORD is a maximal run of non-space code points; U+0020 SPACE +// is the ONLY word separator (U+00A0 no-break space and other +// "whitespace" are ordinary characters — the Latin-1 convention; +// the font's own space glyph is what breaks); +// - a word is placed on the current line when it fits: +// `lineWidth + spaceAdvance + wordWidth <= maxWidth` (a first word +// fits when `wordWidth <= maxWidth`); +// - consecutive spaces between two words on one line collapse to ONE +// space advance in both the width and the pen (the extra spaces +// occupy no ink — the standard layout behavior); +// - a word WIDER than `maxWidth` (and a line that is still empty) is +// SPLIT character-by-character: the greedy longest character prefix +// that fits goes on the line, the rest continues on the next line; +// - a SINGLE CHARACTER wider than `maxWidth` gets its own line whose +// width exceeds `maxWidth` (the only way to draw it — never +// dropped, never clipped — the documented oversized behavior); +// - leading spaces of a line (after a wrap) and trailing spaces of +// the text occupy no line and no width. +// +// The LINE WIDTH is the ink-block width: the sum of the line's glyph +// advances (words + one space advance per inter-word gap), excluding +// leading/trailing spaces. `measureText` returns exactly this width +// for the no-wrap text — the panel-sizing measurement (M2-UI-01). +// +// --------------------------------------------------------------------------- +// The screen domain (the layout's exact-float boundary) +// --------------------------------------------------------------------------- +// +// All layout arithmetic is done in EXACT integer (int64) math; the +// one conversion to render floats happens on the declared quad fields +// (the ARCH-009 presentation boundary). The domain guard: a line wider +// than `kTextMaxLineWidthPx` (= 2^20 px, a 20-bit screen dimension — +// far beyond any UI panel) is UNREPRESENTABLE: `declareText` fails +// `InvalidArgument` and declares NOTHING (the pre-check pass walks +// every line before the first add — a failed call leaves the frame +// untouched). Inside the domain every coordinate is < 2^24 px — exact +// in float (the M2-SPRITE-03 float-exact domain). +// +// --------------------------------------------------------------------------- +// The string table (PRD §10.4) +// --------------------------------------------------------------------------- +// +// `StringTable` is the engine-owned string storage: a fixed pool of u32 +// code points + a header per string (offset, length). Strings are +// INTERNED (the setup/asset path — scene load, not the frame path): +// `intern` copies the code points into the pool once and returns a +// stable `StringRef` handle; the same bytes interned again return the +// SAME handle (dedup — the table's identity property); `text(ref)` +// hands back the span (no copy, no allocation). The frame path reads +// only the handle + the span (no per-frame `std::string` — PRD §10.4). +// +// Bounded by construction (PERF-008): `maxStrings` / `maxCodePoints` +// are fixed at `create`; an `intern` beyond either bound fails +// `BudgetExhausted` + one rate-limited Warn `string_table/table_full` +// (never grows, never throws — the pool's budget contract). +// +// --------------------------------------------------------------------------- +// Ownership, threading (CORE-009, CONC-001) +// --------------------------------------------------------------------------- +// +// `StringTable` is move-only (the `SpriteBatcher` precedent): one owner +// (the scene set-up thread); the render phase reads it (`declareText` +// / `measureText` read the spans) — the table is never shared with a +// concurrent writer (the single-owner pattern). `GlyphAtlas` is the +// scene's (M2-TEXT-01): a `TextItem.font` is a NON-OWNING pointer that +// MUST outlive every frame that declares through it (the scene owns +// the atlas, the frame owns the items). `TextItem` is a plain value +// (no ownership, nothing to release). Presentation-only (ARCH-009): +// none of this is sim state (no state-hash / replay involvement). +// No GL calls anywhere in this header (the GL half is the existing +// M2-SPRITE-02 `SpriteRenderer::submit` + `bindAtlas`). +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003/004, DOC-004) +// --------------------------------------------------------------------------- +// +// StringTable::create O(maxStrings + maxCodePoints): two +// allocations (the header table + the pool). +// Setup path only. +// StringTable::intern O(count · len) (the dedup scan — a linear +// memcmp over the live strings) + O(len) copy. +// SETUP PATH (scene load) — never per frame; +// the frame path is `text` (O(1)) + +// `declareText` (below). +// StringTable::text O(1); no allocation. +// measureText O(glyphs): one pass (the shared layout walk), +// no allocation, no logging, no GL. +// declareText O(glyphs): the line walk twice (the pre-check +// + the emit — both the same `nextLine` +// function) + one batcher add per ink glyph (the +// add is O(1)). No allocation, no logging on +// the happy path (the batcher's G-R11 warn at +// build is the batcher's own cold, rate-limited +// path — LOG-003/004), no GL. +// expandGlyphAtlasRgba8 O(atlas W·H): four byte writes per texel. +// SETUP path (once per font per scene load — +// before the one-time `bindAtlas` upload — +// RENDER-004: no unexpected GPU work in the +// frame hot path). +// +// Call site: once per frame per text item, in the RENDER phase (the +// M2-GL-02 cull/batch stage of the UI pass) — never in the simulation +// tick (ARCH-002). The per-frame text cost is part of the composite +// 50k render-CPU budget (PRD §8.1 `sprites_50k_cpu` — measured with +// M2-PERF-01; no standalone budgets.json entry for this step). +// +// --------------------------------------------------------------------------- +// Failure (CORE-008, API-008 — first failure wins) +// --------------------------------------------------------------------------- +// +// - StringTable::create: maxStrings / maxCodePoints outside +// [1, 2^24] → InvalidArgument (no allocation, no log — the +// create-validation precedent); +// - StringTable::intern: stopped table → InvalidArgument (no log); +// beyond either bound → BudgetExhausted + one rate-limited Warn +// `string_table/table_full` (fields: `limit`, `capacity`); +// - measureText: stopped/missing font, invalid scale, or an invalid +// handle → InvalidArgument (no log — the precondition contract); +// - declareText: stopped/missing font, invalid scale, an invalid +// handle, an out-of-domain maxWidth, or a line wider than +// kTextMaxLineWidthPx → InvalidArgument, NOTHING declared (the +// pre-check runs before the first add — no partial text); a +// failed batcher add (stopped batcher → BudgetExhausted, closed +// window → InvalidArgument) fails the call with the batcher's +// error — the items already declared by the call remain in the +// frame (the batcher owns the frame state — the +// `declareParticles` precedent); the batcher's own overflow policy +// (drop oldest + warn) bounds a frame that declares too many +// glyphs (PERF-008); +// - expandGlyphAtlasRgba8: stopped atlas or a wrong-size span → +// InvalidArgument (no GL work, no log). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Do not intern per frame (PRD §10.4): `intern` is the setup path; +// the frame path is the handle + `text` span + `declareText`. +// - Do not point `TextItem.font` / `TextItem.text` at dead storage: +// the atlas and the table are scene-owned and must outlive every +// frame that declares the item (the lifetime contract above). +// - Do not size a batcher for the world pass only: the UI pass is its +// OWN batcher (one batcher per sprite pass, M2-SPRITE-01), but size +// it for the frame's total glyph count (a 100-character label at +// 4x scale is ~100 quads). +// - Do not expect sub-pixel text: the scale is INTEGER (the +// M2-TEXT-01 convention); fractional scales are out of scope in M2 +// (the SDF pass, M2-TEXT-03, is the zoom-crispness path). +// +// Canonical narrative: docs/concepts/coordinates.md §4.12 (ARCH-008); +// API contract: docs/api/text_items.md. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/render/font.h" +#include "laige/render/sprite_batcher.h" +#include "laige/result.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// The string-table pool domains: [1, 2^24] each (16 777 216 strings / +// code points — 64 MiB of pool at the ceiling, far beyond any UI +// string budget; the 24-bit width is the index domain of the header +// table). +inline constexpr std::uint32_t kStringTableMaxStrings = 1u << 24; +inline constexpr std::uint32_t kStringTableMaxCodePoints = 1u << 24; +// The documented defaults (a typical UI scene: a few hundred distinct +// strings, a few thousand code points — 64 KiB of pool). +inline constexpr std::uint32_t kStringTableDefaultStrings = 4096; +inline constexpr std::uint32_t kStringTableDefaultCodePoints = 16384; + +// The integer scale domain (the M2-TEXT-01 "integer scale" convention): +// [1, kTextMaxScale]. 16 × the 1024 px font ceiling is a 16 384 px +// line height — beyond any practical UI text (the layout math is +// int64; the 2^20 px line-width domain below is the representability +// guard). +inline constexpr std::uint32_t kTextMaxScale = 16; + +// The line-width / max-width domain in screen px: [1, 2^20]. A 20-bit +// screen dimension — far beyond any UI panel, and < 2^24 so every +// layout coordinate inside the domain is exact in float (the +// M2-SPRITE-03 float-exact domain). A line wider than this is +// unrepresentable (the header's screen-domain section). +inline constexpr std::uint32_t kTextMaxLineWidthPx = 1u << 20; + +// The ONLY word separator in the wrap model (U+0020 SPACE — the +// header's wrap section; U+00A0 and friends are ordinary characters). +inline constexpr std::uint32_t kTextSpaceCode = 0x20; + +// --------------------------------------------------------------------------- +// The string-table handle (PRD §10.4) +// --------------------------------------------------------------------------- + +// A stable handle into one `StringTable` (the engine-owned string +// storage). Plain value: the id is dense from 0, stable for the +// table's lifetime (the table never moves, frees, or reallocates +// after create — CORE-007). No generation counter is needed (nothing +// is ever destroyed — the pool is fixed at create). +struct StringRef { + std::uint32_t id{0}; +}; + +// --------------------------------------------------------------------------- +// The engine-owned string table (PRD §10.4) +// --------------------------------------------------------------------------- + +// The fixed pool of interned u32 code-point strings (the header's +// model + failure + performance sections). Move-only (CORE-009); the +// default state is stopped (nothing owned; `intern` fails, `text` +// reads empty). +class StringTable { + public: + // The table budget (API-006): both fields in [1, kStringTableMax*] + // (validated at create — first failure wins, InvalidArgument, no + // allocation). `maxCodePoints` bounds the TOTAL code points stored + // (across all strings); `maxStrings` the distinct string count. + struct Options { + std::uint32_t maxStrings{kStringTableDefaultStrings}; + std::uint32_t maxCodePoints{kStringTableDefaultCodePoints}; + }; + + // The stopped state (default / moved-from): valid() false, + // intern -> InvalidArgument, text reads empty, introspection zero. + StringTable() noexcept = default; + + // The set-up path (scene load): two allocations (the 8 B header per + // string + the 4 B pool per code point — the header's performance + // section). + [[nodiscard]] static laige::Result + create(Options options) noexcept; + + StringTable(const StringTable&) = delete; + StringTable& operator=(const StringTable&) = delete; + // 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). + 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_)) { + o.maxStrings_ = 0; + o.maxCodePoints_ = 0; + o.count_ = 0; + o.stored_ = 0; + } + StringTable& operator=(StringTable&& o) noexcept { + if (this != &o) { + *this = StringTable(std::move(o)); + } + return *this; + } + ~StringTable() noexcept = default; + + // True iff the table is live (create succeeded). + [[nodiscard]] bool valid() const noexcept { return maxStrings_ != 0; } + + // 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). + // + // Fails (no partial state): stopped table → InvalidArgument (no + // log); count at maxStrings or pool past maxCodePoints → + // BudgetExhausted + one rate-limited Warn `string_table/table_full` + // (fields `limit` = "strings" / "code_points", `capacity`). + // + // @budget O(count·len + len) (the dedup scan + the copy) — the + // SETUP path (scene load), never per frame. + [[nodiscard]] laige::Result + intern(std::span codes) noexcept; + + // True iff `ref` names a live string (false in the stopped state or + // for an out-of-range id). O(1). + [[nodiscard]] bool isValid(StringRef ref) const noexcept { + return ref.id < count_; + } + + // 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. + [[nodiscard]] std::span text( + StringRef ref) const noexcept { + if (!isValid(ref)) return std::span{}; + const Header& h = headers_[ref.id]; + return std::span(pool_.get() + h.offset, + h.length); + } + + // The live string count (0 in the stopped state). O(1). + [[nodiscard]] std::uint32_t count() const noexcept { return count_; } + // The total code points stored (0 in the stopped state). O(1). + [[nodiscard]] std::uint32_t storedCodePoints() const noexcept { + return stored_; + } + // The create() budgets (0 in the stopped state). O(1). + [[nodiscard]] std::uint32_t stringCapacity() const noexcept { + return maxStrings_; + } + [[nodiscard]] std::uint32_t codePointCapacity() const noexcept { + return maxCodePoints_; + } + + private: + // One interned string's pool slot (8 B — the header's performance + // section). + struct Header { + std::uint32_t offset{}; + std::uint32_t length{}; + }; + + explicit StringTable(std::uint32_t maxStrings, + std::uint32_t maxCodePoints) noexcept + : maxStrings_(maxStrings), + maxCodePoints_(maxCodePoints), + headers_(std::make_unique(maxStrings)), + pool_(std::make_unique(maxCodePoints)) {} + + std::uint32_t maxStrings_{0}; + std::uint32_t maxCodePoints_{0}; + std::uint32_t count_{0}; + std::uint32_t stored_{0}; + std::unique_ptr headers_; + std::unique_ptr pool_; +}; + +inline laige::Result +StringTable::create(Options options) noexcept { + // The create-validation (first failure wins, InvalidArgument, no + // allocation, no log — the header's failure section). + if (options.maxStrings < 1 || + options.maxStrings > kStringTableMaxStrings || + options.maxCodePoints < 1 || + options.maxCodePoints > kStringTableMaxCodePoints) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + return laige::Result::success( + StringTable(options.maxStrings, options.maxCodePoints)); +} + +inline laige::Result +StringTable::intern(std::span codes) noexcept { + auto fail = [](laige::ErrorCode e, const char* limit, + std::uint32_t capacity) -> laige::Result { + if (e == laige::ErrorCode::BudgetExhausted) { + // One rate-limited warn per failure (LOG-003/004 — the facade + // windows the repeats; the set-up path is low-volume anyway). + LAIGE_LOG_WARN("string_table", "table_full", + "String table intern exceeded a pool bound", + laige::log::field("limit", std::string_view(limit)), + laige::log::field("capacity", capacity)); + } + return laige::Result::failure(e); + }; + if (maxStrings_ == 0) { // the stopped state (no log) + return laige::Result::failure(laige::ErrorCode::InvalidArgument); + } + // The dedup scan (the identity property; the header's performance + // section: the setup path, never per frame). + const std::size_t len = codes.size(); + for (std::uint32_t i = 0; i < count_; ++i) { + const Header& h = headers_[i]; + if (h.length == len && + (len == 0 || + std::memcmp(pool_.get() + h.offset, codes.data(), + len * sizeof(std::uint32_t)) == 0)) { + return laige::Result::success(StringRef{i}); + } + } + // The bounds (first failure wins — the string count, then the pool). + if (count_ == maxStrings_) { + return fail(laige::ErrorCode::BudgetExhausted, "strings", maxStrings_); + } + if (static_cast(stored_) + static_cast(len) > + maxCodePoints_) { + return fail(laige::ErrorCode::BudgetExhausted, "code_points", + maxCodePoints_); + } + // The copy (len <= maxCodePoints fits the u32 header length exactly). + const std::uint32_t length = static_cast(len); + if (len > 0) { + std::memcpy(pool_.get() + stored_, codes.data(), + len * sizeof(std::uint32_t)); + } + headers_[count_] = Header{stored_, length}; + stored_ += length; + return laige::Result::success(StringRef{count_++}); +} + +// --------------------------------------------------------------------------- +// The per-item presentation values (UI pass, screen px) +// --------------------------------------------------------------------------- + +// The per-line alignment of the text block (the header's model +// section: the anchor semantics of TextItem.x). +enum class TextAlignment : std::uint8_t { + // The anchor x is the block's LEFT edge (every line starts there). + Left = 0, + // The anchor x is the block's CENTER x (each line is centered). + Center = 1, + // The anchor x is the block's RIGHT edge (each line ends there). + Right = 2, +}; + +// 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). +struct TextItem { + // The text's string-table handle (PRD §10.4 — no per-frame + // std::string in the path). + StringRef text{}; + // The glyph atlas (M2-TEXT-01) — non-owning (scene-owned; must + // outlive the frame). + const GlyphAtlas* font{}; + // The text block's anchor x, screen px (the header's model section: + // left/center/right edge by `alignment`). + float x{}; + // The FIRST line's baseline, screen px (y down). + float y{}; + // 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). + std::uint32_t scale{1}; + // The text color (the M2-SPRITE-02 multiplicative tint over the + // white-on-alpha upload — the drawn color = color × glyph alpha). + // Default: white. + SpriteTint color{}; + // The per-line alignment (default: left). + TextAlignment alignment{TextAlignment::Left}; + // The max line width, screen px (0 = no wrap — one line). Domain 0 + // or [1, kTextMaxLineWidthPx]; validated by declareText + // (InvalidArgument, no log). + std::int32_t maxWidth{0}; + // 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. + std::uint32_t depthKey{}; + // The glyph atlas's texture id (the scene's bindAtlas id — + // M2-SPRITE-02). + std::uint32_t atlasId{}; + // The material id (0 = the default material — the group key carries + // it per FR-2.1). + std::uint32_t materialId{}; + // The blend state (the group key's third field). Alpha is the text + // convention (the standard alpha blend over the UI panels). + BlendMode blend{BlendMode::Alpha}; +}; + +// --------------------------------------------------------------------------- +// The shared layout walk (the pre-check and the emit use one code path) +// --------------------------------------------------------------------------- + +namespace detail { + +// One laid-out line (the header's wrap model): the line's code-point +// range in `text` (half-open) + its ink-block width (px, exact int). +struct TextLine { + std::uint32_t start{}; + std::uint32_t end{}; + std::int64_t width{}; +}; + +// The next line starting from code index `pos` (the header's wrap +// model — word wrap, the character split, the collapsed spaces). A +// TOTAL function on a live font: `false` when the remaining code +// points are all spaces (or exhausted) — no line is produced. All +// widths are in screen px at the item's `scale` (the integer-scale +// convention — every advance is multiplied by the scale). Pure +// (no allocation, no logging): a handful of integer sums over the +// line's codes. +[[nodiscard]] inline bool nextLine(const GlyphAtlas& font, + std::span codes, + std::uint32_t scale, + std::int64_t maxWidth, std::uint32_t pos, + TextLine& out) noexcept { + const std::uint32_t n = static_cast(codes.size()); + // The scaled advance of the code at index `idx` (the glyph is a + // total function — M2-TEXT-01: a live font never returns null). + auto advance = [&](std::uint32_t idx) { + return static_cast(font.glyph(codes[idx])->advance) * + static_cast(scale); + }; + // Skip the leading spaces of the line (the header's wrap model). + std::uint32_t p = pos; + while (p < n && codes[p] == kTextSpaceCode) ++p; + if (p == n) return false; + // The first word. + std::uint32_t q = p; + while (q < n && codes[q] != kTextSpaceCode) ++q; + std::int64_t wordW = 0; + for (std::uint32_t i = p; i < q; ++i) wordW += advance(i); + if (maxWidth > 0 && wordW > maxWidth) { + // The character split (the header's wrap model): the greedy longest + // prefix that fits (a single character always takes its own line — + // the documented oversized behavior). + std::int64_t cur = 0; + std::uint32_t r = p; + while (r < q) { + const std::int64_t cw = advance(r); + if (cur == 0) { + cur = cw; + ++r; + } else if (cur + cw <= maxWidth) { + cur += cw; + ++r; + } else { + break; + } + } + out = TextLine{p, r, cur}; + return true; + } + // The word fits: fill the line with the following words. + std::int64_t cur = wordW; + std::uint32_t r = q; + const std::int64_t spaceAdv = + static_cast(font.glyph(kTextSpaceCode)->advance) * + static_cast(scale); + while (r < n) { + // The space run between this word and the next (collapsed to one + // space advance — the header's wrap model). + std::uint32_t r2 = r + 1; + while (r2 < n && codes[r2] == kTextSpaceCode) ++r2; + if (r2 == n) break; // trailing spaces: the line ends here + std::uint32_t q2 = r2; + while (q2 < n && codes[q2] != kTextSpaceCode) ++q2; + std::int64_t w2 = 0; + for (std::uint32_t i = r2; i < q2; ++i) w2 += advance(i); + if (maxWidth > 0 && cur + spaceAdv + w2 > maxWidth) break; + cur += spaceAdv + w2; + r = q2; + } + out = TextLine{p, r, cur}; + return true; +} + +} // namespace detail + +// --------------------------------------------------------------------------- +// The width measurement (the M2-UI-01 panel-sizing read) +// --------------------------------------------------------------------------- + +// 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. +// +// Fails (no log — the precondition contract): a stopped/missing font, +// a scale outside [1, kTextMaxScale], or a handle the table does not +// own → InvalidArgument. An empty string (or an all-space one) +// measures 0. +[[nodiscard]] inline laige::Result measureText( + const GlyphAtlas& font, const StringTable& table, StringRef text, + std::uint32_t scale) noexcept { + if (!font.valid() || scale < 1 || scale > kTextMaxScale || + !table.isValid(text)) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + const std::span codes = table.text(text); + detail::TextLine line; + constexpr std::int64_t kNoWrap = 0; // the no-wrap domain value + if (!detail::nextLine(font, codes, scale, kNoWrap, 0, line)) { + return laige::Result::success(0); // empty / all spaces + } + return laige::Result::success(line.width); +} + +// --------------------------------------------------------------------------- +// The text -> glyph-quads pass (the UI pass's declaration) +// --------------------------------------------------------------------------- + +// 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). +[[nodiscard]] inline laige::Status declareText( + SpriteBatcher& batcher, const StringTable& table, + const TextItem& item) noexcept { + // The preconditions (first failure wins, InvalidArgument, no log — + // the header's failure section): the scene-owned storage is live, + // the item fields are in domain. + if (item.font == nullptr || !item.font->valid() || + !table.isValid(item.text) || item.scale < 1 || + item.scale > kTextMaxScale || + (item.maxWidth != 0 && + (item.maxWidth < 1 || + item.maxWidth > static_cast(kTextMaxLineWidthPx)))) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + const GlyphAtlas& font = *item.font; + const std::span codes = table.text(item.text); + const std::int64_t maxWidth = + static_cast(item.maxWidth); // 0 = no wrap + const std::int64_t lineStepPx = + static_cast(font.lineHeight()) * item.scale; + + // 1. The pre-check (the header's screen-domain section): every line + // fits the domain, else nothing is declared. + { + std::uint32_t pos = 0; + detail::TextLine line; + while (detail::nextLine(font, codes, item.scale, maxWidth, pos, line)) { + if (line.width > static_cast(kTextMaxLineWidthPx)) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + pos = line.end; + } + } + + // 2. The emit (the same walk — one code path, the header's layout + // walk): line by line, the per-glyph quads. + std::uint32_t pos = 0; + std::uint32_t lineIndex = 0; + const std::uint32_t atlasW = font.atlasWidth(); + const std::uint32_t atlasH = font.atlasHeight(); + detail::TextLine line; + while (detail::nextLine(font, codes, item.scale, maxWidth, pos, line)) { + // The line's anchor (the header's model section: the alignment + // offset from the ink-block width — one presentation-only float + // division for the center alignment). + float lineX0 = item.x; + if (item.alignment == TextAlignment::Center) { + lineX0 -= static_cast(line.width) * 0.5f; + } else if (item.alignment == TextAlignment::Right) { + lineX0 -= static_cast(line.width); + } + const float baselineY = + item.y + static_cast(static_cast(lineStepPx) * + lineIndex); + // The pen walk (the header's glyph-quad section). + float pen = lineX0; + for (std::uint32_t i = line.start; i < line.end; ++i) { + const std::uint32_t code = codes[i]; + if (code == kTextSpaceCode) { + // One space advance per inter-word gap (the consecutive spaces + // collapse — the header's wrap model). + pen += static_cast( + static_cast(font.glyph(code)->advance) * + item.scale); + while (i + 1 < line.end && codes[i + 1] == kTextSpaceCode) ++i; + continue; + } + const GlyphMetrics& m = *font.glyph(code); + if (m.width > 0 && m.height > 0) { + // The ink rect (exact int math, one float conversion per + // field — the header's screen-domain section). + const float inkW = static_cast( + static_cast(m.width) * item.scale); + const float inkH = static_cast( + static_cast(m.height) * item.scale); + const float inkLeft = + pen + static_cast(static_cast(m.bearingX) * + item.scale); + const float inkTop = + baselineY - static_cast(static_cast( + m.bearingY) * item.scale); + SpriteItem quad; + quad.pos = Vec2{inkLeft + inkW * 0.5f, inkTop + inkH * 0.5f}; + quad.depthKey = item.depthKey; + // The G-R11 escape hatch: the UI pass's manual z (the + // sprite_batcher.h contract — counted + warned at build). + quad.depthOverride = true; + quad.uv = SpriteUvRect{ + static_cast(m.atlasX) / static_cast(atlasW), + static_cast(m.atlasY) / static_cast(atlasH), + static_cast(m.atlasX + + static_cast(m.width)) / + static_cast(atlasW), + static_cast(m.atlasY + + static_cast(m.height)) / + static_cast(atlasH)}; + quad.frameIndex = 0; + quad.rotation = 0.0f; + quad.scale = Vec2{inkW, inkH}; + quad.tint = item.color; + quad.atlasId = item.atlasId; + quad.materialId = item.materialId; + quad.blend = item.blend; + const auto added = batcher.add(std::move(quad)); + if (!added.ok()) { + // The first failed add fails the call (the declareParticles + // precedent): the quads already declared remain in the frame. + return laige::Status::failure(added.error()); + } + } + pen += static_cast(static_cast(m.advance) * + item.scale); + } + pos = line.end; + ++lineIndex; + } + return laige::Status{}; +} + +// --------------------------------------------------------------------------- +// The atlas upload bytes (the M2-SPRITE-02 bindAtlas input) +// --------------------------------------------------------------------------- + +// Expands the 8-bit alpha glyph atlas into the white-on-alpha RGBA8 +// bytes the M2-SPRITE-02 `bindAtlas` uploads (one call per font per +// scene load — the SETUP/asset path, before the one-time texture +// upload, RENDER-004). Every texel becomes (255, 255, 255, alpha): +// the M2-SPRITE-02 shader's multiplicative tint then makes the drawn +// color = tint × glyph alpha (the header's glyph-quad section — the +// standard text coloring). O(W·H); no allocation (the caller owns the +// span — one W·H·4-byte buffer at set-up), no logging, no GL. +// +// Fails (no GL work, no log — the precondition contract): a stopped +// atlas, or `rgba` not exactly `atlasWidth * atlasHeight * 4` bytes → +// InvalidArgument. +[[nodiscard]] inline laige::Status expandGlyphAtlasRgba8( + const GlyphAtlas& atlas, std::span rgba) noexcept { + if (!atlas.valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + const std::uint64_t need = + static_cast(atlas.atlasWidth()) * + static_cast(atlas.atlasHeight()) * 4u; + if (rgba.size() != need) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + const std::span alpha = atlas.atlas(); + for (std::uint64_t i = 0; i < need / 4; ++i) { + const std::uint8_t a = alpha[static_cast(i)]; + std::uint8_t* px = rgba.data() + i * 4; + px[0] = 255; + px[1] = 255; + px[2] = 255; + px[3] = a; + } + return laige::Status{}; +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 7b24b93..33b0758 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -171,7 +171,20 @@ # (key, position) sequence, the machine-greppable state hash), the # 1 000-frame zero-allocation declare loop, and the PRD §8.1-style # 10 000-particle conversion budget gate) — pure data + batcher -# bookkeeping: no GL environment needed. +# bookkeeping: no GL environment needed; and the text items +# (M2-TEXT-02) — the UI-pass text half of FR-2.8 (text_items_tests.cpp's +# Text* suites: the StringTable's intern/dedup/bounds/stopped/move +# contract, the width-measurement goldens on the committed test font, +# the exact wrap model (word wrap, the character split, the oversized +# single-character line, the alignment anchors, the line step), the +# per-glyph quad goldens (pos/scale/uv/tint/depthOverride against the +# M2-TEXT-01 oracle), the group + in-group order contract, the failure +# paths incl. the nothing-declared screen-domain check, the 1 000-frame +# zero-allocation declare loop, and the offscreen GL smoke — the +# white-on-alpha RGBA8 upload through the M2-SPRITE-02 submit stage: +# the draw-call count + exact pixel checks against the atlas bytes; the +# smoke needs a usable OpenGL 3.3 environment and GTEST_SKIPs where +# absent) — pure data + batcher bookkeeping except the GL smoke. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -227,7 +240,7 @@ add_executable(laige-render_tests depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp 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) + font_tests.cpp text_items_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 @@ -418,6 +431,22 @@ set_tests_properties(font PROPERTIES ENVIRONMENT "LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") +# M2-TEXT-02: the step's Verify command is `ctest -R text_items`. The +# StringTable*/TextMeasure*/TextLayout*/TextDeclare*/TextZeroAlloc* +# suites are pure data + batcher bookkeeping (no GL calls) — they run +# in every local tree and in CI. The TextSmoke* suite needs the same +# usable OpenGL 3.3 environment as the SpriteDrawSmoke suite (it +# GTEST_SKIPs where absent — the documented environment contract) and +# reads the committed test font through LAIGE_TEST_FONT_PATH. No +# budget gate: the step's roadmap scope has no standalone budgets.json +# entry (the per-frame text cost is part of the composite 50k +# render-CPU budget, measured with M2-PERF-01). +add_test(NAME text_items COMMAND laige-render_tests + --gtest_filter=TextStringTable.*:TextMeasure.*:TextLayout.*:TextDeclare.*:TextZeroAlloc.*:TextSmoke.*) +set_tests_properties(text_items PROPERTIES + ENVIRONMENT + "LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") + 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) @@ -459,7 +488,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 - PROPERTIES + text_items 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() @@ -485,10 +514,12 @@ if(LAIGE_ASAN) # stays fully active (the smoke still performs its GL work under # the sanitizer). The test-level ENVIRONMENT replaces the job-level # ASAN_OPTIONS (GitHub workflow), so mirror its options here. - # The unfiltered entry runs the Font* suites too (M2-TEXT-01), so the - # replacement environment carries the test-font path as well. + # The unfiltered entry runs the Font* suites too (M2-TEXT-01) and the + # TextSmoke* suite (M2-TEXT-02 — the offscreen smoke creates the same + # headless context as the other render entries), so the replacement + # environment carries the test-font path as well. set_tests_properties(laige-render_tests gl_context render_thread - sprite_draw render_counters PROPERTIES + sprite_draw render_counters text_items PROPERTIES ENVIRONMENT "ASAN_OPTIONS=abort_on_error=1:halt_on_error=1:detect_leaks=0:log_path=${CMAKE_SOURCE_DIR}/asan-reports/asan;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() @@ -538,3 +569,8 @@ set_tests_properties(particle_render PROPERTIES TIMEOUT 120) # a few times (create + determinism re-creates) — small work, 60s is # generous headroom, like the other pure-data entries. set_tests_properties(font PROPERTIES TIMEOUT 60) +# The `text_items` entry includes the offscreen GL smoke (a 1024x1024 +# white-on-alpha glyph-atlas upload + one frame + pixel reads) plus the +# 1 000-frame zero-allocation declare loop — generous headroom for a +# slow headless runner, like the `render_counters` entry. +set_tests_properties(text_items PROPERTIES TIMEOUT 120) diff --git a/tests/laige-render/text_items_tests.cpp b/tests/laige-render/text_items_tests.cpp new file mode 100644 index 0000000..55afd39 --- /dev/null +++ b/tests/laige-render/text_items_tests.cpp @@ -0,0 +1,1110 @@ +// laige-render text items tests (M2-TEXT-02): the step's Verify command +// is `ctest -R text_items`. +// +// The contract pinned against: the StringTable (PRD §10.4 — intern, +// dedup, the bounded pool, the stopped state, the move semantics, the +// capacity failure + one rate-limited warn), the width measurement +// (the hand-computed goldens on the committed test font, the empty / +// all-space behavior, the failure preconditions), the exact wrap model +// (word wrap at max width, the character split, the oversized +// single-character line, the collapsed spaces, the line count / line +// step / alignment offsets), the declaration goldens (the per-glyph +// quad pos / scale / uv / tint / depthOverride fields against the +// M2-TEXT-01 oracle + hand-computed ink rects, the group count, the +// in-group declaration order, the failure paths incl. the +// nothing-declared domain check, the batcher overflow policy), the +// 1 000-frame zero-allocation declare loop (PERF-003), and the offscreen +// GL smoke (the white-on-alpha RGBA8 upload through the M2-SPRITE-02 +// submit stage: the draw-call count + exact pixel checks against the +// atlas bytes). +// +// The StringTable* suites need no GL and no font asset. The TextMeasure* +// / TextLayout* / TextDeclare* / TextZeroAlloc* suites read the +// committed test font (tests/laige-render/assets/vera.ttf — the +// LAIGE_TEST_FONT_PATH env var, CTest ENVIRONMENT) but call no GL. The +// TextSmoke* suite needs a usable OpenGL 3.3 environment (it +// GTEST_SKIPs where absent — the documented environment contract, not +// an engine failure). No budget gate: the step's roadmap scope has no +// standalone budgets.json entry (the per-frame text cost is part of the +// composite 50k render-CPU budget, M2-PERF-01). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/render/gl_context.h" +#include "laige/render/matrices.h" +#include "laige/render/sprite_renderer.h" +#include "laige/render/text_items.h" +#include "laige/result.h" +#include "laige_test_seed.h" + +namespace { + +using laige::ErrorCode; +using laige::Result; +using laige::Status; +using laige::render::BlendMode; +using laige::render::GlyphAtlas; +using laige::render::GlyphMetrics; +using laige::render::Mat4; +using laige::render::SpriteBatch; +using laige::render::SpriteBatcher; +using laige::render::SpriteDrawStats; +using laige::render::SpriteItem; +using laige::render::SpriteTint; +using laige::render::SpriteRenderer; +using laige::render::StringRef; +using laige::render::StringTable; +using laige::render::TextAlignment; +using laige::render::TextItem; +using laige::render::declareText; +using laige::render::expandGlyphAtlasRgba8; +using laige::render::measureText; +using laige::render::kTextMaxScale; + +// The committed test font (tests/laige-render/assets/vera.ttf — the +// LAIGE_TEST_FONT_PATH env var, CTest ENVIRONMENT). The cross-platform +// env read + open (tests/support/laige_test_seed.h; fopen_s on MSVC — +// C4996; a read-only asset needs no _SH_DENYNO). +std::vector loadFont() { + const std::string path = laige::testing::ReadEnvVar("LAIGE_TEST_FONT_PATH"); + if (path.empty()) { + ADD_FAILURE() << "LAIGE_TEST_FONT_PATH is not set (CTest ENVIRONMENT " + "contract — see tests/laige-render/CMakeLists.txt)"; + return {}; + } + std::FILE* f = nullptr; +#if defined(_MSC_VER) + if (fopen_s(&f, path.c_str(), "rb") != 0) { + ADD_FAILURE() << "Cannot open the test font: " << path; + return {}; + } +#else + f = std::fopen(path.c_str(), "rb"); + if (f == nullptr) { + ADD_FAILURE() << "Cannot open the test font: " << path; + return {}; + } +#endif + std::fseek(f, 0, SEEK_END); + const long size = std::ftell(f); + std::fseek(f, 0, SEEK_SET); + std::vector bytes(static_cast(size)); + const std::size_t got = std::fread(bytes.data(), 1, bytes.size(), f); + std::fclose(f); + if (got != bytes.size()) { + ADD_FAILURE() << "Short read of the test font: " << path; + return {}; + } + return bytes; +} + +// The default atlas (the M2-TEXT-01 defaults: 1024x1024, fontSize 16, +// range 32..255). Aborts on failure (the asset is committed — a +// failure is a broken test environment, not engine behavior). +GlyphAtlas makeAtlas() { + auto r = GlyphAtlas::create(loadFont(), GlyphAtlas::Options{}); + if (!r.ok()) { + std::fprintf(stderr, "makeAtlas: create failed\n"); + std::abort(); + } + return std::move(r).takeValue(); +} + +StringTable makeTable(std::uint32_t maxStrings, std::uint32_t maxCodePoints) { + auto r = StringTable::create( + StringTable::Options{maxStrings, maxCodePoints}); + if (!r.ok()) { + std::fprintf(stderr, "makeTable: create failed\n"); + std::abort(); + } + return std::move(r).takeValue(); +} + +// Interns an ASCII literal as u32 code points (the header's model: +// one u32 per code point; ASCII is its own code point value). +StringRef internText(StringTable& table, const char* s) { + std::vector codes; + codes.reserve(std::strlen(s)); + for (const char* p = s; *p != '\0'; ++p) { + codes.push_back(static_cast( + static_cast(*p))); + } + auto r = table.intern(codes); + if (!r.ok()) { + std::fprintf(stderr, "internText: intern failed\n"); + std::abort(); + } + return r.value(); +} + +// The frame's declared quads in batcher order (the single-group +// scenes of these tests: the in-group order IS the declaration +// order — the batcher's stable sort, equal keys). +void declaredQuads(SpriteBatcher& batcher, std::vector& out) { + out.clear(); + const auto batches = batcher.batches(); + EXPECT_EQ(1u, batches.size()); + if (batches.empty()) return; + for (std::uint32_t slot : batches.front().instances) { + out.push_back(batcher.at(slot)); + } +} + +// The quad's ink rect (screen px, y down) from the centered-quad model +// (the M2-SPRITE-02 contract): pos = ink center, scale = ink size. +struct InkRect { + float left{}; + float top{}; + float width{}; + float height{}; +}; + +InkRect inkOf(const SpriteItem& q) { + return InkRect{q.pos.x - q.scale.x * 0.5f, q.pos.y - q.scale.y * 0.5f, + q.scale.x, q.scale.y}; +} + +// Log capture (the sprite_batcher_tests MemorySink pattern — rate +// limiting OFF so the tests assert per-event counts). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + }; + void emit(const laige::log::LogRecord& record) override { + entries.push_back( + Entry{record.severity, std::string(record.subsystem), + std::string(record.event)}); + } + void flush() override {} + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + std::abort(); + } + return ptr; +} + +std::size_t countEvent(const MemorySink* sink, const char* subsystem, + const char* event) { + std::size_t n = 0; + for (const auto& e : sink->entries) { + if (e.subsystem == subsystem && e.event == event) ++n; + } + return n; +} + +} // namespace + +// --------------------------------------------------------------------------- +// StringTable — the PRD §10.4 string storage (no GL, no font asset) +// --------------------------------------------------------------------------- + +TEST(TextStringTable, CreateValidation) { + // First failure wins, InvalidArgument, no log (the create-validation + // precedent). + auto r = StringTable::create(StringTable::Options{0, 100}); + EXPECT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + r = StringTable::create(StringTable::Options{100, 0}); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + r = StringTable::create( + StringTable::Options{laige::render::kStringTableMaxStrings + 1, 100}); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + r = StringTable::create( + StringTable::Options{100, laige::render::kStringTableMaxCodePoints + 1}); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + // The defaults + the documented introspection. + auto ok = StringTable::create(StringTable::Options{}); + ASSERT_TRUE(ok.ok()); + const StringTable table = std::move(ok).takeValue(); + EXPECT_TRUE(table.valid()); + EXPECT_EQ(0u, table.count()); + EXPECT_EQ(0u, table.storedCodePoints()); + EXPECT_EQ(laige::render::kStringTableDefaultStrings, + table.stringCapacity()); + EXPECT_EQ(laige::render::kStringTableDefaultCodePoints, + table.codePointCapacity()); +} + +TEST(TextStringTable, InternAndDedup) { + StringTable table = makeTable(8, 100); + StringRef a = internText(table, "hello"); + StringRef b = internText(table, "hello"); + StringRef c = internText(table, "world"); + StringRef e = internText(table, ""); + ASSERT_EQ(a.id, b.id); // dedup: same bytes -> same handle + EXPECT_NE(a.id, c.id); + EXPECT_NE(a.id, e.id); + EXPECT_EQ(3u, table.count()); + EXPECT_EQ(10u, table.storedCodePoints()); // 5 + 5 + 0 (dedup: one hello) + ASSERT_TRUE(table.isValid(a)); + EXPECT_EQ(5u, static_cast(table.text(a).size())); + EXPECT_EQ(5u, static_cast(table.text(b).size())); + EXPECT_EQ(5u, static_cast(table.text(c).size())); + EXPECT_EQ(0u, static_cast(table.text(e).size())); + // The span round trip (the exact code points). + const std::uint32_t expect[5] = {'h', 'e', 'l', 'l', 'o'}; + EXPECT_EQ(0, std::memcmp(table.text(a).data(), expect, sizeof(expect))); + // An invalid handle reads empty + is not valid (the total read). + EXPECT_FALSE(table.isValid(StringRef{99})); + EXPECT_EQ(0u, static_cast(table.text(StringRef{99}).size())); +} + +TEST(TextStringTable, CapacityBounds) { + MemorySink* sink = installCaptureSink(); + // The string-count bound (one rate-limited warn per failure, the + // LOG-004 facade — rate limiting is OFF in the capture sink, so + // exactly one per call). + { + StringTable table = makeTable(2, 100); + ASSERT_TRUE(internText(table, "aa").id == 0); + ASSERT_TRUE(internText(table, "bb").id == 1); + std::vector c3{'c', 'c'}; + auto r = table.intern(c3); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::BudgetExhausted, r.error()); + EXPECT_EQ(1u, countEvent(sink, "string_table", "table_full")); + EXPECT_EQ(2u, table.count()); // the rejected intern stored nothing + } + // The code-point pool bound (a string that exactly fits is OK; the + // next one fails on the pool, not the count). + { + StringTable table = makeTable(8, 5); + ASSERT_TRUE(internText(table, "abc").id == 0); + ASSERT_TRUE(internText(table, "de").id == 1); // stored: 3 + 2 = 5 + std::vector c3{'x', 'y', 'z'}; + auto r = table.intern(c3); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::BudgetExhausted, r.error()); + EXPECT_EQ(2u, countEvent(sink, "string_table", "table_full")); + EXPECT_EQ(2u, table.count()); + EXPECT_EQ(5u, table.storedCodePoints()); + } +} + +TEST(TextStringTable, StoppedState) { + StringTable stopped; + EXPECT_FALSE(stopped.valid()); + std::vector c{'a'}; + auto r = stopped.intern(c); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, stopped.count()); + EXPECT_FALSE(stopped.isValid(StringRef{0})); + EXPECT_EQ(0u, static_cast(stopped.text(StringRef{0}).size())); + // No log for the stopped-state failure (the no-log precondition). + MemorySink* sink = installCaptureSink(); + (void)stopped.intern(c); + EXPECT_EQ(0u, countEvent(sink, "string_table", "table_full")); +} + +TEST(TextStringTable, MoveSemantics) { + auto r = StringTable::create(StringTable::Options{4, 16}); + ASSERT_TRUE(r.ok()); + StringTable a = std::move(r).takeValue(); + StringRef ref = internText(a, "move"); + StringTable b = std::move(a); + EXPECT_FALSE(a.valid()); // moved-from: stopped + EXPECT_TRUE(b.valid()); + ASSERT_TRUE(b.isValid(ref)); + EXPECT_EQ(4u, static_cast(b.text(ref).size())); +} + +// --------------------------------------------------------------------------- +// 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) +// --------------------------------------------------------------------------- + +TEST(TextMeasure, WidthGoldens) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(16, 256); + struct Case { + const char* text; + std::uint32_t scale; + std::int64_t width; + }; + const Case cases[] = { + {"a", 1, 8}, + {"M", 1, 12}, + {"Ma", 1, 20}, + {"Ma.", 1, 24}, + {"a b", 1, 21}, // 'b' advances 9 (measured, not assumed) + {"a b", 1, 21}, // consecutive spaces collapse to one advance + {"aa aa", 1, 36}, + {"W a", 1, 26}, + {"a", 2, 16}, + {"Ma", 2, 40}, + {"Ma.", 4, 96}, + }; + for (const Case& c : cases) { + StringRef ref = internText(table, c.text); + auto r = measureText(font, table, ref, c.scale); + ASSERT_TRUE(r.ok()) << c.text; + EXPECT_EQ(c.width, r.value()) << c.text << " @ scale " << c.scale; + } +} + +TEST(TextMeasure, EmptyAndAllSpace) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(8, 64); + StringRef empty = internText(table, ""); + StringRef spaces = internText(table, " "); + StringRef padded = internText(table, " a "); + auto r = measureText(font, table, empty, 1); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(0, r.value()); + r = measureText(font, table, spaces, 1); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(0, r.value()); + r = measureText(font, table, padded, 1); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(8, r.value()); // leading/trailing spaces carry no width +} + +TEST(TextMeasure, FailurePreconditions) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(8, 64); + StringRef ref = internText(table, "a"); + GlyphAtlas stopped; + // A stopped/missing font. + auto r = measureText(stopped, table, ref, 1); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + r = measureText(font, table, ref, 0); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + r = measureText(font, table, ref, kTextMaxScale + 1); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + // A handle the table does not own. + r = measureText(font, table, StringRef{99}, 1); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); +} + +// --------------------------------------------------------------------------- +// declareText — the layout (wrap model, alignment, line step) +// --------------------------------------------------------------------------- + +TEST(TextLayout, LineCountAndStep) { + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& mM = *font.glyph('M'); + const GlyphMetrics& ma = *font.glyph('a'); + ASSERT_EQ(12, mM.advance); + ASSERT_EQ(8, ma.advance); + StringTable table = makeTable(16, 256); + struct Case { + const char* text; + std::int32_t maxWidth; + std::uint32_t lines; + std::uint32_t glyphsPerLine[8]; + }; + // Hand-computed from the measured advances (M 12, a 8, b 9, space 4): + const Case cases[] = { + {"aa aa", 0, 1, {4}}, // no wrap + {"aa aa", 36, 1, {4}}, // 16 + 4 + 16 = 36 fits exactly + {"aa aa", 20, 2, {2, 2}}, // 16 + 4 + 16 = 36 > 20 + {"MMMM", 24, 2, {2, 2}}, // 48 > 24: split MM | MM + {"MMMM", 12, 4, {1, 1, 1, 1}}, // each M (12) its own line + {"MMMM", 11, 4, {1, 1, 1, 1}}, // oversized: lines exceed maxWidth + {"a b c", 16, 3, {1, 1, 1}}, // 8 + 4 + 9 = 21 > 16 per word pair + {"a b c", 21, 2, {2, 1}}, // "a b" = 8 + 4 + 9 = 21 fits; "c" next + {"a b ", 16, 2, {1, 1}}, // the trailing space owns no line + {"a b", 21, 1, {2}}, // collapsed spaces: 8 + 4 + 9 = 21 + }; + for (const Case& c : cases) { + StringRef ref = internText(table, c.text); + auto bc = SpriteBatcher::create({256}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 0.0f; + item.y = 20.0f; + item.scale = 1; + item.maxWidth = c.maxWidth; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.ok()) << c.text << " maxWidth " << c.maxWidth; + ASSERT_TRUE(batcher.build().ok()); + std::vector quads; + declaredQuads(batcher, quads); + // The line count: the distinct baselines (the exact integer + // 20 + 16 * lineIndex at scale 1, one presentation-only float). + // The glyph of a quad: its cell's (u0, v0) — the shelf packing + // gives every ink glyph a distinct cell (M2-TEXT-01). + auto codeOf = [&](const SpriteItem& q) { + for (std::uint32_t code = 32; code <= 255; ++code) { + const GlyphMetrics* m = font.glyph(code); + if (static_cast(m->atlasX) / 1024.0f == q.uv.u0 && + static_cast(m->atlasY) / 1024.0f == q.uv.v0) { + return code; + } + } + ADD_FAILURE() << "no atlas cell matches the quad (u0, v0)"; + return 0u; + }; + std::vector perLine; + for (const SpriteItem& q : quads) { + // The baseline = ink top + bearingY (the ink bottom is NOT the + // baseline for ascenders/descenders). + const float inkTop = q.pos.y - q.scale.y * 0.5f; + const float baseline = + inkTop + static_cast(font.glyph(codeOf(q))->bearingY); + if (perLine.empty()) { + perLine.push_back(1); + } else if (std::fabs(baseline - + (20.0f + 16.0f * + static_cast(perLine.size() - 1))) < + 0.001f) { + perLine.back() += 1; + } else { + perLine.push_back(1); + } + // The baseline is the exact integer of its line (line step 16). + const float expectedBaseline = + 20.0f + 16.0f * static_cast(perLine.size() - 1); + EXPECT_FLOAT_EQ(expectedBaseline, baseline) << c.text; + } + ASSERT_EQ(c.lines, perLine.size()) << c.text << " maxWidth " + << c.maxWidth; + for (std::uint32_t i = 0; i < c.lines; ++i) { + EXPECT_EQ(c.glyphsPerLine[i], perLine[i]) << c.text << " line " << i; + } + } +} + +TEST(TextLayout, AlignmentAnchors) { + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& mM = *font.glyph('M'); + const GlyphMetrics& ma = *font.glyph('a'); + ASSERT_EQ(1, mM.bearingX); + ASSERT_EQ(12, mM.advance); + ASSERT_EQ(8, ma.advance); + StringTable table = makeTable(8, 64); + StringRef ref = internText(table, "Ma"); // width 20 + struct Case { + TextAlignment alignment; + float x; + float expectedMInkLeft; + }; + const Case cases[] = { + {TextAlignment::Left, 50.0f, 50.0f + 1.0f}, // anchor = left edge + {TextAlignment::Center, 50.0f, 50.0f - 10.0f + 1.0f}, // 50 - 20/2 + {TextAlignment::Right, 50.0f, 50.0f - 20.0f + 1.0f}, // 50 - 20 + }; + for (const Case& c : cases) { + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = c.x; + item.y = 20.0f; + item.scale = 1; + item.alignment = c.alignment; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.ok()); + ASSERT_TRUE(batcher.build().ok()); + std::vector quads; + declaredQuads(batcher, quads); + ASSERT_EQ(2u, quads.size()); + EXPECT_FLOAT_EQ(c.expectedMInkLeft, inkOf(quads[0]).left); + } +} + +TEST(TextLayout, ScaleDoublesEverything) { + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& mM = *font.glyph('M'); + ASSERT_EQ(10, mM.width); + ASSERT_EQ(11, mM.height); + ASSERT_EQ(1, mM.bearingX); + ASSERT_EQ(11, mM.bearingY); + StringTable table = makeTable(8, 64); + StringRef ref = internText(table, "M"); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 10.0f; + item.y = 30.0f; + item.scale = 2; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.ok()); + ASSERT_TRUE(batcher.build().ok()); + std::vector quads; + declaredQuads(batcher, quads); + ASSERT_EQ(1u, quads.size()); + const InkRect ink = inkOf(quads[0]); + // The 2x ink rect: left 10 + 1*2 = 12, top 30 - 11*2 = 8, 20 x 22. + EXPECT_FLOAT_EQ(12.0f, ink.left); + EXPECT_FLOAT_EQ(8.0f, ink.top); + EXPECT_FLOAT_EQ(20.0f, ink.width); + EXPECT_FLOAT_EQ(22.0f, ink.height); +} + +TEST(TextLayout, EmptyTextDeclaresNothing) { + StringTable table = makeTable(8, 64); + StringRef empty = internText(table, ""); + StringRef spaces = internText(table, " "); + GlyphAtlas font = makeAtlas(); + for (StringRef ref : {empty, spaces}) { + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 0.0f; + item.y = 20.0f; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(0u, batcher.frameCount()); + ASSERT_TRUE(batcher.build().ok()); + EXPECT_EQ(0u, batcher.batches().size()); + } +} + +// --------------------------------------------------------------------------- +// declareText — the per-glyph quad goldens (the M2-TEXT-01 oracle + the +// hand-computed ink rects) + the group / order contract +// --------------------------------------------------------------------------- + +TEST(TextDeclare, QuadGoldens) { + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& mM = *font.glyph('M'); + const GlyphMetrics& ma = *font.glyph('a'); + // The pinned M2-TEXT-01 goldens at 16 px (vera.ttf): + ASSERT_EQ(12, mM.advance); + ASSERT_EQ(10, mM.width); + ASSERT_EQ(11, mM.height); + ASSERT_EQ(1, mM.bearingX); + ASSERT_EQ(11, mM.bearingY); + ASSERT_EQ(8, ma.advance); + ASSERT_EQ(8, ma.width); + ASSERT_EQ(9, ma.height); + ASSERT_EQ(8, ma.bearingY); + StringTable table = makeTable(8, 64); + StringRef ref = internText(table, "Ma"); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 10.0f; + item.y = 20.0f; + item.scale = 2; + item.color = SpriteTint{0.5f, 0.25f, 1.0f, 1.0f}; + item.depthKey = 123; + item.atlasId = 7; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.ok()); + ASSERT_EQ(2u, batcher.frameCount()); + ASSERT_TRUE(batcher.build().ok()); + std::vector quads; + declaredQuads(batcher, quads); + ASSERT_EQ(2u, quads.size()); + const SpriteItem& qM = quads[0]; + const SpriteItem& qa = quads[1]; + // The M quad (hand-computed): ink left 10 + 1*2 = 12, top 20 - 11*2 = + // -2, 20 x 22; the uv is the cell rect over the 1024^2 atlas. + EXPECT_FLOAT_EQ(12.0f + 10.0f, qM.pos.x); // inkLeft + w/2 + EXPECT_FLOAT_EQ(-2.0f + 11.0f, qM.pos.y); // inkTop + h/2 + EXPECT_FLOAT_EQ(20.0f, qM.scale.x); + EXPECT_FLOAT_EQ(22.0f, qM.scale.y); + const float invW = 1.0f / 1024.0f; + EXPECT_FLOAT_EQ(static_cast(mM.atlasX) * invW, qM.uv.u0); + EXPECT_FLOAT_EQ(static_cast(mM.atlasY) * invW, qM.uv.v0); + EXPECT_FLOAT_EQ(static_cast(mM.atlasX + 10u) * invW, qM.uv.u1); + EXPECT_FLOAT_EQ(static_cast(mM.atlasY + 11u) * invW, qM.uv.v1); + // The a quad: the pen advanced by M's advance (12 * 2) from x = 10. + EXPECT_FLOAT_EQ(10.0f + 24.0f + 2.0f * ma.bearingX + 8.0f, qa.pos.x); + EXPECT_FLOAT_EQ(20.0f - 16.0f + 9.0f, qa.pos.y); + EXPECT_FLOAT_EQ(16.0f, qa.scale.x); + EXPECT_FLOAT_EQ(18.0f, qa.scale.y); + EXPECT_FLOAT_EQ(static_cast(ma.atlasX) * invW, qa.uv.u0); + EXPECT_FLOAT_EQ(static_cast(ma.atlasY) * invW, qa.uv.v0); + EXPECT_FLOAT_EQ(static_cast(ma.atlasX + 8u) * invW, qa.uv.u1); + EXPECT_FLOAT_EQ(static_cast(ma.atlasY + 9u) * invW, qa.uv.v1); + // The shared declaration fields (the header's glyph-quad section). + for (const SpriteItem* q : {&qM, &qa}) { + EXPECT_EQ(123u, q->depthKey); + EXPECT_TRUE(q->depthOverride); // the G-R11 UI-z escape hatch + EXPECT_EQ(0u, q->frameIndex); + EXPECT_FLOAT_EQ(0.0f, q->rotation); + EXPECT_FLOAT_EQ(0.5f, q->tint.r); + EXPECT_FLOAT_EQ(0.25f, q->tint.g); + EXPECT_FLOAT_EQ(1.0f, q->tint.b); + EXPECT_FLOAT_EQ(1.0f, q->tint.a); + EXPECT_EQ(7u, q->atlasId); + EXPECT_EQ(0u, q->materialId); + EXPECT_EQ(BlendMode::Alpha, q->blend); // the text convention + } +} + +TEST(TextDeclare, GroupsAndOrder) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(16, 256); + StringRef dot = internText(table, "Ma."); + StringRef second = internText(table, "ab"); + auto bc = SpriteBatcher::create({64}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem a; + a.text = dot; + a.font = &font; + a.x = 0.0f; + a.y = 0.0f; + a.atlasId = 7; + TextItem b; + b.text = second; + b.font = &font; + b.x = 0.0f; + b.y = 32.0f; + b.atlasId = 3; + ASSERT_TRUE(declareText(batcher, table, a).ok()); + ASSERT_TRUE(declareText(batcher, table, b).ok()); + ASSERT_EQ(5u, batcher.frameCount()); // 3 + 2 glyphs + ASSERT_TRUE(batcher.build().ok()); + const auto batches = batcher.batches(); + // One (atlas, material, blend) group per atlas id — ascending + // (RENDER-001/003): atlas 3 first, then 7 (the FR-2.1 group key). + ASSERT_EQ(2u, batches.size()); + EXPECT_EQ(3u, batches[0].atlasId); + EXPECT_EQ(2u, batches[0].instances.size()); + EXPECT_EQ(7u, batches[1].atlasId); + EXPECT_EQ(3u, batches[1].instances.size()); + // The in-group order is the declaration order (equal keys, the + // batcher's stable sort — the M, a, . sequence on line 0). + const GlyphMetrics& mM = *font.glyph('M'); + const GlyphMetrics& mDot = *font.glyph('.'); + const SpriteItem& first = batcher.at(batches[1].instances[0]); + const SpriteItem& last = batcher.at(batches[1].instances[2]); + EXPECT_FLOAT_EQ(static_cast(mM.atlasX) / 1024.0f, first.uv.u0); + EXPECT_FLOAT_EQ(static_cast(mDot.atlasX) / 1024.0f, last.uv.u0); +} + +TEST(TextDeclare, FailurePaths) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(64, 8192); + StringRef ref = internText(table, "Ma"); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 0.0f; + item.y = 0.0f; + // The stopped batcher (the first add fails — BudgetExhausted, nothing + // declared). + { + SpriteBatcher stopped; + auto r = declareText(stopped, table, item); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::BudgetExhausted, r.error()); + EXPECT_EQ(0u, stopped.frameCount()); + } + // The closed window (after build) — InvalidArgument, nothing added. + { + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareText(batcher, table, item).ok()); + ASSERT_TRUE(batcher.build().ok()); + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(2u, batcher.frameCount()); // the first call's quads only + } + // A null / stopped font (InvalidArgument, nothing declared). + { + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + item.font = nullptr; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + GlyphAtlas stopped; + item.font = &stopped; + r = declareText(batcher, table, item); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + item.font = &font; + } + // An invalid handle (the table does not own it). + { + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + item.text = StringRef{999}; + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + item.text = ref; + } + // The out-of-domain fields (scale, maxWidth). + { + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + item.scale = 0; + EXPECT_TRUE(declareText(batcher, table, item).isError()); + item.scale = kTextMaxScale + 1; + EXPECT_TRUE(declareText(batcher, table, item).isError()); + item.scale = 1; + item.maxWidth = -1; + EXPECT_TRUE(declareText(batcher, table, item).isError()); + item.maxWidth = static_cast( + laige::render::kTextMaxLineWidthPx) + 1; + EXPECT_TRUE(declareText(batcher, table, item).isError()); + item.maxWidth = 0; + EXPECT_EQ(0u, batcher.frameCount()); // nothing declared by any fail + } + // The screen domain: a no-wrap line wider than kTextMaxLineWidthPx + // (8192 Ms at 16x: 8192 * 12 * 16 = 1 572 864 px > 2^20) declares + // NOTHING (the pre-check runs before the first add). + { + // Its own table (the shared one already holds "Ma"'s 2 code points + // and would fail the pool bound, not the screen domain). + StringTable big = makeTable(8, 8192); + std::vector ms(8192, 'M'); + auto ri = big.intern(ms); + ASSERT_TRUE(ri.ok()); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + item.text = ri.value(); + item.scale = 16; + item.maxWidth = 0; + auto r = declareText(batcher, big, item); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + EXPECT_EQ(0u, batcher.frameCount()); + } +} + +TEST(TextDeclare, BatcherOverflowDropsOldest) { + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(8, 64); + StringRef ref = internText(table, "Ma."); // 3 ink glyphs + auto bc = SpriteBatcher::create({2}); // the frame holds only 2 + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 0.0f; + item.y = 0.0f; + // The batcher's own overflow policy (drop oldest + warn, PERF-008) + // bounds the frame — declareText itself succeeds. + auto r = declareText(batcher, table, item); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(2u, batcher.frameCount()); + ASSERT_TRUE(batcher.build().ok()); + std::vector quads; + declaredQuads(batcher, quads); + ASSERT_EQ(2u, quads.size()); + // The oldest quad (the M) was dropped: the survivors are a, . in + // declaration order (the M2-TEXT-01 oracle cells). + const GlyphMetrics& ma = *font.glyph('a'); + const GlyphMetrics& mDot = *font.glyph('.'); + EXPECT_FLOAT_EQ(static_cast(ma.atlasX) / 1024.0f, quads[0].uv.u0); + EXPECT_FLOAT_EQ(static_cast(mDot.atlasX) / 1024.0f, + quads[1].uv.u0); +} + +// --------------------------------------------------------------------------- +// expandGlyphAtlasRgba8 — the white-on-alpha upload bytes +// --------------------------------------------------------------------------- + +TEST(TextDeclare, ExpandRgba8) { + GlyphAtlas font = makeAtlas(); + std::vector rgba; + // The wrong-size span (nothing expanded). + rgba.assign(8, 0); + auto r = expandGlyphAtlasRgba8(font, rgba); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(ErrorCode::InvalidArgument, r.error()); + // The exact size: 1024 x 1024 x 4. + rgba.assign(1024u * 1024u * 4u, 0); + ASSERT_TRUE(expandGlyphAtlasRgba8(font, rgba).ok()); + const std::span alpha = font.atlas(); + // Spot checks: white-on-alpha (R = G = B = 255, A = the atlas byte). + for (std::uint32_t i : {0u, 1u, 1024u * 17u + 3u, 1024u * 1023u + 1023u}) { + const std::uint8_t a = alpha[i]; + EXPECT_EQ(255u, rgba[i * 4 + 0]); + EXPECT_EQ(255u, rgba[i * 4 + 1]); + EXPECT_EQ(255u, rgba[i * 4 + 2]); + EXPECT_EQ(a, rgba[i * 4 + 3]); + } + // A stopped atlas (nothing expanded). + GlyphAtlas stopped; + rgba.assign(8, 0); + EXPECT_TRUE(expandGlyphAtlasRgba8(stopped, rgba).isError()); +} + +// --------------------------------------------------------------------------- +// TextZeroAlloc — the per-frame declare loop allocates nothing +// (PERF-003) +// --------------------------------------------------------------------------- + +TEST(TextZeroAlloc, DeclareLoopAllocatesNothing) { + constexpr std::uint32_t kFrames = 1000; + GlyphAtlas font = makeAtlas(); + StringTable table = makeTable(8, 64); + StringRef ref = + internText(table, "The quick brown fox jumps over the lazy dog"); + auto bc = SpriteBatcher::create({512}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 0.0f; + item.y = 0.0f; + item.scale = 2; + item.maxWidth = 40; // a multi-line wrapped text (the loop's shape) + auto runFrames = [&]() { + for (std::uint32_t f = 0; f < kFrames; ++f) { + batcher.beginFrame(); + if (!declareText(batcher, table, item).ok()) { + std::fprintf(stderr, "TextZeroAlloc: declare failed\n"); + std::abort(); + } + if (!batcher.build().ok()) { + std::fprintf(stderr, "TextZeroAlloc: build failed\n"); + std::abort(); + } + } + }; + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + runFrames(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(0ull, reading.allocs) + << kFrames << " frames of a wrapped 2x-scale declare allocated " + << reading.allocs << " heap blocks on the loop thread"; + std::printf("text-items-zeroalloc frames=%u allocs=%llu\n", kFrames, + static_cast(reading.allocs)); + } else { + runFrames(); // sanitizer tree: the leak-free run covers it + } +} + +// --------------------------------------------------------------------------- +// TextSmoke — the offscreen GL render (the M2-SPRITE-02 submit stage +// with the white-on-alpha glyph atlas; GTEST_SKIPs where no usable +// OpenGL 3.3 environment — the documented environment contract) +// --------------------------------------------------------------------------- + +namespace { + +constexpr std::int32_t kSmokeWidth = 64; +constexpr std::int32_t kSmokeHeight = 64; + +// The UI pass's identity screen matrix (screen px, y down -> NDC): +// x_ndc = 2x/w - 1, y_ndc = 1 - 2y/h (the M2-UI-02 plane). +Mat4 smokeMatrix() { + // Column-major (the house Mat4 convention): the linear part in + // columns 0..1, the translation in column 3 (the reference + // rasterizer's a30/a31 — sprite_draw_tests' makeRefQuad). + Mat4 m(1.0f); // the identity + m[0].x = 2.0f / static_cast(kSmokeWidth); + m[1].y = -2.0f / static_cast(kSmokeHeight); + m[3].x = -1.0f; + m[3].y = 1.0f; + return m; +} + +} // namespace + +TEST(TextSmoke, OffscreenTextFrame) { + auto ctx = laige::render::GlContext::createHeadless(kSmokeWidth, + kSmokeHeight); + if (!ctx.ok()) { + GTEST_SKIP() << "No usable OpenGL 3.3 environment: " + << laige::errorText(ctx.error()) + << " (the documented environment contract — the P0 CI " + "runners always have one)"; + } + laige::render::GlContext gl = std::move(ctx).takeValue(); + GlyphAtlas font = makeAtlas(); + const GlyphMetrics& mM = *font.glyph('M'); + const GlyphMetrics& ma = *font.glyph('a'); + ASSERT_EQ(10, mM.width); + ASSERT_EQ(11, mM.height); + ASSERT_EQ(1, mM.bearingX); + ASSERT_EQ(11, mM.bearingY); + ASSERT_EQ(8, ma.width); + ASSERT_EQ(9, ma.height); + // The upload bytes (the setup path — one call per font per scene). + std::vector rgba; + rgba.resize(1024u * 1024u * 4u); + ASSERT_TRUE(expandGlyphAtlasRgba8(font, rgba).ok()); + auto rc = laige::render::SpriteRenderer::create( + gl, SpriteRenderer::Options{16, 8}); + ASSERT_TRUE(rc.ok()); + SpriteRenderer renderer = std::move(rc).takeValue(); + ASSERT_TRUE( + renderer.bindAtlas(3, font.atlasWidth(), font.atlasHeight(), rgba) + .ok()); + StringTable table = makeTable(8, 64); + StringRef ref = internText(table, "Ma"); + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + TextItem item; + item.text = ref; + item.font = &font; + item.x = 10.0f; + item.y = 20.0f; // the first line's baseline (screen y down) + item.atlasId = 3; + ASSERT_TRUE(declareText(batcher, table, item).ok()); + ASSERT_TRUE(batcher.build().ok()); + // The clear BEFORE the first draw (the offscreen frame protocol). + ASSERT_TRUE(gl.clear(0.0f, 0.0f, 0.0f, 1.0f).ok()); + ASSERT_TRUE(renderer.submit(batcher, smokeMatrix()).ok()); + // One (atlas, material, blend) group: one instanced draw, 2 + // instances (RENDER-001). + const SpriteDrawStats stats = renderer.frameStats(); + EXPECT_EQ(1u, stats.drawCalls); + EXPECT_EQ(2u, stats.instances); + // The pixel checks (screen y down -> GL y = H - 1 - sy; the clear + // alpha is 1.0 -> the composite alpha is exactly 255; the white-on- + // alpha glyph over black: rgb = the atlas alpha byte, exactly — the + // GL 3.3 8-bit conversion of 255 * (A/255)). + const std::span alpha = font.atlas(); + auto readScreen = [&](std::int32_t sx, std::int32_t sy) { + std::uint8_t rgba4[4]{}; + auto r = gl.readPixel(sx, kSmokeHeight - 1 - sy, rgba4); + if (!r.ok()) return std::array{}; + return std::array{rgba4[0], rgba4[1], rgba4[2], + rgba4[3]}; + }; + // The ink rects (screen px, y down) — the hand-computed declaration + // (the item at (10, 20), scale 1, left-aligned): the M ink left + // 10 + bearingX, top 20 - bearingY; the a starts at 10 + M's + // advance. + const int inkML = 10 + mM.bearingX; + const int inkMT = 20 - mM.bearingY; + const int inkAL = 10 + mM.advance + ma.bearingX; + const int inkAT = 20 - ma.bearingY; + // The first texel of a cell in an alpha class ({tx, ty, a}; tx = -1 + // when the class is empty): 0 = transparent, 1 = semi, 2 = opaque. + auto firstTexel = [&](const GlyphMetrics& g, int cls) { + for (int ty = 0; ty < g.height; ++ty) { + for (int tx = 0; tx < g.width; ++tx) { + const std::uint8_t a = + alpha[(g.atlasY + static_cast(ty)) * 1024u + + g.atlasX + static_cast(tx)]; + const bool hit = cls == 0 + ? a == 0u + : (cls == 1 + ? (a > 0 && a < 255u) + : a == 255u); + if (hit) { + return std::array{tx, ty, static_cast(a)}; + } + } + } + return std::array{-1, 0, 0}; + }; + // The background (outside both inks). + { + const std::array p = readScreen(5, 40); + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // The fully opaque texels of both glyphs: the white-on-alpha upload + // over the black clear is rgb = A = 255, and the alpha channel is + // exactly 255 (the engine's SRC_ALPHA/ONE_MINUS_SRC_ALPHA blend with + // a source alpha of 1). The pixel's screen position pins the UV + // mapping (that exact texel lands on that exact pixel). + { + const std::array m = firstTexel(mM, 2); + ASSERT_EQ(255, m[2]); + const std::array p = + readScreen(inkML + m[0], inkMT + m[1]); + EXPECT_EQ(255u, p[0]); + EXPECT_EQ(255u, p[1]); + EXPECT_EQ(255u, p[2]); + EXPECT_EQ(255u, p[3]); + } + { + const std::array a = firstTexel(ma, 2); + ASSERT_EQ(255, a[2]); + const std::array p = + readScreen(inkAL + a[0], inkAT + a[1]); + EXPECT_EQ(255u, p[0]); + EXPECT_EQ(255u, p[1]); + EXPECT_EQ(255u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // A transparent texel of the M cell: nothing drawn there -> the + // clear shows through. + { + const std::array t = firstTexel(mM, 0); + ASSERT_NE(-1, t[0]); + const std::array p = + readScreen(inkML + t[0], inkMT + t[1]); + EXPECT_EQ(0u, p[0]); + EXPECT_EQ(0u, p[1]); + EXPECT_EQ(0u, p[2]); + EXPECT_EQ(255u, p[3]); + } + // A semi-transparent texel of the M: the rgb is exactly the atlas + // alpha (the white-on-alpha over black), and the alpha channel + // follows the engine's blend on all four channels (M2-SPRITE-02: + // SRC_ALPHA/ONE_MINUS_SRC_ALPHA) — out.a = A/255 + (1 - A/255) * + // (1 - A/255) in float, one driver-defined 8-bit rounding step, so + // the tolerance is +/- 1. + { + const std::array m = firstTexel(mM, 1); + ASSERT_NE(-1, m[0]); + const std::uint8_t A = static_cast(m[2]); + const std::array p = + readScreen(inkML + m[0], inkMT + m[1]); + EXPECT_EQ(A, p[0]); + EXPECT_EQ(A, p[1]); + EXPECT_EQ(A, p[2]); + const double a = static_cast(A) / 255.0; + const int expect = static_cast(255.0 * (a * a + (1.0 - a)) + 0.5); + EXPECT_LE(static_cast(p[3]) - expect + 1, 1); + EXPECT_LE(expect - static_cast(p[3]) + 1, 1); + } + // The context outlives the renderer (the ownership contract). +}