"""Translate WolfDawn extraction JSON. This module consumes the JSON produced by the vendored WolfDawn ``wolf`` CLI (``strings-extract`` / ``names-extract``) and fills in the ``text`` fields with translations, leaving ``source`` untouched so WolfDawn's inject drift-guard can match each line back to its original. Supported document ``kind``s (all share the ``{source, text}`` leaf pattern): map / common -> scenes[].lines[] db -> groups[].lines[] gamedat -> lines[] txt -> lines[] txt-dir -> files[].lines[] names -> names[] Only entries whose ``source`` contains target-language (Japanese by default) text are sent to the model; everything else keeps ``text == source`` so inject is a no-op for it. names.json safety: WolfDawn's ``names-extract`` groups every value name under a ``note`` category, and many categories are logic keys (variable / file / event names) that break the game if translated. So for ``kind == "names"`` documents, only entries whose ``note`` is in the opt-in safe list (``SAFE_NOTES``, from ``data/wolf_safe_notes.json`` via :mod:`util.wolf_names`) are translated; the rest are left as source. The list is empty by default, so nothing is translated until the user opts categories in from the workflow. Text wrapping: translated dialogue is re-wrapped to a character width (like the RPGMaker module, via :mod:`util.dazedwrap`) so English fits WOLF's message box. Wrapping is applied to the dialogue *body* only - a speaker's nameplate line (the ``\n`` right after a name) is kept on its own line and never folded into the body. Configurable from the workflow (``.env``: ``wolfWrap`` / ``wolfWidth``); a width of 0 (or ``wolfWrap=false``) writes the model output back verbatim. Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker name is baked into line 1 of ``source``. Those lines are reshaped into the shared ``[Speaker]: line`` convention (which the prompt already translates) and restored to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``. Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a per-game, AI-recommended setting from the workflow. """ import json import os import re import threading import time import traceback from colorama import Fore from tqdm import tqdm import util.dazedwrap as dazedwrap from util.paths import PROMPT_PATH, VOCAB_PATH from util.translation import ( TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, ) from util import speakers as wolf_speakers from util import wolf_names # Globals (mirror the other engine modules; populated from .env at import time) MODEL = os.getenv("model") TIMEOUT = int(os.getenv("timeout")) LANGUAGE = os.getenv("language").capitalize() PROMPT = PROMPT_PATH.read_text(encoding="utf-8") VOCAB = VOCAB_PATH.read_text(encoding="utf-8") LOCK = threading.Lock() MAXHISTORY = 10 ESTIMATE = "" TOKENS = [0, 0] MISMATCH = [] # Files that hit a length-mismatch during translation FILENAME = None # Regex - default matches Japanese (kanji, kana, full-width forms). LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]+" # Speaker handling: for first-line-speaker formats, reshape the line into the # shared "[Speaker]: line" transport before translating and restore WOLF's # native "Speaker\nline" layout on write-back. Which formats are reshaped is # configurable from the workflow (data/wolf_speakers.json). SPEAKER_CONFIG = wolf_speakers.load_config() # names.json safety: WolfDawn tags every value name with a ``note`` category. Many # categories are logic keys (variable/file/event names) that break the game if # translated, so only entries whose ``note`` is in this opt-in list are sent to # the model; the rest keep text == source. Empty by default = translate nothing. # Configured from the workflow (data/wolf_safe_notes.json). SAFE_NOTES = wolf_names.load_safe_notes() # Text wrapping: rewrap translated dialogue to a character width the same way the # RPGMaker module does (util.dazedwrap), so English lines fit WOLF's message box. # Only the dialogue *body* is wrapped - the speaker's name line (the "\n" after a # nameplate) is kept on its own line and never merged into the body. Configurable # from the workflow (.env: wolfWrap / wolfWidth). Width <= 0 disables wrapping. WRAP = (os.getenv("wolfWrap", "true").strip().lower() == "true") try: WRAPWIDTH = int(os.getenv("wolfWidth") or 0) except ValueError: WRAPWIDTH = 0 # Pricing / batching from the configured model PRICING_CONFIG = getPricingConfig(MODEL) INPUTAPICOST = PRICING_CONFIG["inputAPICost"] OUTPUTAPICOST = PRICING_CONFIG["outputAPICost"] BATCHSIZE = PRICING_CONFIG["batchSize"] FREQUENCY_PENALTY = PRICING_CONFIG["frequencyPenalty"] # tqdm / progress globals (PBAR is polled by util/subprocess_runner.py) BAR_FORMAT = "{l_bar}{bar:10}{r_bar}{bar:-10b}" POSITION = 0 LEAVE = False PBAR = None TRANSLATION_CONFIG = TranslationConfig( model=MODEL, language=LANGUAGE, prompt=PROMPT, vocab=VOCAB, langRegex=LANGREGEX, batchSize=BATCHSIZE, maxHistory=MAXHISTORY, estimateMode=False, ) def handleWolfDawn(filename, estimate): """Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'.""" global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES, SPEAKER_CONFIG ESTIMATE = estimate FILENAME = filename # Re-read workflow-configured settings so edits made this session take effect # even when translation runs in-process (the module import is cached). SAFE_NOTES = wolf_names.load_safe_notes() SPEAKER_CONFIG = wolf_speakers.load_config() start = time.time() translatedData = openFiles(filename) if not estimate: try: with open("translated/" + filename, "w", encoding="utf-8", newline="\n") as outFile: json.dump(translatedData[0], outFile, ensure_ascii=False, indent=4) except Exception: traceback.print_exc() return "Fail" end = time.time() tqdm.write(getResultString(translatedData, end - start, filename)) with LOCK: TOKENS[0] += translatedData[1][0] TOKENS[1] += translatedData[1][1] totalString = getResultString(["", TOKENS, None], end - start, "TOTAL") if len(MISMATCH) > 0: return totalString + Fore.RED + f"\nMismatch Errors: {MISMATCH}" + Fore.RESET return totalString def openFiles(filename): """Load the extraction JSON, translate it in place, and return [data, tokens, error].""" with open("files/" + filename, "r", encoding="utf-8-sig") as f: data = json.load(f) kind = data.get("kind") if kind not in ("map", "common", "db", "gamedat", "txt", "txt-dir", "names"): raise NameError( f"{filename}: unrecognised WolfDawn document (kind={kind!r}). " "Expected a strings-extract or names-extract JSON." ) return parseDocument(data, filename) def collectEntries(data): """Return the list of leaf {source, text} dicts for a WolfDawn document. The returned dicts are live references into ``data`` so mutating ``text`` updates the document that gets written back out. """ kind = data.get("kind") entries = [] if kind in ("map", "common"): for scene in data.get("scenes") or []: for line in scene.get("lines") or []: entries.append(line) elif kind == "db": for group in data.get("groups") or []: for line in group.get("lines") or []: entries.append(line) elif kind in ("gamedat", "txt"): for line in data.get("lines") or []: entries.append(line) elif kind == "txt-dir": for fileDoc in data.get("files") or []: for line in fileDoc.get("lines") or []: entries.append(line) elif kind == "names": for name in data.get("names") or []: entries.append(name) return entries def _wrap_body(body): """Word-wrap a dialogue body to WRAPWIDTH (no-op when wrapping is disabled).""" if not WRAP or WRAPWIDTH <= 0 or not isinstance(body, str) or not body: return body return dazedwrap.wrapText(body, WRAPWIDTH) def _wrap_plain(text, is_firstline): """Wrap a non-reshaped entry, protecting a nameplate first line if present. ``is_firstline`` is True for first-line-speaker formats that are turned off in the config: their model output is still ``Speaker\\nbody``, so line 1 (the name) is kept intact and only the body is wrapped. Any leading ``@