| name | renpy-translation |
| description | Translate a Ren'Py visual novel game into another language. Use when the user wants to translate, localize, or build a translation/language patch for a Ren'Py game (.rpa, .rpyc, .rpy files), extract dialog strings from a visual novel, decompile Ren'Py archives with unrpa/unrpyc, set up character speech registers or a translation glossary for a game, or QA and deploy a fan translation patch.
|
Ren'Py Game Translation
Full workflow for translating a Ren'Py visual novel into any target language. Work in a dedicated project folder next to the game install, never inside it. The game itself is only ever read from — the finished patch is the single thing copied in, at game/tl/<language>/.
Pipeline
0. Init scaffold the project folder → scripts/init_project.py
1. Decompile .rpa archives → .rpy sources → references/decompiling.md
2. Extract .rpy sources → strings.json → references/extraction.md
3. Profile build profile.json + style guide → references/game-profile.md
4. Translate strings → translations.json → references/translating.md
5. QA technical + register checks → references/qa.md
6. Patch translations → game/tl/<lang>/ files → references/patching.md
7. Deploy copy patch into game, launch, verify → references/patching.md
Start a new project with python init_project.py --game <path> --language <id>: it creates the working folder beside the game with a wired-up profile.json, a style-guide skeleton, qa_rules.json, a .gitignore that keeps game text out of version control, and TRANSLATION.md — the project's own always-loaded instruction file, with harness stubs (CLAUDE.md/AGENTS.md/GEMINI.md) pointing at it. A translation runs for hundreds of batches across many sessions; rules that live only in chat history get re-broken within ten batches.
Read the reference file for a phase when you reach it — don't preload everything. For GUI text (screens, menus) extract with --screens; for text built dynamically in Python (phone/chat/feed subsystems) see references/custom-subsystems.md and scope it with the user early.
Beyond Ren'Py, beyond Claude Code
Ren'Py is this skill's primary purpose and the only shipped engine adapter — but only extract_strings.py, build_patch.py, and four reference docs actually know what Ren'Py is. Everything else (profile system, progress store, TM, QA, token parity) is engine-neutral. references/engine-seam.md states the two file contracts at that boundary, so another engine means writing two programs and changing nothing else.
Likewise, none of the tooling requires Claude Code. PLAYBOOK.md is the same workflow written for any assistant, and AGENTS.md at the repo root routes other agentic tools to it. When a user asks about running this outside Claude Code, point them at PLAYBOOK.md.
First decision: patch mechanism
Two ways to deliver a translation; choose before extracting:
- Runtime filter (default) — a
config.say_menu_text_filter hook plus an English→target dictionary, generated by scripts/build_patch.py (screen/UI strings ride along in a native translate strings: file). Survives game updates, coexists with mods (SanchoMod etc.), needs no Ren'Py SDK. Limitation: exact-match lookup, so one English string gets one translation everywhere it appears.
- Native
translate blocks — Ren'Py's official mechanism. Per-occurrence context (the same English line can translate differently in different scenes), but requires the matching Ren'Py SDK and breaks on game updates. See references/native-translate-blocks.md for when it's worth it.
Game profile and character records
Every game/language pair gets a profile: a profile.json (machine config: characters, glossary, fonts, paths, validation) plus a translation-guide.md (style guide: registers, formality, slang policy, few-shot examples). All pipeline scripts take --profile profile.json.
profile.json → speakers is the character dictionary. Beyond name/gender/role, a record can declare register, self_pronoun/address_pronoun, forbidden, must_use, speech_notes, examples, a monologue override for (...) inner thoughts, and a to map of per-relationship overrides. That last one matters most in register-rich languages, where the pronoun is a property of the pair, not the person — the same protagonist uses one pronoun with a close friend and another with an authority figure.
Each record feeds both ends: translate_api.py renders it as a persona card in the prompt, and qa_check.py turns the same forbidden list into a category-2 rule. A voice the prompt asks for and the gate doesn't check is worse than either alone. Every field is optional — a v1.3 profile with only name/gender/role produces exactly the old behavior.
Who a line is spoken to is resolved from evidence, or not at all. relationships.py answers the addressee question from three named tiers — a scope you declared in profile.json → relationships.declared, a name addressed in vocative position in the English line, or a Ren'Py label containing exactly two character speakers — and reports every other line as unresolved, with the reason. There is no "who spoke nearby" guess: a wrong addressee produces a wrong pronoun on a line that looks fine, which is wrong silently and everywhere.
Resolved lines carry "to": "<name>" into the translation prompt, fill the addressee slot in TM variants, and can be checked by category-3 QA rules generated from to[other].forbidden. Unresolved lines behave exactly as before: the whole to table goes in the prompt and the translator applies it. Run python relationships.py --profile profile.json after filling in the speakers — it reports coverage per tier, which resolved pairs still have no declared register, and which speaker codes have no character record at all. Raise relationships.min_confidence to refuse a tier you don't trust.
A character who speaks under more than one code ("???" before introduction) gets alias_of on the extra code — one record, shared QA rules, no duplicate cast entries.
- Spec and the interview process for building one:
references/game-profile.md
- Complete worked example (Being a DIK → Thai, 35+ characters):
examples/badik-thai/
When starting a new game, copy the example's structure, not its content.
Translation method — always ask first
Before starting the Translate phase, always ask the user which method to use. Never assume or silently default. Present these three options (recommend in-session):
-
In-session — recommended (best quality and throughput). Translate directly in this conversation, in batches of ~40–60 with speaker labels, following the game's translation-guide.md; results go into the progress JSON, then qa_check.py --technical-only after each session. No API key, and the style guide is loaded once per session rather than re-sent per batch — so it gets more strings done per unit of quota than the agent method. Protocol: references/translating.md (Path A).
-
API key — fast bulk first-pass. scripts/translate_api.py --profile profile.json machine-translates everything (resumable, token-validated). Providers: deepseek (preset — just needs DEEPSEEK_API_KEY), openai-compatible (OpenAI, OpenRouter, Ollama, Azure — selected by api.base_url, no SDK needed), gemini, or anthropic. A bulk pass is a draft: always follow with an in-session review driven by the QA report. Details: references/translating.md (Path B).
With a reasoning model, note that thinking tokens are drawn from the same max_tokens budget as the answer — a large prompt can come back empty. The deepseek preset disables thinking for this reason; empty replies are diagnosed with the real cause, retried once at a raised budget, and never saved.
-
Agent (claude-cli) — no API key, but lower throughput. Same bulk pass with provider claude-cli, which spawns headless claude -p agents on the user's existing Claude Code subscription. Works without a key, but each spawned agent re-pays its own context overhead, so it finishes fewer strings per unit of quota than in-session. Offer it mainly when no API key is available and the user prefers not to translate in-session. Details: references/translating.md (Path B).
Confirm the choice, then set api.provider in profile.json accordingly for methods 2–3.
Scripts
In Claude Code they live in ${CLAUDE_PLUGIN_ROOT}/skills/renpy-translation/scripts/; anywhere else, in the scripts/ folder beside this file. Python 3.8+, cross-platform, UTF-8, stdlib-only:
| Script | Purpose |
|---|
init_project.py | scaffold a project folder (--game, --language, --name, --dir, --force) |
extract_strings.py | .rpy sources → strings.json (--src, --out, --no-dedupe, --screens for GUI/_() strings) |
translate_api.py | bulk translation → progress JSON (--profile; providers: deepseek / openai-compatible / gemini / anthropic / claude-cli) |
qa_check.py | technical + declarative register checks (--profile, --technical-only, --report) |
build_patch.py | progress JSON → patch files (--profile, --translations, --strings, --out, --split-size) |
translation_memory.py | Translation Memory cache + tooling (stats / export / import / clean, all --profile) |
relationships.py | addressee resolution + the who-speaks-to-whom report (--profile, --strings, --report) |
characters.py | character records → persona cards + QA register rules (library, no CLI) |
validation.py | shared output-validity gate + atomic writer used by the others (library, no CLI) |
profile_schema.json | JSON Schema documenting profile.json |
Translation Memory (TM)
A durable source→translation cache at .ftp/translation_memory.json (project-local by default; set profile.json → tm.path to a shared file to reuse translations across games). translate_api.py consults it before every LLM call and writes new translations back, so repeated lines — within a game, and on later game updates — never hit the model twice. It seeds itself from any existing translations.json on first run, and prints a savings summary (TM Hits / Context Hits / Default Hits / LLM Calls / Savings %) at the end of a bulk pass.
- Exact + normalized matching (whitespace/CRLF-insensitive, but case- and token-sensitive). Every non-exact hit is re-validated for token preservation against the new source. Fuzzy matching is a v2 feature (
lookup_fuzzy is a no-op).
- Contextual variants (v1.3, schema v2). One source key holds a
default_translation plus context-keyed variants, so the same English line can resolve differently per speaker. Selection is deterministic (no AI): variants are scored +100 speaker · +50 target · +25 scene; the highest wins (ties → count, then recency, then insertion order); a missing speaker or no match falls back to the default. file/line are stored as metadata but never drive selection; confidence/context_hash are reserved for future use. Since v1.5 the target slot is filled by addressee resolution where a line resolves, so variants key on the pair rather than the speaker alone. v1 TM files migrate transparently on load (old translation → default_translation).
- Where context variants help — and the boundary. Variants give the translator zero-cost, speaker-consistent retrieval (re-runs, game updates, shared cross-game TMs). They do not, on their own, make the runtime patch emit different renderings of one line:
translations.json and the say-filter dict (build_patch.py) are keyed by source text only. Per-occurrence runtime output needs the native translate blocks workflow with --no-dedupe (see references/native-translate-blocks.md).
- The engine only ever talks to the
TranslationMemory class — it never opens the JSON directly.
- Commands (conceptually
ftp tm <action>; run as plain scripts here):
python translation_memory.py stats --profile profile.json ·
… export --profile profile.json --out tm.csv ·
… import --profile profile.json --in tm.csv ·
… clean --profile profile.json
- The re-translation trap. Deleting keys from
translations.json does not force re-translation — the TM resolves hits before batching and puts the old value straight back with no model call. Run with tm.enabled: false to force it. (clean only drops empty/duplicate entries.) Entries that echo their source or miss the target script rejected on lookup automatically, so a TM poisoned by an older run self-heals.
Hard rules (always apply)
- Token preservation is non-negotiable. Every
[variable], {tag}, \n/\" escape, and %% in the English must appear in the translation, identical and in the same count. A missing [mc_name] or {/i} crashes or corrupts rendering; a duplicated one is just as broken. qa_check.py --technical-only checks all four token classes by multiset — run it before any patch build.
- A translation identical to its source, or containing none of the target script, is not a translation. It is refused before it can reach the progress store or the Translation Memory, and reported as category 1 by QA. Sources whose only translatable content is a kept proper noun, a variable, a tag, or punctuation are exempt automatically; for anything else that genuinely should stay identical, use
validation.allow_identical.
- Never modify game source files. No edits to the game's
.rpy/.rpyc/.rpa. The patch lives entirely in game/tl/<language>/ and is removable by deleting that folder.
- Never translate code-like strings: label names, image/audio names, transform names, screen ids. The extractor filters most of these; when in doubt, skip and flag.
- Inner monologue markers stay. Text wrapped in
(...) (or the game's own convention) keeps its wrapper in the translation.
- Respect the profile's keep-untranslated list (character names, proper nouns, branded terms) exactly — no transliteration unless the profile says so.
- Don't sanitize. Preserve the source register: crude stays crude, formal stays formal, per the game profile.
- Copyright: never commit or publish game scripts, extracted strings, translation dictionaries of game text,
.rpa files, or commercial fonts. Tools and style guides only.