Reinject from fresh

This commit is contained in:
DazedAnon 2026-07-03 17:09:48 -05:00
parent c1ff514fbd
commit 10eb68503a
3 changed files with 152 additions and 8 deletions

View file

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

View file

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

View file

@ -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}")