@rpgm-tools/neo-angband-mod-sdk 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/MOD_REACH.md CHANGED
@@ -1476,7 +1476,7 @@ Ranked by how much of "the whole game can be made over" each one makes moddable.
1476
1476
  | 17 | **A vault or room template can use a symbol core has never heard of** | **CLOSED 2026-08-09** | `vault.json` and `room_template.json` have always accepted a new record, so a mod could always ship a vault - but only one drawn with the symbols `build_room_template` and `build_vault` already decoded, because those were three closed switches (gen-room.c L1195, L1445, L1523; 16 + 13 + 23 cases). A symbol they did not know became plain floor: **no error, no effect**, and no way for an author to find out except by staring at the level. `GlyphRegistry` (`gen/glyph.ts`) is keyed by decoder and character, with the two passes upstream actually runs - `terrain`, then `populate` once the room's walls exist - and a mod reaches it through `registry:glyph`. Two things are deliberate. The two alphabets stay SEPARATE, because upstream's are: `+` is a closed door in a room template and a SECRET door in a vault, and unifying them would be a parity change wearing a tidiness costume. And the glyphs upstream accepts and does nothing with (`9` in a template's first pass, `/` and `;` in a vault) are registered as explicit no-ops, so `glyphs(kind)` reports the true alphabet - an authoring tool listing what a vault may contain would otherwise be missing them. What proves it: **5,994 golden vectors** recorded from the code BEFORE the registry existed - every room template and every vault of the shipped pack, plus four synthetic ones spelling out the glyphs the pack does not use, at three seeds x three depths - replaying the whole chunk, every placement, and a probe draw that catches a changed DRAW COUNT. The depth list carries a 127 because the first control run broke a vault's `>` and PASSED: with only 5 and 60 in the grid, the dungeon-bottom arm was never reached. Reach is proven from disk (`mod-code.node.test.ts`) by a real mod folder shipping a vault with a `Q` in it, asserted on the CHUNK; the same file keeps the BEFORE picture as a test, so the seam's value is measured rather than asserted. |
1477
1477
  | 18 | **A mod's effect can SAY what it does** | **CLOSED 2026-08-09** | `registry:effect` has always let a mod register a handler for a brand-new effect code and have it DO something. What no mod could do was let the game describe it, because five closed switches stood between an effect and every word the player reads about it: `effectMenuName` and `effect_describe`'s body (both keyed on the EFINFO_* flag, 20 cases each), the activation-property summary walker (keyed on the effect code, 12), `effect_subtype`'s named-subtype decoding (keyed on the effect index, 9) and `requestForEffect` (which item an effect prompts for, 8). **69 cases.** The failure was silent in all five directions: a blank row in the activate/cast menu, nothing at all in object recall, an activation that could never be recognised as duplicating an intrinsic property, an effect that accepted no named subtype (only a bare integer), and an item-consuming effect that could not ask for an item. `EffectInfoRegistry` (`effects/effect-info-registry.ts`) is four tables under three keys - the EFINFO_* flag, the effect code, the effect index - reached through `registry:effect-info`. Two things are deliberate. The tables stay SEPARATE, because upstream's keys are: twenty flags describe a hundred and twelve effects, and collapsing them would force a mod to register the same behaviour three times under three spellings. And the registry is MODULE-LEVEL, which is legitimate only because of the 2026-08-09 ruling that a mod toggle takes effect on the next reload - the table a disabled mod registered into is gone because the module instance is. What proves it: **11,530 golden vectors** recorded from the code BEFORE the registry existed - every one of the 112 effects at five subtypes x three dice shapes x two radius/other shapes x two device-skill boosts, every TMD name through six activation arms, and every effect index against 25 subtype names - replaying the exact strings. No RNG probe, and that is measured rather than forgotten: this path substitutes `Dice.randomValue()` for upstream's `dice_roll` at every site precisely so rendering cannot perturb the stream, so there is no Rng to probe. Reach is proven from disk (`mod-code.node.test.ts`) by a real mod folder giving its own `SOULFIRE` effect a menu row and a recall sentence, asserted on the STRINGS a player would read; the same file wraps a core flag to show composition, and keeps the BEFORE picture - both strings empty, nothing complaining - as a test. Making that reachable took two changes beyond the switches: `describeEffect` used to skip a mod's effect on its `edesc === null` branch before the registry was ever consulted, and `itemTargetRequest` skipped a string effect code outright. A seam its own callers walk past is not a seam. |
1478
1478
  | 19 | **A mod can reach the RANDOM artifact generator** | **CLOSED 2026-08-09** | `artifact.json` has always accepted a new record, so a mod could always ship a FIXED artifact. Reaching the GENERATOR was a different thing: four closed switches decided every property a randart can have - `add_ability_aux` keyed on the `ART_IDX` ability (**87 cases, the biggest dispatch the switch census has ever recorded**), `artifact_prep` keyed on the base item's tval (15), the item-class census keyed on tval (14), and the activation-redundancy test keyed on the `EFPROP` kind (9). **125 cases.** The failure was the worst kind: a mod-coined ability index took `add_ability_aux`'s default arm, which is a bare `break`, so the design loop SPENT POWER on the ability and the artifact got nothing - no error, no effect, and no way for an author to find out except by generating a few hundred artifacts and staring at them. `RandartRegistry` (`obj/randart-registry.ts`) is four tables under three keys, reached through `registry:randart`. What proves it: **834 golden vectors** recorded from the code BEFORE the registry existed (commit `d4a771153`) - three whole artifact sets field by field, every `ART_IDX` at two seeds and two target powers, every item class through `artifact_prep` including the three tvals that take its DEFAULT arm, and the frequency census - with an RNG probe on every per-arm vector, because a changed draw count is invisible in the artifact and diverges every artifact after it. The control was run: one throwaway `randint0` in `ART_IDX.BOW_MIGHT`, changing no artifact field, fails the replay and names the probe. **Reachability took one more change than the conversion.** The `addFlag` / `addMod` / `addResist` / `addBrand` / `addToHit` primitives a handler needs were exported from `randart-build.ts` but that module was never re-exported from core's index, so none of them were in `ctx.core`: a mod could register a handler and had no way to write its body. `index.ts` exports `randart-build.js` and `randart-data.js` now. Reach is proven from disk (`mod-code.node.test.ts`) by a real mod folder coining ability index 500 and running the real `add_ability_aux` over it, asserted on the ARTIFACT; the same file wraps a core ability to show composition and keeps the BEFORE picture - the artifact comes back untouched - as a test. |
1479
- | 21 | **The UI is replaceable**: the HUD, the messages, the menus, the prompts, the full screens (row 9 is the world) | **NOT YET, and NO LONGER DEFERRED (2026-08-21).** From 2026-08-15 this was scoped as "must not GATE the first public release", never as "waits for alpha to end". Alpha shipped as 0.20.0, so this row is open work again and is the gate the in-game mod builder needs lifted. What follows is the standing measurement of the remaining reach. Real progress stands, not zero: the region stack, overlap/transparency, mod-created regions and per-cell input routing are built and tested (`ui-stack.ts`, `region-surface.ts`, #276), and the prompt seam (#258, `yieldTerminal`) is real, not a stub. What remains (roughly 13 of ~50 screen builders still unmodelled, ~20 of 34 full-screen `term.clear()` sites still undeclared, menu *presentation* (only content is reachable today), and a hover/focus input model nobody has scoped yet) is real, bounded, medium-effort work, explicitly held for a post-alpha pass rather than gating the release on a perfect port. See `alpha-waits-for-total-moddability` (superseded 2026-08-15: "any gap from a perfect port" is no longer the gate; moddability is judged sufficient for release as-is). | Split out of row 9, whose title claimed this and whose work never touched it. What a mod reaches today, layer by layer, read off the source rather than remembered: **HUD sidebar**: core already produces it as DATA (`sidebarModel` + `sidebarLayout`, the `update_sidebar` port), and the shell draws it in `renderSidebar`, a closure inside `main.ts` with no seam of any kind on it; `display-wiring.test.ts` exists precisely because that closure is unreachable from a test. **Status line** and **message area**: same shape, drawn from `main.ts`. **Menus**: `registry:menu` reaches menu CONTENT at the `selectFromMenu` choke point: a mod may add, reorder, retitle and re-tag ROWS. It reaches no part of how a menu is PRESENTED, so a console-RPG frame, a radial dial or a floating window is out of reach even though the rows are already semantic. **Full screens** (inventory, equipment, character sheet, knowledge, spell browse, ignore setup, tombstone, ~50 builders in `screens.ts`): these are the worst case, because their model is already flattened: every builder answers `ScreenLine[]`, styled text on a grid. There is nothing for a different presentation to consume; a mod handed those lines would be parsing a rendering, not reading a model. **Layout**: `viewport()` is private to `main.ts` (cells) and cell size / letterbox offset are private to `GlyphTerm` (pixels), so nothing outside knows where any region is. What would have to exist, as a CANDIDATE shape rather than a decision: (a) **named regions** (map, HUD, status, messages, overlay) with published geometry, and core registered as candidate zero for EACH, so a mod claims one or more regions and every region it does not claim keeps being drawn by core. That is the same trick row 9 already used to make its own claim checkable, and it is what makes "let the standard UI shine through, or replace every element" a property of the seam instead of a burden on the mod author. It also retires row 9's remaining defect for free: a `display:replace` mod that claims the map region gets the map rectangle and stops covering the sidebar. (b) **A semantic frame per region**, the `WorldFrame` treatment applied to the rest: the HUD as fields rather than placed text, a menu as a question with choices rather than rows, a full screen as its core model rather than `ScreenLine[]`. `screens.ts` is where most of that work is, because the flattening happens there and core's real models (`characterPanels`, `statTable`, gear lists) are already behind it. (c) **A capability of its own** (`ui:replace`, granted per region), separate from `display:replace`, so taking over the character sheet is not the same grant as taking over the dungeon. The acceptance cases, set 2026-08-13: console-RPG menus and status screens, radial menus, full redesigns into popup windows, and a Dragon Quest-style side panel. Those are the acceptance cases; none of them is expressible today. **2026-08-13, what row 9 built and what it left here.** Half of candidate (a) exists: `packages/web/src/regions.ts` names `messages` / `sidebar` / `map` / `status` and publishes each one's rectangle in cells and CSS pixels, and every `WorldFrame` carries them. That is the LAYOUT half, and it is shared - this row does not need to invent it, only to extend it. What row 9 did NOT build, deliberately, is per-region OWNERSHIP: core is candidate zero for the map because `installFrontend` already selects one owner for the map, and there is nothing to be candidate zero FOR on the other regions until the HUD is a model rather than a closure. That is this row's first job, and it comes before the fun parts. **A NEW gap, measured in the installed build rather than reasoned about**, and it is this row's: a full-screen overlay is drawn across the WHOLE terminal - the game menu, the Mods screen, inventory, the character sheet, every `screens.ts` builder - including the map region a front end holds, so with a front end active those screens are visibly clipped where its canvas sits. Photographed 2026-08-13 with `blueprint-view` active: the menu is legible and `j) Mods` reachable, so it is no longer fatal, but the right-hand half of every row is behind the mod's canvas. Both sides are behaving correctly, which is the tell: the missing thing is that a full screen has no region of its own. Whatever this row builds has to answer what happens to the map region when a screen takes the terminal - the honest candidates being that a full screen IS a region (and the map's stands down while it is up), or that a screen is composed of regions rather than covering them. **DECIDED 2026-08-13: a screen is COMPOSED of regions and does not cover them.** The stand-down candidate was rejected against the acceptance cases, not on taste. The intended overhaul is the map filling the ENTIRE screen with standalone windows floating over it: borders, scrollable contents, an inventory drawn as sprites or tiles where moving the selection (mouse hover, or a controller D-pad) raises a tooltip or fills a detail pane inside the same window, and a Secret-of-Mana radial command dial (Wear/wield, Magic, Zap, Aim, Use, Fire) superimposed over the map with transparency, its second tier either radiating out of the chosen wedge or replacing the first tier in place. Every one of those needs the map to still be drawn UNDERNEATH, which a full-screen region that makes the map stand down forbids by construction. Four consequences follow, and each changes what a region IS rather than adding to it. **(1) Regions overlap, and are ORDERED.** The four names row 9 shipped tile the screen and cannot overlap; a floating window is *defined* by sitting over another region, so regions need a stacking order and the model has to stop assuming disjointness: `regions.test.ts` asserts today that no cell is claimed twice, which is right for the base layout and wrong as a global invariant. **(2) A mod CREATES regions**, it does not only claim the four core names. A radial dial is not `messages` / `sidebar` / `map` / `status`, and neither is a detail pane inside an inventory window. Names stay roles; the set stops being closed. **(3) A region can be transparent**, so the map's `present` keeps running while a screen is up, and compositing (who draws, in what order, over what) becomes part of the seam instead of a property of a takeover. **(4) Input routes to the region under the point.** A hover tooltip and a D-pad walking a grid of item tiles are both hit-testing against a region's own contents, which is the first thing that turns `input-door.ts` from host infrastructure into a mod seam. It also settles candidate (b) as REQUIRED rather than optional: an inventory drawn as sprites cannot be assembled from `ScreenLine[]`, so getting `screens.ts` to give up its real models is not a nice-to-have inside this row: it is the precondition for every acceptance case above. **2026-08-13, step 1 landed: the HUD is a model.** `packages/web/src/hud-view.ts` gives the vitals, the message line and the status line the `WorldFrame` treatment: a frame of named sections, each a list of keyed entries whose runs carry the engine's own `COLOUR_*` attribute beside the css the terminal resolves it to, plus the rectangle the terminal is allowed to draw in and the published region that rectangle plays the role of. `renderSidebar`, `renderCompactVitals` and `renderStatusLine` are gone from `main.ts`; what is left there is an adapter that reads core's models and a single `glyphHudFrameSink` that consumes the frame, so there is no second draw path for a HUD cell. Two things this bought immediately. The layout rules (which fields survive a short screen, where the compact header's separators fall, how far a run may reach, what the targeting loop takes over) are now executable (`hud-view.test.ts` drives them across three layouts and four sizes), where before they lived behind a canvas boot and only a source-text guard could look at them. And the region table finally has something to be checked against: every section carries its region, so "core draws its own furniture inside the rectangles core publishes" is an assertion rather than an argument, at every layout and size. Equivalence was measured rather than asserted: the new path was diffed against `HEAD`'s four draw loops over 4000 randomized frames, and both negative controls (widening the reserved last column; dropping the separator a blank field still charges for) failed it. **Still NO for a mod**, which is why this row is still open: nothing yet lets a plugin receive that frame. Per-region ownership is the next step, and it is now unblocked: there is finally something for a mod to be candidate zero *against* on a region that is not the map. **2026-08-13, step 2 landed: the HUD has OWNERS, one per region.** `ModPlugin.hud(ctx)` returns a sink for each region it is taking: `messages`, `sidebar`, `status`, and `packages/web/src/hud-runtime.ts` selects each region's owner independently, last enabled claimant in load order, with core's terminal as candidate zero for all three through the same call any mod makes. Candidate (c) is built and is per region as designed: `ui:<region>.replace` with a `ui:*.replace` wildcard, a separate capability KIND from `display:replace` in both directions: a mod holding the dungeon cannot draw the vitals and a mod holding the whole interface cannot draw the dungeon, asserted rather than assumed. The consent list names the part (`"Draw the vitals panel - your hit points, food, armour and depth"`), because a grant a player cannot picture is a grant they cannot weigh. Three design points that are decisions rather than details. **The capability IS the claim**, so selection reads the manifests and finishes before anybody's `hud()` is called, the rule `installFrontend` needs so a loser cannot mount a canvas it will never draw into, which per-region ownership would have broken if a claim could only be learned by calling. Its price is stated rather than hidden: a region a mod won and then declined goes back to the game, not on to the next claimant. **A sink for an ungranted region is dropped and reported**, or the capability would be advisory: nothing stops a returned object carrying three keys when its manifest asked for one. **A fault costs one region**: a throwing `status` sink loses the status line for the session and leaves the vitals drawing, because losing your hit points to a mod that draws the clock would be a bigger blast radius than the grant. Along the way, a defect in the shipped map seam: `frontendWorldFrameSink` was rebuilt inside `render()`, so its "this front end faulted, stop calling it" memory was discarded on every repaint and a persistently throwing mod was re-entered and re-reported once per frame. Both sinks are now built once per selection (`liveWorldSink` / `liveHudSink`), and `hud-runtime.test.ts` presents twice and asserts one report. **The proof is a mod, not a fixture**: `samples/vitals-panel/` is a real folder plugin that takes `sidebar` alone and redraws the vitals as a canvas panel on `section.region.pixels`, keyed on `entry.key` and coloured from `run.color` through `ctx.core.COLOUR_*`; `sample-vitals.node.test.ts` loads it from disk through the real selection and asserts it never touches `run.css` or `entry.screen`. **What is NOT closed, and this row stays NO for all of it**: menus, prompts and the ~50 `screens.ts` builders are untouched, so candidate (b) is one third done; the four consequences of the composition ruling (overlapping ordered regions, mod-created regions, transparency, input routed by region) are all unbuilt, and the full-screen-overlay defect above is still live. One limit the sample surfaced and it is named rather than worked around: a HUD entry carries its TEXT, not its numbers (`"HP 20/20"`, no `{current, max}`), so a mod can restyle the vitals and cannot draw a proportional bar without parsing a rendering. Values on the entries is the next increment. **2026-08-13, step 3 landed: the entries carry their NUMBERS.** `SidebarField` and `StatusIndicator` gained `values` in core (`display.ts`), carried unchanged through `hud-view.ts` to every entry and through the cross-plugin snapshot, and mirrored on the SDK's public `HudEntry`. It is deliberately ONE convention rather than a per-field API: **`current` and `max` together mean the field is a PROPORTION**, every other key is a plain named quantity, and a field whose two numbers are not a ratio must not use those names. That is what lets a consumer write `if (v.current !== undefined && v.max !== undefined) drawBar() else drawText()` once and be right on a field a content pack adds after it shipped, and it is why a stat publishes `use`/`cur`/`max` (118 is an encoding meaning 18/100; `use / max` would report a maxed character as 15%) and a drained character publishes `level`/`maxLevel`. Absent means THE GAME DOES NOT KNOW, never zero: the monster health bar publishes nothing while it reads `[----------]`, because `{current: 0, max: 10}` there would draw as "about to die", and `sp` is absent for a class with no mana rather than `0/0`: two facts that are not the same fact. Two numbers are published where core's own field is deliberately BLANK (`speed` at 110, whose text upstream suppresses) because 110 is a known number rather than a missing one, and the level feeling publishes its INDICES rather than the digits it prints, which are inverted by two different constants (`10 - mon`, `11 - obj`). The producer's own risk (a field's numbers going missing looks like nothing at all, since the terminal never reads them and the screen is identical) is pinned by both placement paths (`placements` and `flowEntries`) being asserted, and by the sample. `samples/vitals-panel` now draws hit points and spell points as real bars, coloured from the engine's own attribute and clamped (a big heal can put `chp` above `mhp` for a moment), and `sample-vitals.node.test.ts` asserts the RULE rather than the field: `hp` gets a bar, `str` with its three non-ratio numbers gets text, and the fill is `round(width * 7/20)` of the track. **2026-08-13, step 4 landed: a menu is a QUESTION, and a mod can ask it.** `ModPlugin.menu(ctx)` returns a presenter; `packages/web/src/menu-view.ts` gives every menu a renderer-neutral `MenuQuestion` (title, subtitle, footer, choices with their stable ids and `MenuSemantics`, the command keys the caller handles itself, the initial cursor, `browseOnly`, and a live `detail(index)` callback), and `menu-runtime.ts` selects one presenter by the same last-in-load-order rule the other two seams use. **The seam is different in kind and that is the whole design**: a HUD section is DRAWN, a menu is ASKED, so the boundary is `ask(question) -> answer` rather than `present(frame)`, and taking a question means taking its input - a presentation that could not accept a choice would not be a presentation of a menu. Four decisions that are decisions rather than details. **ONE grant, `ui:menu.replace`, for every menu** - not one per menu id, because ~50 capability strings would be a consent list nobody could read; the fine choice is made per QUESTION instead, where a presenter returns `undefined` to decline the ones it has no better way to ask. Declining is the expected case and costs nothing, which is also why the HUD's "the capability IS the claim" rule does not need to hold here: a declined menu leaves no surface half-owned. **An answer names a choice's stable `id`, never an index** - an index is a fact about a layout, and a presenter that groups its choices into the wedges of a dial has none the game would recognise; `selectFromMenu` already maps ids back to the caller's own row because a `registry:menu` transformer could reorder rows before any of this. **CORE IS NOT A CANDIDATE**, the one place this does not mirror its siblings: core's way of asking is `selectFromMenu`'s own body, a function rather than a presenter object, and every decline and every fault falls into it - a candidate that always declined could never win, because winning is what declining means. **A fault costs the SEAM, not one menu**, the opposite of the HUD's rule and for a stated reason: a presenter that throws on one question generally throws on all of them, so one report and out beats a fault report every time the player opens anything. Answers that cannot be honoured (unknown choice id, a choice on a browse-only question, an unoffered command key) cost that menu only and are reported. The risk this shape invites is a SECOND COPY of the caller's command-key rules - `MENU_REFRESH` and `MENU_CLOSE` are numbers and must be checked before the "it returned a row index" branch - so `menu-runtime.test.ts` drives the same command down the keydown path AND the presenter path in one test and asserts they agree on the resolved value and on the cursor the handler saw. **The proof is a mod**: `samples/command-dial/` presents `core:game-menu` as a radial dial, answers by id, colours the quit wedge from `semantic.ref` rather than the English label, handles its own keys (legitimately - the host attaches no listener for a question a presenter took), and DECLINES every other menu; `sample-dial.node.test.ts` loads it from disk, drives the full round trip, and asserts the declined `core:spell-book` is still answered by the game's own lettered list. **Still open in this row**: the ~50 `screens.ts` builders (candidate (b)'s biggest piece), and all four consequences of the composition ruling - overlapping ordered regions, mod-created regions, transparency, input routed by region. The menu seam names its own share of that: a menu has no published REGION, so a dial over a still-visible dungeon is not yet expressible and `question.style` is the stopgap. **2026-08-13, step 5 landed: a SCREEN is a document, and two of them have given up their models.** `ModPlugin.screen(ctx)` returns a presenter; `packages/web/src/screen-view.ts` gives a screen a renderer-neutral `ScreenView` - a list of BLOCKS - and `screen-runtime.ts` selects one presenter by the same last-in-load-order rule the other three seams use, gated by one grant (`ui:screen.replace`) with the choice made per screen. **The point is the TABLE block.** A list is columns with stable keys and rows whose cells are addressed BY THAT KEY, so a presenter reads `row.cells.name.text` and never learns that an inventory row is `a) ` then a name padded to 45 then a weight. A row carries the SAME identity a `MenuChoice` does - `core:gear:<handle>` and `{kind: "item", ref: handle}`, asserted equal to `packMenu`'s - because an inventory listing and an inventory picker are the same objects seen twice and a mod should not need two vocabularies for them; and a cell carries `values` under the HUD's convention unchanged, so a weight publishes `{each, total, number}` in tenths of a pound and the presenter formats it its own way. **The column widths are PUBLISHED rather than baked into the text**, which is the trick that lets one model serve both renderings: upstream's listings line up because `OLIST_WEIGHT` writes at a fixed offset, so a faithful terminal needs the width, and a presenter with a proportional font ignores it. **ONE renderer, and core's own painting goes through it**: `inventoryLines` is now `screenBodyLines(inventoryScreen(state))`, so the model and the shipped rows cannot part - the lesson from the HUD, where a model beside a hand-laid copy of the same rows was two transcriptions and the unwatched one rotted. A regression the tests caught while this was being written: the lettered-column indent was derived from "does any row have a tag", which silently un-indents the whole equipment listing on a character wearing nothing, so `tagged` is now a required fact about the TABLE - a layout fact that changes with the data is not a layout fact. **A screen is DISMISSED, not answered**, the one shape difference from the menus: `show` declines by returning `undefined` SYNCHRONOUSLY and takes the screen by returning `{dismissed}`, because once the promise means "the player closed it" there is no value left to decline with. A presenter that throws, that returns no dismissal to wait on, or that dies with the screen OPEN loses the seam for the session AND the game shows the screen itself - a player left staring at a dead overlay has no way out, so this is the one recovery that repeats work rather than dropping it. **How much is actually modelled is DATA rather than a claim**: `MODELLED_SCREENS` names `core:inventory` and `core:equipment`, `screen-view.test.ts` DERIVES that list from the `freezeView` calls that exist - so adding an id without building it fails, and building one without listing it fails - and every other screen arrives under the shared id `core:text` with a single `lines` block of pre-wrapped rows, which is enough to reskin a frame and not enough to reimagine a listing. **The proof is a mod**: `samples/sprite-inventory/` draws both listings as item cards, reads `cells.name` and `values.total` and `semantic.kind === "slot"` rather than the rendered row or the English wording, and declines every other screen; `sample-inventory.node.test.ts` drives it through the real `showTextScreen` and builds its fixture from the game's own exported `INVENTORY_COLUMNS` / `EQUIPMENT_COLUMNS`, so a renamed key cannot leave the sample passing against a vocabulary the game stopped using. **Still open in this row**: the other ~48 screen builders (`characterSheetLines`, the knowledge browser, the spell lists, the monster and object lists, the tombstone), and all four consequences of the composition ruling - overlapping ordered regions, mod-created regions, transparency, input routed by region. A screen still has no published REGION, so an inventory panel beside a still-visible dungeon is not expressible and the full-screen-overlay defect above is still live. **2026-08-13, step 5b (first pass): every LISTING has given up its model.** Four more screens are modelled - `core:quiver`, `core:objects-in-view`, `core:messages`, `core:player-history` - taking `MODELLED_SCREENS` from two to six, and each of them was chosen for the same reason: a list is the shape a presenter most wants and `ScreenLine[]` least gives it. Three things this pass established that step 5 had not. **A column publishes THREE facts about the terminal, not one.** `width` was not enough to reproduce upstream's layouts from a generic renderer: `history_display` writes `"%10ld%7d' %s"` - no gap before the depth and two before the note - and the object list writes `"%s %s %s"`, where the location FOLLOWS the name rather than sitting under a column stop. So a column now also carries `gap` (columns of space before it, one by default) and `pad` (false where the game does not line the column up at all). Without them the choice was to change the rendering or leave the screens on `lines`, and changing the rendering to suit the model would have been the port adding something. **The model may carry MORE than the rendering, never less**, and that is now a stated rule with three instances: the quiver publishes its weight as row `values` although upstream's quiver listing has no weight column, the object list publishes its offset as `{dy, dx}` as well as `"2 N 0 W"` (a compass string cannot be turned back into a map marker without parsing English and guessing a sign convention), and the player history publishes `clev`, which the screen never prints. `samples/sprite-inventory` was extended to the quiver to prove the row-level path is reachable - a presenter reading only `cell.values` would have silently lost the weight on the one screen where the terminal cannot show it either. **The `tagged` regression happened again, in its other form, and was caught the same way.** The column HEADER was emitted after the empty-rows check, so a character with no history at all lost the `" Turn Depth Note"` header that upstream prints unconditionally - a table's columns are a fact about the table, not about the rows it holds today, exactly as its letters are. An existing test covered that case and failed; the derived `MODELLED_SCREENS` check also failed, because its regex was pinned to a newline after `freezeView({` and went blind to a call that fits on one line - a derived check a reformat can blind is a declared check wearing a disguise, and it is now whitespace-tolerant. Rendering equivalence for all four screens is carried by the 20-odd tests those builders already had, which passed unchanged; the NEW tests assert only what the model carries beyond the rendering and the two layout facts that are now declared rather than inferred. **Still open in this row**: the screens that are not listings - `characterSheetLines` (whose `showCharacterSheet` is a hand-painted modal with two display modes that never reaches `showTextScreen` at all, so it is a bigger job than a builder swap), the knowledge browser, `spellBrowseLines`, `monsterRecallLines` and the other `Textblock` pages (a `text` block, which would let a presenter re-wrap prose the game currently wraps for it), `tombstoneLines`/`winnerLines` (an `art` block), `help.ts`, `equip-cmp.ts`. Every prose dialog in `mods.ts` / `mod-browse.ts` is arguably FINISHED at `lines` and that judgement still has to be made per screen and recorded. Found and not fixed: `lookLines` has no caller and no test - dead since the target picker replaced it, and left for the orphan sweep (#228) rather than settled here. All four consequences of composing a screen from regions - overlapping ordered regions, mod-created regions, transparency, input routed by region - remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-ii (first group): the PROSE pages give up their models, and the wrapper that laid them out turns out to have been wrong since it was written.** Three more screens are modelled - `core:object-recall` (the 'I' inspect, the context menu's Inspect, the store's Examine and one side of the equipment comparison, all four the same page of the same object seen from four places), `core:object-comparison` and `core:monster-recall` - taking `MODELLED_SCREENS` from six to nine. They are the pages built from a core `Textblock`, and what they now publish is a `text` block: **paragraphs of coloured runs, UNWRAPPED**, split where the core emitted a '\n' and nowhere else. That is the whole point of the block and it is not a cosmetic difference from `lines`: a `lines` block has already been broken into 79-character rows, so a presenter with a panel of its own width - or a proportional font, where "79 characters" is not a width at all - can only re-flow it by undoing the game's wrap first and guessing which breaks were the game's and which were the sentence's. Given the paragraph there is nothing to undo. **ONE renderer again**, the same discipline `inventoryLines` follows: `wrapRuns` is now `screenBodyLines` applied to a `text` block rather than a second hand-rolled wrapper beside the model, and `charsToLine` is deleted. **AND THAT MOVE FOUND A PORT DEFECT, in the function it moved.** Reproducing `wrapRuns` exactly inside the renderer made an existing `text`-block test disagree with it, so the two were traced against 4.2.6's `text_out_to_screen` (ui-output.c L279-347) rather than argued about: upstream wraps on `(x >= wrap - 1) && (ch != L' ')`, so a space landing ON the boundary column is written rather than wrapped on and only the following non-space takes the break. The port scanned backwards from `end - 1` and so never looked at that space, which pushed a word that fit EXACTLY onto the next line - "one two three four five" at width 10 broke as "three" / "four five" where 4.2.6 gives "three four" / "five". Every recall page, every object description and every lore paragraph has been wrapping one word early in that case since the port had recall pages. Fixed in core (a port defect is core's to correct; the `bug-fixes` mod is for upstream's own bugs), with the upstream trace written into the test and a negative control that moves the boundary by one so "never wrap" cannot pass. It was invisible for a reason worth keeping: **10,213 tests passed both before and after the fix**, because no test had ever put a word on the boundary - the case only exists at one specific length, and prose fixtures are written for their content. **The proof is the sample, and it is a comparison rather than a source scan.** `samples/sprite-inventory` now takes the three recall ids and lays their paragraphs out into a 360px panel by MEASURING them through `measureText`; `sample-inventory.node.test.ts` runs the same view through the sample and through `screenBodyLines` at 80 columns and asserts the narrower panel produced MORE rows, which a presenter quietly reusing the game's wrap could not. One existing source assertion had to be narrowed and the narrowing is the interesting part: a blanket ban on `.text.split(` was there to stop the sample parsing a formatted CELL, and word-wrapping a paragraph's run is not that, so the check now names cells instead of banning a method. **Three screens were LOOKED AT AND LEFT, which is the judgement this step owed rather than a shortfall.** `spellBrowseLines` is not a screen at all - it is `selectFromMenu`'s `detail(i)` panel inside the cast/browse menu, so it belongs to the MENU seam and modelling it as a screen would have been wrong; that a menu's detail is still `ScreenLine[]` is a real gap and it is the menu seam's, not this one's. The knowledge browser's rune, feature, trap and shape recalls build `[title, blank, description]` by hand and push the description as ONE line, so the terminal TRUNCATES a long one at `cols - 1` instead of wrapping it - a second finding, not fixed here, because correcting it changes what the player sees and deserves its own measurement against upstream's `textblock` path. And `objectFakeRecall` / `artifactFakeRecall` / `egoFakeRecall` render the same `Textblock` through `recallBodyLines(textblockToString(tb))`, a THIRD path that flattens to a string and drops every run colour - the knowledge browser's object recall is monochrome where the 'I' inspect of the same object is not. All three are recorded here and none is claimed done. **Still open in this row**: `characterSheetLines`, whose `showCharacterSheet` is a hand-painted modal with two display modes and its own key handling that never reaches `showTextScreen` at all, so it is a seam problem rather than a builder swap; the knowledge browser's own pages (the three paths above); `help.ts`; `monsterListScreenLines`. Every prose dialog in `mods.ts` / `mod-browse.ts` is arguably FINISHED at `lines` and that judgement still has to be made per screen and recorded. All four consequences of composing a screen from regions - overlapping ordered regions, mod-created regions, transparency, input routed by region - remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-iii (first group): the DEATH SCREENS, and an `art` block that publishes the writing apart from the picture.** `core:tombstone` and `core:winner` are modelled, taking `MODELLED_SCREENS` from nine to eleven. The interesting part is not that they were converted but WHAT the conversion had to separate: upstream's tombstone is `dead.txt` with the character's name, class, level, experience, gold and killing blow OVERWRITTEN into columns 8-39 of it by `put_str_centred` (ui-death.c L40-56), so the finished picture has the epitaph burned in and a presenter handed only that picture would have to know the band and the row numbers to get a name back out. So `ScreenArtBlock` grew `fields`: each one a stable `key` (`name`, `title`, `class`, `level`, `exp`, `gold`, `death`, `killer`, `date`), its `text`, `values` where the text is a formatted number, and the `row`/`x1`/`x2` the faithful terminal needs - published beside the data rather than baked into it, exactly as a table column publishes `width`. A field with NO band is centred on the full terminal width, which is not a special case but literally `put_str_centred(i, 0, wid, ...)` - what `display_winner` does for its banner. **ONE renderer again**: `put_str_centred` moved out of `screens.ts` and into `screen-view.ts`, `tombstoneLines` and `winnerLines` are now `screenBodyLines` over their views, and the existing upstream-cited parity tests for both passed UNCHANGED, which is the evidence the picture did not move on the player's screen. The `art` block had been declared since step 5 with no producer; this is its first. **The proof is what did NOT reach the canvas.** `samples/sprite-inventory` draws its own stone and writes `Frodo` onto it, reading `fields` by key and the level and gold from `values`; the test asserts that not one of the block's own ASCII rows appears among the strings the sample drew, and takes those rows from the block under test rather than from a guess about the art, so redrawing `dead.txt` cannot quietly retire the assertion. **Still open in this row**: `characterSheetLines`, whose `showCharacterSheet` is a hand-painted modal with two display modes and its own key handling that never reaches `showTextScreen` at all, so it is a seam problem rather than a builder swap - and it is now the LARGEST remaining screen; the knowledge browser's own pages on the three paths above; `help.ts`; `monsterListScreenLines`; and the per-screen judgement on every prose dialog in `mods.ts` / `mod-browse.ts`. All four consequences of the composition ruling remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-iv: the CHARACTER SHEET, both pages, and the first screen a presenter can ACT on.** `core:character` (display_player mode 0: the stat table, the five panels, the history) and `core:character-flags` (mode 1: the four resist / ability / hindrance / modifier regions and the sustains block) take `MODELLED_SCREENS` from eleven to thirteen. Two things here were not builder swaps. **First, the SEAM.** `showCharacterSheet` is a hand-painted modal with two display modes and its own key handling that never reached `showTextScreen`, so no presenter was ever offered the sheet at all - it is now offered its view directly through `showThroughPresenter`, and falls back to the terminal on a decline or a fault exactly as `showTextScreen` does. **Second, ACTIONS.** Upstream's `do_cmd_change_name` (ui-player.c L1219-1289) offers renaming, a character dump and the page cycle from inside the same modal, so a presenter that took the sheet and could not reach them would quietly take three commands away from the player. `ScreenView.actions` publishes them as data - stable `id`, the key the FAITHFUL TERMINAL listens for, the game's own label - and `show(view, host)` hands over a `ScreenHost` whose `invoke(id)` runs one and resolves with the view the player should see next: the renamed sheet, the other page, or `undefined` for "the game has taken it back". An unknown id is a no-op returning the current view, so a presenter built against a later engine cannot close the player's character sheet by asking for a command this one has not got. `installScreen`'s adapter was dropping the second argument and now forwards it, which would otherwise have left every action unreachable with no error anywhere. **Four model additions, each a layout fact published BESIDE the data** rather than baked into it: `ScreenColumn.glyph` (the flag grid's columns ARE the equipment slots, and upstream draws the worn item's glyph over each - a fact about the column, not a first row to skip), `ScreenTableBlock.headerColor`, `ScreenTableBlock.gapAfter` (the blank row between panels, rather than a fake all-empty row that says nothing), and `ScreenTextBlock.wrap` (text_out_wrap = 72 for the history, a clamp on the terminal width and never a minimum). **ONE renderer, again**: `characterSheetLines`, `characterGridLines`, `statHeaderLine`, `statRowLine`, `historyBlockLines` and the wide layout's per-panel blits are all now `screenBodyLines` / the new `screenBlockLines` over the model, `wrapPlain` is deleted, and the whole existing upstream-cited suite for the sheet passed UNCHANGED - which is the evidence the player's screen did not move. **The proof is again what did NOT reach the canvas**: `samples/sprite-inventory` draws the sheet as panels, reading `cells.eb.values.bonus` as a BAR (a thing no re-reading of "STR! 18/100 +1" could give) and `column.glyph` for the slots, and the test asserts that not one COMPOSITE row the faithful terminal would have produced - a label joined to its value, or a padded multi-field line - appears among the strings it drew, with that set taken from the view under test. It also presses 'h' and asserts the page moved THROUGH the host. **Still open in this row**: the knowledge browser's own pages on the three paths above; `help.ts`; `monsterListScreenLines`; the equip-cmp picker/filter overlays; and the per-screen judgement on every prose dialog in `mods.ts` / `mod-browse.ts`. All four consequences of the composition ruling remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-v: the KNOWLEDGE BROWSER, all seven recall pages, and two defects the player could see.** `core:rune-recall`, `core:feature-recall`, `core:trap-recall`, `core:shape-recall`, `core:artifact-recall`, `core:ego-recall` and `core:object-kind-recall` take `MODELLED_SCREENS` from thirteen to twenty - seven ids rather than one, because a mod that draws an artifact page as a plaque and a trap page as a warning card has to be able to tell them apart. This row was NOT a builder swap either. All seven end at the same upstream call, `textui_textblock_show` (ui-output.c L155), and it does two things the port was not doing. **(1) It WRAPS**: `textblock_calculate_lines` (z-textblock.c L238) breaks the run stream at the region width, while the port pushed each description as ONE `ScreenLine` and `showTextScreen` slices a line at `cols - 1` - so any description longer than the terminal had its TAIL CUT OFF, on the rune, feature, trap and shape pages. **(2) It KEEPS COLOUR**: `display_area` (ui-output.c L100) writes `attrs[]` per character, while the artifact / ego / object-kind recalls flattened through `textblockToString` and painted the page one colour - which is why the browser object recall was monochrome where the `I` inspect of the SAME object was not. A third correction comes out in the wash: the rune / feature / trap / shape lores pass `header = NULL` and put the capitalised name in the BODY, but the port draws a title row on every screen AND kept the name as body line 0, so the player read it twice; the name is only the title now. `recallBodyLines` is deleted and all seven go through `proseBlock` / `textParagraphs` and the ONE renderer. `samples/sprite-inventory` takes all seven and NOTHING IN ITS PROSE PANEL CHANGED to accept them - adding them was seven strings in a list, which is what a model with a small vocabulary buys. **Recorded here, then measured and closed as #255 on 2026-08-13**: the note filed at the time said the renderer wrapped at `cols - 1` (79) where upstream wraps at 80. The premise was wrong and finding that out was the deliverable - the port matched `textblock_calculate_lines` on 1041 of 1041 shipped descriptions at 80 columns. The real defect was a CITATION: `textBlockLines` implemented `textblock_calculate_lines` while its comment named `text_out_to_screen`, which is why reasoning from the comment produced a false bug report. Measuring instead of reading turned up three genuine ones - the hard-split arm took `cols - 1` where the C takes `cols` (unreachable at 80, reachable by a mod re-rendering at 16); a paragraph ending exactly on a break lost its blank row; and the character sheet history was two columns wide of 4.2.6 and re-flowed, because it is the ONE page on the other algorithm. **2026-08-13, step 5b-vi, part one: the VISIBLE-MONSTER LIST.** `core:monster-list` takes `MODELLED_SCREENS` from twenty to twenty-one, and it is the screen where reading numbers instead of text pays most obviously: a row publishes `values.dy`/`values.dx`, so a presenter can draw an ARROW at the monster, and no arrow is recoverable from `" 3 N 2 W"` without parsing a compass back into the vector it was made from. `values.asleep` is a count where the terminal has the sentence `"(2 asleep)"`, `semantic.ref` is the race a tileset mod looks a sprite up by, and `semantic.data.name` is the game own pluralisation (`get_mon_name`) so a card can say "3 kobolds" without a mod reimplementing English. **The layout question this row had to answer**: the C pads the name with `"%-*s%s"` at a width computed per row (`full_width = max_width - 2 - len(location) - 1`), which LOOKS per-row and is not - the total is `max_width - 1` on every row, because the width shrinks by exactly what the location adds, and upstream own comment says the point of it is to "align the location to the right" (ui-mon-list.c L156). So the model is a fixed name column plus a right-aligned location column, which reproduces the C byte for byte while the name cell arrives UNPADDED. The one place they part is clipping - the C clips at that row own `full_width`, which is more generous on a row whose location is shorter than the section longest - and that is measured rather than argued: the widest name-plus-tag the shipped pack can generate clears the narrowest 80-column name column by 14, and a test fails if a content mod ever closes that gap. **The seam moved out of `main.ts`.** This screen has an ACTION (`sort-exp`, the `x` toggle at ui-mon-list.c L410,456), so it needs a `ScreenHost`, and a seam that only exists inside the game entry point is a seam nothing can drive - the character sheet host wiring was untestable for exactly as long as it lived there. `packages/web/src/monster-list.ts` now holds both ways of showing it. **The sample takes it**, which is the test: `samples/sprite-inventory` draws the list as cards with a compass arrow, and `screens.test.ts` loads that folder by path, drives the real `showMonsterList` through the real seam against a real `GameState` with a real monster, and asserts `"-> 3"` reached the canvas - a string the game never produces. Then it presses `x` and checks the footer changed, so the GAME re-sorted and handed back the new view. **2026-08-14, step 5b-vi finished, and the scope it was written against was short by six screens.** `MODELLED_SCREENS` goes from twenty-one to **thirty-seven**: the four help pages, the equip-cmp screen's two help overlays, the mod manager's four listings, the hall of fame, the knowledge store view, the update and report pages, and wizard mode's two debug readouts. **The step was scoped from a list and the list was wrong**, which is the finding worth keeping: a sweep of every OTHER file in `packages/web/src` turned up `score.ts`, `main.ts`'s `showStoreKnowledge`, both wizard readouts and the two shell pages, none of which the step named. Three of those were a different KIND of gap - `score.ts`, `drawWizItem` and the two shell pages called `term.clear()` / `term.print()` and never reached `showThroughPresenter` at all, so they were invisible to a mod rather than merely flat, and invisible to a grep for screen call sites too. **THE HALL OF FAME IS THE ONE THAT MATTERED**: a leaderboard is the first thing a UI-replacing mod rebuilds, `HighScore` already carries a field per column, and `scoreRow` joined them into three prose strings before anything could see them. The fix went into CORE - one `ScoreRow.fields` extraction that the three display lines are then built FROM - because publishing the fields as a second independent read beside the strings is the two-transcriptions failure, and the copy nobody looks at is the one that rots. **Two more screens were prose only because a PRODUCER had destroyed their structure**, which is now its own row (#256): `mod-install.ts` flattened `checkMod`'s `{title, problem}[]` into a string that `mod-browse.ts` then split back apart and re-wrapped - the process re-parsing a rendering it produced two frames earlier - and `mod-conflicts.ts` described every `ContestedSlot` into `string[]` before the pane saw it. Both now carry the records beside the sentences, with the sentences DERIVED from the records so the two cannot part, and `core:mod-conflicts` is a table. **Saying so was half the work, as this row said it would be**: all 32 `showTextScreen` call sites in the mod manager were read one at a time and twenty-four are genuinely prose, each with the reason in the source. **A NEW KIND OF BLOCKER appeared and is counted separately**: the install-refusal screens stay at `lines` not because of their data, which now arrives intact, but because a `table` row is exactly one terminal row and cannot wrap, while four of five bullets the shipped requirement set produces are longer than the width the screen wraps to. That is the block VOCABULARY, and it belongs to `screen-view.ts`. **Byte-identical rendering was the acceptance criterion throughout**, against output captured from the real producers before each change and asserted as full `ScreenLine` objects rather than `.text`, so a coloured cell emitting a `runs` array where a plain coloured line used to go would show; one check was verified by BREAKING IT ON PURPOSE, because a snapshot that has never failed is not a snapshot. Two rows could not be reproduced and are reported rather than adjusted: the store view's header (a `tagged` table renders its header on the data grid, which would move `Store Inventory` from column 0 to 3), and the symbols page's intro, which stays on `lines` because upstream hand-wrapped that file and reproducing its breaks from a paragraph needed a wrap clamp chosen to fit the answer. **The playing guide and the community page went the other way** and publish their prose unwrapped, moving seven paragraphs' line breaks and no words - a trade available only because both are PORT ADDITIONS with no upstream layout to be faithful to. **Still open in this row**: overlapping, ordered, mod-created, transparent regions and compositing, and input routed by region - both now DESIGNED in seven staged commits (#261), neither built; a PROMPT SEAM (#258), because `ScreenHost.invoke` holds when the game's answer is a new view and breaks when its answer is to ask the player something, which the character sheet's `rename` and `file` have done since they shipped; and the spell lists, which belong to the menu seam. The full-screen-overlay defect above is still live, and the design reframes it: both sides were behaving correctly, and what was missing was that a screen had no rectangle at all, so nothing could learn it was being covered. **2026-08-14, milestone 6 landed: three of the four consequences are built, and the fourth is the one left.** Regions overlap and are ORDERED by band (`RegionLayer`: `base`/`overlay`/`modal`/`system`, later-declared on top within a band, `system` reserved to the game); a mod CREATES regions (`ModPlugin.regions()`, `ui:region.create`, a distinct capability ACTION that `ui:*.replace` deliberately does not cover, published across all four ABI sites in #267); a region is TRANSPARENT wherever it did not write, with `clipSurface` DROPPING an outside write rather than clamping it onto a neighbour. `ui-stack.ts` is the live stack and the compositor, `paintRegionStack` is pinned on the AST as the literal last statement of `render()`, and `samples/sprite-inventory` ships a real `regions()` panel that draws on the character grid with no DOM at all. Five owner runtimes are wired in `main.ts`, and core is candidate zero for every base region it is not out-claimed on. **What is NOT built is consequence (4), input routed by region**: `topRegionAt` exists, is tested, and has ZERO production consumers, and `RegionDeclaration` has no input member of any kind. The cost is measurable rather than theoretical: `main.ts`'s tap-to-move handler gates on `modalDepth` and the map rectangle and on nothing else, so **tapping a mod's own overlay region walks the player's character**, and a long-press there opens the game's context menu for the dungeon square underneath it. **The full-screen-overlay defect is now PARTLY closed, and the remainder is counted**: `showViewOnTerminal` and `showLevelMap` declare regions, so a front end is told and `blueprint-view` stands down, but of the four screens photographed above, only the inventory goes through that path. **2026-08-14, #253: ten more screens declare their rectangle**: all four birth screens (and `drawBirthSheet`, which took all four of its callers to convert), both of the character sheet's own terminal painters, and all four of the mod browser's wait screens. **20 of the 34 enumerated `term.clear()` sites are still full-screen erases nothing can learn about**; four of those 20 are the PROMPTS, which is where this row and #258 meet, and two more are `main.ts`'s own report and update pages. **This closes no seam and this row stays open**: converting a screen gives a mod nothing it could not reach before, it removes a reason the screen could not coexist with one. The remainder is `main-regions.test.ts`'s `TERM_CLEAR_PENDING`, which is now a VALUE rather than a list of comments, so the count above is derived from the table rather than recounted by hand. **2026-08-14, #276: input is ROUTED BY REGION, built.** `clipSurface` records the cells each painter writes, erases included, and `regionInputAt` answers with the topmost painted owner in region-local coordinates. Tap, desktop context-click and touch long-press ask that cell question before core movement or context routing, so **tapping a mod's own panel no longer walks the player's character**; a region with no `input` handler still consumes pointers on its painted cells, which is what makes the shipped `sprite-inventory` sample correct without changing it. Pointer input is positional ONLY: keyboard focus stays out of scope, because a region that took keys would be a second answer to what a player command means. `topRegionAt` remains the rectangle/layout helper rather than the input router. The design is `docs/modding/REGION_INPUT.md`, ruled cell-opaque (a region is opaque to input exactly where it PAINTED, so sight and touch obey one rule) and written independently by two model families that converged on every load-bearing decision. Two corrections this row owed: the tap handler does NOT gate on `modalDepth` and the map rectangle alone: it also checks `scoresOpen`, `dead` and `mouse_movement`, and candidate-zero is not one universal runtime, `hud-runtime.ts` supplies it for HUD regions while the map's lives in `frontend-runtime.ts`. **2026-08-21, step 7 landed: a mod can draw with real HTML, and the input door will stand down for it.** Every step above this one widened what a mod could do with the CHARACTER GRID, and each was the right move for a HUD region or a menu and the wrong one for a form. `RegionSurface` publishes seven methods; a field with a caret, a list with a scrollbar and a table sorted by clicking a column are a caret, a tab order and a focus model reimplemented inside a terminal. So `ui:panel.mount` grants `ctx.ui.openPanel(spec)` (`packages/web/src/panel-runtime.ts`), which mounts a host-owned container on the page above the game and returns a closed shadow root to build in. WHAT WAS ACTUALLY BLOCKING THIS WAS THE KEYBOARD, not the drawing: a plugin runs in the page's realm and could always append an element, but this front end has ONE keydown registration - `window`, capture phase, installed at import (`input-door.ts`) - and every modal handler behind it cancels the event, so a real `<input>` received nothing and its keystrokes were read as game commands. The door now asks a `DomKeyboardOwner` per keystroke and stands down for one whose composed path reaches the top panel before the game's canvas, which makes the caret the thing that decides whose key it is. That is the only power in the grant a mod did not already have, and it is built to be revocable and to FAIL OPEN: a container detached, moved out of the layer, or made a parent of the canvas is closed and the keyboard goes back, checked as the key arrives rather than once at mount; a panel under another is inert; a real Escape closes the topmost panel before the panel is offered the key, and focus returns to the game; a modal panel carries a host-drawn close control because a phone has no Escape key; a player-initiated close puts that mod on a brief pause so a reopen loop cannot outrun the key that ends it. NOT CONTAINMENT, and the consent text says so rather than implying otherwise - the shadow root is style hygiene, an iframe would have fenced the half that draws a form while the half holding `ctx.core` sat outside it, and the boundary here is the same one it is everywhere else in this system, which is the player's decision to enable the mod. WHAT THIS ROW STILL OWES is unchanged by it: the ~13 unmodelled screen builders, the ~20 of 34 undeclared full-screen erases, menu presentation, and a hover/focus input model for the GRID. A mod that wants the game's own screens redrawn still needs those; this step is for the mod that wants a screen the game does not have. **A note on this row's own bookkeeping:** the "14 milestones, 13 done" figure that has been quoted in status reports was recorded in NO artefact in this repository when that was written. **Re-measured 2026-08-14, and the figure had already decayed:** `MOD_REACH.md` now contains the word "milestone" five times and `CHANGELOG.md` twice, so a reader who trusted the sentence above would be counting against a tree that had moved under it, which is the exact failure the sentence was warning about. The GitHub issue list is still empty. A 14-item list can be RECONSTRUCTED from this row's dated steps plus milestones 6 and 7, and it happens to fit, but that is an inference. A number nothing in the tree states is a number that cannot go stale visibly. |
1479
+ | 21 | **The UI is replaceable**: the HUD, the messages, the menus, the prompts, the full screens (row 9 is the world) | **NOT YET, and NO LONGER DEFERRED (2026-08-21).** From 2026-08-15 this was scoped as "must not GATE the first public release", never as "waits for alpha to end". Alpha shipped as 0.20.0, so this row is open work again and is the gate the in-game mod builder needs lifted. What follows is the standing measurement of the remaining reach. Real progress stands, not zero: the region stack, overlap/transparency, mod-created regions and per-cell input routing are built and tested (`ui-stack.ts`, `region-surface.ts`, #276), and the prompt seam (#258, `yieldTerminal`) is real, not a stub. What remains (roughly 13 of ~50 screen builders still unmodelled, ~21 of 34 full-screen `term.clear()` sites still undeclared, menu *presentation* (only content is reachable today), and a hover/focus input model nobody has scoped yet) is real, bounded, medium-effort work, explicitly held for a post-alpha pass rather than gating the release on a perfect port. See `alpha-waits-for-total-moddability` (superseded 2026-08-15: "any gap from a perfect port" is no longer the gate; moddability is judged sufficient for release as-is). | Split out of row 9, whose title claimed this and whose work never touched it. What a mod reaches today, layer by layer, read off the source rather than remembered: **HUD sidebar**: core already produces it as DATA (`sidebarModel` + `sidebarLayout`, the `update_sidebar` port), and the shell draws it in `renderSidebar`, a closure inside `main.ts` with no seam of any kind on it; `display-wiring.test.ts` exists precisely because that closure is unreachable from a test. **Status line** and **message area**: same shape, drawn from `main.ts`. **Menus**: `registry:menu` reaches menu CONTENT at the `selectFromMenu` choke point: a mod may add, reorder, retitle and re-tag ROWS. It reaches no part of how a menu is PRESENTED, so a console-RPG frame, a radial dial or a floating window is out of reach even though the rows are already semantic. **Full screens** (inventory, equipment, character sheet, knowledge, spell browse, ignore setup, tombstone, ~50 builders in `screens.ts`): these are the worst case, because their model is already flattened: every builder answers `ScreenLine[]`, styled text on a grid. There is nothing for a different presentation to consume; a mod handed those lines would be parsing a rendering, not reading a model. **Layout**: `viewport()` is private to `main.ts` (cells) and cell size / letterbox offset are private to `GlyphTerm` (pixels), so nothing outside knows where any region is. What would have to exist, as a CANDIDATE shape rather than a decision: (a) **named regions** (map, HUD, status, messages, overlay) with published geometry, and core registered as candidate zero for EACH, so a mod claims one or more regions and every region it does not claim keeps being drawn by core. That is the same trick row 9 already used to make its own claim checkable, and it is what makes "let the standard UI shine through, or replace every element" a property of the seam instead of a burden on the mod author. It also retires row 9's remaining defect for free: a `display:replace` mod that claims the map region gets the map rectangle and stops covering the sidebar. (b) **A semantic frame per region**, the `WorldFrame` treatment applied to the rest: the HUD as fields rather than placed text, a menu as a question with choices rather than rows, a full screen as its core model rather than `ScreenLine[]`. `screens.ts` is where most of that work is, because the flattening happens there and core's real models (`characterPanels`, `statTable`, gear lists) are already behind it. (c) **A capability of its own** (`ui:replace`, granted per region), separate from `display:replace`, so taking over the character sheet is not the same grant as taking over the dungeon. The acceptance cases, set 2026-08-13: console-RPG menus and status screens, radial menus, full redesigns into popup windows, and a Dragon Quest-style side panel. Those are the acceptance cases; none of them is expressible today. **2026-08-13, what row 9 built and what it left here.** Half of candidate (a) exists: `packages/web/src/regions.ts` names `messages` / `sidebar` / `map` / `status` and publishes each one's rectangle in cells and CSS pixels, and every `WorldFrame` carries them. That is the LAYOUT half, and it is shared - this row does not need to invent it, only to extend it. What row 9 did NOT build, deliberately, is per-region OWNERSHIP: core is candidate zero for the map because `installFrontend` already selects one owner for the map, and there is nothing to be candidate zero FOR on the other regions until the HUD is a model rather than a closure. That is this row's first job, and it comes before the fun parts. **A NEW gap, measured in the installed build rather than reasoned about**, and it is this row's: a full-screen overlay is drawn across the WHOLE terminal - the game menu, the Mods screen, inventory, the character sheet, every `screens.ts` builder - including the map region a front end holds, so with a front end active those screens are visibly clipped where its canvas sits. Photographed 2026-08-13 with `blueprint-view` active: the menu is legible and `j) Mods` reachable, so it is no longer fatal, but the right-hand half of every row is behind the mod's canvas. Both sides are behaving correctly, which is the tell: the missing thing is that a full screen has no region of its own. Whatever this row builds has to answer what happens to the map region when a screen takes the terminal - the honest candidates being that a full screen IS a region (and the map's stands down while it is up), or that a screen is composed of regions rather than covering them. **DECIDED 2026-08-13: a screen is COMPOSED of regions and does not cover them.** The stand-down candidate was rejected against the acceptance cases, not on taste. The intended overhaul is the map filling the ENTIRE screen with standalone windows floating over it: borders, scrollable contents, an inventory drawn as sprites or tiles where moving the selection (mouse hover, or a controller D-pad) raises a tooltip or fills a detail pane inside the same window, and a Secret-of-Mana radial command dial (Wear/wield, Magic, Zap, Aim, Use, Fire) superimposed over the map with transparency, its second tier either radiating out of the chosen wedge or replacing the first tier in place. Every one of those needs the map to still be drawn UNDERNEATH, which a full-screen region that makes the map stand down forbids by construction. Four consequences follow, and each changes what a region IS rather than adding to it. **(1) Regions overlap, and are ORDERED.** The four names row 9 shipped tile the screen and cannot overlap; a floating window is *defined* by sitting over another region, so regions need a stacking order and the model has to stop assuming disjointness: `regions.test.ts` asserts today that no cell is claimed twice, which is right for the base layout and wrong as a global invariant. **(2) A mod CREATES regions**, it does not only claim the four core names. A radial dial is not `messages` / `sidebar` / `map` / `status`, and neither is a detail pane inside an inventory window. Names stay roles; the set stops being closed. **(3) A region can be transparent**, so the map's `present` keeps running while a screen is up, and compositing (who draws, in what order, over what) becomes part of the seam instead of a property of a takeover. **(4) Input routes to the region under the point.** A hover tooltip and a D-pad walking a grid of item tiles are both hit-testing against a region's own contents, which is the first thing that turns `input-door.ts` from host infrastructure into a mod seam. It also settles candidate (b) as REQUIRED rather than optional: an inventory drawn as sprites cannot be assembled from `ScreenLine[]`, so getting `screens.ts` to give up its real models is not a nice-to-have inside this row: it is the precondition for every acceptance case above. **2026-08-13, step 1 landed: the HUD is a model.** `packages/web/src/hud-view.ts` gives the vitals, the message line and the status line the `WorldFrame` treatment: a frame of named sections, each a list of keyed entries whose runs carry the engine's own `COLOUR_*` attribute beside the css the terminal resolves it to, plus the rectangle the terminal is allowed to draw in and the published region that rectangle plays the role of. `renderSidebar`, `renderCompactVitals` and `renderStatusLine` are gone from `main.ts`; what is left there is an adapter that reads core's models and a single `glyphHudFrameSink` that consumes the frame, so there is no second draw path for a HUD cell. Two things this bought immediately. The layout rules (which fields survive a short screen, where the compact header's separators fall, how far a run may reach, what the targeting loop takes over) are now executable (`hud-view.test.ts` drives them across three layouts and four sizes), where before they lived behind a canvas boot and only a source-text guard could look at them. And the region table finally has something to be checked against: every section carries its region, so "core draws its own furniture inside the rectangles core publishes" is an assertion rather than an argument, at every layout and size. Equivalence was measured rather than asserted: the new path was diffed against `HEAD`'s four draw loops over 4000 randomized frames, and both negative controls (widening the reserved last column; dropping the separator a blank field still charges for) failed it. **Still NO for a mod**, which is why this row is still open: nothing yet lets a plugin receive that frame. Per-region ownership is the next step, and it is now unblocked: there is finally something for a mod to be candidate zero *against* on a region that is not the map. **2026-08-13, step 2 landed: the HUD has OWNERS, one per region.** `ModPlugin.hud(ctx)` returns a sink for each region it is taking: `messages`, `sidebar`, `status`, and `packages/web/src/hud-runtime.ts` selects each region's owner independently, last enabled claimant in load order, with core's terminal as candidate zero for all three through the same call any mod makes. Candidate (c) is built and is per region as designed: `ui:<region>.replace` with a `ui:*.replace` wildcard, a separate capability KIND from `display:replace` in both directions: a mod holding the dungeon cannot draw the vitals and a mod holding the whole interface cannot draw the dungeon, asserted rather than assumed. The consent list names the part (`"Draw the vitals panel - your hit points, food, armour and depth"`), because a grant a player cannot picture is a grant they cannot weigh. Three design points that are decisions rather than details. **The capability IS the claim**, so selection reads the manifests and finishes before anybody's `hud()` is called, the rule `installFrontend` needs so a loser cannot mount a canvas it will never draw into, which per-region ownership would have broken if a claim could only be learned by calling. Its price is stated rather than hidden: a region a mod won and then declined goes back to the game, not on to the next claimant. **A sink for an ungranted region is dropped and reported**, or the capability would be advisory: nothing stops a returned object carrying three keys when its manifest asked for one. **A fault costs one region**: a throwing `status` sink loses the status line for the session and leaves the vitals drawing, because losing your hit points to a mod that draws the clock would be a bigger blast radius than the grant. Along the way, a defect in the shipped map seam: `frontendWorldFrameSink` was rebuilt inside `render()`, so its "this front end faulted, stop calling it" memory was discarded on every repaint and a persistently throwing mod was re-entered and re-reported once per frame. Both sinks are now built once per selection (`liveWorldSink` / `liveHudSink`), and `hud-runtime.test.ts` presents twice and asserts one report. **The proof is a mod, not a fixture**: `samples/vitals-panel/` is a real folder plugin that takes `sidebar` alone and redraws the vitals as a canvas panel on `section.region.pixels`, keyed on `entry.key` and coloured from `run.color` through `ctx.core.COLOUR_*`; `sample-vitals.node.test.ts` loads it from disk through the real selection and asserts it never touches `run.css` or `entry.screen`. **What is NOT closed, and this row stays NO for all of it**: menus, prompts and the ~50 `screens.ts` builders are untouched, so candidate (b) is one third done; the four consequences of the composition ruling (overlapping ordered regions, mod-created regions, transparency, input routed by region) are all unbuilt, and the full-screen-overlay defect above is still live. One limit the sample surfaced and it is named rather than worked around: a HUD entry carries its TEXT, not its numbers (`"HP 20/20"`, no `{current, max}`), so a mod can restyle the vitals and cannot draw a proportional bar without parsing a rendering. Values on the entries is the next increment. **2026-08-13, step 3 landed: the entries carry their NUMBERS.** `SidebarField` and `StatusIndicator` gained `values` in core (`display.ts`), carried unchanged through `hud-view.ts` to every entry and through the cross-plugin snapshot, and mirrored on the SDK's public `HudEntry`. It is deliberately ONE convention rather than a per-field API: **`current` and `max` together mean the field is a PROPORTION**, every other key is a plain named quantity, and a field whose two numbers are not a ratio must not use those names. That is what lets a consumer write `if (v.current !== undefined && v.max !== undefined) drawBar() else drawText()` once and be right on a field a content pack adds after it shipped, and it is why a stat publishes `use`/`cur`/`max` (118 is an encoding meaning 18/100; `use / max` would report a maxed character as 15%) and a drained character publishes `level`/`maxLevel`. Absent means THE GAME DOES NOT KNOW, never zero: the monster health bar publishes nothing while it reads `[----------]`, because `{current: 0, max: 10}` there would draw as "about to die", and `sp` is absent for a class with no mana rather than `0/0`: two facts that are not the same fact. Two numbers are published where core's own field is deliberately BLANK (`speed` at 110, whose text upstream suppresses) because 110 is a known number rather than a missing one, and the level feeling publishes its INDICES rather than the digits it prints, which are inverted by two different constants (`10 - mon`, `11 - obj`). The producer's own risk (a field's numbers going missing looks like nothing at all, since the terminal never reads them and the screen is identical) is pinned by both placement paths (`placements` and `flowEntries`) being asserted, and by the sample. `samples/vitals-panel` now draws hit points and spell points as real bars, coloured from the engine's own attribute and clamped (a big heal can put `chp` above `mhp` for a moment), and `sample-vitals.node.test.ts` asserts the RULE rather than the field: `hp` gets a bar, `str` with its three non-ratio numbers gets text, and the fill is `round(width * 7/20)` of the track. **2026-08-13, step 4 landed: a menu is a QUESTION, and a mod can ask it.** `ModPlugin.menu(ctx)` returns a presenter; `packages/web/src/menu-view.ts` gives every menu a renderer-neutral `MenuQuestion` (title, subtitle, footer, choices with their stable ids and `MenuSemantics`, the command keys the caller handles itself, the initial cursor, `browseOnly`, and a live `detail(index)` callback), and `menu-runtime.ts` selects one presenter by the same last-in-load-order rule the other two seams use. **The seam is different in kind and that is the whole design**: a HUD section is DRAWN, a menu is ASKED, so the boundary is `ask(question) -> answer` rather than `present(frame)`, and taking a question means taking its input - a presentation that could not accept a choice would not be a presentation of a menu. Four decisions that are decisions rather than details. **ONE grant, `ui:menu.replace`, for every menu** - not one per menu id, because ~50 capability strings would be a consent list nobody could read; the fine choice is made per QUESTION instead, where a presenter returns `undefined` to decline the ones it has no better way to ask. Declining is the expected case and costs nothing, which is also why the HUD's "the capability IS the claim" rule does not need to hold here: a declined menu leaves no surface half-owned. **An answer names a choice's stable `id`, never an index** - an index is a fact about a layout, and a presenter that groups its choices into the wedges of a dial has none the game would recognise; `selectFromMenu` already maps ids back to the caller's own row because a `registry:menu` transformer could reorder rows before any of this. **CORE IS NOT A CANDIDATE**, the one place this does not mirror its siblings: core's way of asking is `selectFromMenu`'s own body, a function rather than a presenter object, and every decline and every fault falls into it - a candidate that always declined could never win, because winning is what declining means. **A fault costs the SEAM, not one menu**, the opposite of the HUD's rule and for a stated reason: a presenter that throws on one question generally throws on all of them, so one report and out beats a fault report every time the player opens anything. Answers that cannot be honoured (unknown choice id, a choice on a browse-only question, an unoffered command key) cost that menu only and are reported. The risk this shape invites is a SECOND COPY of the caller's command-key rules - `MENU_REFRESH` and `MENU_CLOSE` are numbers and must be checked before the "it returned a row index" branch - so `menu-runtime.test.ts` drives the same command down the keydown path AND the presenter path in one test and asserts they agree on the resolved value and on the cursor the handler saw. **The proof is a mod**: `samples/command-dial/` presents `core:game-menu` as a radial dial, answers by id, colours the quit wedge from `semantic.ref` rather than the English label, handles its own keys (legitimately - the host attaches no listener for a question a presenter took), and DECLINES every other menu; `sample-dial.node.test.ts` loads it from disk, drives the full round trip, and asserts the declined `core:spell-book` is still answered by the game's own lettered list. **Still open in this row**: the ~50 `screens.ts` builders (candidate (b)'s biggest piece), and all four consequences of the composition ruling - overlapping ordered regions, mod-created regions, transparency, input routed by region. The menu seam names its own share of that: a menu has no published REGION, so a dial over a still-visible dungeon is not yet expressible and `question.style` is the stopgap. **2026-08-13, step 5 landed: a SCREEN is a document, and two of them have given up their models.** `ModPlugin.screen(ctx)` returns a presenter; `packages/web/src/screen-view.ts` gives a screen a renderer-neutral `ScreenView` - a list of BLOCKS - and `screen-runtime.ts` selects one presenter by the same last-in-load-order rule the other three seams use, gated by one grant (`ui:screen.replace`) with the choice made per screen. **The point is the TABLE block.** A list is columns with stable keys and rows whose cells are addressed BY THAT KEY, so a presenter reads `row.cells.name.text` and never learns that an inventory row is `a) ` then a name padded to 45 then a weight. A row carries the SAME identity a `MenuChoice` does - `core:gear:<handle>` and `{kind: "item", ref: handle}`, asserted equal to `packMenu`'s - because an inventory listing and an inventory picker are the same objects seen twice and a mod should not need two vocabularies for them; and a cell carries `values` under the HUD's convention unchanged, so a weight publishes `{each, total, number}` in tenths of a pound and the presenter formats it its own way. **The column widths are PUBLISHED rather than baked into the text**, which is the trick that lets one model serve both renderings: upstream's listings line up because `OLIST_WEIGHT` writes at a fixed offset, so a faithful terminal needs the width, and a presenter with a proportional font ignores it. **ONE renderer, and core's own painting goes through it**: `inventoryLines` is now `screenBodyLines(inventoryScreen(state))`, so the model and the shipped rows cannot part - the lesson from the HUD, where a model beside a hand-laid copy of the same rows was two transcriptions and the unwatched one rotted. A regression the tests caught while this was being written: the lettered-column indent was derived from "does any row have a tag", which silently un-indents the whole equipment listing on a character wearing nothing, so `tagged` is now a required fact about the TABLE - a layout fact that changes with the data is not a layout fact. **A screen is DISMISSED, not answered**, the one shape difference from the menus: `show` declines by returning `undefined` SYNCHRONOUSLY and takes the screen by returning `{dismissed}`, because once the promise means "the player closed it" there is no value left to decline with. A presenter that throws, that returns no dismissal to wait on, or that dies with the screen OPEN loses the seam for the session AND the game shows the screen itself - a player left staring at a dead overlay has no way out, so this is the one recovery that repeats work rather than dropping it. **How much is actually modelled is DATA rather than a claim**: `MODELLED_SCREENS` names `core:inventory` and `core:equipment`, `screen-view.test.ts` DERIVES that list from the `freezeView` calls that exist - so adding an id without building it fails, and building one without listing it fails - and every other screen arrives under the shared id `core:text` with a single `lines` block of pre-wrapped rows, which is enough to reskin a frame and not enough to reimagine a listing. **The proof is a mod**: `samples/sprite-inventory/` draws both listings as item cards, reads `cells.name` and `values.total` and `semantic.kind === "slot"` rather than the rendered row or the English wording, and declines every other screen; `sample-inventory.node.test.ts` drives it through the real `showTextScreen` and builds its fixture from the game's own exported `INVENTORY_COLUMNS` / `EQUIPMENT_COLUMNS`, so a renamed key cannot leave the sample passing against a vocabulary the game stopped using. **Still open in this row**: the other ~48 screen builders (`characterSheetLines`, the knowledge browser, the spell lists, the monster and object lists, the tombstone), and all four consequences of the composition ruling - overlapping ordered regions, mod-created regions, transparency, input routed by region. A screen still has no published REGION, so an inventory panel beside a still-visible dungeon is not expressible and the full-screen-overlay defect above is still live. **2026-08-13, step 5b (first pass): every LISTING has given up its model.** Four more screens are modelled - `core:quiver`, `core:objects-in-view`, `core:messages`, `core:player-history` - taking `MODELLED_SCREENS` from two to six, and each of them was chosen for the same reason: a list is the shape a presenter most wants and `ScreenLine[]` least gives it. Three things this pass established that step 5 had not. **A column publishes THREE facts about the terminal, not one.** `width` was not enough to reproduce upstream's layouts from a generic renderer: `history_display` writes `"%10ld%7d' %s"` - no gap before the depth and two before the note - and the object list writes `"%s %s %s"`, where the location FOLLOWS the name rather than sitting under a column stop. So a column now also carries `gap` (columns of space before it, one by default) and `pad` (false where the game does not line the column up at all). Without them the choice was to change the rendering or leave the screens on `lines`, and changing the rendering to suit the model would have been the port adding something. **The model may carry MORE than the rendering, never less**, and that is now a stated rule with three instances: the quiver publishes its weight as row `values` although upstream's quiver listing has no weight column, the object list publishes its offset as `{dy, dx}` as well as `"2 N 0 W"` (a compass string cannot be turned back into a map marker without parsing English and guessing a sign convention), and the player history publishes `clev`, which the screen never prints. `samples/sprite-inventory` was extended to the quiver to prove the row-level path is reachable - a presenter reading only `cell.values` would have silently lost the weight on the one screen where the terminal cannot show it either. **The `tagged` regression happened again, in its other form, and was caught the same way.** The column HEADER was emitted after the empty-rows check, so a character with no history at all lost the `" Turn Depth Note"` header that upstream prints unconditionally - a table's columns are a fact about the table, not about the rows it holds today, exactly as its letters are. An existing test covered that case and failed; the derived `MODELLED_SCREENS` check also failed, because its regex was pinned to a newline after `freezeView({` and went blind to a call that fits on one line - a derived check a reformat can blind is a declared check wearing a disguise, and it is now whitespace-tolerant. Rendering equivalence for all four screens is carried by the 20-odd tests those builders already had, which passed unchanged; the NEW tests assert only what the model carries beyond the rendering and the two layout facts that are now declared rather than inferred. **Still open in this row**: the screens that are not listings - `characterSheetLines` (whose `showCharacterSheet` is a hand-painted modal with two display modes that never reaches `showTextScreen` at all, so it is a bigger job than a builder swap), the knowledge browser, `spellBrowseLines`, `monsterRecallLines` and the other `Textblock` pages (a `text` block, which would let a presenter re-wrap prose the game currently wraps for it), `tombstoneLines`/`winnerLines` (an `art` block), `help.ts`, `equip-cmp.ts`. Every prose dialog in `mods.ts` / `mod-browse.ts` is arguably FINISHED at `lines` and that judgement still has to be made per screen and recorded. Found and not fixed: `lookLines` has no caller and no test - dead since the target picker replaced it, and left for the orphan sweep (#228) rather than settled here. All four consequences of composing a screen from regions - overlapping ordered regions, mod-created regions, transparency, input routed by region - remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-ii (first group): the PROSE pages give up their models, and the wrapper that laid them out turns out to have been wrong since it was written.** Three more screens are modelled - `core:object-recall` (the 'I' inspect, the context menu's Inspect, the store's Examine and one side of the equipment comparison, all four the same page of the same object seen from four places), `core:object-comparison` and `core:monster-recall` - taking `MODELLED_SCREENS` from six to nine. They are the pages built from a core `Textblock`, and what they now publish is a `text` block: **paragraphs of coloured runs, UNWRAPPED**, split where the core emitted a '\n' and nowhere else. That is the whole point of the block and it is not a cosmetic difference from `lines`: a `lines` block has already been broken into 79-character rows, so a presenter with a panel of its own width - or a proportional font, where "79 characters" is not a width at all - can only re-flow it by undoing the game's wrap first and guessing which breaks were the game's and which were the sentence's. Given the paragraph there is nothing to undo. **ONE renderer again**, the same discipline `inventoryLines` follows: `wrapRuns` is now `screenBodyLines` applied to a `text` block rather than a second hand-rolled wrapper beside the model, and `charsToLine` is deleted. **AND THAT MOVE FOUND A PORT DEFECT, in the function it moved.** Reproducing `wrapRuns` exactly inside the renderer made an existing `text`-block test disagree with it, so the two were traced against 4.2.6's `text_out_to_screen` (ui-output.c L279-347) rather than argued about: upstream wraps on `(x >= wrap - 1) && (ch != L' ')`, so a space landing ON the boundary column is written rather than wrapped on and only the following non-space takes the break. The port scanned backwards from `end - 1` and so never looked at that space, which pushed a word that fit EXACTLY onto the next line - "one two three four five" at width 10 broke as "three" / "four five" where 4.2.6 gives "three four" / "five". Every recall page, every object description and every lore paragraph has been wrapping one word early in that case since the port had recall pages. Fixed in core (a port defect is core's to correct; the `bug-fixes` mod is for upstream's own bugs), with the upstream trace written into the test and a negative control that moves the boundary by one so "never wrap" cannot pass. It was invisible for a reason worth keeping: **10,213 tests passed both before and after the fix**, because no test had ever put a word on the boundary - the case only exists at one specific length, and prose fixtures are written for their content. **The proof is the sample, and it is a comparison rather than a source scan.** `samples/sprite-inventory` now takes the three recall ids and lays their paragraphs out into a 360px panel by MEASURING them through `measureText`; `sample-inventory.node.test.ts` runs the same view through the sample and through `screenBodyLines` at 80 columns and asserts the narrower panel produced MORE rows, which a presenter quietly reusing the game's wrap could not. One existing source assertion had to be narrowed and the narrowing is the interesting part: a blanket ban on `.text.split(` was there to stop the sample parsing a formatted CELL, and word-wrapping a paragraph's run is not that, so the check now names cells instead of banning a method. **Three screens were LOOKED AT AND LEFT, which is the judgement this step owed rather than a shortfall.** `spellBrowseLines` is not a screen at all - it is `selectFromMenu`'s `detail(i)` panel inside the cast/browse menu, so it belongs to the MENU seam and modelling it as a screen would have been wrong; that a menu's detail is still `ScreenLine[]` is a real gap and it is the menu seam's, not this one's. The knowledge browser's rune, feature, trap and shape recalls build `[title, blank, description]` by hand and push the description as ONE line, so the terminal TRUNCATES a long one at `cols - 1` instead of wrapping it - a second finding, not fixed here, because correcting it changes what the player sees and deserves its own measurement against upstream's `textblock` path. And `objectFakeRecall` / `artifactFakeRecall` / `egoFakeRecall` render the same `Textblock` through `recallBodyLines(textblockToString(tb))`, a THIRD path that flattens to a string and drops every run colour - the knowledge browser's object recall is monochrome where the 'I' inspect of the same object is not. All three are recorded here and none is claimed done. **Still open in this row**: `characterSheetLines`, whose `showCharacterSheet` is a hand-painted modal with two display modes and its own key handling that never reaches `showTextScreen` at all, so it is a seam problem rather than a builder swap; the knowledge browser's own pages (the three paths above); `help.ts`; `monsterListScreenLines`. Every prose dialog in `mods.ts` / `mod-browse.ts` is arguably FINISHED at `lines` and that judgement still has to be made per screen and recorded. All four consequences of composing a screen from regions - overlapping ordered regions, mod-created regions, transparency, input routed by region - remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-iii (first group): the DEATH SCREENS, and an `art` block that publishes the writing apart from the picture.** `core:tombstone` and `core:winner` are modelled, taking `MODELLED_SCREENS` from nine to eleven. The interesting part is not that they were converted but WHAT the conversion had to separate: upstream's tombstone is `dead.txt` with the character's name, class, level, experience, gold and killing blow OVERWRITTEN into columns 8-39 of it by `put_str_centred` (ui-death.c L40-56), so the finished picture has the epitaph burned in and a presenter handed only that picture would have to know the band and the row numbers to get a name back out. So `ScreenArtBlock` grew `fields`: each one a stable `key` (`name`, `title`, `class`, `level`, `exp`, `gold`, `death`, `killer`, `date`), its `text`, `values` where the text is a formatted number, and the `row`/`x1`/`x2` the faithful terminal needs - published beside the data rather than baked into it, exactly as a table column publishes `width`. A field with NO band is centred on the full terminal width, which is not a special case but literally `put_str_centred(i, 0, wid, ...)` - what `display_winner` does for its banner. **ONE renderer again**: `put_str_centred` moved out of `screens.ts` and into `screen-view.ts`, `tombstoneLines` and `winnerLines` are now `screenBodyLines` over their views, and the existing upstream-cited parity tests for both passed UNCHANGED, which is the evidence the picture did not move on the player's screen. The `art` block had been declared since step 5 with no producer; this is its first. **The proof is what did NOT reach the canvas.** `samples/sprite-inventory` draws its own stone and writes `Frodo` onto it, reading `fields` by key and the level and gold from `values`; the test asserts that not one of the block's own ASCII rows appears among the strings the sample drew, and takes those rows from the block under test rather than from a guess about the art, so redrawing `dead.txt` cannot quietly retire the assertion. **Still open in this row**: `characterSheetLines`, whose `showCharacterSheet` is a hand-painted modal with two display modes and its own key handling that never reaches `showTextScreen` at all, so it is a seam problem rather than a builder swap - and it is now the LARGEST remaining screen; the knowledge browser's own pages on the three paths above; `help.ts`; `monsterListScreenLines`; and the per-screen judgement on every prose dialog in `mods.ts` / `mod-browse.ts`. All four consequences of the composition ruling remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-iv: the CHARACTER SHEET, both pages, and the first screen a presenter can ACT on.** `core:character` (display_player mode 0: the stat table, the five panels, the history) and `core:character-flags` (mode 1: the four resist / ability / hindrance / modifier regions and the sustains block) take `MODELLED_SCREENS` from eleven to thirteen. Two things here were not builder swaps. **First, the SEAM.** `showCharacterSheet` is a hand-painted modal with two display modes and its own key handling that never reached `showTextScreen`, so no presenter was ever offered the sheet at all - it is now offered its view directly through `showThroughPresenter`, and falls back to the terminal on a decline or a fault exactly as `showTextScreen` does. **Second, ACTIONS.** Upstream's `do_cmd_change_name` (ui-player.c L1219-1289) offers renaming, a character dump and the page cycle from inside the same modal, so a presenter that took the sheet and could not reach them would quietly take three commands away from the player. `ScreenView.actions` publishes them as data - stable `id`, the key the FAITHFUL TERMINAL listens for, the game's own label - and `show(view, host)` hands over a `ScreenHost` whose `invoke(id)` runs one and resolves with the view the player should see next: the renamed sheet, the other page, or `undefined` for "the game has taken it back". An unknown id is a no-op returning the current view, so a presenter built against a later engine cannot close the player's character sheet by asking for a command this one has not got. `installScreen`'s adapter was dropping the second argument and now forwards it, which would otherwise have left every action unreachable with no error anywhere. **Four model additions, each a layout fact published BESIDE the data** rather than baked into it: `ScreenColumn.glyph` (the flag grid's columns ARE the equipment slots, and upstream draws the worn item's glyph over each - a fact about the column, not a first row to skip), `ScreenTableBlock.headerColor`, `ScreenTableBlock.gapAfter` (the blank row between panels, rather than a fake all-empty row that says nothing), and `ScreenTextBlock.wrap` (text_out_wrap = 72 for the history, a clamp on the terminal width and never a minimum). **ONE renderer, again**: `characterSheetLines`, `characterGridLines`, `statHeaderLine`, `statRowLine`, `historyBlockLines` and the wide layout's per-panel blits are all now `screenBodyLines` / the new `screenBlockLines` over the model, `wrapPlain` is deleted, and the whole existing upstream-cited suite for the sheet passed UNCHANGED - which is the evidence the player's screen did not move. **The proof is again what did NOT reach the canvas**: `samples/sprite-inventory` draws the sheet as panels, reading `cells.eb.values.bonus` as a BAR (a thing no re-reading of "STR! 18/100 +1" could give) and `column.glyph` for the slots, and the test asserts that not one COMPOSITE row the faithful terminal would have produced - a label joined to its value, or a padded multi-field line - appears among the strings it drew, with that set taken from the view under test. It also presses 'h' and asserts the page moved THROUGH the host. **Still open in this row**: the knowledge browser's own pages on the three paths above; `help.ts`; `monsterListScreenLines`; the equip-cmp picker/filter overlays; and the per-screen judgement on every prose dialog in `mods.ts` / `mod-browse.ts`. All four consequences of the composition ruling remain unbuilt, and the full-screen-overlay defect above is still live. **2026-08-13, step 5b-v: the KNOWLEDGE BROWSER, all seven recall pages, and two defects the player could see.** `core:rune-recall`, `core:feature-recall`, `core:trap-recall`, `core:shape-recall`, `core:artifact-recall`, `core:ego-recall` and `core:object-kind-recall` take `MODELLED_SCREENS` from thirteen to twenty - seven ids rather than one, because a mod that draws an artifact page as a plaque and a trap page as a warning card has to be able to tell them apart. This row was NOT a builder swap either. All seven end at the same upstream call, `textui_textblock_show` (ui-output.c L155), and it does two things the port was not doing. **(1) It WRAPS**: `textblock_calculate_lines` (z-textblock.c L238) breaks the run stream at the region width, while the port pushed each description as ONE `ScreenLine` and `showTextScreen` slices a line at `cols - 1` - so any description longer than the terminal had its TAIL CUT OFF, on the rune, feature, trap and shape pages. **(2) It KEEPS COLOUR**: `display_area` (ui-output.c L100) writes `attrs[]` per character, while the artifact / ego / object-kind recalls flattened through `textblockToString` and painted the page one colour - which is why the browser object recall was monochrome where the `I` inspect of the SAME object was not. A third correction comes out in the wash: the rune / feature / trap / shape lores pass `header = NULL` and put the capitalised name in the BODY, but the port draws a title row on every screen AND kept the name as body line 0, so the player read it twice; the name is only the title now. `recallBodyLines` is deleted and all seven go through `proseBlock` / `textParagraphs` and the ONE renderer. `samples/sprite-inventory` takes all seven and NOTHING IN ITS PROSE PANEL CHANGED to accept them - adding them was seven strings in a list, which is what a model with a small vocabulary buys. **Recorded here, then measured and closed as #255 on 2026-08-13**: the note filed at the time said the renderer wrapped at `cols - 1` (79) where upstream wraps at 80. The premise was wrong and finding that out was the deliverable - the port matched `textblock_calculate_lines` on 1041 of 1041 shipped descriptions at 80 columns. The real defect was a CITATION: `textBlockLines` implemented `textblock_calculate_lines` while its comment named `text_out_to_screen`, which is why reasoning from the comment produced a false bug report. Measuring instead of reading turned up three genuine ones - the hard-split arm took `cols - 1` where the C takes `cols` (unreachable at 80, reachable by a mod re-rendering at 16); a paragraph ending exactly on a break lost its blank row; and the character sheet history was two columns wide of 4.2.6 and re-flowed, because it is the ONE page on the other algorithm. **2026-08-13, step 5b-vi, part one: the VISIBLE-MONSTER LIST.** `core:monster-list` takes `MODELLED_SCREENS` from twenty to twenty-one, and it is the screen where reading numbers instead of text pays most obviously: a row publishes `values.dy`/`values.dx`, so a presenter can draw an ARROW at the monster, and no arrow is recoverable from `" 3 N 2 W"` without parsing a compass back into the vector it was made from. `values.asleep` is a count where the terminal has the sentence `"(2 asleep)"`, `semantic.ref` is the race a tileset mod looks a sprite up by, and `semantic.data.name` is the game own pluralisation (`get_mon_name`) so a card can say "3 kobolds" without a mod reimplementing English. **The layout question this row had to answer**: the C pads the name with `"%-*s%s"` at a width computed per row (`full_width = max_width - 2 - len(location) - 1`), which LOOKS per-row and is not - the total is `max_width - 1` on every row, because the width shrinks by exactly what the location adds, and upstream own comment says the point of it is to "align the location to the right" (ui-mon-list.c L156). So the model is a fixed name column plus a right-aligned location column, which reproduces the C byte for byte while the name cell arrives UNPADDED. The one place they part is clipping - the C clips at that row own `full_width`, which is more generous on a row whose location is shorter than the section longest - and that is measured rather than argued: the widest name-plus-tag the shipped pack can generate clears the narrowest 80-column name column by 14, and a test fails if a content mod ever closes that gap. **The seam moved out of `main.ts`.** This screen has an ACTION (`sort-exp`, the `x` toggle at ui-mon-list.c L410,456), so it needs a `ScreenHost`, and a seam that only exists inside the game entry point is a seam nothing can drive - the character sheet host wiring was untestable for exactly as long as it lived there. `packages/web/src/monster-list.ts` now holds both ways of showing it. **The sample takes it**, which is the test: `samples/sprite-inventory` draws the list as cards with a compass arrow, and `screens.test.ts` loads that folder by path, drives the real `showMonsterList` through the real seam against a real `GameState` with a real monster, and asserts `"-> 3"` reached the canvas - a string the game never produces. Then it presses `x` and checks the footer changed, so the GAME re-sorted and handed back the new view. **2026-08-14, step 5b-vi finished, and the scope it was written against was short by six screens.** `MODELLED_SCREENS` goes from twenty-one to **thirty-seven**: the four help pages, the equip-cmp screen's two help overlays, the mod manager's four listings, the hall of fame, the knowledge store view, the update and report pages, and wizard mode's two debug readouts. **The step was scoped from a list and the list was wrong**, which is the finding worth keeping: a sweep of every OTHER file in `packages/web/src` turned up `score.ts`, `main.ts`'s `showStoreKnowledge`, both wizard readouts and the two shell pages, none of which the step named. Three of those were a different KIND of gap - `score.ts`, `drawWizItem` and the two shell pages called `term.clear()` / `term.print()` and never reached `showThroughPresenter` at all, so they were invisible to a mod rather than merely flat, and invisible to a grep for screen call sites too. **THE HALL OF FAME IS THE ONE THAT MATTERED**: a leaderboard is the first thing a UI-replacing mod rebuilds, `HighScore` already carries a field per column, and `scoreRow` joined them into three prose strings before anything could see them. The fix went into CORE - one `ScoreRow.fields` extraction that the three display lines are then built FROM - because publishing the fields as a second independent read beside the strings is the two-transcriptions failure, and the copy nobody looks at is the one that rots. **Two more screens were prose only because a PRODUCER had destroyed their structure**, which is now its own row (#256): `mod-install.ts` flattened `checkMod`'s `{title, problem}[]` into a string that `mod-browse.ts` then split back apart and re-wrapped - the process re-parsing a rendering it produced two frames earlier - and `mod-conflicts.ts` described every `ContestedSlot` into `string[]` before the pane saw it. Both now carry the records beside the sentences, with the sentences DERIVED from the records so the two cannot part, and `core:mod-conflicts` is a table. **Saying so was half the work, as this row said it would be**: all 32 `showTextScreen` call sites in the mod manager were read one at a time and twenty-four are genuinely prose, each with the reason in the source. **A NEW KIND OF BLOCKER appeared and is counted separately**: the install-refusal screens stay at `lines` not because of their data, which now arrives intact, but because a `table` row is exactly one terminal row and cannot wrap, while four of five bullets the shipped requirement set produces are longer than the width the screen wraps to. That is the block VOCABULARY, and it belongs to `screen-view.ts`. **Byte-identical rendering was the acceptance criterion throughout**, against output captured from the real producers before each change and asserted as full `ScreenLine` objects rather than `.text`, so a coloured cell emitting a `runs` array where a plain coloured line used to go would show; one check was verified by BREAKING IT ON PURPOSE, because a snapshot that has never failed is not a snapshot. Two rows could not be reproduced and are reported rather than adjusted: the store view's header (a `tagged` table renders its header on the data grid, which would move `Store Inventory` from column 0 to 3), and the symbols page's intro, which stays on `lines` because upstream hand-wrapped that file and reproducing its breaks from a paragraph needed a wrap clamp chosen to fit the answer. **The playing guide and the community page went the other way** and publish their prose unwrapped, moving seven paragraphs' line breaks and no words - a trade available only because both are PORT ADDITIONS with no upstream layout to be faithful to. **Still open in this row**: overlapping, ordered, mod-created, transparent regions and compositing, and input routed by region - both now DESIGNED in seven staged commits (#261), neither built; a PROMPT SEAM (#258), because `ScreenHost.invoke` holds when the game's answer is a new view and breaks when its answer is to ask the player something, which the character sheet's `rename` and `file` have done since they shipped; and the spell lists, which belong to the menu seam. The full-screen-overlay defect above is still live, and the design reframes it: both sides were behaving correctly, and what was missing was that a screen had no rectangle at all, so nothing could learn it was being covered. **2026-08-14, milestone 6 landed: three of the four consequences are built, and the fourth is the one left.** Regions overlap and are ORDERED by band (`RegionLayer`: `base`/`overlay`/`modal`/`system`, later-declared on top within a band, `system` reserved to the game); a mod CREATES regions (`ModPlugin.regions()`, `ui:region.create`, a distinct capability ACTION that `ui:*.replace` deliberately does not cover, published across all four ABI sites in #267); a region is TRANSPARENT wherever it did not write, with `clipSurface` DROPPING an outside write rather than clamping it onto a neighbour. `ui-stack.ts` is the live stack and the compositor, `paintRegionStack` is pinned on the AST as the literal last statement of `render()`, and `samples/sprite-inventory` ships a real `regions()` panel that draws on the character grid with no DOM at all. Five owner runtimes are wired in `main.ts`, and core is candidate zero for every base region it is not out-claimed on. **What is NOT built is consequence (4), input routed by region**: `topRegionAt` exists, is tested, and has ZERO production consumers, and `RegionDeclaration` has no input member of any kind. The cost is measurable rather than theoretical: `main.ts`'s tap-to-move handler gates on `modalDepth` and the map rectangle and on nothing else, so **tapping a mod's own overlay region walks the player's character**, and a long-press there opens the game's context menu for the dungeon square underneath it. **The full-screen-overlay defect is now PARTLY closed, and the remainder is counted**: `showViewOnTerminal` and `showLevelMap` declare regions, so a front end is told and `blueprint-view` stands down, but of the four screens photographed above, only the inventory goes through that path. **2026-08-14, #253: ten more screens declare their rectangle**: all four birth screens (and `drawBirthSheet`, which took all four of its callers to convert), both of the character sheet's own terminal painters, and all four of the mod browser's wait screens. **20 of the 34 enumerated `term.clear()` sites are still full-screen erases nothing can learn about**; four of those 20 are the PROMPTS, which is where this row and #258 meet, and two more are `main.ts`'s own report and update pages. **This closes no seam and this row stays open**: converting a screen gives a mod nothing it could not reach before, it removes a reason the screen could not coexist with one. The remainder is `main-regions.test.ts`'s `TERM_CLEAR_PENDING`, which is now a VALUE rather than a list of comments, so the count above is derived from the table rather than recounted by hand. **2026-08-14, #276: input is ROUTED BY REGION, built.** `clipSurface` records the cells each painter writes, erases included, and `regionInputAt` answers with the topmost painted owner in region-local coordinates. Tap, desktop context-click and touch long-press ask that cell question before core movement or context routing, so **tapping a mod's own panel no longer walks the player's character**; a region with no `input` handler still consumes pointers on its painted cells, which is what makes the shipped `sprite-inventory` sample correct without changing it. Pointer input is positional ONLY: keyboard focus stays out of scope, because a region that took keys would be a second answer to what a player command means. `topRegionAt` remains the rectangle/layout helper rather than the input router. The design is `docs/modding/REGION_INPUT.md`, ruled cell-opaque (a region is opaque to input exactly where it PAINTED, so sight and touch obey one rule) and written independently by two model families that converged on every load-bearing decision. Two corrections this row owed: the tap handler does NOT gate on `modalDepth` and the map rectangle alone: it also checks `scoresOpen`, `dead` and `mouse_movement`, and candidate-zero is not one universal runtime, `hud-runtime.ts` supplies it for HUD regions while the map's lives in `frontend-runtime.ts`. **2026-08-21, step 7 landed: a mod can draw with real HTML, and the input door will stand down for it.** Every step above this one widened what a mod could do with the CHARACTER GRID, and each was the right move for a HUD region or a menu and the wrong one for a form. `RegionSurface` publishes seven methods; a field with a caret, a list with a scrollbar and a table sorted by clicking a column are a caret, a tab order and a focus model reimplemented inside a terminal. So `ui:panel.mount` grants `ctx.ui.openPanel(spec)` (`packages/web/src/panel-runtime.ts`), which mounts a host-owned container on the page above the game and returns a closed shadow root to build in. WHAT WAS ACTUALLY BLOCKING THIS WAS THE KEYBOARD, not the drawing: a plugin runs in the page's realm and could always append an element, but this front end has ONE keydown registration - `window`, capture phase, installed at import (`input-door.ts`) - and every modal handler behind it cancels the event, so a real `<input>` received nothing and its keystrokes were read as game commands. The door now asks a `DomKeyboardOwner` per keystroke and stands down for one whose composed path reaches the top panel before the game's canvas, which makes the caret the thing that decides whose key it is. That is the only power in the grant a mod did not already have, and it is built to be revocable and to FAIL OPEN: a container detached, moved out of the layer, or made a parent of the canvas is closed and the keyboard goes back, checked as the key arrives rather than once at mount; a panel under another is inert; a real Escape closes the topmost panel before the panel is offered the key, and focus returns to the game; a modal panel carries a host-drawn close control because a phone has no Escape key; a player-initiated close puts that mod on a brief pause so a reopen loop cannot outrun the key that ends it. NOT CONTAINMENT, and the consent text says so rather than implying otherwise - the shadow root is style hygiene, an iframe would have fenced the half that draws a form while the half holding `ctx.core` sat outside it, and the boundary here is the same one it is everywhere else in this system, which is the player's decision to enable the mod. WHAT THIS ROW STILL OWES is unchanged by it: the ~13 unmodelled screen builders, the ~20 of 34 undeclared full-screen erases, menu presentation, and a hover/focus input model for the GRID. A mod that wants the game's own screens redrawn still needs those; this step is for the mod that wants a screen the game does not have. **A note on this row's own bookkeeping:** the "14 milestones, 13 done" figure that has been quoted in status reports was recorded in NO artefact in this repository when that was written. **Re-measured 2026-08-14, and the figure had already decayed:** `MOD_REACH.md` now contains the word "milestone" five times and `CHANGELOG.md` twice, so a reader who trusted the sentence above would be counting against a tree that had moved under it, which is the exact failure the sentence was warning about. The GitHub issue list is still empty. A 14-item list can be RECONSTRUCTED from this row's dated steps plus milestones 6 and 7, and it happens to fit, but that is an inference. A number nothing in the tree states is a number that cannot go stale visibly. |
1480
1480
  | 20 | **A mod can teach core a new ITEM CLASS** | **CLOSED 2026-08-09** | `object.json` has always accepted a new record, so a mod could always ship a new ITEM. Making core recognise a new item CLASS - a tval - was a different thing entirely, and **the switch census could see almost none of it.** The census counts `switch` statements; `obj/object.ts` exports **34 class predicates** ported from `obj-tval.c`, and the 29 written as `tval === TV.STAFF` are exactly as closed to a mod as the 5 written as switches. **408 call sites** across `core` and `web` ask these questions, and a mod-coined tval answered **no to every one**: its items were not weapons, could not be worn, could not be flavoured, could not be browsed as a book, had no charges and no timeout. Plus two dispatches: `kindIsGood` (`obj-make.c`), so a new class could never be good on the strength of its own plusses, and `objectValueBase` (`obj-power.c`), whose `default: return 0` means a shop shows an unidentified item of an unknown class as **worthless**. The five census rows were also shelved under "object naming / description" through four re-measurements - a verdict can have the right CLASS and the wrong SUBJECT, which is why `switch-census.test.ts` stayed green over the error. `TvalRegistry` (`obj/tval-registry.ts`) is three tables reached through `registry:tval`, and `classes` is **keyed on the exported predicate's own name** (`"tvalIsWeapon"`), so there is no translation table between what a mod writes and what core calls - and `tval-registry.test.ts` DERIVES its expectations from the module's exports, so a predicate added later and forgotten fails instead of silently answering `false` forever. What proves the conversion: **1,224 golden answers** (36 tvals x 34 predicates, the complete cross product rather than a sample - these are pure functions of one small integer, so there is no sampling judgement to get wrong) plus **389 real object kinds** through both dispatches, all recorded before the registry existed (commits `22eb08009`, `16f8599c5`). No RNG probe, and that is measured: nothing on these paths draws. Controls all run and restored - widening `tvalIsBolt` to accept ARROW fails and names ARROW; changing `objectValueBase`'s WAND arm from 50 to 51 fails; dropping one arm fails the derived check by name; unwiring the host target throws "did not wire"; a mod without the capability throws at the gate. Reach is proven from disk (`mod-code.node.test.ts`) by a real mod folder coining tval 200, WRAPPING three class predicates and registering both dispatches, then read back through the REAL exported predicates - the same functions the 408 call sites call, not the table. |
1481
1481
 
1482
1482
  ---
package/docs/PLUGINS.md CHANGED
@@ -397,6 +397,43 @@ be a lookup: allocate whatever tiles your answers need (with `transform`, during
397
397
  the fill) and read the table here. A provider that throws loses that one
398
398
  frame's answer and nothing else.
399
399
 
400
+ ### Reading a monster's tile: `ctx.tiles`
401
+
402
+ `registry:tiles` above is the WRITE side: supplying art the loaded pack has
403
+ never heard of. `ctx.tiles` is the READ side, for a mod that wants to draw a
404
+ monster's existing tile art somewhere of its own - a portrait in a dialog, a
405
+ sidebar row, anything outside the dungeon grid itself.
406
+
407
+ ```js
408
+ register(host, ctx) {
409
+ const painted = ctx.tiles?.active && ctx.tiles.hasMonsterTile(race.ridx)
410
+ ? ctx.tiles.drawMonster(myCanvas.getContext("2d"), race.ridx, 0, 0, 32, 32)
411
+ : false;
412
+ if (!painted) drawAsciiGlyphInstead(race);
413
+ }
414
+ ```
415
+
416
+ - **`ctx.tiles.active`** is true when a graphics/tileset mode, rather than
417
+ ASCII, is the current display mode. False in ASCII mode, always - there is
418
+ nothing to paint.
419
+ - **`ctx.tiles.hasMonsterTile(ridx)`** answers whether the ACTIVE pack assigns
420
+ this race a tile at all (core's own `tileForMonster`), without touching a
421
+ canvas. False in ASCII mode, and false for a race the pack has never drawn -
422
+ an old pack with no art for content added since, say.
423
+ - **`ctx.tiles.drawMonster(ctx2d, ridx, dx, dy, dw, dh)`** paints the race's
424
+ tile onto a 2D canvas context you own, at `(dx, dy)` scaled to `(dw, dh)`,
425
+ composited over a neutral floor tile - the same terrain-then-foreground blit
426
+ the dungeon view itself draws a monster standing on open ground with.
427
+ Returns `true` when art was drawn; `false` (ASCII mode, no tile for this
428
+ race, or its image has not finished loading yet) means fall back to your own
429
+ ASCII glyph.
430
+ - **Entirely ungated**, the same reasoning `display` and `subwindows` carry:
431
+ this is read-only art the page already fetched (the active pack's own
432
+ images), not a capability over the game or the platform.
433
+ - **Absent on an older host.** `ctx.tiles` did not exist before neo-angband#256;
434
+ guard it with `ctx.tiles?.` and degrade to the ASCII glyph, the same shape
435
+ every other optional `ctx` door already uses.
436
+
400
437
  ### Engine-wide settings you change through `ctx.core`, not through a hook
401
438
 
402
439
  A few of the engine's decisions are not taken inside a turn and have no game
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgm-tools/neo-angband-mod-sdk",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "Pack schemas, validation, and tooling for Neo Angband mods (content packs, tile packs, scripted plugins)",
5
5
  "license": "GPL-2.0-only",
6
6
  "author": "neostryder (RPGM Tools)",