Reinject from fresh
This commit is contained in:
parent
c1ff514fbd
commit
10eb68503a
3 changed files with 152 additions and 8 deletions
|
|
@ -324,11 +324,11 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
|
||||||
|
|
||||||
| Step | Action |
|
| Step | Action |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation; `names.json` is translated later (Step 3) and the Step 2 bulk run skips it. **Set up git tracking** by copying the `gameupdate/` folder (updater scripts, `patch.sh`/`patch.ps1`, and a `.gitignore` that excludes the original game files) into the game root, so the translation project can be tracked with git and later shipped as a git-based patch players apply themselves. Finally, **Format extracted JSON** (dazedformat) normalises `wolf_json/` and `files/` to the same layout the translator writes back (`json.dump`, indent 4) — run it once and commit it as your baseline so later injects produce clean, line-level git diffs instead of reformatting whole files. |
|
| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation; `names.json` is translated later (Step 3) and the Step 2 bulk run skips it. Extraction also snapshots the pristine (untranslated) binaries into `wolf_json/originals/` so Step 4 can inject idempotently. **Set up git tracking** by copying the `gameupdate/` folder (updater scripts, `patch.sh`/`patch.ps1`, and a `.gitignore` that excludes the original game files) into the game root, so the translation project can be tracked with git and later shipped as a git-based patch players apply themselves. Finally, **Format extracted JSON** (dazedformat) normalises `wolf_json/` and `files/` to the same layout the translator writes back (`json.dump`, indent 4) — run it once and commit it as your baseline so later injects produce clean, line-level git diffs instead of reformatting whole files. |
|
||||||
| **1 Glossary** | Build `vocab.txt` before translating: copy the WOLF-tailored prompt into Cursor/Copilot with the extracted `files/` JSON, let it discover character names, speech registers, and lore terms, then paste the result into the in-tab editor and save (the shared `vocab.txt` is used by every translation batch to keep names and voice consistent). Item/skill/enemy value names (`names.json`) are handled later in Step 3, not here. |
|
| **1 Glossary** | Build `vocab.txt` before translating: copy the WOLF-tailored prompt into Cursor/Copilot with the extracted `files/` JSON, let it discover character names, speech registers, and lore terms, then paste the result into the in-tab editor and save (the shared `vocab.txt` is used by every translation batch to keep names and voice consistent). Item/skill/enemy value names (`names.json`) are handled later in Step 3, not here. |
|
||||||
| **2 Translate** | Pick the **Translation mode** (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only), then run the `Wolf RPG (WolfDawn)` module over `files/`. Under **Speaker handling**, toggle which speaker formats are reshaped: WolfDawn tags who speaks on each line, and for lines where the name is baked into the first line (a nameplate) the translator reshapes them into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restores WOLF's native `Speaker⏎line` layout on inject. Turn a format off if a game's first lines are not really speaker names. Under **Text wrap width**, set whether/where translated dialogue re-wraps to fit WOLF's message box (same `dazedwrap` engine the RPG Maker workflow uses, saved to `.env` as `wolfWrap` / `wolfWidth`); only the dialogue body is wrapped - a speaker's nameplate line is always kept separate so the `⏎` after a name is never folded into the text. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
|
| **2 Translate** | Pick the **Translation mode** (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only), then run the `Wolf RPG (WolfDawn)` module over `files/`. Under **Speaker handling**, toggle which speaker formats are reshaped: WolfDawn tags who speaks on each line, and for lines where the name is baked into the first line (a nameplate) the translator reshapes them into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restores WOLF's native `Speaker⏎line` layout on inject. Turn a format off if a game's first lines are not really speaker names. Under **Text wrap width**, set whether/where translated dialogue re-wraps to fit WOLF's message box (same `dazedwrap` engine the RPG Maker workflow uses, saved to `.env` as `wolfWrap` / `wolfWidth`); only the dialogue body is wrapped - a speaker's nameplate line is always kept separate so the `⏎` after a name is never folded into the text. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
|
||||||
| **3 Names** | Curate `names.json` (item/skill/enemy/variable value names). **Translating all of them breaks the game** - many WOLF value names double as logic keys (compared by value, used as variable/file/event names), so by default none are translated. WolfDawn tags each name with its WOLF database **category** (the `note` field), so instead of judging thousands of names you pick which *categories* are safe. Copy the **names-safety prompt** into a repo-aware AI (Cursor/Copilot with `files/` open); it groups names by category, checks how each is used, and returns a JSON list of the safe categories in a code block. Paste that list and click **Apply** to tick the matching rows (or tick them by hand - each row shows the category, its name count, and an example), then **Save** to `data/wolf_safe_notes.json`. Finally **Translate safe names** - the WolfDawn module only translates entries whose category you approved and leaves every other name identical to the source; `names.json`'s structure is unchanged. Conservative by design: nothing changes until you opt categories in. |
|
| **3 Names** | Curate `names.json` (item/skill/enemy/variable value names). **Translating all of them breaks the game** - many WOLF value names double as logic keys (compared by value, used as variable/file/event names), so by default none are translated. WolfDawn tags each name with its WOLF database **category** (the `note` field), so instead of judging thousands of names you pick which *categories* are safe. Copy the **names-safety prompt** into a repo-aware AI (Cursor/Copilot with `files/` open); it groups names by category, checks how each is used, and returns a JSON list of the safe categories in a code block. Paste that list and click **Apply** to tick the matching rows (or tick them by hand - each row shows the category, its name count, and an example), then **Save** to `data/wolf_safe_notes.json`. Finally **Translate safe names** - the WolfDawn module only translates entries whose category you approved and leaves every other name identical to the source; `names.json`'s structure is unchanged. Conservative by design: nothing changes until you opt categories in. |
|
||||||
| **4 Inject** | Write the translations back into the game's `Data/` binaries **and** refresh the git-tracked `wolf_json/` with the translated JSON, so the game project's git diff shows both the JSON source and the injected binaries. Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed, and use **Check name consistency** to catch names translated differently across files. **Quick inject** lists the files you've translated so far (present in `translated/`) so you can tick just a few, inject them straight into `Data/`, and review the git diff / test in-game - ideal for iterating. **Inject all translations** writes every document, including the safe name values from Step 3, across `Data/`. |
|
| **4 Inject** | Write the translations back into the game's `Data/` binaries **and** refresh the git-tracked `wolf_json/` with the translated JSON, so the game project's git diff shows both the JSON source and the injected binaries. Injection always reads from a **pristine snapshot** of the original binaries (`wolf_json/originals/`, captured during Step 0 extraction and rebuilt from the `.wolf`/`.wolf.bak` archives if missing) and writes the result into the live `Data/` file. This keeps injection **idempotent** - WolfDawn locates each Japanese source string in the original, so re-injecting after fixing a few lines actually updates the binary instead of silently no-op'ing (the old in-place inject broke once a file had already been translated). Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed, and use **Check name consistency** to catch names translated differently across files. **Quick inject** lists the files you've translated so far (present in `translated/`) so you can tick just a few, inject them straight into `Data/`, and review the git diff / test in-game - ideal for iterating. **Inject all translations** writes every document, including the safe name values from Step 3, across `Data/`. |
|
||||||
| **5 Package** | Either run from the loose `Data/` folder (backs up `Data.wolf` → `Data.wolf.bak`), or **Repack** a fresh `Data.wolf`, inheriting the original archive's encryption. |
|
| **5 Package** | Either run from the loose `Data/` folder (backs up `Data.wolf` → `Data.wolf.bak`), or **Repack** a fresh `Data.wolf`, inheriting the original archive's encryption. |
|
||||||
| **6 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. |
|
| **6 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. |
|
||||||
|
|
||||||
|
|
|
||||||
3
gameupdate/.gitignore
vendored
3
gameupdate/.gitignore
vendored
|
|
@ -31,6 +31,9 @@
|
||||||
!(SRPG_Unpacker Patcher).bat
|
!(SRPG_Unpacker Patcher).bat
|
||||||
!Data/BasicData/CommonEvent.dat
|
!Data/BasicData/CommonEvent.dat
|
||||||
|
|
||||||
|
# WolfDawn pristine originals kept locally for re-injection (never shipped)
|
||||||
|
wolf_json/originals/
|
||||||
|
|
||||||
# Ignore
|
# Ignore
|
||||||
previous_patch_sha.txt
|
previous_patch_sha.txt
|
||||||
kabe3_save.dat
|
kabe3_save.dat
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ project's git history tracks both the JSON source and the updated binaries.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal
|
from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal
|
||||||
|
|
@ -82,6 +84,23 @@ MANIFEST_NAME = "manifest.json"
|
||||||
NAMES_JSON = "names.json"
|
NAMES_JSON = "names.json"
|
||||||
WORK_DIR_NAME = "wolf_json"
|
WORK_DIR_NAME = "wolf_json"
|
||||||
|
|
||||||
|
# WolfDawn's inject commands print e.g. "applied 91 translation(s) (0 untranslated,
|
||||||
|
# 0 drifted); wrote <path>". We parse the counts to distinguish a real inject from a
|
||||||
|
# silent no-op (exit 0 but 0 applied because the base was already translated).
|
||||||
|
_INJECT_COUNTS_RE = re.compile(
|
||||||
|
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_inject_counts(stdout: str):
|
||||||
|
"""Return (applied, drifted) ints from wolf inject output, or (None, None)."""
|
||||||
|
if not stdout:
|
||||||
|
return None, None
|
||||||
|
m = _INJECT_COUNTS_RE.search(stdout)
|
||||||
|
if not m:
|
||||||
|
return None, None
|
||||||
|
return int(m.group(1)), int(m.group(2))
|
||||||
|
|
||||||
# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted
|
# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted
|
||||||
# JSON (source/text pairs staged in files/). Produces the same two-section format
|
# JSON (source/text pairs staged in files/). Produces the same two-section format
|
||||||
# the shared vocab.txt expects, so the AI output pastes straight into the editor.
|
# the shared vocab.txt expects, so the AI output pastes straight into the editor.
|
||||||
|
|
@ -286,6 +305,93 @@ class WolfWorkflowTab(QWidget):
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ─────────────────────────── pristine originals ──────────────────────────
|
||||||
|
# WolfDawn injects by locating each Japanese `source` string inside the base
|
||||||
|
# binary and replacing it. That only works against the *original* (untranslated)
|
||||||
|
# binary: once a file has been injected it holds English, so re-injecting can no
|
||||||
|
# longer find the Japanese sources and every line is reported as "drift" (a
|
||||||
|
# silent no-op). To keep injection idempotent and re-runnable, we always inject
|
||||||
|
# from a pristine snapshot of the binaries into the live Data/ folder.
|
||||||
|
|
||||||
|
def _originals_dir(self) -> Path:
|
||||||
|
return self._work_dir() / "originals"
|
||||||
|
|
||||||
|
def _orig_base_for(self, entry: dict, data_dir: Path) -> Path:
|
||||||
|
"""Pristine-snapshot path mirroring an entry's base under originals/."""
|
||||||
|
base = Path(entry["base"])
|
||||||
|
try:
|
||||||
|
rel = base.relative_to(data_dir)
|
||||||
|
except ValueError:
|
||||||
|
rel = Path(base.name)
|
||||||
|
return self._originals_dir() / rel
|
||||||
|
|
||||||
|
def _snapshot_originals(self, entries: list[dict], data_dir: Path, log) -> None:
|
||||||
|
"""Copy the pristine base binaries into originals/ (only when missing, so a
|
||||||
|
good snapshot is never clobbered by a later extract of injected data)."""
|
||||||
|
for entry in entries:
|
||||||
|
if entry.get("kind") == "names":
|
||||||
|
continue
|
||||||
|
src = Path(entry["base"])
|
||||||
|
dst = self._orig_base_for(entry, data_dir)
|
||||||
|
if dst.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if src.is_dir():
|
||||||
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
|
elif src.is_file():
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f" ⚠ could not snapshot original {src.name}: {exc}")
|
||||||
|
|
||||||
|
def _ensure_originals(self, manifest: dict, log) -> None:
|
||||||
|
"""Make sure a pristine snapshot exists; if entries are missing it and the
|
||||||
|
game still has its .wolf archives (packaging renames them to .wolf.bak),
|
||||||
|
rebuild the snapshot by unpacking those archives into originals/."""
|
||||||
|
from util import wolfdawn
|
||||||
|
|
||||||
|
data_dir = Path(manifest["data_dir"])
|
||||||
|
entries = manifest.get("entries", [])
|
||||||
|
missing = [
|
||||||
|
e for e in entries
|
||||||
|
if e.get("kind") != "names" and not self._orig_base_for(e, data_dir).exists()
|
||||||
|
]
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
root = Path(self._game_root)
|
||||||
|
# Only the binary archives (MapData / BasicData) are needed for inject.
|
||||||
|
archives: list[Path] = []
|
||||||
|
for base in (root, data_dir):
|
||||||
|
if base.is_dir():
|
||||||
|
for pat in ("*.wolf", "*.wolf.bak"):
|
||||||
|
archives.extend(base.glob(pat))
|
||||||
|
wanted = [a for a in archives if a.name.lower().startswith(("mapdata", "basicdata"))]
|
||||||
|
if not wanted:
|
||||||
|
log(
|
||||||
|
" ⚠ No pristine originals and no .wolf archives to rebuild them from. "
|
||||||
|
"Re-run Step 0 (Unpack + Extract) on the untranslated game so injection "
|
||||||
|
"has an original to work from."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
originals = self._originals_dir()
|
||||||
|
originals.mkdir(parents=True, exist_ok=True)
|
||||||
|
log("Rebuilding pristine originals from the game's .wolf archives …")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
inputs: list[str] = []
|
||||||
|
for arc in wanted:
|
||||||
|
# unpack-all only accepts a .wolf extension; present .bak copies as .wolf.
|
||||||
|
if arc.suffix == ".bak":
|
||||||
|
staged = Path(tmp) / arc.with_suffix("").name # X.wolf.bak -> X.wolf
|
||||||
|
shutil.copy2(arc, staged)
|
||||||
|
inputs.append(str(staged))
|
||||||
|
else:
|
||||||
|
inputs.append(str(arc))
|
||||||
|
res = wolfdawn.unpack_all(inputs, str(originals), log_fn=log)
|
||||||
|
if not res.ok:
|
||||||
|
log(f" ⚠ could not rebuild originals (unpack exit {res.returncode}).")
|
||||||
|
|
||||||
# ───────────────────────────────── UI setup ──────────────────────────────
|
# ───────────────────────────────── UI setup ──────────────────────────────
|
||||||
|
|
||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
|
|
@ -722,6 +828,11 @@ class WolfWorkflowTab(QWidget):
|
||||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot the pristine (untranslated) binaries now, while Data/ is still
|
||||||
|
# the extracted source, so later injects always have an original to read
|
||||||
|
# from and re-injecting a file actually updates the binary.
|
||||||
|
self._snapshot_originals(manifest_entries, Path(data_dir), log)
|
||||||
|
|
||||||
# Import all extracted JSON (except the manifest) into files/.
|
# Import all extracted JSON (except the manifest) into files/.
|
||||||
files_dir = Path("files")
|
files_dir = Path("files")
|
||||||
files_dir.mkdir(exist_ok=True)
|
files_dir.mkdir(exist_ok=True)
|
||||||
|
|
@ -1524,12 +1635,17 @@ class WolfWorkflowTab(QWidget):
|
||||||
def task(log):
|
def task(log):
|
||||||
from util import wolfdawn
|
from util import wolfdawn
|
||||||
|
|
||||||
|
# Guarantee a pristine snapshot to inject from (rebuild from archives if
|
||||||
|
# an older extraction predates snapshotting).
|
||||||
|
self._ensure_originals(manifest, log)
|
||||||
|
|
||||||
entries = manifest["entries"]
|
entries = manifest["entries"]
|
||||||
data_dir = manifest["data_dir"]
|
data_dir = manifest["data_dir"]
|
||||||
|
data_dir_path = Path(data_dir)
|
||||||
applied = 0
|
applied = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
# Non-name documents: inject each back onto its base binary.
|
# Non-name documents: inject each pristine original onto its live binary.
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if entry["kind"] == "names":
|
if entry["kind"] == "names":
|
||||||
continue
|
continue
|
||||||
|
|
@ -1539,16 +1655,31 @@ class WolfWorkflowTab(QWidget):
|
||||||
if src is None:
|
if src is None:
|
||||||
log(f" ⚠ no JSON for {entry['json']} — skipped")
|
log(f" ⚠ no JSON for {entry['json']} — skipped")
|
||||||
continue
|
continue
|
||||||
base = entry["base"]
|
out = entry["base"] # live game binary (or txt-dir) we write into
|
||||||
out = base # in-place (txt-dir writes each file under this dir)
|
orig = self._orig_base_for(entry, data_dir_path)
|
||||||
log(f"Injecting {entry['json']} → {Path(base).name} …")
|
base = str(orig) if orig.exists() else out
|
||||||
|
if not orig.exists():
|
||||||
|
log(
|
||||||
|
f" ⚠ no pristine original for {entry['json']}; injecting against the "
|
||||||
|
"live binary, which fails if it was already translated."
|
||||||
|
)
|
||||||
|
log(f"Injecting {entry['json']} → {Path(out).name} …")
|
||||||
res = wolfdawn.strings_inject(
|
res = wolfdawn.strings_inject(
|
||||||
str(src), base, out,
|
str(src), base, out,
|
||||||
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
|
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
|
||||||
)
|
)
|
||||||
if res.ok:
|
a, d = _parse_inject_counts(res.stdout)
|
||||||
|
if res.ok and not (a == 0 and (d or 0) > 0):
|
||||||
applied += 1
|
applied += 1
|
||||||
_sync_json(entry["json"], src, log)
|
_sync_json(entry["json"], src, log)
|
||||||
|
elif res.ok:
|
||||||
|
# Exit 0 but nothing applied and lines drifted = stale/injected base.
|
||||||
|
failed += 1
|
||||||
|
log(
|
||||||
|
f" ⚠ {entry['json']}: 0 applied, {d} skipped as drift — the base "
|
||||||
|
"looks already translated. Restore the pristine original (re-run "
|
||||||
|
"Step 0 Unpack on the untranslated game) and inject again."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
|
log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
|
||||||
|
|
@ -1561,14 +1692,24 @@ class WolfWorkflowTab(QWidget):
|
||||||
if names_entry and (only_json is None or names_entry["json"] in only_json):
|
if names_entry and (only_json is None or names_entry["json"] in only_json):
|
||||||
src = self._translated_or_source(names_entry["json"])
|
src = self._translated_or_source(names_entry["json"])
|
||||||
if src is not None:
|
if src is not None:
|
||||||
|
# Runs in place on the live Data/, which the strings-inject pass
|
||||||
|
# above just regenerated from the pristine originals (so name
|
||||||
|
# values are still Japanese and match names.json's sources).
|
||||||
log("Applying curated name values across Data/ …")
|
log("Applying curated name values across Data/ …")
|
||||||
res = wolfdawn.names_inject(
|
res = wolfdawn.names_inject(
|
||||||
str(src), data_dir,
|
str(src), data_dir,
|
||||||
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
|
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
|
||||||
)
|
)
|
||||||
if res.ok:
|
a, d = _parse_inject_counts(res.stdout)
|
||||||
|
if res.ok and not (a == 0 and (d or 0) > 0):
|
||||||
applied += 1
|
applied += 1
|
||||||
_sync_json(names_entry["json"], src, log)
|
_sync_json(names_entry["json"], src, log)
|
||||||
|
elif res.ok:
|
||||||
|
failed += 1
|
||||||
|
log(
|
||||||
|
f" ⚠ names: 0 applied, {d} skipped as drift — run a full inject "
|
||||||
|
"so the binaries are reset from the originals first."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
log(f" ⚠ names-inject exit {res.returncode}")
|
log(f" ⚠ names-inject exit {res.returncode}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue