Cnass commited on
Commit
4f1858c
·
verified ·
1 Parent(s): ae5b3b4

Upload 4 files

Browse files
app/rpgmaker/extract.py CHANGED
@@ -3,15 +3,15 @@
3
  Deliberately narrow. RPG Maker stores executable script, plugin bindings,
4
  asset filenames and engine identifiers in the same JSON arrays as dialogue,
5
  and translating any of those breaks the game rather than mistranslating it -
6
- so this reads exactly two things:
7
 
8
  401 / 405 Show Text / Show Scrolling Text - the dialogue itself
9
  102 / 402 Show Choices / When[choice] - the buttons under that dialogue
 
10
 
11
  Everything else is left alone, including the database files (item and skill
12
- names), System.json terms, the MZ speaker-name field on 101, name-change
13
- commands (320/324/325), plugin commands (356/357) and Script blocks
14
- (355/655).
15
 
16
  Consecutive 401 commands are one message box split across lines, not separate
17
  sentences, so a run of them is merged into a single unit and translated as one
@@ -34,6 +34,26 @@ CODE_WHEN_CHOICE = 402 # When [choice] (parameters[1] repeats the label)
34
  # Files that never contain event lists; skipped before parsing.
35
  _SKIP_FILES = {"Tilesets.json", "Animations.json", "MapInfos.json"}
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  @dataclass
39
  class Slot:
@@ -136,6 +156,39 @@ def _walk_pages(container: dict, base_path: list, file_name: str, sink) -> None:
136
  )
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def _extract_file(name: str, data, sink) -> None:
140
  if name.startswith("Map") and isinstance(data, dict):
141
  # Map001.json ... events[] -> pages[] -> list[]
@@ -157,6 +210,9 @@ def _extract_file(name: str, data, sink) -> None:
157
  if isinstance(troop, dict):
158
  _walk_pages(troop, [troop_index], name, sink)
159
 
 
 
 
160
 
161
  def detect_data_root(root: Path) -> tuple[Path, str] | None:
162
  """Locate the data folder in an unpacked upload.
 
3
  Deliberately narrow. RPG Maker stores executable script, plugin bindings,
4
  asset filenames and engine identifiers in the same JSON arrays as dialogue,
5
  and translating any of those breaks the game rather than mistranslating it -
6
+ so this reads exactly three things:
7
 
8
  401 / 405 Show Text / Show Scrolling Text - the dialogue itself
9
  102 / 402 Show Choices / When[choice] - the buttons under that dialogue
10
+ System.json terms - the menus wrapped around both
11
 
12
  Everything else is left alone, including the database files (item and skill
13
+ names), the MZ speaker-name field on 101, name-change commands (320/324/325),
14
+ plugin commands (356/357) and Script blocks (355/655).
 
15
 
16
  Consecutive 401 commands are one message box split across lines, not separate
17
  sentences, so a run of them is merged into a single unit and translated as one
 
34
  # Files that never contain event lists; skipped before parsing.
35
  _SKIP_FILES = {"Tilesets.json", "Animations.json", "MapInfos.json"}
36
 
37
+ # System.json keys holding player-visible interface text.
38
+ #
39
+ # `terms` is the menu and options screen: `commands` is New Game / Continue /
40
+ # Save / Options, `messages` is the options labels (BGM Volume, Always Dash)
41
+ # and the battle log templates, `basic` and `params` are the stat labels.
42
+ # The type arrays are shown in the equip and status screens.
43
+ #
44
+ # Excluded on purpose: `gameTitle` (a proper name, like the character names
45
+ # this tool already leaves alone), `currencyUnit` (usually a one-letter symbol
46
+ # such as "G" that reads as noise to a translator), and `switches` /
47
+ # `variables`, which are developer-facing labels the player never sees.
48
+ _SYSTEM_TERM_LISTS = ("basic", "commands", "params")
49
+ _SYSTEM_TYPE_LISTS = (
50
+ "armorTypes",
51
+ "elements",
52
+ "equipTypes",
53
+ "skillTypes",
54
+ "weaponTypes",
55
+ )
56
+
57
 
58
  @dataclass
59
  class Slot:
 
156
  )
157
 
158
 
159
+ def _extract_system(data: dict, sink) -> None:
160
+ """Read the player-visible strings out of System.json.
161
+
162
+ Entries are frequently null or "" (RPG Maker pads these arrays to a fixed
163
+ length and index 0 is usually blank), so every value is checked before it
164
+ is emitted - writing a translation into a slot the engine expects to be
165
+ empty would put stray text in the menu.
166
+ """
167
+ name = "System.json"
168
+
169
+ terms = data.get("terms")
170
+ if isinstance(terms, dict):
171
+ for key in _SYSTEM_TERM_LISTS:
172
+ values = terms.get(key)
173
+ if isinstance(values, list):
174
+ for index, value in enumerate(values):
175
+ if isinstance(value, str) and value.strip():
176
+ sink(value, Slot(file=name, paths=[["terms", key, index]]))
177
+
178
+ messages = terms.get("messages")
179
+ if isinstance(messages, dict):
180
+ for key, value in messages.items():
181
+ if isinstance(value, str) and value.strip():
182
+ sink(value, Slot(file=name, paths=[["terms", "messages", key]]))
183
+
184
+ for key in _SYSTEM_TYPE_LISTS:
185
+ values = data.get(key)
186
+ if isinstance(values, list):
187
+ for index, value in enumerate(values):
188
+ if isinstance(value, str) and value.strip():
189
+ sink(value, Slot(file=name, paths=[[key, index]]))
190
+
191
+
192
  def _extract_file(name: str, data, sink) -> None:
193
  if name.startswith("Map") and isinstance(data, dict):
194
  # Map001.json ... events[] -> pages[] -> list[]
 
210
  if isinstance(troop, dict):
211
  _walk_pages(troop, [troop_index], name, sink)
212
 
213
+ elif name == "System.json" and isinstance(data, dict):
214
+ _extract_system(data, sink)
215
+
216
 
217
  def detect_data_root(root: Path) -> tuple[Path, str] | None:
218
  """Locate the data folder in an unpacked upload.
app/rpgmaker/inject.py CHANGED
@@ -6,29 +6,16 @@ Two invariants matter more than translation quality here:
6
  removing entries in an event list shifts every index after it, which breaks
7
  conditional branches and jump targets. So a translation is re-wrapped into
8
  exactly the original line count, never more, never fewer.
9
- 2. A string that failed validation is not written at all. Leaving the English
10
- line is a cosmetic problem; writing a line whose \\C[2] got mangled into
11
- "C[2]" is a rendering bug in the shipped game.
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import json
17
- from collections import Counter
18
  from pathlib import Path
19
 
20
- from ..translation import control_codes
21
-
22
-
23
- def codes_match(source: str, translated: str) -> bool:
24
- """True when the translation carries exactly the source's control codes.
25
-
26
- Order is not compared - Turkish reorders clauses, so "\\N[1] said X" may
27
- legitimately become "X, dedi \\N[1]" - but the multiset must be identical:
28
- every code present, none invented, none duplicated.
29
- """
30
- return Counter(control_codes(source)) == Counter(control_codes(translated))
31
-
32
 
33
  def rewrap(text: str, line_count: int) -> list[str]:
34
  """Fit `text` into exactly `line_count` lines.
 
6
  removing entries in an event list shifts every index after it, which breaks
7
  conditional branches and jump targets. So a translation is re-wrapped into
8
  exactly the original line count, never more, never fewer.
9
+ 2. A string is written exactly as the model returned it. Hy-MT2 carries
10
+ control codes through on its own, so there is no post-hoc validation step
11
+ holding translations back.
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import json
 
17
  from pathlib import Path
18
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  def rewrap(text: str, line_count: int) -> list[str]:
21
  """Fit `text` into exactly `line_count` lines.
app/rpgmaker/jobs.py CHANGED
@@ -18,12 +18,10 @@ import time
18
  from .. import config, glossary as glossary_store, llama_client
19
  from ..translation import build_messages, is_translatable
20
  from . import store
21
- from .inject import codes_match
22
 
23
  # How often progress reaches disk. Small enough that a crash loses seconds of
24
  # GPU time, large enough not to rewrite a growing JSON file per string.
25
  _FLUSH_EVERY = 25
26
- _MAX_REVIEW_SAMPLES = 50
27
 
28
  _task: asyncio.Task | None = None
29
  _cancel = False
@@ -38,10 +36,17 @@ def is_running() -> bool:
38
  return active_project_id() is not None
39
 
40
 
41
- async def _translate_one(source: str, target_lang: str) -> tuple[str, bool]:
42
- """Translate one unit. Returns (text, needs_review)."""
 
 
 
 
 
 
 
43
  if not is_translatable(source):
44
- return source, False
45
 
46
  messages = build_messages(
47
  source,
@@ -54,13 +59,7 @@ async def _translate_one(source: str, target_lang: str) -> tuple[str, bool]:
54
  None,
55
  )
56
  translated = (await llama_client.chat_complete(messages)).strip()
57
-
58
- # A translation that lost or mangled a control code is worse than no
59
- # translation: keep the source so the game still renders correctly, and
60
- # surface it for review.
61
- if not codes_match(source, translated):
62
- return source, True
63
- return translated, False
64
 
65
 
66
  async def _run(project_id: str, target_lang: str) -> None:
@@ -87,8 +86,7 @@ async def _run(project_id: str, target_lang: str) -> None:
87
  if slot.file in done_slots:
88
  done_slots[slot.file] += 1
89
 
90
- review_samples: list[dict] = list(meta.get("review_samples", []))
91
- review_count = int(meta.get("review_count", 0))
92
  queue: asyncio.Queue = asyncio.Queue()
93
  for unit in pending:
94
  queue.put_nowait(unit)
@@ -101,33 +99,31 @@ async def _run(project_id: str, target_lang: str) -> None:
101
  for name, count in done_slots.items():
102
  meta["files"][name]["done"] = count
103
  meta["translated_units"] = len(translations)
104
- meta["review_count"] = review_count
105
- meta["review_samples"] = review_samples
106
  store.save_meta(project_id, meta)
107
 
108
  async def worker() -> None:
109
- nonlocal processed_since_flush, review_count
110
  while not _cancel:
111
  try:
112
  unit = queue.get_nowait()
113
  except asyncio.QueueEmpty:
114
  return
115
  try:
116
- text, needs_review = await _translate_one(unit.source, target_lang)
117
  except Exception: # noqa: BLE001 - one bad string must not kill the run
118
- text, needs_review = unit.source, True
 
 
 
 
 
119
 
120
  async with lock:
121
  translations[unit.source] = text
122
  for slot in unit.slots:
123
  if slot.file in done_slots:
124
  done_slots[slot.file] += 1
125
- if needs_review:
126
- review_count += 1
127
- if len(review_samples) < _MAX_REVIEW_SAMPLES:
128
- review_samples.append(
129
- {"source": unit.source, "translation": text}
130
- )
131
  processed_since_flush += 1
132
  if processed_since_flush >= _FLUSH_EVERY:
133
  processed_since_flush = 0
 
18
  from .. import config, glossary as glossary_store, llama_client
19
  from ..translation import build_messages, is_translatable
20
  from . import store
 
21
 
22
  # How often progress reaches disk. Small enough that a crash loses seconds of
23
  # GPU time, large enough not to rewrite a growing JSON file per string.
24
  _FLUSH_EVERY = 25
 
25
 
26
  _task: asyncio.Task | None = None
27
  _cancel = False
 
36
  return active_project_id() is not None
37
 
38
 
39
+ async def _translate_one(source: str, target_lang: str) -> str:
40
+ """Translate one unit.
41
+
42
+ The result is used as-is. An earlier version compared the source's control
43
+ codes against the translation's and held back any string where they
44
+ differed, but Hy-MT2 preserves \\C[2], \\N[1] and friends on its own, so
45
+ that gate mostly withheld good translations. The only thing still refused
46
+ is an empty response, which would blank a line in-game.
47
+ """
48
  if not is_translatable(source):
49
+ return source
50
 
51
  messages = build_messages(
52
  source,
 
59
  None,
60
  )
61
  translated = (await llama_client.chat_complete(messages)).strip()
62
+ return translated or source
 
 
 
 
 
 
63
 
64
 
65
  async def _run(project_id: str, target_lang: str) -> None:
 
86
  if slot.file in done_slots:
87
  done_slots[slot.file] += 1
88
 
89
+ failed = int(meta.get("failed_units", 0))
 
90
  queue: asyncio.Queue = asyncio.Queue()
91
  for unit in pending:
92
  queue.put_nowait(unit)
 
99
  for name, count in done_slots.items():
100
  meta["files"][name]["done"] = count
101
  meta["translated_units"] = len(translations)
102
+ meta["failed_units"] = failed
 
103
  store.save_meta(project_id, meta)
104
 
105
  async def worker() -> None:
106
+ nonlocal processed_since_flush, failed
107
  while not _cancel:
108
  try:
109
  unit = queue.get_nowait()
110
  except asyncio.QueueEmpty:
111
  return
112
  try:
113
+ text = await _translate_one(unit.source, target_lang)
114
  except Exception: # noqa: BLE001 - one bad string must not kill the run
115
+ # Backend error, not a bad translation: keep the source line so
116
+ # the game still reads correctly and count it so the total is
117
+ # honest about what the model actually produced.
118
+ text = unit.source
119
+ async with lock:
120
+ failed += 1
121
 
122
  async with lock:
123
  translations[unit.source] = text
124
  for slot in unit.slots:
125
  if slot.file in done_slots:
126
  done_slots[slot.file] += 1
 
 
 
 
 
 
127
  processed_since_flush += 1
128
  if processed_since_flush >= _FLUSH_EVERY:
129
  processed_since_flush = 0
app/rpgmaker/store.py CHANGED
@@ -163,8 +163,7 @@ def create(zip_bytes: bytes, name: str) -> dict:
163
  "total_units": len(units),
164
  "total_slots": sum(f["total"] for f in files.values()),
165
  "translated_units": 0,
166
- "review_count": 0,
167
- "review_samples": [],
168
  "unreadable_files": broken,
169
  "files": files,
170
  "error": None,
 
163
  "total_units": len(units),
164
  "total_slots": sum(f["total"] for f in files.values()),
165
  "translated_units": 0,
166
+ "failed_units": 0,
 
167
  "unreadable_files": broken,
168
  "files": files,
169
  "error": None,