feat(compiler): two holes where player-visible text escapes translation (spec-0029) - #317
Open
stellarfeline wants to merge 2 commits into
Open
feat(compiler): two holes where player-visible text escapes translation (spec-0029)#317stellarfeline wants to merge 2 commits into
stellarfeline wants to merge 2 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_stringinventoried an actor's drops but never the actor's ownname. One hole, two signatures on the island:npc/polyphemusactor/polyphemus-{herdsman,roused,blinded,walker}— all namedPolyphemusactor/sheep-1..8actor/voyage-{eurylochus,perimedes,elpenor,antiphos}Binding count: 16 actors on the island, 16 carrying a
name, 0 inventoried.The design decision, not just the field
Adding
actor.<id>.nameand stopping would have made it worse: a translator asked forPolyphemusfive 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:
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.*.name∪actor.*.name. Prose keeps one key per site (coinciding English may legitimately need different renderings).wave.*.mob.*.nameis deliberately not merged — same shape (the island's three storm waves are allDrowned of Poseidonunder 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[].namewas 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:
DW0185proves a string the inventory knows about reaches a component, and is blind to one the inventory never met.crates/dsl/tests/l10n_surface.rscloses that half — every DSL string field must be classifiedInventoried/Reference/Machine/NotPlayerVisible(<why>), checked against the live schema in both directions. A newStringanywhere in the DSL is red until somebody records whether a player reads it. It is a test rather than aDWcode because the defect is in the compiler: no campaign input can produce it, the same reasoning that makesDW0185a 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) — reserveddelvewright.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.emit::tr/snbt_component; a site that fails to isDW0185exactly as for authored text. No parallel path to drift down.%scarried by the component'swith—"%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%sis an objective title that never reaches the screen.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 producedelvewright., and vanilla never defines it.en_us.json, or through the component's ownfallbackfor a player who declined the pack, and reads English. Absent, never English written intofr_fr.jsonunder a translated name.en_us+ declared languages), so a French client on a Chinese-only campaign never gets French chrome around English story. Uniform beats partial.--langbake: ships no lang files, so the fallback is what the player reads —Chrome::for_buildputs the baked language there.%sstill substitutes; vanilla formats the fallback with the samewitharguments.Language set — derived, version-exact
dsl::mclang::CLIENT_LANGSis the pinned client's own language set: 143 stems (142minecraft/lang/*.jsonfrom the 1.21.11 asset index +en_usfrom the jar), derived via version manifest →1.21.11.json(sha15333851…) → asset index29(sha1e9f7ce4…). Digests are in the module header andtools/derive-client-langs.pyre-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_codetable and is a strict superset — every code that resolved before resolves to the same stem, so nothing that compiled stops compiling.DW0184now 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 isboundary.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
DW0185actors[].nameentered 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.DW0186delvewright.ui.class.titleto the island's sidecar →DW0186naming the key and the file.DW0186.l10n_surfaceInventoriedrow, or adding a DSLString, fails with the field named.Before / after — the owner's own scenario
delvec build nobodys-cave-island(zh-cn declared), sheep nameplate:The giant, in all four cutscene poses:
— one key, the NPC's own, so he cannot be called two things in two scenes.
sheep-1/2/5/6shareactor.sheep-1.name;3/4/7shareactor.sheep-3.name;8has its own. The four crew mannequins take their NPCs' keys. 16 actors → 3 new keys, not 16.Chrome, same build:
zh_cn.jsonin the shipped pack: 447 keys, 434 campaign + 13 chrome.Translation provenance —
DW0187/DW0188Raised 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 —
DW0180reports 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 eachcontentrow was translated from. Coverage compares key sets and can never see this;sourcelets 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.sourceis 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.pywritessourcefor every row it emits, so adopting the guard is a re-run that retranslates nothing.Binding counts and reds, each with a control
DW0187bindingkeep-trialfixture, which is adopted in this PR. Zero would be a finding.world.title→DW0187names the key and what it now reads.Ram of the Cave; rename only the first →DW0187fires onactor.ram-a.name(the row that is wrong) whileDW0180separately reportsactor.ram-b.name(the key that is missing). Reproduced on the island too: renameactor/sheep-1alone and its 洞中公羊 row is caught.sourcenaming a key the campaign no longer has →DW0187.DW0188bindingsourceis stripped. On the un-adopted island: 434 of 434 — stated, not silent.DW0187and 0 ×DW0188.The island's live binding is zero until its content PR — its sidecar records no provenance today, so
DW0187compares 0 rows there andDW0188says so on every build. That is a finding, named here rather than left to be discovered, and adoption is onei18n-translate.pyre-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.mdgains 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 namedDrowned of Poseidonare 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:
SKILL.md§Ddocs/reference/i18n.mdsourceguards a rowdocs/reference/compiler.mdDW0187/DW0188Content changes this needs (not in this PR)
One content PR, per campaign, both mechanical:
nobodys-cave-island'sl10n/zh-cn.jsonneeds 3 new rows —actor.sheep-1.name,actor.sheep-3.name,actor.sheep-8.name. Everything else is already there, because the puppets share their NPCs' keys.source(tools/i18n-translate.py <campaign> --lang zh-cn— it records provenance for rows it already has and retranslates nothing), which turnsDW0188's 434-of-434 intoDW0187actually guarding 434 rows.Neither is blocking: the island builds green today, with
DW0188as a warning. The proofs above were built from scratchpad copies; the content repo is untouched (verifiedgit statusclean).Gate
Not merged. Inherits #314's gate: the owner's playtest.