feat(compiler,harness): i18n v2 — a delve ships every language and the client picks (spec-0029) - #314
feat(compiler,harness): i18n v2 — a delve ships every language and the client picks (spec-0029)#314stellarfeline wants to merge 3 commits into
Conversation
…e client picks (spec-0029)
`nobodys-cave-island v1.0.0` shipped Chinese-only: `build --lang zh-cn` swaps
every player-visible string before compiling, so the datapack carries exactly one
language and an English player reading it has no fallback path at all.
Emission now lowers every authored player-visible string into
`{"translate": "<l10n key>", "fallback": "<English source>"}` and writes one
`assets/delvewright/lang/<mc_code>.json` per declared language — plus `en_us.json`
— into the resource pack the release already ships. The client auto-selects its
own locale; an unshipped locale, a missed key, and a player who DECLINES the pack
all resolve through the fallback riding the component itself.
The key set is the existing l10n inventory: `each_string` stays the single
authority over what is translatable, so measured and translated cannot drift.
`DW0185` makes spec-0029's central risk an invariant instead of an audit: each
inventoried string enters emission carrying its key in a reserved private-use tag,
an emitter either lowers it through a component helper or reads it through
`l10n::plain`, and a tag still in the finished tree is a site that did neither.
Feature-blind and last, so it guards emitters not yet written.
`--lang` survives unchanged as the single-language bake. No DSL change, no
`dsl_version` bump: emission only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
`emit_narrate` no longer case-folds an `art` narrate on its way into the title
command — a `{"translate": …}` component resolves in the client, long after the
compiler could uppercase anything — so the fold moved into the font: a second
bitmap provider addresses the same atlas cells by their lowercase letters.
Nothing downstream looks at a texture, so losing that provider is a silent
VISUAL regression: a translated or lowercase-authored art banner renders as
missing-glyph boxes and every gate stays green. Watched red by deleting the
provider (`uppercase + lowercase over one atlas`); binding count asserted at all
26 letters, not a subset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
…osts (spec-0029) The resource pack stops being optional dressing and becomes the language carrier, so `SKINS.md` — the note the packaging task reads while wiring `RESOURCE_PACK` — now names the languages the delve ships and states plainly that a player who declines the prompt reads English and can still finish the delve. Host-facing prose only: which languages, what declining does. No key scheme, no pipeline (CLAUDE.md audience separation). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
|
Planner review: approved on content, HELD for the owner's playtest batch (engine emission — every campaign's bytes move, so this is hers to clear, not mine). The thing that makes this PR goodYou did not close the central risk the way the spec described it. The spec said "enumerate every emission site and prove each one lands in a component" — and you correctly refused that, because an enumerated site list rots the moment someone adds a site. That is the exact defect class this repo has paid for repeatedly: hand-rolled walks that named 3 of 5 effect roots and were right until they weren't. Instead the key travels in-band with the string, and an emitter either lowers it or explicitly reads it as a named exclusion. A surviving tag in the finished tree is The proof that it works: 17 real sites on first run, and the three false hits on prefab
Judgment calls I agree with
The three calls, answered
Why it is held rather than mergedEvery campaign's emitted bytes change. That is expected and you showed determinism holds — but "the bytes are deterministic" is not "the delve reads correctly to a person in the language they chose." Only she can clear that, and this pairs naturally with the island v1.1 release it exists to enable. |
Implements spec-0029 — i18n v2: the client picks the language (owner directive 2026-08-06).
Depends on the spec PR (
docs/spec-0029-i18n-v2) landing; nothing here duplicates that file.nobodys-cave-island v1.0.0shipped Chinese-only, becausebuild --lang zh-cnswaps everyplayer-visible string before compiling: the datapack carries exactly one language and there is
no fallback path at all. This makes a delve carry all of them.
The emission shape
Every authored player-visible string becomes a text component carrying its l10n key and its
English source; one
assets/delvewright/lang/<mc_code>.jsonper declared language, plusen_us.json, goes into the resource pack the release already ships.Real before/after,
nobodys-cave-island,function/campaign_complete.mcfunction:Note the literal concatenation
{title} — complete.splitting into two components: a translatekey cannot carry a suffix. Same shape in SNBT (
CustomName:{fallback:"The Keeper",translate:"npc.keeper.name"})and in dialogs (
"label": {"fallback":"Who are you?","translate":"dlg.keeper.greeting.opt.0.label"}).fallbackrides the component, not the pack'sen_us.json— deliberately, and this is theone call not to "simplify". A player who declines the resource-pack prompt has no lang files at
all; a design leaning on pack-level fallback fails exactly that player.
Three things kept this cheap, all confirmed before designing anything:
each_stringalreadyvisits every player-visible string under a stable key across all five effect roots (that is the
key set — no new scheme, no new inventory);
resourcepack::build_pack(skins, extra)alreadytakes arbitrary extra assets (lang files are a caller change);
DW0330/DW0331alreadywidth-check source and every declared translation — now load-bearing rather than
belt-and-braces, since any declared language may be what a player sees.
The risk, closed as an invariant rather than an audit
An authored string that does not land in a text component cannot carry a translate key.
Enumerating the emission sites once and trusting the list is exactly the kind of proof that
rots. Instead:
dsl::l10n::tag_translatablesrewrites each inventoried string to<U+E000><key><U+E000><English>once, before
Plan::build. An emitter either lowers it throughemit::tr/emit::snbt_component,or reads it through
dsl::l10n::plain. A tag still present in the finished tree is a site thatdid neither —
DW0185, build-tier, feature-blind, run last over the finished output besideDW0497, so it guards emitters not yet written. The scan is total: a file that is neitherUTF-8 text nor a classified verbatim binary output (
.nbt,.png,resourcepack.zip) alsofails, so a new binary artifact cannot quietly opt out.
DW0183reserves the private-use block in authored and translated content, so no string canforge or shadow a tag.
DW0184rejects a declared language with no Minecraft lang-file code —never a silently dropped language.
Named exclusions (also in
compiler.md§2)emit::artifact_titlepacktest-datapack/**#>test descriptionsemit::emit_packtest(dialogue-visibility test)v04_dialogue_visibility.mcfunctionrender_plan::{npc_name, area_name_of, first_clause}+ the NPC shotexpectrender-plan.jsoncritical-path.json,validation/*.json,combat-plan.jsonandmanifest.jsoncarry ids, neverauthored prose — the bot contract was already language-neutral, so they need no exclusion.
validate,analyze,l10n-inventory,snapshotandeditnever see a tag: tagging happensinside
build, after validation.One design consequence worth flagging
emit_narrateused toto_ascii_uppercase()anart-styled narrate — a transform a{"translate": …}component cannot express, because the client resolves the lang file after thecompiler is gone. Rather than bake case into content or into the lang values (which would break
AC2), the
delve:artfont gained a second bitmap provider over the same atlas addressed by thelowercase letters. A lowercase letter renders through its uppercase bitmap: identical pixels,
now reachable by translation. Cells with no lowercase form use vanilla's unused-cell marker
(
U+0000), so no character is claimed twice.Acceptance criteria — spec-0029, with binding counts
All in
crates/compiler/tests/i18n_v2.rs, driven through the shippeddelvec buildpath(a hand-assembled
Planemits the same components with literal bodies and would prove nothingabout delivery). Every set states its binding; a zero binding fails.
["zh-cn"]campaign emits exactlyen_us.json+zh_cn.json, key sets equalac1_a_declared_language_ships_its_lang_file_beside_english; the emitter re-proves equality over the bytes it is about to write (DW0180/DW0181)en_us.jsonvalue equals the English source, compared against a fresh inventory, not a fixtureac2_english_lang_file_is_the_live_inventory—en_us.jsonisdsl::l10n::inventoryof the stage docsac3_ac4_…walks every fixture's tree for"text":"<English>"outside the exclusion table;DW0185proves the complementary halffallbackequal to the English source; binding printed; zero examined = failureac3_ac4_…+every_translate_component_carries_its_own_fallback(JSON and SNBT forms)ac5_an_unmappable_language_is_dw0184;DW0184at validate (exit 1), message nameskl-ingonandmc_lang_codeac6_double_build_is_byte_identical_lang_files_included; lang maps areBTreeMaps inside the existing STORE zipunboundstated explicitlyHarness / PackTest audit — measured, not assumed
displayNameOf(harness/src/executor.ts) reads an entity's custom name to prefer amongsame-type candidates. A translate component has no
textfield, so the old reader would havefallen through to
toString()— whose result depends on whether the installed prismarine-chatresolves an unknown key to its fallback or renders the raw key. It now reads
fallbackexplicitly and first: that is by construction the English the plan's
actors[].namecarries,so the heuristic keeps matching instead of depending on a library detail. Pinned by a new unit
test including the "toString returns the raw key" case.
Per the spec this is measured, not assumed:
findActorBodycounts everycandidate-preference decision, how many had a usable name, and the candidate totals, and the run
report emits
always — including the all-zero shape with
unbound: true, which is a finding, not a pass. Adrop to
with_usable_name: 0with non-zero decisions is the specific regression to watch.This PR does not run the bot ladder (tier 3, release candidates only), so no live decision
count exists yet; the field is what makes the next run answer the question rather than assume it.
PackTest: 111 assert lines across 41 generated tests on the island — zero assert on
rendered text (assert lines mentioning
translate/fallback: 0). The only text component in agenerated PackTest is a setup line (
collect_container.mcfunctionpre-loads the stack theobjective counts), emitted by the same helper the datapack uses so the two cannot drift; and
adjudication matches on item id, never on name.
Expected scope: every campaign's bytes change
Literals become components, so every campaign's emitted bytes move. That is the change, not a
regression. What is proven unchanged is the property that matters: the double-build
byte-identity gate passes, on keep-trial in CI and on
nobodys-cave-islandby hand (609 files,diff -rclean). Two smaller intended shifts: a--langbake now ships noresourcepack.zip/SKINS.md(there is nothing to select between), and everyl10n/<code>.jsonis now a build input of every build and is hashed into
manifest.json— the sidecar's bytes shipin the pack, so they are as much an input as a stage document.
The reds each new gate was watched producing
A gate never watched failing is not known to work.
DW0185, on real emitters. First run over hello-world: 13 files — the campaign-completeadvancement description, three dialog
labels, the completiontellraw, a kit item'scustom_name, the NPC summon'sCustomName, five PackTest descriptions andrender-plan.json.keep-trial then produced 4 more, including three false hits on prefab
.nbtpayloads — whichis how the scan learned to classify verbatim binary outputs instead of guessing at
from_utf8_lossy. All 17 are sites this PR fixed; the count is now 0.DW0185, on a synthetic tree (dw0185_catches_an_authored_string_emitted_as_a_literal):a clean tree passes, one un-lowered tagged string fails naming key + artifact, and an
unclassified non-UTF-8 output fails rather than skipping the scan. Driven against the checker
directly, because a red that needs a defective emitter to exist is a red nobody can re-run.
DW0183— watched red twice: an authoredworld.titlecarrying U+E000, and azh-cn.jsonvalue carrying U+E001. Both exit 1.
DW0184— watched red onlanguages: ["kl-ingon"](exit 1, names the code and the table),and confirmed not firing for
pt-br, which is mapped.area.keep.namefrom the sidecar goes red asDW0180atvalidate. Which means the emit-time key-equality re-proof is unreachable from the CLI: it is
defence-in-depth for library callers of
emit::build, the same standing this file's other"callable without validation" checks have. Stated rather than claimed as coverage.
marker_name_fields_never_leak_a_raw_id(an untagged CustomName keeps the bare quoted-stringform; the compound is only for tagged strings);
the_rest_dialog_offers_exactly_two_optionsand two
v08_option_tooltiptests (dialog labels/tooltips are components now, so a fixture'scompiler-baked English reads
["label"]["text"]);lang_build_localizes_only_strings…, whichnow asserts positively that the default build ships the language carrier and a
--langbakedoes not.
call_graph_integritywent red because keep-trial's build now legitimately requiresits sidecar among the inputs — fixed by passing real inputs, never by relaxing the requirement.
Two findings surfaced by the binding counts (neither a blocker, neither fixed here)
1. Compiler chrome has no l10n key at all
Walking the island's emitted datapack for surviving
"text"literals leaves exactly themachine marker channel plus 12 compiler-owned player-visible strings. Five of them already
have an author override that translates when authored —
world.boundary.message, aclose-gate'ssealed_hint, and a bonfire'sprompt/rest_label/save_label. Eight haveno override and no key, so they are English in every language:
New objective:,Objective complete:,— complete.,A Delvewright delve.,Delve Complete,Choose your class,Pick the kit you will carry.,Waiting for the party —.This is not a regression — a
--lang zh-cnbake left them English too, since they neverpass through a
Campaignfield and so were never in the inventory. But v2 makes shipping everylanguage the normal case, so a Chinese player now reads Chinese content wrapped in English
chrome, and that is newly conspicuous rather than newly broken. The fix is cheap now that the
delivery exists (compiler-owned keys under a reserved prefix, with translations the compiler
ships in every lang file), but it adds keys no campaign sidecar covers — an l10n-surface
decision, so it is flagged, not taken.
2. Twelve inventoried keys reach no player
Island: 419 of 431 inventoried keys are emitted as components. 12 never reach a player at
all —
area.island.name,area.open-sea.name, and all 10quest.<q>.goals. Area names areconsumed only by
render-plan.json; a quest goal is emitted only as the campaign-completedescription, and only when
world.outrois unauthored (the island authors one). This ispre-existing — under v1 those strings were swapped into a campaign that never emitted them
either — but it means a translator is asked for 12 strings no player reads. Trimming the
inventory changes every campaign's sidecar coverage, so it is an owner-facing decision, not a
drive-by.
Out of scope, needs a content-repo PR
release.yml'slangstep — the one readinglanguages[0]as "the primary authored language"and passing
--lang— lives in delvewright-campaigns, not here. Deleting it (spec-0029Consequences) and re-releasing the island as v1.1 is a separate PR in that repo. This engine is
ready for it:
delvec buildwith no--langis now the release build.Residual risk identified, deliberately not acted on
A
%in a player-visible string is a format specifier inside atranslatecomponent, where itwas an ordinary character inside
{"text":…}. Zero occurrences today — checked across all 4campaigns and all 5 fixtures. I did not add a gate: the exact degradation path needs a live-client
observation I cannot make offline, and inventing a diagnostic on speculation is unrequested
change. Proposal, one line, awaiting a call: add a validate-tier warning when a player-visible
string or translation contains
%, telling the author to write%%.Where the fallback path is already exercised
validation/never setsRESOURCE_PACK, and the emittedserver/server.propertiescarries noresource-pack lines — so the whole validation ladder already runs with no pack at all. Every
green tier-2 run is therefore a run of exactly the AC8 scenario: no lang files, every string
rendered from the
fallbackon its own component. The lang files themselves are proven by theunit/CLI tier, which is where they belong.
CI
All ten required jobs green on this branch, including tier 2 (datapack load + the whole
generated PackTest suite) on a live pinned server and the mecha cross-check, which
independently re-validates every emitted mcfunction — i.e. the new JSON and SNBT component forms
parse for an implementation that is not this compiler.
cargo fmt --check,clippy -D warnings,cargo test --workspacegreen; harnesstypecheckgreen, 377 harness tests green;
check-dw-codes.py(210 codes, both directions),check-doc-dupes.py,check-required-contexts.py,check-skill-version.py,check-storybook-version.py,check-harness-dsl-version.py,check-compose-isolation.py,check-shell-pipe-shortcircuit.py,check-versions.sh,check-world-settings.shall greenlocally.
docs/reference/compiler.md(delivery section, named-exclusion table,DW0183/DW0184/DW0185),docs/reference/i18n.md,docs/reference/tools.mdand the/new-delveskill are updated in this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL