diff --git a/modules/rpgmakermvmz.py b/modules/rpgmakermvmz.py index 0848a43..2f7abd5 100644 --- a/modules/rpgmakermvmz.py +++ b/modules/rpgmakermvmz.py @@ -1,4838 +1,5108 @@ -# Libraries -import json -import os -import re -import util.dazedwrap as dazedwrap -import threading -import time -import traceback -import copy -from pathlib import Path -import shutil -from colorama import Fore -from dotenv import load_dotenv -from retry import retry -from tqdm import tqdm -from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost, get_var_translation, set_var_translations_batch - -# Globals -MODEL = os.getenv("model") -TIMEOUT = int(os.getenv("timeout")) -LANGUAGE = os.getenv("language").capitalize() -PROMPT = Path("prompt.txt").read_text(encoding="utf-8") -VOCAB = Path("vocab.txt").read_text(encoding="utf-8") -LOCK = threading.Lock() -THREAD_CTX = threading.local() -WIDTH = int(os.getenv("width")) -LISTWIDTH = int(os.getenv("listWidth")) -NOTEWIDTH = int(os.getenv("noteWidth")) -MAXHISTORY = 10 -ESTIMATE = "" -TOKENS = [0, 0] -MISMATCH = [] # Lists files that throw a mismatch error (Length of GPT list response is wrong) -PBAR = None -FILENAME = None -TIMETOTAL = 0 # Total Time Taken for all translations -VOCAB_LOCK = threading.Lock() -PREFLIGHT_COUNT_MODE = False # When True, translateAI wrapper only counts units and never calls API - -# Speakers -NAMESLIST = [] -SPEAKER_PARSE_MODE = False -_speakerCache = {} -_speakerCacheLock = threading.Lock() -SPEAKER_COLLECTED = [] # Original speaker names collected during parse mode (untranslated) - -# Actor variable substitution (\n[X] -> name before AI, name -> \n[X] after) -_ACTOR_MAP_CACHE: dict | None = None -_ACTOR_MAP_CACHE_LOCK = threading.Lock() -_VAR_ACTOR_RE = re.compile(r"\\n\[(\d+)\]", re.IGNORECASE) - -# Regex - Need to change this if you want to translate from/to other languages. Default is Japanese Regex -LANGREGEX = r"[\u3000\u3002-\u3009\u300C-\u303F\u3040-\u309A\u309C-\u30FA\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]+" - -# Get pricing configuration based on the model -PRICING_CONFIG = getPricingConfig(MODEL) -INPUTAPICOST = PRICING_CONFIG["inputAPICost"] -OUTPUTAPICOST = PRICING_CONFIG["outputAPICost"] -BATCHSIZE = PRICING_CONFIG["batchSize"] -FREQUENCY_PENALTY = PRICING_CONFIG["frequencyPenalty"] - -# tqdm Globals -BAR_FORMAT = "{desc}: {percentage:3.0f}%|{bar:10}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]" -POSITION = 0 - -# Initialize Translation Config -TRANSLATION_CONFIG = TranslationConfig( - model=MODEL, - language=LANGUAGE, - prompt=PROMPT, - vocab=VOCAB, - langRegex=LANGREGEX, - batchSize=BATCHSIZE, - maxHistory=MAXHISTORY, - estimateMode=False # Will be set dynamically based on ESTIMATE -) -LEAVE = False - -# Config (Default) -# FIRSTLINESPEAKERS: Guess speaker from first line. -FIRSTLINESPEAKERS = False -# INLINE401SPEAKERS: Extract speaker from "Name「dialogue」" inline format on 401 lines. -INLINE401SPEAKERS = False -# FACENAME101: Map face name -> speaker. -FACENAME101 = False -# Face name -> speaker mapping for FACENAME101. -# Matching: if face string contains "_talk", split on it and look up the prefix; -# otherwise try startswith against each key (longest key first). -FACENAME101_MAP = { - "aglo": "Agro", - "Ai": "AI", - "cron": "Cron", - "diado": "Diad", - "doctor": "Doctor", - "dragon": "Dragon", - "dragonpeaple": "Dragonpeople", - "Eno": "Eno", - "fight": "Fight", - "kajua": "Kajua", - "last_boss": "Last Boss", - "MC": "MC", - "mizel": "Mizel", - "peaple": "People", - "professor": "Professor", - "ReceptionWoman": "ReceptionWoman", - "risa": "Risalue", - "roma": "Romasha", - "romasha": "Romasha", - "spina_dragonewt": "Spina Dragonewt", - "spina": "Spina", - "supi": "Supi", - "TMob": "TMob", - "TMobBlue": "TMobBlue", - "TMobGreen": "TMobGreen", - "TMobOrange": "TMobOrange", - "TMobPink": "TMobPink", - "TMobsyota": "TMobsyota", - "TMobYellow": "TMobYellow", - "TMobZERO": "TMobZERO", - "Trash": "Trash", - "underpeaple": "Underpeople", - "vanila": "Vanilla", - "Yudo": "Yudonge", - "zizi": "Zizi", -} -# Pre-sorted by key length descending so longer prefixes match first. -FACENAME101_MAP_SORTED = sorted(FACENAME101_MAP.items(), key=lambda x: len(x[0]), reverse=True) -# BRFLAG: Newlines ->
. -BRFLAG = False -# FIXTEXTWRAP: Rewrap text to WIDTH/NOTEWIDTH. -FIXTEXTWRAP = True -# IGNORETLTEXT: Skip Translated Text. -IGNORETLTEXT = False -# TLSYSTEMVARIABLES: Translate System Variables. (Optional but sometimes necessary. Can break stuff.) -TLSYSTEMVARIABLES = False -# TLSYSTEMSWITCHES: Translate System Switches. (Optional. Translates switch names in System.json.) -TLSYSTEMSWITCHES = False -# Join 408 codes into a single string like 401. -JOIN408 = False - -# Dialogue / Scroll / Choices (Main Codes) -CODE101 = True -CODE401 = True -CODE405 = True -CODE102 = True - -# Optional -CODE408 = False - -# Variables -CODE122 = False -CODE122_VAR_MIN = 0 -CODE122_VAR_MAX = 2000 - -# Plugins / Scripts -CODE355655 = False -CODE357 = False -CODE657 = False -CODE356 = False -CODE320 = False -CODE324 = False -CODE325 = False -CODE111 = False -CODE108 = False - -# ─── Plugin Manager ────────────────────────────────────────────────────────── -# All known code-357 headerMapping entries. Enable entries via ENABLED_PLUGINS_357. -# The GUI reads this dict to build the checkbox list dynamically. -HEADER_MAPPINGS_357 = { - "LL_InfoPopupWIndow": (["messageText"], None), - "QuestSystem": (["DetailNote"], None), - "BalloonInBattle": (["text"], None), - "MNKR_CommonPopupCoreMZ": (["text"], None), - "DestinationWindow": (["destination"], None), - "_TMLogWindowMZ": (["text"], None), - "TorigoyaMZ_NotifyMessage": (["message"], None), - "SoR_GabWindow": (["arg1"], None), - "DarkPlasma_CharacterText": (["text"], None), - "DTextPicture": (["text"], None), - "TextPicture": (["text"], None), - "TRP_SkitMZ": (["name"], None), - "LogWindow": (["text"], None), - "BattleLogOutput": (["message"], None), - "TorigoyaMZ_NotifyMessage_CommandMessage": (["message"], None), - "NUUN_SaveScreen": (["AnyName"], None), - "build/ARPG_Core": (["Text", "SkillByName"], None), - "EventLabel": (["text"], None), - "KN_MapBattle": (["enemyName"], None), - "KN_Shop": (["goodsType"], None), - "KN_StillManager": (["label"], None), # OPEN_GALLERY category label in parameters[3] - "Mano_CurrencyUnit": (["unit"], None), - "SceneGlossary": (["category"], None), -} -# Subset of HEADER_MAPPINGS_357 keys that should be processed (empty = none). -ENABLED_PLUGINS_357: set = set() - -# All known code-355/655 script patterns. Enable entries via ENABLED_PATTERNS_355655. -PATTERNS_355655 = { - "テキスト-": (r"テキスト-(.+)", False), - "=": (r'=\s?(.*)",', False), - "var text": (r'var\stext\d+\s=\s\"(.+)\"', False), - "logtxt = ": (r"logtxt\s=\s'(.+)'", False), - ".setNickname": (r'.setNickname\(\\?"(.+?)\\?"\)', False), - "_subject=": (r'_subject=(.+?)(?=[_\\"\]])', False), - "text =": (r"text\s*=\s*'(.+[^\\])'", False), - "const text": (r'(const\stext\s?=\s?"(.+)";?)', False), - "ex_a_name": (r'ex_a_name\(\d+,"(.+)"\)', False), - "gameVariables.setValue": (r'\$gameVariables\.setValue\(\d+,\s*"([^"]*)"\)', False), - "$gameVariables._data": (r"\$gameVariables\._data(?:\[[^\]]+\])+\s*=\s*['\"]((?:\\.|[^'\"\\])*)['\"]", False), - "$gameMessage.add": (r"\$gameMessage\.add\(.+?\)(.+?)", True), - "BattleManager._logWindow.push('addText'": (r"BattleManager._logWindow.push\('addText',\s'(.+)'\)", False), - # Supports addText('msg'), addText("msg"), and addText(expr+'msg') where expr contains () e.g. .members() - "BattleManager._logWindow.addText": ( - r"BattleManager\._logWindow\.addText\(\s*(?:(?:[^()]|\([^)]*\))*\+\s*)?(['\"])((?:\\.|(?!\1).)*)\1\s*\)", - True, - ), - "let out": (r"let\s+out\d+\s*=\s*\(.+?\)(.+?)", True), - "moji": (r"(?:let\s+)?moji\s*\+?=\s*(.+)", True), - "this.BLogAdd": (r'this\.BLogAdd\?(.+?\\?"(.+?)\\?"\)', False), - "Fuki_Set": (r'Fuki_Set\([\s,\d\w\W]+?"(.+?)",', False), - "_EventSetting": (r'_EventSetting[\s,\d\w\W]+?"(.+?)";', False), - "this.Menu_SexTxtSet(": (r'"(.+)"', True), - "Rn_RsltTxtArr": (r'"(.+)"', True), - "_章切り替えStart": (r'_章切り替えStart\(\s*\\?"\s?,?.+?\\?"\s?,?\s?\\?"(.+?)\\?"', False), - "SkillLogAdd": (r'SkillLogAdd\((?:.+?\+\s*)?\\?"(?:\\\\+[A-Za-z]\[\d+\])?(.+?)\\?"', False), - "MobNameSet": (r'MobNameSet\(\\?"(.+?)\\?"\)', False), - "AddAddress": (r'AddAddress\(\d+,\s*\\?"(.+?)\\?"', False), -} -# Subset of PATTERNS_355655 keys that should be processed (empty = none). -ENABLED_PATTERNS_355655: set = {"BattleManager._logWindow.addText"} - - -def _pat355655_captured_text(match): - """Substring to translate for PATTERNS_355655; last capture group is always the visible text.""" - return match.group(match.lastindex) - - -def handleMVMZ(filename, estimate): - global ESTIMATE, TOKENS, FILENAME, MISMATCH - ESTIMATE = estimate - FILENAME = filename - MISMATCH = [] # Reset per-file; prevents cross-file contamination in CLI mode - # Also record per-thread filename to avoid cross-thread interference - try: - THREAD_CTX.filename = filename - except Exception: - pass - - # Translate - start = time.time() - translatedData = openFiles(filename) - - # Translate - # Skip writing output file during speaker-parse mode - if not estimate and not SPEAKER_PARSE_MODE: - 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" - - # Print File - end = time.time() - tqdm.write(getResultString(translatedData, end - start, filename)) - with LOCK: - TOKENS[0] += translatedData[1][0] - TOKENS[1] += translatedData[1][1] - - # Print Total - totalString = getResultString(["", TOKENS, None], end - start, "TOTAL") - - # Print any errors on maps - if len(MISMATCH) > 0: - return totalString + Fore.RED + f"\nMismatch Errors: {MISMATCH}" + Fore.RESET - else: - return totalString - - -def openFiles(filename): - with open("files/" + filename, "r", encoding="utf-8-sig") as f: - data = json.load(f) - - # Map Files - if "Map" in filename and "MapInfos" not in filename: - translatedData = parseMap(data, filename) - - # CommonEvents Files - elif "CommonEvents" in filename: - translatedData = parseCommonEvents(data, filename) - - # Actor File - elif "Actors" in filename: - translatedData = parseNames(data, filename, "Actors") - - # Armor File - elif "Armors" in filename: - translatedData = parseNames(data, filename, "Armors") - - # Weapons File - elif "Weapons" in filename: - translatedData = parseNames(data, filename, "Weapons") - - # Classes File - elif "Classes" in filename: - translatedData = parseNames(data, filename, "Classes") - - # Enemies File - elif "Enemies" in filename: - translatedData = parseNames(data, filename, "Enemies") - - # Items File - elif "Items" in filename: - translatedData = parseNames(data, filename, "Items") - - # MapInfo File - elif "MapInfos" in filename: - translatedData = parseNames(data, filename, "MapInfos") - - # Skills File - elif "Skills" in filename: - translatedData = parseNames(data, filename, "Skills") - - # Troops File - elif "Troops" in filename: - translatedData = parseTroops(data, filename) - - # States File - elif "States" in filename: - translatedData = parseSS(data, filename) - - # System File - elif "System" in filename: - translatedData = parseSystem(data, filename) - - # Scenario File - elif "Scenario" in filename: - translatedData = parseScenario(data, filename) - - else: - raise NameError(filename + " Not Supported") - - return translatedData - - -def getResultString(translatedData, translationTime, filename): - global TIMETOTAL - # File Print String - cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL) - totalTokenstring = ( - Fore.YELLOW + "[Input: " + str(translatedData[1][0]) + "]" - "[Output: " - + str(translatedData[1][1]) - + "]" "[Cost: ${:,.4f}".format(cost) - + "]" - ) - if filename != "TOTAL": - timeString = Fore.BLUE + "[" + str(round(translationTime, 1)) + "s]" - TIMETOTAL += round(translationTime, 1) - else: - timeString = Fore.BLUE + "[" + str(round(TIMETOTAL, 1)) + "s]" - - if translatedData[2] is None: - # Success - return filename + ": " + totalTokenstring + timeString + Fore.GREEN + " \u2713 " + Fore.RESET - else: - # Fail - try: - raise translatedData[2] - except Exception as e: - traceback.print_exc() - errorString = str(e) + Fore.RED - return filename + ": " + totalTokenstring + timeString + Fore.RED + " \u2717 " + errorString + Fore.RESET - - -def saveProgress(data, filename): - """Atomically write current data to translated/filename to avoid progress loss. - Skips when running in estimate mode. - """ - try: - # Also skip progress saves during speaker-parse mode - if ESTIMATE or SPEAKER_PARSE_MODE: - return - os.makedirs("translated", exist_ok=True) - # Use a unique temp file name to avoid collisions across threads/processes - tmp_path = os.path.join( - "translated", - f"{filename}.{os.getpid()}.{threading.get_ident()}.tmp", - ) - final_path = os.path.join("translated", filename) - with open(tmp_path, "w", encoding="utf-8", newline="\n") as outFile: - json.dump(data, outFile, ensure_ascii=False, indent=4) - outFile.flush() - try: - os.fsync(outFile.fileno()) - except Exception: - # fsync may not be available on some platforms; ignore best-effort - pass - - # Replace atomically when possible, with retries to mitigate transient locks on Windows - attempts = 6 - delay = 0.1 - last_err = None - for attempt in range(attempts): - try: - os.replace(tmp_path, final_path) - last_err = None - break - except PermissionError as e: - last_err = e - # Try to relax permissions on target if it exists, then back off - try: - if os.path.exists(final_path): - os.chmod(final_path, 0o666) - except Exception: - pass - time.sleep(delay) - delay = min(1.0, delay * 2) - except Exception as e: - last_err = e - break - if last_err is not None: - # Fallback: try move via shutil (not guaranteed atomic), then raise on failure - try: - shutil.move(tmp_path, final_path) - except Exception: - # Ensure tmp is cleaned up if move failed - try: - if os.path.exists(tmp_path): - os.remove(tmp_path) - except Exception: - pass - raise last_err - except Exception: - # Best-effort; don't crash the translation if saving fails - traceback.print_exc() - - -def _scalar_original(cmd) -> str | None: - """Return scalar _original on an event command, or None if absent/empty.""" - orig = cmd.get("_original") - if orig is not None and not isinstance(orig, list) and str(orig).strip(): - return str(orig) - return None - - -def _param_source(cmd, index: int) -> str: - """Prefer scalar _original; else parameters[index] (401/405 dialogue lines).""" - orig = _scalar_original(cmd) - if orig is not None: - return orig - params = cmd.get("parameters") or [] - if index < len(params) and params[index] is not None: - return str(params[index]) - return "" - - -def _group_source(codeList, start: int, end: int) -> str: - """Join source text for a merged 401/405 group (indices start..end inclusive).""" - if start < len(codeList): - orig = _scalar_original(codeList[start]) - if orig is not None: - return orig - parts = [] - for idx in range(start, end + 1): - if idx >= len(codeList): - break - cmd = codeList[idx] - if not cmd or cmd.get("code") not in (401, 405, -1): - continue - params = cmd.get("parameters") or [] - if not params: - continue - src = _param_source(cmd, 0) - if src.strip(): - parts.append(src) - return "\n".join(parts) - - -def _group_raw_source(codeList, group_start: int, source_parts: list[str]) -> str: - """Batch source for merged 401/405; anchor _original wins on re-run.""" - if group_start < len(codeList): - orig = _scalar_original(codeList[group_start]) - if orig is not None: - return orig - return "\n".join(source_parts) - - -def _apply_original(cmd, raw_source: str) -> None: - """Set scalar _original only when not already present (re-run safe).""" - if not raw_source or not str(raw_source).strip(): - return - if _scalar_original(cmd) is not None: - return - cmd["_original"] = raw_source - - -def _choice_source(cmd, index: int) -> str: - """Prefer _original[index] for code 102 choices; else parameters[0][index].""" - orig_list = cmd.get("_original") - if isinstance(orig_list, list) and index < len(orig_list): - slot = orig_list[index] - if slot is not None and str(slot).strip(): - return str(slot) - params = cmd.get("parameters") or [[]] - choices = params[0] if params else [] - if isinstance(choices, list) and index < len(choices) and choices[index] is not None: - return str(choices[index]) - return "" - - -def _apply_choice_original(cmd, index: int, raw_source: str) -> None: - """Set _original[index] for code 102 only when that slot is empty.""" - if not raw_source or not str(raw_source).strip(): - return - params = cmd.get("parameters") or [[]] - choices = params[0] if params else [] - n = len(choices) if isinstance(choices, list) else 0 - orig_list = cmd.get("_original") - if not isinstance(orig_list, list): - orig_list = [None] * n - cmd["_original"] = orig_list - while len(orig_list) < n: - orig_list.append(None) - if index < len(orig_list): - existing = orig_list[index] - if existing is not None and str(existing).strip(): - return - orig_list[index] = raw_source - - -def _122_inner_source(cmd) -> str | None: - """Inner quoted value for code 122: _original or extract from parameters[4].""" - orig = _scalar_original(cmd) - if orig is not None: - return orig - params = cmd.get("parameters") or [] - if len(params) <= 4: - return None - jaString = params[4] - if not isinstance(jaString, str): - return None - if len(re.findall(r"([\'\"\`])", jaString)) >= 2: - matchedText = re.search(r"[\'\"\`](.*)[\'\"\`]", jaString) - if matchedText and matchedText.group(1).strip(): - return matchedText.group(1) - return None - - -def _101_name_source(cmd, is_var: bool) -> str: - """Speaker name field for code 101: _original or parameters[4]/[0].""" - orig = _scalar_original(cmd) - if orig is not None: - return orig - params = cmd.get("parameters") or [] - if is_var and len(params) > 0 and params[0] is not None: - return str(params[0]) - if not is_var and len(params) > 4 and params[4] is not None: - return str(params[4]) - return "" - - -_COLOR_SPEAKER_RE = re.compile( - r"^[\\]+[cC]\[\d+\]【?(.+?)】?[\\]+[cC]\[\d+\](?:[\\]+[A-Za-z]+(?:\[[^\]]*\])?)*[\\]*$" -) - - -def _replace_speaker_in_param(param_str: str, source_name: str, translated_name: str) -> str: - """Replace a speaker name inside a 401/101 parameter while keeping colour/bracket wrappers.""" - if not param_str or not translated_name: - return param_str - m = _COLOR_SPEAKER_RE.match(param_str) - if m: - return param_str.replace(m.group(1), translated_name, 1) - bracket_disp = re.findall(r"【(.+?)】", param_str) - if bracket_disp: - return param_str.replace(bracket_disp[0], translated_name, 1) - if source_name and source_name in param_str: - return param_str.replace(source_name, translated_name, 1) - return param_str - - -def checkSave(data, filename, tokens): - """Save progress only if the given tokens reflect an actual translation. - tokens should be a [input_tokens, output_tokens] pair returned by a search/translate call. - """ - try: - # Never save progress to translated/ during speaker-parse mode - if SPEAKER_PARSE_MODE: - return - if not tokens: - return - if (isinstance(tokens, (list, tuple)) and len(tokens) >= 2 and (tokens[0] or tokens[1])): - saveProgress(data, filename) - except Exception: - # Don't let saving issues affect the translation flow - traceback.print_exc() - - -def update_vocab_section(category: str, pairs: list[tuple[str, str]]): - """Update or insert a section in vocab.txt for the given category with provided pairs. - Only writes when there's an actual translation (dst is non-empty and differs from src after normalization). - - category: e.g., "Items", "Weapons", etc. Section header will be "# {category}". - - pairs: list of (source, translated) strings. Duplicates by source are deduped (last wins). - The existing section is replaced entirely; other sections are preserved. - """ - try: - vocab_path = Path("vocab.txt") - - # Helper: normalized comparison to detect no-op translations - def _norm(s: str) -> str: - if s is None: - return "" - # Collapse whitespace and case-fold; leave punctuation to avoid over-matching - return re.sub(r"\s+", " ", str(s)).strip().casefold() - - # Filter and deduplicate by source term (last mapping wins) - dedup: dict[str, str] = {} - for src, dst in pairs: - if not src: - continue - # Skip when no destination or no actual change - if dst is None or _norm(dst) == "" or _norm(dst) == _norm(src): - continue - dedup[src] = dst - - # If nothing to add after filtering, skip touching the file - if not dedup: - return - - # Guard the read-modify-write with a dedicated lock to avoid races - with VOCAB_LOCK: - existing = vocab_path.read_text(encoding="utf-8") if vocab_path.exists() else "" - - lines = [f"{src} ({dst})" for src, dst in dedup.items()] - # Always terminate a section with a blank line to separate from next header - new_block = f"# {category}\n" + "\n".join(lines) - if not new_block.endswith("\n\n"): - if not new_block.endswith("\n"): - new_block += "\n" - new_block += "\n" - - # Regex to find the specific section starting at the header for this category - # and ending right before the next header (any number of '#') or EOF. - # - Handles headers like '#Category', '# Category', '## Category', etc. - # - Uses non-greedy matching for the body to avoid spanning multiple sections. - pattern = re.compile( - rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)", - re.MULTILINE | re.DOTALL, - ) - if pattern.search(existing): - # Replace only the first matching section for this category. - updated = pattern.sub(lambda m: new_block, existing, count=1) - else: - updated = existing - if updated and not updated.endswith("\n\n"): - # Ensure a blank line before appending new section if file not empty - if not updated.endswith("\n"): - updated += "\n" - updated += "\n" - updated += new_block - - # Avoid writing if nothing changed - if updated == existing: - return - # Atomic write: write to unique temp and replace with retries on Windows - tmp_path = vocab_path.with_suffix(vocab_path.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp") - tmp_path.write_text(updated, encoding="utf-8") - - attempts = 6 - delay = 0.1 - last_err = None - for attempt in range(attempts): - try: - os.replace(tmp_path, vocab_path) - last_err = None - break - except PermissionError as e: - last_err = e - # Try relaxing permissions then retry - try: - if vocab_path.exists(): - os.chmod(vocab_path, 0o666) - except Exception: - pass - time.sleep(delay) - delay = min(1.0, delay * 2) - except Exception as e: - last_err = e - break - if last_err is not None: - try: - shutil.move(str(tmp_path), str(vocab_path)) - except Exception: - try: - if tmp_path.exists(): - tmp_path.unlink(missing_ok=True) - except Exception: - pass - raise last_err - except Exception: - traceback.print_exc() - - -def parseMap(data, filename): - totalTokens = [0, 0] - events = data["events"] - global LOCK - - # --- Preflight: estimate exact progress total using the same translation batching --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - # Silent during preflight - pass - def refresh(self): - pass - - def _estimate_map_units(d, fname) -> int: - # Avoid deep copy - just count items directly - count = 0 - try: - # Count display name TL (1 unit if present) - if "Map" in fname and isinstance(d.get("displayName", None), str): - count += 1 - - # Notes and pages - count actual translatable items - evts = d.get("events", []) or [] - for evt in evts: - if not evt: - continue - note_val = evt.get("note") or "" - if not isinstance(note_val, str): - note_val = str(note_val) if note_val is not None else "" - - # Count note-based translations - if "" in note_val: - name_val = evt.get("name") or "" - if isinstance(name_val, str) and name_val: - count += 1 - - if "", note_val, re.DOTALL) - count += len(matches) - - if ".*", note_val) - count += len(matches) - if ".*", note_val) - count += len(matches) - - # Count commands in pages (rough estimate) - for page in (evt.get("pages", []) or []): - if page and "list" in page: - # Count translatable codes - for cmd in page.get("list", []): - if cmd and "code" in cmd: - code = cmd["code"] - # Count common translatable codes - if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: - count += 1 - - return count if count > 0 else 1 - except Exception: - return 1 - - # Translate displayName for Map files - if "Map" in filename: - response = translateAI( - data["displayName"], - "Reply with only the " + LANGUAGE + " translation of the RPG location name", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - data["displayName"] = response[0].replace('"', "") - - # Compute accurate total using preflight (includes speakers, choices, groups, and notes) - totalLines = _estimate_map_units(data, filename) - if not isinstance(totalLines, int) or totalLines <= 0: - # Fallback to naive count so a bar still renders - totalLines = 0 - for event in events: - if event: - for page in event.get("pages", []) or []: - try: - totalLines += len(page.get("list", [])) - except Exception: - pass - global PBAR - - # Process each page synchronously with progress updates - with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - - # Batch translate event names - lbTokens = translateLBNames(events) - totalTokens[0] += lbTokens[0] - totalTokens[1] += lbTokens[1] - - for event in events: - if event is not None: - # Normalize note to a safe string - note_val = event.get("note") or "" - if not isinstance(note_val, str): - note_val = str(note_val) if note_val is not None else "" - - # This translates ID of events. (May break the game) - if ".*") - if tok and isinstance(tok, (list, tuple)): - totalTokens[0] += tok[0] - totalTokens[1] += tok[1] - if ".*") - if tok and isinstance(tok, (list, tuple)): - totalTokens[0] += tok[0] - totalTokens[1] += tok[1] - - for page in event["pages"]: - if page is not None: - try: - totalTokensPage = searchCodes(page, pbar, [], filename) - totalTokens[0] += totalTokensPage[0] - totalTokens[1] += totalTokensPage[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist progress only if this page produced tokens - checkSave(data, filename, totalTokensPage) - return [data, totalTokens, None] - - -def _normalize_sg_desc(text: str) -> str: - """Normalize SG description text before AI translation. - - Japanese body text is hard-wrapped at screen width using bare \\n. - This collapses those intra-paragraph newlines into spaces so the AI - receives clean prose paragraphs, while preserving: - - \\n\\n paragraph / section breaks - - ◆ / ・ / • / ● header lines (kept on their own line) - """ - HEADER_CHARS = ("◆", "・", "•", "●") - blocks = text.split("\n\n") - normalized_blocks = [] - for block in blocks: - lines = block.split("\n") - result_lines: list[str] = [] - body_buf: list[str] = [] - for line in lines: - stripped = line.strip() - if stripped.startswith(HEADER_CHARS): - if body_buf: - result_lines.append(" ".join(body_buf)) - body_buf = [] - result_lines.append(stripped) - elif stripped: - body_buf.append(stripped) - if body_buf: - result_lines.append(" ".join(body_buf)) - normalized_blocks.append("\n".join(result_lines)) - return "\n\n".join(normalized_blocks) - - - # Regex String - jaString = event.get("note") or "" - if not isinstance(jaString, str): - jaString = str(jaString) if jaString is not None else "" - match = re.findall(regex, jaString, re.DOTALL) - if match: - tokens = [0, 0] - i = 0 - while i < len(match): - initialJAString = match[i] - modifiedJAString = initialJAString - # Remove any textwrap - if wordwrap: - modifiedJAString = modifiedJAString.replace("\n", " ") - - # Translate - response = translateAI( - modifiedJAString, - "Reply with only the " + LANGUAGE + " translation.", - False, - ) - translatedText = response[0] - tokens[0] += response[1][0] - tokens[1] += response[1][1] - - # Textwrap - if wordwrap: - translatedText = dazedwrap.wrapText(translatedText, width=NOTEWIDTH) - translatedText = translatedText.replace('"', "") - - jaString = jaString.replace(initialJAString, translatedText) - event["note"] = jaString - i += 1 - return tokens - return [0, 0] - - -# For notes that can't have spaces. -def translateNoteOmitSpace(event, regex): - # Regex that only matches text inside LB. - jaString = event.get("note") or "" - if not isinstance(jaString, str): - jaString = str(jaString) if jaString is not None else "" - - match = re.findall(regex, jaString, re.DOTALL) - if match: - oldJAString = match[0] - # Remove any textwrap - jaString = re.sub(r"\n", " ", oldJAString) - - # Translate - response = translateAI( - jaString, - "Reply with the " + LANGUAGE + " translation of the location name.", - False, - ) - # Defend against unexpected response shapes - try: - translatedText = response[0] - token_info = response[1] if isinstance(response, (list, tuple)) and len(response) > 1 else [0, 0] - if not (isinstance(token_info, (list, tuple)) and len(token_info) >= 2): - token_info = [0, 0] - except Exception: - translatedText = str(response) if response is not None else "" - token_info = [0, 0] - - translatedText = translatedText.replace('"', "") - translatedText = translatedText.replace(" ", "_") - # Safely update the note if it exists and is a string - current_note = event.get("note") - if isinstance(current_note, str): - event["note"] = current_note.replace(oldJAString, translatedText) - return token_info - return [0, 0] - - -def translateLBNames(events): - """Batch translate event names for events with tag. - Collects all names, translates in a single batch, then applies results. - Returns [input_tokens, output_tokens]. - """ - totalTokens = [0, 0] - - # Collect events with tag that have translatable names - lb_events = [] # List of (event_index, original_name) - for idx, event in enumerate(events): - if event is None: - continue - note_val = event.get("note") or "" - if not isinstance(note_val, str): - note_val = str(note_val) if note_val is not None else "" - - if "" in note_val: - name_val = event.get("name") or "" - if isinstance(name_val, str) and name_val and re.search(LANGREGEX, name_val): - lb_events.append((idx, name_val)) - - # Batch translate if we have any - if lb_events: - names_to_translate = [item[1] for item in lb_events] - response = translateAI( - names_to_translate, - "Reply with only the " + LANGUAGE + " translation of the name.", - True, - ) - translated_names = response[0] if isinstance(response[0], list) else [response[0]] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Apply translations back to events - for i, (evt_idx, _) in enumerate(lb_events): - if i < len(translated_names): - events[evt_idx]["name"] = translated_names[i].replace('"', "").replace(" ", "_") - - return totalTokens - - -def parseCommonEvents(data, filename): - totalTokens = [0, 0] - global LOCK - - # --- Preflight: estimate exact progress total using same batching --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - pass - def refresh(self): - pass - - def _estimate_units(pages, fname) -> int: - # Avoid deep copy - just count commands directly - count = 0 - try: - for page in pages: - if page is not None and "list" in page: - for cmd in page.get("list", []): - if cmd and "code" in cmd: - code = cmd["code"] - if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: - count += 1 - return count if count > 0 else 1 - except Exception: - return 1 - - totalLines = _estimate_units(data, filename) - if not isinstance(totalLines, int) or totalLines <= 0: - # Fallback to naive command count - totalLines = 0 - for page in data: - if page is not None: - try: - totalLines += len(page.get("list", [])) - except Exception: - pass - global PBAR - - with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - for page in data: - if page is not None: - try: - totalTokensPage = searchCodes(page, pbar, [], filename) - totalTokens[0] += totalTokensPage[0] - totalTokens[1] += totalTokensPage[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist progress only if this page produced tokens - checkSave(data, filename, totalTokensPage) - return [data, totalTokens, None] - - -def parseTroops(data, filename): - totalTokens = [0, 0] - global LOCK - - # --- Preflight total using same code paths --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - pass - def refresh(self): - pass - - def _estimate_units(troops, fname) -> int: - # Avoid deep copy - just count commands directly - count = 0 - try: - for troop in troops: - if troop is None: - continue - for page in (troop.get("pages", []) or []): - if page is not None and "list" in page: - for cmd in page.get("list", []): - if cmd and "code" in cmd: - code = cmd["code"] - if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: - count += 1 - return count if count > 0 else 1 - except Exception: - return 1 - - totalLines = _estimate_units(data, filename) - if not isinstance(totalLines, int) or totalLines <= 0: - totalLines = 0 - for troop in data: - if troop is not None: - for page in troop.get("pages", []) or []: - try: - totalLines += len(page.get("list", [])) - except Exception: - pass - global PBAR - - with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - for troop in data: - if troop is not None: - for page in troop["pages"]: - if page is not None: - try: - totalTokensPage = searchCodes(page, pbar, [], filename) - totalTokens[0] += totalTokensPage[0] - totalTokens[1] += totalTokensPage[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist progress only if this page produced tokens - checkSave(data, filename, totalTokensPage) - return [data, totalTokens, None] - - -def parseNames(data, filename, context): - totalTokens = [0, 0] - - # --- Preflight: custom estimator that mirrors searchNames increments (incl. notes/messages) --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - pass - def refresh(self): - pass - - def _estimate_names_units(entries, ctx, fname) -> int: - # Avoid deep copy - just count fields directly - count = 0 - try: - note_regexes = [ - (r"", False), - (r"", False), - (r"<[Hh]int:(.*?)>", False), - (r"", False), - (r"", True), - (r"", False), - (r"", False), - (r"", False), - (r"", True), - (r"", False), - (r"\n(.*)\n", False), - (r"", False), - (r"WATs:(.+?)>", False), - (r"ADTs?:(.+?)>", False), - (r"", False), - (r"", False), - (r"]+)", True), - (r"]+)", True), - (r"]+)", True), - (r"", True), - (r"", True), - (r"", False), - (r"<拡張説明:(.+?)>", False), - (r"\n(.+?)\n<", False), - (r"text:(.+)>", False), - (r"\d][^,>]*)", False), - (r"\n?(.*?)", False), - (r"<コメント:\n?(.*?)>", True), - ] - - for entry in entries: - if not entry: - continue - nm = entry.get("name") or "" - ds = entry.get("description") or "" - nn = entry.get("nickname") or "" - pf = entry.get("profile") or "" - if ctx == "Actors": - if nm: count += 1 - if nn: count += 1 - if pf: count += 1 - elif ctx in ["Armors", "Weapons", "Items"]: - if nm: count += 1 - if ds: count += 1 - elif ctx == "Skills": - if nm: count += 1 - if ds: count += 1 - for k in range(1,5): - if entry.get(f"message{k}"): count += 1 - elif ctx in ["Enemies", "Classes", "MapInfos"]: - if nm: count += 1 - - # Notes counting - note = entry.get("note") or "" - if isinstance(note, str) and note: - for regex, _ww in note_regexes: - try: - matches = re.findall(regex, note, re.DOTALL) - except Exception: - matches = [] - if regex.startswith(r" 0 else 1 - except Exception: - return 1 - - total_units = _estimate_names_units(data, context, filename) - if not isinstance(total_units, int) or total_units <= 0: - # Reasonable fallback: count visible fields/messages (no notes) - total_units = 0 - for entry in data: - if not entry: - continue - if entry.get("name"): total_units += 1 - if context in ["Armors", "Weapons", "Items", "Skills"] and entry.get("description"): total_units += 1 - if context == "Actors": - if entry.get("nickname"): total_units += 1 - if entry.get("profile"): total_units += 1 - if context == "Skills": - for k in range(1,5): - if entry.get(f"message{k}"): total_units += 1 - global PBAR - - with tqdm(total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - try: - # Thread the filename through so progress saves write to the right file - result = searchNames(data, pbar, context, filename) - totalTokens[0] += result[0] - totalTokens[1] += result[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist progress only if this names pass produced tokens - checkSave(data, filename, totalTokens) - return [data, totalTokens, None] - - -def parseSS(data, filename): - totalTokens = [0, 0] - - # --- Preflight using searchSS over deep copy --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - pass - def refresh(self): - pass - - def _estimate_units(states, fname) -> int: - # Avoid deep copy - just count fields directly - count = 0 - try: - for st in states: - if not st: - continue - if st.get("name"): count += 1 - if st.get("description"): count += 1 - for n in range(1,5): - if st.get(f"message{n}"): count += 1 - return count if count > 0 else 1 - except Exception: - return 1 - - total_units = _estimate_units(data, filename) - if not isinstance(total_units, int) or total_units <= 0: - total_units = 0 - for st in data: - if not st: - continue - if st.get("name"): total_units += 1 - if st.get("description"): total_units += 1 - for n in range(1,5): - if st.get(f"message{n}"): total_units += 1 - global PBAR - - with tqdm(total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - for ss in data: - if ss is not None: - try: - result = searchSS(ss, pbar) - totalTokens[0] += result[0] - totalTokens[1] += result[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist progress only if this state produced tokens - checkSave(data, filename, result) - return [data, totalTokens, None] - - -def parseSystem(data, filename): - totalTokens = [0, 0] - - # --- Preflight: call searchSystem on deep copy to count increments --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - pass - def refresh(self): - pass - - def _estimate_units(sysobj, fname) -> int: - # Avoid deep copy - just count fields directly - count = 0 - try: - for term in sysobj.get("terms", {}) or {}: - termList = sysobj["terms"][term] - if isinstance(termList, list): - count += len(termList) - gt = sysobj.get("gameTitle") - if isinstance(gt, str) and gt: - count += 1 - count += len(sysobj.get("variables", []) or []) - count += len(sysobj.get("switches", []) or []) - count += len(sysobj.get("weaponTypes", []) or []) - count += len(sysobj.get("armorTypes", []) or []) - count += len(sysobj.get("skillTypes", []) or []) - count += len(sysobj.get("equipTypes", []) or []) - return count if count > 0 else 1 - except Exception: - return 1 - - total_units = _estimate_units(data, filename) - if not isinstance(total_units, int) or total_units <= 0: - # Fallback: rough count of strings - total_units = 0 - if data.get("gameTitle"): total_units += 1 - terms = data.get("terms", {}) or {} - for k,v in terms.items(): - if k == "messages": - continue - if isinstance(v, list): - total_units += sum(1 for x in v if x is not None) - total_units += len(data.get("armorTypes", []) or []) - total_units += len(data.get("skillTypes", []) or []) - total_units += len(data.get("equipTypes", []) or []) - total_units += len((terms.get("messages", {}) or {})) - global PBAR - - with tqdm(total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - try: - result = searchSystem(data, pbar) - totalTokens[0] += result[0] - totalTokens[1] += result[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist only if system sections produced tokens - checkSave(data, filename, result) - return [data, totalTokens, None] - - -def parseScenario(data, filename): - totalTokens = [0, 0] - global LOCK - - # --- Preflight: run searchCodes on each page list --- - class _CountingBar: - def __init__(self): - self.n = 0 - def update(self, n=1): - try: - self.n += int(n) if n is not None else 1 - except Exception: - self.n += 1 - def write(self, *args, **kwargs): - pass - def refresh(self): - pass - - def _estimate_units(scenario, fname) -> int: - # Avoid deep copy - just count commands directly - count = 0 - try: - for key, lst in scenario.items(): - if lst is not None and "list" in lst: - for cmd in lst.get("list", []): - if cmd and "code" in cmd: - code = cmd["code"] - if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: - count += 1 - return count if count > 0 else 1 - except Exception: - return 1 - - totalLines = _estimate_units(data, filename) - if not isinstance(totalLines, int) or totalLines <= 0: - totalLines = 0 - for _, lst in data.items(): - try: - totalLines += len(lst or []) - except Exception: - pass - global PBAR - - with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: - PBAR = pbar - for page in data.items(): - if page[1] is not None: - try: - totalTokensPage = searchCodes(page[1], pbar, [], filename) - totalTokens[0] += totalTokensPage[0] - totalTokens[1] += totalTokensPage[1] - except Exception as e: - traceback.print_exc() - return [data, totalTokens, e] - finally: - # Persist progress only if this page produced tokens - checkSave(data, filename, totalTokensPage) - return [data, totalTokens, None] - - -def searchNames(data, pbar, context, filename): - totalTokens = [0, 0] - nameList = [] - profileList = [] - nicknameList = [] - descriptionList = [] - # For Skills: collect messages across all entries for batch translation - messagesList = [] # List of tuples: (entry_idx, message_field, message_text, needs_taro) - # Collect name mappings for vocab per run - vocab_pairs: list[tuple[str, str]] = [] - vocab_enabled = context in ["Armors", "Weapons", "Items", "MapInfos", "Classes", "Enemies", "Skills"] - # For batching all note types - notesBatch = [] # List of (i, regex, match_text, note_type) - notesBatchMap = [] # List of (i, regex, match_text, note_type, groupidx) - i = 0 # Counter - j = 0 # Counter 2 - filling = False - mismatch = False - batchFull = False - - # Set the context of what we are translating - if "Actors" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the NPC name" - if "Armors" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG equipment name" - if "Classes" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG class name" - if "MapInfos" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the location name" - if "Enemies" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the enemy NPC name" - if "Weapons" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG weapon name" - if "Items" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG item name" - if "Skills" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG skill name" - - # Names - with open("log/translations.txt", "a", encoding="utf-8") as file: - file.write(f"\n#{context}\n") - - # --- Batching pass: collect all note texts for all note types --- - note_regexes = [ - (r"", False), - (r"", False), - (r"<[Hh]int:(.*?)>", False), - (r"", False), - (r"", True), - (r"", False), - (r"", False), - (r"", False), - (r"", True), - (r"", False), - (r"\n(.*)\n", False), - (r"", False), - (r"WATs:(.+?)>", False), - (r"ADTs?:(.+?)>", False), - (r"", False), - (r"", False), - (r"]+)", True), - (r"]+)", True), - (r"]+)", True), - (r"", True), - (r"", True), - (r"", False), - (r"<拡張説明:(.+?)>", False), - (r"\n(.+?)\n<", False), - (r"text:(.+)>", False), - (r"\d][^,>]*)", False), - (r"\n?(.*?)", False), - (r"<コメント:\n?(.*?)>", True), - ] - # For each entry, collect all note matches - for idx, entry in enumerate(data): - if entry is None or "note" not in entry or not entry["note"]: - continue - note = entry["note"] - for regex, wordwrap in note_regexes: - matches = re.findall(regex, note, re.DOTALL) - # Special filter for to skip if 'Client' is in the match - if regex.startswith(r"= len(translatedNotesBatch): - break - translated = translatedNotesBatch[note_insert_idx] - if wordwrap: - if regex.startswith(r" 0 and msg_text[0] in ["は", "を", "の", "に", "が"] - if needs_taro: - messages_batch.append("Taro" + msg_text) - else: - messages_batch.append(msg_text) - messages_map.append((idx, msg_field, needs_taro)) - - # Batch translate all messages - if messages_batch: - response = translateAI( - messages_batch, - "reply with only the gender neutral " + LANGUAGE + " translation of the action log. For messages starting with Taro, always start the sentence with Taro. For example, translate 'Taroを倒した!' as 'Taro was defeated!'", - False, - ) - translated_messages = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Apply translations back to data - for msg_idx, (entry_idx, msg_field, needs_taro) in enumerate(messages_map): - if msg_idx < len(translated_messages): - translation = translated_messages[msg_idx] - if needs_taro: - translation = translation.replace("Taro", "") - data[entry_idx][msg_field] = translation - - # Update progress for messages - if pbar is not None: - pbar.refresh() - - # Now continue with the rest of the batching logic for names, descriptions, etc. - i = 0 - filling = False - batchFull = False - mismatch = False - while i < len(data) or filling == True: - if i < len(data): - # Empty Data - if data[i] is None or data[i]["name"] == "": - i += 1 - continue - # Filling up Batch - filling = True - if context in "Actors": - if len(nameList) < BATCHSIZE: - if data[i]["name"] != "": - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["name"])): - nameList.append(data[i]["name"]) - if "nickname" in data[i] and data[i]["nickname"]: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["nickname"])): - nicknameList.append(data[i]["nickname"]) - if "profile" in data[i] and data[i]["profile"]: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["profile"])): - profileList.append(data[i]["profile"].replace("\n", " ")) - i += 1 - else: - batchFull = True - if context in ["Armors", "Weapons", "Items"]: - if len(nameList) < BATCHSIZE: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["name"])): - nameList.append(data[i]["name"]) - if "description" in data[i] and data[i]["description"] != "": - description = data[i]["description"] - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, description)): - description = description.replace("\n", " ") - descriptionList.append(description) - i += 1 - else: - batchFull = True - if context in ["Skills"]: - if len(nameList) < BATCHSIZE: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["name"])): - nameList.append(data[i]["name"]) - if "description" in data[i] and data[i]["description"]: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["description"])): - descriptionList.append(data[i]["description"].replace("\n", " ")) - i += 1 - else: - batchFull = True - if context in ["Enemies", "Classes", "MapInfos"]: - if len(nameList) < BATCHSIZE: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data[i]["name"])): - nameList.append(data[i]["name"]) - i += 1 - else: - batchFull = True - - # Batch Full - if batchFull == True or i >= len(data): - k = j # Original Index - if context in "Actors": - # Track tokens for this batch - batchTokens = [0, 0] - # Name - response = translateAI(nameList, newContext) - translatedNameBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - batchTokens[0] += response[1][0] - batchTokens[1] += response[1][1] - if pbar is not None and nameList: - pbar.refresh() - - # Nickname - if nicknameList: - response = translateAI(nicknameList, newContext) - translatedNicknameBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - batchTokens[0] += response[1][0] - batchTokens[1] += response[1][1] - if pbar is not None: - pbar.refresh() - - # Profile - if profileList: - response = translateAI(profileList, "") - translatedProfileBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - batchTokens[0] += response[1][0] - batchTokens[1] += response[1][1] - if pbar is not None: - pbar.refresh() - - # Set Data - if len(nameList) == len(translatedNameBatch): - j = k - while j < i: - # Empty Data - if data[j] is None or data[j]["name"] == "": - j += 1 - continue - else: - # Get Text - if data[j]["name"] != "": - with open("log/translations.txt", "a", encoding="utf-8") as file: - file.write(f'{data[j]["name"]} ({translatedNameBatch[0]})\n') - # Actors are excluded from vocab updates - data[j]["name"] = translatedNameBatch[0] - translatedNameBatch.pop(0) - if "nickname" in data[j] and data[j]["nickname"]: - data[j]["nickname"] = translatedNicknameBatch[0] - translatedNicknameBatch.pop(0) - if "profile" in data[j] and data[j]["profile"]: - data[j]["profile"] = dazedwrap.wrapText(translatedProfileBatch[0], LISTWIDTH) - translatedProfileBatch.pop(0) - - # If Batch is empty. Move on. - if len(translatedNameBatch) == 0: - nameList.clear() - profileList.clear() - nicknameList.clear() - batchFull = False - filling = False - j += 1 - # Persist after applying this batch only if we actually translated something in this batch - checkSave(data, filename, batchTokens) - else: - mismatch = True - - if context in ["Armors", "Weapons", "Items", "Skills"]: - # Track tokens for this batch - batchTokens = [0, 0] - # Name - response = translateAI(nameList, newContext) - translatedNameBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - batchTokens[0] += response[1][0] - batchTokens[1] += response[1][1] - if pbar is not None and nameList: - pbar.refresh() - - # Description - if descriptionList: - response = translateAI( - descriptionList, - f"Reply with only the {LANGUAGE} translation of the text.", - True, - ) - translatedDescriptionBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - batchTokens[0] += response[1][0] - batchTokens[1] += response[1][1] - if pbar is not None: - pbar.refresh() - - # Set Data - if len(nameList) == len(translatedNameBatch): - j = k - with open("log/translations.txt", "a", encoding="utf-8") as file: - while j < i: - # Empty Data - if data[j] is None or data[j]["name"] == "": - j += 1 - continue - else: - # Get Text - file.write(f"{data[j]['name']} ({translatedNameBatch[0]})\n") - if vocab_enabled: - try: - vocab_pairs.append((data[j]['name'], translatedNameBatch[0])) - except Exception: - pass - data[j]["name"] = translatedNameBatch[0] - translatedNameBatch.pop(0) - if "description" in data[j] and data[j]["description"] != "": - translatedDescriptionBatch[0] = dazedwrap.wrapText(translatedDescriptionBatch[0], LISTWIDTH) - data[j]["description"] = translatedDescriptionBatch[0] - translatedDescriptionBatch.pop(0) - - # If Batch is empty. Move on. - if len(translatedNameBatch) == 0: - nameList.clear() - descriptionList.clear() - batchFull = False - filling = False - j += 1 - # Persist after applying this batch only if we actually translated something in this batch - checkSave(data, filename, batchTokens) - else: - mismatch = True - if context in ["Enemies", "Classes", "MapInfos"]: - # Track tokens for this batch - batchTokens = [0, 0] - response = translateAI(nameList, newContext) - translatedNameBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - batchTokens[0] += response[1][0] - batchTokens[1] += response[1][1] - if pbar is not None and nameList: - pbar.refresh() - - # Set Data - if len(nameList) == len(translatedNameBatch): - j = k - while j < i: - # Empty Data - if data[j] is None or data[j]["name"] == "": - j += 1 - continue - else: - with open("log/translations.txt", "a", encoding="utf-8") as file: - file.write(f'{data[j]["name"]} ({translatedNameBatch[0]})\n') - # Get Text - if vocab_enabled: - try: - vocab_pairs.append((data[j]["name"], translatedNameBatch[0])) - except Exception: - pass - data[j]["name"] = translatedNameBatch[0] - translatedNameBatch.pop(0) - - # If Batch is empty. Move on. - if len(translatedNameBatch) == 0: - nameList.clear() - batchFull = False - filling = False - j += 1 - # Persist after applying this batch only if we actually translated something in this batch - checkSave(data, filename, batchTokens) - else: - mismatch = True - - # Mismatch - if mismatch == True: - MISMATCH.append(nameList) - nameList.clear() - profileList.clear() - descriptionList.clear() - filling = False - mismatch = False - batchFull = False - - i += 1 - - # Update vocab section once per context after processing all names - if vocab_enabled and vocab_pairs: - update_vocab_section(context, vocab_pairs) - - return totalTokens - - -def searchCodes(page, pbar, jobList, filename): - if len(jobList) > 0: - list401 = jobList[0] - list122 = jobList[1] - list355655 = jobList[2] - list108 = jobList[3] - list356 = jobList[4] - list357 = jobList[5] - list324 = jobList[6] - list408 = jobList[7] - list325 = jobList[8] - list657 = jobList[9] - setData = False - else: - list401 = [] - list122 = [] - list355655 = [] - list108 = [] - list356 = [] - list357 = [] - list324 = [] - list408 = [] - list325 = [] - list657 = [] - setData = True - textHistory = [] - match = [] - totalTokens = [0, 0] - translatedText = "" - speaker = "" - speakerID = None - syncIndex = 0 - maxHistory = MAXHISTORY - VNameValue = None - reduceWidthFlag = False # Track if 101 code has non-empty first parameter - global LOCK - global NAMESLIST - global MISMATCH - global PBAR - with LOCK: - PBAR = pbar - - # Begin Parsing File - try: - # Normal Format - if "list" in page: - codeList = page["list"] - - # Special Format (Scenario) - else: - codeList = page - - # Iterate through page - i = 0 - while i < len(codeList): - with LOCK: - # syncIndex will keep i in sync when it gets modified - if syncIndex > i: - i = syncIndex - if len(codeList) <= i: - break - - # Declare Varss - currentGroup = [] - sourceGroup = [] - nametag = "" - - ## Event Code: 401 Show Text - if "code" in codeList[i] and codeList[i]["code"] in [401, 405, -1] and ((codeList[i]["code"] in [401, -1] and CODE401) or (codeList[i]["code"] == 405 and CODE405)): - # Save Code and starting index (j) - code = codeList[i]["code"] - j = i - groupStart = j - endtag = "" - instantLineFlag = False - - # Grab String - if len(codeList[i]["parameters"]) > 0: - jaString = codeList[i]["parameters"][0] - oldjaString = _param_source(codeList[i], 0) - speakerWork = oldjaString - else: - codeList[i]["code"] = -1 - i += 1 - continue - - # # For Retarded Devs - # retardRegex = r'([\\]+[nN]\[[\\]+V\[\d*?\]\])' - # match = re.search(retardRegex, jaString) - # if match: - # if VNameValue == 1: - # jaString = re.sub(retardRegex, 'リッカ', jaString) - # if VNameValue == 2: - # jaString = re.sub(retardRegex, 'ミミ', jaString) - # if VNameValue == 3: - # jaString = re.sub(retardRegex, 'ヒトミ', jaString) - # if VNameValue == 4: - # jaString = re.sub(retardRegex, 'Taro', jaString) - # if VNameValue == 5: - # jaString = re.sub(retardRegex, '富士見', jaString) - - # Speaker Check - speakerList = [] - - # Remove any RPGMaker Code at start - ffMatch = re.search( - r"^((?:[\\]+[^cCnNiIkKvV]+\[[\d\w]+\])+)", - jaString, - ) - if ffMatch != None: - jaString = jaString.replace(ffMatch.group(0), "") - nametag += ffMatch.group(0) - - # m and z Codes - match = re.search(r"(.*?)[\\]+m\[\d+?\][\\]+z\[\d+?\]", speakerWork) - if match: - speakerList.append(match.group(1)) - if "\\c" in speakerList[0]: - speakerList = re.findall( - r"^[\\]+[cC]\[\d+\]【?(.+?)】?[\\]+[cC]\[\d+\](?:[\\]+[A-Za-z]+(?:\[[^\]]*\])?)*[\\]*$", - speakerList[0], - ) - - # Brackets (support multiple names like 【A】【B】) - if len(speakerList) == 0: - # Check for bracket at start with dialogue following (【name】dialogue...) - inlineBracketMatch = re.match(r"^\s*【([^】]+)】(.+)", speakerWork, re.DOTALL) - - if inlineBracketMatch: - # Inline bracket with dialogue on same line - speakerList = [inlineBracketMatch.group(1).strip()] - else: - # Only consider bracketed names when the line starts with '【' and - # ends with either '】' or trailing variable/control codes like \n[2], \FF[\w[3]], etc. - startsWithBracket = re.match(r"^\s*【", speakerWork) is not None - endsWithBracket = re.search( - r"(】\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)$)", - speakerWork, - ) is not None - - if startsWithBracket and endsWithBracket: - candidates = re.findall(r"【(.*?)】", speakerWork) - if candidates: - candidates = [c.strip() for c in candidates] - if candidates: - speakerList = candidates - - # Colors - if len(speakerList) == 0: - speakerList = re.findall( - r"^[\\]+[cC]\[\d+\]【?(.+?)】?[\\]+[cC]\[\d+\](?:[\\]+[A-Za-z]+(?:\[[^\]]*\])?)*[\\]*$", - speakerWork, - ) - - # Colons - if len(speakerList) == 0: - speakerList = re.findall( - r"(.+):$", - speakerWork, - ) - - # [Speaker] standalone line format (written back by inline re-export) - if len(speakerList) == 0: - inlineFmtMatch = re.match(r"^\[([^\[\]\n]+)\]\s*$", speakerWork) - if inlineFmtMatch: - speakerList = [inlineFmtMatch.group(1).strip()] - - # Inline speaker detection — Name「/Name: "/Name: (/[Name] "/[Name] ( - if len(speakerList) == 0 and INLINE401SPEAKERS: - inlineSpeakerMatch = re.match( - r'^(?:\[([^\]]{1,30})\]\s*|([^\s「」。、!?…\\\n“”"(:\[\]]{1,20})(?:[:::]?\s*)(?=[「“"(]))(.*)', - speakerWork, re.DOTALL - ) - if inlineSpeakerMatch: - speakerList = [(inlineSpeakerMatch.group(1) or inlineSpeakerMatch.group(2)).strip()] - else: - inlineSpeakerMatch = None - - # First Line Speakers - if len(speakerList) == 0 and FIRSTLINESPEAKERS is True: - # Test Speaker - if ( - len(speakerWork) < 40 - and "code" in codeList[i + 1] - and codeList[i + 1]["code"] in [401, 405, -1] - and len(codeList[i + 1]["parameters"]) > 0 - and len(codeList[i + 1]["parameters"][0]) > 0 - ): - nextString = codeList[i + 1]["parameters"][0].strip() - - # Remove any RPGMaker Code at start - ffMatchNS = re.search( - r"^((?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+)", - nextString, - ) - formatMatch = re.search(r"(^[\\]+[\W]+?)", nextString) - if ffMatchNS != None: - nextString = nextString.replace(ffMatchNS.group(1), "") - if formatMatch != None: - nextString = nextString.replace(formatMatch.group(1), "") - - if nextString and nextString[0] in [ - "「", - '"', - "(", - "(", - "*", - "[", - ]: - speakerList = re.findall(r".+", speakerWork) - - # Replace Speaker - if len(speakerList) != 0: - # Check if speaker+dialogue are on same line - sameLineMatch = re.match(r"^\s*【([^】]+)】(.+)", speakerWork, re.DOTALL) - if inlineSpeakerMatch and len(speakerList) == 1: - # Strip speaker prefix, keep everything after as dialogue - response = getSpeaker(speakerList[0]) - speaker = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - jaString = inlineSpeakerMatch.group(3) - if not setData: - nametag = f"[{speaker}]\n" + nametag - elif sameLineMatch and len(speakerList) == 1: - # Translate speaker - response = getSpeaker(speakerList[0]) - speaker = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - # Remove speaker bracket from jaString, let dialogue get translated - jaString = sameLineMatch.group(2) - # Store the translated bracket to add back later - if not setData: - nametag = f"[{speaker}]\n" + nametag - # Don't skip to next line - continue with current line - elif codeList[i + 1]["code"] in [401, 405, -1]: - # Original behavior: speaker on its own line, dialogue on next line - # Single - if len(speakerList) == 1: - response = getSpeaker(speakerList[0]) - speaker = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Multiple (Brackets) - elif len(speakerList) > 1: - jaStringUpdated = jaString - for idx, sp in enumerate(speakerList): - response = getSpeaker(sp) - tled = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if not setData: - pattern = r"【\s*" + re.escape(sp) + r"\s*】" - jaStringUpdated = re.sub(pattern, lambda m: f"【{tled}】", jaStringUpdated) - # Back-compat: set 'speaker' to the first translated name - if idx == 0: - speaker = tled - - # Set Data - if not setData and len(speakerList) > 1: - codeList[i]["parameters"][0] = nametag + jaStringUpdated - _apply_original(codeList[i], oldjaString) - elif not setData and len(speakerList) == 1: - paramStr = codeList[i]["parameters"][0] - codeList[i]["parameters"][0] = nametag + _replace_speaker_in_param( - paramStr, speakerList[0], speaker - ) - _apply_original(codeList[i], oldjaString) - nametag = "" - - # Iterate to next string - i += 1 - j = i - while codeList[i]["code"] in [-1]: - i += 1 - j = i - jaString = codeList[i]["parameters"][0] - groupStart = i - - # Using this to keep track of 401's in a row (display text for Pass 2 formatting). - currentGroup.append(jaString) - anchor_has_orig = _scalar_original(codeList[groupStart]) is not None - sourceGroup.append(_param_source(codeList[i], 0)) - - # Join Up 401's into single string - if len(codeList) > i + 1: - while codeList[i + 1]["code"] in [401, 405, -1] and len(codeList[i]["parameters"]) > 0 and len(codeList[i + 1]["parameters"]) > 0 and not re.match(r"^(\s*[\\]+[aAbBdDeEfFgGhHjJlLmMoOpPqQrRsStTuUwWxXyYzZ]+\[[\w\d\[\]\\]+\])", codeList[i+1]["parameters"][0]): - if not setData: - codeList[i]["parameters"] = [] - codeList[i]["code"] = -1 - i += 1 - j = i - - jaString = codeList[i]["parameters"][0] - if jaString.strip(): - currentGroup.append(jaString) - if not anchor_has_orig: - sourceGroup.append(_param_source(codeList[i], 0)) - - # Make sure not the end of the list. - if len(codeList) <= i + 1: - break - - # Format String - if len(currentGroup) > 0: - rawSource = _group_raw_source(codeList, groupStart, sourceGroup) - if not rawSource.strip(): - i += 1 - continue - if not re.search(LANGREGEX, rawSource) and IGNORETLTEXT: - i += 1 - continue - - finalJAString = rawSource - oldjaString = rawSource - - # Set Back - if not setData: - codeList[i]["parameters"] = [finalJAString] - - ### \\n - regex = r"([\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>])" - match = re.search(regex, finalJAString) - - # Set Name - if match: - nametag = match.group(1) - speaker = match.group(2) - - # Translate Speaker - response = getSpeaker(speaker) - tledSpeaker = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Set Nametag and Remove from Final String - finalJAString = finalJAString.replace(nametag, "") - nametag = nametag.replace(speaker, tledSpeaker) - speaker = tledSpeaker - - # Remove Extra Stuff bad for translation. - finalJAString = finalJAString.replace("゙", "") - finalJAString = finalJAString.replace(" ", "") - finalJAString = finalJAString.replace("「", '"') - finalJAString = finalJAString.replace("」", '"') - finalJAString = finalJAString.replace("\\,", ',') - - ### Remove format codes - # Furigana: \r or \rb [base,reading] -> keep reading/base per pattern - finalJAString = re.sub(r"[\\]+[rR][bB]?\[(.*?),.*?\]", r"\1", finalJAString) - - # Curly-brace furigana: {base|reading} -> keep base - finalJAString = re.sub(r"\{([^|{}]+)\|[^|{}]+?\}", r"\1", finalJAString) - - # Remove any RPGMaker Code at start - ffMatch = re.search( - r"^((?:[\\]+[^cCnNiIkKvV{}]+?\[[\d\w\W]+?\]?\])+)", - finalJAString, - ) - if ffMatch != None: - finalJAString = finalJAString.replace(ffMatch.group(1), "") - nametag = ffMatch.group(1) + nametag - - # Remove bare escape codes at start (e.g. \\mn\\tmn, \\tmn, \\mn, \\vc) - bareMatch = re.match(r"^(\\mn\\tmn|\\tmn|\\mn|\\vc)", finalJAString) - if bareMatch is not None: - finalJAString = finalJAString[len(bareMatch.group(0)):] - nametag = bareMatch.group(0) + nametag - - # Remove _ABL Codes - ffMatch = re.search(r"^(_ABL).*", finalJAString) - if ffMatch != None: - finalJAString = finalJAString.replace(ffMatch.group(1), "") - nametag += ffMatch.group(1) - - # Center Lines (We Nuke These) - if "\\CL" in finalJAString or "\\ac" in finalJAString or "\\#" in finalJAString: - finalJAString = finalJAString.replace("\\CL", "") - finalJAString = finalJAString.replace("\\ac", "") - finalJAString = finalJAString.replace("\\#", "") - - # Handle Formatting Codes - if "\\>" in finalJAString: - instantLineFlag = True - finalJAString = finalJAString.replace("\\>", "") - - # Check if Empty - if finalJAString == "": - if nametag and match: - codeList[j]["parameters"][0] = codeList[j]["parameters"][0].replace(match.group(2), tledSpeaker) - i += 1 - continue - - # Pass 1 (Grabbing Data) - if setData: - # Remove Textwrap - if FIXTEXTWRAP: - finalJAString = finalJAString.replace("\n", " ") - if "\\px[200]" in finalJAString: - finalJAString = finalJAString.replace("\\px[200]", "") - - # Append - if finalJAString != "": - if speaker == "" and finalJAString != "": - list401.append(finalJAString) - historyEntry = finalJAString - elif finalJAString != "": - list401.append(f"[{speaker}]: {finalJAString}") - historyEntry = f"[{speaker}]: {finalJAString}" - else: - list401.append(speaker) - historyEntry = speaker - speaker = "" - match = [] - nametag = "" - currentGroup = [] - sourceGroup = [] - syncIndex = i + 1 - - # Keep textHistory list at length maxHistory - textHistory.append('"' + historyEntry + '"') - if len(textHistory) > maxHistory: - textHistory.pop(0) - - # Pass 2 (Setting Data) - else: - # Grab Translated String - if len(list401) > 0: - rawSource = _group_raw_source(codeList, groupStart, sourceGroup) - translatedText = list401[0] - - # Remove speaker prefix if present - match = re.search(r'(^\[(.+?)\]\s?[|:]\s?)', translatedText) - if match: - translatedText = translatedText.replace(match.group(1), "") - - # Remove 。 that appears after ... in AI output - translatedText = re.sub(r'\.\.\.(。)+', '...', translatedText) - - # Ensure a space follows sentence-ending punctuation before a capital letter. - # Japanese doesn't use spaces after !/?, so the AI omits them too. - translatedText = re.sub(r'([!?])([A-Z])', r'\1 \2', translatedText) - - # Ensure a single space before a run of RPGMaker pause/wait codes - # (\. \! \| \^) when immediately preceded by a word/punctuation char. - # Matches the whole code run at once so no intra-run spaces are added. - translatedText = re.sub(r'([^\s\\])((?:\\[.!|^])+)', r'\1 \2', translatedText) - - # Fix '- ' - translatedText = translatedText.replace("- ", "-") - - # Textwrap - if FIXTEXTWRAP is True: - finalJAString = re.sub(r"\n", " ", finalJAString) - finalJAString = finalJAString.replace("
", " ") - - # Determine width based on reduceWidthFlag - currentWidth = WIDTH - 15 if reduceWidthFlag else WIDTH - - if FIXTEXTWRAP is True and "_ABL" in nametag: - translatedText = dazedwrap.wrapText(translatedText, width=100) - elif FIXTEXTWRAP is True: - translatedText = dazedwrap.wrapText(translatedText, width=currentWidth) - - # Reset the flag after using it - reduceWidthFlag = False - - # Formatting Code - if instantLineFlag: - translatedText = translatedText.replace("\n", "\n\\>") - translatedText = f"\\>{translatedText}" - - # BR Flag - if BRFLAG is True: - translatedText = translatedText.replace("\n", "
") - - # px - if "\\px[200]" in nametag: - translatedText = translatedText.replace("\\px[200]", "") - translatedText = translatedText.replace("\n", "\n\\px[200]") - - # Add Nametag Back In - translatedText = nametag + translatedText - nametag = "" - - # Endtag - if endtag != "": - translatedText = translatedText + endtag - endtag = "" - - # Set Code - codeList[j]["code"] = code - - # Handle 405 - if codeList[j]["code"] == 405: - # 1. Split translatedText by newlines - lines = [line for line in translatedText.split('\n') if line.strip() != ""] - - # 2. Set the first string to codeList[j]["parameters"] - codeList[j]["parameters"] = [lines[0]] - - # 3. Make copies for each additional line and insert them - for idx, line in enumerate(lines[1:]): - new_item = copy.deepcopy(codeList[j]) - new_item.pop("_original", None) - new_item["parameters"] = [line] - codeList.insert(j + idx + 1, new_item) - - # 4. Update syncIndex to the last modified/added position - syncIndex = j + len(lines) - - # Handle 401 - else: - codeList[j]["parameters"] = [translatedText] - codeList[j]["code"] = code - syncIndex = i + 1 - - _apply_original(codeList[j], rawSource) - - # Reset - speaker = "" - match = [] - currentGroup = [] - sourceGroup = [] - list401.pop(0) - - ## Event Code: 122 [Set Variables] - if "code" in codeList[i] and codeList[i]["code"] == 122 and CODE122 is True: - # This is going to be the var being translated. - # Only translate variables within the specified range. - if codeList[i]["parameters"][0] not in list(range(CODE122_VAR_MIN, CODE122_VAR_MAX)): - i += 1 - continue - - jaString = codeList[i]["parameters"][4] - - # # For Retarded Devs - # VNameValue = jaString - # i += 1 - # continue - - # Validate String - if not isinstance(jaString, str): - i += 1 - continue - - # Definitely don't want to mess with files - if 'gameV' in jaString or '_' in jaString or '"[' in jaString or '@' in jaString: - i += 1 - continue - - # # Avoid anything not quoted - # if '\"' not in jaString: - # i += 1 - # continue - - # Validate Japanese Text - # if not re.search(LANGREGEX, jaString): - # i += 1 - # continue - - # Set String - innerSource = _122_inner_source(codeList[i]) - if innerSource is not None and innerSource.strip(): - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, innerSource): - i += 1 - continue - - # Remove Textwrap - finalJAString = innerSource.replace("\\n", " ") - - # Pass 1 - if setData: - if finalJAString != "": - list122.append(finalJAString) - - # Pass 2 - else: - if len(list122) > 0: - rawInner = innerSource - hadSemicolon = ';' in jaString - # Grab and Replace - translatedText = list122[0] - translatedText = jaString.replace(jaString, translatedText) - - # Remove characters that may break scripts - charList = ['"', "\\n"] - for char in charList: - translatedText = translatedText.replace(char, "") - - # Force 4 Escapes - translatedText = re.sub(r'(? 0: - # Grab and Replace - translatedText = list357[0] - translatedText = jaString.replace(jaString, translatedText) - - # Remove characters that may break scripts - charList = ['"', "\n"] - for char in charList: - translatedText = translatedText.replace(char, "") - - # Textwrap - # translatedText = dazedwrap.wrapText(translatedText, 80) - # translatedText = translatedText.replace("\n", "\\n") - # translatedText = re.sub(r"[\\]+c", r"\\\\c", translatedText) - translatedText = re.sub(r"[\\]+\*item", r"\\\\*item", translatedText) - - # Center Text - if acExist: - translatedText = f'\\ac {translatedText.replace('\n', '\n\\ac ')}' - - # Check and Set Font - if "fontSize" in codeList[i]["parameters"][3]: - if font: - codeList[i]["parameters"][3]["fontSize"] = font - - # Set - codeList[i]["parameters"][3][argVar] = f"{translatedText}" - list357.pop(0) - - # Map Plugins — use module-level registry filtered by ENABLED_PLUGINS_357 - headerMappings = { - k: v for k, v in HEADER_MAPPINGS_357.items() - if k in ENABLED_PLUGINS_357 - } - - for key, (argVars, font) in headerMappings.items(): - if key in headerString: - for argVar in argVars: - translatePlugins(argVar, font) - - # KN_StillManager: translate parameters[2] (the display label, e.g. "ギャラリーを開く") - # Only OPEN_GALLERY has a player-visible label in parameters[2]. - # Other commands (SHOW_BY_ID, HIDE, etc.) use parameters[2] as an internal label. - if (headerString == "KN_StillManager" and "KN_StillManager" in ENABLED_PLUGINS_357 - and len(codeList[i]["parameters"]) > 2 - and len(codeList[i]["parameters"]) > 1 - and codeList[i]["parameters"][1] == "OPEN_GALLERY"): - p2 = codeList[i]["parameters"][2] - if isinstance(p2, str) and p2.strip(): - if not (IGNORETLTEXT and not re.search(LANGREGEX, p2)): - if setData: - list357.append(p2) - else: - if len(list357) > 0: - translatedText = list357[0] - list357.pop(0) - translatedText = translatedText.replace('"', "") - codeList[i]["parameters"][2] = translatedText - - # AdvExtention plugin support (message event) - if headerString == "AdvExtentionllk" and len(codeList[i]["parameters"]) > 3: - try: - params_obj = codeList[i]["parameters"][3] - except Exception: - params_obj = None - - if isinstance(params_obj, dict): - # 1) Speaker comes from 'name', fallback to 'altName' if missing/empty - speaker_name = "" - if isinstance(params_obj.get("altName", None), str) and params_obj["altName"].strip(): - speaker_name = params_obj["altName"].strip() - if speaker_name: - response = getSpeaker(speaker_name) - params_obj["altName"] = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - speaker = response[0] - if isinstance(params_obj.get("name", None), str) and params_obj["name"].strip(): - speaker_name = params_obj["name"].strip() - if speaker_name: - response = getSpeaker(speaker_name) - params_obj["name"] = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - speaker = response[0] - speaker = "" - - # 2) Line comes from 'comment' if present, else 'text' - chosen_key = None - if isinstance(params_obj.get("comment", None), str) and params_obj["comment"].strip(): - chosen_key = "comment" - elif isinstance(params_obj.get("text", None), str): - chosen_key = "text" - - if chosen_key is not None: - jaString = params_obj.get(chosen_key, "") - if isinstance(jaString, str): - # Pass 1 (collect data) - if setData: - if FIXTEXTWRAP: - jaString = jaString.replace("\n", " ") - # Include speaker context like 401 does - if 'speaker' in locals() and isinstance(speaker, str) and speaker.strip(): - list357.append(f"[{speaker}]: {jaString}") - else: - list357.append(jaString) - # Pass 2 (apply translation) - else: - if len(list357) > 0: - translatedText = list357[0] - list357.pop(0) - - # Remove speaker prefix if present (same pattern used for 401) - m = re.search(r'(^\[.+?\]\s?[|:]\s?)', translatedText) - if m: - translatedText = translatedText.replace(m.group(1), "") - - if FIXTEXTWRAP: - translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) - - params_obj[chosen_key] = translatedText - - # VisuMZ_4_ProximityMessages handler - # Text:json value is stored as a JSON-encoded string, e.g. "\"\\\\{\\\\{text\"" - # After Python JSON parsing: "\\{\\{text" (outer quotes + \\{ formatting prefix) - if "VisuMZ_4_ProximityMessages" in headerString and len(codeList[i]["parameters"]) > 3: - params_obj = codeList[i]["parameters"][3] - if isinstance(params_obj, dict) and "Text:json" in params_obj: - rawValue = params_obj["Text:json"] - if isinstance(rawValue, str): - # Strip outer JSON quotes ("\"...\"" wrapper) - innerMatch = re.match(r'^"(.*)"$', rawValue, re.DOTALL) - innerText = innerMatch.group(1) if innerMatch else rawValue - - # Preserve \\{ / \\} RPGMaker font-size codes at start and end - prefixMatch = re.match(r'^((?:\\\\[{}])+)', innerText) - prefix = prefixMatch.group(1) if prefixMatch else "" - remaining = innerText[len(prefix):] - suffixMatch = re.search(r'((?:\\\\[{}])+)$', remaining) - suffix = suffixMatch.group(1) if suffixMatch else "" - jaString = remaining[: len(remaining) - len(suffix)] if suffix else remaining - - # Skip if IGNORETLTEXT is enabled and no Japanese text - skip = IGNORETLTEXT and not re.search(LANGREGEX, jaString) - if not skip and jaString.strip(): - # Pass 1 - if setData: - list357.append(jaString) - # Pass 2 - else: - if len(list357) > 0: - translatedText = list357[0] - list357.pop(0) - - # Remove characters that would break the JSON string encoding - translatedText = translatedText.replace('"', "'") - - # Normalize color/name codes to 4 backslashes (required for Text:json encoding) - translatedText = re.sub(r'\\{1,3}([cCnNiIvV]\[\d+\])', r'\\\\\\\\\1', translatedText) - - # Reassemble: restore outer quotes and formatting codes - params_obj["Text:json"] = f'"{prefix}{translatedText}{suffix}"' - - if headerString == "LL_GalgeChoiceWindow": - ### Message Text First - jaString = codeList[i]["parameters"][3]["messageText"] - - # Remove any textwrap & TL - jaString = re.sub(r"\n", " ", jaString) - response = translateAI(jaString, "") - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Textwrap & Set - translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) - codeList[i]["parameters"][3]["messageText"] = translatedText - - ### Choices - jaString = codeList[i]["parameters"][3]["choices"] - matchList = re.findall(r'"label[\\]*":[\\]*"(.*?)[\\]', jaString) - if matchList != None: - # Translate - question = codeList[i]["parameters"][3]["messageText"] - response = translateAI( - matchList, - f"Previous text for context: {question}\n", - True, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - translatedText = jaString - - # Replace Strings - for j in range(len(matchList)): - translatedText = translatedText.replace(matchList[j], response[0][j].replace('"', '')) - - # Set Data - codeList[i]["parameters"][3]["choices"] = translatedText - - ## Event Code: 657 [Picture Text] [Optional] - if "code" in codeList[i] and codeList[i]["code"] == 657 and CODE657 is True: - jaString = codeList[i]["parameters"][0] - if not isinstance(jaString, str): - i += 1 - continue - - # Definitely don't want to mess with files - if "_" in jaString: - i += 1 - continue - - # Only translate 'メッセージ = ' key/value pairs. - # All other keys (ページ番号, イベントID, アイコンID, etc.) are internal references. - kvMatch = re.match(r"^'?([^=]+?)\s*=\s*(.*?)'?$", jaString, re.DOTALL) - if kvMatch: - kvKey = kvMatch.group(1).strip() - kvValue = kvMatch.group(2).strip() - # Strip any outer single-quotes wrapping the value - kvValue = re.sub(r"^'(.*)'$", r"\1", kvValue) - - if kvKey != 'メッセージ': - i += 1 - continue - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, kvValue): - i += 1 - continue - - if not kvValue.strip(): - i += 1 - continue - - # Remove any textwrap - kvValue = re.sub(r"\n", " ", kvValue) - - # Pass 1 – collect value for batch translation - if setData: - list657.append(kvValue) - - # Pass 2 – apply translated value - else: - if len(list657) > 0: - translatedText = list657[0] - list657.pop(0) - for char in ['"', "'"]: - translatedText = translatedText.replace(char, "") - codeList[i]["parameters"][0] = f"'{kvKey} = {translatedText}'" - - ## Event Code: 101 [Name] [Optional] - if "code" in codeList[i] and codeList[i]["code"] == 101 and CODE101 is True: - isVar = False - - # Check for face name mappings first (before other processing) - if FACENAME101 and len(codeList[i]["parameters"]) > 0: - faceName = codeList[i]["parameters"][0] - if isinstance(faceName, str) and faceName: - matchedSpeaker = None - - # 1) _talk_ pattern: split on "_talk" and exact-match the prefix - if "_talk" in faceName: - prefix = faceName.split("_talk")[0] - matchedSpeaker = FACENAME101_MAP.get(prefix) - - # 2) Longest-prefix startswith match - if matchedSpeaker is None: - for prefix, name in FACENAME101_MAP_SORTED: - if faceName.startswith(prefix): - matchedSpeaker = name - break - - if matchedSpeaker is not None: - speaker = matchedSpeaker - i += 1 - continue - - # Grab String - jaString = "" - if len(codeList[i]["parameters"]) > 4: - # Set flag if first parameter has a non-empty string - if isinstance(codeList[i]["parameters"][0], str) and codeList[i]["parameters"][0].strip(): - reduceWidthFlag = True - jaString = codeList[i]["parameters"][4] - # Check for Var (only when parameters[0] is not a face file, - # i.e. fewer than 4 params — standard code 101 always has 4: - # [faceFile, faceIndex, background, position]) - elif 0 < len(codeList[i]["parameters"]) < 4: - jaString = codeList[i]["parameters"][0] - isVar = True - if not isinstance(jaString, str): - i += 1 - continue - - varActorMatch = re.match(r"^\s*(?:[\\]+[cC]\[\d+?\]\s*)?[\\]+[nN]\[(\d+)\]", jaString) - if varActorMatch: - actorName = _get_actor_map().get(int(varActorMatch.group(1))) - speaker = actorName or varActorMatch.group(0).strip() - i += 1 - continue - - # Force Speaker using var - if "memerisu" in jaString.lower(): - speaker = "Memerisu" - i += 1 - continue - elif "thina" in jaString.lower(): - speaker = "Tina" - i += 1 - continue - elif "\\ap" in jaString.lower(): - # Extract actor ID from format like \\AP[2左] or \\AP[2]仙人 - apMatch = re.search(r"[\\]+[aA][pP]\[(\d+)[^\]]*\](.*)$", jaString, re.IGNORECASE) - if apMatch: - actorId = int(apMatch.group(1)) - additionalText = apMatch.group(2).strip() - - # Load Actors.json to get the actor name - try: - actorsPath = Path("files/Actors.json") - if actorsPath.exists(): - with open(actorsPath, 'r', encoding='utf-8') as f: - actorsData = json.load(f) - - # Find the actor with matching ID - actorName = None - for actor in actorsData: - if actor and isinstance(actor, dict) and actor.get("id") == actorId: - actorName = actor.get("name", "") - break - - if actorName: - speaker = actorName - - # If there's additional text after \\AP[ID], translate it - if additionalText: - response = getSpeaker(additionalText) - translatedAdditionalText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Replace the text in the parameter - if isVar == False and len(codeList[i]["parameters"]) > 4: - codeList[i]["parameters"][4] = codeList[i]["parameters"][4].replace(additionalText, translatedAdditionalText) - else: - codeList[i]["parameters"][0] = codeList[i]["parameters"][0].replace(additionalText, translatedAdditionalText) - except Exception as e: - # If there's any error loading actors, just extract what's in the brackets - speaker = apMatch.group(1) - else: - # Fallback to old behavior - speaker = re.search(r"[\\]+AP\[(.*?)\]", jaString).group(1) - i += 1 - continue - - # Get Speaker - rawName = _101_name_source(codeList[i], isVar) - match = re.search(r"^(?:[\\]+[cC]\[\d+?\])?([^\\]+)", rawName) - if match: - sourceName = match.group(1) - response = getSpeaker(sourceName) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - speaker = response[0] - - # Validate Speaker is not empty - if len(speaker) > 0: - paramIdx = 0 if isVar else 4 - paramStr = codeList[i]["parameters"][paramIdx] - codeList[i]["parameters"][paramIdx] = _replace_speaker_in_param( - paramStr, sourceName, speaker - ) - _apply_original(codeList[i], rawName) - isVar = False - i += 1 - continue - else: - speaker = "" - - - ## Event Code: 355 or 655 Scripts [Optional] - if "code" in codeList[i] and (codeList[i]["code"] == 355 or codeList[i]["code"] == 655) and CODE355655 is True: - jaString = codeList[i]["parameters"][0] - - # Patterns — use module-level registry filtered by ENABLED_PATTERNS_355655 - patterns = { - k: v for k, v in PATTERNS_355655.items() - if k in ENABLED_PATTERNS_355655 - } - - for key, (regex, multiline) in patterns.items(): - if key in jaString: - # Multi-line pattern: spans 355 + subsequent 655 codes - # Each 655 line is translated separately (as a batch) and stays in its own line - if multiline and codeList[i]["code"] == 355: - textLines = [] - textLineIndices = [] - j = i + 1 - - while j < len(codeList) and codeList[j]["code"] == 655: - param = codeList[j]["parameters"][0] if codeList[j]["parameters"] else "" - textMatch = re.search(regex, param) - if textMatch: - text = _pat355655_captured_text(textMatch) - if not (IGNORETLTEXT and not re.search(LANGREGEX, text)): - textLines.append(text) - textLineIndices.append(j) - j += 1 - - if textLines: - if setData: - # Store each line separately for batch translation - for text in textLines: - list355655.append(text) - else: - # Apply each translated line back to its corresponding 655 code - for lineIdx in textLineIndices: - if len(list355655) > 0: - translatedText = list355655[0] - list355655.pop(0) - - # Replace quotes with apostrophes to avoid breaking plugin - translatedText = translatedText.replace('\\"', "'") - translatedText = translatedText.replace('"', "'") - - origParam = codeList[lineIdx]["parameters"][0] - origMatch = re.search(regex, origParam) - if origMatch: - old = _pat355655_captured_text(origMatch) - codeList[lineIdx]["parameters"][0] = origParam.replace(old, translatedText) - - i = j - 1 - break - - # Single-line pattern - else: - match = re.search(regex, jaString) - if match: - cap = _pat355655_captured_text(match) - if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', cap): - continue - - if IGNORETLTEXT and not re.search(LANGREGEX, cap): - continue - - if setData: - list355655.append(cap) - else: - translatedText = list355655[0] - list355655.pop(0) - - if "gameVariables.setValue" in codeList[i]["parameters"][0]: - translatedText = translatedText.replace('\"', "'") - - if "$gameVariables._data" in codeList[i]["parameters"][0]: - translatedText = re.sub(r"(? 0: - translatedText = list355655[0] - list355655.pop(0) - # Strip speaker prefix if present - translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText) - # Replace double quotes to avoid breaking the JSON/JS syntax - translatedText = translatedText.replace('\\"', "'") - translatedText = translatedText.replace('"', "'") - jaString = jaString.replace(s, translatedText, 1) - # Replace the speaker name directly (already translated via getSpeaker) - if nameStr and translatedName: - translatedName = translatedName.replace('\\"', "'") - translatedName = translatedName.replace('"', "'") - jaString = jaString.replace(nameStr, translatedName, 1) - codeList[i]["parameters"][0] = jaString - - # AddMaill handler - translate sender name (3rd quoted arg) and title (4th quoted arg) - # Example: this.AddMaill("M_IcoMail","liliy","リリィ","お得なクーポン配布",_MTxt,[24],193,true,504,1) - if "AddMaill(" in jaString: - # Extract all quoted strings in order - allQuoted = re.findall(r'\\?"([^"]*?)\\?"', jaString) - # args: [0]=icon, [1]=id, [2]=sender, [3]=title, ... - translatable = [] - translatableIndices = [] - for idx in [2, 3]: - if idx < len(allQuoted): - s = allQuoted[idx] - if not s.strip(): - continue - if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', s): - continue - if IGNORETLTEXT and not re.search(LANGREGEX, s): - continue - translatable.append(s) - translatableIndices.append(idx) - - if translatable: - if setData: - for s in translatable: - list355655.append(s) - else: - for s in translatable: - if len(list355655) > 0: - translatedText = list355655[0] - list355655.pop(0) - translatedText = translatedText.replace('\\"', "'") - translatedText = translatedText.replace('"', "'") - jaString = jaString.replace(s, translatedText, 1) - codeList[i]["parameters"][0] = jaString - - # # AddBbs handler - translate arrays of posts/replies, username, and location - # # Example: AddBbs(["この開発したパッチを..."], "コンピューターおじいちゃん","場所:猪鹿蝶",["良きパッチが..."],"patch_npc") - # if "AddBbs(" in jaString: - # translatable = [] - - # # Extract strings from the first array (topic posts) - # # Anchor with ],\s*\\?" after ] to skip past inner brackets like \\C[3] - # firstArrayMatch = re.search(r'AddBbs\s*\(\s*\[(.+?)\]\s*,\s*\\?"', jaString) - # firstArrayStrings = [] - # if firstArrayMatch: - # firstArrayStrings = re.findall(r'\\?"([^"]+?)\\?"', firstArrayMatch.group(1)) - # for s in firstArrayStrings: - # if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', s): - # continue - # if IGNORETLTEXT and not re.search(LANGREGEX, s): - # continue - # translatable.append(s) - - # # After the first array, extract: "username","location",["replies"],"picture_id" - # afterFirstArray = re.search(r'AddBbs\s*\(\s*\[.+?\]\s*,\s*(.*)\)\s*;?\s*$', jaString) - # nameStr = None - # translatedName = "" - # locationStr = None - # secondArrayStrings = [] - - # if afterFirstArray: - # rest = afterFirstArray.group(1) - - # # Username (first quoted string after the array) - # nameMatch = re.match(r'\s*\\?"([^"]+?)\\?"', rest) - # if nameMatch: - # n = nameMatch.group(1) - # if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', n): - # if not (IGNORETLTEXT and not re.search(LANGREGEX, n)): - # nameStr = n - # response = getSpeaker(n) - # translatedName = response[0] - # totalTokens[0] += response[1][0] - # totalTokens[1] += response[1][1] - - # # Location (second quoted string after array, before second array) - # locMatch = re.match(r'\s*\\?"[^"]*?\\?"\s*,\s*\\?"([^"]+?)\\?"', rest) - # if locMatch: - # loc = locMatch.group(1) - # if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', loc): - # if not (IGNORETLTEXT and not re.search(LANGREGEX, loc)): - # locationStr = loc - # translatable.append(loc) - - # # Second array (replies) - # # Anchor with ],\s*\\?" after ] to skip past inner brackets like \\C[3] - # secondArrayMatch = re.search(r',\s*\[(.+?)\]\s*,\s*\\?"', rest) - # if secondArrayMatch: - # secondArrayStrings = re.findall(r'\\?"([^"]+?)\\?"', secondArrayMatch.group(1)) - # for s in secondArrayStrings: - # if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', s): - # continue - # if IGNORETLTEXT and not re.search(LANGREGEX, s): - # continue - # translatable.append(s) - - # if translatable or nameStr: - # speakerPrefix = translatedName if translatedName else "" - - # if setData: - # for s in translatable: - # if speakerPrefix: - # list355655.append(f"[{speakerPrefix}]: {s}") - # else: - # list355655.append(s) - # else: - # for s in translatable: - # if len(list355655) > 0: - # translatedText = list355655[0] - # list355655.pop(0) - # translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText) - # translatedText = translatedText.replace('\\"', "'") - # translatedText = translatedText.replace('"', "'") - # jaString = jaString.replace(s, translatedText, 1) - # # Replace the username directly (already translated via getSpeaker) - # if nameStr and translatedName: - # translatedName = translatedName.replace('\\"', "'") - # translatedName = translatedName.replace('"', "'") - # jaString = jaString.replace(nameStr, translatedName, 1) - # # Normalize \\C and \\N codes to always have exactly 4 backslashes - # jaString = re.sub(r'\\+([cCnN]\[\d+\])', r'\\\\\1', jaString) - # codeList[i]["parameters"][0] = jaString - - # _MTxt handler - translates var _MTxt = "text" + "\n"; across 355 + 655 lines - # Code 355: var _MTxt = "text" + "\n"; - # Code 655: _MTxt += "text" + "\n"; - if "_MTxt" in jaString and codeList[i]["code"] == 355: - mtxtRegex = r'"(.+?)"\s*\+\s*"\\n"' - textLines = [] - textLineIndices = [] - - # Extract text from the 355 line itself - match355 = re.search(mtxtRegex, jaString) - if match355: - text = match355.group(1) - if not (IGNORETLTEXT and not re.search(LANGREGEX, text)): - textLines.append(text) - textLineIndices.append(i) - - # Extract text from subsequent 655 lines - j = i + 1 - while j < len(codeList) and codeList[j]["code"] == 655: - param = codeList[j]["parameters"][0] if codeList[j]["parameters"] else "" - if "_MTxt" in param: - textMatch = re.search(mtxtRegex, param) - if textMatch: - text = textMatch.group(1) - if not (IGNORETLTEXT and not re.search(LANGREGEX, text)): - textLines.append(text) - textLineIndices.append(j) - j += 1 - - if textLines: - if setData: - for text in textLines: - list355655.append(text) - else: - # Collect all translated lines and re-wrap them - translatedLines = [] - for _ in textLineIndices: - if len(list355655) > 0: - tl = list355655.pop(0) - tl = tl.replace('\\"', "'") - tl = tl.replace('"', "'") - translatedLines.append(tl) - - if translatedLines: - # Join all lines and re-wrap to WIDTH - combined = " ".join(translatedLines) - wrapped = dazedwrap.wrapText(combined, width=WIDTH) - wrappedLines = [l for l in wrapped.split("\n") if l.strip()] - - # Distribute wrapped lines across existing 355/655 slots - for idx, lineIdx in enumerate(textLineIndices): - if idx < len(wrappedLines): - origParam = codeList[lineIdx]["parameters"][0] - origMatch = re.search(mtxtRegex, origParam) - if origMatch: - codeList[lineIdx]["parameters"][0] = origParam.replace(origMatch.group(1), wrappedLines[idx]) - else: - # More slots than lines: blank out the text - origParam = codeList[lineIdx]["parameters"][0] - origMatch = re.search(mtxtRegex, origParam) - if origMatch: - codeList[lineIdx]["parameters"][0] = origParam.replace(origMatch.group(1), "") - - # If more wrapped lines than slots, insert new 655 codes - if len(wrappedLines) > len(textLineIndices): - lastIdx = textLineIndices[-1] - indent = codeList[lastIdx].get("indent", 0) - for extra in range(len(textLineIndices), len(wrappedLines)): - new_item = { - "code": 655, - "indent": indent, - "parameters": [ - ' _MTxt += "' + wrappedLines[extra] + '" + "\\n";' - ], - } - insertPos = lastIdx + 1 + (extra - len(textLineIndices)) - codeList.insert(insertPos, new_item) - # Adjust j to account for inserted items - j += len(wrappedLines) - len(textLineIndices) - - i = j - 1 - - # OpeSet handler - translate speaker name (1st arg) and dialogue text (2nd arg) - # Example: this.OpeSet(\"オペレーター\",\"今回の任務の内容は迷子になった少女を救出することです。\",\"ope\",180) - if "OpeSet(" in jaString: - # Extract speaker name (1st quoted arg) and text (2nd quoted arg) - nameMatch = re.search(r'OpeSet\s*\(\s*\\?"(.+?)\\?"\s*,', jaString) - textMatch = re.search(r'OpeSet\s*\(\s*\\?"[^"]*?\\?"\s*,\s*\\?"(.+?)\\?"', jaString) - - nameStr = None - translatedName = "" - textStr = None - - # Process speaker name via getSpeaker - if nameMatch: - n = nameMatch.group(1) - if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', n): - if not (IGNORETLTEXT and not re.search(LANGREGEX, n)): - nameStr = n - response = getSpeaker(n) - translatedName = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Process text (2nd arg) - if textMatch: - t = textMatch.group(1) - if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', t): - if not (IGNORETLTEXT and not re.search(LANGREGEX, t)): - textStr = t - - if textStr or nameStr: - speakerPrefix = translatedName if translatedName else "" - - if setData: - if textStr: - if speakerPrefix: - list355655.append(f"[{speakerPrefix}]: {textStr}") - else: - list355655.append(textStr) - else: - if textStr: - if len(list355655) > 0: - translatedText = list355655[0] - list355655.pop(0) - # Strip speaker prefix if present - translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText) - # Replace double quotes to avoid breaking JS syntax - translatedText = translatedText.replace('\\"', "'") - translatedText = translatedText.replace('"', "'") - jaString = jaString.replace(textStr, translatedText, 1) - # Replace the speaker name (already translated via getSpeaker) - if nameStr and translatedName: - translatedName = translatedName.replace('\\"', "'") - translatedName = translatedName.replace('"', "'") - jaString = jaString.replace(nameStr, translatedName, 1) - # Normalize \\N and \\C codes to always have exactly 4 backslashes - jaString = re.sub(r'\\+([cCnN]\[\d+\])', r'\\\\\1', jaString) - codeList[i]["parameters"][0] = jaString - - ## Event Code: 408 (Script) - if "code" in codeList[i] and (codeList[i]["code"] == 408) and CODE408 is True: - # Only translate if preceded by a 108 with "選択肢ヘルプ" or another 408 - if i > 0: - prevCode = codeList[i - 1].get("code", None) - if prevCode == 408: - pass # Consecutive 408s are allowed - elif prevCode == 108 and len(codeList[i - 1].get("parameters", [])) > 0 and codeList[i - 1]["parameters"][0] == "選択肢ヘルプ": - pass # 108 with 選択肢ヘルプ is allowed - else: - i += 1 - continue - - if not codeList[i].get("parameters"): - i += 1 - continue - - groupStart408 = i - j = i - source408Parts = [] - rawSource = _param_source(codeList[i], 0) - ojaString = rawSource - anchor408HasOrig = _scalar_original(codeList[groupStart408]) is not None - source408Parts.append(rawSource) - - if not rawSource.strip(): - i += 1 - continue - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, rawSource): - i += 1 - continue - - # Join Up 408's into single string - if len(codeList) > i + 1 and JOIN408 is True: - while codeList[i + 1]["code"] in [408] and len(codeList[i]["parameters"]) > 0 and len(codeList[i + 1]["parameters"]) > 0 and not re.match(r"^(\s*[\\]+[aAbBdDeEfFgGhHjJlLmMoOpPqQrRsStTuUwWxXyYzZ]+\[[\w\d\[\]\\]+\])", codeList[i+1]["parameters"][0]): - if not setData: - codeList[i]["parameters"] = [] - codeList[i]["code"] = -1 - i += 1 - j = i - - lineSource = _param_source(codeList[i], 0) - if lineSource.strip() and not anchor408HasOrig: - source408Parts.append(lineSource) - - if len(codeList) <= i + 1: - break - - rawSource = _group_raw_source(codeList, groupStart408, source408Parts) - ojaString = rawSource - jaString = rawSource.replace("\n", " ") - - # Pass 1 - if setData: - list408.append(jaString) - - # Pass 2 - else: - if len(list408) > 0: - translatedText = list408[0] - list408.pop(0) - - merged408 = len(source408Parts) > 1 - if merged408: - codeList[i]["parameters"] = [translatedText] - else: - param0 = codeList[i]["parameters"][0] - if ojaString in param0: - codeList[i]["parameters"][0] = param0.replace(ojaString, translatedText) - else: - flatSource = ojaString.replace("\n", " ") - if flatSource in param0: - codeList[i]["parameters"][0] = param0.replace(flatSource, translatedText) - else: - codeList[i]["parameters"][0] = translatedText - - _apply_original(codeList[i], rawSource) - - ## Event Code: 108 (Script) - if "code" in codeList[i] and (codeList[i]["code"] == 108) and CODE108 is True: - jaString = codeList[i]["parameters"][0] - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, jaString): - i += 1 - continue - - # Translate - if "info:" in jaString: - regex = r"info:([^,]+)" - elif "ActiveMessage:" in jaString: - regex = r"?" - elif "event_text" in jaString: - regex = r"event_text\s*:\s*(.*)" - elif "Menu Name" in jaString: - regex = r"Menu\sName\s*:\s*(.*)>" - elif "text_indicator" in jaString: - regex = r"text_indicator\s?:\s?(.+)" - elif "NW名前指定" in jaString: - regex = r"NW名前指定\s+(.+)" - else: - i += 1 - continue - - # Need to remove outside code and put it back later - match = re.search(regex, jaString) - if match: - # Pass 1 - if setData: - list108.append(match.group(1)) - - # # Grab Next - # j = i - # while codeList[j + 1]["code"] == 408: - # j += 1 - # list108[0] = list108[0] + codeList[j]["parameters"][0].replace(">", "") - # codeList[j]["parameters"][0] = "" - # list108[0] = list108[0].replace("\n", " ") - - # Pass 2 - else: - # Grab and Replace - translatedText = list108[0] - list108.pop(0) - - # Textwrap - # if codeList[i + 1]["code"] == 408: - # translatedText = dazedwrap.wrapText(translatedText, WIDTH) - - # Remove characters that may break scripts - charList = ['"'] - for char in charList: - translatedText = translatedText.replace(char, "") - translatedText = translatedText.replace('"', '"') - translatedText = translatedText.replace(" ", "_") - translatedText = jaString.replace(match.group(1), translatedText) - - # Add > - # if "ActiveMessage" in translatedText and ">" not in translatedText: - # translatedText = translatedText + ">" - - # Set Data - codeList[i]["parameters"][0] = translatedText - - ## Event Code: 356 - if "code" in codeList[i] and codeList[i]["code"] == 356 and CODE356 is True: - jaString = codeList[i]["parameters"][0] - oldjaString = jaString - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, jaString): - i += 1 - continue - - # Grab Speaker - if "Tachie showName" in jaString: - matchList = re.findall(r"Tachie showName (.+)", jaString) - if len(matchList) > 0: - # Translate - response = translateAI( - matchList[0], - "Reply with the " + LANGUAGE + " translation of the NPC name.", - False, - ) - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Set Text - speaker = translatedText - speaker = speaker.replace(" ", " ") - codeList[i]["parameters"][0] = jaString.replace(matchList[0], speaker) - i += 1 - continue - - # Want to translate this script - if "D_TEXT " in jaString: - regex = r"D_TEXT\s*(.+?)(?:\s+\d+)?$" - elif "ShowInfo" in jaString: - regex = r"ShowInfo\s(.*)" - elif "PushGab" in jaString: - regex = r"PushGab\s(.*)" - elif "addLog" in jaString: - regex = r"addLog\s(.*)" - elif "DW_" in jaString: - regex = r"DW_.*\s\d+\s(.+)" - elif "CommonPopup" in jaString: - regex = r"CommonPopup\sadd\stext:(.+?)(?=\s+count:|\s*$)" - elif "AddCustomChoice" in jaString: - regex = r"AddCustomChoice\s\d+\s(.+)\s\d" - else: - regex = r"" - - # Remove any textwrap - jaString = re.sub(r"\n", "_", jaString) - - # Capture Arguments and text - textMatch = re.search(regex, jaString) - if textMatch and textMatch.group(0) != "": - text = textMatch.group(1) - - # Capture Speakers - match = re.search(r"[\\]+ow\[\d+\][\\]+c\[\d+\](.+)", text) - if match: - speakerJA = match.group(1) - - # Translate - response = getSpeaker(speakerJA) - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - codeList[i]["parameters"][0] = jaString.replace(speakerJA, translatedText) - i += 1 - continue - else: - speaker = "" - - # Pass 1 - if setData: - text = text.replace("_", " ") - list356.append(text) - - # Pass 2 - else: - if len(list356) > 0: - # Grab - translatedText = list356[0] - - # Remove characters that may break scripts - # addLog keeps dots and quotes (they're fine in log text) - if "addLog" not in jaString: - charList = [".", '"'] - for char in charList: - translatedText = translatedText.replace(char, "") - - # Cant have spaces? - translatedText = translatedText.replace(" ", "_") - if "D_TEXT " not in jaString: - translatedText = translatedText.replace("__", "_") - - # Put Args Back - translatedText = jaString.replace(text, translatedText) - - # Set Data - codeList[i]["parameters"][0] = translatedText - list356.pop(0) - - if "namePop" in jaString: - # Support both "" and "namePop [num] text" formats - matchList = re.findall(r"]+)>", jaString) - if not matchList: - m = re.search(r"\bnamePop\b\s*(?:-?\d+)?\s*([^\r\n<>]+)", jaString) - if m: - matchList = [m.group(1).strip()] - if len(matchList) > 0: - # Translate - text = matchList[0] - response = translateAI(text, "Reply with the " + LANGUAGE + " Translation") - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Set Data - updated = jaString.replace(text, translatedText.replace(" ", "_")) - codeList[i]["parameters"][0] = updated - - if "LL_InfoPopupWIndowMV" in jaString: - matchList = re.findall(r"LL_InfoPopupWIndowMV\sshowWindow\s(.+?) .+", jaString) - if len(matchList) > 0: - text = matchList[0] - - # Pass 1: collect into batch - if setData: - # store without underscores for cleaner translation later - list356.append(text.replace("_", " ")) - - # Pass 2: apply translations from list356 - else: - if len(list356) > 0: - translatedText = list356[0] - list356.pop(0) - - # Replace spaces with underscores as original format expects - translatedText = translatedText.replace(" ", "_") - - # Put Args Back - translatedText = jaString.replace(text, translatedText) - - # Set Data - codeList[i]["parameters"][0] = translatedText - - if "OriginMenuStatus SetParam" in jaString: - matchList = re.findall(r"OriginMenuStatus\sSetParam\sparam[\d]\s(.*)", jaString) - if len(matchList) > 0: - # Translate - text = matchList[0] - response = translateAI(text, "Reply with the " + LANGUAGE + " Translation") - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Set Data - translatedText = translatedText.replace(" ", "_") - translatedText = jaString.replace(text, translatedText) - codeList[i]["parameters"][0] = translatedText - - # LL_GalgeChoiceWindowMV Message - if "LL_GalgeChoiceWindowMV setMessageText" in jaString: - ### Message Text First - match = re.search(r"LL_GalgeChoiceWindowMV setMessageText (.+)", jaString) - if match: - jaString = match.group(1) - - # Remove any textwrap & TL - jaString = re.sub(r"\n", " ", jaString) - response = translateAI(jaString, "") - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Textwrap & Replace Whitespace - translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) - translatedText = translatedText.replace(" ", "_") - - # Replace and Set - translatedText = match.group(0).replace(match.group(1), translatedText) - codeList[i]["parameters"][0] = translatedText - - # LL_GalgeChoiceWindowMV Choices - if "LL_GalgeChoiceWindowMV setChoices": - match = re.search(r"LL_GalgeChoiceWindowMV setChoices (.+)", jaString) - if match: - jaString = match.group(1) - choiceList = jaString.split(",") - - # Translate - question = translatedText - response = translateAI( - choiceList, - f"Previous text for context: {question}\n", - True, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - choiceListTL = response[0] - translatedText = match.group(0) - - # Replace Strings - for j in range(len(choiceListTL)): - choiceListTL[j] = choiceListTL[j].replace(" ", "_") - translatedText = translatedText.replace(choiceList[j], choiceListTL[j]) - - # Set Data - codeList[i]["parameters"][0] = translatedText - - ### Event Code: 102 Show Choice - if "code" in codeList[i] and codeList[i]["code"] == 102 and CODE102 is True: - choiceList = [] - varList = [] - choiceIndexMap = [] # Track which original indices we're processing - choiceSourceList = [] - - # Process each string in the parameters list - for choice in range(len(codeList[i]["parameters"][0])): - rawSource = _choice_source(codeList[i], choice) - jaString = rawSource.replace(" 。", ".") - - # Avoid Empty Strings - if not jaString.strip(): - continue - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, rawSource): - continue - - # If and En Statements - ifVar = "" - ifList = re.findall(r"([ei][nf]\(.+?\)\)?\)?)", jaString) - if len(ifList) != 0: - for var in ifList: - jaString = jaString.replace(var, "") - ifVar += var - - # Store the formatting and cleaned string - varList.append(ifVar) - choiceList.append(jaString) - choiceIndexMap.append(choice) - choiceSourceList.append(rawSource) - - # Translate the list - if len(choiceList) > 0: - if len(textHistory) > 0: - response = translateAI( - choiceList, - f"Reply with the English translation of the dialogue choice.\n\nPrevious text for context: {str(textHistory)}\n", - True, - ) - else: - response = translateAI(choiceList, "Reply with the English translation of the dialogue choice.") - - translatedTextList = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Check Mismatch and set translations - if len(translatedTextList) == len(choiceList): - for idx, translatedText in enumerate(translatedTextList): - originalIndex = choiceIndexMap[idx] - - # Apply formatting - if translatedText != "": - translatedText = varList[idx] + translatedText[0].upper() + translatedText[1:] - else: - translatedText = varList[idx] + translatedText - - # Set the translation back to the original position - codeList[i]["parameters"][0][originalIndex] = translatedText - _apply_choice_original(codeList[i], originalIndex, choiceSourceList[idx]) - else: - if filename not in MISMATCH: - MISMATCH.append(filename) - - ### Event Code: 111 Script - if "code" in codeList[i] and codeList[i]["code"] == 111 and CODE111 is True: - for j in range(len(codeList[i]["parameters"])): - jaString = codeList[i]["parameters"][j] - - # Check if String - if not isinstance(jaString, str): - continue - - # Only TL the Game Variable - if "$gameVariables" not in jaString: - continue - - # Need to remove outside code and put it back later - matchList = re.findall(r"['\"`](.*?)['\"`]", jaString) - - for match in matchList: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, match): - continue - - # Look up translation from code 122 cache (file-backed) - cachedTranslation = get_var_translation(match) - - if cachedTranslation is not None: - jaString = jaString.replace(match, cachedTranslation) - - # Set Data - codeList[i]["parameters"][j] = jaString - - ### Event Code: 320 Set Variable - if "code" in codeList[i] and codeList[i]["code"] == 320 and CODE320 is True: - jaString = codeList[i]["parameters"][1] - if not isinstance(jaString, str): - i += 1 - continue - - # Definitely don't want to mess with files - if "■" in jaString or "_" in jaString: - i += 1 - continue - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, jaString): - i += 1 - continue - - # Translate - response = getSpeaker(jaString) - translatedText = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Remove characters that may break scripts - charList = [".", '"', "'", "\\n"] - for char in charList: - translatedText = translatedText.replace(char, "") - - # Set Data - codeList[i]["parameters"][1] = translatedText - - ### Event Code: 325 - if "code" in codeList[i] and codeList[i]["code"] == 325 and CODE325 is True: - # Expect parameters like [index, "text"] where parameters[1] is the string - if len(codeList[i]["parameters"]) <= 1: - i += 1 - continue - - jaString = codeList[i]["parameters"][1] - if not isinstance(jaString, str): - i += 1 - continue - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, jaString): - i += 1 - continue - - # Remove Textwrap - collectString = jaString.replace("\n", " ") - - # Pass 1: collect into batch - if setData: - list325.append(collectString) - - # Pass 2: apply translations from batch - else: - if len(list325) > 0: - translatedText = list325[0] - list325.pop(0) - - # Textwrap - translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) - - # Set translated value back into parameters[1] - codeList[i]["parameters"][1] = "\\}\\}" + translatedText - - ### Event Code: 324 - if "code" in codeList[i] and codeList[i]["code"] == 324 and CODE324 is True: - # Expect parameters like [1, "text"] where index 1 is the string to translate - if len(codeList[i]["parameters"]) <= 1: - i += 1 - continue - - jaString = codeList[i]["parameters"][1] - if not isinstance(jaString, str): - i += 1 - continue - - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, jaString): - i += 1 - continue - - # Remove any textwrap for collection - collectString = jaString.replace("\n", " ") - - # Pass 1: collect - if setData: - list324.append(collectString) - - # Pass 2: apply translations from list324 - else: - if len(list324) > 0: - translatedText = list324[0] - list324.pop(0) - - # Clean translation - for ch in ['"', "\\n"]: - translatedText = translatedText.replace(ch, "") - - # Textwrap to reasonable width - translatedText = dazedwrap.wrapText(translatedText, width=LISTWIDTH) - - # Set translated value back into parameters[1] - codeList[i]["parameters"][1] = translatedText - - # Iterate - i += 1 - - # EOF - list401TL = [] - list408TL = [] - list324TL = [] - list122TL = [] - list356TL = [] - list357TL = [] - list355655TL = [] - list108TL = [] - list325TL = [] - list657TL = [] - PBAR = pbar - - # 401 - if len(list401) > 0: - response = translateAI(list401, "") - list401TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list401TL) != len(list401): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 122 - if len(list122) > 0: - response = translateAI(list122, "Keep your translation as brief as possible") - list122TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list122TL) != len(list122): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - else: - # Store each original→translated pair for code 111 consistency (file-backed) - set_var_translations_batch(list(zip(list122, list122TL))) - - # 355/655 - if len(list355655) > 0: - response = translateAI(list355655, textHistory) - list355655TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list355655TL) != len(list355655): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 108 - if len(list108) > 0: - response = translateAI(list108, "This text is a label. Use title capitalization and keep it brief.") - list108TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list108TL) != len(list108): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 356 - if len(list356) > 0: - response = translateAI(list356, textHistory) - list356TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list356TL) != len(list356): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 357 - if len(list357) > 0: - response = translateAI(list357, textHistory) - list357TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list357TL) != len(list357): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 657 - if len(list657) > 0: - response = translateAI(list657, textHistory) - list657TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list657TL) != len(list657): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 408 - if len(list408) > 0: - response = translateAI(list408, "") - list408TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list408TL) != len(list408): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 324 - if len(list324) > 0: - # Generic short-text translation for parameter index 1 - response = translateAI(list324, "Reply with only the " + LANGUAGE + " translation of the text.") - list324TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list324TL) != len(list324): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # 325 - if len(list325) > 0: - # Use same short-text speaker-style translation as other name fields - response = translateAI(list325, "Reply with the " + LANGUAGE + " translation of the NPC name.") - list325TL = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - if len(list325TL) != len(list325): - with LOCK: - if filename not in MISMATCH: - MISMATCH.append(filename) - - # Start Pass 2 - if setData: - searchCodes( - page, - pbar, - [ - list401TL, - list122TL, - list355655TL, - list108TL, - list356TL, - list357TL, - list324TL, - list408TL, - list325TL, - list657TL, - ], - filename, - ) - - # Delete all -1 codes - codeListFinal = [] - for i in range(len(codeList)): - if "code" in codeList[i] and codeList[i]["code"] != -1: - codeListFinal.append(codeList[i]) - - # Normal Format - if "list" in page: - page["list"] = codeListFinal - - # Special Format (Scenario) - else: - page[:] = codeListFinal - except IndexError as e: - traceback.print_exc() - except Exception as e: - traceback.print_exc() - - return totalTokens - - -def searchSS(state, pbar): - totalTokens = [0, 0] - - # --- Batch collection for basic fields and messages --- - batch_texts = [] - batch_map = [] # [(field_type, field_name, needs_taro_prefix), ...] - - # Name - if "name" in state and state["name"]: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, state["name"])): - batch_texts.append(state["name"]) - batch_map.append(("name", "name", False)) - - # Description - if "description" in state and state["description"]: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, state["description"])): - batch_texts.append(state["description"]) - batch_map.append(("description", "description", False)) - - # Messages - collect all with Taro prefix handling - for msg_field in ["message1", "message2", "message3", "message4"]: - if msg_field in state and state[msg_field]: - msg_text = state[msg_field] - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, msg_text): - continue - needs_taro = len(msg_text) > 0 and msg_text[0] in ["は", "を", "の", "に", "が"] - if needs_taro: - batch_texts.append("Taro" + msg_text) - else: - batch_texts.append(msg_text) - batch_map.append(("message", msg_field, needs_taro)) - - # --- Batch translate all basic fields --- - nameResponse = "" - descriptionResponse = "" - message1Response = "" - message2Response = "" - message3Response = "" - message4Response = "" - - if batch_texts: - response = translateAI( - batch_texts, - "reply with only the gender neutral " + LANGUAGE + " translation. For messages starting with Taro, always start the sentence with Taro. For example, translate 'Taroを倒した!' as 'Taro was defeated!'", - False, - ) - translated_batch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - - # Map translations back to their fields - for idx, (field_type, field_name, needs_taro) in enumerate(batch_map): - if idx < len(translated_batch): - translation = translated_batch[idx] - if field_type == "name": - nameResponse = [translation, [0, 0]] - elif field_type == "description": - descriptionResponse = [translation, [0, 0]] - elif field_type == "message": - response_obj = [translation, [0, 0]] - if field_name == "message1": - message1Response = response_obj - elif field_name == "message2": - message2Response = response_obj - elif field_name == "message3": - message3Response = response_obj - elif field_name == "message4": - message4Response = response_obj - - # --- Batching pass: collect all note texts for all note types --- - note_regexes = [ - (r"]*)>", False), - (r"\n(.*)\n", False), - (r"", False), - (r"", False), - (r"<説明:([^>]*)>", False), - ] - notesBatch = [] - notesBatchMap = [] - if "note" in state and state["note"]: - note = state["note"] - for regex, wordwrap in note_regexes: - matches = re.findall(regex, note, re.DOTALL) - for m in matches: - match_text = m if isinstance(m, str) else m[0] - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, match_text): - continue - notesBatch.append(match_text) - notesBatchMap.append((regex, match_text, wordwrap)) - - # --- Batch translate all notes --- - translatedNotesBatch = [] - if notesBatch: - response = translateAI(notesBatch, f"Reply with only the {LANGUAGE} translation of the note text.") - translatedNotesBatch = response[0] - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - # Notes don't update progress - - # --- Insert translated notes back --- - note_insert_idx = 0 - if "note" in state and state["note"]: - for regex, match_text, wordwrap in notesBatchMap: - if note_insert_idx >= len(translatedNotesBatch): - break - translated = translatedNotesBatch[note_insert_idx] - if wordwrap: - translated = dazedwrap.wrapText(translated, width=NOTEWIDTH) - translated = translated.replace('"', "") - # Replace only the matched text in the note using a literal replacement - # Avoid re.sub here because replacement strings with backslashes (e.g., \I) - # are interpreted as escapes and can raise re.PatternError. - state["note"] = state["note"].replace(match_text, translated, 1) - note_insert_idx += 1 - - # Progress accounting for this state: name + description + messages present - if pbar is not None: - work_units = 0 - work_units += 1 if nameResponse != "" else 0 - work_units += 1 if descriptionResponse != "" else 0 - work_units += 1 if message1Response != "" else 0 - work_units += 1 if message2Response != "" else 0 - work_units += 1 if message3Response != "" else 0 - work_units += 1 if message4Response != "" else 0 - if work_units: - pbar.refresh() - - # Set Data - if "name" in state and nameResponse != "": - state["name"] = nameResponse[0].replace('"', "") - if "description" in state and descriptionResponse != "": - # Textwrap - translatedText = descriptionResponse[0] - translatedText = dazedwrap.wrapText(translatedText, width=LISTWIDTH) - state["description"] = translatedText.replace('"', "") - if "message1" in state and message1Response != "": - state["message1"] = message1Response[0].replace('"', "").replace("Taro", "") - if "message2" in state and message2Response != "": - state["message2"] = message2Response[0].replace('"', "").replace("Taro", "") - if "message3" in state and message3Response != "": - state["message3"] = message3Response[0].replace('"', "").replace("Taro", "") - if "message4" in state and message4Response != "": - state["message4"] = message4Response[0].replace('"', "").replace("Taro", "") - - return totalTokens - - -def searchSystem(data, pbar): - totalTokens = [0, 0] - context = "Reply with only the " + LANGUAGE + ' translation of the UI textbox."' - - # Title - batch as a single-item list - # Skip if IGNORETLTEXT is enabled and no Japanese text - if not (IGNORETLTEXT and not re.search(LANGREGEX, data["gameTitle"])): - response = translateAI( - [data["gameTitle"]], - " Reply with the " + LANGUAGE + " translation of the game title name", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - data["gameTitle"] = response[0][0].strip(".") - if pbar is not None: - pbar.refresh() - - # Terms - batch translate all term items - for term in data["terms"]: - if term != "messages": - termList = data["terms"][term] - term_values = [] - term_indices = [] - for i in range(len(termList)): - if termList[i] is not None: - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, str(termList[i])): - continue - term_values.append(termList[i]) - term_indices.append(i) - - if term_values: - response = translateAI(term_values, context) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - - for n, idx in enumerate(term_indices[: len(tl_list)]): - termList[idx] = tl_list[n].replace('"', "").strip() - - if pbar is not None: - pbar.refresh() - - # Armor Types - batch translate all - armor_values = [] - armor_indices = [] - for i in range(len(data["armorTypes"])): - val = data["armorTypes"][i] - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and (not val or not re.search(LANGREGEX, str(val))): - continue - armor_values.append(val) - armor_indices.append(i) - if armor_values: - response = translateAI( - armor_values, - "Reply with only the " + LANGUAGE + " translation of the armor type", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - for n, idx in enumerate(armor_indices[: len(tl_list)]): - data["armorTypes"][idx] = tl_list[n].replace('"', "").strip() - if pbar is not None: - pbar.refresh() - - # Skill Types - batch translate all - skill_values = [] - skill_indices = [] - for i in range(len(data["skillTypes"])): - val = data["skillTypes"][i] - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and (not val or not re.search(LANGREGEX, str(val))): - continue - skill_values.append(val) - skill_indices.append(i) - if skill_values: - response = translateAI( - skill_values, - "Reply with only the " + LANGUAGE + " translation", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - for n, idx in enumerate(skill_indices[: len(tl_list)]): - data["skillTypes"][idx] = tl_list[n].replace('"', "").strip() - if pbar is not None: - pbar.refresh() - - # Equip Types - batch translate all (not present in RPG Maker Ace) - equip_values = [] - equip_indices = [] - for i in range(len(data.get("equipTypes", []) or [])): - val = data["equipTypes"][i] - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and (not val or not re.search(LANGREGEX, str(val))): - continue - equip_values.append(val) - equip_indices.append(i) - if equip_values: - response = translateAI( - equip_values, - "Reply with only the " + LANGUAGE + " translation of the equipment type. No disclaimers.", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - for n, idx in enumerate(equip_indices[: len(tl_list)]): - data["equipTypes"][idx] = tl_list[n].replace('"', "").strip() - if pbar is not None: - pbar.refresh() - - # Elements - batch translate all (skip empty) - element_values = [] - element_indices = [] - for i in range(len(data["elements"])): - if data["elements"][i]: # Skip empty strings - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, str(data["elements"][i])): - continue - element_values.append(data["elements"][i]) - element_indices.append(i) - - if element_values: - response = translateAI( - element_values, - "Reply with only the " + LANGUAGE + " translation of the element type", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - for n, idx in enumerate(element_indices[: len(tl_list)]): - data["elements"][idx] = tl_list[n].replace('"', "").strip() - if pbar is not None: - pbar.refresh() - - # Weapon Types - batch translate all (skip empty) - weapon_values = [] - weapon_indices = [] - for i in range(len(data["weaponTypes"])): - if data["weaponTypes"][i]: # Skip empty strings - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, str(data["weaponTypes"][i])): - continue - weapon_values.append(data["weaponTypes"][i]) - weapon_indices.append(i) - - if weapon_values: - response = translateAI( - weapon_values, - "Reply with only the " + LANGUAGE + " translation of the weapon type", - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - for n, idx in enumerate(weapon_indices[: len(tl_list)]): - data["weaponTypes"][idx] = tl_list[n].replace('"', "").strip() - if pbar is not None: - pbar.refresh() - - # Variables (Optional usually) — batch translate to reduce calls - if TLSYSTEMVARIABLES and "variables" in data and isinstance(data["variables"], list): - var_indices = [] - var_values = [] - for idx, val in enumerate(data["variables"]): - if isinstance(val, str) and val.strip(): - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, val): - continue - var_indices.append(idx) - var_values.append(val) - if var_values: - response = translateAI( - var_values, - 'Reply with only the ' + LANGUAGE + ' translation of the title', - True, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - # Assign back translations to corresponding indices - for n, idx in enumerate(var_indices[: len(tl_list)]): - data["variables"][idx] = tl_list[n].replace('"', '').strip() - if pbar is not None: - pbar.refresh() - - # Switches (Optional) — batch translate to reduce calls - if TLSYSTEMSWITCHES and "switches" in data and isinstance(data["switches"], list): - switch_indices = [] - switch_values = [] - for idx, val in enumerate(data["switches"]): - if isinstance(val, str) and val.strip(): - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, val): - continue - switch_indices.append(idx) - switch_values.append(val) - if switch_values: - response = translateAI( - switch_values, - 'Reply with only the ' + LANGUAGE + ' translation of the switch name', - True, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - # Assign back translations to corresponding indices - for n, idx in enumerate(switch_indices[: len(tl_list)]): - data["switches"][idx] = tl_list[n].replace('"', '').strip() - if pbar is not None: - pbar.refresh() - - # Messages — batch translate to reduce calls - messages = data["terms"]["messages"] - if messages: - msg_keys = [] - msg_values = [] - for key, value in messages.items(): - if isinstance(value, str) and value.strip(): - # Skip if IGNORETLTEXT is enabled and no Japanese text - if IGNORETLTEXT and not re.search(LANGREGEX, value): - continue - msg_keys.append(key) - msg_values.append(value) - - if msg_values: - response = translateAI( - msg_values, - "Reply with only the " - + LANGUAGE - + ' translation of the battle text.\nTranslate "常時ダッシュ" as "Always Dash"\nTranslate "次の%1まで" as Next %1.', - False, - ) - totalTokens[0] += response[1][0] - totalTokens[1] += response[1][1] - tl_list = response[0] - - # Remove characters that may break scripts - charList = [".", '"', "\\n"] - - # Assign back translations to corresponding keys - for n, key in enumerate(msg_keys[: len(tl_list)]): - translatedText = tl_list[n] - for char in charList: - translatedText = translatedText.replace(char, "") - messages[key] = translatedText - - if pbar is not None: - pbar.refresh() - - return totalTokens - -# Regex that matches one or more markup codes like \c[1], \n[2], \ow[3], etc. -_MARKUP_STRIP_RE = re.compile(r"[\\]+[a-zA-Z]+\[[\w\d]*\]") - -def _is_plausible_speaker(name: str) -> bool: - """Return True only if *name* looks like a character name rather than dialogue or junk. - - Called during SPEAKER_PARSE_MODE to filter false positives before they - enter SPEAKER_COLLECTED. Heuristics (applied after stripping markup): - • 1–20 characters long - • Contains at least one Japanese character (kana / kanji) - • No sentence-ending / mid-sentence punctuation (。!?…、) - • No dialogue-opening quotes (「"() - • No newlines, underscores, slashes, or dots - """ - clean = _MARKUP_STRIP_RE.sub("", name).strip() - if not clean: - return False - if len(clean) > 20: - return False - # Must have at least one Japanese character - if not re.search(r"[\u3040-\u30FA\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uFF61-\uFF9F]", clean): - return False - # Reject sentence-like strings - if re.search(r"[。!?…、]", clean): - return False - # Reject dialogue openers / structural characters - if re.search(r"[「」""\n\r_/\\.]", clean): - return False - return True - - -# Save some money and enter the character before translation -def getSpeaker(speaker: str): - """Return (and possibly collect) speaker name. - - Parse mode (SPEAKER_PARSE_MODE=True): - - Don't translate immediately. Collect unique originals in SPEAKER_COLLECTED. - - Return original so caller logic works; token cost is zero. - - Normal mode: translate immediately with caching. - """ - if speaker == "": - return ["", [0, 0]] - - # Preflight count mode: skip translation and caching entirely - if 'PREFLIGHT_COUNT_MODE' in globals() and PREFLIGHT_COUNT_MODE: - return [speaker, [0, 0]] - - if SPEAKER_PARSE_MODE: - with _speakerCacheLock: - if speaker in _speakerCache: - return [_speakerCache[speaker], [0, 0]] - if speaker not in SPEAKER_COLLECTED and _is_plausible_speaker(speaker): - SPEAKER_COLLECTED.append(speaker) - return [speaker, [0, 0]] - - # Normal mode translation path - with _speakerCacheLock: - cached = _speakerCache.get(speaker) - if cached is not None: - return [cached, [0, 0]] - - try: - THREAD_CTX.in_speaker = True - except Exception: - pass - response = translateAI( - speaker, - "Reply with the " + LANGUAGE + " translation of the NPC name.", - False, - ) - try: - THREAD_CTX.in_speaker = False - except Exception: - pass - translated = response[0].strip().title().replace("'S", "'s").replace("Speaker: ", "") - translated = re.sub(r'(\d)(St|Nd|Rd|Th)\b', lambda m: m.group(1) + m.group(2).lower(), translated) - - if re.search(r"([a-zA-Z??])", translated) is None: - try: - THREAD_CTX.in_speaker = True - except Exception: - pass - response = translateAI( - speaker, - "Reply with the " + LANGUAGE + " translation of the NPC name.", - False, - ) - try: - THREAD_CTX.in_speaker = False - except Exception: - pass - translated = response[0].strip().title().replace("'S", "'s") - translated = re.sub(r'(\d)(St|Nd|Rd|Th)\b', lambda m: m.group(1) + m.group(2).lower(), translated) - - with _speakerCacheLock: - if speaker not in _speakerCache: - _speakerCache[speaker] = translated - NAMESLIST.append([speaker, translated]) - return [translated, response[1]] - -def _get_actor_map() -> dict: - """Lazily load actor_id -> name from Actors.json, falling back to vocab actor entries.""" - global _ACTOR_MAP_CACHE - with _ACTOR_MAP_CACHE_LOCK: - if _ACTOR_MAP_CACHE: - return _ACTOR_MAP_CACHE - for candidate in (Path("translated/Actors.json"), Path("files/Actors.json")): - if candidate.is_file(): - try: - data = json.loads(candidate.read_text(encoding="utf-8-sig")) - m: dict = {} - for entry in data: - if not entry or not isinstance(entry, dict): - continue - aid = entry.get("id") - name = (entry.get("name") or "").strip() - if aid is not None and name: - m[int(aid)] = name - if m: - _ACTOR_MAP_CACHE = m - return m - except Exception: - continue - try: - m: dict = {} - for line in VOCAB.splitlines(): - match = re.search(r"\(([^()]+)\)\s*-\s*.*?\bactor\s+ID\s+(\d+)\b", line, re.IGNORECASE) - if match: - name = match.group(1).strip() - aid = int(match.group(2)) - if name: - m[aid] = name - if m: - _ACTOR_MAP_CACHE = m - return m - except Exception: - pass - _ACTOR_MAP_CACHE = {} - return {} - - -def resetActorMapCache(): - """Invalidate the cached actor map so it reloads on next use.""" - global _ACTOR_MAP_CACHE - with _ACTOR_MAP_CACHE_LOCK: - _ACTOR_MAP_CACHE = None - - -def translateAI(text, history, history_ctx=None): - """ - Legacy wrapper function for the new shared translation utility. - This maintains compatibility with existing code while using the new shared implementation. - """ - global PBAR, MISMATCH, FILENAME - - # Update config estimate mode based on global ESTIMATE - TRANSLATION_CONFIG.estimateMode = bool(ESTIMATE) - - # Call the new shared translation function - # Prefer thread-local filename for logging; fall back to global - try: - tl_filename = getattr(THREAD_CTX, "filename", FILENAME) - except Exception: - tl_filename = FILENAME - - # Speaker-parse mode: bypass all non-speaker translations to save tokens - if SPEAKER_PARSE_MODE and not getattr(THREAD_CTX, "in_speaker", False): - # Return original text unmodified with zero tokens - return [text, [0, 0]] - - # Preflight count mode: don't hit API; just simulate progress units - if 'PREFLIGHT_COUNT_MODE' in globals() and PREFLIGHT_COUNT_MODE: - try: - n = len(text) if isinstance(text, list) else 1 - except Exception: - n = 1 - if PBAR is not None: - try: - with LOCK: - PBAR.update(n) - except Exception: - pass - # Return original payload and zero tokens so totals aren't affected - return [text, [0, 0]] - - # ── Actor variable substitution ────────────────────────────────────────── - # Replace \n[X] codes with actor names before sending to AI so the model - # sees real character names. Restore only exact-case name matches afterward; - # this avoids lower-case words like "red" and keeps the prompt clean. - actor_map = _get_actor_map() - reverse: dict[str, str] = {} # actor_name -> "\\n[X]" - - def _sub(s: str, reverse_map: dict[str, str]) -> str: - if not isinstance(s, str) or not actor_map: - return s - - def _display_actor_name(m: re.Match) -> str: - name = actor_map.get(int(m.group(1))) - return name if name else m.group(0) - - def _repl(m: re.Match) -> str: - aid = int(m.group(1)) - name = actor_map.get(aid) - if name: - reverse_map[name] = m.group(0) - return name - return m.group(0) - - speaker_prefix = re.match( - r"^(?P\s*\[)(?P(?:\\n\[\d+\]|[^\]\n])+)(?P\]\s*[|:]\s*)", - s, - re.IGNORECASE, - ) - if speaker_prefix: - speaker = _VAR_ACTOR_RE.sub(_display_actor_name, speaker_prefix.group("speaker")) - body = _VAR_ACTOR_RE.sub(_repl, s[speaker_prefix.end():]) - return f"{speaker_prefix.group('open')}{speaker}{speaker_prefix.group('close')}{body}" - - return _VAR_ACTOR_RE.sub(_repl, s) - - if isinstance(text, list): - item_reverses: list[dict[str, str]] = [] - subbed_text = [] - for s in text: - item_reverse: dict[str, str] = {} - subbed_text.append(_sub(s, item_reverse)) - item_reverses.append(item_reverse) - text = subbed_text - else: - item_reverses = [] - text = _sub(text, reverse) - - result = sharedtranslateAI( - text=text, - history=history, - config=TRANSLATION_CONFIG, - filename=tl_filename, - pbar=PBAR, - lock=LOCK, - mismatchList=MISMATCH - ) - - # ── Restore \n[X] codes in translated output ─────────────────────────── - def _restore(s: str, reverse_map: dict[str, str]) -> str: - if not isinstance(s, str) or not reverse_map: - return s - restore_pat = re.compile( - r"(? name before AI, name -> \n[X] after) +_ACTOR_MAP_CACHE: dict | None = None +_ACTOR_MAP_CACHE_LOCK = threading.Lock() +_VAR_ACTOR_RE = re.compile(r"\\n\[(\d+)\]", re.IGNORECASE) + +# Regex - Need to change this if you want to translate from/to other languages. Default is Japanese Regex +LANGREGEX = r"[\u3000\u3002-\u3009\u300C-\u303F\u3040-\u309A\u309C-\u30FA\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]+" + +# Get pricing configuration based on the model +PRICING_CONFIG = getPricingConfig(MODEL) +INPUTAPICOST = PRICING_CONFIG["inputAPICost"] +OUTPUTAPICOST = PRICING_CONFIG["outputAPICost"] +BATCHSIZE = PRICING_CONFIG["batchSize"] +FREQUENCY_PENALTY = PRICING_CONFIG["frequencyPenalty"] + +# tqdm Globals +BAR_FORMAT = "{desc}: {percentage:3.0f}%|{bar:10}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]" +POSITION = 0 + +# Initialize Translation Config +TRANSLATION_CONFIG = TranslationConfig( + model=MODEL, + language=LANGUAGE, + prompt=PROMPT, + vocab=VOCAB, + langRegex=LANGREGEX, + batchSize=BATCHSIZE, + maxHistory=MAXHISTORY, + estimateMode=False # Will be set dynamically based on ESTIMATE +) +LEAVE = False + +# Config (Default) +# FIRSTLINESPEAKERS: Guess speaker from first line. +FIRSTLINESPEAKERS = False +# INLINE401SPEAKERS: Extract speaker from "Name「dialogue」" inline format on 401 lines. +INLINE401SPEAKERS = False +# FACENAME101: Map face name -> speaker. +FACENAME101 = False +# Face name -> speaker mapping for FACENAME101. +# Matching: if face string contains "_talk", split on it and look up the prefix; +# otherwise try startswith against each key (longest key first). +FACENAME101_MAP = { + "aglo": "Agro", + "Ai": "AI", + "cron": "Cron", + "diado": "Diad", + "doctor": "Doctor", + "dragon": "Dragon", + "dragonpeaple": "Dragonpeople", + "Eno": "Eno", + "fight": "Fight", + "kajua": "Kajua", + "last_boss": "Last Boss", + "MC": "MC", + "mizel": "Mizel", + "peaple": "People", + "professor": "Professor", + "ReceptionWoman": "ReceptionWoman", + "risa": "Risalue", + "roma": "Romasha", + "romasha": "Romasha", + "spina_dragonewt": "Spina Dragonewt", + "spina": "Spina", + "supi": "Supi", + "TMob": "TMob", + "TMobBlue": "TMobBlue", + "TMobGreen": "TMobGreen", + "TMobOrange": "TMobOrange", + "TMobPink": "TMobPink", + "TMobsyota": "TMobsyota", + "TMobYellow": "TMobYellow", + "TMobZERO": "TMobZERO", + "Trash": "Trash", + "underpeaple": "Underpeople", + "vanila": "Vanilla", + "Yudo": "Yudonge", + "zizi": "Zizi", +} +# Pre-sorted by key length descending so longer prefixes match first. +FACENAME101_MAP_SORTED = sorted(FACENAME101_MAP.items(), key=lambda x: len(x[0]), reverse=True) +# BRFLAG: Newlines ->
. +BRFLAG = False +# FIXTEXTWRAP: Rewrap text to WIDTH/NOTEWIDTH. +FIXTEXTWRAP = True +# IGNORETLTEXT: Skip Translated Text. +IGNORETLTEXT = False +# TLSYSTEMVARIABLES: Translate System Variables. (Optional but sometimes necessary. Can break stuff.) +TLSYSTEMVARIABLES = False +# TLSYSTEMSWITCHES: Translate System Switches. (Optional. Translates switch names in System.json.) +TLSYSTEMSWITCHES = False +# Join 408 codes into a single string like 401. +JOIN408 = False + +# Dialogue / Scroll / Choices (Main Codes) +CODE101 = False +CODE401 = False +CODE405 = False +CODE102 = False + +# Optional +CODE408 = False + +# Variables +CODE122 = False +CODE122_VAR_MIN = 0 +CODE122_VAR_MAX = 2000 + +# Plugins / Scripts +CODE355655 = False +CODE357 = False +CODE657 = False +CODE356 = False +CODE320 = False +CODE324 = False +CODE325 = False +CODE111 = False +CODE108 = False + +# ─── Plugin Manager ────────────────────────────────────────────────────────── +# All known code-357 headerMapping entries. Enable entries via ENABLED_PLUGINS_357. +# The GUI reads this dict to build the checkbox list dynamically. +HEADER_MAPPINGS_357 = { + "LL_InfoPopupWIndow": (["messageText"], None), + "QuestSystem": (["DetailNote"], None), + "BalloonInBattle": (["text"], None), + "MNKR_CommonPopupCoreMZ": (["text"], None), + "DestinationWindow": (["destination"], None), + "_TMLogWindowMZ": (["text"], None), + "TorigoyaMZ_NotifyMessage": (["message"], None), + "SoR_GabWindow": (["arg1"], None), + "DarkPlasma_CharacterText": (["text"], None), + "DTextPicture": (["text"], None), + "TextPicture": (["text"], None), + "TRP_SkitMZ": (["name"], None), + "LogWindow": (["text"], None), + "BattleLogOutput": (["message"], None), + "TorigoyaMZ_NotifyMessage_CommandMessage": (["message"], None), + "NUUN_SaveScreen": (["AnyName"], None), + "build/ARPG_Core": (["Text", "SkillByName"], None), + "EventLabel": (["text"], None), + "KN_MapBattle": (["enemyName"], None), + "KN_Shop": (["goodsType"], None), + "KN_StillManager": (["label"], None), # OPEN_GALLERY category label in parameters[3] + "Mano_CurrencyUnit": (["unit"], None), + "SceneGlossary": (["category"], None), +} +# Subset of HEADER_MAPPINGS_357 keys that should be processed (empty = none). +ENABLED_PLUGINS_357: set = set() + +# All known code-355/655 script patterns. Enable entries via ENABLED_PATTERNS_355655. +PATTERNS_355655 = { + "テキスト-": (r"テキスト-(.+)", False), + "=": (r'=\s?(.*)",', False), + "var text": (r'var\stext\d+\s=\s\"(.+)\"', False), + "logtxt = ": (r"logtxt\s=\s'(.+)'", False), + ".setNickname": (r'.setNickname\(\\?"(.+?)\\?"\)', False), + "_subject=": (r'_subject=(.+?)(?=[_\\"\]])', False), + "text =": (r"text\s*=\s*'(.+[^\\])'", False), + "const text": (r'(const\stext\s?=\s?"(.+)";?)', False), + "ex_a_name": (r'ex_a_name\(\d+,"(.+)"\)', False), + "gameVariables.setValue": (r'\$gameVariables\.setValue\(\d+,\s*"([^"]*)"\)', False), + "$gameVariables._data": (r"\$gameVariables\._data(?:\[[^\]]+\])+\s*=\s*['\"]((?:\\.|[^'\"\\])*)['\"]", False), + "$gameMessage.add": (r"\$gameMessage\.add\(.+?\)(.+?)", True), + "BattleManager._logWindow.push('addText'": (r"BattleManager._logWindow.push\('addText',\s'(.+)'\)", False), + # Supports addText('msg'), addText("msg"), and addText(expr+'msg') where expr contains () e.g. .members() + "BattleManager._logWindow.addText": ( + r"BattleManager\._logWindow\.addText\(\s*(?:(?:[^()]|\([^)]*\))*\+\s*)?(['\"])((?:\\.|(?!\1).)*)\1\s*\)", + True, + ), + "let out": (r"let\s+out\d+\s*=\s*\(.+?\)(.+?)", True), + "moji": (r"(?:let\s+)?moji\s*\+?=\s*(.+)", True), + "this.BLogAdd": (r'this\.BLogAdd\?(.+?\\?"(.+?)\\?"\)', False), + "Fuki_Set": (r'Fuki_Set\([\s,\d\w\W]+?"(.+?)",', False), + "_EventSetting": (r'_EventSetting[\s,\d\w\W]+?"(.+?)";', False), + "this.Menu_SexTxtSet(": (r'"(.+)"', True), + "Rn_RsltTxtArr": (r'"(.+)"', True), + "_章切り替えStart": (r'_章切り替えStart\(\s*\\?"\s?,?.+?\\?"\s?,?\s?\\?"(.+?)\\?"', False), + "SkillLogAdd": (r'SkillLogAdd\((?:.+?\+\s*)?\\?"(?:\\\\+[A-Za-z]\[\d+\])?(.+?)\\?"', False), + "MobNameSet": (r'MobNameSet\(\\?"(.+?)\\?"\)', False), + "AddAddress": (r'AddAddress\(\d+,\s*\\?"(.+?)\\?"', False), +} +# Subset of PATTERNS_355655 keys that should be processed (empty = none). +ENABLED_PATTERNS_355655: set = {"BattleManager._logWindow.addText"} + + +def _pat355655_captured_text(match): + """Substring to translate for PATTERNS_355655; last capture group is always the visible text.""" + return match.group(match.lastindex) + + +def handleMVMZ(filename, estimate): + global ESTIMATE, TOKENS, FILENAME, MISMATCH + ESTIMATE = estimate + FILENAME = filename + MISMATCH = [] # Reset per-file; prevents cross-file contamination in CLI mode + # Also record per-thread filename to avoid cross-thread interference + try: + THREAD_CTX.filename = filename + except Exception: + pass + + # Translate + start = time.time() + translatedData = openFiles(filename) + + # Translate + # Skip writing output file during speaker-parse mode + if not estimate and not SPEAKER_PARSE_MODE: + 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" + + # Print File + end = time.time() + tqdm.write(getResultString(translatedData, end - start, filename)) + with LOCK: + TOKENS[0] += translatedData[1][0] + TOKENS[1] += translatedData[1][1] + + # Print Total + totalString = getResultString(["", TOKENS, None], end - start, "TOTAL") + + # Print any errors on maps + if len(MISMATCH) > 0: + return totalString + Fore.RED + f"\nMismatch Errors: {MISMATCH}" + Fore.RESET + else: + return totalString + + +def openFiles(filename): + with open("files/" + filename, "r", encoding="utf-8-sig") as f: + data = json.load(f) + + # Map Files + if "Map" in filename and "MapInfos" not in filename: + translatedData = parseMap(data, filename) + + # CommonEvents Files + elif "CommonEvents" in filename: + translatedData = parseCommonEvents(data, filename) + + # Actor File + elif "Actors" in filename: + translatedData = parseNames(data, filename, "Actors") + + # Armor File + elif "Armors" in filename: + translatedData = parseNames(data, filename, "Armors") + + # Weapons File + elif "Weapons" in filename: + translatedData = parseNames(data, filename, "Weapons") + + # Classes File + elif "Classes" in filename: + translatedData = parseNames(data, filename, "Classes") + + # Enemies File + elif "Enemies" in filename: + translatedData = parseNames(data, filename, "Enemies") + + # Items File + elif "Items" in filename: + translatedData = parseNames(data, filename, "Items") + + # MapInfo File + elif "MapInfos" in filename: + translatedData = parseNames(data, filename, "MapInfos") + + # Skills File + elif "Skills" in filename: + translatedData = parseNames(data, filename, "Skills") + + # Troops File + elif "Troops" in filename: + translatedData = parseTroops(data, filename) + + # States File + elif "States" in filename: + translatedData = parseSS(data, filename) + + # System File + elif "System" in filename: + translatedData = parseSystem(data, filename) + + # Scenario File + elif "Scenario" in filename: + translatedData = parseScenario(data, filename) + + else: + raise NameError(filename + " Not Supported") + + return translatedData + + +def getResultString(translatedData, translationTime, filename): + global TIMETOTAL + # File Print String + cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL) + totalTokenstring = ( + Fore.YELLOW + "[Input: " + str(translatedData[1][0]) + "]" + "[Output: " + + str(translatedData[1][1]) + + "]" "[Cost: ${:,.4f}".format(cost) + + "]" + ) + if filename != "TOTAL": + timeString = Fore.BLUE + "[" + str(round(translationTime, 1)) + "s]" + TIMETOTAL += round(translationTime, 1) + else: + timeString = Fore.BLUE + "[" + str(round(TIMETOTAL, 1)) + "s]" + + if translatedData[2] is None: + # Success + return filename + ": " + totalTokenstring + timeString + Fore.GREEN + " \u2713 " + Fore.RESET + else: + # Fail + try: + raise translatedData[2] + except Exception as e: + traceback.print_exc() + errorString = str(e) + Fore.RED + return filename + ": " + totalTokenstring + timeString + Fore.RED + " \u2717 " + errorString + Fore.RESET + + +def saveProgress(data, filename): + """Atomically write current data to translated/filename to avoid progress loss. + Skips when running in estimate mode. + """ + try: + # Also skip progress saves during speaker-parse mode + if ESTIMATE or SPEAKER_PARSE_MODE: + return + os.makedirs("translated", exist_ok=True) + # Use a unique temp file name to avoid collisions across threads/processes + tmp_path = os.path.join( + "translated", + f"{filename}.{os.getpid()}.{threading.get_ident()}.tmp", + ) + final_path = os.path.join("translated", filename) + with open(tmp_path, "w", encoding="utf-8", newline="\n") as outFile: + json.dump(data, outFile, ensure_ascii=False, indent=4) + outFile.flush() + try: + os.fsync(outFile.fileno()) + except Exception: + # fsync may not be available on some platforms; ignore best-effort + pass + + # Replace atomically when possible, with retries to mitigate transient locks on Windows + attempts = 6 + delay = 0.1 + last_err = None + for attempt in range(attempts): + try: + os.replace(tmp_path, final_path) + last_err = None + break + except PermissionError as e: + last_err = e + # Try to relax permissions on target if it exists, then back off + try: + if os.path.exists(final_path): + os.chmod(final_path, 0o666) + except Exception: + pass + time.sleep(delay) + delay = min(1.0, delay * 2) + except Exception as e: + last_err = e + break + if last_err is not None: + # Fallback: try move via shutil (not guaranteed atomic), then raise on failure + try: + shutil.move(tmp_path, final_path) + except Exception: + # Ensure tmp is cleaned up if move failed + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + raise last_err + except Exception: + # Best-effort; don't crash the translation if saving fails + traceback.print_exc() + + +def _scalar_original(cmd) -> str | None: + """Return scalar _original on an event command, or None if absent/empty.""" + orig = cmd.get("_original") + if orig is not None and not isinstance(orig, list) and str(orig).strip(): + return str(orig) + return None + + +def _param_source(cmd, index: int) -> str: + """Prefer scalar _original; else parameters[index] (401/405 dialogue lines).""" + orig = _scalar_original(cmd) + if orig is not None: + return orig + params = cmd.get("parameters") or [] + if index < len(params) and params[index] is not None: + return str(params[index]) + return "" + + +def _group_source(codeList, start: int, end: int) -> str: + """Join source text for a merged 401/405 group (indices start..end inclusive).""" + if start < len(codeList): + orig = _scalar_original(codeList[start]) + if orig is not None: + return orig + parts = [] + for idx in range(start, end + 1): + if idx >= len(codeList): + break + cmd = codeList[idx] + if not cmd or cmd.get("code") not in (401, 405, -1): + continue + params = cmd.get("parameters") or [] + if not params: + continue + src = _param_source(cmd, 0) + if src.strip(): + parts.append(src) + return "\n".join(parts) + + +def _group_raw_source(codeList, group_start: int, source_parts: list[str]) -> str: + """Batch source for merged 401/405; anchor _original wins on re-run.""" + if group_start < len(codeList): + orig = _scalar_original(codeList[group_start]) + if orig is not None: + return orig + return "\n".join(source_parts) + + +def _apply_original(cmd, raw_source: str) -> None: + """Set scalar _original only when not already present (re-run safe).""" + if not raw_source or not str(raw_source).strip(): + return + if _scalar_original(cmd) is not None: + return + cmd["_original"] = raw_source + + +def _choice_source(cmd, index: int) -> str: + """Prefer _original[index] for code 102 choices; else parameters[0][index].""" + orig_list = cmd.get("_original") + if isinstance(orig_list, list) and index < len(orig_list): + slot = orig_list[index] + if slot is not None and str(slot).strip(): + return str(slot) + params = cmd.get("parameters") or [[]] + choices = params[0] if params else [] + if isinstance(choices, list) and index < len(choices) and choices[index] is not None: + return str(choices[index]) + return "" + + +def _apply_choice_original(cmd, index: int, raw_source: str) -> None: + """Set _original[index] for code 102 only when that slot is empty.""" + if not raw_source or not str(raw_source).strip(): + return + params = cmd.get("parameters") or [[]] + choices = params[0] if params else [] + n = len(choices) if isinstance(choices, list) else 0 + orig_list = cmd.get("_original") + if not isinstance(orig_list, list): + orig_list = [None] * n + cmd["_original"] = orig_list + while len(orig_list) < n: + orig_list.append(None) + if index < len(orig_list): + existing = orig_list[index] + if existing is not None and str(existing).strip(): + return + orig_list[index] = raw_source + + +def _122_inner_source(cmd) -> str | None: + """Inner quoted value for code 122: _original or extract from parameters[4].""" + orig = _scalar_original(cmd) + if orig is not None: + return orig + params = cmd.get("parameters") or [] + if len(params) <= 4: + return None + jaString = params[4] + if not isinstance(jaString, str): + return None + if len(re.findall(r"([\'\"\`])", jaString)) >= 2: + matchedText = re.search(r"[\'\"\`](.*)[\'\"\`]", jaString) + if matchedText and matchedText.group(1).strip(): + return matchedText.group(1) + return None + + +def _101_name_source(cmd, is_var: bool) -> str: + """Speaker name field for code 101: _original or parameters[4]/[0].""" + orig = _scalar_original(cmd) + if orig is not None: + return orig + params = cmd.get("parameters") or [] + if is_var and len(params) > 0 and params[0] is not None: + return str(params[0]) + if not is_var and len(params) > 4 and params[4] is not None: + return str(params[4]) + return "" + + +def _entry_orig(entry) -> dict: + """Return _original dict on a database entry, or empty dict if absent.""" + orig = entry.get("_original") if isinstance(entry, dict) else None + return orig if isinstance(orig, dict) else {} + + +def _entry_field_source(entry, field: str) -> str: + """Prefer _original[field]; else entry[field] (database scalar fields).""" + if not isinstance(entry, dict): + return "" + orig = _entry_orig(entry) + slot = orig.get(field) + if slot is not None and not isinstance(slot, (dict, list)) and str(slot).strip(): + return str(slot) + val = entry.get(field) + if val is not None: + return str(val) + return "" + + +def _apply_entry_field_original(entry, field: str, raw: str) -> None: + """Set _original[field] only when empty and raw contains Japanese.""" + if not isinstance(entry, dict) or not raw or not str(raw).strip(): + return + if not re.search(LANGREGEX, raw): + return + orig = entry.get("_original") + if not isinstance(orig, dict): + orig = {} + entry["_original"] = orig + existing = orig.get(field) + if existing is not None and not isinstance(existing, (dict, list)) and str(existing).strip(): + return + orig[field] = raw + + +def _system_orig(data) -> dict: + """Get or create root _original dict on System.json.""" + orig = data.get("_original") if isinstance(data, dict) else None + if isinstance(orig, dict): + return orig + orig = {} + data["_original"] = orig + return orig + + +def _system_scalar_source(data, field: str) -> str: + """Prefer root _original[field]; else data[field].""" + if not isinstance(data, dict): + return "" + orig = data.get("_original") + if isinstance(orig, dict): + slot = orig.get(field) + if slot is not None and not isinstance(slot, (dict, list)) and str(slot).strip(): + return str(slot) + val = data.get(field) + if val is not None: + return str(val) + return "" + + +def _apply_system_scalar_original(data, field: str, raw: str) -> None: + """Set root _original[field] only when empty and raw contains Japanese.""" + if not raw or not str(raw).strip() or not re.search(LANGREGEX, raw): + return + orig = _system_orig(data) + existing = orig.get(field) + if existing is not None and not isinstance(existing, (dict, list)) and str(existing).strip(): + return + orig[field] = raw + + +def _system_list_source(data, list_name: str, index: int) -> str: + """Prefer _original[list_name][str(index)]; else data[list_name][index].""" + if not isinstance(data, dict): + return "" + orig = data.get("_original") + if isinstance(orig, dict): + list_orig = orig.get(list_name) + if isinstance(list_orig, dict): + slot = list_orig.get(str(index)) + if slot is not None and str(slot).strip(): + return str(slot) + lst = data.get(list_name) or [] + if index < len(lst) and lst[index] is not None: + return str(lst[index]) + return "" + + +def _apply_system_list_original(data, list_name: str, index: int, raw: str) -> None: + """Set _original[list_name][str(index)] only when empty and raw contains Japanese.""" + if not raw or not str(raw).strip() or not re.search(LANGREGEX, raw): + return + orig = _system_orig(data) + list_orig = orig.get(list_name) + if not isinstance(list_orig, dict): + list_orig = {} + orig[list_name] = list_orig + key = str(index) + existing = list_orig.get(key) + if existing is not None and str(existing).strip(): + return + list_orig[key] = raw + + +def _system_terms_source(data, category: str, index: int) -> str: + """Prefer _original.terms[category][str(index)]; else terms[category][index].""" + if not isinstance(data, dict): + return "" + orig = data.get("_original") + if isinstance(orig, dict): + terms_orig = orig.get("terms") + if isinstance(terms_orig, dict): + cat_orig = terms_orig.get(category) + if isinstance(cat_orig, dict): + slot = cat_orig.get(str(index)) + if slot is not None and str(slot).strip(): + return str(slot) + term_list = (data.get("terms") or {}).get(category) + if isinstance(term_list, list) and index < len(term_list) and term_list[index] is not None: + return str(term_list[index]) + return "" + + +def _apply_system_terms_original(data, category: str, index: int, raw: str) -> None: + """Set _original.terms[category][str(index)] only when empty and raw contains Japanese.""" + if not raw or not str(raw).strip() or not re.search(LANGREGEX, raw): + return + orig = _system_orig(data) + terms = orig.get("terms") + if not isinstance(terms, dict): + terms = {} + orig["terms"] = terms + cat = terms.get(category) + if not isinstance(cat, dict): + cat = {} + terms[category] = cat + key = str(index) + existing = cat.get(key) + if existing is not None and str(existing).strip(): + return + cat[key] = raw + + +def _system_terms_message_source(data, key: str) -> str: + """Prefer _original.terms.messages[key]; else terms.messages[key].""" + if not isinstance(data, dict): + return "" + orig = data.get("_original") + if isinstance(orig, dict): + terms_orig = orig.get("terms") + if isinstance(terms_orig, dict): + msg_orig = terms_orig.get("messages") + if isinstance(msg_orig, dict): + slot = msg_orig.get(key) + if slot is not None and str(slot).strip(): + return str(slot) + messages = (data.get("terms") or {}).get("messages") or {} + val = messages.get(key) + if val is not None: + return str(val) + return "" + + +def _apply_system_terms_message_original(data, key: str, raw: str) -> None: + """Set _original.terms.messages[key] only when empty and raw contains Japanese.""" + if not raw or not str(raw).strip() or not re.search(LANGREGEX, raw): + return + orig = _system_orig(data) + terms = orig.get("terms") + if not isinstance(terms, dict): + terms = {} + orig["terms"] = terms + msg = terms.get("messages") + if not isinstance(msg, dict): + msg = {} + terms["messages"] = msg + existing = msg.get(key) + if existing is not None and str(existing).strip(): + return + msg[key] = raw + + +_COLOR_SPEAKER_RE = re.compile( + r"^[\\]+[cC]\[\d+\]【?(.+?)】?[\\]+[cC]\[\d+\](?:[\\]+[A-Za-z]+(?:\[[^\]]*\])?)*[\\]*$" +) + + +def _replace_speaker_in_param(param_str: str, source_name: str, translated_name: str) -> str: + """Replace a speaker name inside a 401/101 parameter while keeping colour/bracket wrappers.""" + if not param_str or not translated_name: + return param_str + m = _COLOR_SPEAKER_RE.match(param_str) + if m: + return param_str.replace(m.group(1), translated_name, 1) + bracket_disp = re.findall(r"【(.+?)】", param_str) + if bracket_disp: + return param_str.replace(bracket_disp[0], translated_name, 1) + if source_name and source_name in param_str: + return param_str.replace(source_name, translated_name, 1) + return param_str + + +def checkSave(data, filename, tokens): + """Save progress only if the given tokens reflect an actual translation. + tokens should be a [input_tokens, output_tokens] pair returned by a search/translate call. + """ + try: + # Never save progress to translated/ during speaker-parse mode + if SPEAKER_PARSE_MODE: + return + if not tokens: + return + if (isinstance(tokens, (list, tuple)) and len(tokens) >= 2 and (tokens[0] or tokens[1])): + saveProgress(data, filename) + except Exception: + # Don't let saving issues affect the translation flow + traceback.print_exc() + + +def update_vocab_section(category: str, pairs: list[tuple[str, str]]): + """Update or insert a section in vocab.txt for the given category with provided pairs. + Only writes when there's an actual translation (dst is non-empty and differs from src after normalization). + - category: e.g., "Items", "Weapons", etc. Section header will be "# {category}". + - pairs: list of (source, translated) strings. Duplicates by source are deduped (last wins). + The existing section is replaced entirely; other sections are preserved. + """ + try: + vocab_path = Path("vocab.txt") + + # Helper: normalized comparison to detect no-op translations + def _norm(s: str) -> str: + if s is None: + return "" + # Collapse whitespace and case-fold; leave punctuation to avoid over-matching + return re.sub(r"\s+", " ", str(s)).strip().casefold() + + # Filter and deduplicate by source term (last mapping wins) + dedup: dict[str, str] = {} + for src, dst in pairs: + if not src: + continue + # Skip when no destination or no actual change + if dst is None or _norm(dst) == "" or _norm(dst) == _norm(src): + continue + dedup[src] = dst + + # If nothing to add after filtering, skip touching the file + if not dedup: + return + + # Guard the read-modify-write with a dedicated lock to avoid races + with VOCAB_LOCK: + existing = vocab_path.read_text(encoding="utf-8") if vocab_path.exists() else "" + + lines = [f"{src} ({dst})" for src, dst in dedup.items()] + # Always terminate a section with a blank line to separate from next header + new_block = f"# {category}\n" + "\n".join(lines) + if not new_block.endswith("\n\n"): + if not new_block.endswith("\n"): + new_block += "\n" + new_block += "\n" + + # Regex to find the specific section starting at the header for this category + # and ending right before the next header (any number of '#') or EOF. + # - Handles headers like '#Category', '# Category', '## Category', etc. + # - Uses non-greedy matching for the body to avoid spanning multiple sections. + pattern = re.compile( + rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)", + re.MULTILINE | re.DOTALL, + ) + if pattern.search(existing): + # Replace only the first matching section for this category. + updated = pattern.sub(lambda m: new_block, existing, count=1) + else: + updated = existing + if updated and not updated.endswith("\n\n"): + # Ensure a blank line before appending new section if file not empty + if not updated.endswith("\n"): + updated += "\n" + updated += "\n" + updated += new_block + + # Avoid writing if nothing changed + if updated == existing: + return + # Atomic write: write to unique temp and replace with retries on Windows + tmp_path = vocab_path.with_suffix(vocab_path.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp") + tmp_path.write_text(updated, encoding="utf-8") + + attempts = 6 + delay = 0.1 + last_err = None + for attempt in range(attempts): + try: + os.replace(tmp_path, vocab_path) + last_err = None + break + except PermissionError as e: + last_err = e + # Try relaxing permissions then retry + try: + if vocab_path.exists(): + os.chmod(vocab_path, 0o666) + except Exception: + pass + time.sleep(delay) + delay = min(1.0, delay * 2) + except Exception as e: + last_err = e + break + if last_err is not None: + try: + shutil.move(str(tmp_path), str(vocab_path)) + except Exception: + try: + if tmp_path.exists(): + tmp_path.unlink(missing_ok=True) + except Exception: + pass + raise last_err + except Exception: + traceback.print_exc() + + +def parseMap(data, filename): + totalTokens = [0, 0] + events = data["events"] + global LOCK + + # --- Preflight: estimate exact progress total using the same translation batching --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + # Silent during preflight + pass + def refresh(self): + pass + + def _estimate_map_units(d, fname) -> int: + # Avoid deep copy - just count items directly + count = 0 + try: + # Count display name TL (1 unit if present) + if "Map" in fname and isinstance(d.get("displayName", None), str): + count += 1 + + # Notes and pages - count actual translatable items + evts = d.get("events", []) or [] + for evt in evts: + if not evt: + continue + note_val = evt.get("note") or "" + if not isinstance(note_val, str): + note_val = str(note_val) if note_val is not None else "" + + # Count note-based translations + if "" in note_val: + name_val = evt.get("name") or "" + if isinstance(name_val, str) and name_val: + count += 1 + + if "", note_val, re.DOTALL) + count += len(matches) + + if ".*", note_val) + count += len(matches) + if ".*", note_val) + count += len(matches) + + # Count commands in pages (rough estimate) + for page in (evt.get("pages", []) or []): + if page and "list" in page: + # Count translatable codes + for cmd in page.get("list", []): + if cmd and "code" in cmd: + code = cmd["code"] + # Count common translatable codes + if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: + count += 1 + + return count if count > 0 else 1 + except Exception: + return 1 + + # Translate displayName for Map files + if "Map" in filename: + response = translateAI( + data["displayName"], + "Reply with only the " + LANGUAGE + " translation of the RPG location name", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + data["displayName"] = response[0].replace('"', "") + + # Compute accurate total using preflight (includes speakers, choices, groups, and notes) + totalLines = _estimate_map_units(data, filename) + if not isinstance(totalLines, int) or totalLines <= 0: + # Fallback to naive count so a bar still renders + totalLines = 0 + for event in events: + if event: + for page in event.get("pages", []) or []: + try: + totalLines += len(page.get("list", [])) + except Exception: + pass + global PBAR + + # Process each page synchronously with progress updates + with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + + # Batch translate event names + lbTokens = translateLBNames(events) + totalTokens[0] += lbTokens[0] + totalTokens[1] += lbTokens[1] + + for event in events: + if event is not None: + # Normalize note to a safe string + note_val = event.get("note") or "" + if not isinstance(note_val, str): + note_val = str(note_val) if note_val is not None else "" + + # This translates ID of events. (May break the game) + if ".*") + if tok and isinstance(tok, (list, tuple)): + totalTokens[0] += tok[0] + totalTokens[1] += tok[1] + if ".*") + if tok and isinstance(tok, (list, tuple)): + totalTokens[0] += tok[0] + totalTokens[1] += tok[1] + + for page in event["pages"]: + if page is not None: + try: + totalTokensPage = searchCodes(page, pbar, [], filename) + totalTokens[0] += totalTokensPage[0] + totalTokens[1] += totalTokensPage[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist progress only if this page produced tokens + checkSave(data, filename, totalTokensPage) + return [data, totalTokens, None] + + +def _normalize_sg_desc(text: str) -> str: + """Normalize SG description text before AI translation. + + Japanese body text is hard-wrapped at screen width using bare \\n. + This collapses those intra-paragraph newlines into spaces so the AI + receives clean prose paragraphs, while preserving: + - \\n\\n paragraph / section breaks + - ◆ / ・ / • / ● header lines (kept on their own line) + """ + HEADER_CHARS = ("◆", "・", "•", "●") + blocks = text.split("\n\n") + normalized_blocks = [] + for block in blocks: + lines = block.split("\n") + result_lines: list[str] = [] + body_buf: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith(HEADER_CHARS): + if body_buf: + result_lines.append(" ".join(body_buf)) + body_buf = [] + result_lines.append(stripped) + elif stripped: + body_buf.append(stripped) + if body_buf: + result_lines.append(" ".join(body_buf)) + normalized_blocks.append("\n".join(result_lines)) + return "\n\n".join(normalized_blocks) + + + # Regex String + jaString = event.get("note") or "" + if not isinstance(jaString, str): + jaString = str(jaString) if jaString is not None else "" + match = re.findall(regex, jaString, re.DOTALL) + if match: + tokens = [0, 0] + i = 0 + while i < len(match): + initialJAString = match[i] + modifiedJAString = initialJAString + # Remove any textwrap + if wordwrap: + modifiedJAString = modifiedJAString.replace("\n", " ") + + # Translate + response = translateAI( + modifiedJAString, + "Reply with only the " + LANGUAGE + " translation.", + False, + ) + translatedText = response[0] + tokens[0] += response[1][0] + tokens[1] += response[1][1] + + # Textwrap + if wordwrap: + translatedText = dazedwrap.wrapText(translatedText, width=NOTEWIDTH) + translatedText = translatedText.replace('"', "") + + jaString = jaString.replace(initialJAString, translatedText) + event["note"] = jaString + i += 1 + return tokens + return [0, 0] + + +# For notes that can't have spaces. +def translateNoteOmitSpace(event, regex): + # Regex that only matches text inside LB. + jaString = event.get("note") or "" + if not isinstance(jaString, str): + jaString = str(jaString) if jaString is not None else "" + + match = re.findall(regex, jaString, re.DOTALL) + if match: + oldJAString = match[0] + # Remove any textwrap + jaString = re.sub(r"\n", " ", oldJAString) + + # Translate + response = translateAI( + jaString, + "Reply with the " + LANGUAGE + " translation of the location name.", + False, + ) + # Defend against unexpected response shapes + try: + translatedText = response[0] + token_info = response[1] if isinstance(response, (list, tuple)) and len(response) > 1 else [0, 0] + if not (isinstance(token_info, (list, tuple)) and len(token_info) >= 2): + token_info = [0, 0] + except Exception: + translatedText = str(response) if response is not None else "" + token_info = [0, 0] + + translatedText = translatedText.replace('"', "") + translatedText = translatedText.replace(" ", "_") + # Safely update the note if it exists and is a string + current_note = event.get("note") + if isinstance(current_note, str): + event["note"] = current_note.replace(oldJAString, translatedText) + return token_info + return [0, 0] + + +def translateLBNames(events): + """Batch translate event names for events with tag. + Collects all names, translates in a single batch, then applies results. + Returns [input_tokens, output_tokens]. + """ + totalTokens = [0, 0] + + # Collect events with tag that have translatable names + lb_events = [] # List of (event_index, original_name) + for idx, event in enumerate(events): + if event is None: + continue + note_val = event.get("note") or "" + if not isinstance(note_val, str): + note_val = str(note_val) if note_val is not None else "" + + if "" in note_val: + name_val = event.get("name") or "" + if isinstance(name_val, str) and name_val and re.search(LANGREGEX, name_val): + lb_events.append((idx, name_val)) + + # Batch translate if we have any + if lb_events: + names_to_translate = [item[1] for item in lb_events] + response = translateAI( + names_to_translate, + "Reply with only the " + LANGUAGE + " translation of the name.", + True, + ) + translated_names = response[0] if isinstance(response[0], list) else [response[0]] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Apply translations back to events + for i, (evt_idx, _) in enumerate(lb_events): + if i < len(translated_names): + events[evt_idx]["name"] = translated_names[i].replace('"', "").replace(" ", "_") + + return totalTokens + + +def parseCommonEvents(data, filename): + totalTokens = [0, 0] + global LOCK + + # --- Preflight: estimate exact progress total using same batching --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + pass + def refresh(self): + pass + + def _estimate_units(pages, fname) -> int: + # Avoid deep copy - just count commands directly + count = 0 + try: + for page in pages: + if page is not None and "list" in page: + for cmd in page.get("list", []): + if cmd and "code" in cmd: + code = cmd["code"] + if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: + count += 1 + return count if count > 0 else 1 + except Exception: + return 1 + + totalLines = _estimate_units(data, filename) + if not isinstance(totalLines, int) or totalLines <= 0: + # Fallback to naive command count + totalLines = 0 + for page in data: + if page is not None: + try: + totalLines += len(page.get("list", [])) + except Exception: + pass + global PBAR + + with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + for page in data: + if page is not None: + try: + totalTokensPage = searchCodes(page, pbar, [], filename) + totalTokens[0] += totalTokensPage[0] + totalTokens[1] += totalTokensPage[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist progress only if this page produced tokens + checkSave(data, filename, totalTokensPage) + return [data, totalTokens, None] + + +def parseTroops(data, filename): + totalTokens = [0, 0] + global LOCK + + # --- Preflight total using same code paths --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + pass + def refresh(self): + pass + + def _estimate_units(troops, fname) -> int: + # Avoid deep copy - just count commands directly + count = 0 + try: + for troop in troops: + if troop is None: + continue + for page in (troop.get("pages", []) or []): + if page is not None and "list" in page: + for cmd in page.get("list", []): + if cmd and "code" in cmd: + code = cmd["code"] + if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: + count += 1 + return count if count > 0 else 1 + except Exception: + return 1 + + totalLines = _estimate_units(data, filename) + if not isinstance(totalLines, int) or totalLines <= 0: + totalLines = 0 + for troop in data: + if troop is not None: + for page in troop.get("pages", []) or []: + try: + totalLines += len(page.get("list", [])) + except Exception: + pass + global PBAR + + with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + for troop in data: + if troop is not None: + for page in troop["pages"]: + if page is not None: + try: + totalTokensPage = searchCodes(page, pbar, [], filename) + totalTokens[0] += totalTokensPage[0] + totalTokens[1] += totalTokensPage[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist progress only if this page produced tokens + checkSave(data, filename, totalTokensPage) + return [data, totalTokens, None] + + +def parseNames(data, filename, context): + totalTokens = [0, 0] + + # --- Preflight: custom estimator that mirrors searchNames increments (incl. notes/messages) --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + pass + def refresh(self): + pass + + def _estimate_names_units(entries, ctx, fname) -> int: + # Avoid deep copy - just count fields directly + count = 0 + try: + note_regexes = [ + (r"", False), + (r"", False), + (r"<[Hh]int:(.*?)>", False), + (r"", False), + (r"", True), + (r"", False), + (r"", False), + (r"", False), + (r"", True), + (r"", False), + (r"\n(.*)\n", False), + (r"", False), + (r"WATs:(.+?)>", False), + (r"ADTs?:(.+?)>", False), + (r"", False), + (r"", False), + (r"]+)", True), + (r"]+)", True), + (r"]+)", True), + (r"", True), + (r"", True), + (r"", False), + (r"<拡張説明:(.+?)>", False), + (r"\n(.+?)\n<", False), + (r"text:(.+)>", False), + (r"\d][^,>]*)", False), + (r"\n?(.*?)", False), + (r"<コメント:\n?(.*?)>", True), + ] + + for entry in entries: + if not entry: + continue + nm = entry.get("name") or "" + ds = entry.get("description") or "" + nn = entry.get("nickname") or "" + pf = entry.get("profile") or "" + if ctx == "Actors": + if nm: count += 1 + if nn: count += 1 + if pf: count += 1 + elif ctx in ["Armors", "Weapons", "Items"]: + if nm: count += 1 + if ds: count += 1 + elif ctx == "Skills": + if nm: count += 1 + if ds: count += 1 + for k in range(1,5): + if entry.get(f"message{k}"): count += 1 + elif ctx in ["Enemies", "Classes", "MapInfos"]: + if nm: count += 1 + + # Notes counting + note = entry.get("note") or "" + if isinstance(note, str) and note: + for regex, _ww in note_regexes: + try: + matches = re.findall(regex, note, re.DOTALL) + except Exception: + matches = [] + if regex.startswith(r" 0 else 1 + except Exception: + return 1 + + total_units = _estimate_names_units(data, context, filename) + if not isinstance(total_units, int) or total_units <= 0: + # Reasonable fallback: count visible fields/messages (no notes) + total_units = 0 + for entry in data: + if not entry: + continue + if entry.get("name"): total_units += 1 + if context in ["Armors", "Weapons", "Items", "Skills"] and entry.get("description"): total_units += 1 + if context == "Actors": + if entry.get("nickname"): total_units += 1 + if entry.get("profile"): total_units += 1 + if context == "Skills": + for k in range(1,5): + if entry.get(f"message{k}"): total_units += 1 + global PBAR + + with tqdm(total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + try: + # Thread the filename through so progress saves write to the right file + result = searchNames(data, pbar, context, filename) + totalTokens[0] += result[0] + totalTokens[1] += result[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist progress only if this names pass produced tokens + checkSave(data, filename, totalTokens) + return [data, totalTokens, None] + + +def parseSS(data, filename): + totalTokens = [0, 0] + + # --- Preflight using searchSS over deep copy --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + pass + def refresh(self): + pass + + def _estimate_units(states, fname) -> int: + # Avoid deep copy - just count fields directly + count = 0 + try: + for st in states: + if not st: + continue + if st.get("name"): count += 1 + if st.get("description"): count += 1 + for n in range(1,5): + if st.get(f"message{n}"): count += 1 + return count if count > 0 else 1 + except Exception: + return 1 + + total_units = _estimate_units(data, filename) + if not isinstance(total_units, int) or total_units <= 0: + total_units = 0 + for st in data: + if not st: + continue + if st.get("name"): total_units += 1 + if st.get("description"): total_units += 1 + for n in range(1,5): + if st.get(f"message{n}"): total_units += 1 + global PBAR + + with tqdm(total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + for ss in data: + if ss is not None: + try: + result = searchSS(ss, pbar) + totalTokens[0] += result[0] + totalTokens[1] += result[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist progress only if this state produced tokens + checkSave(data, filename, result) + return [data, totalTokens, None] + + +def parseSystem(data, filename): + totalTokens = [0, 0] + + # --- Preflight: call searchSystem on deep copy to count increments --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + pass + def refresh(self): + pass + + def _estimate_units(sysobj, fname) -> int: + # Avoid deep copy - just count fields directly + count = 0 + try: + for term in sysobj.get("terms", {}) or {}: + termList = sysobj["terms"][term] + if isinstance(termList, list): + count += len(termList) + gt = sysobj.get("gameTitle") + if isinstance(gt, str) and gt: + count += 1 + count += len(sysobj.get("variables", []) or []) + count += len(sysobj.get("switches", []) or []) + count += len(sysobj.get("weaponTypes", []) or []) + count += len(sysobj.get("armorTypes", []) or []) + count += len(sysobj.get("skillTypes", []) or []) + count += len(sysobj.get("equipTypes", []) or []) + return count if count > 0 else 1 + except Exception: + return 1 + + total_units = _estimate_units(data, filename) + if not isinstance(total_units, int) or total_units <= 0: + # Fallback: rough count of strings + total_units = 0 + if data.get("gameTitle"): total_units += 1 + terms = data.get("terms", {}) or {} + for k,v in terms.items(): + if k == "messages": + continue + if isinstance(v, list): + total_units += sum(1 for x in v if x is not None) + total_units += len(data.get("armorTypes", []) or []) + total_units += len(data.get("skillTypes", []) or []) + total_units += len(data.get("equipTypes", []) or []) + total_units += len((terms.get("messages", {}) or {})) + global PBAR + + with tqdm(total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + try: + result = searchSystem(data, pbar) + totalTokens[0] += result[0] + totalTokens[1] += result[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist only if system sections produced tokens + checkSave(data, filename, result) + return [data, totalTokens, None] + + +def parseScenario(data, filename): + totalTokens = [0, 0] + global LOCK + + # --- Preflight: run searchCodes on each page list --- + class _CountingBar: + def __init__(self): + self.n = 0 + def update(self, n=1): + try: + self.n += int(n) if n is not None else 1 + except Exception: + self.n += 1 + def write(self, *args, **kwargs): + pass + def refresh(self): + pass + + def _estimate_units(scenario, fname) -> int: + # Avoid deep copy - just count commands directly + count = 0 + try: + for key, lst in scenario.items(): + if lst is not None and "list" in lst: + for cmd in lst.get("list", []): + if cmd and "code" in cmd: + code = cmd["code"] + if code in [401, 405, 102, 122, 408, 355, 655, 356, 357, 320, 324, 325, 111, 108, 657]: + count += 1 + return count if count > 0 else 1 + except Exception: + return 1 + + totalLines = _estimate_units(data, filename) + if not isinstance(totalLines, int) or totalLines <= 0: + totalLines = 0 + for _, lst in data.items(): + try: + totalLines += len(lst or []) + except Exception: + pass + global PBAR + + with tqdm(total=totalLines, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, desc=filename) as pbar: + PBAR = pbar + for page in data.items(): + if page[1] is not None: + try: + totalTokensPage = searchCodes(page[1], pbar, [], filename) + totalTokens[0] += totalTokensPage[0] + totalTokens[1] += totalTokensPage[1] + except Exception as e: + traceback.print_exc() + return [data, totalTokens, e] + finally: + # Persist progress only if this page produced tokens + checkSave(data, filename, totalTokensPage) + return [data, totalTokens, None] + + +def searchNames(data, pbar, context, filename): + totalTokens = [0, 0] + nameList = [] + nameSourceList = [] + profileList = [] + profileSourceList = [] + nicknameList = [] + nicknameSourceList = [] + descriptionList = [] + descriptionSourceList = [] + # For Skills: collect messages across all entries for batch translation + messagesList = [] # List of tuples: (entry_idx, message_field, message_text, needs_taro) + # Collect name mappings for vocab per run + vocab_pairs: list[tuple[str, str]] = [] + vocab_enabled = context in ["Armors", "Weapons", "Items", "MapInfos", "Classes", "Enemies", "Skills"] + # For batching all note types + notesBatch = [] # List of (i, regex, match_text, note_type) + notesBatchMap = [] # List of (i, regex, match_text, note_type, groupidx) + i = 0 # Counter + j = 0 # Counter 2 + filling = False + mismatch = False + batchFull = False + + # Set the context of what we are translating + if "Actors" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the NPC name" + if "Armors" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the RPG equipment name" + if "Classes" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the RPG class name" + if "MapInfos" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the location name" + if "Enemies" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the enemy NPC name" + if "Weapons" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the RPG weapon name" + if "Items" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the RPG item name" + if "Skills" in context: + newContext = "Reply with only the " + LANGUAGE + " translation of the RPG skill name" + + # Names + with open("log/translations.txt", "a", encoding="utf-8") as file: + file.write(f"\n#{context}\n") + + # --- Batching pass: collect all note texts for all note types --- + note_regexes = [ + (r"", False), + (r"", False), + (r"<[Hh]int:(.*?)>", False), + (r"", False), + (r"", True), + (r"", False), + (r"", False), + (r"", False), + (r"", True), + (r"", False), + (r"\n(.*)\n", False), + (r"", False), + (r"WATs:(.+?)>", False), + (r"ADTs?:(.+?)>", False), + (r"", False), + (r"", False), + (r"]+)", True), + (r"]+)", True), + (r"]+)", True), + (r"", True), + (r"", True), + (r"", False), + (r"<拡張説明:(.+?)>", False), + (r"\n(.+?)\n<", False), + (r"text:(.+)>", False), + (r"\d][^,>]*)", False), + (r"\n?(.*?)", False), + (r"<コメント:\n?(.*?)>", True), + ] + # For each entry, collect all note matches + for idx, entry in enumerate(data): + if entry is None or "note" not in entry or not entry["note"]: + continue + note = entry["note"] + for regex, wordwrap in note_regexes: + matches = re.findall(regex, note, re.DOTALL) + # Special filter for to skip if 'Client' is in the match + if regex.startswith(r"= len(translatedNotesBatch): + break + translated = translatedNotesBatch[note_insert_idx] + if wordwrap: + if regex.startswith(r" 0 and msg_text[0] in ["は", "を", "の", "に", "が"] + if needs_taro: + messages_batch.append("Taro" + msg_text) + else: + messages_batch.append(msg_text) + messages_map.append((idx, msg_field, needs_taro, msg_text)) + + # Batch translate all messages + if messages_batch: + response = translateAI( + messages_batch, + "reply with only the gender neutral " + LANGUAGE + " translation of the action log. For messages starting with Taro, always start the sentence with Taro. For example, translate 'Taroを倒した!' as 'Taro was defeated!'", + False, + ) + translated_messages = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Apply translations back to data + for msg_idx, (entry_idx, msg_field, needs_taro, raw_msg) in enumerate(messages_map): + if msg_idx < len(translated_messages): + translation = translated_messages[msg_idx] + if needs_taro: + translation = translation.replace("Taro", "") + data[entry_idx][msg_field] = translation + _apply_entry_field_original(data[entry_idx], msg_field, raw_msg) + + # Update progress for messages + if pbar is not None: + pbar.refresh() + + # Now continue with the rest of the batching logic for names, descriptions, etc. + i = 0 + filling = False + batchFull = False + mismatch = False + while i < len(data) or filling == True: + if i < len(data): + # Empty Data + if data[i] is None or data[i]["name"] == "": + i += 1 + continue + # Filling up Batch + filling = True + if context in "Actors": + if len(nameList) < BATCHSIZE: + name_src = _entry_field_source(data[i], "name") + if name_src != "": + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, name_src)): + nameList.append(name_src) + nameSourceList.append(name_src) + if "nickname" in data[i]: + nick_src = _entry_field_source(data[i], "nickname") + if nick_src: + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, nick_src)): + nicknameList.append(nick_src) + nicknameSourceList.append(nick_src) + if "profile" in data[i]: + prof_src = _entry_field_source(data[i], "profile") + if prof_src: + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, prof_src)): + profileList.append(prof_src.replace("\n", " ")) + profileSourceList.append(prof_src) + i += 1 + else: + batchFull = True + if context in ["Armors", "Weapons", "Items"]: + if len(nameList) < BATCHSIZE: + name_src = _entry_field_source(data[i], "name") + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, name_src)): + nameList.append(name_src) + nameSourceList.append(name_src) + if "description" in data[i]: + desc_src = _entry_field_source(data[i], "description") + if desc_src != "": + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, desc_src)): + descriptionList.append(desc_src.replace("\n", " ")) + descriptionSourceList.append(desc_src) + i += 1 + else: + batchFull = True + if context in ["Skills"]: + if len(nameList) < BATCHSIZE: + name_src = _entry_field_source(data[i], "name") + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, name_src)): + nameList.append(name_src) + nameSourceList.append(name_src) + if "description" in data[i]: + desc_src = _entry_field_source(data[i], "description") + if desc_src: + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, desc_src)): + descriptionList.append(desc_src.replace("\n", " ")) + descriptionSourceList.append(desc_src) + i += 1 + else: + batchFull = True + if context in ["Enemies", "Classes", "MapInfos"]: + if len(nameList) < BATCHSIZE: + name_src = _entry_field_source(data[i], "name") + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, name_src)): + nameList.append(name_src) + nameSourceList.append(name_src) + i += 1 + else: + batchFull = True + + # Batch Full + if batchFull == True or i >= len(data): + k = j # Original Index + if context in "Actors": + # Track tokens for this batch + batchTokens = [0, 0] + # Name + response = translateAI(nameList, newContext) + translatedNameBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + batchTokens[0] += response[1][0] + batchTokens[1] += response[1][1] + if pbar is not None and nameList: + pbar.refresh() + + # Nickname + if nicknameList: + response = translateAI(nicknameList, newContext) + translatedNicknameBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + batchTokens[0] += response[1][0] + batchTokens[1] += response[1][1] + if pbar is not None: + pbar.refresh() + + # Profile + if profileList: + response = translateAI(profileList, "") + translatedProfileBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + batchTokens[0] += response[1][0] + batchTokens[1] += response[1][1] + if pbar is not None: + pbar.refresh() + + # Set Data + if len(nameList) == len(translatedNameBatch): + j = k + while j < i: + # Empty Data + if data[j] is None or data[j]["name"] == "": + j += 1 + continue + else: + # Get Text + if data[j]["name"] != "" and nameSourceList: + with open("log/translations.txt", "a", encoding="utf-8") as file: + file.write(f'{data[j]["name"]} ({translatedNameBatch[0]})\n') + # Actors are excluded from vocab updates + raw_name = nameSourceList.pop(0) + data[j]["name"] = translatedNameBatch.pop(0) + _apply_entry_field_original(data[j], "name", raw_name) + if "nickname" in data[j] and data[j]["nickname"] and nicknameSourceList: + raw_nick = nicknameSourceList.pop(0) + data[j]["nickname"] = translatedNicknameBatch.pop(0) + _apply_entry_field_original(data[j], "nickname", raw_nick) + if "profile" in data[j] and data[j]["profile"] and profileSourceList: + raw_prof = profileSourceList.pop(0) + data[j]["profile"] = dazedwrap.wrapText(translatedProfileBatch.pop(0), LISTWIDTH) + _apply_entry_field_original(data[j], "profile", raw_prof) + + # If Batch is empty. Move on. + if len(translatedNameBatch) == 0: + nameList.clear() + nameSourceList.clear() + profileList.clear() + profileSourceList.clear() + nicknameList.clear() + nicknameSourceList.clear() + batchFull = False + filling = False + j += 1 + # Persist after applying this batch only if we actually translated something in this batch + checkSave(data, filename, batchTokens) + else: + mismatch = True + + if context in ["Armors", "Weapons", "Items", "Skills"]: + # Track tokens for this batch + batchTokens = [0, 0] + # Name + response = translateAI(nameList, newContext) + translatedNameBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + batchTokens[0] += response[1][0] + batchTokens[1] += response[1][1] + if pbar is not None and nameList: + pbar.refresh() + + # Description + if descriptionList: + response = translateAI( + descriptionList, + f"Reply with only the {LANGUAGE} translation of the text.", + True, + ) + translatedDescriptionBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + batchTokens[0] += response[1][0] + batchTokens[1] += response[1][1] + if pbar is not None: + pbar.refresh() + + # Set Data + if len(nameList) == len(translatedNameBatch): + j = k + with open("log/translations.txt", "a", encoding="utf-8") as file: + while j < i: + # Empty Data + if data[j] is None or data[j]["name"] == "": + j += 1 + continue + else: + # Get Text + raw_name = nameSourceList.pop(0) if nameSourceList else _entry_field_source(data[j], "name") + file.write(f"{data[j]['name']} ({translatedNameBatch[0]})\n") + if vocab_enabled: + try: + vocab_pairs.append((raw_name, translatedNameBatch[0])) + except Exception: + pass + data[j]["name"] = translatedNameBatch.pop(0) + _apply_entry_field_original(data[j], "name", raw_name) + desc_src = _entry_field_source(data[j], "description") if "description" in data[j] else "" + if ( + desc_src + and not (IGNORETLTEXT and not re.search(LANGREGEX, desc_src)) + and descriptionSourceList + ): + raw_desc = descriptionSourceList.pop(0) + wrapped = dazedwrap.wrapText(translatedDescriptionBatch.pop(0), LISTWIDTH) + data[j]["description"] = wrapped + _apply_entry_field_original(data[j], "description", raw_desc) + + # If Batch is empty. Move on. + if len(translatedNameBatch) == 0: + nameList.clear() + nameSourceList.clear() + descriptionList.clear() + descriptionSourceList.clear() + batchFull = False + filling = False + j += 1 + # Persist after applying this batch only if we actually translated something in this batch + checkSave(data, filename, batchTokens) + else: + mismatch = True + if context in ["Enemies", "Classes", "MapInfos"]: + # Track tokens for this batch + batchTokens = [0, 0] + response = translateAI(nameList, newContext) + translatedNameBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + batchTokens[0] += response[1][0] + batchTokens[1] += response[1][1] + if pbar is not None and nameList: + pbar.refresh() + + # Set Data + if len(nameList) == len(translatedNameBatch): + j = k + while j < i: + # Empty Data + if data[j] is None or data[j]["name"] == "": + j += 1 + continue + else: + raw_name = nameSourceList.pop(0) if nameSourceList else _entry_field_source(data[j], "name") + with open("log/translations.txt", "a", encoding="utf-8") as file: + file.write(f'{data[j]["name"]} ({translatedNameBatch[0]})\n') + # Get Text + if vocab_enabled: + try: + vocab_pairs.append((raw_name, translatedNameBatch[0])) + except Exception: + pass + data[j]["name"] = translatedNameBatch.pop(0) + _apply_entry_field_original(data[j], "name", raw_name) + + # If Batch is empty. Move on. + if len(translatedNameBatch) == 0: + nameList.clear() + nameSourceList.clear() + batchFull = False + filling = False + j += 1 + # Persist after applying this batch only if we actually translated something in this batch + checkSave(data, filename, batchTokens) + else: + mismatch = True + + # Mismatch + if mismatch == True: + MISMATCH.append(nameList) + nameList.clear() + nameSourceList.clear() + profileList.clear() + profileSourceList.clear() + nicknameList.clear() + nicknameSourceList.clear() + descriptionList.clear() + descriptionSourceList.clear() + filling = False + mismatch = False + batchFull = False + + i += 1 + + # Update vocab section once per context after processing all names + if vocab_enabled and vocab_pairs: + update_vocab_section(context, vocab_pairs) + + return totalTokens + + +def searchCodes(page, pbar, jobList, filename): + if len(jobList) > 0: + list401 = jobList[0] + list122 = jobList[1] + list355655 = jobList[2] + list108 = jobList[3] + list356 = jobList[4] + list357 = jobList[5] + list324 = jobList[6] + list408 = jobList[7] + list325 = jobList[8] + list657 = jobList[9] + setData = False + else: + list401 = [] + list122 = [] + list355655 = [] + list108 = [] + list356 = [] + list357 = [] + list324 = [] + list408 = [] + list325 = [] + list657 = [] + setData = True + textHistory = [] + match = [] + totalTokens = [0, 0] + translatedText = "" + speaker = "" + speakerID = None + syncIndex = 0 + maxHistory = MAXHISTORY + VNameValue = None + reduceWidthFlag = False # Track if 101 code has non-empty first parameter + global LOCK + global NAMESLIST + global MISMATCH + global PBAR + with LOCK: + PBAR = pbar + + # Begin Parsing File + try: + # Normal Format + if "list" in page: + codeList = page["list"] + + # Special Format (Scenario) + else: + codeList = page + + # Iterate through page + i = 0 + while i < len(codeList): + with LOCK: + # syncIndex will keep i in sync when it gets modified + if syncIndex > i: + i = syncIndex + if len(codeList) <= i: + break + + # Declare Varss + currentGroup = [] + sourceGroup = [] + nametag = "" + + ## Event Code: 401 Show Text + if "code" in codeList[i] and codeList[i]["code"] in [401, 405, -1] and ((codeList[i]["code"] in [401, -1] and CODE401) or (codeList[i]["code"] == 405 and CODE405)): + # Save Code and starting index (j) + code = codeList[i]["code"] + j = i + groupStart = j + endtag = "" + instantLineFlag = False + + # Grab String + if len(codeList[i]["parameters"]) > 0: + jaString = codeList[i]["parameters"][0] + oldjaString = _param_source(codeList[i], 0) + speakerWork = oldjaString + else: + codeList[i]["code"] = -1 + i += 1 + continue + + # # For Retarded Devs + # retardRegex = r'([\\]+[nN]\[[\\]+V\[\d*?\]\])' + # match = re.search(retardRegex, jaString) + # if match: + # if VNameValue == 1: + # jaString = re.sub(retardRegex, 'リッカ', jaString) + # if VNameValue == 2: + # jaString = re.sub(retardRegex, 'ミミ', jaString) + # if VNameValue == 3: + # jaString = re.sub(retardRegex, 'ヒトミ', jaString) + # if VNameValue == 4: + # jaString = re.sub(retardRegex, 'Taro', jaString) + # if VNameValue == 5: + # jaString = re.sub(retardRegex, '富士見', jaString) + + # Speaker Check + speakerList = [] + + # Remove any RPGMaker Code at start + ffMatch = re.search( + r"^((?:[\\]+[^cCnNiIkKvV]+\[[\d\w]+\])+)", + jaString, + ) + if ffMatch != None: + jaString = jaString.replace(ffMatch.group(0), "") + nametag += ffMatch.group(0) + + # m and z Codes + match = re.search(r"(.*?)[\\]+m\[\d+?\][\\]+z\[\d+?\]", speakerWork) + if match: + speakerList.append(match.group(1)) + if "\\c" in speakerList[0]: + speakerList = re.findall( + r"^[\\]+[cC]\[\d+\]【?(.+?)】?[\\]+[cC]\[\d+\](?:[\\]+[A-Za-z]+(?:\[[^\]]*\])?)*[\\]*$", + speakerList[0], + ) + + # Brackets (support multiple names like 【A】【B】) + if len(speakerList) == 0: + # Check for bracket at start with dialogue following (【name】dialogue...) + inlineBracketMatch = re.match(r"^\s*【([^】]+)】(.+)", speakerWork, re.DOTALL) + + if inlineBracketMatch: + # Inline bracket with dialogue on same line + speakerList = [inlineBracketMatch.group(1).strip()] + else: + # Only consider bracketed names when the line starts with '【' and + # ends with either '】' or trailing variable/control codes like \n[2], \FF[\w[3]], etc. + startsWithBracket = re.match(r"^\s*【", speakerWork) is not None + endsWithBracket = re.search( + r"(】\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)$)", + speakerWork, + ) is not None + + if startsWithBracket and endsWithBracket: + candidates = re.findall(r"【(.*?)】", speakerWork) + if candidates: + candidates = [c.strip() for c in candidates] + if candidates: + speakerList = candidates + + # Colors + if len(speakerList) == 0: + speakerList = re.findall( + r"^[\\]+[cC]\[\d+\]【?(.+?)】?[\\]+[cC]\[\d+\](?:[\\]+[A-Za-z]+(?:\[[^\]]*\])?)*[\\]*$", + speakerWork, + ) + + # Colons + if len(speakerList) == 0: + speakerList = re.findall( + r"(.+):$", + speakerWork, + ) + + # [Speaker] standalone line format (written back by inline re-export) + if len(speakerList) == 0: + inlineFmtMatch = re.match(r"^\[([^\[\]\n]+)\]\s*$", speakerWork) + if inlineFmtMatch: + speakerList = [inlineFmtMatch.group(1).strip()] + + # Inline speaker detection — Name「/Name: "/Name: (/[Name] "/[Name] ( + if len(speakerList) == 0 and INLINE401SPEAKERS: + inlineSpeakerMatch = re.match( + r'^(?:\[([^\]]{1,30})\]\s*|([^\s「」。、!?…\\\n“”"(:\[\]]{1,20})(?:[:::]?\s*)(?=[「“"(]))(.*)', + speakerWork, re.DOTALL + ) + if inlineSpeakerMatch: + speakerList = [(inlineSpeakerMatch.group(1) or inlineSpeakerMatch.group(2)).strip()] + else: + inlineSpeakerMatch = None + + # First Line Speakers + if len(speakerList) == 0 and FIRSTLINESPEAKERS is True: + # Test Speaker + if ( + len(speakerWork) < 40 + and "code" in codeList[i + 1] + and codeList[i + 1]["code"] in [401, 405, -1] + and len(codeList[i + 1]["parameters"]) > 0 + and len(codeList[i + 1]["parameters"][0]) > 0 + ): + nextString = codeList[i + 1]["parameters"][0].strip() + + # Remove any RPGMaker Code at start + ffMatchNS = re.search( + r"^((?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+)", + nextString, + ) + formatMatch = re.search(r"(^[\\]+[\W]+?)", nextString) + if ffMatchNS != None: + nextString = nextString.replace(ffMatchNS.group(1), "") + if formatMatch != None: + nextString = nextString.replace(formatMatch.group(1), "") + + if nextString and nextString[0] in [ + "「", + '"', + "(", + "(", + "*", + "[", + ]: + speakerList = re.findall(r".+", speakerWork) + + # Replace Speaker + if len(speakerList) != 0: + # Check if speaker+dialogue are on same line + sameLineMatch = re.match(r"^\s*【([^】]+)】(.+)", speakerWork, re.DOTALL) + if inlineSpeakerMatch and len(speakerList) == 1: + # Strip speaker prefix, keep everything after as dialogue + response = getSpeaker(speakerList[0]) + speaker = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + jaString = inlineSpeakerMatch.group(3) + if not setData: + nametag = f"[{speaker}]\n" + nametag + elif sameLineMatch and len(speakerList) == 1: + # Translate speaker + response = getSpeaker(speakerList[0]) + speaker = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + # Remove speaker bracket from jaString, let dialogue get translated + jaString = sameLineMatch.group(2) + # Store the translated bracket to add back later + if not setData: + nametag = f"[{speaker}]\n" + nametag + # Don't skip to next line - continue with current line + elif codeList[i + 1]["code"] in [401, 405, -1]: + # Original behavior: speaker on its own line, dialogue on next line + # Single + if len(speakerList) == 1: + response = getSpeaker(speakerList[0]) + speaker = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Multiple (Brackets) + elif len(speakerList) > 1: + jaStringUpdated = jaString + for idx, sp in enumerate(speakerList): + response = getSpeaker(sp) + tled = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if not setData: + pattern = r"【\s*" + re.escape(sp) + r"\s*】" + jaStringUpdated = re.sub(pattern, lambda m: f"【{tled}】", jaStringUpdated) + # Back-compat: set 'speaker' to the first translated name + if idx == 0: + speaker = tled + + # Set Data + if not setData and len(speakerList) > 1: + codeList[i]["parameters"][0] = nametag + jaStringUpdated + _apply_original(codeList[i], oldjaString) + elif not setData and len(speakerList) == 1: + paramStr = codeList[i]["parameters"][0] + codeList[i]["parameters"][0] = nametag + _replace_speaker_in_param( + paramStr, speakerList[0], speaker + ) + _apply_original(codeList[i], oldjaString) + nametag = "" + + # Iterate to next string + i += 1 + j = i + while codeList[i]["code"] in [-1]: + i += 1 + j = i + jaString = codeList[i]["parameters"][0] + groupStart = i + + # Using this to keep track of 401's in a row (display text for Pass 2 formatting). + currentGroup.append(jaString) + anchor_has_orig = _scalar_original(codeList[groupStart]) is not None + sourceGroup.append(_param_source(codeList[i], 0)) + + # Join Up 401's into single string + if len(codeList) > i + 1: + while codeList[i + 1]["code"] in [401, 405, -1] and len(codeList[i]["parameters"]) > 0 and len(codeList[i + 1]["parameters"]) > 0 and not re.match(r"^(\s*[\\]+[aAbBdDeEfFgGhHjJlLmMoOpPqQrRsStTuUwWxXyYzZ]+\[[\w\d\[\]\\]+\])", codeList[i+1]["parameters"][0]): + if not setData: + codeList[i]["parameters"] = [] + codeList[i]["code"] = -1 + i += 1 + j = i + + jaString = codeList[i]["parameters"][0] + if jaString.strip(): + currentGroup.append(jaString) + if not anchor_has_orig: + sourceGroup.append(_param_source(codeList[i], 0)) + + # Make sure not the end of the list. + if len(codeList) <= i + 1: + break + + # Format String + if len(currentGroup) > 0: + rawSource = _group_raw_source(codeList, groupStart, sourceGroup) + if not rawSource.strip(): + i += 1 + continue + if not re.search(LANGREGEX, rawSource) and IGNORETLTEXT: + i += 1 + continue + + finalJAString = rawSource + oldjaString = rawSource + + # Set Back + if not setData: + codeList[i]["parameters"] = [finalJAString] + + ### \\n + regex = r"([\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>])" + match = re.search(regex, finalJAString) + + # Set Name + if match: + nametag = match.group(1) + speaker = match.group(2) + + # Translate Speaker + response = getSpeaker(speaker) + tledSpeaker = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Set Nametag and Remove from Final String + finalJAString = finalJAString.replace(nametag, "") + nametag = nametag.replace(speaker, tledSpeaker) + speaker = tledSpeaker + + # Remove Extra Stuff bad for translation. + finalJAString = finalJAString.replace("゙", "") + finalJAString = finalJAString.replace(" ", "") + finalJAString = finalJAString.replace("「", '"') + finalJAString = finalJAString.replace("」", '"') + finalJAString = finalJAString.replace("\\,", ',') + + ### Remove format codes + # Furigana: \r or \rb [base,reading] -> keep reading/base per pattern + finalJAString = re.sub(r"[\\]+[rR][bB]?\[(.*?),.*?\]", r"\1", finalJAString) + + # Curly-brace furigana: {base|reading} -> keep base + finalJAString = re.sub(r"\{([^|{}]+)\|[^|{}]+?\}", r"\1", finalJAString) + + # Remove any RPGMaker Code at start + ffMatch = re.search( + r"^((?:[\\]+[^cCnNiIkKvV{}]+?\[[\d\w\W]+?\]?\])+)", + finalJAString, + ) + if ffMatch != None: + finalJAString = finalJAString.replace(ffMatch.group(1), "") + nametag = ffMatch.group(1) + nametag + + # Remove bare escape codes at start (e.g. \\mn\\tmn, \\tmn, \\mn, \\vc) + bareMatch = re.match(r"^(\\mn\\tmn|\\tmn|\\mn|\\vc)", finalJAString) + if bareMatch is not None: + finalJAString = finalJAString[len(bareMatch.group(0)):] + nametag = bareMatch.group(0) + nametag + + # Remove _ABL Codes + ffMatch = re.search(r"^(_ABL).*", finalJAString) + if ffMatch != None: + finalJAString = finalJAString.replace(ffMatch.group(1), "") + nametag += ffMatch.group(1) + + # Center Lines (We Nuke These) + if "\\CL" in finalJAString or "\\ac" in finalJAString or "\\#" in finalJAString: + finalJAString = finalJAString.replace("\\CL", "") + finalJAString = finalJAString.replace("\\ac", "") + finalJAString = finalJAString.replace("\\#", "") + + # Handle Formatting Codes + if "\\>" in finalJAString: + instantLineFlag = True + finalJAString = finalJAString.replace("\\>", "") + + # Check if Empty + if finalJAString == "": + if nametag and match: + codeList[j]["parameters"][0] = codeList[j]["parameters"][0].replace(match.group(2), tledSpeaker) + i += 1 + continue + + # Pass 1 (Grabbing Data) + if setData: + # Remove Textwrap + if FIXTEXTWRAP: + finalJAString = finalJAString.replace("\n", " ") + if "\\px[200]" in finalJAString: + finalJAString = finalJAString.replace("\\px[200]", "") + + # Append + if finalJAString != "": + if speaker == "" and finalJAString != "": + list401.append(finalJAString) + historyEntry = finalJAString + elif finalJAString != "": + list401.append(f"[{speaker}]: {finalJAString}") + historyEntry = f"[{speaker}]: {finalJAString}" + else: + list401.append(speaker) + historyEntry = speaker + speaker = "" + match = [] + nametag = "" + currentGroup = [] + sourceGroup = [] + syncIndex = i + 1 + + # Keep textHistory list at length maxHistory + textHistory.append('"' + historyEntry + '"') + if len(textHistory) > maxHistory: + textHistory.pop(0) + + # Pass 2 (Setting Data) + else: + # Grab Translated String + if len(list401) > 0: + rawSource = _group_raw_source(codeList, groupStart, sourceGroup) + translatedText = list401[0] + + # Remove speaker prefix if present + match = re.search(r'(^\[(.+?)\]\s?[|:]\s?)', translatedText) + if match: + translatedText = translatedText.replace(match.group(1), "") + + # Remove 。 that appears after ... in AI output + translatedText = re.sub(r'\.\.\.(。)+', '...', translatedText) + + # Ensure a space follows sentence-ending punctuation before a capital letter. + # Japanese doesn't use spaces after !/?, so the AI omits them too. + translatedText = re.sub(r'([!?])([A-Z])', r'\1 \2', translatedText) + + # Ensure a single space before a run of RPGMaker pause/wait codes + # (\. \! \| \^) when immediately preceded by a word/punctuation char. + # Matches the whole code run at once so no intra-run spaces are added. + translatedText = re.sub(r'([^\s\\])((?:\\[.!|^])+)', r'\1 \2', translatedText) + + # Fix '- ' + translatedText = translatedText.replace("- ", "-") + + # Textwrap + if FIXTEXTWRAP is True: + finalJAString = re.sub(r"\n", " ", finalJAString) + finalJAString = finalJAString.replace("
", " ") + + # Determine width based on reduceWidthFlag + currentWidth = WIDTH - 15 if reduceWidthFlag else WIDTH + + if FIXTEXTWRAP is True and "_ABL" in nametag: + translatedText = dazedwrap.wrapText(translatedText, width=100) + elif FIXTEXTWRAP is True: + translatedText = dazedwrap.wrapText(translatedText, width=currentWidth) + + # Reset the flag after using it + reduceWidthFlag = False + + # Formatting Code + if instantLineFlag: + translatedText = translatedText.replace("\n", "\n\\>") + translatedText = f"\\>{translatedText}" + + # BR Flag + if BRFLAG is True: + translatedText = translatedText.replace("\n", "
") + + # px + if "\\px[200]" in nametag: + translatedText = translatedText.replace("\\px[200]", "") + translatedText = translatedText.replace("\n", "\n\\px[200]") + + # Add Nametag Back In + translatedText = nametag + translatedText + nametag = "" + + # Endtag + if endtag != "": + translatedText = translatedText + endtag + endtag = "" + + # Set Code + codeList[j]["code"] = code + + # Handle 405 + if codeList[j]["code"] == 405: + # 1. Split translatedText by newlines + lines = [line for line in translatedText.split('\n') if line.strip() != ""] + + # 2. Set the first string to codeList[j]["parameters"] + codeList[j]["parameters"] = [lines[0]] + + # 3. Make copies for each additional line and insert them + for idx, line in enumerate(lines[1:]): + new_item = copy.deepcopy(codeList[j]) + new_item.pop("_original", None) + new_item["parameters"] = [line] + codeList.insert(j + idx + 1, new_item) + + # 4. Update syncIndex to the last modified/added position + syncIndex = j + len(lines) + + # Handle 401 + else: + codeList[j]["parameters"] = [translatedText] + codeList[j]["code"] = code + syncIndex = i + 1 + + _apply_original(codeList[j], rawSource) + + # Reset + speaker = "" + match = [] + currentGroup = [] + sourceGroup = [] + list401.pop(0) + + ## Event Code: 122 [Set Variables] + if "code" in codeList[i] and codeList[i]["code"] == 122 and CODE122 is True: + # This is going to be the var being translated. + # Only translate variables within the specified range. + if codeList[i]["parameters"][0] not in list(range(CODE122_VAR_MIN, CODE122_VAR_MAX)): + i += 1 + continue + + jaString = codeList[i]["parameters"][4] + + # # For Retarded Devs + # VNameValue = jaString + # i += 1 + # continue + + # Validate String + if not isinstance(jaString, str): + i += 1 + continue + + # Definitely don't want to mess with files + if 'gameV' in jaString or '_' in jaString or '"[' in jaString or '@' in jaString: + i += 1 + continue + + # # Avoid anything not quoted + # if '\"' not in jaString: + # i += 1 + # continue + + # Validate Japanese Text + # if not re.search(LANGREGEX, jaString): + # i += 1 + # continue + + # Set String + innerSource = _122_inner_source(codeList[i]) + if innerSource is not None and innerSource.strip(): + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, innerSource): + i += 1 + continue + + # Remove Textwrap + finalJAString = innerSource.replace("\\n", " ") + + # Pass 1 + if setData: + if finalJAString != "": + list122.append(finalJAString) + + # Pass 2 + else: + if len(list122) > 0: + rawInner = innerSource + hadSemicolon = ';' in jaString + # Grab and Replace + translatedText = list122[0] + translatedText = jaString.replace(jaString, translatedText) + + # Remove characters that may break scripts + charList = ['"', "\\n"] + for char in charList: + translatedText = translatedText.replace(char, "") + + # Force 4 Escapes + translatedText = re.sub(r'(? 0: + # Grab and Replace + translatedText = list357[0] + translatedText = jaString.replace(jaString, translatedText) + + # Remove characters that may break scripts + charList = ['"', "\n"] + for char in charList: + translatedText = translatedText.replace(char, "") + + # Textwrap + # translatedText = dazedwrap.wrapText(translatedText, 80) + # translatedText = translatedText.replace("\n", "\\n") + # translatedText = re.sub(r"[\\]+c", r"\\\\c", translatedText) + translatedText = re.sub(r"[\\]+\*item", r"\\\\*item", translatedText) + + # Center Text + if acExist: + translatedText = f'\\ac {translatedText.replace('\n', '\n\\ac ')}' + + # Check and Set Font + if "fontSize" in codeList[i]["parameters"][3]: + if font: + codeList[i]["parameters"][3]["fontSize"] = font + + # Set + codeList[i]["parameters"][3][argVar] = f"{translatedText}" + list357.pop(0) + + # Map Plugins — use module-level registry filtered by ENABLED_PLUGINS_357 + headerMappings = { + k: v for k, v in HEADER_MAPPINGS_357.items() + if k in ENABLED_PLUGINS_357 + } + + for key, (argVars, font) in headerMappings.items(): + if key in headerString: + for argVar in argVars: + translatePlugins(argVar, font) + + # KN_StillManager: translate parameters[2] (the display label, e.g. "ギャラリーを開く") + # Only OPEN_GALLERY has a player-visible label in parameters[2]. + # Other commands (SHOW_BY_ID, HIDE, etc.) use parameters[2] as an internal label. + if (headerString == "KN_StillManager" and "KN_StillManager" in ENABLED_PLUGINS_357 + and len(codeList[i]["parameters"]) > 2 + and len(codeList[i]["parameters"]) > 1 + and codeList[i]["parameters"][1] == "OPEN_GALLERY"): + p2 = codeList[i]["parameters"][2] + if isinstance(p2, str) and p2.strip(): + if not (IGNORETLTEXT and not re.search(LANGREGEX, p2)): + if setData: + list357.append(p2) + else: + if len(list357) > 0: + translatedText = list357[0] + list357.pop(0) + translatedText = translatedText.replace('"', "") + codeList[i]["parameters"][2] = translatedText + + # AdvExtention plugin support (message event) + if headerString == "AdvExtentionllk" and len(codeList[i]["parameters"]) > 3: + try: + params_obj = codeList[i]["parameters"][3] + except Exception: + params_obj = None + + if isinstance(params_obj, dict): + # 1) Speaker comes from 'name', fallback to 'altName' if missing/empty + speaker_name = "" + if isinstance(params_obj.get("altName", None), str) and params_obj["altName"].strip(): + speaker_name = params_obj["altName"].strip() + if speaker_name: + response = getSpeaker(speaker_name) + params_obj["altName"] = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + speaker = response[0] + if isinstance(params_obj.get("name", None), str) and params_obj["name"].strip(): + speaker_name = params_obj["name"].strip() + if speaker_name: + response = getSpeaker(speaker_name) + params_obj["name"] = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + speaker = response[0] + speaker = "" + + # 2) Line comes from 'comment' if present, else 'text' + chosen_key = None + if isinstance(params_obj.get("comment", None), str) and params_obj["comment"].strip(): + chosen_key = "comment" + elif isinstance(params_obj.get("text", None), str): + chosen_key = "text" + + if chosen_key is not None: + jaString = params_obj.get(chosen_key, "") + if isinstance(jaString, str): + # Pass 1 (collect data) + if setData: + if FIXTEXTWRAP: + jaString = jaString.replace("\n", " ") + # Include speaker context like 401 does + if 'speaker' in locals() and isinstance(speaker, str) and speaker.strip(): + list357.append(f"[{speaker}]: {jaString}") + else: + list357.append(jaString) + # Pass 2 (apply translation) + else: + if len(list357) > 0: + translatedText = list357[0] + list357.pop(0) + + # Remove speaker prefix if present (same pattern used for 401) + m = re.search(r'(^\[.+?\]\s?[|:]\s?)', translatedText) + if m: + translatedText = translatedText.replace(m.group(1), "") + + if FIXTEXTWRAP: + translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) + + params_obj[chosen_key] = translatedText + + # VisuMZ_4_ProximityMessages handler + # Text:json value is stored as a JSON-encoded string, e.g. "\"\\\\{\\\\{text\"" + # After Python JSON parsing: "\\{\\{text" (outer quotes + \\{ formatting prefix) + if "VisuMZ_4_ProximityMessages" in headerString and len(codeList[i]["parameters"]) > 3: + params_obj = codeList[i]["parameters"][3] + if isinstance(params_obj, dict) and "Text:json" in params_obj: + rawValue = params_obj["Text:json"] + if isinstance(rawValue, str): + # Strip outer JSON quotes ("\"...\"" wrapper) + innerMatch = re.match(r'^"(.*)"$', rawValue, re.DOTALL) + innerText = innerMatch.group(1) if innerMatch else rawValue + + # Preserve \\{ / \\} RPGMaker font-size codes at start and end + prefixMatch = re.match(r'^((?:\\\\[{}])+)', innerText) + prefix = prefixMatch.group(1) if prefixMatch else "" + remaining = innerText[len(prefix):] + suffixMatch = re.search(r'((?:\\\\[{}])+)$', remaining) + suffix = suffixMatch.group(1) if suffixMatch else "" + jaString = remaining[: len(remaining) - len(suffix)] if suffix else remaining + + # Skip if IGNORETLTEXT is enabled and no Japanese text + skip = IGNORETLTEXT and not re.search(LANGREGEX, jaString) + if not skip and jaString.strip(): + # Pass 1 + if setData: + list357.append(jaString) + # Pass 2 + else: + if len(list357) > 0: + translatedText = list357[0] + list357.pop(0) + + # Remove characters that would break the JSON string encoding + translatedText = translatedText.replace('"', "'") + + # Normalize color/name codes to 4 backslashes (required for Text:json encoding) + translatedText = re.sub(r'\\{1,3}([cCnNiIvV]\[\d+\])', r'\\\\\\\\\1', translatedText) + + # Reassemble: restore outer quotes and formatting codes + params_obj["Text:json"] = f'"{prefix}{translatedText}{suffix}"' + + if headerString == "LL_GalgeChoiceWindow": + ### Message Text First + jaString = codeList[i]["parameters"][3]["messageText"] + + # Remove any textwrap & TL + jaString = re.sub(r"\n", " ", jaString) + response = translateAI(jaString, "") + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Textwrap & Set + translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) + codeList[i]["parameters"][3]["messageText"] = translatedText + + ### Choices + jaString = codeList[i]["parameters"][3]["choices"] + matchList = re.findall(r'"label[\\]*":[\\]*"(.*?)[\\]', jaString) + if matchList != None: + # Translate + question = codeList[i]["parameters"][3]["messageText"] + response = translateAI( + matchList, + f"Previous text for context: {question}\n", + True, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + translatedText = jaString + + # Replace Strings + for j in range(len(matchList)): + translatedText = translatedText.replace(matchList[j], response[0][j].replace('"', '')) + + # Set Data + codeList[i]["parameters"][3]["choices"] = translatedText + + ## Event Code: 657 [Picture Text] [Optional] + if "code" in codeList[i] and codeList[i]["code"] == 657 and CODE657 is True: + jaString = codeList[i]["parameters"][0] + if not isinstance(jaString, str): + i += 1 + continue + + # Definitely don't want to mess with files + if "_" in jaString: + i += 1 + continue + + # Only translate 'メッセージ = ' key/value pairs. + # All other keys (ページ番号, イベントID, アイコンID, etc.) are internal references. + kvMatch = re.match(r"^'?([^=]+?)\s*=\s*(.*?)'?$", jaString, re.DOTALL) + if kvMatch: + kvKey = kvMatch.group(1).strip() + kvValue = kvMatch.group(2).strip() + # Strip any outer single-quotes wrapping the value + kvValue = re.sub(r"^'(.*)'$", r"\1", kvValue) + + if kvKey != 'メッセージ': + i += 1 + continue + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, kvValue): + i += 1 + continue + + if not kvValue.strip(): + i += 1 + continue + + # Remove any textwrap + kvValue = re.sub(r"\n", " ", kvValue) + + # Pass 1 – collect value for batch translation + if setData: + list657.append(kvValue) + + # Pass 2 – apply translated value + else: + if len(list657) > 0: + translatedText = list657[0] + list657.pop(0) + for char in ['"', "'"]: + translatedText = translatedText.replace(char, "") + codeList[i]["parameters"][0] = f"'{kvKey} = {translatedText}'" + + ## Event Code: 101 [Name] [Optional] + if "code" in codeList[i] and codeList[i]["code"] == 101 and CODE101 is True: + isVar = False + + # Check for face name mappings first (before other processing) + if FACENAME101 and len(codeList[i]["parameters"]) > 0: + faceName = codeList[i]["parameters"][0] + if isinstance(faceName, str) and faceName: + matchedSpeaker = None + + # 1) _talk_ pattern: split on "_talk" and exact-match the prefix + if "_talk" in faceName: + prefix = faceName.split("_talk")[0] + matchedSpeaker = FACENAME101_MAP.get(prefix) + + # 2) Longest-prefix startswith match + if matchedSpeaker is None: + for prefix, name in FACENAME101_MAP_SORTED: + if faceName.startswith(prefix): + matchedSpeaker = name + break + + if matchedSpeaker is not None: + speaker = matchedSpeaker + i += 1 + continue + + # Grab String + jaString = "" + if len(codeList[i]["parameters"]) > 4: + # Set flag if first parameter has a non-empty string + if isinstance(codeList[i]["parameters"][0], str) and codeList[i]["parameters"][0].strip(): + reduceWidthFlag = True + jaString = codeList[i]["parameters"][4] + # Check for Var (only when parameters[0] is not a face file, + # i.e. fewer than 4 params — standard code 101 always has 4: + # [faceFile, faceIndex, background, position]) + elif 0 < len(codeList[i]["parameters"]) < 4: + jaString = codeList[i]["parameters"][0] + isVar = True + if not isinstance(jaString, str): + i += 1 + continue + + varActorMatch = re.match(r"^\s*(?:[\\]+[cC]\[\d+?\]\s*)?[\\]+[nN]\[(\d+)\]", jaString) + if varActorMatch: + actorName = _get_actor_map().get(int(varActorMatch.group(1))) + speaker = actorName or varActorMatch.group(0).strip() + i += 1 + continue + + # Force Speaker using var + if "memerisu" in jaString.lower(): + speaker = "Memerisu" + i += 1 + continue + elif "thina" in jaString.lower(): + speaker = "Tina" + i += 1 + continue + elif "\\ap" in jaString.lower(): + # Extract actor ID from format like \\AP[2左] or \\AP[2]仙人 + apMatch = re.search(r"[\\]+[aA][pP]\[(\d+)[^\]]*\](.*)$", jaString, re.IGNORECASE) + if apMatch: + actorId = int(apMatch.group(1)) + additionalText = apMatch.group(2).strip() + + # Load Actors.json to get the actor name + try: + actorsPath = Path("files/Actors.json") + if actorsPath.exists(): + with open(actorsPath, 'r', encoding='utf-8') as f: + actorsData = json.load(f) + + # Find the actor with matching ID + actorName = None + for actor in actorsData: + if actor and isinstance(actor, dict) and actor.get("id") == actorId: + actorName = actor.get("name", "") + break + + if actorName: + speaker = actorName + + # If there's additional text after \\AP[ID], translate it + if additionalText: + response = getSpeaker(additionalText) + translatedAdditionalText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Replace the text in the parameter + if isVar == False and len(codeList[i]["parameters"]) > 4: + codeList[i]["parameters"][4] = codeList[i]["parameters"][4].replace(additionalText, translatedAdditionalText) + else: + codeList[i]["parameters"][0] = codeList[i]["parameters"][0].replace(additionalText, translatedAdditionalText) + except Exception as e: + # If there's any error loading actors, just extract what's in the brackets + speaker = apMatch.group(1) + else: + # Fallback to old behavior + speaker = re.search(r"[\\]+AP\[(.*?)\]", jaString).group(1) + i += 1 + continue + + # Get Speaker + rawName = _101_name_source(codeList[i], isVar) + match = re.search(r"^(?:[\\]+[cC]\[\d+?\])?([^\\]+)", rawName) + if match: + sourceName = match.group(1) + response = getSpeaker(sourceName) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + speaker = response[0] + + # Validate Speaker is not empty + if len(speaker) > 0: + paramIdx = 0 if isVar else 4 + paramStr = codeList[i]["parameters"][paramIdx] + codeList[i]["parameters"][paramIdx] = _replace_speaker_in_param( + paramStr, sourceName, speaker + ) + _apply_original(codeList[i], rawName) + isVar = False + i += 1 + continue + else: + speaker = "" + + + ## Event Code: 355 or 655 Scripts [Optional] + if "code" in codeList[i] and (codeList[i]["code"] == 355 or codeList[i]["code"] == 655) and CODE355655 is True: + jaString = codeList[i]["parameters"][0] + + # Patterns — use module-level registry filtered by ENABLED_PATTERNS_355655 + patterns = { + k: v for k, v in PATTERNS_355655.items() + if k in ENABLED_PATTERNS_355655 + } + + for key, (regex, multiline) in patterns.items(): + if key in jaString: + # Multi-line pattern: spans 355 + subsequent 655 codes + # Each 655 line is translated separately (as a batch) and stays in its own line + if multiline and codeList[i]["code"] == 355: + textLines = [] + textLineIndices = [] + j = i + 1 + + while j < len(codeList) and codeList[j]["code"] == 655: + param = codeList[j]["parameters"][0] if codeList[j]["parameters"] else "" + textMatch = re.search(regex, param) + if textMatch: + text = _pat355655_captured_text(textMatch) + if not (IGNORETLTEXT and not re.search(LANGREGEX, text)): + textLines.append(text) + textLineIndices.append(j) + j += 1 + + if textLines: + if setData: + # Store each line separately for batch translation + for text in textLines: + list355655.append(text) + else: + # Apply each translated line back to its corresponding 655 code + for lineIdx in textLineIndices: + if len(list355655) > 0: + translatedText = list355655[0] + list355655.pop(0) + + # Replace quotes with apostrophes to avoid breaking plugin + translatedText = translatedText.replace('\\"', "'") + translatedText = translatedText.replace('"', "'") + + origParam = codeList[lineIdx]["parameters"][0] + origMatch = re.search(regex, origParam) + if origMatch: + old = _pat355655_captured_text(origMatch) + codeList[lineIdx]["parameters"][0] = origParam.replace(old, translatedText) + + i = j - 1 + break + + # Single-line pattern + else: + match = re.search(regex, jaString) + if match: + cap = _pat355655_captured_text(match) + if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', cap): + continue + + if IGNORETLTEXT and not re.search(LANGREGEX, cap): + continue + + if setData: + list355655.append(cap) + else: + translatedText = list355655[0] + list355655.pop(0) + + if "gameVariables.setValue" in codeList[i]["parameters"][0]: + translatedText = translatedText.replace('\"', "'") + + if "$gameVariables._data" in codeList[i]["parameters"][0]: + translatedText = re.sub(r"(? 0: + translatedText = list355655[0] + list355655.pop(0) + # Strip speaker prefix if present + translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText) + # Replace double quotes to avoid breaking the JSON/JS syntax + translatedText = translatedText.replace('\\"', "'") + translatedText = translatedText.replace('"', "'") + jaString = jaString.replace(s, translatedText, 1) + # Replace the speaker name directly (already translated via getSpeaker) + if nameStr and translatedName: + translatedName = translatedName.replace('\\"', "'") + translatedName = translatedName.replace('"', "'") + jaString = jaString.replace(nameStr, translatedName, 1) + codeList[i]["parameters"][0] = jaString + + # AddMaill handler - translate sender name (3rd quoted arg) and title (4th quoted arg) + # Example: this.AddMaill("M_IcoMail","liliy","リリィ","お得なクーポン配布",_MTxt,[24],193,true,504,1) + if "AddMaill(" in jaString: + # Extract all quoted strings in order + allQuoted = re.findall(r'\\?"([^"]*?)\\?"', jaString) + # args: [0]=icon, [1]=id, [2]=sender, [3]=title, ... + translatable = [] + translatableIndices = [] + for idx in [2, 3]: + if idx < len(allQuoted): + s = allQuoted[idx] + if not s.strip(): + continue + if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', s): + continue + if IGNORETLTEXT and not re.search(LANGREGEX, s): + continue + translatable.append(s) + translatableIndices.append(idx) + + if translatable: + if setData: + for s in translatable: + list355655.append(s) + else: + for s in translatable: + if len(list355655) > 0: + translatedText = list355655[0] + list355655.pop(0) + translatedText = translatedText.replace('\\"', "'") + translatedText = translatedText.replace('"', "'") + jaString = jaString.replace(s, translatedText, 1) + codeList[i]["parameters"][0] = jaString + + # # AddBbs handler - translate arrays of posts/replies, username, and location + # # Example: AddBbs(["この開発したパッチを..."], "コンピューターおじいちゃん","場所:猪鹿蝶",["良きパッチが..."],"patch_npc") + # if "AddBbs(" in jaString: + # translatable = [] + + # # Extract strings from the first array (topic posts) + # # Anchor with ],\s*\\?" after ] to skip past inner brackets like \\C[3] + # firstArrayMatch = re.search(r'AddBbs\s*\(\s*\[(.+?)\]\s*,\s*\\?"', jaString) + # firstArrayStrings = [] + # if firstArrayMatch: + # firstArrayStrings = re.findall(r'\\?"([^"]+?)\\?"', firstArrayMatch.group(1)) + # for s in firstArrayStrings: + # if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', s): + # continue + # if IGNORETLTEXT and not re.search(LANGREGEX, s): + # continue + # translatable.append(s) + + # # After the first array, extract: "username","location",["replies"],"picture_id" + # afterFirstArray = re.search(r'AddBbs\s*\(\s*\[.+?\]\s*,\s*(.*)\)\s*;?\s*$', jaString) + # nameStr = None + # translatedName = "" + # locationStr = None + # secondArrayStrings = [] + + # if afterFirstArray: + # rest = afterFirstArray.group(1) + + # # Username (first quoted string after the array) + # nameMatch = re.match(r'\s*\\?"([^"]+?)\\?"', rest) + # if nameMatch: + # n = nameMatch.group(1) + # if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', n): + # if not (IGNORETLTEXT and not re.search(LANGREGEX, n)): + # nameStr = n + # response = getSpeaker(n) + # translatedName = response[0] + # totalTokens[0] += response[1][0] + # totalTokens[1] += response[1][1] + + # # Location (second quoted string after array, before second array) + # locMatch = re.match(r'\s*\\?"[^"]*?\\?"\s*,\s*\\?"([^"]+?)\\?"', rest) + # if locMatch: + # loc = locMatch.group(1) + # if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', loc): + # if not (IGNORETLTEXT and not re.search(LANGREGEX, loc)): + # locationStr = loc + # translatable.append(loc) + + # # Second array (replies) + # # Anchor with ],\s*\\?" after ] to skip past inner brackets like \\C[3] + # secondArrayMatch = re.search(r',\s*\[(.+?)\]\s*,\s*\\?"', rest) + # if secondArrayMatch: + # secondArrayStrings = re.findall(r'\\?"([^"]+?)\\?"', secondArrayMatch.group(1)) + # for s in secondArrayStrings: + # if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', s): + # continue + # if IGNORETLTEXT and not re.search(LANGREGEX, s): + # continue + # translatable.append(s) + + # if translatable or nameStr: + # speakerPrefix = translatedName if translatedName else "" + + # if setData: + # for s in translatable: + # if speakerPrefix: + # list355655.append(f"[{speakerPrefix}]: {s}") + # else: + # list355655.append(s) + # else: + # for s in translatable: + # if len(list355655) > 0: + # translatedText = list355655[0] + # list355655.pop(0) + # translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText) + # translatedText = translatedText.replace('\\"', "'") + # translatedText = translatedText.replace('"', "'") + # jaString = jaString.replace(s, translatedText, 1) + # # Replace the username directly (already translated via getSpeaker) + # if nameStr and translatedName: + # translatedName = translatedName.replace('\\"', "'") + # translatedName = translatedName.replace('"', "'") + # jaString = jaString.replace(nameStr, translatedName, 1) + # # Normalize \\C and \\N codes to always have exactly 4 backslashes + # jaString = re.sub(r'\\+([cCnN]\[\d+\])', r'\\\\\1', jaString) + # codeList[i]["parameters"][0] = jaString + + # _MTxt handler - translates var _MTxt = "text" + "\n"; across 355 + 655 lines + # Code 355: var _MTxt = "text" + "\n"; + # Code 655: _MTxt += "text" + "\n"; + if "_MTxt" in jaString and codeList[i]["code"] == 355: + mtxtRegex = r'"(.+?)"\s*\+\s*"\\n"' + textLines = [] + textLineIndices = [] + + # Extract text from the 355 line itself + match355 = re.search(mtxtRegex, jaString) + if match355: + text = match355.group(1) + if not (IGNORETLTEXT and not re.search(LANGREGEX, text)): + textLines.append(text) + textLineIndices.append(i) + + # Extract text from subsequent 655 lines + j = i + 1 + while j < len(codeList) and codeList[j]["code"] == 655: + param = codeList[j]["parameters"][0] if codeList[j]["parameters"] else "" + if "_MTxt" in param: + textMatch = re.search(mtxtRegex, param) + if textMatch: + text = textMatch.group(1) + if not (IGNORETLTEXT and not re.search(LANGREGEX, text)): + textLines.append(text) + textLineIndices.append(j) + j += 1 + + if textLines: + if setData: + for text in textLines: + list355655.append(text) + else: + # Collect all translated lines and re-wrap them + translatedLines = [] + for _ in textLineIndices: + if len(list355655) > 0: + tl = list355655.pop(0) + tl = tl.replace('\\"', "'") + tl = tl.replace('"', "'") + translatedLines.append(tl) + + if translatedLines: + # Join all lines and re-wrap to WIDTH + combined = " ".join(translatedLines) + wrapped = dazedwrap.wrapText(combined, width=WIDTH) + wrappedLines = [l for l in wrapped.split("\n") if l.strip()] + + # Distribute wrapped lines across existing 355/655 slots + for idx, lineIdx in enumerate(textLineIndices): + if idx < len(wrappedLines): + origParam = codeList[lineIdx]["parameters"][0] + origMatch = re.search(mtxtRegex, origParam) + if origMatch: + codeList[lineIdx]["parameters"][0] = origParam.replace(origMatch.group(1), wrappedLines[idx]) + else: + # More slots than lines: blank out the text + origParam = codeList[lineIdx]["parameters"][0] + origMatch = re.search(mtxtRegex, origParam) + if origMatch: + codeList[lineIdx]["parameters"][0] = origParam.replace(origMatch.group(1), "") + + # If more wrapped lines than slots, insert new 655 codes + if len(wrappedLines) > len(textLineIndices): + lastIdx = textLineIndices[-1] + indent = codeList[lastIdx].get("indent", 0) + for extra in range(len(textLineIndices), len(wrappedLines)): + new_item = { + "code": 655, + "indent": indent, + "parameters": [ + ' _MTxt += "' + wrappedLines[extra] + '" + "\\n";' + ], + } + insertPos = lastIdx + 1 + (extra - len(textLineIndices)) + codeList.insert(insertPos, new_item) + # Adjust j to account for inserted items + j += len(wrappedLines) - len(textLineIndices) + + i = j - 1 + + # OpeSet handler - translate speaker name (1st arg) and dialogue text (2nd arg) + # Example: this.OpeSet(\"オペレーター\",\"今回の任務の内容は迷子になった少女を救出することです。\",\"ope\",180) + if "OpeSet(" in jaString: + # Extract speaker name (1st quoted arg) and text (2nd quoted arg) + nameMatch = re.search(r'OpeSet\s*\(\s*\\?"(.+?)\\?"\s*,', jaString) + textMatch = re.search(r'OpeSet\s*\(\s*\\?"[^"]*?\\?"\s*,\s*\\?"(.+?)\\?"', jaString) + + nameStr = None + translatedName = "" + textStr = None + + # Process speaker name via getSpeaker + if nameMatch: + n = nameMatch.group(1) + if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', n): + if not (IGNORETLTEXT and not re.search(LANGREGEX, n)): + nameStr = n + response = getSpeaker(n) + translatedName = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Process text (2nd arg) + if textMatch: + t = textMatch.group(1) + if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]', t): + if not (IGNORETLTEXT and not re.search(LANGREGEX, t)): + textStr = t + + if textStr or nameStr: + speakerPrefix = translatedName if translatedName else "" + + if setData: + if textStr: + if speakerPrefix: + list355655.append(f"[{speakerPrefix}]: {textStr}") + else: + list355655.append(textStr) + else: + if textStr: + if len(list355655) > 0: + translatedText = list355655[0] + list355655.pop(0) + # Strip speaker prefix if present + translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText) + # Replace double quotes to avoid breaking JS syntax + translatedText = translatedText.replace('\\"', "'") + translatedText = translatedText.replace('"', "'") + jaString = jaString.replace(textStr, translatedText, 1) + # Replace the speaker name (already translated via getSpeaker) + if nameStr and translatedName: + translatedName = translatedName.replace('\\"', "'") + translatedName = translatedName.replace('"', "'") + jaString = jaString.replace(nameStr, translatedName, 1) + # Normalize \\N and \\C codes to always have exactly 4 backslashes + jaString = re.sub(r'\\+([cCnN]\[\d+\])', r'\\\\\1', jaString) + codeList[i]["parameters"][0] = jaString + + ## Event Code: 408 (Script) + if "code" in codeList[i] and (codeList[i]["code"] == 408) and CODE408 is True: + # Only translate if preceded by a 108 with "選択肢ヘルプ" or another 408 + if i > 0: + prevCode = codeList[i - 1].get("code", None) + if prevCode == 408: + pass # Consecutive 408s are allowed + elif prevCode == 108 and len(codeList[i - 1].get("parameters", [])) > 0 and codeList[i - 1]["parameters"][0] == "選択肢ヘルプ": + pass # 108 with 選択肢ヘルプ is allowed + else: + i += 1 + continue + + if not codeList[i].get("parameters"): + i += 1 + continue + + groupStart408 = i + j = i + source408Parts = [] + rawSource = _param_source(codeList[i], 0) + ojaString = rawSource + anchor408HasOrig = _scalar_original(codeList[groupStart408]) is not None + source408Parts.append(rawSource) + + if not rawSource.strip(): + i += 1 + continue + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, rawSource): + i += 1 + continue + + # Join Up 408's into single string + if len(codeList) > i + 1 and JOIN408 is True: + while codeList[i + 1]["code"] in [408] and len(codeList[i]["parameters"]) > 0 and len(codeList[i + 1]["parameters"]) > 0 and not re.match(r"^(\s*[\\]+[aAbBdDeEfFgGhHjJlLmMoOpPqQrRsStTuUwWxXyYzZ]+\[[\w\d\[\]\\]+\])", codeList[i+1]["parameters"][0]): + if not setData: + codeList[i]["parameters"] = [] + codeList[i]["code"] = -1 + i += 1 + j = i + + lineSource = _param_source(codeList[i], 0) + if lineSource.strip() and not anchor408HasOrig: + source408Parts.append(lineSource) + + if len(codeList) <= i + 1: + break + + rawSource = _group_raw_source(codeList, groupStart408, source408Parts) + ojaString = rawSource + jaString = rawSource.replace("\n", " ") + + # Pass 1 + if setData: + list408.append(jaString) + + # Pass 2 + else: + if len(list408) > 0: + translatedText = list408[0] + list408.pop(0) + + merged408 = len(source408Parts) > 1 + if merged408: + codeList[i]["parameters"] = [translatedText] + else: + param0 = codeList[i]["parameters"][0] + if ojaString in param0: + codeList[i]["parameters"][0] = param0.replace(ojaString, translatedText) + else: + flatSource = ojaString.replace("\n", " ") + if flatSource in param0: + codeList[i]["parameters"][0] = param0.replace(flatSource, translatedText) + else: + codeList[i]["parameters"][0] = translatedText + + _apply_original(codeList[i], rawSource) + + ## Event Code: 108 (Script) + if "code" in codeList[i] and (codeList[i]["code"] == 108) and CODE108 is True: + jaString = codeList[i]["parameters"][0] + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, jaString): + i += 1 + continue + + # Translate + if "info:" in jaString: + regex = r"info:([^,]+)" + elif "ActiveMessage:" in jaString: + regex = r"?" + elif "event_text" in jaString: + regex = r"event_text\s*:\s*(.*)" + elif "Menu Name" in jaString: + regex = r"Menu\sName\s*:\s*(.*)>" + elif "text_indicator" in jaString: + regex = r"text_indicator\s?:\s?(.+)" + elif "NW名前指定" in jaString: + regex = r"NW名前指定\s+(.+)" + else: + i += 1 + continue + + # Need to remove outside code and put it back later + match = re.search(regex, jaString) + if match: + # Pass 1 + if setData: + list108.append(match.group(1)) + + # # Grab Next + # j = i + # while codeList[j + 1]["code"] == 408: + # j += 1 + # list108[0] = list108[0] + codeList[j]["parameters"][0].replace(">", "") + # codeList[j]["parameters"][0] = "" + # list108[0] = list108[0].replace("\n", " ") + + # Pass 2 + else: + # Grab and Replace + translatedText = list108[0] + list108.pop(0) + + # Textwrap + # if codeList[i + 1]["code"] == 408: + # translatedText = dazedwrap.wrapText(translatedText, WIDTH) + + # Remove characters that may break scripts + charList = ['"'] + for char in charList: + translatedText = translatedText.replace(char, "") + translatedText = translatedText.replace('"', '"') + translatedText = translatedText.replace(" ", "_") + translatedText = jaString.replace(match.group(1), translatedText) + + # Add > + # if "ActiveMessage" in translatedText and ">" not in translatedText: + # translatedText = translatedText + ">" + + # Set Data + codeList[i]["parameters"][0] = translatedText + + ## Event Code: 356 + if "code" in codeList[i] and codeList[i]["code"] == 356 and CODE356 is True: + jaString = codeList[i]["parameters"][0] + oldjaString = jaString + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, jaString): + i += 1 + continue + + # Grab Speaker + if "Tachie showName" in jaString: + matchList = re.findall(r"Tachie showName (.+)", jaString) + if len(matchList) > 0: + # Translate + response = translateAI( + matchList[0], + "Reply with the " + LANGUAGE + " translation of the NPC name.", + False, + ) + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Set Text + speaker = translatedText + speaker = speaker.replace(" ", " ") + codeList[i]["parameters"][0] = jaString.replace(matchList[0], speaker) + i += 1 + continue + + # Want to translate this script + if "D_TEXT " in jaString: + regex = r"D_TEXT\s*(.+?)(?:\s+\d+)?$" + elif "ShowInfo" in jaString: + regex = r"ShowInfo\s(.*)" + elif "PushGab" in jaString: + regex = r"PushGab\s(.*)" + elif "addLog" in jaString: + regex = r"addLog\s(.*)" + elif "DW_" in jaString: + regex = r"DW_.*\s\d+\s(.+)" + elif "CommonPopup" in jaString: + regex = r"CommonPopup\sadd\stext:(.+?)(?=\s+count:|\s*$)" + elif "AddCustomChoice" in jaString: + regex = r"AddCustomChoice\s\d+\s(.+)\s\d" + else: + regex = r"" + + # Remove any textwrap + jaString = re.sub(r"\n", "_", jaString) + + # Capture Arguments and text + textMatch = re.search(regex, jaString) + if textMatch and textMatch.group(0) != "": + text = textMatch.group(1) + + # Capture Speakers + match = re.search(r"[\\]+ow\[\d+\][\\]+c\[\d+\](.+)", text) + if match: + speakerJA = match.group(1) + + # Translate + response = getSpeaker(speakerJA) + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + codeList[i]["parameters"][0] = jaString.replace(speakerJA, translatedText) + i += 1 + continue + else: + speaker = "" + + # Pass 1 + if setData: + text = text.replace("_", " ") + list356.append(text) + + # Pass 2 + else: + if len(list356) > 0: + # Grab + translatedText = list356[0] + + # Remove characters that may break scripts + # addLog keeps dots and quotes (they're fine in log text) + if "addLog" not in jaString: + charList = [".", '"'] + for char in charList: + translatedText = translatedText.replace(char, "") + + # Cant have spaces? + translatedText = translatedText.replace(" ", "_") + if "D_TEXT " not in jaString: + translatedText = translatedText.replace("__", "_") + + # Put Args Back + translatedText = jaString.replace(text, translatedText) + + # Set Data + codeList[i]["parameters"][0] = translatedText + list356.pop(0) + + if "namePop" in jaString: + # Support both "" and "namePop [num] text" formats + matchList = re.findall(r"]+)>", jaString) + if not matchList: + m = re.search(r"\bnamePop\b\s*(?:-?\d+)?\s*([^\r\n<>]+)", jaString) + if m: + matchList = [m.group(1).strip()] + if len(matchList) > 0: + # Translate + text = matchList[0] + response = translateAI(text, "Reply with the " + LANGUAGE + " Translation") + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Set Data + updated = jaString.replace(text, translatedText.replace(" ", "_")) + codeList[i]["parameters"][0] = updated + + if "LL_InfoPopupWIndowMV" in jaString: + matchList = re.findall(r"LL_InfoPopupWIndowMV\sshowWindow\s(.+?) .+", jaString) + if len(matchList) > 0: + text = matchList[0] + + # Pass 1: collect into batch + if setData: + # store without underscores for cleaner translation later + list356.append(text.replace("_", " ")) + + # Pass 2: apply translations from list356 + else: + if len(list356) > 0: + translatedText = list356[0] + list356.pop(0) + + # Replace spaces with underscores as original format expects + translatedText = translatedText.replace(" ", "_") + + # Put Args Back + translatedText = jaString.replace(text, translatedText) + + # Set Data + codeList[i]["parameters"][0] = translatedText + + if "OriginMenuStatus SetParam" in jaString: + matchList = re.findall(r"OriginMenuStatus\sSetParam\sparam[\d]\s(.*)", jaString) + if len(matchList) > 0: + # Translate + text = matchList[0] + response = translateAI(text, "Reply with the " + LANGUAGE + " Translation") + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Set Data + translatedText = translatedText.replace(" ", "_") + translatedText = jaString.replace(text, translatedText) + codeList[i]["parameters"][0] = translatedText + + # LL_GalgeChoiceWindowMV Message + if "LL_GalgeChoiceWindowMV setMessageText" in jaString: + ### Message Text First + match = re.search(r"LL_GalgeChoiceWindowMV setMessageText (.+)", jaString) + if match: + jaString = match.group(1) + + # Remove any textwrap & TL + jaString = re.sub(r"\n", " ", jaString) + response = translateAI(jaString, "") + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Textwrap & Replace Whitespace + translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) + translatedText = translatedText.replace(" ", "_") + + # Replace and Set + translatedText = match.group(0).replace(match.group(1), translatedText) + codeList[i]["parameters"][0] = translatedText + + # LL_GalgeChoiceWindowMV Choices + if "LL_GalgeChoiceWindowMV setChoices": + match = re.search(r"LL_GalgeChoiceWindowMV setChoices (.+)", jaString) + if match: + jaString = match.group(1) + choiceList = jaString.split(",") + + # Translate + question = translatedText + response = translateAI( + choiceList, + f"Previous text for context: {question}\n", + True, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + choiceListTL = response[0] + translatedText = match.group(0) + + # Replace Strings + for j in range(len(choiceListTL)): + choiceListTL[j] = choiceListTL[j].replace(" ", "_") + translatedText = translatedText.replace(choiceList[j], choiceListTL[j]) + + # Set Data + codeList[i]["parameters"][0] = translatedText + + ### Event Code: 102 Show Choice + if "code" in codeList[i] and codeList[i]["code"] == 102 and CODE102 is True: + choiceList = [] + varList = [] + choiceIndexMap = [] # Track which original indices we're processing + choiceSourceList = [] + + # Process each string in the parameters list + for choice in range(len(codeList[i]["parameters"][0])): + rawSource = _choice_source(codeList[i], choice) + jaString = rawSource.replace(" 。", ".") + + # Avoid Empty Strings + if not jaString.strip(): + continue + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, rawSource): + continue + + # If and En Statements + ifVar = "" + ifList = re.findall(r"([ei][nf]\(.+?\)\)?\)?)", jaString) + if len(ifList) != 0: + for var in ifList: + jaString = jaString.replace(var, "") + ifVar += var + + # Store the formatting and cleaned string + varList.append(ifVar) + choiceList.append(jaString) + choiceIndexMap.append(choice) + choiceSourceList.append(rawSource) + + # Translate the list + if len(choiceList) > 0: + if len(textHistory) > 0: + response = translateAI( + choiceList, + f"Reply with the English translation of the dialogue choice.\n\nPrevious text for context: {str(textHistory)}\n", + True, + ) + else: + response = translateAI(choiceList, "Reply with the English translation of the dialogue choice.") + + translatedTextList = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Check Mismatch and set translations + if len(translatedTextList) == len(choiceList): + for idx, translatedText in enumerate(translatedTextList): + originalIndex = choiceIndexMap[idx] + + # Apply formatting + if translatedText != "": + translatedText = varList[idx] + translatedText[0].upper() + translatedText[1:] + else: + translatedText = varList[idx] + translatedText + + # Set the translation back to the original position + codeList[i]["parameters"][0][originalIndex] = translatedText + _apply_choice_original(codeList[i], originalIndex, choiceSourceList[idx]) + else: + if filename not in MISMATCH: + MISMATCH.append(filename) + + ### Event Code: 111 Script + if "code" in codeList[i] and codeList[i]["code"] == 111 and CODE111 is True: + for j in range(len(codeList[i]["parameters"])): + jaString = codeList[i]["parameters"][j] + + # Check if String + if not isinstance(jaString, str): + continue + + # Only TL the Game Variable + if "$gameVariables" not in jaString: + continue + + # Need to remove outside code and put it back later + matchList = re.findall(r"['\"`](.*?)['\"`]", jaString) + + for match in matchList: + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, match): + continue + + # Look up translation from code 122 cache (file-backed) + cachedTranslation = get_var_translation(match) + + if cachedTranslation is not None: + jaString = jaString.replace(match, cachedTranslation) + + # Set Data + codeList[i]["parameters"][j] = jaString + + ### Event Code: 320 Set Variable + if "code" in codeList[i] and codeList[i]["code"] == 320 and CODE320 is True: + jaString = codeList[i]["parameters"][1] + if not isinstance(jaString, str): + i += 1 + continue + + # Definitely don't want to mess with files + if "■" in jaString or "_" in jaString: + i += 1 + continue + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, jaString): + i += 1 + continue + + # Translate + response = getSpeaker(jaString) + translatedText = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Remove characters that may break scripts + charList = [".", '"', "'", "\\n"] + for char in charList: + translatedText = translatedText.replace(char, "") + + # Set Data + codeList[i]["parameters"][1] = translatedText + + ### Event Code: 325 + if "code" in codeList[i] and codeList[i]["code"] == 325 and CODE325 is True: + # Expect parameters like [index, "text"] where parameters[1] is the string + if len(codeList[i]["parameters"]) <= 1: + i += 1 + continue + + jaString = codeList[i]["parameters"][1] + if not isinstance(jaString, str): + i += 1 + continue + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, jaString): + i += 1 + continue + + # Remove Textwrap + collectString = jaString.replace("\n", " ") + + # Pass 1: collect into batch + if setData: + list325.append(collectString) + + # Pass 2: apply translations from batch + else: + if len(list325) > 0: + translatedText = list325[0] + list325.pop(0) + + # Textwrap + translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) + + # Set translated value back into parameters[1] + codeList[i]["parameters"][1] = "\\}\\}" + translatedText + + ### Event Code: 324 + if "code" in codeList[i] and codeList[i]["code"] == 324 and CODE324 is True: + # Expect parameters like [1, "text"] where index 1 is the string to translate + if len(codeList[i]["parameters"]) <= 1: + i += 1 + continue + + jaString = codeList[i]["parameters"][1] + if not isinstance(jaString, str): + i += 1 + continue + + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, jaString): + i += 1 + continue + + # Remove any textwrap for collection + collectString = jaString.replace("\n", " ") + + # Pass 1: collect + if setData: + list324.append(collectString) + + # Pass 2: apply translations from list324 + else: + if len(list324) > 0: + translatedText = list324[0] + list324.pop(0) + + # Clean translation + for ch in ['"', "\\n"]: + translatedText = translatedText.replace(ch, "") + + # Textwrap to reasonable width + translatedText = dazedwrap.wrapText(translatedText, width=LISTWIDTH) + + # Set translated value back into parameters[1] + codeList[i]["parameters"][1] = translatedText + + # Iterate + i += 1 + + # EOF + list401TL = [] + list408TL = [] + list324TL = [] + list122TL = [] + list356TL = [] + list357TL = [] + list355655TL = [] + list108TL = [] + list325TL = [] + list657TL = [] + PBAR = pbar + + # 401 + if len(list401) > 0: + response = translateAI(list401, "") + list401TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list401TL) != len(list401): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 122 + if len(list122) > 0: + response = translateAI(list122, "Keep your translation as brief as possible") + list122TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list122TL) != len(list122): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + else: + # Store each original→translated pair for code 111 consistency (file-backed) + set_var_translations_batch(list(zip(list122, list122TL))) + + # 355/655 + if len(list355655) > 0: + response = translateAI(list355655, textHistory) + list355655TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list355655TL) != len(list355655): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 108 + if len(list108) > 0: + response = translateAI(list108, "This text is a label. Use title capitalization and keep it brief.") + list108TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list108TL) != len(list108): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 356 + if len(list356) > 0: + response = translateAI(list356, textHistory) + list356TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list356TL) != len(list356): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 357 + if len(list357) > 0: + response = translateAI(list357, textHistory) + list357TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list357TL) != len(list357): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 657 + if len(list657) > 0: + response = translateAI(list657, textHistory) + list657TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list657TL) != len(list657): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 408 + if len(list408) > 0: + response = translateAI(list408, "") + list408TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list408TL) != len(list408): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 324 + if len(list324) > 0: + # Generic short-text translation for parameter index 1 + response = translateAI(list324, "Reply with only the " + LANGUAGE + " translation of the text.") + list324TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list324TL) != len(list324): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # 325 + if len(list325) > 0: + # Use same short-text speaker-style translation as other name fields + response = translateAI(list325, "Reply with the " + LANGUAGE + " translation of the NPC name.") + list325TL = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + if len(list325TL) != len(list325): + with LOCK: + if filename not in MISMATCH: + MISMATCH.append(filename) + + # Start Pass 2 + if setData: + searchCodes( + page, + pbar, + [ + list401TL, + list122TL, + list355655TL, + list108TL, + list356TL, + list357TL, + list324TL, + list408TL, + list325TL, + list657TL, + ], + filename, + ) + + # Delete all -1 codes + codeListFinal = [] + for i in range(len(codeList)): + if "code" in codeList[i] and codeList[i]["code"] != -1: + codeListFinal.append(codeList[i]) + + # Normal Format + if "list" in page: + page["list"] = codeListFinal + + # Special Format (Scenario) + else: + page[:] = codeListFinal + except IndexError as e: + traceback.print_exc() + except Exception as e: + traceback.print_exc() + + return totalTokens + + +def searchSS(state, pbar): + totalTokens = [0, 0] + + # --- Batch collection for basic fields and messages --- + batch_texts = [] + batch_map = [] # [(field_type, field_name, needs_taro_prefix, raw_source), ...] + + # Name + if "name" in state and state["name"]: + name_src = _entry_field_source(state, "name") + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, name_src)): + batch_texts.append(name_src) + batch_map.append(("name", "name", False, name_src)) + + # Description + if "description" in state and state["description"]: + desc_src = _entry_field_source(state, "description") + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, desc_src)): + batch_texts.append(desc_src) + batch_map.append(("description", "description", False, desc_src)) + + # Messages - collect all with Taro prefix handling + for msg_field in ["message1", "message2", "message3", "message4"]: + if msg_field in state and state[msg_field]: + msg_text = _entry_field_source(state, msg_field) + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, msg_text): + continue + needs_taro = len(msg_text) > 0 and msg_text[0] in ["は", "を", "の", "に", "が"] + if needs_taro: + batch_texts.append("Taro" + msg_text) + else: + batch_texts.append(msg_text) + batch_map.append(("message", msg_field, needs_taro, msg_text)) + + # --- Batch translate all basic fields --- + nameResponse = "" + descriptionResponse = "" + message1Response = "" + message2Response = "" + message3Response = "" + message4Response = "" + + if batch_texts: + response = translateAI( + batch_texts, + "reply with only the gender neutral " + LANGUAGE + " translation. For messages starting with Taro, always start the sentence with Taro. For example, translate 'Taroを倒した!' as 'Taro was defeated!'", + False, + ) + translated_batch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + + # Map translations back to their fields + for idx, (field_type, field_name, needs_taro, raw_source) in enumerate(batch_map): + if idx < len(translated_batch): + translation = translated_batch[idx] + if field_type == "name": + nameResponse = [translation, [0, 0]] + _apply_entry_field_original(state, "name", raw_source) + elif field_type == "description": + descriptionResponse = [translation, [0, 0]] + _apply_entry_field_original(state, "description", raw_source) + elif field_type == "message": + response_obj = [translation, [0, 0]] + _apply_entry_field_original(state, field_name, raw_source) + if field_name == "message1": + message1Response = response_obj + elif field_name == "message2": + message2Response = response_obj + elif field_name == "message3": + message3Response = response_obj + elif field_name == "message4": + message4Response = response_obj + + # --- Batching pass: collect all note texts for all note types --- + note_regexes = [ + (r"]*)>", False), + (r"\n(.*)\n", False), + (r"", False), + (r"", False), + (r"<説明:([^>]*)>", False), + ] + notesBatch = [] + notesBatchMap = [] + if "note" in state and state["note"]: + note = state["note"] + for regex, wordwrap in note_regexes: + matches = re.findall(regex, note, re.DOTALL) + for m in matches: + match_text = m if isinstance(m, str) else m[0] + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, match_text): + continue + notesBatch.append(match_text) + notesBatchMap.append((regex, match_text, wordwrap)) + + # --- Batch translate all notes --- + translatedNotesBatch = [] + if notesBatch: + response = translateAI(notesBatch, f"Reply with only the {LANGUAGE} translation of the note text.") + translatedNotesBatch = response[0] + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + # Notes don't update progress + + # --- Insert translated notes back --- + note_insert_idx = 0 + if "note" in state and state["note"]: + for regex, match_text, wordwrap in notesBatchMap: + if note_insert_idx >= len(translatedNotesBatch): + break + translated = translatedNotesBatch[note_insert_idx] + if wordwrap: + translated = dazedwrap.wrapText(translated, width=NOTEWIDTH) + translated = translated.replace('"', "") + # Replace only the matched text in the note using a literal replacement + # Avoid re.sub here because replacement strings with backslashes (e.g., \I) + # are interpreted as escapes and can raise re.PatternError. + state["note"] = state["note"].replace(match_text, translated, 1) + note_insert_idx += 1 + + # Progress accounting for this state: name + description + messages present + if pbar is not None: + work_units = 0 + work_units += 1 if nameResponse != "" else 0 + work_units += 1 if descriptionResponse != "" else 0 + work_units += 1 if message1Response != "" else 0 + work_units += 1 if message2Response != "" else 0 + work_units += 1 if message3Response != "" else 0 + work_units += 1 if message4Response != "" else 0 + if work_units: + pbar.refresh() + + # Set Data + if "name" in state and nameResponse != "": + state["name"] = nameResponse[0].replace('"', "") + if "description" in state and descriptionResponse != "": + # Textwrap + translatedText = descriptionResponse[0] + translatedText = dazedwrap.wrapText(translatedText, width=LISTWIDTH) + state["description"] = translatedText.replace('"', "") + if "message1" in state and message1Response != "": + state["message1"] = message1Response[0].replace('"', "").replace("Taro", "") + if "message2" in state and message2Response != "": + state["message2"] = message2Response[0].replace('"', "").replace("Taro", "") + if "message3" in state and message3Response != "": + state["message3"] = message3Response[0].replace('"', "").replace("Taro", "") + if "message4" in state and message4Response != "": + state["message4"] = message4Response[0].replace('"', "").replace("Taro", "") + + return totalTokens + + +def searchSystem(data, pbar): + totalTokens = [0, 0] + context = "Reply with only the " + LANGUAGE + ' translation of the UI textbox."' + + # Title - batch as a single-item list + title_src = _system_scalar_source(data, "gameTitle") + # Skip if IGNORETLTEXT is enabled and no Japanese text + if not (IGNORETLTEXT and not re.search(LANGREGEX, title_src)): + response = translateAI( + [title_src], + " Reply with the " + LANGUAGE + " translation of the game title name", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + data["gameTitle"] = response[0][0].strip(".") + _apply_system_scalar_original(data, "gameTitle", title_src) + if pbar is not None: + pbar.refresh() + + # Terms - batch translate all term items + for term in data["terms"]: + if term != "messages": + termList = data["terms"][term] + term_values = [] + term_indices = [] + term_sources = [] + for i in range(len(termList)): + if termList[i] is not None: + src = _system_terms_source(data, term, i) + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, src): + continue + term_values.append(src) + term_indices.append(i) + term_sources.append(src) + + if term_values: + response = translateAI(term_values, context) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + + for n, idx in enumerate(term_indices[: len(tl_list)]): + termList[idx] = tl_list[n].replace('"', "").strip() + _apply_system_terms_original(data, term, idx, term_sources[n]) + + if pbar is not None: + pbar.refresh() + + # Armor Types - batch translate all + armor_values = [] + armor_indices = [] + armor_sources = [] + for i in range(len(data["armorTypes"])): + src = _system_list_source(data, "armorTypes", i) + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and (not src or not re.search(LANGREGEX, src)): + continue + armor_values.append(src) + armor_indices.append(i) + armor_sources.append(src) + if armor_values: + response = translateAI( + armor_values, + "Reply with only the " + LANGUAGE + " translation of the armor type", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + for n, idx in enumerate(armor_indices[: len(tl_list)]): + data["armorTypes"][idx] = tl_list[n].replace('"', "").strip() + _apply_system_list_original(data, "armorTypes", idx, armor_sources[n]) + if pbar is not None: + pbar.refresh() + + # Skill Types - batch translate all + skill_values = [] + skill_indices = [] + skill_sources = [] + for i in range(len(data["skillTypes"])): + src = _system_list_source(data, "skillTypes", i) + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and (not src or not re.search(LANGREGEX, src)): + continue + skill_values.append(src) + skill_indices.append(i) + skill_sources.append(src) + if skill_values: + response = translateAI( + skill_values, + "Reply with only the " + LANGUAGE + " translation", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + for n, idx in enumerate(skill_indices[: len(tl_list)]): + data["skillTypes"][idx] = tl_list[n].replace('"', "").strip() + _apply_system_list_original(data, "skillTypes", idx, skill_sources[n]) + if pbar is not None: + pbar.refresh() + + # Equip Types - batch translate all (not present in RPG Maker Ace) + equip_values = [] + equip_indices = [] + equip_sources = [] + for i in range(len(data.get("equipTypes", []) or [])): + src = _system_list_source(data, "equipTypes", i) + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and (not src or not re.search(LANGREGEX, src)): + continue + equip_values.append(src) + equip_indices.append(i) + equip_sources.append(src) + if equip_values: + response = translateAI( + equip_values, + "Reply with only the " + LANGUAGE + " translation of the equipment type. No disclaimers.", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + for n, idx in enumerate(equip_indices[: len(tl_list)]): + data["equipTypes"][idx] = tl_list[n].replace('"', "").strip() + _apply_system_list_original(data, "equipTypes", idx, equip_sources[n]) + if pbar is not None: + pbar.refresh() + + # Elements - batch translate all (skip empty) + element_values = [] + element_indices = [] + element_sources = [] + for i in range(len(data["elements"])): + src = _system_list_source(data, "elements", i) + if not src: + continue + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, src): + continue + element_values.append(src) + element_indices.append(i) + element_sources.append(src) + + if element_values: + response = translateAI( + element_values, + "Reply with only the " + LANGUAGE + " translation of the element type", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + for n, idx in enumerate(element_indices[: len(tl_list)]): + data["elements"][idx] = tl_list[n].replace('"', "").strip() + _apply_system_list_original(data, "elements", idx, element_sources[n]) + if pbar is not None: + pbar.refresh() + + # Weapon Types - batch translate all (skip empty) + weapon_values = [] + weapon_indices = [] + weapon_sources = [] + for i in range(len(data["weaponTypes"])): + src = _system_list_source(data, "weaponTypes", i) + if not src: + continue + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, src): + continue + weapon_values.append(src) + weapon_indices.append(i) + weapon_sources.append(src) + + if weapon_values: + response = translateAI( + weapon_values, + "Reply with only the " + LANGUAGE + " translation of the weapon type", + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + for n, idx in enumerate(weapon_indices[: len(tl_list)]): + data["weaponTypes"][idx] = tl_list[n].replace('"', "").strip() + _apply_system_list_original(data, "weaponTypes", idx, weapon_sources[n]) + if pbar is not None: + pbar.refresh() + + # Variables (Optional usually) — batch translate to reduce calls + if TLSYSTEMVARIABLES and "variables" in data and isinstance(data["variables"], list): + var_indices = [] + var_values = [] + var_sources = [] + for idx, val in enumerate(data["variables"]): + src = _system_list_source(data, "variables", idx) + if isinstance(val, str) and src.strip(): + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, src): + continue + var_indices.append(idx) + var_values.append(src) + var_sources.append(src) + if var_values: + response = translateAI( + var_values, + 'Reply with only the ' + LANGUAGE + ' translation of the title', + True, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + # Assign back translations to corresponding indices + for n, idx in enumerate(var_indices[: len(tl_list)]): + data["variables"][idx] = tl_list[n].replace('"', '').strip() + _apply_system_list_original(data, "variables", idx, var_sources[n]) + if pbar is not None: + pbar.refresh() + + # Switches (Optional) — batch translate to reduce calls + if TLSYSTEMSWITCHES and "switches" in data and isinstance(data["switches"], list): + switch_indices = [] + switch_values = [] + switch_sources = [] + for idx, val in enumerate(data["switches"]): + src = _system_list_source(data, "switches", idx) + if isinstance(val, str) and src.strip(): + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, src): + continue + switch_indices.append(idx) + switch_values.append(src) + switch_sources.append(src) + if switch_values: + response = translateAI( + switch_values, + 'Reply with only the ' + LANGUAGE + ' translation of the switch name', + True, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + # Assign back translations to corresponding indices + for n, idx in enumerate(switch_indices[: len(tl_list)]): + data["switches"][idx] = tl_list[n].replace('"', '').strip() + _apply_system_list_original(data, "switches", idx, switch_sources[n]) + if pbar is not None: + pbar.refresh() + + # Messages — batch translate to reduce calls + messages = data["terms"]["messages"] + if messages: + msg_keys = [] + msg_values = [] + msg_sources = [] + for key, value in messages.items(): + src = _system_terms_message_source(data, key) + if isinstance(value, str) and src.strip(): + # Skip if IGNORETLTEXT is enabled and no Japanese text + if IGNORETLTEXT and not re.search(LANGREGEX, src): + continue + msg_keys.append(key) + msg_values.append(src) + msg_sources.append(src) + + if msg_values: + response = translateAI( + msg_values, + "Reply with only the " + + LANGUAGE + + ' translation of the battle text.\nTranslate "常時ダッシュ" as "Always Dash"\nTranslate "次の%1まで" as Next %1.', + False, + ) + totalTokens[0] += response[1][0] + totalTokens[1] += response[1][1] + tl_list = response[0] + + # Remove characters that may break scripts + charList = [".", '"', "\\n"] + + # Assign back translations to corresponding keys + for n, key in enumerate(msg_keys[: len(tl_list)]): + translatedText = tl_list[n] + for char in charList: + translatedText = translatedText.replace(char, "") + messages[key] = translatedText + _apply_system_terms_message_original(data, key, msg_sources[n]) + + if pbar is not None: + pbar.refresh() + + return totalTokens + +# Regex that matches one or more markup codes like \c[1], \n[2], \ow[3], etc. +_MARKUP_STRIP_RE = re.compile(r"[\\]+[a-zA-Z]+\[[\w\d]*\]") + +def _is_plausible_speaker(name: str) -> bool: + """Return True only if *name* looks like a character name rather than dialogue or junk. + + Called during SPEAKER_PARSE_MODE to filter false positives before they + enter SPEAKER_COLLECTED. Heuristics (applied after stripping markup): + • 1–20 characters long + • Contains at least one Japanese character (kana / kanji) + • No sentence-ending / mid-sentence punctuation (。!?…、) + • No dialogue-opening quotes (「"() + • No newlines, underscores, slashes, or dots + """ + clean = _MARKUP_STRIP_RE.sub("", name).strip() + if not clean: + return False + if len(clean) > 20: + return False + # Must have at least one Japanese character + if not re.search(r"[\u3040-\u30FA\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uFF61-\uFF9F]", clean): + return False + # Reject sentence-like strings + if re.search(r"[。!?…、]", clean): + return False + # Reject dialogue openers / structural characters + if re.search(r"[「」""\n\r_/\\.]", clean): + return False + return True + + +# Save some money and enter the character before translation +def getSpeaker(speaker: str): + """Return (and possibly collect) speaker name. + + Parse mode (SPEAKER_PARSE_MODE=True): + - Don't translate immediately. Collect unique originals in SPEAKER_COLLECTED. + - Return original so caller logic works; token cost is zero. + + Normal mode: translate immediately with caching. + """ + if speaker == "": + return ["", [0, 0]] + + # Preflight count mode: skip translation and caching entirely + if 'PREFLIGHT_COUNT_MODE' in globals() and PREFLIGHT_COUNT_MODE: + return [speaker, [0, 0]] + + if SPEAKER_PARSE_MODE: + with _speakerCacheLock: + if speaker in _speakerCache: + return [_speakerCache[speaker], [0, 0]] + if speaker not in SPEAKER_COLLECTED and _is_plausible_speaker(speaker): + SPEAKER_COLLECTED.append(speaker) + return [speaker, [0, 0]] + + # Normal mode translation path + with _speakerCacheLock: + cached = _speakerCache.get(speaker) + if cached is not None: + return [cached, [0, 0]] + + try: + THREAD_CTX.in_speaker = True + except Exception: + pass + response = translateAI( + speaker, + "Reply with the " + LANGUAGE + " translation of the NPC name.", + False, + ) + try: + THREAD_CTX.in_speaker = False + except Exception: + pass + translated = response[0].strip().title().replace("'S", "'s").replace("Speaker: ", "") + translated = re.sub(r'(\d)(St|Nd|Rd|Th)\b', lambda m: m.group(1) + m.group(2).lower(), translated) + + if re.search(r"([a-zA-Z??])", translated) is None: + try: + THREAD_CTX.in_speaker = True + except Exception: + pass + response = translateAI( + speaker, + "Reply with the " + LANGUAGE + " translation of the NPC name.", + False, + ) + try: + THREAD_CTX.in_speaker = False + except Exception: + pass + translated = response[0].strip().title().replace("'S", "'s") + translated = re.sub(r'(\d)(St|Nd|Rd|Th)\b', lambda m: m.group(1) + m.group(2).lower(), translated) + + with _speakerCacheLock: + if speaker not in _speakerCache: + _speakerCache[speaker] = translated + NAMESLIST.append([speaker, translated]) + return [translated, response[1]] + +def _get_actor_map() -> dict: + """Lazily load actor_id -> name from Actors.json, falling back to vocab actor entries.""" + global _ACTOR_MAP_CACHE + with _ACTOR_MAP_CACHE_LOCK: + if _ACTOR_MAP_CACHE: + return _ACTOR_MAP_CACHE + for candidate in (Path("translated/Actors.json"), Path("files/Actors.json")): + if candidate.is_file(): + try: + data = json.loads(candidate.read_text(encoding="utf-8-sig")) + m: dict = {} + for entry in data: + if not entry or not isinstance(entry, dict): + continue + aid = entry.get("id") + name = (entry.get("name") or "").strip() + if aid is not None and name: + m[int(aid)] = name + if m: + _ACTOR_MAP_CACHE = m + return m + except Exception: + continue + try: + m: dict = {} + for line in VOCAB.splitlines(): + match = re.search(r"\(([^()]+)\)\s*-\s*.*?\bactor\s+ID\s+(\d+)\b", line, re.IGNORECASE) + if match: + name = match.group(1).strip() + aid = int(match.group(2)) + if name: + m[aid] = name + if m: + _ACTOR_MAP_CACHE = m + return m + except Exception: + pass + _ACTOR_MAP_CACHE = {} + return {} + + +def resetActorMapCache(): + """Invalidate the cached actor map so it reloads on next use.""" + global _ACTOR_MAP_CACHE + with _ACTOR_MAP_CACHE_LOCK: + _ACTOR_MAP_CACHE = None + + +def translateAI(text, history, history_ctx=None): + """ + Legacy wrapper function for the new shared translation utility. + This maintains compatibility with existing code while using the new shared implementation. + """ + global PBAR, MISMATCH, FILENAME + + # Update config estimate mode based on global ESTIMATE + TRANSLATION_CONFIG.estimateMode = bool(ESTIMATE) + + # Call the new shared translation function + # Prefer thread-local filename for logging; fall back to global + try: + tl_filename = getattr(THREAD_CTX, "filename", FILENAME) + except Exception: + tl_filename = FILENAME + + # Speaker-parse mode: bypass all non-speaker translations to save tokens + if SPEAKER_PARSE_MODE and not getattr(THREAD_CTX, "in_speaker", False): + # Return original text unmodified with zero tokens + return [text, [0, 0]] + + # Preflight count mode: don't hit API; just simulate progress units + if 'PREFLIGHT_COUNT_MODE' in globals() and PREFLIGHT_COUNT_MODE: + try: + n = len(text) if isinstance(text, list) else 1 + except Exception: + n = 1 + if PBAR is not None: + try: + with LOCK: + PBAR.update(n) + except Exception: + pass + # Return original payload and zero tokens so totals aren't affected + return [text, [0, 0]] + + # ── Actor variable substitution ────────────────────────────────────────── + # Replace \n[X] codes with actor names before sending to AI so the model + # sees real character names. Restore only exact-case name matches afterward; + # this avoids lower-case words like "red" and keeps the prompt clean. + actor_map = _get_actor_map() + reverse: dict[str, str] = {} # actor_name -> "\\n[X]" + + def _sub(s: str, reverse_map: dict[str, str]) -> str: + if not isinstance(s, str) or not actor_map: + return s + + def _display_actor_name(m: re.Match) -> str: + name = actor_map.get(int(m.group(1))) + return name if name else m.group(0) + + def _repl(m: re.Match) -> str: + aid = int(m.group(1)) + name = actor_map.get(aid) + if name: + reverse_map[name] = m.group(0) + return name + return m.group(0) + + speaker_prefix = re.match( + r"^(?P\s*\[)(?P(?:\\n\[\d+\]|[^\]\n])+)(?P\]\s*[|:]\s*)", + s, + re.IGNORECASE, + ) + if speaker_prefix: + speaker = _VAR_ACTOR_RE.sub(_display_actor_name, speaker_prefix.group("speaker")) + body = _VAR_ACTOR_RE.sub(_repl, s[speaker_prefix.end():]) + return f"{speaker_prefix.group('open')}{speaker}{speaker_prefix.group('close')}{body}" + + return _VAR_ACTOR_RE.sub(_repl, s) + + if isinstance(text, list): + item_reverses: list[dict[str, str]] = [] + subbed_text = [] + for s in text: + item_reverse: dict[str, str] = {} + subbed_text.append(_sub(s, item_reverse)) + item_reverses.append(item_reverse) + text = subbed_text + else: + item_reverses = [] + text = _sub(text, reverse) + + result = sharedtranslateAI( + text=text, + history=history, + config=TRANSLATION_CONFIG, + filename=tl_filename, + pbar=PBAR, + lock=LOCK, + mismatchList=MISMATCH + ) + + # ── Restore \n[X] codes in translated output ─────────────────────────── + def _restore(s: str, reverse_map: dict[str, str]) -> str: + if not isinstance(s, str) or not reverse_map: + return s + restore_pat = re.compile( + r"(? bool: + return bool(re.search(LANGREGEX, s or "")) + + +def _run_search_names(data, context, filename): + captured = [] + + def translate(text, history, batch=False): + captured.append(copy.deepcopy(text)) + return _mock_translate(text, history, batch) + + orig_t = mvmz.translateAI + orig_vocab = mvmz.update_vocab_section + mvmz.translateAI = translate + mvmz.update_vocab_section = lambda *args, **kwargs: None + try: + data_copy = copy.deepcopy(data) + mvmz.searchNames(data_copy, None, context, filename) + return data_copy, captured + finally: + mvmz.translateAI = orig_t + mvmz.update_vocab_section = orig_vocab + + +def _run_search_ss(state): + captured = [] + + def translate(text, history, batch=False): + captured.append(copy.deepcopy(text)) + return _mock_translate(text, history, batch) + + orig_t = mvmz.translateAI + mvmz.translateAI = translate + try: + state_copy = copy.deepcopy(state) + mvmz.searchSS(state_copy, None) + return state_copy, captured + finally: + mvmz.translateAI = orig_t + + +def _run_search_system(data): + captured = [] + + def translate(text, history, batch=False): + captured.append(copy.deepcopy(text)) + return _mock_translate(text, history, batch) + + orig_t = mvmz.translateAI + mvmz.translateAI = translate + try: + data_copy = copy.deepcopy(data) + mvmz.searchSystem(data_copy, None) + return data_copy, captured + finally: + mvmz.translateAI = orig_t + + +def _assert_batches_japanese(captured): + for payload in captured: + items = payload if isinstance(payload, list) else [payload] + for item in items: + if not isinstance(item, str) or item == "EN_TRANSLATED": + continue + if item.startswith("Taro"): + item = item[4:] + self_fail = not _has_japanese(item) + if self_fail: + raise AssertionError(f"Re-run sent non-Japanese to translateAI: {item!r}") + + +class TestActorsOriginal(unittest.TestCase): + def test_first_pass_writes_original(self): + data = json.loads((FIXTURES / "Actors_original_fixture.json").read_text(encoding="utf-8")) + result, _ = _run_search_names(data, "Actors", "Actors.json") + entry = result[MANIFEST["actors"]["entry_index"]] + expected = MANIFEST["actors"]["expected_original"] + self.assertEqual(entry.get("_original"), expected) + self.assertNotEqual(entry["name"], expected["name"]) + self.assertNotEqual(entry["nickname"], expected["nickname"]) + self.assertNotEqual(entry["profile"], expected["profile"]) + + def test_rerun_preserves_original(self): + data = json.loads((FIXTURES / "Actors_original_fixture.json").read_text(encoding="utf-8")) + result1, _ = _run_search_names(data, "Actors", "Actors.json") + orig_snapshot = copy.deepcopy(result1[1]["_original"]) + result2, captured2 = _run_search_names(result1, "Actors", "Actors.json") + self.assertEqual(result2[1]["_original"], orig_snapshot) + _assert_batches_japanese(captured2) + + +class TestItemsOriginal(unittest.TestCase): + def test_first_pass_writes_original(self): + data = json.loads((FIXTURES / "Items_original_fixture.json").read_text(encoding="utf-8")) + result, _ = _run_search_names(data, "Items", "Items.json") + entry = result[MANIFEST["items"]["entry_index"]] + expected = MANIFEST["items"]["expected_original"] + self.assertEqual(entry.get("_original"), expected) + self.assertNotEqual(entry["name"], expected["name"]) + self.assertNotEqual(entry["description"], expected["description"]) + + def test_rerun_preserves_original(self): + data = json.loads((FIXTURES / "Items_original_fixture.json").read_text(encoding="utf-8")) + result1, _ = _run_search_names(data, "Items", "Items.json") + orig_snapshot = copy.deepcopy(result1[1]["_original"]) + result2, captured2 = _run_search_names(result1, "Items", "Items.json") + self.assertEqual(result2[1]["_original"], orig_snapshot) + _assert_batches_japanese(captured2) + + +class TestSkillsOriginal(unittest.TestCase): + def test_first_pass_writes_original(self): + data = json.loads((FIXTURES / "Skills_original_fixture.json").read_text(encoding="utf-8")) + result, _ = _run_search_names(data, "Skills", "Skills.json") + entry = result[MANIFEST["skills"]["entry_index"]] + expected = MANIFEST["skills"]["expected_original"] + self.assertEqual(entry.get("_original"), expected) + for field, jp in expected.items(): + self.assertNotEqual(entry[field], jp) + + def test_rerun_preserves_original(self): + data = json.loads((FIXTURES / "Skills_original_fixture.json").read_text(encoding="utf-8")) + result1, _ = _run_search_names(data, "Skills", "Skills.json") + orig_snapshot = copy.deepcopy(result1[1]["_original"]) + result2, captured2 = _run_search_names(result1, "Skills", "Items.json") + self.assertEqual(result2[1]["_original"], orig_snapshot) + _assert_batches_japanese(captured2) + + +class TestStatesOriginal(unittest.TestCase): + def test_first_pass_writes_original(self): + data = json.loads((FIXTURES / "States_original_fixture.json").read_text(encoding="utf-8")) + state = data[MANIFEST["states"]["entry_index"]] + result, _ = _run_search_ss(state) + expected = MANIFEST["states"]["expected_original"] + self.assertEqual(result.get("_original"), expected) + for field, jp in expected.items(): + self.assertNotEqual(result[field], jp) + + def test_rerun_preserves_original(self): + data = json.loads((FIXTURES / "States_original_fixture.json").read_text(encoding="utf-8")) + state = data[MANIFEST["states"]["entry_index"]] + result1, _ = _run_search_ss(state) + orig_snapshot = copy.deepcopy(result1["_original"]) + result2, captured2 = _run_search_ss(result1) + self.assertEqual(result2["_original"], orig_snapshot) + _assert_batches_japanese(captured2) + + +class TestSystemOriginal(unittest.TestCase): + def test_first_pass_writes_original(self): + data = json.loads((FIXTURES / "System_original_fixture.json").read_text(encoding="utf-8")) + result, _ = _run_search_system(data) + expected = MANIFEST["system"]["expected_original"] + self.assertEqual(result.get("_original"), expected) + self.assertNotEqual(result["gameTitle"], expected["gameTitle"]) + self.assertNotEqual(result["terms"]["basic"][1], expected["terms"]["basic"]["1"]) + self.assertNotEqual(result["armorTypes"][1], expected["armorTypes"]["1"]) + + def test_rerun_preserves_original(self): + data = json.loads((FIXTURES / "System_original_fixture.json").read_text(encoding="utf-8")) + result1, _ = _run_search_system(data) + orig_snapshot = copy.deepcopy(result1["_original"]) + result2, captured2 = _run_search_system(result1) + self.assertEqual(result2["_original"], orig_snapshot) + _assert_batches_japanese(captured2) + + +if __name__ == "__main__": + unittest.main(verbosity=2)