@rpgm-tools/neo-angband-mod-sdk 1.1.0 → 1.1.2

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.
@@ -0,0 +1,1567 @@
1
+ # Mod plugins: shipping CODE in a mod folder
2
+
3
+ A mod that only changes records needs no code: drop `manifest.json` plus one
4
+ `<record-type>.json` per thing you change into a folder and you are done (see
5
+ [../MODS.md](https://github.com/neostryder/neo-angband/blob/master/docs/MODS.md)). This document is for the other kind: a mod that changes
6
+ *behaviour*.
7
+
8
+ > **A breaking mod API change is now a MAJOR version bump.** Before 1.0, the
9
+ > API could change on any minor release; from 1.0.0 on, a plugin written
10
+ > against the current API keeps loading across patch and minor releases, and
11
+ > the deprecation window described below still gives an author a release's
12
+ > warning before a real break lands. See [Version contract](#version-contract).
13
+
14
+ ## The shape
15
+
16
+ A mod is a **folder**, and it may hold as much as it needs to: several scripts,
17
+ records, images, sounds, data of its own.
18
+
19
+ ```
20
+ mods/my-mod/
21
+ manifest.json
22
+ plugin.js <- the entry point, default-exporting a plugin
23
+ lib/dice.js <- more scripts; import them relatively
24
+ lib/format.js
25
+ monster.json <- a record contribution (needs the "content" facet too)
26
+ tiles/orc.png <- an image: await ctx.assetUrl("tiles/orc.png")
27
+ data/spawns.json <- your own data (nested .json is an asset, not a record)
28
+ README.md
29
+ ```
30
+
31
+ Only `plugin.js` is loaded by name. Everything else your code reaches itself:
32
+ scripts by importing them, everything else through `ctx.assetUrl`.
33
+
34
+ `manifest.json` must declare the **`plugin` facet** and `modApi`.
35
+
36
+ A mod that only runs code can say `"shape": "plugin"` and stop there. A mod that
37
+ contributes **both** records and code (the ordinary case, e.g. a new monster
38
+ plus the behaviour that makes it interesting) lists both facets:
39
+
40
+ ```json
41
+ { "shape": "content", "facets": ["content", "plugin"] }
42
+ ```
43
+
44
+ `facets` must contain `shape`, so the two can never contradict each other. Either
45
+ spelling works (`shape` is just the primary kind, and what the mod manager
46
+ displays).
47
+
48
+ > Until 2026-07-29 `shape` was **exclusive**, and the loader gated code on
49
+ > `"plugin"` while composition gated records on `"content"`. A folder like the one
50
+ > above therefore could not work: declaring `plugin` dropped `monster.json`
51
+ > silently, and declaring `content` refused the code. If you wrote a mod against
52
+ > the older text and found half of it inert, that was this, and `facets` is the fix.
53
+
54
+ ```json
55
+ {
56
+ "id": "my-mod",
57
+ "name": "My Mod",
58
+ "version": "1.0.0",
59
+ "shape": "plugin",
60
+ "modApi": 1,
61
+ "affectsGameplay": true,
62
+ "capabilities": ["registry:effect"],
63
+ "description": "What this does, in your own words. The mod manager shows this."
64
+ }
65
+ ```
66
+
67
+ `plugin.js`:
68
+
69
+ ```js
70
+ export default {
71
+ api: 1,
72
+
73
+ // Behaviour: folded into the one ModHooks the engine consults.
74
+ hooks(ctx) {
75
+ if (!ctx.flags.myToggle) return undefined; // a rule the player turned off
76
+ return {
77
+ messageText: (raw) => raw.replace("You have", "Thou hast"),
78
+ };
79
+ },
80
+
81
+ // System overrides: capability-gated registries.
82
+ register(host, ctx) {
83
+ host.effects.register("MY_EFFECT", {
84
+ handler: (context) => { /* runs inside effect_do, live state */ return true; },
85
+ });
86
+ },
87
+ };
88
+ ```
89
+
90
+ Every member is optional, and the two above are only the two most common. The
91
+ full set is `hooks`, `register`, `controller`, `frontend`, `hud`, `menu`,
92
+ `screen` and `regions`, plus `migrateBag` and `uninstall`. A plugin is refused
93
+ only when ALL eight of the first group are absent, because a mod with no code
94
+ simply ships no `plugin.js` - so a plugin whose only member is `frontend`, or
95
+ `controller`, or `regions`, loads fine. `migrateBag` deliberately does not count
96
+ as code of its own: a plugin that can only migrate a bag it never writes has
97
+ nothing to migrate.
98
+
99
+ ## Your own saved data, and changing its shape
100
+
101
+ You may keep whatever JSON you like in the player's save, under your mod's id.
102
+ The engine round-trips it verbatim and never reads it. It is stored with a
103
+ `schema` number, whatever your manifest's `saveSchema` was when it was written.
104
+
105
+ **The bag is not on `ctx.state`.** `GameState` does not carry the bags at all;
106
+ they live on the `StartedGame` the host holds, which is what `saveGame` reads.
107
+ Your handle on your own bag is the `data` argument `migrateBag` is given, and the
108
+ value the host writes back for you. Reaching for `ctx.state.mods` finds nothing,
109
+ because there is no such field.
110
+
111
+ When you change the SHAPE of that data, bump `saveSchema` and ship a
112
+ `migrateBag`:
113
+
114
+ ```js
115
+ export default {
116
+ api: 1,
117
+ hooks(ctx) { /* ... */ },
118
+
119
+ // Called at mod-load time, BEFORE register(), when the bag in the save is
120
+ // behind your manifest's saveSchema. Return the same data in the new shape.
121
+ migrateBag(data, fromSchema, ctx) {
122
+ if (fromSchema < 2) return { kills: Object.keys(data.killed ?? {}).length };
123
+ return data;
124
+ },
125
+ };
126
+ ```
127
+
128
+ Only you can do this: nobody else knows what is in there. What the game does
129
+ around it:
130
+
131
+ | Situation | What happens |
132
+ |---|---|
133
+ | bag behind `saveSchema`, `migrateBag` present | it runs, and the schema is stamped forward |
134
+ | bag behind `saveSchema`, **no** `migrateBag` | the old data is kept **exactly as it was**, and the player is told your mod has data it may not understand. The schema is *not* stamped forward, so you would be handed old data labelled new |
135
+ | `migrateBag` throws, or returns nothing | the old bag stands, and the reason goes on your mod's row |
136
+ | bag **ahead** of `saveSchema` (the player rolled you back) | nothing is changed, and the player is told. A migration backwards is something only you could write |
137
+ | you declare no `saveSchema` | nothing happens, ever |
138
+
139
+ If you would rather branch on the schema inline than ship a migrator, you can:
140
+ the bag carries its own `schema` and you can read it. Omitting `migrateBag`
141
+ *after* bumping `saveSchema` is the case the game reports, because it cannot tell
142
+ that apart from an oversight.
143
+
144
+ ## Why the engine is not imported
145
+
146
+ `ctx.core` **is** the engine: the same live module instance the game is running
147
+ on, passed in rather than imported.
148
+
149
+ This is not a limitation, it is the point. A bare specifier like
150
+ `import { tunnelAux } from "@rpgm-tools/neo-angband-core"` cannot resolve in a module fetched
151
+ from a folder, and bundling the engine into your plugin would give you your *own*
152
+ copy of every registry and singleton: your effect handler would land on a registry
153
+ the interpreter never consults, and your mod would appear to do nothing at all,
154
+ with no error anywhere. One instance, passed in, makes that failure impossible.
155
+
156
+ What `ctx` carries:
157
+
158
+ | Field | What it is |
159
+ |---|---|
160
+ | `id` | your mod's id, which is also its folder name |
161
+ | `api` | the ABI version the **host** implements |
162
+ | `engine` | the engine version, if you want to adapt rather than refuse |
163
+ | `flags` | **your** resolved rule toggles: `choices[flag] ?? rule.default`, sliced to the rules your own manifest declares |
164
+ | `core` | the live engine namespace: core's entire public API |
165
+ | `state` | the live `GameState`. Handed to `register`, `migrateBag`, `controller` and every display seam; **never** to `hooks`, because the host composes hooks before a game exists |
166
+ | `assetUrl` | `(path) => Promise<string \| null>`, a URL for one of *your* files |
167
+ | `data` | your own record files, parsed, keyed without `.json` |
168
+ | `prefs` | `{ get(), set(value) }`, one JSON value of **yours**, kept outside every save |
169
+ | `newCharacter` | whether this session's character was just created, rather than loaded |
170
+ | `registries` | the whole bound `CoreRegistries`: every race, kind, feature, trap, store, projection, room, profile, constant, quest and hint this session actually runs on (absent during content composition) |
171
+ | `log` | a diagnostic line; the host decides where it goes |
172
+ | `backupFolder` | present only when your manifest declared `backup:folder` and the platform can actually offer a folder picker; absent everywhere else, so test for it rather than assuming it |
173
+ | `debug` | `{ spawnObject(kind), spawnMonster(race) }`, conjuring one item or creature into the live game. Present only when your manifest declared `debug:spawn` and there is a game to conjure into. The **first** use in a character asks the game's own debug question and marks that character permanently, so tell the player before they press your button |
174
+ | `wizard` | the game's whole debug set - depth, experience, gold, stats, acquirement, mapping, plus a catalogue of every item, creature and artifact this game has and which pack added each. Present only when your manifest declared `debug:wizard` and there is a game to drive. **Every method refuses until you call `sandbox()`**, which cuts the session loose from its save slot and cannot be undone: the character on disk keeps whatever the last save left, and nothing after that is written. Call `attached()` first to get the name for the question you put to the player. See `MOD_SEAMS.md` section 4f |
175
+ | `installMod` | `(bytes) => Promise<{ok, id, version, lines} \| {ok, problem, lines}>`, installing a **content** mod from the bytes of an archive. Present only when your manifest declared `mod:install`. Code is refused, and what you install lands **switched off** - the player enables it themselves, and a mod takes effect on reload. `lines` is the wording the Mods screen prints for the same outcome, per-requirement rows and all; print it rather than writing a second vocabulary for one concept |
176
+ | `reloadGame` | `() => Promise<void>`, the game's own mod-change reload: every plugin's `uninstall()`, the autoplayer's keyboard handed back, the live character written down, and the session resumed on that character. Present on exactly the terms `installMod` is, because installing something the process will never load is half an act. Not a permission - a plugin reaches `location` regardless - but `location.reload()` skips all four steps, and the third is the player's progress |
177
+ | `loadModForSession` | `(bytes) => Promise<{ok, id, version, survivesReload} \| {ok, problem}>`, loading a **content** mod for this session only. Present only when your manifest declared `mod:session`. Code is refused on the same terms. It is **on** from the next reload rather than waiting to be switched on, and the archive is forgotten when the game closes - but what it did to a character is not. Say that to the player |
178
+ | `ui` | `{ openPanel(spec), openPanels }`, panels of **real HTML** rather than character cells. Present only when your manifest declared `ui:panel.mount`; absent everywhere else, so test for it. See `MOD_SEAMS.md` section 4b - the two things that surprise everybody are that Escape is the player's and that a non-modal panel's container takes no pointer events |
179
+
180
+ `flags` is sliced per mod on purpose: a mod must not be able to read or act on
181
+ another mod's toggles, or its behaviour would silently depend on which other mods
182
+ the player happened to enable.
183
+
184
+ ### `state` is absent in `hooks`, and it always will be
185
+
186
+ The row above is easy to skim past and it is load-bearing. The host composes
187
+ every enabled mod's hooks **before it starts the game**, because the composed
188
+ `ModHooks` is an argument to `startGame`, so there is no `GameState` to hand you
189
+ yet, and there never can be. `hooks(ctx)` is a factory over your flags; anything
190
+ needing the live game belongs in `register(host, ctx)`, which runs once with the
191
+ game built.
192
+
193
+ If you need the engine at `hooks` time for something that is not the live game
194
+ (classifying option names, reading a constant), `ctx.core` is there and is the
195
+ same module instance the game runs on.
196
+
197
+ ### `registries` is the content, where `state` is the level
198
+
199
+ These two are easy to confuse and the difference decides whether a whole class of
200
+ mod can be written at all.
201
+
202
+ `state` is **this level**: the monsters standing on it, the objects lying on it,
203
+ the player. It is what you read to draw a frame or to answer a question about
204
+ something in front of the player right now.
205
+
206
+ `registries` is **what the game is made of**: `registries.monsters.races` is every
207
+ race the pack defines, at its real `ridx`; `registries.objects.kinds` is every
208
+ object kind, at its real `kidx`; and features, traps, stores and projections are
209
+ there on the same terms. You need it whenever your question is about a thing by
210
+ INDEX rather than by presence - what a creature you are merely remembering can do,
211
+ what an item you have never seen is worth, which kinds share a `tval`.
212
+
213
+ ```js
214
+ register(host, ctx) {
215
+ const races = ctx.registries?.monsters.races;
216
+ if (!races) return; // composition time, or an older host
217
+ const byRidx = new Map(races.map((r) => [r.ridx, r]));
218
+ }
219
+ ```
220
+
221
+ Three things about it:
222
+
223
+ - **It is the whole `CoreRegistries`, not a curated slice**, for the reason
224
+ `ctx.core` is the whole namespace: a curated list is the thing that drifts. See
225
+ MOD_COMPATIBILITY.md decision 18.
226
+ - **A mod's content is in it on exactly the same terms as core's.** Binding runs
227
+ after every enabled mod has composed its content, and mods append, so a monster
228
+ a mod added is a `MonsterRace` at a real `ridx` and an item a mod added is an
229
+ `ObjectKind` at a real `kidx`. Nothing in a lookup distinguishes them; the only
230
+ marker is the optional `from` provenance field, and most consumers should not
231
+ read it. **This is what makes modded content work the same as vanilla content
232
+ without anybody arranging it** - the alternative is every consumer keeping its
233
+ own table of core's content and silently ignoring everything a mod added.
234
+ - **Guard it.** It is absent during content composition (at that point this is
235
+ what is being built) and absent on any host older than 2026-08-21, so
236
+ `if (!ctx.registries) return;` is the shape, the same one `ctx.backupFolder`
237
+ uses.
238
+ - **It is the READ seam, and it is not read-only.** These are the live objects
239
+ the engine runs on, so `registries.rooms`, `registries.profiles` and
240
+ `registries.rooms.glyphs` carry the same mutators the capability-gated facades
241
+ write through. Use the facade and declare the capability: see
242
+ [What a capability gates, and what it does not](#what-a-capability-gates-and-what-it-does-not)
243
+ for why the gate cannot stop you and why declaring still matters.
244
+
245
+ ### Authoring: `ctx.authoring` and `ctx.composedRecords`
246
+
247
+ For a mod that WRITES content rather than one that plays it. A tool that helps an
248
+ author draft a monster needs two things the seams above cannot give it: the
249
+ functions that know what a well-formed record looks like, and the records the
250
+ game was actually composed from.
251
+
252
+ `ctx.authoring` is the mod SDK's public barrel, live and always present.
253
+ `RECORD_BLUEPRINTS` and `blueprintFor` carry every field's measured shape, type
254
+ set and range; `fieldUsage` and `requiredFields` say how common a field is;
255
+ `peersFor` builds a table of comparable records with the sentence saying why they
256
+ are comparable; `suggestFields` proposes a value with its reason; `checkRecords`
257
+ and `COMPANION_RULES` validate at the three levels the running game uses; and
258
+ `ModProject` assembles and emits a mod folder. It is ungated, because these are
259
+ pure functions over data you pass them.
260
+
261
+ `ctx.composedRecords` is the UNBOUND twin of `ctx.registries`: every record the
262
+ running game composed, as JSON, keyed by pack-file stem with no extension.
263
+
264
+ ```js
265
+ register(host, ctx) {
266
+ const records = ctx.composedRecords;
267
+ if (!records) return; // composition time, or an older host
268
+ const { peers, because } = ctx.authoring.peersFor(
269
+ "monster",
270
+ { name: "Warg matriarch", base: "canine", depth: 22 },
271
+ records,
272
+ );
273
+ ctx.log(`${peers.length} comparable monsters: ${because}`);
274
+ }
275
+ ```
276
+
277
+ - **Use `composedRecords`, not `registries`, for anything the SDK takes.** Every
278
+ `records` parameter in the authoring functions is keyed by file stem and holds
279
+ raw JSON. `registries.monsters.races` is bound: the binder resolved `base` into
280
+ a pointer and dropped the string, so a peer table grouped on `base` cannot be
281
+ built from it, and neither can a question about a field that bound to nothing.
282
+ - **Mod-added records are in it on the same terms as core's**, exactly as they
283
+ are in `registries`, each carrying its provenance. A draft based on another
284
+ mod's sword has acquired a dependency, and this is what lets you see that at
285
+ the moment the base is chosen rather than at install time.
286
+ - **Guard `composedRecords`, not `authoring`.** The records are absent during
287
+ content composition, for the reason `registries` is. The barrel is not: it
288
+ waits on nothing and is there on every context. An older host has neither, so
289
+ `if (!ctx.composedRecords) return;` covers both.
290
+ - **It holds record objects only.** Passthrough files can carry arrays and
291
+ scalars, and the host narrows through the SDK's own `composedObjects` before
292
+ handing it over, so the authoring functions never meet an element they cannot
293
+ read.
294
+
295
+ ### Filling tiles
296
+
297
+ `registry:tiles` is the seam for one narrow thing: **a picture for content the
298
+ loaded tile pack has never heard of.** No tile set was built knowing about your
299
+ mod, so in tile mode a creature you added is a coloured letter standing in a
300
+ tiled dungeon, and the only portable alternative used to be one pref file per
301
+ tile set naming atlas coordinates, which is unmaintainable.
302
+
303
+ ```js
304
+ register(host, ctx) {
305
+ host.tiles.register((fill) => {
306
+ if (fill.pack.engine !== "linoleum") return; // only packs you know
307
+ const races = ctx.registries?.monsters.races;
308
+ if (!races) return;
309
+ for (const race of races) {
310
+ if (fill.monsterTile(race.ridx)) continue; // somebody drew it already
311
+ const donor = fill.monsterTile(0);
312
+ if (donor) fill.fillMonster(race.ridx, fill.derive(donor, 90) ?? { ...donor });
313
+ }
314
+ });
315
+ }
316
+ ```
317
+
318
+ - **You cannot repaint the tile set.** `fillMonster` / `fillObject` write only
319
+ where nothing is assigned and return `false` otherwise. Every pref layer - the
320
+ pack's own, then each enabled mod's - runs before any filler, so a tile an
321
+ author named is not a blank. That also means two mods cannot fight: whoever
322
+ asks first for an index gets it, and neither can undo the other.
323
+ - **`derive(donor, hue)` may return `null`, and usually does.** It asks the
324
+ engine for a tile drawing the donor's asset with its hue rotated. A tilesheet
325
+ cannot: its tiles are cells of a fixed atlas and there is no spare cell for a
326
+ variant, so it always answers `null`. A loose pack can, unless the donor's
327
+ asset is not one of its own. Fall back to a plain copy.
328
+ - **Check `fill.pack`.** A tileset mod's rule is right for its own art and a
329
+ guess about anybody else's. Declining a pack you do not own is the normal case,
330
+ not an edge one.
331
+ - **The game has no opinion about who deserves a tile.** It used to: 0.22.0
332
+ shipped a rule in core that drew a mod-added monster from a race sharing its
333
+ `base`, and 0.23.0 removed it, because Angband 4.2.6 has no concept of a record
334
+ a mod added and the port adds nothing. That rule now lives in `linoleum`,
335
+ which is the worked example. Note what it does NOT fill: rings, amulets,
336
+ mushrooms and food are drawn by FLAVOUR and their kind slots are blank on
337
+ purpose, and an older pack has no art for content added since it was drawn -
338
+ both are blanks where a letter is the honest answer, so the rule is restricted
339
+ to records a mod added, by provenance.
340
+
341
+ If you are shipping content rather than tiles: **draw your own.** A fallback is
342
+ for the mods that do not, and it cannot know what you meant.
343
+
344
+ ### Repainting a tile, and drawing the player's own cell
345
+
346
+ Two more things live on the same `registry:tiles` seam, and neither is filling a
347
+ blank: both start from a tile that already has a picture.
348
+
349
+ **`fill.transform(donor, spec)`** is `derive`'s sibling, for when rotating the
350
+ donor's own hue is not what you want. `derive` keeps the donor's colours and
351
+ turns them, which is a no-op on grey; `transform` replaces the palette outright.
352
+ Every pixel is indexed by brightness into a ramp you hand over and repainted
353
+ with the colour that index names, darkest first, so the result is in YOUR
354
+ colours whatever the donor's were - grey donors included - and alpha carries
355
+ through untouched, so the silhouette stays the donor's exactly. It can also
356
+ mirror the picture horizontally, independently of the ramp. Like `derive`, it
357
+ returns `null` on a fixed tilesheet (no spare cell for a variant) and on the
358
+ same other two refusals; fall back to a plain copy.
359
+
360
+ ```js
361
+ const tile = fill.transform(donor, { mirror: true, ramp: [[20, 10, 30], [90, 40, 110], [180, 120, 220]] });
362
+ ```
363
+
364
+ **`tiles.player(provider)`** answers a different question, once per frame
365
+ rather than once per map build: given who the character is right now, is there
366
+ a tile that fits better than the pack's own player picture? The player is race
367
+ 0 in the monster tile table and every shipped pack assigns it, so there is no
368
+ blank here for a filler to write into - this asks instead of filling, and
369
+ `null` means "no", which is also what happens with no provider installed at
370
+ all. First non-null answer in load order wins, so two such mods can coexist.
371
+
372
+ ```js
373
+ host.tiles.player((view) => {
374
+ if (!view.shape) return null; // normal shape: let the pack draw it
375
+ return shapeTileFor(view.shape, view.level, view.cls, view.race);
376
+ });
377
+ ```
378
+
379
+ The view you get (`shape`, `level`, `cls`, `race`) is deliberately not the live
380
+ player record - naming facts rather than handing over something a render-time
381
+ hook could mutate. Because this runs inside the render path, the provider must
382
+ be a lookup: allocate whatever tiles your answers need (with `transform`, during
383
+ the fill) and read the table here. A provider that throws loses that one
384
+ frame's answer and nothing else.
385
+
386
+ ### Engine-wide settings you change through `ctx.core`, not through a hook
387
+
388
+ A few of the engine's decisions are not taken inside a turn and have no game
389
+ state to hang a hook on. Those are exposed as a **module-level policy** you set
390
+ once, and `hooks(ctx)` is where you set it: it is the earliest moment your code
391
+ runs, before `startGame`, and before boot reads anything.
392
+
393
+ The one that exists today:
394
+
395
+ | Call | Changes | Faithful default |
396
+ |---|---|---|
397
+ | `ctx.core.setPrefErrorPolicy(policy \| null)` | What a pref file does with a line it cannot parse. `{ continueAfterError, reportLimit }`, whether the rest of the file is still applied, and how many errors the player is told about. | `UPSTREAM_PREF_ERROR_POLICY`: stop at the first bad line, which is what `process_pref_file_named` does in 4.2.6. |
398
+
399
+ ```js
400
+ hooks(ctx) {
401
+ if (ctx.flags["mymod.forgivingPrefFiles"]) {
402
+ ctx.core.setPrefErrorPolicy({ continueAfterError: true, reportLimit: 20 });
403
+ }
404
+ return {};
405
+ }
406
+ ```
407
+
408
+ Three rules, and they are the same rules the rest of the mod system runs on:
409
+
410
+ - **Last load wins, and there is exactly one winner.** The host calls each
411
+ enabled mod's `hooks` in load order, so the last mod to set a policy is the one
412
+ that stands, the same promise the mod manager's row makes the player ("Move
413
+ later (loads last, wins conflicts)"). There is nothing to fold: two policies
414
+ cannot be merged into a third that is either of them, which is why this is not
415
+ a `ModHooks` member. If your mod cares, say so in its README; a player who
416
+ installs two mods with opinions about the same policy will get the later one.
417
+ - **Only set it when your flag is on.** Setting the faithful default explicitly
418
+ is not the same as not setting it: it still makes your mod the winner, and
419
+ still overrides a mod loaded before you. A patch the player switched off must
420
+ not call at all.
421
+ - **Turning your mod off really does take it away.** A module-level value would
422
+ otherwise outlive a mod being disabled, but disabling does not take effect
423
+ inside one process. The manager prompts to save and reloads, and after the
424
+ reload your `hooks` is never called, so nothing installs a policy and the
425
+ engine is back on its faithful default. `setPrefErrorPolicy(null)` is the same
426
+ seam for a test.
427
+
428
+ Guard the call if your `engine` range allows a version that predates the seam:
429
+ `typeof ctx.core.setPrefErrorPolicy === "function"`. The range is the gate; that
430
+ check is the seatbelt, and a `ctx.log` line when it fires is what stops a mod
431
+ being silently inert.
432
+
433
+ ### `prefs`: the place for data that outlives a character
434
+
435
+ Your mod has two places to put data and they are not interchangeable.
436
+
437
+ | | lives in | dies when | for |
438
+ |---|---|---|---|
439
+ | your save bag (through `migrateBag`) | the character's save file | that character does | what happened to this character |
440
+ | `ctx.prefs` | the player's install | never, until you clear it | what this player likes |
441
+
442
+ `prefs` is one JSON value, replaced whole, scoped to your mod's id by the host:
443
+ you cannot read another mod's, and passing a different id is not a thing you can
444
+ do. Setting `null` forgets it. Every failure is swallowed and logged rather than
445
+ thrown at you: a full disk must not take your mod down from inside a hook.
446
+
447
+ Where there is no storage at all, `prefs` still exists and simply never
448
+ remembers, so a mod written against it runs on a front end that has none.
449
+
450
+ Reach for `localStorage` yourself and you have hard-coded a browser into a mod
451
+ that would otherwise run anywhere the game does.
452
+
453
+ ## Several scripts
454
+
455
+ Split your plugin up however you like and import the pieces relatively:
456
+
457
+ ```js
458
+ // plugin.js
459
+ import { roll } from "./lib/dice.js";
460
+ import { describe } from "./lib/format.js";
461
+ ```
462
+
463
+ Two rules, both of them things a browser cannot do rather than choices:
464
+
465
+ - **Put the extension on.** `"./lib/dice.js"`, not `"./lib/dice"`. Extensionless
466
+ resolution is a Node and bundler convenience; no browser has ever done it.
467
+ - **No cycles.** Two files that import each other cannot both be loaded from a
468
+ browser folder: a file's address there only exists once its text is final, and a
469
+ cycle needs both addresses at once. Move the shared part into a third file.
470
+
471
+ Anything else works, in as many subdirectories as you want. If a script is missing
472
+ or two import each other, the mod manager names *those* files, not your entry
473
+ point.
474
+
475
+ ## Images, sounds, and your own data
476
+
477
+ Ask for a URL; do not build a path.
478
+
479
+ ```js
480
+ register(host, ctx) {
481
+ ctx.assetUrl("tiles/orc.png").then((url) => {
482
+ if (url) { /* an <img>, a canvas draw, a texture */ }
483
+ });
484
+ }
485
+
486
+ // data too - your own JSON, not a record contribution
487
+ const spawns = await fetch(await ctx.assetUrl("data/spawns.json")).then((r) => r.json());
488
+ ```
489
+
490
+ On desktop that URL is an `http:` one under the shell's own server; in a browser
491
+ tab it is a `blob:`. A mod that hard-codes either is a mod that runs on one of the
492
+ two front ends. The URL lasts for the session, and asking twice gives you the same
493
+ one.
494
+
495
+ `ctx.assetUrl` only ever reaches **your own** folder: the id is fixed by the host,
496
+ and a path that climbs out of it is refused.
497
+
498
+ ## Bare specifiers still do not work
499
+
500
+ `import { tunnelAux } from "@rpgm-tools/neo-angband-core"` cannot resolve from a folder, and
501
+ there is nothing to import: the engine is `ctx.core`, already live. That is the one
502
+ import a folder plugin cannot have, and the mod manager says so instead of
503
+ repeating the browser's message.
504
+
505
+ ## Version contract
506
+
507
+ `modApi` is an integer, and the host accepts a **window**: everything from
508
+ `MOD_API_MIN` up to `MOD_API_VERSION` inclusive
509
+ (`packages/web/src/mod-plugin.ts`). A plugin inside the window but below the
510
+ current version loads and is reported as DEPRECATED, which is what gives its
511
+ author a release's warning before the number that would strand it moves. Today
512
+ `MOD_API_MIN` and `MOD_API_VERSION` are both `1`, so the window is one value wide
513
+ and an exact match and a window are indistinguishable from the outside - but the
514
+ mechanism is the window, and `MOD_COMPATIBILITY.md` is where the two-release rule
515
+ that rides on it is written down.
516
+
517
+ Outside the window the plugin does not load, and the mod manager names both
518
+ numbers and which side is behind: a too-new mod needs a newer game, a too-old one
519
+ needs a mod update, and "incompatible" on its own sends the player to the wrong
520
+ place.
521
+
522
+ It is declared in the **manifest**, not only inside `plugin.js`, so an incompatible
523
+ plugin can be refused *before* it is imported. A version check inside the module
524
+ can only run after the module's top-level code has already executed, which is the
525
+ wrong order for code that came out of a folder anyone can write into.
526
+
527
+ ## What has to be true before your code runs
528
+
529
+ In order, and all of it before the import:
530
+
531
+ 1. the folder ships `plugin.js` (from the directory listing, with no probing);
532
+ 2. the mod is **enabled**, since a disabled mod's code does not exist, the same rule as
533
+ a disabled mod's patches;
534
+ 3. the manifest declares the `plugin` facet (via `shape` or `facets`);
535
+ 4. `modApi` matches;
536
+ 5. the player has **consented** to every capability the manifest requests.
537
+
538
+ Then the module is imported and its default export is shape-checked.
539
+
540
+ Nothing about a bad plugin can stop the game booting. A hand-edited manifest, a
541
+ half-finished download, a plugin that throws at import or inside `hooks()`: each
542
+ becomes one line the mod manager shows, and the other mods carry on.
543
+
544
+ ### Message types are declared as DATA, not in `register()`
545
+
546
+ `register()` runs after the game has been bound, **384 top-level statements
547
+ after**, measured rather than estimated, so a message type declared there is
548
+ declared after every record that could have named it. And a content-only pack has
549
+ no `register()` at all, so for the packs most likely to want one this was not
550
+ late, it was unreachable.
551
+
552
+ So if your pack's own spell, blow method, summon or projection carries a `msgt:`,
553
+ ship the type as a `message_type` record file instead:
554
+
555
+ ```json
556
+ { "records": [
557
+ { "name": "SOULFIRE", "sound": "soulfire", "sounds": "sf_one sf_two" }
558
+ ] }
559
+ ```
560
+
561
+ `name` is the bare `MSG_` name a `msgt:` spells, `sound` is the `sound.prf` key
562
+ the type plays under, and `sounds` is the space-separated sample list bound to
563
+ it, all three, because a pack that could name a type and never bind a sample to
564
+ it would be half a capability. **No capability and no `plugin.js` are needed**: it
565
+ is a record file like any other, and gating one record file while a pack may
566
+ already add a projection, a monster, an artifact and an ego item ungated would be
567
+ a fence with no wall attached.
568
+
569
+ Declarations are additive, attributed to the pack that coined them, idempotent
570
+ across the new-game and load paths, and **never throw**: a refused declaration
571
+ loses one message type and reports it rather than taking the boot down.
572
+
573
+ `host.messages.define(...)` still exists and is still the right call for a type a
574
+ plugin coins at runtime, e.g. to re-point sounds. It is only the wrong place for
575
+ a type your own records name.
576
+
577
+ ## Front-end replacement
578
+
579
+ `frontend(ctx)` is an optional `plugin.js` member. It returns a sink for the
580
+ live map stream (or `undefined` to decline):
581
+
582
+ ```js
583
+ export default {
584
+ api: 1,
585
+ frontend(ctx) {
586
+ return {
587
+ present(frame) {
588
+ // frame.cells: semantic terrain/occupant layers and visibility,
589
+ // not terminal characters that need reverse-parsing.
590
+ },
591
+ };
592
+ },
593
+ };
594
+ ```
595
+
596
+ A complete worked example lives in **`samples/blueprint-view/`**, a folder you
597
+ can copy straight into a mods folder. It draws a blueprint of the dungeon from
598
+ the frame's semantic layers, and `packages/web/src/sample-blueprint.node.test.ts`
599
+ loads that folder by path and records what it draws, so the sample is checked
600
+ code rather than an illustration. It has also been run in the installed desktop
601
+ build, which is where the missing viewport geometry stopped being theoretical;
602
+ see **Where you may draw** below for what it now reads instead.
603
+
604
+ The manifest must request **`display:replace`**, and the player must approve it:
605
+
606
+ ```json
607
+ { "shape": "plugin", "capabilities": ["display:replace"] }
608
+ ```
609
+
610
+ Declaring `frontend` without it is reported by name and the game keeps drawing.
611
+ It is deliberately not a `registry:` domain and **`registry:*` does not cover
612
+ it**: an override grant changes one named game system among many, while this one
613
+ means everything the player sees of the dungeon comes from the mod. That is the
614
+ same reasoning that makes `controller` require `command:add`.
615
+
616
+ For TypeScript, import the public data contract type-only from the SDK:
617
+ `import type { WorldFrame, WorldFrameSink } from
618
+ "@rpgm-tools/neo-angband-mod-sdk"`. The build erases that import, so a folder
619
+ plugin still has no bare runtime dependency. There is one slot and the **last
620
+ eligible mod in load order wins**; earlier frontend factories are not called.
621
+ The host hands the winner a frozen, structurally owned snapshot per real map
622
+ repaint. It is safe to retain for an animation frame, but cannot expose or
623
+ mutate the live player-grid object. A frontend that throws loses its display
624
+ attempt and the game's own renderer resumes.
625
+
626
+ **The game's renderer competes in that same list, as candidate zero.** It
627
+ declares `frontend` and `display:replace` exactly as a mod does, and it wins
628
+ whenever no mod outranks it - it is not a fallback the selection falls through
629
+ to. That is what makes the seam's claim checkable rather than aspirational: if
630
+ it could not express the front end the game already ships, "a mod can replace
631
+ the front end" would be a claim about a shape nobody had built through it.
632
+
633
+ This replaces the map display only. Menus still use `registry:menu`, and input
634
+ still enters through the host's device-neutral input door; gamepad bindings and
635
+ whole-screen ownership are later seams.
636
+
637
+ ### Where you may draw: `frame.regions`
638
+
639
+ Every frame carries the named parts of the screen, so a front end no longer has
640
+ to guess where the map is:
641
+
642
+ ```js
643
+ present(frame) {
644
+ const box = frame.regions?.map?.pixels;
645
+ if (!box) return; // no geometry: draw NOTHING
646
+ canvas.style.left = `${box.x}px`;
647
+ canvas.style.top = `${box.y}px`;
648
+ canvas.style.width = `${box.width}px`;
649
+ canvas.style.height = `${box.height}px`;
650
+ // ...
651
+ }
652
+ ```
653
+
654
+ `regions` has `map`, and, depending on the player's sidebar mode, `messages`,
655
+ `sidebar` and `status`. Each carries `cells` (a rectangle of the character grid)
656
+ and `pixels` (CSS pixels in the game window's coordinate space, the space
657
+ `getBoundingClientRect()` answers in). `map` is yours while you hold the
658
+ display; the others are core's and are published so you can stay off them, or
659
+ cover them knowing what you are covering.
660
+
661
+ Three things worth knowing:
662
+
663
+ - **The names are roles, not places.** `sidebar` is the 13-column left column in
664
+ the classic layout and a one-line header under the messages in the compact
665
+ one, and it is **absent** when the player has turned the vitals off. Read it
666
+ every frame rather than caching it: it moves on a resize, on a sidebar-mode
667
+ change, and when a narrow window forces the compact layout.
668
+ - **`regions` is optional.** A host with no fitted surface has none to give.
669
+ Treat that as "draw nothing", not as "fall back to the window": falling back
670
+ reintroduces the defect below, intermittently.
671
+ - **The map is one column narrower than the screen.** That is upstream's own
672
+ rule (`SCREEN_WID` reserves the rightmost column), not a rounding error.
673
+
674
+ **Two properties the four regions have today that will not hold forever.** They
675
+ tile the screen without overlapping, and the set is closed. Neither is a promise:
676
+ the UI seam (`MOD_REACH.md` gap 21) is decided to make a full screen **composed
677
+ of** regions rather than covering them, which means regions will overlap, gain a
678
+ stacking order, and be creatable by a mod: a floating window over a map that is
679
+ still being drawn is the whole point of it. Nothing in the code above changes
680
+ when that lands; code that *infers* disjointness does. Read `regions.map` and
681
+ draw in it; do not compute your rectangle by subtracting the others.
682
+
683
+ **Covering the window costs the player everything else on it.** Before regions
684
+ existed, running the sample in the installed build made this plain:
685
+ `display:replace` really does replace the map only: core stops drawing the
686
+ dungeon and goes on drawing the sidebar, the message line and every menu, but a
687
+ front end that covers the window paints over all of it, so you could not read
688
+ your hit points, see a message, or open the Mods screen to turn the mod off. You
689
+ would have had to edit the enabled set by hand.
690
+
691
+ A front end is still *allowed* to take the whole window; an isometric or 3D view
692
+ may want to. What the regions change is that it is now a decision, taken knowing
693
+ what is being covered, rather than the only thing a mod could do.
694
+
695
+ ### Knowing when you are covered: `frame.stack`
696
+
697
+ `frame.regions` says where the map is. `frame.stack` says what is on top of it:
698
+ every region on screen, bottom to top, beginning with the four base tiles
699
+ `regions` names. A region later in the array is drawn over one earlier in it.
700
+ Find the entry whose `id` is `"map"`; if any entry after it overlaps its `cells`,
701
+ hide your display.
702
+
703
+ ```js
704
+ function coveredUp(frame) {
705
+ const stack = frame.stack;
706
+ if (!stack) return false; // this host publishes none
707
+ const at = stack.findIndex((r) => r.id === "map");
708
+ if (at < 0) return true; // a stack that stopped naming the map
709
+ const map = stack[at].cells;
710
+ return stack.slice(at + 1).some((r) =>
711
+ r.cells.col < map.col + map.cols && map.col < r.cells.col + r.cells.cols &&
712
+ r.cells.row < map.row + map.rows && map.row < r.cells.row + r.cells.rows);
713
+ }
714
+ ```
715
+
716
+ `samples/blueprint-view/plugin.js` ships exactly this. The game's own copy of the
717
+ question is `occludersOf` in `packages/web/src/regions.ts`, with
718
+ `regionsIntersect` (the four comparisons above) beside it. Neither is reachable
719
+ from a mod, and that is a property of how a mod is loaded rather than an
720
+ oversight: a module fetched from a mod folder cannot resolve a package by name,
721
+ so `neo-angband-mod-build` marks every bare specifier external and fails the
722
+ build on any that survive. **Types cross that line, because the build erases
723
+ them; functions do not.** Publishing these two through the SDK would therefore
724
+ publish a member no mod could import. So write your own as above: it is nine
725
+ lines, and keep the `undefined` case, which is the part worth copying.
726
+
727
+ **You will be told.** The game's own screens (the inventory, the knowledge
728
+ browser, the Mods screen you would use to turn this mod off) repaint the
729
+ terminal *without producing a world frame*, because a screen redraws from its own
730
+ key loop. So when the stack changes with nothing behind it, the host presents
731
+ your **last** frame again with `stack` updated. The cells will be the ones you
732
+ already drew; that is deliberate. Re-projecting the world from a shell that is
733
+ not in a repaint would be inventing a frame, and nothing has run that could have
734
+ changed the dungeon. The stack is the part that changed, and it is the part to
735
+ read.
736
+
737
+ The notification fires when the composite **changes**, not every time it is
738
+ recomputed: a listener on every recompose would double every repaint for news
739
+ that had not changed.
740
+
741
+ **There are THREE answers here, not two, and the third is the one worth writing
742
+ down.** An empty stack, or one whose entries do not overlap you, means nothing is
743
+ over you. A **missing** `stack` means this host publishes none, so nothing is
744
+ known, so draw, because `place()` already declines when there is no pixel
745
+ geometry to draw into. But a stack that **is** published and does **not contain
746
+ `"map"`** is a host that has stopped describing the map, and that is COVERED, not
747
+ clear. Collapsing that case into "nothing is over me" is how a mod canvas ends up
748
+ cheerfully painting over whatever replaced the map, for ever, with no error
749
+ anywhere.
750
+
751
+ A HUD region owner reads the same field on `HudFrame`, asking about its own
752
+ section's `region.name`.
753
+
754
+ **What this does not yet reach:** a mod *presenter* holding a screen does not
755
+ push a region, so the check above answers "nothing is over me" while a
756
+ presenter-owned screen is up. That is incompleteness, not wrongness: the
757
+ notification is correct for every region that *is* pushed, and today the
758
+ text-screen path is what pushes them.
759
+
760
+ ## The HUD, region by region
761
+
762
+ `hud(ctx)` is the companion to `frontend(ctx)`. `frontend` is the dungeon; `hud`
763
+ is everything around it: the message line, the vitals, the status line, and
764
+ unlike the map, **it is owned one region at a time**:
765
+
766
+ ```js
767
+ export default {
768
+ api: 1,
769
+ hud(ctx) {
770
+ const canvas = makeMyPanel(globalThis.document);
771
+ return {
772
+ sidebar: {
773
+ present(section, frame) {
774
+ const box = section.region?.pixels;
775
+ if (!box) return; // no geometry: draw nothing
776
+ placeOn(canvas, box);
777
+ for (const entry of section.entries) {
778
+ draw(entry.key, textOf(entry), inkFor(entry.runs[0]?.color));
779
+ }
780
+ },
781
+ },
782
+ };
783
+ },
784
+ };
785
+ ```
786
+
787
+ Return a sink for each region you are taking and omit the rest; they stay the
788
+ game's and keep being drawn. `undefined` or `{}` declines everything, which is
789
+ the right answer on a host you cannot draw on: a *throwing* factory also loses
790
+ your regions but is reported as your fault, and "there is no document here" is
791
+ not a fault.
792
+
793
+ Each region needs its own capability, or the wildcard for all three:
794
+
795
+ ```json
796
+ { "shape": "plugin", "capabilities": ["ui:sidebar.replace"] }
797
+ ```
798
+
799
+ `ui:messages.replace`, `ui:sidebar.replace`, `ui:status.replace`,
800
+ `ui:*.replace`. There is deliberately no `ui:map.replace`: the dungeon is
801
+ `display:replace`'s, and one region answering to two capabilities would be two
802
+ answers to "who draws this". The two do not cover each other in either
803
+ direction: holding the map does not let you draw the vitals, and holding the
804
+ whole interface does not let you draw the map.
805
+
806
+ **The capability is the claim.** The host picks each region's owner from the
807
+ *manifests*, before it calls anybody's `hud()`, so a mod that loses is never
808
+ constructed and cannot mount UI it will never draw into. Two consequences follow,
809
+ and both are worth knowing before you write the manifest:
810
+
811
+ - A sink for a region you did not ask for is **dropped and reported by name**.
812
+ - A region you won and then declined goes back to the **game**, not on to the
813
+ next claimant. Ask for the regions you actually draw.
814
+
815
+ **What you get on every repaint** is your own `section` plus the whole `frame`,
816
+ both frozen and structurally yours, so retaining one to animate from is safe. The
817
+ frame is the context that changes what a section *means*: `frame.targeting` says
818
+ the message row is a look-description rather than a message, and `frame.layout`
819
+ (`"left" | "top" | "none"`) says whether the vitals are a column, a one-line
820
+ header, or turned off. Under `"none"` there is no `sidebar` section at all and
821
+ your sink is simply not called: the player turned the furniture off, which is a
822
+ choice to respect rather than one to style.
823
+
824
+ Read `entry.key`, one of `hp`, `sp`, `ac`, `depth`, `state`: the engine's own
825
+ `side_handlers[]` / `status_handlers[]` name minus its `prt_` prefix, and
826
+ `run.color`, its `COLOUR_*` attribute, which you resolve through
827
+ `ctx.core.COLOUR_L_GREEN` and friends **by name**, never by the number it
828
+ currently has. `run.css` and `entry.screen` are the faithful terminal's own
829
+ projection: there for a text-mode replacement, and the thing to ignore if you are
830
+ drawing your own.
831
+
832
+ **A fault costs you one region.** If your `status` sink throws, the game resumes
833
+ drawing the status line for the rest of the session and says so by name; your
834
+ `sidebar` keeps drawing, and the player keeps their game.
835
+
836
+ **Draw bars from `entry.values`, never from the text.** An entry carries the
837
+ numbers its text was formatted from, so hit points arrive as
838
+ `{ current: 7, max: 34 }` beside `"HP 7/ 34"`. Parsing the string works right
839
+ up until somebody loads a pref file, plays in another language, or a content pack
840
+ widens a field: it is the reverse-engineering this seam exists to end.
841
+
842
+ The convention is one rule and it is worth reading once. **`current` and `max`
843
+ TOGETHER mean the field is a proportion**, and `current / max` is meaningful.
844
+ Every other key is a plain named quantity. A field with two numbers that are *not*
845
+ a ratio deliberately avoids those names: a stat publishes `use` / `cur` / `max`,
846
+ because `118` is an encoding meaning 18/100 and a bar over it would report a maxed
847
+ character as 15%. So `if (v.current !== undefined && v.max !== undefined) drawBar()
848
+ else drawText()` is safe on every field, including ones added after you shipped.
849
+
850
+ Absent always means *the game does not know*, never zero: the monster health bar
851
+ publishes nothing while it reads `[----------]`, and `sp` is absent for a class
852
+ with no mana rather than `0/0`. The full per-field key list is on `HudValues` in
853
+ the SDK.
854
+
855
+ `samples/vitals-panel/` is a complete worked example: it takes `sidebar` alone
856
+ and leaves the rest of the screen to the game.
857
+
858
+ ## `menu(ctx)`: ask the game's questions your own way
859
+
860
+ The third owner seam, and the one that is different in kind. A HUD section is
861
+ **drawn**; a menu is **asked**. So the boundary is not `present(frame)` but
862
+ `ask(question) -> answer`, and taking a question means taking its input too: a
863
+ presentation that could not accept a choice would not be a presentation of a
864
+ menu.
865
+
866
+ ```js
867
+ menu(ctx) {
868
+ return {
869
+ ask(question) {
870
+ if (question.id !== "core:game-menu") return undefined; // decline
871
+ return drawDialAndWait(question); // -> MenuAnswer
872
+ },
873
+ };
874
+ }
875
+ ```
876
+
877
+ Gated by the single `ui:menu.replace` capability (or the wildcard
878
+ `ui:*.replace`). **One grant for every menu**, not one per menu id: there are
879
+ ~50 of them, and 50 capability strings would be a consent list nobody could read.
880
+
881
+ **Declining is the normal case, not a failure path.** Your presenter is offered
882
+ every menu the game asks, and returns `undefined` from `ask` for the ones you
883
+ have no better way to present; the game then asks those its own way. A radial
884
+ dial for six command verbs genuinely has no opinion about the mod manager's
885
+ thirty-row list. Declining costs nothing: you drew nothing, and there is no
886
+ surface left half-owned.
887
+
888
+ **Answer by the choice's stable `id`, never by an index.** An index is a fact
889
+ about a layout, and if you have grouped the choices into the wedges of a dial you
890
+ have no index the game would recognise. Read `choice.semantic` (`{kind, ref}`) for
891
+ what a choice *means*, independent of its wording, and `question.id` to recognise
892
+ which question you are being asked.
893
+
894
+ The answers are `choose`, `cancel`, `command` and `options`. **`command` runs one
895
+ of `question.commands`**, the caller's own handler, exactly as the key would,
896
+ and the question is then asked *again* unless that handler resolved it. That is
897
+ how a reimagined store can offer "buy" without knowing what buying does. You
898
+ cannot invent those keys; they belong to whoever opened the menu.
899
+
900
+ **Throwing costs you the seam for the session, on every menu**, unlike `hud`,
901
+ where a fault costs one region. A presenter that throws on one question generally
902
+ throws on all of them, and one report beats a report every time the player opens
903
+ anything. Answers that cannot be honoured (an unknown choice id, a choice on a
904
+ browse-only question, a command key that was never offered) cost you *that menu
905
+ only*, and are reported.
906
+
907
+ **A menu still has no published region of its own**, though overlapping, ordered,
908
+ mod-created regions have since landed; see [`regions(ctx)`](#regionsctx-put-furniture-of-your-own-on-the-screen).
909
+ `regions.ts` names the four parts of the screen that tile it, and a floating menu
910
+ is by definition one that overlaps. `question.style` tells you whether the game
911
+ would have cleared the screen (`"screen"`) or drawn a box over a still-visible
912
+ map (`"overlay"`).
913
+
914
+ `samples/command-dial/` is a complete worked example: it takes the game menu and
915
+ declines every other question in the game.
916
+
917
+ ## `screen(ctx)`: show the game's full screens your own way
918
+
919
+ The fourth owner seam, and the one that reaches the **content** rather than the
920
+ frame. Before it, the inventory arrived as `ScreenLine[]`, a row of characters
921
+ and a colour, so a mod wanting to draw items as sprite cards would have had to
922
+ parse `"a) a Potion of Cure Light Wounds 4.0 lb"` back into a name and a
923
+ weight, and would break the day a pref file changed a colour or a translation
924
+ changed a width. A screen now arrives as a **document of blocks**.
925
+
926
+ ```js
927
+ screen(ctx) {
928
+ return {
929
+ show(view) {
930
+ if (view.id !== "core:inventory") return undefined; // decline
931
+ const table = view.blocks.find((b) => b.kind === "table");
932
+ drawCards(table.rows); // row.cells.name.text, row.semantic, ...
933
+ return { dismissed: whenThePlayerCloses() };
934
+ },
935
+ };
936
+ }
937
+ ```
938
+
939
+ Gated by the single `ui:screen.replace` capability (or the wildcard
940
+ `ui:*.replace`), on the same bargain as `menu`: **one grant for every screen**,
941
+ and the fine choice made per screen by declining.
942
+
943
+ **A list is a `table`, and cells are addressed by column key.** Columns have
944
+ stable keys: `name`, `slot`, `weight`, `turn`, so you read `row.cells.name.text`
945
+ and never count characters. A column publishes three facts about the *terminal's*
946
+ layout, all of which you are free to ignore: `width` (the field width where
947
+ upstream fixed one), `gap` (columns of space before it, as the history screen writes
948
+ `"%10ld%7d' %s"`, no gap before the depth and two before the note) and `pad`
949
+ (false where the game does **not** line the column up, as the object list's
950
+ location simply follows the name). They are published *beside* the data rather
951
+ than baked into it as padding, which is what makes both renderings possible from
952
+ one model.
953
+
954
+ **A row means something.** `row.semantic` is the same `{kind, ref}` a `MenuChoice`
955
+ carries, so an item is one thing to you whether the game is listing it or asking
956
+ you to pick one: an inventory row and its picker choice share an id. An empty
957
+ equipment slot is `{kind: "slot"}` rather than an item. `row.color` is the
958
+ object's own attr as CSS, and `row.tag` the letter the terminal would offer.
959
+
960
+ **Numbers come with the text.** `cell.values` follows the HUD's convention
961
+ exactly: `current` + `max` *together* mean a proportion; every other key is a
962
+ named quantity; absent means the game does not know it. A weight cell publishes
963
+ `{each, total, number}` in tenths of a pound, so you format it your own way.
964
+
965
+ **Check `row.values` as well as `cell.values`.** The model is allowed to carry
966
+ *more* than the rendering, never less, so a number the terminal has no column for
967
+ lives on the row: the quiver publishes its weight that way, the object list its
968
+ offset as `{dy, dx}` rather than only as `"2 N 0 W"`, the player history the
969
+ character level it never prints. Those are exactly the numbers a presenter needs
970
+ and a text screen cannot show.
971
+
972
+ **Prose arrives UNWRAPPED, in a `text` block.** `paragraphs` is a run stream per
973
+ paragraph, split where the game meant a break and nowhere else, so the object
974
+ recall, the object comparison and the monster recall hand you the text and let
975
+ *you* choose the width. That is the difference the block exists for: a `lines`
976
+ block has already been broken into terminal-width rows, and re-flowing those to a
977
+ panel of your own size means undoing the game's wrap first and guessing which
978
+ breaks were the game's and which were the sentence's. `block.color` is the
979
+ prose's default, for the parts no run speaks for.
980
+
981
+ `block.flow` names **which** of Angband's two wraps laid the prose out, for a
982
+ presenter that wants to reproduce the terminal rather than re-flow. Absent means
983
+ `textblock_calculate_lines`, which is every page but one; `"text-out"` means
984
+ `text_out_to_screen`, which is the character sheet's history and nothing else.
985
+ They differ by two columns and by whether a sentence's second space survives a
986
+ break, so a renderer that assumed one rule for both was wrong on one of them.
987
+ Most presenters can ignore this entirely: it matters only if you are wrapping
988
+ *as the terminal would* rather than at a width of your own.
989
+
990
+ **Art and the writing on it are separate.** An `art` block's `lines` are the
991
+ picture, the tombstone or the winner's crown, and its `fields` are the text the
992
+ game writes *onto* the picture. Upstream's tombstone is one drawing with the
993
+ character burned into columns 8-39 of it, so a presenter handed only the drawing
994
+ would have to know that to get the name back. Instead each field carries a stable
995
+ `key` (`name`, `title`, `class`, `level`, `exp`, `gold`, `death`, `killer`,
996
+ `date`), its `text`, and `values` where the text is a formatted number. The
997
+ `row`/`x1`/`x2` beside them are where the *faithful terminal* puts each one;
998
+ ignore them and draw a real gravestone. A field with no band is centred on the
999
+ full width, which is what upstream does for the winner's banner.
1000
+
1001
+ **A column can carry a picture, and a table can space itself.** The character
1002
+ sheet's flag grid has one column per equipment slot, and upstream draws the *worn
1003
+ item's* glyph over each. That is a fact about the column (what is in this slot),
1004
+ so it arrives as `column.glyph` rather than as a first row you would have to know
1005
+ to skip; draw the item's icon there. Two more layout facts published beside the
1006
+ data rather than baked into it: `headerColor` is the header row's colour where the
1007
+ game colours it, and `gapAfter` is the blank rows the faithful terminal leaves
1008
+ under a table. A `text` block's `wrap` is the same idea for prose: the width
1009
+ *upstream* wraps at (72 for the character history on an 80-column screen), always
1010
+ a clamp and never a minimum. Ignore all four if you lay things out yourself.
1011
+
1012
+ **Not every screen has a model yet.** `MODELLED_SCREENS`
1013
+ (`packages/web/src/screen-view.ts`) names the ones that do, today thirty-nine: the
1014
+ inventory, the equipment, the quiver, the object list, the monster list, the
1015
+ message history, the
1016
+ player history, the object recall, the object comparison, the monster recall, the
1017
+ tombstone, the winner, the character sheet's two pages (`core:character` and
1018
+ `core:character-flags`), the knowledge browser's seven recall pages
1019
+ (`core:rune-recall`, `core:feature-recall`, `core:trap-recall`,
1020
+ `core:shape-recall`, `core:artifact-recall`, `core:ego-recall`,
1021
+ `core:object-kind-recall`), the four help pages (`core:help-commands`,
1022
+ `core:help-symbols`, `core:help-guide`, `core:help-community`), the
1023
+ equipment-comparison screen's two help overlays (`core:equip-cmp-help`,
1024
+ `core:equip-cmp-select-help`), the mod manager's four listings
1025
+ (`core:mod-updates`, `core:mod-auto-sort`, `core:mod-capabilities`,
1026
+ `core:mod-conflicts`), the hall of fame (`core:hall-of-fame`), the knowledge
1027
+ menu's store view (`core:store-knowledge`), the update and report pages
1028
+ (`core:update`, `core:report`), and wizard mode's two debug readouts
1029
+ (`core:wizard-keylog`, `core:wizard-item`). Everything else arrives under the
1030
+ shared id `core:text` with a single `lines` block of pre-wrapped rows: enough to
1031
+ reskin a frame, not enough to reimagine a listing. **Check `view.id`.**
1032
+
1033
+ What is left under `core:text` is now mostly there on purpose. Every
1034
+ `showTextScreen` call site in the mod manager was read one at a time in August
1035
+ 2026 and twenty-four of the thirty-two were ruled **prose**: warnings, outcome
1036
+ reports, error explanations and a mod author's own description, which are
1037
+ sentences a human reads and which a presenter gains nothing by addressing field
1038
+ by field. The screens that are still genuinely unfinished are named in
1039
+ `MOD_REACH.md` gap 21 rather than left for you to discover: the spell lists,
1040
+ which belong to the menu seam; and the install-refusal screens, which are no
1041
+ longer blocked by the model but are not yet wired to it (see the next section).
1042
+
1043
+ ### Regions are stacked, and a screen is one of them
1044
+
1045
+ `ui-stack.ts` holds the live stack. `pushRegion` adds a region, the returned
1046
+ handle's `release()` removes it, `relayoutStack` re-places every region when the
1047
+ terminal changes shape, and `paintRegionStack` draws the ones that want a
1048
+ painter, bottom band to top, each through a surface clipped to its own
1049
+ rectangle.
1050
+
1051
+ `place(grid)` is called on **every** layout change, so its contract is narrow:
1052
+ **return a rectangle and do no work.** Do not paint in it, do not read the game
1053
+ in it, and do not throw from it: a resize can arrive between any two
1054
+ keystrokes. It runs inside a try/catch so one author's mistake cannot take down
1055
+ the relayout for every other region, and a region whose `place()` throws or
1056
+ whose rectangle runs off the grid is recorded in `regionStackFaults()` rather
1057
+ than silently omitted. A silently omitted region is a window that is simply not
1058
+ there, with nothing to search for.
1059
+
1060
+ `paint(surface)` is optional, and its absence is the normal case for core: a
1061
+ screen that owns the keyboard repaints itself when a key arrives. Give your
1062
+ region a painter when you want it redrawn every frame: a HUD window over a
1063
+ live map.
1064
+
1065
+ A core screen occupies `core:screen` on the `modal` band and its rectangle is
1066
+ the whole terminal. **That is not a placeholder and it will not shrink.** A mod
1067
+ that wants a panel declares its own region rather than asking core to make room;
1068
+ shrinking core's screens would move pictures that upstream-cited parity tests
1069
+ pin byte for byte, for the benefit of no mod. To find out whether anything is
1070
+ over the map before you draw on it, read `frame.stack`; see
1071
+ [Knowing when you are covered](#knowing-when-you-are-covered-framestack). Core's
1072
+ own version of the question is `occludersOf` (`packages/web/src/regions.ts`),
1073
+ which is host-internal and returns `undefined`, not `[]`, when you name a region
1074
+ that is not in the stack, so a typo reads as a question you cannot answer rather
1075
+ than as good news. Your own copy should keep that distinction.
1076
+
1077
+ ### Standing aside for the game's own prompt
1078
+
1079
+ A screen's `actions` are the game's own commands, and some of them ask the player
1080
+ a question on the faithful terminal underneath you. The character sheet's `c`
1081
+ (rename) opens a name prompt; its `f` (dump to file) asks for a filename. Your
1082
+ overlay is on top of that terminal. If you keep drawing, the player is answering
1083
+ a question they cannot see, and the rename reaches `persistSave()`, so **two
1084
+ keystrokes, `c` then Enter, wrote the save with nothing visible on screen at
1085
+ all.** Escape was the only key that got out without writing it.
1086
+
1087
+ The fix is *not* a rule against prompting inside `ScreenHost.invoke`. That would
1088
+ make the actions a mod can offer a strict subset of the game's, which is the
1089
+ opposite of what this seam is for. Instead **the game announces the prompt before
1090
+ it lands**: a presenter that can stand aside is told what is coming, awaited while
1091
+ it animates out, and given its screen back afterwards.
1092
+
1093
+ show(view, host) {
1094
+ return {
1095
+ dismissed,
1096
+ yieldTerminal(request) {
1097
+ // request: a PromptRequest while the game needs the terminal,
1098
+ // null when you can take it back.
1099
+ canvas.style.display = request === null ? "block" : "none";
1100
+ },
1101
+ };
1102
+ }
1103
+
1104
+ The request says what is being asked (`label`), which of your `actions` led there
1105
+ (`action`), a stable identity you can match on without parsing prose (`id`, e.g.
1106
+ `"charsheet:rename"`), how much of the terminal it needs (`extent`: `"line"` for a
1107
+ row-0 prompt, `"screen"` for one that clears the grid) and the rectangle it will
1108
+ land in (`clip`). A `"line"` prompt only needs row 0, so you may keep drawing
1109
+ everything below it.
1110
+
1111
+ **Whatever you return is awaited**, so a fade-out is legitimate and the prompt
1112
+ will not land until it has finished. There is no timeout.
1113
+
1114
+ `yieldTerminal` is optional, and omitting it is not an error, but it is reported
1115
+ once, by name, with the member to add spelled out in the sentence, and the game
1116
+ draws its prompt over your screen anyway. It never refuses to run the command:
1117
+ your actions are not a smaller set than the game's.
1118
+
1119
+ **It is on the published type**, in both copies: `ScreenShown` in the host's
1120
+ `packages/web/src/screen-view.ts` and in the SDK's
1121
+ `packages/mod-sdk/src/screen.ts`, together with the vocabulary of the
1122
+ announcement, `PromptRequest` and `PromptExtent`. For TypeScript, take all three
1123
+ type-only from the SDK, which the build erases like any other type import:
1124
+
1125
+ ```ts
1126
+ import type {
1127
+ PromptRequest,
1128
+ ScreenPresenter,
1129
+ ScreenShown,
1130
+ } from "@rpgm-tools/neo-angband-mod-sdk";
1131
+ ```
1132
+
1133
+ It was not always, and what that cost is worth knowing because it is invisible:
1134
+ until 2026-08-14 the member was declared only on a host-local `YieldingScreen`
1135
+ and the mechanism worked anyway. Not publishing a member does not stop a mod
1136
+ implementing it: `tsc` accepts `show: () => ({ dismissed, yieldTerminal })`
1137
+ against a `ScreenShown | undefined` return with no cast and no excess-property
1138
+ error. What it stops is *learning that the member exists*, and *being told when
1139
+ you get it wrong*: `yieldTerminal(request: string)` compiled and was handed a
1140
+ `PromptRequest` at runtime.
1141
+
1142
+ `packages/mod-sdk/src/screen-abi-agreement.test.ts` holds the two copies in
1143
+ agreement: the member list, `yieldTerminal`'s signature character for character,
1144
+ the sentence above, and `PromptRequest`'s own field list and types. It reads both
1145
+ **files**; importing both types would prove nothing, because two structurally
1146
+ identical interfaces are one type to the compiler, which is the same blindness
1147
+ that let the member ship unpublished.
1148
+
1149
+ **Every action in the `SCREEN_PROMPTS` census is announced.** `charsheet.ts`
1150
+ covers `core:character` and `core:character-flags` (`rename`, `file`); `main.ts`
1151
+ covers `core:report`'s `describe` and `core:update`'s `mods`. The last of those is
1152
+ the interesting one: `mods` opens a whole nested page (`showModUpgrades`) whose
1153
+ own screens come back round to the presenter that is *already* holding
1154
+ `core:update`. While you are stood aside you are simply not offered them; the
1155
+ game shows those itself, because re-offering would ask you to draw over the very
1156
+ terminal you just cleared.
1157
+
1158
+ ### A row with a paragraph
1159
+
1160
+ `ScreenRow.detail` is prose attached to one row of a table. It is a
1161
+ `ScreenProse`, the same `{ paragraphs, indent?, wrap?, flow?, color? }` a
1162
+ `text` block is made of, so a presenter that can already draw a prose block can
1163
+ draw a detail, and one that only wants the record can ignore it.
1164
+
1165
+ The rule for telling the two apart has not changed, and it is why `detail` is
1166
+ shaped this way: **structure is what has keys; prose is what has paragraphs.**
1167
+ Anything you need to reach by name is a cell, addressed by its column key. A
1168
+ detail has no key and is not addressable, and that is deliberate: if you find
1169
+ yourself parsing a detail, the thing you are parsing is a column the screen has
1170
+ not declared yet, and that is a bug to report rather than a string to split. The
1171
+ ids behind a dependency cycle like `A -> B -> A` are on `semantic.data`, where
1172
+ `autoSortScreen` already puts them.
1173
+
1174
+ A detail never affects layout beyond its own row. It is not consulted when
1175
+ column widths are computed, so a long paragraph cannot widen a column or move
1176
+ the row above it, and a row without a detail is laid out identically whether or
1177
+ not its neighbours have one. It also introduces no third wrapping rule: it is
1178
+ laid out by the same function a `text` block is, and says which of Angband's two
1179
+ algorithms it wants through the same `flow` field.
1180
+
1181
+ This was added because three screens (the install refusal, a dropped auto-sort
1182
+ suggestion, a declared-conflict claim) are each **a record with a paragraph
1183
+ attached**, and had been stuck at `lines` because there was nowhere to put the
1184
+ paragraph. Cutting it into row fragments would have made them `lines` wearing a
1185
+ costume: a presenter would still have had to know that some rows continue
1186
+ others.
1187
+
1188
+ The seven recall pages are all `text` blocks, and they are seven ids rather than
1189
+ one on purpose: a mod that draws an artifact's page as a plaque and a trap's as a
1190
+ warning card has to be able to tell them apart, and a shared id cannot. If you
1191
+ only want to restyle prose, match on all seven (or on `block.kind === "text"`)
1192
+ and you are done: nothing in a prose panel needs to know which of them it has.
1193
+
1194
+ **A screen is dismissed, not answered**, which is the one shape difference from
1195
+ `menu`. `show` declines by returning `undefined` **synchronously** and takes the
1196
+ screen by returning `{ dismissed }`, a promise you resolve when the player closes
1197
+ it. There is no answer value left to decline with once the promise means "they
1198
+ closed it", and deciding never needs to be async anyway, since you match on `view.id`.
1199
+ Resolving `dismissed` is the whole contract: a presenter that forgets is a game
1200
+ the player cannot get back to.
1201
+
1202
+ **Some screens can be acted on, and those hand you a way back in.** Most screens
1203
+ are only dismissed. The character sheet is not: upstream offers renaming, a
1204
+ character dump and the page cycle from the same modal, and a presenter that took
1205
+ the sheet without being able to reach them would quietly take those commands away
1206
+ from the player. The visible-monster list is the other one: a single action,
1207
+ `sort-exp` (`x`), which flips the sort between depth and experience. So `view.actions` publishes them as data: a stable `id`
1208
+ (`rename`, `file`, `page-next`, `page-prev`), the `key` the *faithful terminal*
1209
+ listens for, and the game's own `label`, and `show(view, host)` hands you a
1210
+ `ScreenHost` whose `invoke(id)` runs one.
1211
+
1212
+ ```js
1213
+ show(view, host) {
1214
+ if (view.id !== "core:character") return undefined;
1215
+ let shown = view;
1216
+ const onKey = (ev) => {
1217
+ const action = shown.actions && shown.actions.find((a) => a.key === ev.key);
1218
+ if (!action) return;
1219
+ host.invoke(action.id).then((next) => {
1220
+ if (!next) return close(); // the game has taken the screen back
1221
+ shown = next; // the same sheet renamed, or the other page
1222
+ paint(shown);
1223
+ });
1224
+ };
1225
+ ...
1226
+ }
1227
+ ```
1228
+
1229
+ `invoke` runs the **game's** code: a rename still opens the game's prompt, a dump
1230
+ still writes the game's file, and resolves with what the player should be looking
1231
+ at next: usually the same screen with new content, or the next page. `undefined`
1232
+ means the game has taken the screen back; resolve `dismissed` when you see it. An
1233
+ id this engine has not got is a no-op that hands the current view back, so asking
1234
+ for a newer command can never close the player's screen. `host` arrives only where
1235
+ `actions` does, and it is a second parameter rather than a field of the view
1236
+ because a view is frozen data and a way back into the game cannot be.
1237
+
1238
+ **Throwing costs you the seam for the session**, as with `menu`. If you throw
1239
+ while a screen is *open*, or reject `dismissed`, the game reports you by name and
1240
+ **shows the screen itself**: a player left staring at a dead overlay has no way
1241
+ out. That recovery exists so a bug is not a lost character, not as a place to be
1242
+ relaxed.
1243
+
1244
+ **A screen has no published region either.** It covers the window, for the same
1245
+ reason a floating menu does.
1246
+
1247
+ `samples/sprite-inventory/` is a complete worked example: it draws the inventory,
1248
+ the equipment and the quiver as item cards, lays the recall pages out into a
1249
+ panel of its own width by measuring them, and declines every other screen.
1250
+
1251
+ ## `regions(ctx)`: put furniture of your own on the screen
1252
+
1253
+ The fifth owner seam, and **the only one nobody wins.**
1254
+
1255
+ The other four each answer *who gets it*, because the map, a HUD region, the menu
1256
+ seam and the screen seam are each one thing and two mods cannot both have it. A
1257
+ region is not one thing. Two mods that both declare a region are not in
1258
+ contention at all: they are two pieces of furniture, and they **coexist**, each
1259
+ at its own band, in load order. "Last load wins" appears here only in its
1260
+ ordinary form: within a band, the later-loaded region draws on top.
1261
+
1262
+ ```js
1263
+ export default {
1264
+ api: 1,
1265
+ regions(ctx) {
1266
+ return [{
1267
+ id: "carried",
1268
+ layer: "overlay",
1269
+ place: (grid) => ({ x: grid.cols - 18, y: 1, w: 17, h: 1 }),
1270
+ paint: (surface) => surface.put(0, 0, `Carried ${weight()} lb`),
1271
+ }];
1272
+ },
1273
+ };
1274
+ ```
1275
+
1276
+ Requires **`ui:region.create`**. Note that `ui:*.replace` does **not** grant it:
1277
+ the wildcard ranges over which of the *game's* regions changes hands, and adding
1278
+ one of your own is a different sentence for the player to agree to. Declaring
1279
+ `regions()` without the capability is reported by name with the fix in the
1280
+ sentence, rather than silently drawing nothing.
1281
+
1282
+ **Your id is namespaced.** Declare `"carried"` and the live stack carries
1283
+ `my-mod:carried`. That is a correctness rule rather than tidiness: a mod naming
1284
+ its region `map` would put a second `map` in the stack, and `occludersOf` answers
1285
+ about the **first** match, so a front end's one question would quietly start
1286
+ being answered about somebody else's rectangle.
1287
+
1288
+ **The unit of failure is the declaration, not the mod.** A rectangle with no
1289
+ `paint`, a band that does not exist, a duplicate name, a `paint` that throws on
1290
+ its first frame, and each costs exactly that one region, is reported once, and
1291
+ leaves your others and every other mod's alone.
1292
+
1293
+ **A faulting region is withdrawn, not left empty.** This is the one place the
1294
+ mechanical answer is wrong: `ui-stack.ts` leaves a faulted core screen in the
1295
+ composite, which is right for something that still owns the keyboard. Your
1296
+ decorative panel has no such claim: left in the stack it is a phantom
1297
+ **occluder**, and a replacement front end asking `occludersOf(stack, "map")`
1298
+ would stand its canvas down for a region that has drawn nothing since the first
1299
+ frame. So the handle is released and the region vanishes *with* a message rather
1300
+ than persisting without one.
1301
+
1302
+ **`place(grid)` must be cheap, total and pure.** It runs for every open region on
1303
+ every frame and on every resize. No game reads, no painting, no allocation you
1304
+ can avoid. Return the rectangle for a terminal of that size; one that runs off
1305
+ the grid is recorded in `regionStackFaults()` rather than drawn.
1306
+
1307
+ **The `system` layer is reserved to the game** and asking for it is refused with
1308
+ its own sentence rather than a generic bad-band one: it is a real band, it is
1309
+ the top one, and the reason you may not have it is a reason rather than a typo.
1310
+ The mod manager and a fault report have to be drawable *above* a mod, including
1311
+ above one that has gone wrong. Use `"overlay"` for furniture, or `"modal"` for
1312
+ something that takes the player's attention.
1313
+
1314
+ ## Capabilities
1315
+
1316
+ The `GridSurface` rendering contract is host infrastructure, not a registry
1317
+ capability. `frontend` is a direct `ModPlugin` member because it selects one
1318
+ display owner rather than registering an independent game behaviour - and it is
1319
+ gated by `display:replace`, its own capability kind, for the same reason. `hud`
1320
+ is the same shape one level finer: a direct member because it selects an owner,
1321
+ and gated by `ui:<region>.replace` - per region, because a mod drawing hit points
1322
+ as a bar has no business taking the message log with it, and because a player
1323
+ consenting deserves to be told which part of their screen is changing hands.
1324
+ `menu` is the third, gated by `ui:menu.replace` - and deliberately NOT per menu
1325
+ id, because the unit a player can weigh is "the game's menus", not fifty
1326
+ individual screens. All three `ui:*` grants and `display:replace` are separate
1327
+ kinds in both directions: a mod holding the dungeon cannot draw the vitals, and a
1328
+ mod holding every menu cannot draw the dungeon.
1329
+
1330
+ `ui:` has three ACTIONS, not one, and the action is compared as well as the
1331
+ region. `replace` hands over something the game already draws. `region.create`
1332
+ adds a rectangle of the game's own character grid beside it. `panel.mount` puts
1333
+ real HTML on the page above the game, reached through `ctx.ui` rather than
1334
+ through a `ModPlugin` member, because a panel is opened when the player asks for
1335
+ one rather than declared once at load. `ui:*.replace` covers none of the other
1336
+ two: the wildcard ranges over WHICH of the game's regions changes hands, and
1337
+ adding furniture or mounting a web page are different sentences for a player to
1338
+ agree to.
1339
+
1340
+ `mod:` has two ACTIONS, and neither grant carries the other. `mod:install` puts a
1341
+ content pack in the player's library **switched off**, and that waiting is exactly
1342
+ why one sentence on a consent list is a proportionate price for it: the player
1343
+ still meets the arriving mod on the Mods screen and still reads its own list.
1344
+ `mod:session` loads one for the rest of the session, on from the next reload,
1345
+ without any of that - so it is MORE, not less, and it cannot be sold under the
1346
+ install line. `grantCovers` compares the action, which is the `ui:` lesson (#261)
1347
+ applied before it could be re-learned here. Both refuse code and both refuse an
1348
+ archive whose manifest asks for a capability, so neither is a route to running
1349
+ something a mod wrote.
1350
+
1351
+ **"Only for this session" is a lifetime, not a privilege**, and the docs will not
1352
+ imply otherwise. A session-loaded pack composes into the game on the same terms as
1353
+ an installed one. What is short-lived is the archive: what the records did to a
1354
+ character, and anything a mod wrote while they were loaded, outlives the session
1355
+ exactly as it would have if the mod had been installed. The one thing the session
1356
+ tier genuinely buys a player is that it cannot accumulate: the mod is listed and
1357
+ marked, it can be dropped, and closing the game forgets it.
1358
+
1359
+ And the lifetime is a strong convention rather than an enforced boundary. The
1360
+ archive lives in `sessionStorage`, which survives a reload - that is what makes
1361
+ the tier work at all, since a reload is what applies a mod - and which a browser
1362
+ restores when it restores a closed or crashed window, and which a window the page
1363
+ itself opens inherits a copy of. So "gone when you close the game" is what
1364
+ normally happens rather than a guarantee, and the mitigation is visibility: a
1365
+ session mod is always on the list, always marked, and always droppable, so it can
1366
+ never be quietly resident.
1367
+
1368
+ What `ui:panel.mount` is, and is not, stated here because the difference is the
1369
+ whole of it. It is not a fence around the DOM: your `plugin.js` runs in the
1370
+ page's own realm, so `document` is ambient to it with or without any capability,
1371
+ and a mod that never declares this can still append an element to the body. What
1372
+ the grant carries is a sentence the player reads first, a container the host owns
1373
+ and can take away, and one thing a mod genuinely cannot do without it - stand the
1374
+ game's input door down, so a real `<input>` inside your panel can be typed into
1375
+ instead of the keystrokes being read as game commands.
1376
+
1377
+ The live `WorldFrame` in
1378
+ `packages/web/src/world-view.ts`: `render()` invokes the extracted
1379
+ `world-render-data.ts` with the actual map-knowledge reads and passes its
1380
+ frame to a `WorldFrameSink`; the default glyph terminal
1381
+ is that sink and consumes its fallback visual
1382
+ projection, including the terrain-under-foreground tile inputs for a path over
1383
+ otherwise bare seen terrain. The frame
1384
+ carries semantic feature/trap/object/monster ids,
1385
+ visibility, ordered layers, cursor, and player placement, so a selected front
1386
+ end can make an isometric or 3D view without decoding terminal glyphs.
1387
+ The Phase-4 control executes the same producer used by `render()`, checks its
1388
+ unmodded pre-frame glyph tuples, and tees that exact frame to an independent
1389
+ host sink in the same call.
1390
+ The selected frontend receives a frozen copy of that frame; the unselected and
1391
+ absent paths leave the exact glyph sink active.
1392
+
1393
+ The same is true of `UiInput` in `packages/web/src/input-door.ts`. It is the
1394
+ single device-neutral route by which keyboard and keymap input reaches screens;
1395
+ its direction carries an analog vector and angle. It does not grant a plugin a
1396
+ binding registry in this phase. Stored player keymaps are evaluated first when
1397
+ the root owns input; score pages, modals, and run interruption retain their
1398
+ existing literal-key gates, so a mod must not use injected input to outrank the
1399
+ player's chosen mapping or an active screen.
1400
+
1401
+ `register` reaches eighteen registries, each gated by a capability your manifest
1402
+ must declare **and** the player must consent to. `REGISTRY_CAPABILITIES`
1403
+ (`packages/core/src/mod/registry-host.ts`) is the one place the vocabulary is
1404
+ written down, and the table below is a reading of it:
1405
+
1406
+ | Capability | What it opens |
1407
+ |---|---|
1408
+ | `registry:effect` | add a new effect code, or replace a core one: combat, healing, teleport, detection |
1409
+ | `registry:room` | room and level builders, referenced from a dungeon profile |
1410
+ | `registry:profile` | whole-cave builders and dungeon profiles: a new *kind* of level, and which kind you get at a depth |
1411
+ | `registry:blow` | what a monster's attacks do to you, and new kinds of attack: `define()` takes one description and the engine derives both of the handlers it needs |
1412
+ | `registry:store` | what a shop will buy, and how many of a thing it stocks |
1413
+ | `registry:command` | what a player command *does*, and what it is CALLED: `commands.register(code, action)` for the behaviour, `commands.setVerb(code, verb)` for the verb the `!`-inscription confirm reads. Skip the verb and a player who has inscribed `!z` on a Potion of Death is asked "Really **do that with** your Potion of Death?" instead of your command's own name; `commands.verbFor(code)` returns what is installed, so a later mod can wrap an earlier one's |
1414
+ | `registry:monster` | a hook at the top of every monster's turn; return true to take the turn over |
1415
+ | `registry:projection` | what a projection does to terrain, floor items and the player: `projections.feat` / `.obj` / `.player`, one projection `code` at a time. This is the behaviour half of adding your own element: the `projection.json` record makes it exist, these three make it *do* something |
1416
+ | `registry:ui-entry` | what a `combine:` or an `entry-renderer:` `code:` *means* on the second character screen and the equipment comparison: `uiEntry.combiners.set("my-mod:worst-of", ...)` (how a row's per-slot values reduce to the one that colours its label) and `uiEntry.backends.set("my-mod:bars", ...)` (how a value becomes a cell symbol and colour). Adding a `ui_entry.json` ROW needs no capability; a row naming a combiner or renderer nothing answers for draws as an empty row rather than failing, so this is what makes your row mean something |
1417
+ | `registry:glyph` | what one character of a room-template or vault layout means when the level is drawn: `glyphs.set("vault", "Q", ...)`. The behaviour half of shipping a vault with a symbol core has never seen |
1418
+ | `registry:effect-info` | what the game *says* about an effect: `effectInfo.text` (the menu row and the recall sentence), `.summary` (the object properties an activation grants), `.subtype` (the named subtypes it accepts) and `.request` (which item it prompts for). This is the description half of `registry:effect`: without it your new effect works and the game has nothing to say about it |
1419
+ | `registry:tval` | what an item CLASS *is*: `tval.classes` (keyed on the predicate's own name, so `handlerFor("tvalIsWeapon")` returns core's arm and a mod ORs its own tval into it), `.good` (whether a template counts as good for allocation) and `.valueBase` (what an unidentified item of the class is worth) and `.basename` (what the class is CALLED - without it every message, menu row and shop line naming the class reads the literal "(nothing)"). Shipping a new *item* needs no capability; this is the class |
1420
+ | `registry:randart` | how RANDOM artifacts are built: `randart.abilities` (what a power does), `.prep` (what an item class starts with), `.census` (which frequency bucket it feeds) and `.redundancy` (whether an activation duplicates something the artifact already has). Shipping a *fixed* artifact needs no capability; this is the generator |
1421
+ | `registry:rune` | what a RUNE is: the unit of object knowledge. `rune.desc` (the recall line), `.name` (the display decoration), `.knows` / `.learn` (the knowledge pair, handed the player so YOUR mod keeps the store, since core never grew a slot for it), `.objectHas` (whether an item carries it) and `.modMessage` (the "You feel stronger!" line, keyed on the modifier). Plus `.contribute`, which is how your rune gets into the list every consumer enumerates, and without it the six tables above are handlers nothing ever calls |
1422
+ | `registry:vocab` | declare genuinely new vocabulary (flags, stats, mod-coined kinds) and store per-entity values |
1423
+ | `registry:message` | message TYPES: `messages.define(name, sound?)` coins one and returns its number, `.lookup(name)` finds an existing one, `.types()` lists what has been added, `.addSounds(...)` attaches sounds. Adding a `message_type.json` RECORD needs no capability, the same way adding an item does; this is the code half, for a type your own plugin raises |
1424
+ | `registry:menu` | rewrite one stable menu id's semantic rows. `menus.handlerFor(id)` returns the earlier transformer, so a later mod wraps it before calling `menus.register(id, ...)`; a throw or a non-row-array result is reported against that mod and leaves the original menu usable |
1425
+ | `registry:tiles` | supply tiles for content the loaded tile pack does not draw, which in practice means content a mod added. `tiles.register(filler)` installs one filler per mod; every registered filler runs, in load order, after the pack's own prefs and every mod's. It can only write where NOTHING is assigned, so it cannot repaint the tile set even by mistake and two mods cannot fight over an index. The same seam also grew a repaint-in-place door (`fill.transform`, mirror and/or a palette remap over an existing tile) and a player-cell door (`tiles.player`, asked once per frame what the character's own cell should show). See [Filling tiles](#filling-tiles) and [Repainting a tile, and drawing the player's own cell](#repainting-a-tile-and-drawing-the-players-own-cell) |
1426
+
1427
+ A facade you did not declare throws when you touch it, even if the player
1428
+ consented to something else. Consent says the player allowed these domains; the
1429
+ manifest says you asked for them; both must hold.
1430
+
1431
+ ### What a capability gates, and what it does not
1432
+
1433
+ The sentence above is exactly true and it is narrower than it looks. **The gate
1434
+ is on the facade, not on the registry behind it.** A `registry:*` capability is a
1435
+ DECLARATION, and what it buys is real but bounded:
1436
+
1437
+ - the player sees the list, in plain language, before consenting;
1438
+ - the conflict report and the manager row are built from it;
1439
+ - an author who forgot to declare a domain gets a clear throw with the
1440
+ capability name in it, rather than a silent surprise later.
1441
+
1442
+ What it is **not** is a fence around the registry. Your `register(host, ctx)` is
1443
+ handed three things with no capability check at all, because a mod is meant to be
1444
+ able to LOOK at everything without declaring anything:
1445
+
1446
+ | Handed to you ungated | The gated facade it is the twin of |
1447
+ |---|---|
1448
+ | `ctx.registries.rooms`, `.profiles`, `.rooms.glyphs` | `registry:room`, `registry:profile`, `registry:glyph` |
1449
+ | `ctx.state.blowEffects`, `.storeBehaviour`, `.projectionHandlers`, `.uiEntry`, `.commandVerbs`, `.monsterTurnHook` | `registry:blow`, `registry:store`, `registry:projection`, `registry:ui-entry`, `registry:command`, `registry:monster` |
1450
+ | `ctx.core.tvalRegistry()`, `.runeRegistry()`, `.randartRegistry()`, `.effectInfoRegistry()`, `.messageTypes`, `.soundPrefRegistry` | `registry:tval`, `registry:rune`, `registry:randart`, `registry:effect-info`, `registry:message` |
1451
+
1452
+ `ctx.authoring` and `ctx.composedRecords` are ungated too, and for a different
1453
+ reason worth keeping separate from the one above: they are not twins of anything.
1454
+ The barrel is pure functions over data you pass in, and the records are the
1455
+ content the player already has, in the shape it was read in. Nothing there is a
1456
+ second route to a gated door.
1457
+
1458
+ Those are the same live objects, by identity, not copies. `ctx.core` also exports
1459
+ `createModRegistryHost` itself, which grants every domain when it is called
1460
+ without a capability set, so an ungated host is one call away for anything
1461
+ holding the namespace.
1462
+
1463
+ **This is inherent, not an oversight waiting to be fixed.** A trusted plugin runs
1464
+ in-process, synchronously, holding the engine namespace, because that is the only
1465
+ way a handler can touch the live `rng`, `chunk` and `player` deep inside a turn
1466
+ (the reasoning is in `packages/core/src/mod/registry-host.ts`, under WHY
1467
+ IN-PROCESS AND TRUSTED). Nothing reachable from inside that namespace can be
1468
+ withheld from code already inside it, and a read-only view over `ctx.registries`
1469
+ would close three of the fifteen twins above while reading as though it had
1470
+ closed the class. `packages/web/src/capability-gate-reach.test.ts` measures both
1471
+ halves - the gate refusing, and the twin reaching - so this page cannot quietly
1472
+ drift into claiming containment.
1473
+
1474
+ **`registry:*` is not the only family this is true of, in the in-process tier.**
1475
+ `state:<domain>.read` gates the perceive facade's accessors per domain, and
1476
+ `ctx.state` is the whole live `GameState`. `network:<host>` gates the act
1477
+ facade's request helper, and a plugin is an ES module in the game's own page with
1478
+ the global `fetch` in scope. Same shape, same reason.
1479
+
1480
+ **Where these capabilities ARE enforcement is the sandboxed Worker tier.** That
1481
+ tier is isolated by construction: it gets the reactive perceive / act / event
1482
+ surface across a message boundary and none of `ctx.core`, `ctx.state` or
1483
+ `ctx.registries`, so there is no twin to reach and a denied domain is denied. The
1484
+ same capability string therefore means containment on one side of that boundary
1485
+ and declaration on the other, which is why this section is about the in-process
1486
+ tier specifically.
1487
+
1488
+ **The boundary that IS real is the install.** A mod is code, nothing in this
1489
+ project reviews it, and the toggle on the Mods screen is where that decision gets
1490
+ made (`packages/web/src/mod-consent.ts`). The consent screen therefore says the
1491
+ in-process line for any mod that ships code, whatever its declared list looks
1492
+ like, and the manager row on a plugin declaring nothing says so rather than
1493
+ reading as a reassurance.
1494
+
1495
+ **So declare what you override.** Not because the throw is the only thing
1496
+ stopping you, but because the declaration is what the player reads and what the
1497
+ conflict report is made of. Reaching a registry around its facade means the
1498
+ player consented to a mod that did not say what it does, and the manager will
1499
+ report a clash it has no way to see.
1500
+
1501
+ ### Overwriting and extending: yours, core's, or somebody else's
1502
+
1503
+ Every registry here is keyed, and you write **one key at a time**. That is what
1504
+ makes two mods able to touch the same system: the last one to write a key wins
1505
+ that key, and every other key either mod wrote survives. Handing over a whole
1506
+ table instead would mean the second mod loaded silently erased the first.
1507
+
1508
+ Each facade also hands back what is installed *right now*, so extending is the
1509
+ same move as replacing:
1510
+
1511
+ ```js
1512
+ register(host) {
1513
+ // Your own element, given a body.
1514
+ host.projections.player.set("frost:rime", (ctx) => {
1515
+ ctx.msg("The rime bites deeper than cold.");
1516
+ ctx.incTimed(TMD_SLOW, 5, true);
1517
+ });
1518
+
1519
+ // Core's FIRE, extended. `previous` is core's handler, or, if a mod loaded
1520
+ // before yours already replaced it, THEIRS. You do not need to know which.
1521
+ const previous = host.projections.player.handlerFor("FIRE");
1522
+ host.projections.player.set("FIRE", (ctx) => {
1523
+ previous(ctx);
1524
+ ctx.msg("Your cloak smoulders.");
1525
+ });
1526
+ }
1527
+ ```
1528
+
1529
+ `handlerFor` is on every facade in the table above: `blows.handlerFor`,
1530
+ `stores.willBuyFor`, `profiles.builder`. Reach for it before reimplementing
1531
+ anything: a wrapper survives a core change that a copy does not.
1532
+
1533
+ Menus are declared by stable ids such as `core:game-menu` and
1534
+ `core:knowledge-group`, never their localized titles. Each row has a stable
1535
+ `id` plus `semantic: { kind, ref?, data? }`: a command wheel can use a command
1536
+ row's `ref`, while an inventory grid can use an item row without reverse-parsing
1537
+ the label. A transformer receives those rows, may add/remove/reorder/relabel,
1538
+ and returns the replacement row array:
1539
+
1540
+ ```js
1541
+ register(host) {
1542
+ const previous = host.menus.handlerFor("core:game-menu");
1543
+ host.menus.register("core:game-menu", (id, rows) => [
1544
+ ...(previous ? previous(id, rows) : rows),
1545
+ { id: "my-mod:rest", label: "Rest", semantic: { kind: "command", ref: "rest" } },
1546
+ ]);
1547
+ }
1548
+ ```
1549
+
1550
+ Plugin code runs **in process, synchronously**, with the same access to the rng,
1551
+ the chunk, the player and the monster that core has, because a deep override
1552
+ cannot cross an async, isolated Worker boundary. So it is trusted code, exactly as
1553
+ it is in SKSE or Forge, and the consent prompt is the boundary. If your mod only
1554
+ needs to react to events rather than override systems, the untrusted Worker tier
1555
+ exists for that and needs no trust at all.
1556
+
1557
+ ## Testing yours
1558
+
1559
+ Point the game at your folder and read the mod manager: it lists what was found,
1560
+ and one line per pack it could not use. `Where mods come from` names the exact
1561
+ directory.
1562
+
1563
+ The loader itself is covered by `packages/web/src/mod-code.test.ts` (the gates,
1564
+ with the importer injected so an absence of execution can be asserted) and
1565
+ `mod-code.node.test.ts` (a real folder on disk, a real dynamic import, a real
1566
+ `ModHooks` coming back out). The second is the one worth copying if you want a
1567
+ harness of your own.