@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,477 @@
1
+ # What an engine release may break, and what it may not
2
+
3
+ This is the promise a mod author is owed: **which of my mods stop working when
4
+ the game updates, and what do I have to do about it?**
5
+
6
+ The short answer, and the target the rest of this document explains:
7
+
8
+ > A mod that is pure data should survive engine releases without being
9
+ > republished. A mod that ships code should survive patch and minor releases,
10
+ > and get a release's warning before an ABI change strands it.
11
+
12
+ Written down on 2026-08-02, after measuring how the four gates actually behaved.
13
+ Three of the four were stricter than they needed to be, and the fourth did not
14
+ exist.
15
+
16
+ ## The four things that can strand a mod
17
+
18
+ | # | Gate | What it judges | On failure |
19
+ |---|---|---|---|
20
+ | 1 | `engine` | a semver range over `ENGINE_VERSION` | **warns** for data, **refuses** code |
21
+ | 2 | `modApi` | the plugin ABI, an integer | refuses outside the accepted window |
22
+ | 3 | a patch target | one `patches` / `fieldPatches` / `removes` ref | **skips that op**, keeps the mod |
23
+ | 4 | `ctx.core` | any of the engine's ~1950 exports | nothing. See below. |
24
+
25
+ ### 1. `engine` is a label on data and a gate on code
26
+
27
+ `engine` says which builds the author *tested*. Until 2026-08-02 the host read
28
+ that as a demand and refused any pack outside it - so a pack of JSON went dark
29
+ because of a string in its manifest, which is ratified decision 18 ("the engine
30
+ labels, it does not forbid") applied backwards.
31
+
32
+ Now the range only **blocks** a pack that ships code, and `modApi` is the signal:
33
+ the manifest already requires it of exactly the packs with a `plugin.js`. Code is
34
+ what genuinely breaks across a release - it calls functions, and a renamed
35
+ function is a crash. A tile pack is data too, on the same reasoning: a stale
36
+ mapping loses individual tiles to the ASCII fallback, which the player can see,
37
+ and that beats a whole tileset going dark.
38
+
39
+ **What to write.** A minimum, not a caret:
40
+
41
+ ```json
42
+ "engine": ">=0.13.0"
43
+ ```
44
+
45
+ `^0.13.0` on a `0.x` version means *0.13.x only*, so it excludes 0.14.0 - which
46
+ is how an author accidentally opts into a warning on every minor release. A
47
+ two-sided range is for a mod that genuinely knows it breaks above some version.
48
+ Omitting the field entirely is a reasonable choice for a data pack, and the
49
+ core content pack does exactly that in spirit with `">=0.1.0"`.
50
+
51
+ #### A range this build fails is no longer the end of the road
52
+
53
+ Until 2026-08-21 a code pack whose newest release declared a range this build sits
54
+ outside was simply refused. The row read `will not run on this version` and stopped
55
+ there, even when the same repository still held a release that ran perfectly.
56
+ Installing that older release was possible the whole time by pasting a
57
+ `github.com/owner/repo/tree/<tag>` URL into **Add from a repository address**, so
58
+ what was missing was never the capability. Nothing looked, and nothing said.
59
+
60
+ Discovery now walks a repository's tags newest first and offers the newest release
61
+ this build will actually run. What that walk guarantees:
62
+
63
+ - **The same gate decides.** Each candidate is judged by the loader's own rule, so
64
+ a version a screen offers is a version load time accepts. There is no second
65
+ opinion about what "runnable" means: the install screen and *Update installed
66
+ mods* share the one walk, because two walks would be two chances to disagree.
67
+ - **One manifest read in the ordinary case.** The loop stops at the first candidate
68
+ it accepts, and the newest release is nearly always that candidate, so a mod that
69
+ is keeping up costs exactly what it cost before. Manifests are read from
70
+ `raw.githubusercontent.com`, which is unmetered, rather than from the API, whose
71
+ sixty requests an hour a screenful of mod rows would spend on nothing but
72
+ walking.
73
+ - **The walk is bounded at eight versions** (`MAX_VERSIONS_TRIED`). A mod whose
74
+ last eight releases all want a newer game is telling the player to update the
75
+ game, and that is what the refusal then says, along with how many releases were
76
+ tried.
77
+ - **A pinned tag is never walked past.** A player who named a version is owed that
78
+ version, so a pin that will not run is refused as a pin rather than quietly
79
+ becoming a different install than the one that was asked for.
80
+ - **A data pack out of range is never walked past either,** because gate 1 lets it
81
+ load. Its newest release is still the one offered, still carrying the line about
82
+ the range.
83
+ - **The release that was passed over is named.** A player offered 0.14.4 while the
84
+ mod's front page shows 0.15.0 would otherwise conclude the game is broken or the
85
+ listing is stale, when the real answer is that 0.15.0 wants a newer game. The
86
+ row, the detail pane and the refusal screen all say which newer release was
87
+ stepped past and why.
88
+ - **"Update the game" is said only when a newer game is what would help.** Gate 1
89
+ deliberately refuses to say which side is behind, because `>=0.24.0` wants a
90
+ newer game and `<0.5.0` wants an older one, and a confident instruction would be
91
+ wrong half the time on the one line a player acts on. A screen that has decided
92
+ to offer an older release still has to answer the question, so it is answered
93
+ separately and by probing: `newerGameCouldRun` tries the next nine patches, the
94
+ next nine minors and the next nine majors above the running version, plus one
95
+ far-future version for an open upper bound. Where none of those satisfies the
96
+ range, both versions are named and nothing is advised, which is the same
97
+ restraint the verdict itself shows.
98
+ - **An update is never offered that the loader would refuse.** *Update installed
99
+ mods* counts only releases that run here, so a mod already sitting on the newest
100
+ release this build can run reads as exactly that rather than as out of date, and
101
+ the number of mods holding a release back for a newer game is shown beside it.
102
+ - **A manifest that could not be read keeps the optimistic answer.** Withholding an
103
+ update over one failed request would be a claim about a mod made without asking,
104
+ which is the failure the update screen was rebuilt to avoid. The install path
105
+ runs the same walk with a live connection and steps back there.
106
+
107
+ **What this asks of an author.** A range stricter than the mod needs now costs a
108
+ player the mod's newest release rather than the mod itself. That is a smaller harm
109
+ and still a real one, so a two-sided range should be one that was meant. It also
110
+ means fixing a range in a new release reaches players immediately: the walk finds
111
+ the fixed release without the game having to update first.
112
+
113
+ ### 2. `modApi` accepts a window
114
+
115
+ `MOD_API_VERSION` is what this host implements; `MOD_API_MIN` is the oldest it
116
+ still accepts. Everything in between loads, and anything below the current
117
+ version is reported to its author as running on a compatibility path.
118
+
119
+ Until 2026-08-02 the check was `declared !== MOD_API_VERSION`, so the day the
120
+ number moved, **every mod in existence stopped loading at once** - before any
121
+ author could react, for a change most of them were not affected by.
122
+
123
+ **A bump now takes two releases:**
124
+
125
+ 1. Ship the new behaviour. Leave `MOD_API_MIN` where it is. Keep honouring the
126
+ old contract for plugins that declared the old number - `LoadedModPlugin.api`
127
+ carries what each one declared, which is what makes that possible. Authors
128
+ start seeing the deprecation line.
129
+ 2. Raise `MOD_API_MIN`. Delete the old path.
130
+
131
+ If a change genuinely cannot be conditioned on the declared version, both move
132
+ in one step - and that is a decision to take deliberately, which is the reason
133
+ there are two constants and a test that fails when they stop making sense.
134
+
135
+ ### 3. A missing patch target costs the patch, not the mod
136
+
137
+ A `fieldPatch` at a record that no longer exists used to throw, and the host
138
+ answers a throw by dropping the whole pack. So a mod patching forty monsters
139
+ lost all forty - plus its code, its rules and its tiles - because one of the
140
+ forty had been renamed.
141
+
142
+ It is now one reported line on that mod's row, and everything else in the pack
143
+ still applies. The line says the target may have been renamed, because that is
144
+ the likeliest cause and an author who knows they got the ref right will
145
+ otherwise go looking in the wrong place.
146
+
147
+ This is the same behaviour the *other* half of the composer has had all along:
148
+ 20 of core's 44 record files take a "passthrough" merge path that reported and
149
+ carried on, and 24 take a "composable" one that threw. Nobody chose that split;
150
+ it fell out of the shape of core's own records.
151
+
152
+ **A patch that applies cleanly can still name something that is not there,** and
153
+ until 2026-08-20 that was a *worse* outcome than a missing target: the composer
154
+ was satisfied, and the binder threw. A store's `normal` stock table is the case
155
+ that made it ordinary: `append` exists so mod A can stock an item mod B defines,
156
+ tutorial 2 teaches exactly that patch, and disabling mod B left an appended line
157
+ naming nothing. `bindStore` threw `store: unknown sval` from inside `bindCore`,
158
+ which the host runs at module top level, so one line of one shop's stock table
159
+ produced the crash screen and no game.
160
+
161
+ The rule is now gate 3's rule one layer down: the line is dropped, the mod is
162
+ told on its own row, and the rest of the store, and every other store, is
163
+ untouched. **Core's own data still throws,** and record provenance is what
164
+ separates them: an unresolvable entry in a store no pack has touched is core's
165
+ mistake and fails loudly, which is every store in a modless game.
166
+
167
+ This now covers every field of a store record a patch can reach. `normal`,
168
+ `always` and `buy` each lose the one entry that resolved to nothing. The
169
+ `store:` entrance feature is a scalar, so there is no entry to drop and nothing
170
+ left of the shop: the record survives with an entrance nothing matches, the shop
171
+ cannot be entered, and the mod is told. It is not removed from the store list,
172
+ because that list is consumed positionally: dropping a record would renumber
173
+ every store after it and move a saved game's stock between shops.
174
+
175
+ The owner list resolves no names at all, so there is no per-entry miss to
176
+ refuse the way a stock line has - but the field can still go missing
177
+ *entirely*, which is a different failure than the wrong-shape one the composer
178
+ answers one level up (below). A `replaces` body on a record a mod owns can
179
+ legitimately drop `owner:` as part of a total conversion, and the composer's
180
+ own shape guard deliberately does not restore a field that is simply absent
181
+ (see "A patch cannot make a field unreadable" below) - so `owner: undefined`
182
+ used to reach `rec.owner.map` as a bare `TypeError` naming no pack. The store
183
+ binder now guards the field itself: a missing or malformed owner list is
184
+ dropped to empty and reported against whichever pack is answerable, the same
185
+ way every other field on the record already is (#8).
186
+
187
+ **A patch cannot make a field unreadable.** The composer already checked shape on
188
+ the load path with `field/type` in the record check, but that check reports and
189
+ never refuses, deliberately, because the blueprint it reads is a *measurement* of
190
+ core's records and an unlisted value is legal (a mod inventing a new tval is
191
+ doing something the mod system exists to allow). That is right for a statistic
192
+ and wrong for container-ness: every binder reads a list field by iterating it, so
193
+ a list field holding a string, a number or `null` is a `TypeError` inside
194
+ `bindCore` inside `startGame`: the crash screen, over one field. The composer now
195
+ **refuses exactly that class**: the field is put back to what the record had
196
+ before, the pack is told on its own row, and the rest of the patch lands.
197
+
198
+ Two things it deliberately does not do, both load-bearing:
199
+
200
+ - **A scalar written as the wrong scalar stays a finding.** `weight` as `"40"` is
201
+ readable, some binders coerce it, and the measurement cannot prove otherwise.
202
+ - **A field the patch REMOVES is not put back.** Dropping a field is how a total
203
+ conversion works: `replaces` swaps the whole record, and a monster rewritten
204
+ as `{name, hp}` legitimately has no `flags`. Restoring an absent field would
205
+ silently undo a supported feature. An absent required field is reported
206
+ (`field/required`), and refusing a record the *mod itself owns* belongs in
207
+ the binders - the store binder's `owner:` guard above is that case, applied
208
+ where it is reachable today.
209
+
210
+ **This is not a store-only rule.** An ego's `item:` list names specific base
211
+ kinds and takes the same `append`, so it had the same defect and now gets the
212
+ same answer: the line is dropped, the ego keeps its other candidates, and the
213
+ mod is told. The core-versus-mod decision lives in `packages/core/src/mod/
214
+ refusal.ts`, one `fieldOwner`, shared, precisely so that two binders cannot
215
+ reach different verdicts about the same provenance. A binder that resolves names
216
+ from a list a mod can append to should be reading from there rather than
217
+ inventing its own rule.
218
+
219
+ A systematic pass over every binder (#8) found six more instances of the same
220
+ shape and closed them the same way: an artifact's `flags:` and `values:`
221
+ tokens, a curse's `type:` entries, a monster's `base:` (the whole race, sized
222
+ like the artifact's `base-object:`), `friends-base:`, `friends:` and `shape:`
223
+ (one entry each), and a terrain feature's `mimic:`. Two fields were audited and
224
+ found not to need it: an artifact's `act:` resolves the same way upstream's own
225
+ `findact` does - silently, to nothing, on core's own data too - so refusing a
226
+ mod's version would make the identical mistake louder than core's; and the trap
227
+ binder resolves no name from an appendable list at all, only fixed compiled
228
+ tables. `composePacks`'s `fieldPatches` loop also reached `applyFieldPatch`
229
+ without going through `refuse()`, so a malformed op (an `append` with `value`
230
+ instead of `values`, say) took down the whole pack, or every installed mod when
231
+ the raw error named none of them; it is now refused the same way a missing
232
+ patch target already was.
233
+
234
+ ### 4. The handed-in namespaces are not covered by any of the above, and that is the honest gap
235
+
236
+ `ModPluginContext.core` is the **live core module namespace** - the whole engine,
237
+ around 1,950 runtime exports, deliberately not a curated slice (decision 18, and
238
+ because a curated list is the thing that drifts). The count is not maintained by
239
+ hand: `packages/core/mod-api-surface.json` is the recorded surface and
240
+ `mod-core-surface.test.ts` fails in BOTH directions against it, so a removal and
241
+ an addition are each a visible diff rather than a number somebody has to
242
+ remember to change here.
243
+
244
+ `MOD_API_VERSION` does not version it. It versions the *shape of the plugin
245
+ contract*: the members of `ModPlugin`, what the host passes, when it calls them.
246
+ A core function can be renamed without touching any of that, so the one number a
247
+ mod author checks says nothing about the surface they spend all their time
248
+ calling.
249
+
250
+ What exists now is not a fence but a **ratchet**:
251
+ `packages/core/mod-api-surface.json` records every runtime export, and
252
+ `mod-core-surface.test.ts` fails when the set changes in either direction.
253
+
254
+ - A **removal or rename** fails CI with the names, and the fix is either to keep
255
+ the old name as an alias or to record the break here and take it knowingly.
256
+ - An **addition** also fails, with a one-line fix
257
+ (`node tools/api-surface.mjs --update`). That is not pedantry: a baseline that
258
+ tolerated additions would go stale, an export added in one release and removed
259
+ in the next would never have been recorded, and the removal check would be
260
+ measuring nothing.
261
+
262
+ #### There are two such namespaces now, watched the same way
263
+
264
+ `ctx.authoring` is the mod SDK's public barrel, 94 runtime exports, handed over
265
+ whole on exactly the terms `ctx.core` is and for exactly the same reason: a
266
+ curated subset of an authoring API is a second list to maintain, and the first
267
+ thing that happens to a curated list is that it lags the function somebody needs.
268
+
269
+ Until it was handed to a plugin, a rename inside the SDK was caught by `tsc -b`
270
+ over this repository, because every consumer of it was in the repository. That
271
+ stops being true the moment a plugin holds the namespace: a plugin ships as built
272
+ JavaScript and resolves no specifier, so the compiler never sees the call. The
273
+ second door therefore arrived carrying the first door's hole, and closes it the
274
+ same way. `packages/mod-sdk/mod-sdk-api-surface.json` is the recorded surface,
275
+ `mod-authoring-surface.test.ts` fails in both directions against it, and
276
+ `node tools/api-surface.mjs` checks and updates both baselines in one run.
277
+
278
+ The SDK barrel is a considered surface rather than everything that happens to be
279
+ exported: `applyFieldPolicy` is deliberately kept out of its `index.ts`, and says
280
+ so in a comment there. A removal from it is recorded in the table below on the
281
+ same terms a core removal is.
282
+
283
+ #### Additions, which strand nobody
284
+
285
+ An added `ctx` field cannot break an existing plugin: it reads what it reads, and
286
+ a name it never mentions cannot change its meaning. So `MOD_API_VERSION` does not
287
+ move for one (its own doc comment says as much), and the additions are recorded
288
+ here for discoverability rather than as a warning.
289
+
290
+ | Version | Field | What it is |
291
+ |---|---|---|
292
+ | unreleased (2026-08-22) | `ctx.authoring` | The mod SDK's public barrel: blueprints, `peersFor`, `suggestFields`, `checkRecords`, `ModProject` and the rest of the authoring stack. Always present, because these are pure functions over data the caller supplies and there is no boot state they wait on. Ungated, on the reasoning `capability-gate-reach.test.ts` already pins for `ctx.core` and `ctx.registries`: nothing here reads game state, nothing mutates a registry, and every name is reachable to anybody who can install the published npm package. |
293
+ | unreleased (2026-08-22) | `ctx.composedRecords` | Every content record the running game was composed from, as JSON, keyed by pack-file stem with no extension. The UNBOUND twin of `ctx.registries`, and the shape the authoring functions above accept: `registries.monsters.races` is bound and carries neither the JSON key names nor the fields that bound to nothing, so a peer table cannot be built from it. Mod-added records are in it on the same terms as core's, each carrying its provenance. Absent during content composition, for the same reason `registries` is. |
294
+ | unreleased (2026-08-22) | `ctx.reloadGame` | The game's own mod-change reload, so a mod that installed something can apply it: every plugin's `uninstall()` runs, the autoplayer hands the keyboard back, the live character is written down, and the session resumes that character instead of landing on the title screen. Behind `mod:install` rather than a capability of its own, because content composes at load and an install a mod cannot follow with a reload leaves the player holding something the process will never load. Not a permission to reload - a plugin reaches `location` with no grant - it is the four steps a mod cannot take for itself. |
295
+ | unreleased (2026-08-22) | `ctx.installMod(...).lines` | A field on both arms of the existing install outcome: the wording the Mods screen itself prints for that same outcome, including one row per unmet requirement and the author's advice under them. Added because a mod built in the game must fail a requirement in the same words a downloaded mod fails in; `problem` is unchanged and remains one whole sentence. |
296
+
297
+ #### Removals taken knowingly
298
+
299
+ Six rows, and they are the shape the mechanism above is for. Four of them are
300
+ one removal: the parse-error limit, which had no counterpart in Angband 4.2.6
301
+ and so had no business in a port. The sixth is the same shape a release later,
302
+ and the first one that had SHIPPED.
303
+
304
+ | Version | Export | Why | What to use instead |
305
+ |---|---|---|---|
306
+ | unreleased (2026-08-12) | `optionFileErrorMessage` | Its whole subject is gone. The custom-options reader was a port of upstream **master**'s `struct parser` grammar; #149 rewrote it to 4.2.6's hand-rolled read loop, which has no `parser_state` to format - it emits three plain `msg()` lines instead. | `prefErrorMessage` (`visuals/prefs.ts`), which formats the identical `Parse error in %s line %d column %d: %s: %s` from the same `ParserState`. It was always the same function; this one was the duplicate. |
307
+ | unreleased (2026-08-14) | `PARSE_ERROR_LIMIT` | The port's own cap of 20 parse errors per file. A citation sweep (#268) found no `PARSE_ERROR_LIMIT`, no `get_parser_error_limit` and no error COUNT anywhere in 4.2.6 - `process_pref_file_named` (`ui-prefs.c` L1225-1231) `break`s on the FIRST bad line - so it was an improvement the port had added, and the port adds nothing (#272). | Nothing in core: the number was never a fact about Angband. A mod that wants a cap chooses its own and passes it as `PrefErrorPolicy.reportLimit` to `setPrefErrorPolicy` (`visuals/prefs.ts`). The `qol` mod uses 20, so a player sees the familiar behaviour. |
308
+ | unreleased (2026-08-14) | `getParserErrorLimit` | The reader for the above, including a `PARSE_ERROR_LIMIT` environment override that no upstream build has. Removed with its subject (#272). | `prefErrorPolicy()`, which answers with the policy in force - `UPSTREAM_PREF_ERROR_POLICY` unless a mod installed another. It answers a richer question, because one number could not express both "keep reading" and "keep reporting". |
309
+ | unreleased (2026-08-14) | `setParserErrorLimit` | The test seam for the above. Nothing in the game ever called it, and its subject is gone (#272). | `setPrefErrorPolicy(policy \| null)`, which is a real seam rather than a test hook: it is the documented way a mod changes what a bad pref line costs, and `null` restores 4.2.6's behaviour. |
310
+ | unreleased (2026-08-14) | `parseParserErrorLimitEnv` | Parsed `PARSE_ERROR_LIMIT` out of the environment with C's `strtol` rules, so a host could set the cap without owning the rule. There is no cap and no environment variable (#272). | Nothing. A mod that wants its policy configurable owns that decision, and `ctx.prefs` is where a mod keeps a player's answer to it. |
311
+ | 0.23.0 | `fillTilesFromKin` | The port's own rule that a mod-added monster with no tile is drawn with the tile of a race sharing its `base`, and an added object kind with a kind sharing its `tval`. Shipped in 0.22.0 and removed one release later: 4.2.6 has no concept of a record a mod added, so it has no opinion about what one should look like, and "the lowest-index relative's picture" is authored taste rather than ported behaviour. It also made that call on behalf of tile sets the game does not own - a pack drawn in 2003 has no art for content added twenty years later, and a sibling's picture there is a confident lie where a letter was an honest answer. The port adds nothing (#272, again). | The seam it became: `registry:tiles`. A tileset mod registers a filler through `host.tiles.register`, reads what the game is made of through `ctx.registries`, and writes through a door that refuses any tile something else assigned (`TileFill`). `neo-linoleum` 0.15.0 carries exactly the rule that used to be here, applied to linoleum packs only. Its three supporting types went with it (`KinTileDeps`, `KinTileFill`, `KinTileDerivation`); being types, they never appeared in the surface list at all. |
312
+
313
+ `parseCustomOptionsText` survives by name but **changed shape** on 2026-08-12: it
314
+ returns `string[]` (the messages) rather than `ParserState[]`, and its fourth
315
+ `errorLimit` parameter is gone, because 4.2.6's reader has no error cap. A plugin
316
+ calling it for its own diagnostics gets a type error at build and a different
317
+ array at runtime. Recorded here rather than aliased: there is no honest alias for
318
+ "the same call now answers a different question."
319
+
320
+ `CellView.trap` survives by name but **changed meaning**, unreleased
321
+ (2026-08-21). It used to be "this grid holds any trap record" and it is now
322
+ `square_isdisarmabletrap`: a VISIBLE PLAYER trap that is not already disabled.
323
+
324
+ Recorded here rather than kept and supplemented with a second field, because the
325
+ old answer was not a different question - it was the wrong answer to this one. The
326
+ trap list is also where a closed door's LOCK lives (`square_set_door_lock`, flagged
327
+ `LOCK | INVISIBLE`), along with a glyph of warding, a web and a decoy. None of
328
+ those is a trap a player can see or the `disarm` command will act on, so a mod
329
+ reading the old field got a locked door presented as something to disarm - and
330
+ `disarm` refuses it without spending a turn, which turns a plausible decision into
331
+ a hang. The old meaning also contradicted the view's own rule, stated on the
332
+ neighbouring `trapGlyph`: a trap the player has not found is not on the screen and
333
+ so is not in the view.
334
+
335
+ **What to do about it.** A mod that used `trap` to decide whether to disarm needs
336
+ no change and stops hanging. A mod that used it to ask "is there anything in the
337
+ trap list here" - a map overlay counting glyphs of warding, say - now gets `false`
338
+ for a glyph and needs the trap layer instead: `trapGlyph` is present for exactly
339
+ the traps the player can see, glyph included.
340
+
341
+ `ProcessPrefOptions` **changed shape** for the same reason and in the same
342
+ direction (#272, unreleased 2026-08-14): its `errorLimit?: number` is now
343
+ `errorPolicy?: PrefErrorPolicy`. A plugin that called
344
+ `processPrefText(text, deps, sink, { errorLimit: 0 })` gets a type error at
345
+ build, and the fix is `{ errorPolicy: { continueAfterError: true, reportLimit: 0 } }`.
346
+ Not aliased, because the old field could not say what the new one has to: a
347
+ single number conflated "stop applying the file" with "stop collecting errors",
348
+ and the second is the one a player wants bounded. Four names arrived with it:
349
+ `PrefErrorPolicy`, which is a type and so never appears in the surface list, and
350
+ the three runtime exports `UPSTREAM_PREF_ERROR_POLICY`, `prefErrorPolicy()` and
351
+ `setPrefErrorPolicy()`.
352
+
353
+ `msgt(sinks, type, text)` likewise **keeps its name and signature but no longer
354
+ touches `sinks.sound`** (#239, unreleased 2026-08-13). It used to call both
355
+ halves by hand; the host's `msg` sink is now `msgt` itself, so calling both
356
+ would play the sound twice. Nothing breaks at build time and the common case is
357
+ unchanged - a plugin that calls `msgt(ctx.state, "HUNGRY", "...")` still gets
358
+ message *and* sound, because the state's sink supplies it. What changed is a
359
+ plugin that binds its **own** non-sounding `msg` into a `MessageSinks` and
360
+ relied on `msgt` to reach `sound` separately: that now goes quiet, and the fix
361
+ is to make its sink typed-aware with the exported `messageSound(type)`, the same
362
+ one-line rule `web/src/main.ts` uses. Not aliasable: two functions differing only
363
+ in whether they double-fire is worse than one rule.
364
+
365
+ One more shape change, in the SDK rather than in `ctx.core`: `ParsedCapability`
366
+ gained a `{ kind: "display"; action: "replace" }` variant (#140, unreleased
367
+ 2026-08-13), because `ModPlugin.frontend` now requires `display:replace`. Nothing
368
+ that *builds* a capability string breaks; what breaks is a plugin that
369
+ `switch`es exhaustively over `parseCapability`'s result in TypeScript, which
370
+ gets a compile error naming the new arm. That is the intended outcome - a mod
371
+ rendering the capability list to its own UI should be told a kind exists that it
372
+ does not describe. Additive at runtime: an older build simply never emits it.
373
+
374
+ The same thing happened once more, for the same reason and with the same
375
+ consequence: `ParsedCapability` gained `{ kind: "ui"; region: string; action:
376
+ "replace" }` and `ContestedLayer` gained `"hud"` (#253, unreleased 2026-08-13),
377
+ because `ModPlugin.hud` requires `ui:<region>.replace` and the conflict report
378
+ now has a slot per HUD region. An exhaustive `switch` over either gets a compile
379
+ error naming the new arm, which is the intended outcome. `ModPlugin` itself only
380
+ gained an optional member, so no existing plugin's shape changes.
381
+
382
+ Two more SDK additions, and this pair breaks nothing at all, recorded because
383
+ the entries above establish that a shape change gets written down whether or not
384
+ it strands anybody, and a page that only lists the painful ones stops being a
385
+ record. `WorldFrame` and `HudFrame` each gained an **optional** `stack`
386
+ (`readonly LiveRegion[] | undefined`), and `LiveRegion` / `RegionLayer` are now
387
+ exported from the SDK (#261, unreleased 2026-08-14). An optional member added to
388
+ an interface a plugin *receives* cannot break a plugin: nothing that reads a
389
+ frame stops compiling, and a host that publishes no stack simply leaves it
390
+ `undefined`, which the seam gives a distinct meaning to on purpose, so a front
391
+ end must not read a missing stack as "nothing is covering me". See
392
+ [PLUGINS.md](PLUGINS.md#knowing-when-you-are-covered-framestack). Note the
393
+ asymmetry with the `ParsedCapability` rows above: those broke exhaustive
394
+ `switch`es because a plugin *inspects* a capability, and nobody exhaustively
395
+ switches over a frame.
396
+
397
+ One SDK **removal**, and it is the first on this page that removes a name rather
398
+ than reshaping one: `applyFieldPolicy` is gone from the package index (#285,
399
+ unreleased 2026-08-15). It arrived public by accident: the index said
400
+ `export * from "./fields.js"`, and it was unusable and dangerous in the same
401
+ breath. The function judges a namespace trespass from a `FieldProvenance` map
402
+ built during composition, and the accessor that builds one (`fieldProvenanceOf`)
403
+ was never exported. So the only form an outside caller could write was the
404
+ three-argument one, whose defaults are empty maps: it strips undeclared keys,
405
+ finds no recorded writer for anything, judges no write a trespass, and hands back
406
+ a fault list indistinguishable from a clean pass. **A gate that reports success
407
+ while checking nothing is worse than no gate**, because the caller stops looking.
408
+ The two provenance parameters are now required as well, so the same mistake is a
409
+ compile error inside the SDK. Nothing in this repository or in the four mod
410
+ repositories called it, so no author is stranded; the door to the rule is
411
+ `composeContentPacks`, which supplies both maps and always did.
412
+ `checkUnqualified`, `declaredFields`, `fieldOwner`, `isExtensionKey` and
413
+ `FIELD_TYPES` are unaffected, and are now named explicitly rather than swept up
414
+ by a wildcard, which is what let this one out in the first place.
415
+
416
+ Two **field renames** that the export ratchet cannot see, and that is exactly why
417
+ they are written here (#283, unreleased 2026-08-15). The ratchet compares the set
418
+ of exported NAMES; it says nothing about the shape of what a name hands back. The
419
+ ui-entry config a plugin gets from `buildUiEntryConfig` changed two fields:
420
+
421
+ | Was | Now | Why not aliased |
422
+ |---|---|---|
423
+ | `UiEntry.combinerIndex: number` (1-based into core's nine) | `UiEntry.combinerName: string` | The slot was the bug. It is a coordinate into core's own compiled table, so a combiner a mod registers has none, and keeping the index would have frozen the table at nine and made `registry:ui-entry` inert. Keeping BOTH would mean two identities for one thing and a rule about which wins. |
424
+ | `RendererInfo.backendIndex: number` (0..5), `RendererInfo.combinerIndex: number` | `RendererInfo.backendName: string`, `RendererInfo.combinerName: string` | Same reason, and the same fix: read the name. `RendererInfo` is now an exported TYPE as well, which it was not before, and a plugin writing a renderer backend needs to name it. |
425
+
426
+ Nothing else about `UiEntryConfig` moved, and a plugin that only calls
427
+ `characterGrid`, `equipCmpSummary`, `applyRenderer` or `combineValues` is
428
+ unaffected: every one of those gained an OPTIONAL trailing registry argument and
429
+ behaves exactly as before when it is omitted.
430
+
431
+ **This does not make `ctx.core` stable.** It makes breaking it visible to the
432
+ person breaking it, in the repository where it happens, before it reaches a
433
+ player's browser. The remaining pressure valve is `ModHooks`, which is a closed
434
+ interface of eight members that the bug-fixes mod alone needed six of - if
435
+ authors keep reaching past it into `ctx.core`, that is the signal to grow the
436
+ seam, not to fence the namespace.
437
+
438
+ ## What is *not* a compatibility mechanism
439
+
440
+ - **Save data.** A mod's own bag in the player's save is migrated by the mod, via
441
+ `ModPlugin.migrateBag` and `saveSchema`. Core round-trips the bag verbatim and
442
+ never reads it, so only the mod knows what its own data means.
443
+ - **Load order.** Nothing here changes who wins a conflict. That is
444
+ MOD_LIFECYCLE.md section 3, and the answer is always the mod that loads last.
445
+
446
+ ## Who finds out first
447
+
448
+ The **mod canary** (`.github/workflows/mod-canary.yml`) runs the curated list
449
+ against this build daily and whenever the list changes: every repository in
450
+ `mods/registry.json` is discovered the way the game discovers it, and its manifest
451
+ is put through this build's gates. So an engine release that would strand a
452
+ curated mod shows up here rather than in a player's install - the automated
453
+ equivalent of SMAPI's compatibility list, and the reason a release can be held
454
+ rather than apologised for.
455
+
456
+ That covers curated mods only. A mod nobody has listed finds out the same way
457
+ every mod always has, which is why the gates above are built to degrade rather
458
+ than refuse.
459
+
460
+ ## Prior art, and where this deliberately differs
461
+
462
+ - **[SMAPI](https://github.com/Pathoschild/SMAPI)** (Stardew Valley) is the model
463
+ for most of this: it rewrites mods' compiled code for renamed members, detects
464
+ an incompatible mod and disables it with a clear message rather than letting it
465
+ crash the game, and publishes a live compatibility list. The lesson taken here
466
+ is the *ordering* - degrade, report, and only refuse what genuinely cannot run.
467
+ Not taken: the IL rewriting, which has no equivalent for a JavaScript module
468
+ and would be the wrong tool anyway.
469
+ - **[Factorio](https://lua-api.factorio.com/latest/auxiliary/data-lifecycle.html)**
470
+ supplies migrations in two flavours - JSON to rename a prototype, Lua to fix up
471
+ a loaded save - and remembers per save which have run. `migrateBag` is the
472
+ second of those. The first is the shape a core-side rename alias would take if
473
+ one is ever needed; nothing needs it yet, because core's record names are
474
+ upstream Angband's and the parity mandate keeps them still.
475
+ - **NeoForge and Factorio both BLOCK** on a declared incompatibility. This engine
476
+ does not, per decision 18. An author's declaration is shown with their reason
477
+ and never overrides the player's setup.