Skip to content

feat(compiler): two holes where player-visible text escapes translation (spec-0029) - #317

Open
stellarfeline wants to merge 2 commits into
worker/i18n-v2from
worker/l10n-holes
Open

feat(compiler): two holes where player-visible text escapes translation (spec-0029)#317
stellarfeline wants to merge 2 commits into
worker/i18n-v2from
worker/l10n-holes

Conversation

@stellarfeline

@stellarfeline stellarfeline commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Two independent leaks of player-visible text, found by the owner playing the island in Chinese and reading English. Branched from worker/i18n-v2 (#314) — this builds on i18n v2 and inherits its gate.

Hole 1 — a body's name was not in the inventory

dsl::l10n::each_string inventoried an actor's drops but never the actor's own name. One hole, two signatures on the island:

identity translated before? when the player saw it
npc/polyphemus yes standing still → correct
actor/polyphemus-{herdsman,roused,blinded,walker} — all named Polyphemus no cutscenes → English
actor/sheep-1..8 no always — a sheep has no NPC identity at all
actor/voyage-{eurylochus,perimedes,elpenor,antiphos} no invisible: proper nouns that translate to the same glyphs. "We did not see it" is not "it was not happening."

Binding count: 16 actors on the island, 16 carrying a name, 0 inventoried.

The design decision, not just the field

Adding actor.<id>.name and stopping would have made it worse: a translator asked for Polyphemus five times can answer five ways, and the giant's name would change as he walks into a cutscene — authored, not accidental.

So an entity display name is keyed by its canonical English text, not by its declaration site. The first site in the traversal (NPCs before actors) owns the key; every later site carrying the byte-identical name emits that same key. The inventory asks once, and two bodies a player reads as one character cannot render as two.

Alternatives and why they lose:

  • One key per site + a diagnostic that identical names must translate identically — catches divergence instead of preventing it, and still asks the translator 16 questions, 13 of which must be answered identically to existing rows. By-construction beats a check (CLAUDE.md debug doctrine).
  • An actor declaring portrays: npc/<id> — new DSL surface the owner has not approved, and it does nothing for the sheep, who have no NPC.

Scope is npc.*.nameactor.*.name. Prose keeps one key per site (coinciding English may legitimately need different renderings). wave.*.mob.*.name is deliberately not merged — same shape (the island's three storm waves are all Drowned of Poseidon under three keys, free to diverge), but merging retires keys two live campaigns already translate. Owner ruling 2026-08-06: do not extend the merge. Live campaigns keep their keys, and the divergence is prevented at authoring time instead — see Authoring rule below.

The sweep, and its permanent form

78 string-valued stage-document fields examined — every string-valued property of the seven stage schemas, which are derived from the Rust types, so the enumeration is complete by construction rather than by diligence. Result: actors[].name was the only uninventoried player-visible string. Everything else is 27 inventoried, 28 references/registry ids, 11 serde discriminators, 12 explicitly-excluded authoring context (persona, doing, happening.text, theme/premise, note), each with its reason recorded.

A clean sweep is only worth something if it stays clean, and the asymmetry the dispatch named is the reason it did not: DW0185 proves a string the inventory knows about reaches a component, and is blind to one the inventory never met. crates/dsl/tests/l10n_surface.rs closes that half — every DSL string field must be classified Inventoried / Reference / Machine / NotPlayerVisible(<why>), checked against the live schema in both directions. A new String anywhere in the DSL is red until somebody records whether a player reads it. It is a test rather than a DW code because the defect is in the compiler: no campaign input can produce it, the same reasoning that makes DW0185 a build-tier output scan.

Hole 2 — the compiler's own chrome

Thirteen strings the compiler writes itself had no key and no override. The mechanism, per the owner's ruling: compiler-owned end to end (dsl::chrome) — reserved delvewright.ui.… keys, English canonical, translations shipped with the engine, zero campaign authoring. The sentence that decided it: eight of these are product chrome no campaign author wants to write, so giving them an override would move the engine's maintenance cost onto content. The other five are the diegetic defaults (boundary.message, sealed_hint, the bonfire's three) whose authored overrides already exist, are unchanged, and still win.

  • One road, not two. Chrome enters emission as a translation tag and lowers through emit::tr/snbt_component; a site that fails to is DW0185 exactly as for authored text. No parallel path to drift down.
  • Sentences, not fragments. Three of the eight were concatenations, which freeze English word order into every language. They are now one key with %s carried by the component's with"%s — complete.", "New objective: %s", "Waiting for the party — %s / %s" — vanilla's own primitive. A unit test requires each language's placeholder count to equal the English's; a dropped %s is an objective title that never reaches the screen.
  • Namespace reserved (DW0186). A sidecar may not define a chrome key: it would be written into the language file and silently replace product chrome. Collision is impossible the other way by construction — the l10n key scheme derives a fixed set of kinds and can never produce delvewright., and vanilla never defines it.
  • Honest fallback. A language with no chrome table gets no chrome rows at all — the client resolves through en_us.json, or through the component's own fallback for a player who declined the pack, and reads English. Absent, never English written into fr_fr.json under a translated name.
  • Emitted only into the files the delve already ships (en_us + declared languages), so a French client on a Chinese-only campaign never gets French chrome around English story. Uniform beats partial.
  • --lang bake: ships no lang files, so the fallback is what the player reads — Chrome::for_build puts the baked language there. %s still substitutes; vanilla formats the fallback with the same with arguments.

Language set — derived, version-exact

dsl::mclang::CLIENT_LANGS is the pinned client's own language set: 143 stems (142 minecraft/lang/*.json from the 1.21.11 asset index + en_us from the jar), derived via version manifest → 1.21.11.json (sha1 5333851…) → asset index 29 (sha1 e9f7ce4…). Digests are in the module header and tools/derive-client-langs.py re-derives and re-prints them; the table is baked into the source so the compiler never reaches the network (ADR-0006), the same way the server jar's provenance is recorded rather than fetched.

It replaces the hand-maintained 25-entry mc_lang_code table and is a strict superset — every code that resolved before resolves to the same stem, so nothing that compiled stops compiling. DW0184 now also rejects an ambiguous bare code (zh, sr, be), because guessing the region is how a language ships invisible.

Translation quality, stated plainly — approved, not resolved

30 chrome tables, covering 47 of the client's 143 locales, plus the 5 English locales that need none. Every other language reads English. The tables are machine-produced and unreviewed — no native-speaker check — which is recorded in dsl::chrome's module header, not glossed. Owner ruling 2026-08-06: ship them. The reasoning, so it stays legible to whoever reads this next: the failure mode that would actually break a delve — a translation dropping a %s — is already prevented by the placeholder-parity test binding all 30 tables × 13 strings, so what remains is awkward phrasing rather than a broken string, on thirteen maximally formulaic UI lines that any native speaker can correct one row at a time. This is an approved risk, not a closed one; the statement above and the list below stay in the record.

Least confident, in order: Hebrew, Arabic, Hindi, Thai, Filipino/Tagalog, Malay (script/register conventions I cannot verify), then Finnish, Hungarian, Greek (case and agreement around the %s). Most confident: Chinese (both scripts), Japanese, German, French, Spanish, Portuguese, Russian, Italian, Dutch, Polish. The one string I would flag everywhere is boundary.message — the only literary line in the set; a campaign that cares authors its own.

I did not produce tables for the client's constructed and joke locales (Klingon, Lojban, Quenya, Toki Pona, LOLCAT, Pirate, Upside-down English, Anglish, Shakespearean, Interslavic, Viossa and similar). Those would be fabrication rather than translation, and a campaign will never declare them. The derived code table is complete; adding a language later is a table row and no engine change.

The % diagnostic — measured, not minted

#314 declined a %-in-content diagnostic because it bound to nothing. Re-measured across the whole content repo: 3787 authored/translated string leaves, 0 containing %. Still zero binding, so it is still not minted. The real risk — a chrome translation that drops or invents a %s — is covered by the placeholder-parity unit test instead, which binds to all 30 tables × 13 strings.

Reds watched, each with a control

gate red control
DW0185 The moment actors[].name entered the inventory, the island build failed: validation/combat-plan.json: 'npc.polyphemus.name' — an emission site that neither lowered the string nor read it as an exclusion. Fixed as a named exclusion (plain), documented. Before the inventory change the same build was green: the string carried no key, so nothing could catch it. That is the hole.
DW0186 Adding delvewright.ui.class.title to the island's sidecar → DW0186 naming the key and the file. The untouched island raises 0 × DW0186.
l10n_surface Removing an Inventoried row, or adding a DSL String, fails with the field named. Green at 78/78 today.

Before / after — the owner's own scenario

delvec build nobodys-cave-island (zh-cn declared), sheep nameplate:

- CustomName:"Ram of the Cave"
+ CustomName:{fallback:"Ram of the Cave",translate:"actor.sheep-1.name"}     → 洞中公羊

The giant, in all four cutscene poses:

- CustomName:"Polyphemus"
+ CustomName:{fallback:"Polyphemus",translate:"npc.polyphemus.name"}         → 波吕斐摩斯

— one key, the NPC's own, so he cannot be called two things in two scenes. sheep-1/2/5/6 share actor.sheep-1.name; 3/4/7 share actor.sheep-3.name; 8 has its own. The four crew mannequins take their NPCs' keys. 16 actors → 3 new keys, not 16.

Chrome, same build:

- {"text":"New objective: ","color":"yellow","bold":true}, {…title…}
+ {"translate":"delvewright.ui.objective.new","fallback":"New objective: %s",
   "color":"yellow","bold":true,"with":[{…title…,"bold":false}]}             → 新目标:<标题>

zh_cn.json in the shipped pack: 447 keys, 434 campaign + 13 chrome.

Translation provenance — DW0187 / DW0188

Raised in review: text-owned keys give staleness a second way in. Renaming one body migrates the key to another, so the row that goes wrong is not the row the author edited — DW0180 reports the newly-required key (somewhere else entirely) while the actually-stale row stays present, applied and wrong.

Assessed as contained and implemented, in the general form rather than a migration-only guard: per-site keying has the same staleness hazard, and half-covering it would read as closed while leaving the class open.

The sidecar now carries source — the canonical English each content row was translated from. Coverage compares key sets and can never see this; source lets the compiler compare instead of a human auditing.

  • DW0187 (error): a recorded source differs from the key's live English, or names a key the sidecar no longer translates.
  • DW0188 (warning): rows with no recorded provenance, counted. source is additive — an older sidecar parses unchanged — so without this an unadopted sidecar would be a silence that reads like a pass. It is the one-version deprecation window, and it makes a zero binding a reported number on every run.
  • tools/i18n-translate.py writes source for every row it emits, so adopting the guard is a re-run that retranslates nothing.

Binding counts and reds, each with a control

result
DW0187 binding 21 of 21 rows compared on the in-repo keep-trial fixture, which is adopted in this PR. Zero would be a finding.
red — general Edit world.titleDW0187 names the key and what it now reads.
red — migration (the reviewer's case) Two puppets share Ram of the Cave; rename only the firstDW0187 fires on actor.ram-a.name (the row that is wrong) while DW0180 separately reports actor.ram-b.name (the key that is missing). Reproduced on the island too: rename actor/sheep-1 alone and its 洞中公羊 row is caught.
red — stale provenance source naming a key the campaign no longer has → DW0187.
DW0188 binding 21 of 21 unguarded rows reported when source is stripped. On the un-adopted island: 434 of 434 — stated, not silent.
control The adopted fixture and the adopted island copy both raise 0 × DW0187 and 0 × DW0188.

The island's live binding is zero until its content PR — its sidecar records no provenance today, so DW0187 compares 0 rows there and DW0188 says so on every build. That is a finding, named here rather than left to be discovered, and adoption is one i18n-translate.py re-run.

Authoring rule — where the wave-mob divergence is actually prevented

The owner declined the merge and named where the fix belongs instead, so this is the deliverable that closes the finding, not a footnote on the decline.

.claude/skills/new-delve/SKILL.md gains Writing craft §D — a name spelled the same way IS the same name: a hard rule an author applies while naming, telling it that byte-identical entity display names collapse into one name and one translation (so a character's NPC and every puppet of it must be spelled identically — copy, do not retype), that genuinely different bodies must be spelled differently, and that wave-mob names do not collapse — three waves you both named Drowned of Poseidon are three rows a translator answers three times and may render three ways — with what to do in each direction.

Audience separation (CLAUDE.md), since the same fact now lives in three files:

file reader what it says
SKILL.md §D an authoring agent, mid-campaign the rule to apply while naming — no key syntax, no DW codes, no compiler internals
docs/reference/i18n.md whoever runs a translation round that names are pooled, that wave mobs are not, that the glossary holds them together, and how source guards a row
docs/reference/compiler.md a compiler maintainer the key scheme, the ownership rule, the sidecar format, DW0187/DW0188

Content changes this needs (not in this PR)

One content PR, per campaign, both mechanical:

  1. nobodys-cave-island's l10n/zh-cn.json needs 3 new rowsactor.sheep-1.name, actor.sheep-3.name, actor.sheep-8.name. Everything else is already there, because the puppets share their NPCs' keys.
  2. Both live sidecars should adopt source (tools/i18n-translate.py <campaign> --lang zh-cn — it records provenance for rows it already has and retranslates nothing), which turns DW0188's 434-of-434 into DW0187 actually guarding 434 rows.

Neither is blocking: the island builds green today, with DW0188 as a warning. The proofs above were built from scratchpad copies; the content repo is untouched (verified git status clean).

Gate

Not merged. Inherits #314's gate: the owner's playtest.

stellarfeline and others added 2 commits August 6, 2026 01:06
…on (spec-0029)

The owner played the island in Chinese and read English. Two independent leaks,
both closed here, plus the general form of each so neither class can recur.

**Hole 1 — a body's name was not in the inventory.** `dsl::l10n::each_string`
inventoried an actor's DROPS but never the actor's own `name`, so every
`actors[].name` shipped as a literal. That is one hole with two signatures on the
island: the giant is `npc/polyphemus` (translated, correct when he stands still)
AND four actor puppets all named `Polyphemus` (untranslated, English the moment a
cutscene stages one) — while the eight sheep have no NPC identity at all and were
English in every frame. 16 actors, 16 carrying a name, 0 inventoried.

Inventorying the field alone would have made it worse: a translator asked for
`Polyphemus` five times can answer five ways, and the giant's name would change as
he walked into a cutscene — an authored defect rather than an accidental one. So
**an entity display name is keyed by its canonical English text, not by its
declaration site**: the first site in the traversal (NPCs before actors) owns the
key and every later site carrying the identical name emits that same key. The
translator is asked once; two bodies a player reads as one character cannot render
as two. Scoped to `npc.*.name` + `actor.*.name` — prose stays per-site, and
merging `wave.*.mob.*.name` would retire keys live campaigns already translate,
which is an owner call, not this PR's.

**The general form.** `DW0185` proves a string the inventory KNOWS reaches a
component; it is blind to one the inventory never met, which ships English in
silence — how this survived twenty playtest rounds. `crates/dsl/tests/
l10n_surface.rs` closes that half: it enumerates every string-valued property of
the seven stage schemas (derived from the Rust types, so complete by construction
— 78 today) and requires each to be classified Inventoried / Reference / Machine /
NotPlayerVisible(<why>), in both directions. A new `String` anywhere in the DSL is
red until somebody records whether a player reads it. A test rather than a DW
code because the defect is in the compiler: no campaign input can produce it.

**Hole 2 — the compiler's own chrome was untranslatable.** Thirteen strings the
compiler writes itself had no key and no override. They are now compiler-owned end
to end (`dsl::chrome`): reserved `delvewright.ui.…` keys, English canonical,
translations shipped with the engine, zero campaign authoring. Eight are product
chrome (`New objective: `, `Delve Complete`, `Choose your class`, …) — no author
wants to write those, which is why the answer is not an override; five are the
diegetic defaults whose authored overrides already exist, are unchanged, and still
win.

Chrome rides the existing road rather than a parallel one: it enters emission as a
translation tag, lowers through `emit::tr`/`snbt_component`, and a site that fails
to is `DW0185` like any authored string.

Three of the eight were CONCATENATIONS, which freeze English word order into every
language. They are now one key with `%s` carried by the component's `with` —
`"%s — complete."`, `"New objective: %s"`, `"Waiting for the party — %s / %s"` —
vanilla's own primitive for the job. A unit test requires every language's
placeholder count to equal the English's.

`DW0186` reserves the namespace: a sidecar may not define a chrome key, which
would otherwise be written into the language file and replace product chrome.

**Language coverage.** `dsl::mclang::CLIENT_LANGS` is the pinned client's own
language set — 143 stems DERIVED from Mojang's 1.21.11 asset index, with the
digests recorded and `tools/derive-client-langs.py` to re-derive; it replaces the
hand-maintained 25-entry table and is a strict superset, so nothing that compiled
stops compiling. Chrome tables: 30, covering 47 locales; every other language
reads English — absent from its lang file, never faked. Chrome is emitted only
into the files a delve already ships, so a French client never gets French chrome
around English story.

The translations are machine-produced and unreviewed; that is stated in the
module header, not glossed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
…t prevents divergence

Three follow-ups on the review of #317.

**1. A sidecar row can go stale silently — now it cannot (`DW0187`).** Coverage
validation compares key SETS: rewrite an authored line and its translation is
present, applied and WRONG, with no key moved and every check green. The text-owned
key scheme this PR introduced adds a second, nastier way in — rename ONE body and
the key migrates to ANOTHER, so the row that goes stale is not the row the author
edited, and `DW0180` points somewhere else entirely.

The sidecar now records the English each row was translated FROM (`source`), so the
compiler compares instead of a human auditing. It is deliberately the GENERAL fix,
not a migration-only guard: any edited line is caught, whatever kind of string it
is.

`source` is additive — an older sidecar parses unchanged — so `DW0188` counts its
unguarded rows on every run and names the deprecation window. An unadopted sidecar
is a reported number, never a silence that reads like a pass. `i18n-translate.py`
writes `source`, so adoption is a re-run that retranslates nothing.

**2. Wave-mob names stay per-site keyed** (owner ruling, 2026-08-06). Live
campaigns keep the keys they already translate.

**3. The prevention moves to authoring time, where she put it.** Declining the
merge leaves the divergence real, so the rule goes where an author can act on it:
`SKILL.md` *Writing craft* §D — byte-identical entity names collapse to one name
and one translation, wave-mob names do not, and what to do in each direction.
Written as a rule applied while naming, for an authoring agent; the key-scheme
mechanics stay in `compiler.md`, for a compiler maintainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant