Download app/rpgmaker/extract.py from Cnass/mtapi: direct link, hf CLI and curl.
- Browser
- Download file 13 kB
-
https://huggingface.co/spaces/Cnass/mtapi/resolve/main/app/rpgmaker/extract.py
- Command line
-
hf download hf://spaces/Cnass/mtapi/app/rpgmaker/extract.py
-
curl -L -o extract.py https://huggingface.co/spaces/Cnass/mtapi/resolve/main/app/rpgmaker/extract.py
13 kB
| """Pull translatable dialogue out of an RPG Maker MV/MZ data folder. | |
| Deliberately narrow. RPG Maker stores executable script, plugin bindings, | |
| asset filenames and engine identifiers in the same JSON arrays as dialogue, | |
| and translating any of those breaks the game rather than mistranslating it - | |
| so this reads exactly four things: | |
| 401 / 405 Show Text / Show Scrolling Text - the dialogue itself | |
| 102 / 402 Show Choices / When[choice] - the buttons under that dialogue | |
| System.json terms - the menus wrapped around both | |
| database name/description/message fields - items, skills, enemies, states | |
| Everything else is left alone, including the MZ speaker-name field on 101, | |
| name-change commands (320/324/325), plugin commands (356/357) and Script | |
| blocks (355/655). | |
| Consecutive 401 commands are one message box split across lines, not separate | |
| sentences, so a run of them is merged into a single unit and translated as one | |
| piece of text - translating "I've been waiting for you" and "for a while." | |
| independently produces two unrelated fragments. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| # Event command codes we read. Anything not listed here is skipped. | |
| CODE_TEXT = 401 # Show Text body line | |
| CODE_SCROLL_TEXT = 405 # Show Scrolling Text body line | |
| CODE_CHOICES = 102 # Show Choices (parameters[0] is a list of labels) | |
| CODE_WHEN_CHOICE = 402 # When [choice] (parameters[1] repeats the label) | |
| # Files that never contain event lists; skipped before parsing. | |
| _SKIP_FILES = {"Tilesets.json", "Animations.json", "MapInfos.json"} | |
| # System.json keys holding player-visible interface text. | |
| # | |
| # `terms` is the menu and options screen: `commands` is New Game / Continue / | |
| # Save / Options, `messages` is the options labels (BGM Volume, Always Dash) | |
| # and the battle log templates, `basic` and `params` are the stat labels. | |
| # The type arrays are shown in the equip and status screens. | |
| # | |
| # Excluded on purpose: `gameTitle` (a proper name, like the character names | |
| # this tool already leaves alone), `currencyUnit` (usually a one-letter symbol | |
| # such as "G" that reads as noise to a translator), and `switches` / | |
| # `variables`, which are developer-facing labels the player never sees. | |
| _SYSTEM_TERM_LISTS = ("basic", "commands", "params") | |
| _SYSTEM_TYPE_LISTS = ( | |
| "armorTypes", | |
| "elements", | |
| "equipTypes", | |
| "skillTypes", | |
| "weaponTypes", | |
| ) | |
| # Database files: flat arrays of entries, index 0 always null (RPG Maker's ids | |
| # are 1-based). Only the fields the player actually reads are listed. | |
| # | |
| # `message1`..`message4` are battle-log templates ("%1 takes damage!") whose %1 | |
| # and %2 are filled in by the engine; Hy-MT2 carries them through the same way | |
| # it carries \\C[2], so they are translated like any other line. | |
| # | |
| # Deliberately absent: | |
| # note - stores plugin notetags (<tag:value>) mixed with free text. | |
| # Translating one would silently change plugin behaviour, and | |
| # splitting text from tags is guesswork, so the whole field is | |
| # left alone. | |
| # Actors - names, nicknames and profiles are the character names this | |
| # tool does not translate. | |
| # Classes - class names read as character labels next to actor names, so | |
| # they follow the same rule. | |
| _DATABASE_FIELDS: dict[str, tuple[str, ...]] = { | |
| "Items.json": ("name", "description"), | |
| "Weapons.json": ("name", "description"), | |
| "Armors.json": ("name", "description"), | |
| "Skills.json": ("name", "description", "message1", "message2"), | |
| "Enemies.json": ("name",), | |
| "States.json": ("name", "message1", "message2", "message3", "message4"), | |
| } | |
| class Slot: | |
| """Where one unit's translation gets written back. | |
| `paths` is a list because a merged run of 401 commands writes one line per | |
| original command: the box keeps the same number of lines it had. | |
| `raw` marks a slot that owns a whole field rather than one line of a | |
| message box. Those are written exactly as the model returned them - a | |
| two-line item description has to stay two lines, where the line-per-command | |
| slots get re-flowed to match the box they came from. | |
| """ | |
| file: str | |
| paths: list[list] = field(default_factory=list) | |
| raw: bool = False | |
| class Unit: | |
| """One source string plus every place it occurs. | |
| Units are keyed by source text across the whole project, so a line like | |
| "Yes" that appears in 300 events is translated once and written 300 times. | |
| In a full game this is the difference between tens of thousands of | |
| generations and a few thousand. | |
| """ | |
| source: str | |
| slots: list[Slot] = field(default_factory=list) | |
| def occurrences(self) -> int: | |
| return len(self.slots) | |
| def _walk_event_list(commands: list, base_path: list, file_name: str, sink) -> None: | |
| """Scan one event command list, emitting (source, Slot) pairs to `sink`.""" | |
| index = 0 | |
| while index < len(commands): | |
| command = commands[index] | |
| if not isinstance(command, dict): | |
| index += 1 | |
| continue | |
| code = command.get("code") | |
| params = command.get("parameters") | |
| if code in (CODE_TEXT, CODE_SCROLL_TEXT): | |
| # Gather the whole run of same-code commands: one message box. | |
| run_paths: list[list] = [] | |
| run_lines: list[str] = [] | |
| while index < len(commands): | |
| nxt = commands[index] | |
| if ( | |
| not isinstance(nxt, dict) | |
| or nxt.get("code") != code | |
| or not isinstance(nxt.get("parameters"), list) | |
| or not nxt["parameters"] | |
| or not isinstance(nxt["parameters"][0], str) | |
| ): | |
| break | |
| run_paths.append(base_path + [index, "parameters", 0]) | |
| run_lines.append(nxt["parameters"][0]) | |
| index += 1 | |
| if run_paths: | |
| sink("\n".join(run_lines), Slot(file=file_name, paths=run_paths)) | |
| continue | |
| if code == CODE_CHOICES and isinstance(params, list) and params: | |
| labels = params[0] | |
| if isinstance(labels, list): | |
| for label_index, label in enumerate(labels): | |
| if isinstance(label, str) and label.strip(): | |
| sink( | |
| label, | |
| Slot( | |
| file=file_name, | |
| paths=[base_path + [index, "parameters", 0, label_index]], | |
| ), | |
| ) | |
| elif code == CODE_WHEN_CHOICE and isinstance(params, list) and len(params) > 1: | |
| # Mirrors the 102 label. Emitted separately, but because units are | |
| # keyed by source text both copies land on the same translation and | |
| # stay in sync automatically. | |
| if isinstance(params[1], str) and params[1].strip(): | |
| sink( | |
| params[1], | |
| Slot(file=file_name, paths=[base_path + [index, "parameters", 1]]), | |
| ) | |
| index += 1 | |
| def _walk_pages(container: dict, base_path: list, file_name: str, sink) -> None: | |
| """Events hold their commands under pages[].list; troops do the same.""" | |
| pages = container.get("pages") | |
| if not isinstance(pages, list): | |
| return | |
| for page_index, page in enumerate(pages): | |
| if isinstance(page, dict) and isinstance(page.get("list"), list): | |
| _walk_event_list( | |
| page["list"], base_path + ["pages", page_index, "list"], file_name, sink | |
| ) | |
| def _extract_system(data: dict, sink) -> None: | |
| """Read the player-visible strings out of System.json. | |
| Entries are frequently null or "" (RPG Maker pads these arrays to a fixed | |
| length and index 0 is usually blank), so every value is checked before it | |
| is emitted - writing a translation into a slot the engine expects to be | |
| empty would put stray text in the menu. | |
| """ | |
| name = "System.json" | |
| terms = data.get("terms") | |
| if isinstance(terms, dict): | |
| for key in _SYSTEM_TERM_LISTS: | |
| values = terms.get(key) | |
| if isinstance(values, list): | |
| for index, value in enumerate(values): | |
| if isinstance(value, str) and value.strip(): | |
| sink(value, Slot(file=name, paths=[["terms", key, index]], raw=True)) | |
| messages = terms.get("messages") | |
| if isinstance(messages, dict): | |
| for key, value in messages.items(): | |
| if isinstance(value, str) and value.strip(): | |
| sink(value, Slot(file=name, paths=[["terms", "messages", key]], raw=True)) | |
| for key in _SYSTEM_TYPE_LISTS: | |
| values = data.get(key) | |
| if isinstance(values, list): | |
| for index, value in enumerate(values): | |
| if isinstance(value, str) and value.strip(): | |
| sink(value, Slot(file=name, paths=[[key, index]], raw=True)) | |
| def _extract_database(name: str, data: list, fields: tuple[str, ...], sink) -> None: | |
| """Read the player-visible fields out of one database file. | |
| Index 0 of these arrays is null and entries can carry empty strings for | |
| fields the designer never filled in, so both are skipped: writing a | |
| translation into a blank description would put text on an item that is | |
| meant to show none. | |
| """ | |
| for entry_index, entry in enumerate(data): | |
| if not isinstance(entry, dict): | |
| continue | |
| for key in fields: | |
| value = entry.get(key) | |
| if isinstance(value, str) and value.strip(): | |
| sink( | |
| value, | |
| Slot(file=name, paths=[[entry_index, key]], raw=True), | |
| ) | |
| def _extract_file(name: str, data, sink) -> None: | |
| if name.startswith("Map") and isinstance(data, dict): | |
| # Map001.json ... events[] -> pages[] -> list[] | |
| events = data.get("events") | |
| if isinstance(events, list): | |
| for event_index, event in enumerate(events): | |
| if isinstance(event, dict): | |
| _walk_pages(event, ["events", event_index], name, sink) | |
| elif name == "CommonEvents.json" and isinstance(data, list): | |
| # Flat array of events, each with its own list[] (no pages). | |
| for event_index, event in enumerate(data): | |
| if isinstance(event, dict) and isinstance(event.get("list"), list): | |
| _walk_event_list(event["list"], [event_index, "list"], name, sink) | |
| elif name == "Troops.json" and isinstance(data, list): | |
| # Battle events, shaped like map events. | |
| for troop_index, troop in enumerate(data): | |
| if isinstance(troop, dict): | |
| _walk_pages(troop, [troop_index], name, sink) | |
| elif name == "System.json" and isinstance(data, dict): | |
| _extract_system(data, sink) | |
| elif name in _DATABASE_FIELDS and isinstance(data, list): | |
| _extract_database(name, data, _DATABASE_FIELDS[name], sink) | |
| def detect_data_root(root: Path) -> tuple[Path, str] | None: | |
| """Locate the data folder in an unpacked upload. | |
| MV keeps it at www/data, MZ at data, and people zip projects from | |
| inconsistent depths - so search rather than assume. Returns the folder and | |
| the engine label, or None when nothing looks like an RPG Maker project. | |
| """ | |
| candidates: list[tuple[Path, str]] = [] | |
| for path in [root, *root.rglob("*")]: | |
| if not path.is_dir(): | |
| continue | |
| if path.name == "data": | |
| engine = "mv" if path.parent.name == "www" else "mz" | |
| candidates.append((path, engine)) | |
| for data_dir, engine in sorted(candidates, key=lambda item: len(item[0].parts)): | |
| # A real data folder has the database files, not just a stray "data". | |
| names = {p.name for p in data_dir.glob("*.json")} | |
| if "System.json" in names or any(n.startswith("Map") for n in names): | |
| return data_dir, engine | |
| return None | |
| def extract(data_dir: Path) -> tuple[list[Unit], list[str]]: | |
| """Read every event file. Returns (units, skipped_files_with_errors).""" | |
| by_source: dict[str, Unit] = {} | |
| broken: list[str] = [] | |
| def sink(source: str, slot: Slot) -> None: | |
| if not source.strip(): | |
| return | |
| unit = by_source.get(source) | |
| if unit is None: | |
| unit = Unit(source=source) | |
| by_source[source] = unit | |
| unit.slots.append(slot) | |
| for json_path in sorted(data_dir.glob("*.json")): | |
| if json_path.name in _SKIP_FILES: | |
| continue | |
| try: | |
| data = json.loads(json_path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| broken.append(json_path.name) | |
| continue | |
| _extract_file(json_path.name, data, sink) | |
| return list(by_source.values()), broken | |