File size: 13,034 Bytes
6967bb5
 
 
 
 
6de14d3
6967bb5
 
 
4f1858c
6de14d3
6967bb5
6de14d3
 
 
6967bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f1858c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6de14d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6967bb5
 
 
 
 
 
 
6de14d3
 
 
 
 
6967bb5
 
 
 
6de14d3
6967bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f1858c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6de14d3
4f1858c
 
 
 
 
6de14d3
4f1858c
 
 
 
 
 
6de14d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f1858c
 
6967bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f1858c
 
 
6de14d3
 
 
6967bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
"""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"),
}


@dataclass
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


@dataclass
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)

    @property
    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