diff --git a/README.md b/README.md index 68092f8..225f014 100644 --- a/README.md +++ b/README.md @@ -1,455 +1,456 @@ -# DazedMTLTool - -An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyrano, Wolf RPG, Kirikiri, and other game engines from Japanese to English using GPT, Gemini, [Mistral](https://docs.mistral.ai/api), or other compatible AI models. - -## Credits - -- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. A copy is bundled offline in `util/ace/offline/`; newer builds are downloaded when online. -- **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting. -- **Len** — [Forge](https://gitgud.io/zero64801/forge-mvmz) MV/MZ playtest plugin (`util/forge/`), Mistral API support (provider integration and adaptive rate limiting), and batch translation mode (Anthropic Message Batches API). - -## Table of Contents - -- [Supported Engines](#supported-engines) -- [Requirements](#requirements) -- [Installing Python](#installing-python) -- [Quick Start](#quick-start) -- [Using the GUI](#using-the-gui) -- [Vocab & Prompt](#vocab--prompt) -- [Tips](#tips) -- [Mistral API (free tier)](#mistral-api-free-tier) -- [Batch Translation (Anthropic, 50% off)](#batch-translation-anthropic-50-off) -- [Folder Structure](#folder-structure) -- [Finding Untranslated Text (Snipping Tool OCR)](#finding-untranslated-text-snipping-tool-ocr) -- [RPG Maker Translation Workflow](#rpg-maker-translation-workflow) -- [Wolf RPG (WolfDawn) Translation Workflow](#wolf-rpg-wolfdawn-translation-workflow) -- [Using Copilot & VSCode](#using-copilot--vscode) -- [Version Control with Git](#version-control-with-git) -- [Troubleshooting](#troubleshooting) - -## Supported Engines - -- RPG Maker (MV, MZ, Ace, and more) -- Wolf RPG Editor -- Ren'Py -- TyranoBuilder / TyranoScript -- Kirikiri -- NScripter -- CSV / Text files - ---- - -## Requirements - -- **Python 3.12 – 3.14** — See [Installing Python](#installing-python) below if you don't have it yet. -- **An AI API Key** — You'll need an API key from [OpenAI](https://platform.openai.com/settings/organization/api-keys), [Google Gemini](https://aistudio.google.com/apikey), [Mistral](https://docs.mistral.ai/api) (free tier available — no credit card), or a compatible provider. - ---- - -## Installing Python - -If you already have Python 3.12–3.14 installed and working, skip to [Quick Start](#quick-start). - -### Step 1 — Download - -Go to [python.org/downloads](https://www.python.org/downloads/) and download **Python 3.13** (or any version from 3.12 to 3.14). - -### Step 2 — Install (Important!) - -When the installer opens, **check both boxes at the bottom before clicking Install**: - -- ✅ **"Add python.exe to PATH"** — This is the most important step. Without it, your system won't be able to find Python. -- ✅ **"Use admin privileges when installing pip"** - -Then click **Install Now**. The default settings are fine for everything else. - -### Step 3 — Verify - -Open a **new** terminal window (don't reuse an old one — it won't see the new PATH) and run: - -``` -python -V -``` - -You should see something like `Python 3.13.x`. Then check pip: - -``` -pip -V -``` - -You should see something like `pip 24.x.x from ...`. - -### If `python` or `pip` is not recognized - -This means Python wasn't added to your PATH. You have two options: - -**Option A — Reinstall (easiest)** -1. Open the Python installer again (or redownload it). -2. Select **Modify**. -3. Click **Next** on the first screen. -4. On the Advanced Options screen, check **"Add Python to environment variables"**. -5. Click **Install**. Then open a **new** terminal and try again. - -**Option B — Add to PATH manually** -1. Press `Win+R`, type `sysdm.cpl`, press Enter. -2. Go to the **Advanced** tab → **Environment Variables**. -3. Under **System variables**, find `Path` and click **Edit**. -4. Click **New** and add the path where Python was installed. Typically: - - `C:\Users\YourName\AppData\Local\Programs\Python\Python313\` - - `C:\Users\YourName\AppData\Local\Programs\Python\Python313\Scripts\` -5. Click **OK** on all dialogs, then open a **new** terminal and try `python -V` again. - -> **Tip:** If `python` works but `pip` doesn't, try `python -m pip -V` instead. If that works, you can use `python -m pip install` anywhere you'd normally use `pip install`. - ---- - -## Quick Start - -### 1. Download the Tool - -1. Click the green **Code** button at the top of this page and select **Download ZIP**. -2. Extract the ZIP to a folder of your choice (e.g., `C:\DazedMTLTool`). - -### 2. Set Up Your API Key - -1. Inside the tool folder, find `.env.example` and make a copy of it named `.env`. -2. Open `.env` in any text editor (Notepad works fine) and fill in your API details: - - `api` — Your API base URL (for Nvidia use `https://integrate.api.nvidia.com/v1/`, for Mistral use `https://api.mistral.ai/v1/`). - - `key` — Your API key. - - `organization` — Your organization key (make something up if using a self-hosted or non-OpenAI API). - - `API_PROVIDER` — Use `openai` for OpenAI-compatible providers (including Nvidia), `gemini` for Gemini, or `mistral` for Mistral (only needed when `api` is left empty). - - `model` — For Nvidia/custom OpenAI-compatible endpoints, enter the model name manually (example: `deepseek-ai/deepseek-v4-pro`). -3. The rest of the settings (wordwrap, batch size, etc.) can be left as defaults for now. You can tweak them later. - -> **Trying Mistral?** Set `API_PROVIDER=mistral`, add your key, use `mistral-medium-3.5`. Free tier details in [Mistral API (free tier)](#mistral-api-free-tier). - -### 3. Launch the GUI - -**Windows:** Double-click `START.bat`. It will create a virtual environment, install dependencies, and open the GUI. - -**Linux/macOS:** Run `./START.sh`, or double-click `DazedMTLTool.desktop` (choose **Allow Launching** when your file manager asks). From then on, either method works. - -That's it! Use the same launcher each time you want to open the tool. - ---- - -## Using the GUI - -The GUI has several tabs that handle different parts of the translation process: - -### Config Tab -This is where you configure your API settings, wordwrap widths, and other options. Most of these mirror what's in the `.env` file, but you can adjust them visually. - -### Translation Tab -The main tab for translating files. - -1. **Add files** — Place the game files you want to translate into the `files` folder (inside the tool directory). -2. **Select a module** — Pick the engine that matches your game (e.g., RPG Maker MV/MZ, Wolf RPG, Ren'Py, etc.). -3. **Click Translate** — The tool will process each file and output translated versions to the `translated` folder. -4. **Copy the results** — Move the translated files from `translated` back into your game's data folder. - -### Engine Config Tab -Engine-specific settings. RPG Maker has the most mature support — you can toggle exactly which event codes to translate (dialogue, choices, variables, plugin commands, etc.). The defaults cover ~95% of a game's text out of the box. See the [RPG Maker Translation Workflow](#rpg-maker-translation-workflow) section for a detailed step-by-step guide. - -Other engines (Wolf RPG, Ren'Py, Tyrano, etc.) have less granular support and may require more manual tweaking or post-editing depending on the game. - -### RPG Maker / Wolf / CSV Tabs -Specialized tabs with extra options for those specific engines. - ---- - -## Vocab & Prompt - -### data/vocab.txt -This file gives the AI context about your game — character names, genders, recurring terms, etc. The better your vocab file, the more consistent the translation. - -On first run, `data/vocab.txt` is created automatically from `data/vocab_base.txt` if it does not exist yet. Add entries like: - -```plaintext -# Game Characters -水無月 士乃 (Minazuki Shino) - Female -暗黒斎 (Dark Kokusai) - Male -フトシ (Futoshi) - Male -``` - -Format: Japanese name, English name in parentheses, then gender. - -> **Note:** A very large vocab file can increase API costs and potentially reduce quality. Focus on the most important characters and terms. - -### data/prompt.txt -This is the system prompt sent to the AI. A default `data/prompt.txt` is included and works well for most games. You generally don't need to edit it unless you want to customize the translation style. - ---- - -## Tips - -- **Check `log/translations.txt`** after a run to see what was translated. You can copy useful terms from it into `data/vocab.txt` for consistency in future runs. -- **Start small** — Translate a few files first to make sure the output looks good before doing the whole game. -- **Wordwrap** — If text overflows or looks awkward in-game, adjust the `width` setting in `.env` or the Config tab. `60` is a good default for most RPG Maker games. -- **Version control** — Using [Git](https://git-scm.com/) with the game folder is highly recommended. It lets you track every change the translation makes, compare with original files, and roll back if needed. - ---- - -## Mistral API (free tier) - -Mistral's [API is free for now](https://docs.mistral.ai/api) (no credit card), and the tool has proper support for it — not just a generic OpenAI-compatible URL. Mistral rate-limits pretty aggressively per model, so there's an adaptive limiter that reads the live headers and paces requests automatically. That lets you crank up `fileThreads` without the run dying to 429s. - -`mistral-medium-3.5` is the recommended model for translation. Avoid `mistral-medium-latest` — it still points at the older 3.1 release. - -Quick setup in `.env`: - -``` -API_PROVIDER=mistral -api="https://api.mistral.ai/v1/" -key="your-mistral-api-key" -model="mistral-medium-3.5" -``` - -When the API URL points at `api.mistral.ai`, requests are paced automatically. Mistral enforces a **per-minute request limit** and a **per-minute token limit**, both **per-model** — e.g. `mistral-medium` allows 25 req/min while `ministral-3b` allows 750/min. The limiter reads both limits from the live `x-ratelimit-*` response headers, spaces requests so it never overruns the per-minute budget, and honours `Retry-After` on 429s. Override the seeds with `mistralReqPerSec`, `mistralTokPerMin`, and `mistralTokenHeadroom` in `.env` if needed (rarely). - -> **Note:** Batch mode (50% off) is Anthropic/Claude only. Mistral runs live translation through the rate limiter above. - ---- - -## Batch Translation (Anthropic, 50% off) - -Batch mode — see [Credits](#credits). - -When using a Claude model, the CLI offers a third mode that translates through the -[Anthropic Message Batches API](https://platform.claude.com/docs/en/build-with-claude/batch-processing.md) -— every token (input, output, and prompt-cache reads/writes) is billed at **50% of the live price**. -Batches usually finish within an hour (24h worst case), so use it for large jobs where you don't -need results immediately. - -``` -python start.py - -> 3. Batch Translate (Anthropic Batches API, 50% off) -``` - -How it works (all engine modules are supported automatically): - -1. **Pass 1 (collect)** — files are processed normally, but instead of calling the API each - request is queued to `log/batch_requests.json`. Requests are byte-identical to live ones: - the static `prompt.txt` block is cached with a 1h TTL, matched vocab and translation - history ride along per request, and structured output enforces the exact line count. - Speaker/variable names still translate live during this pass (they get embedded into the - dialogue payloads, so both passes must resolve them identically) — they're a tiny share - of the volume. -2. **Cost estimate** — before anything is submitted you get a cost breakdown - (batch + cache / batch worst-case / live price) and a y/n confirmation. -3. **Submit / poll / fetch** — the batch is submitted, polled until it ends - (`batchPollInterval` env var controls the interval, default 60s), and the results are - saved to `log/batch_results.json`. Ctrl-C while polling is safe — the batch keeps - processing server-side. -4. **Pass 2 (consume)** — files are processed again; every payload is filled from the batch - results through the normal validation pipeline (line counts, placeholders, content - checks). Anything the batch missed or that fails validation falls back to the live API - automatically, so the output is always complete. - -Context note: in live mode the rolling translation history contains the previous batch's -English lines; in batch mode requests are independent, so the history carries the previous -batch's *source* lines instead. The model still sees the surrounding scene, and `vocab.txt` -keeps names and terms consistent — so keep your vocab file in good shape for batch runs. - -Cost tracking is exact: per-file and total costs printed after the consume pass use the real -billed token counts (cache reads at 0.1x, cache writes at 2x, output at the output rate) with -the 50% batch discount applied. - -`python selftest_batch.py` round-trips the whole flow offline (no API key needed) if you want -to verify the pipeline after making changes. - ---- - -## Folder Structure - -| Folder | Purpose | -|---|---| -| `files/` | Place game files here before translating | -| `translated/` | Translated output appears here | -| `log/` | Translation logs and cache | -| `modules/` | Engine-specific translation scripts | -| `gui/` | GUI source code | - ---- - -## Finding Untranslated Text (Snipping Tool OCR) - -When playtesting a translated game, you'll inevitably find text that was missed or needs fixing. The fastest way to grab Japanese text from the screen and search for it in the game files is with the **Windows 11 Snipping Tool** — its built-in OCR is far better than most dedicated OCR tools for Japanese text. - -### How to Use It - -1. Press **Win+Shift+S** to open the Snipping Tool and take a screenshot of the untranslated text in-game. -2. The screenshot opens in the Snipping Tool editor. Click the **Text Actions** button (the icon with lines of text) in the toolbar. -3. The tool will detect and highlight all text in the image. You can now **click and drag** to select specific text, or click **Copy all text** to grab everything. -4. Paste the copied Japanese text into VSCode's search (`Ctrl+Shift+F` to search across all files) to find exactly where it lives in the game data. -5. Fix or re-translate that file as needed. - -### Why Snipping Tool? - -- **Built into Windows 11** — no extra software to install. -- **Excellent Japanese OCR** — handles kanji, hiragana, and katakana very accurately, even from stylized game fonts. -- **Quick workflow** — screenshot → copy text → paste into search, all in a few seconds. - -> **Tip:** If you're on Windows 10 or the Text Actions button doesn't appear, make sure Snipping Tool is updated via the Microsoft Store. Alternatively, [ShareX](https://getsharex.com/) with its OCR feature is a good free option. - ---- - -## RPG Maker Translation Workflow - -Here's the recommended step-by-step process for translating an RPG Maker MV/MZ game. This is also shown inside the GUI's RPG Maker tab. - -| Step | Action | -|------|--------| -| **1** | **Parse speakers → vocab** — Use the Parse Speakers feature to pull character names from the game files into `data/vocab.txt`. | -| **2** | **Identify speaker genders** — Figure out which characters are male/female and update `data/vocab.txt` accordingly. This helps the AI use correct pronouns. | -| **3** | **Translate Actors.json, MapInfos.json** — These are small files with character and map names. Good to do first. | -| **4** | **Translate Items, System, Weapons, etc.** — All the data files that aren't maps or events. Place them in `files/`, translate, then copy results back. | -| **5** | **Find speaker names** — Enable CODE 101 (Speakers), check for bracketed names, or use the "First Line = Speaker" option to capture speaker names properly. | -| **6** | **Replace `\n[0-999]` variables** — Some games use variable codes like `\n[1]` for character names. Replace these with the actual actor names so the AI can translate around them. | -| **7** | **Translate Maps & CommonEvents** — The bulk of the game's dialogue. Start with a small map to test, then do the rest. You can use **Estimate** in the GUI to check the cost before running. | -| **8** | **Edit plugins for menus/text** — Some UI text lives in `plugins.js` or plugin parameters. You may need to manually translate these in a text editor. | -| **9** | **Translate CODE 122 vars, 356 plugins as needed** — Enable these codes in the RPG Maker tab if the game stores dialogue in variables or plugin commands. | -| **10** | **Playtest → find issues → fix → repeat** — Play through the game, screenshot any untranslated text, search for it in the game files, and re-translate as needed. | - -> **Note:** Some text (e.g., CODE 122 variables) may only update when starting a new save file. - ---- - -## Wolf RPG (WolfDawn) Translation Workflow - -WOLF RPG Editor games are handled by a dedicated, guided workflow built on the bundled [WolfDawn](https://gitgud.io/zero64801/wolfdawn) `wolf` CLI. -It unpacks the game's `.wolf` archives, extracts every translatable string to JSON, translates it with the same AI pipeline used elsewhere, then injects the results back into the game byte-exact. - -Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine selector at the top. - -| 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, including `names.json` (translated later in Phase 0 only). 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 curated in Step 2 and harvested into `vocab.txt` automatically during Phase 0 — do not list them here. | -| **2 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`. Pick the **Translation mode** here (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only); the same choice applies to all later phases. Run **Translate safe names (Phase 0)** once categories are saved - this is the first translation step and harvests item/skill/term names into `vocab.txt`. | -| **3 Translate** | Run the `Wolf RPG (WolfDawn)` module in three ordered phases (RPG Maker's DB-first strategy): **Phase 0 · Names → vocab.txt** translates only the safe name categories from Step 2 and harvests them into `vocab.txt` (grouped by bilingual category headers such as `Weapon · 武器`); **Phase 1 · Database text** translates item/skill/state descriptions and system messages from `DataBase.project.json` / `CDataBase.project.json`; **Phase 2 · Maps / events** translates map scripts (`.mps`), `CommonEvent.dat.json` (common events and UI/objective text), `Game.dat`, and `Evtext`. Run them in order so later phases benefit from the glossary seeded in Phase 0. **Speaker handling** and **Text wrap width** sit under Phase 2 because they apply to dialogue-like lines in maps and events: speaker attribution is automatic for real nameplates (face window → name → line, reshaped to `[Speaker]: line` for the prompt and restored to WOLF's `Speaker⏎line` on inject); copy the **speaker-format prompt** so a repo-aware AI can recommend whether to enable low-confidence first-line names. Text wrap re-flows translated dialogue to fit WOLF's message box (`wolfWrap` / `wolfWidth` in `.env`; nameplate lines stay separate). Only the `text` fields are filled in; `source` is preserved so injection can verify each line. | -| **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 Phase 0, 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. | -| **6 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. | - -> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin//`, so no toolchain or build step is needed. If your platform's binary is missing, the tool downloads a prebuilt one from the [WolfDawn release page](https://gitgud.io/zero64801/wolfdawn) and caches it into that same folder for offline reuse. A clear error is shown if no bundled binary is present and the download can't be completed (e.g. no published binary for your platform). - -> **Legacy modules:** The older `Wolf RPG` / `Wolf RPG 2` modules (configured in the Engine Config tab) still exist for edge cases, but the WolfDawn workflow above is the recommended path. - ---- - -## Using Copilot & VSCode - -[VSCode](https://code.visualstudio.com/) is a free code editor, and with [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) you get an AI assistant built right into it. This is incredibly useful for translation work — you can ask the AI to help modify game files or even tweak the tool's modules without needing to know how to code. - -### Setup - -1. Install [VSCode](https://code.visualstudio.com/). -2. Install the **GitHub Copilot** extension (`Ctrl+Shift+X` → search "GitHub Copilot"). -3. Sign in with your GitHub account (Copilot has a free tier). - -### Editing Game Files with AI - -Open your game folder in VSCode (`Right Click` → `Open with Code`) and use Copilot Chat (`Ctrl+Shift+I`) to ask for changes. Examples: - -- *"Replace all `\n[1]` with `Shino` in this file"* -- *"Translate all the Japanese menu text in this plugins.js file to English"* -- *"This dialogue has broken line breaks — fix the formatting"* - -You can also select a block of text, right-click, and choose **Copilot → Fix / Explain / Modify** to work on just that selection. - -### Modifying Tool Modules - -Open the DazedMTLTool folder in VSCode and ask Copilot to make changes to the translation modules. Examples: - -- *"Add a new regex pattern to skip lines that start with //"* -- *"Change the wordwrap logic to break on full-width punctuation"* -- *"Explain what CODE 356 does in rpgmakermvmz.py"* - -Copilot can read the surrounding code and suggest context-aware edits — you just review and accept. This makes it easy to customize the tool for specific games without deep Python knowledge. - -### Tips - -- Use **Ctrl+Shift+I** to open Copilot Chat and ask questions about any file you have open. -- Use **Ctrl+I** for inline editing — select code, describe what you want changed, and Copilot will rewrite it in place. -- Use [Git](https://git-scm.com/) with your game folder so you can always undo changes if something breaks. The [GitLens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) extension makes this even easier. - ---- - -## Version Control with Git - -Git tracks every change you make to your game files, so you can compare translations against the originals and roll back mistakes. This is optional but **highly recommended** — it has saved countless hours of work. - -### Install Git - -1. Download and install [Git](https://git-scm.com/). The default settings during installation are fine. -2. Open a terminal and verify it's installed: `git -v` - -### Set Up Git in Your Game Folder - -1. Open your **game folder** (where `Game.exe` lives) in VSCode — right-click the folder → **Open with Code**. -2. Open the terminal in VSCode (`Ctrl+`` ` ``) and run: - ``` - git init - ``` - This creates a new Git repository in that folder. - -### Create a .gitignore - -Not every file needs to be tracked. Create a file called `.gitignore` in the game folder with contents like this: - -```plaintext -# Ignore everything except text-based game files -*.* -# Allow these file types -!*.json -!*.txt -!*.js -!*.csv -!*.ks -!*.tjs -!*.rb -!*.rvdata2 -# Other useful files -!.gitignore -``` - -This tells Git to only track the file types that matter for translation. - -### Save Your First Commit - -1. Click the **Source Control** icon on the left sidebar (or press `Ctrl+Shift+G`). -2. You'll see all the game files listed. Type `Initial Commit` in the message box and click **Commit** → **Yes** (to stage all files). -3. Your original files are now saved. - -### Create an "original" Branch - -This lets you always compare your translated files against the untouched originals. - -1. Press `Ctrl+Shift+P` → type **Create Branch** → name it `original` → press Enter. -2. Press `Ctrl+Shift+P` → type **Checkout** → select `main` (or `master`). - -Now you're back on the main branch. Any translations you make here can be compared against the `original` branch at any time. - -### Comparing Changes - -After translating and copying files back into the game folder: - -1. Open **Source Control** — you'll see all modified files listed. -2. Click any file to see a side-by-side diff of what changed. -3. Commit your changes with a message like `Translated Items, Weapons, Actors`. - -To compare with the original untranslated files: -- Right-click any file → **Open Changes** → **Open Changes with Branch** → select `original`. - -### Recommended Extension - -Install [GitLens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) (`Ctrl+Shift+X` → search "GitLens") for a much richer Git experience — commit history, file annotations, branch comparisons, and more. - ---- - -## Troubleshooting - -- **`START.bat` closes immediately** — Make sure Python 3.12–3.14 is installed and added to your PATH. Open a terminal and run `python -V` to check. -- **API errors** — Double-check your API key and organization in `.env`. Make sure you have credits/quota with your provider. +# DazedMTLTool + +An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyrano, Wolf RPG, Kirikiri, and other game engines from Japanese to English using GPT, Gemini, [Mistral](https://docs.mistral.ai/api), or other compatible AI models. + +## Credits + +- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. A copy is bundled offline in `util/ace/offline/`; newer builds are downloaded when online. +- **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting. +- **Len** — [Forge](https://gitgud.io/zero64801/forge-mvmz) MV/MZ playtest plugin (`util/forge/`), Mistral API support (provider integration and adaptive rate limiting), and batch translation mode (Anthropic Message Batches API). + +## Table of Contents + +- [Supported Engines](#supported-engines) +- [Requirements](#requirements) +- [Installing Python](#installing-python) +- [Quick Start](#quick-start) +- [Using the GUI](#using-the-gui) +- [Vocab & Prompt](#vocab--prompt) +- [Tips](#tips) +- [Mistral API (free tier)](#mistral-api-free-tier) +- [Batch Translation (Anthropic, 50% off)](#batch-translation-anthropic-50-off) +- [Folder Structure](#folder-structure) +- [Finding Untranslated Text (Snipping Tool OCR)](#finding-untranslated-text-snipping-tool-ocr) +- [RPG Maker Translation Workflow](#rpg-maker-translation-workflow) +- [Wolf RPG (WolfDawn) Translation Workflow](#wolf-rpg-wolfdawn-translation-workflow) +- [Using Copilot & VSCode](#using-copilot--vscode) +- [Version Control with Git](#version-control-with-git) +- [Troubleshooting](#troubleshooting) + +## Supported Engines + +- RPG Maker (MV, MZ, Ace, and more) +- Wolf RPG Editor +- Ren'Py +- TyranoBuilder / TyranoScript +- Kirikiri +- NScripter +- CSV / Text files + +--- + +## Requirements + +- **Python 3.12 – 3.14** — See [Installing Python](#installing-python) below if you don't have it yet. +- **An AI API Key** — You'll need an API key from [OpenAI](https://platform.openai.com/settings/organization/api-keys), [Google Gemini](https://aistudio.google.com/apikey), [Mistral](https://docs.mistral.ai/api) (free tier available — no credit card), or a compatible provider. + +--- + +## Installing Python + +If you already have Python 3.12–3.14 installed and working, skip to [Quick Start](#quick-start). + +### Step 1 — Download + +Go to [python.org/downloads](https://www.python.org/downloads/) and download **Python 3.13** (or any version from 3.12 to 3.14). + +### Step 2 — Install (Important!) + +When the installer opens, **check both boxes at the bottom before clicking Install**: + +- ✅ **"Add python.exe to PATH"** — This is the most important step. Without it, your system won't be able to find Python. +- ✅ **"Use admin privileges when installing pip"** + +Then click **Install Now**. The default settings are fine for everything else. + +### Step 3 — Verify + +Open a **new** terminal window (don't reuse an old one — it won't see the new PATH) and run: + +``` +python -V +``` + +You should see something like `Python 3.13.x`. Then check pip: + +``` +pip -V +``` + +You should see something like `pip 24.x.x from ...`. + +### If `python` or `pip` is not recognized + +This means Python wasn't added to your PATH. You have two options: + +**Option A — Reinstall (easiest)** +1. Open the Python installer again (or redownload it). +2. Select **Modify**. +3. Click **Next** on the first screen. +4. On the Advanced Options screen, check **"Add Python to environment variables"**. +5. Click **Install**. Then open a **new** terminal and try again. + +**Option B — Add to PATH manually** +1. Press `Win+R`, type `sysdm.cpl`, press Enter. +2. Go to the **Advanced** tab → **Environment Variables**. +3. Under **System variables**, find `Path` and click **Edit**. +4. Click **New** and add the path where Python was installed. Typically: + - `C:\Users\YourName\AppData\Local\Programs\Python\Python313\` + - `C:\Users\YourName\AppData\Local\Programs\Python\Python313\Scripts\` +5. Click **OK** on all dialogs, then open a **new** terminal and try `python -V` again. + +> **Tip:** If `python` works but `pip` doesn't, try `python -m pip -V` instead. If that works, you can use `python -m pip install` anywhere you'd normally use `pip install`. + +--- + +## Quick Start + +### 1. Download the Tool + +1. Click the green **Code** button at the top of this page and select **Download ZIP**. +2. Extract the ZIP to a folder of your choice (e.g., `C:\DazedMTLTool`). + +### 2. Set Up Your API Key + +1. Inside the tool folder, find `.env.example` and make a copy of it named `.env`. +2. Open `.env` in any text editor (Notepad works fine) and fill in your API details: + - `api` — Your API base URL (for Nvidia use `https://integrate.api.nvidia.com/v1/`, for Mistral use `https://api.mistral.ai/v1/`). + - `key` — Your API key. + - `organization` — Your organization key (make something up if using a self-hosted or non-OpenAI API). + - `API_PROVIDER` — Use `openai` for OpenAI-compatible providers (including Nvidia), `gemini` for Gemini, or `mistral` for Mistral (only needed when `api` is left empty). + - `model` — For Nvidia/custom OpenAI-compatible endpoints, enter the model name manually (example: `deepseek-ai/deepseek-v4-pro`). +3. The rest of the settings (wordwrap, batch size, etc.) can be left as defaults for now. You can tweak them later. + +> **Trying Mistral?** Set `API_PROVIDER=mistral`, add your key, use `mistral-medium-3.5`. Free tier details in [Mistral API (free tier)](#mistral-api-free-tier). + +### 3. Launch the GUI + +**Windows:** Double-click `START.bat`. It will create a virtual environment, install dependencies, and open the GUI. + +**Linux/macOS:** Run `./START.sh`, or double-click `DazedMTLTool.desktop` (choose **Allow Launching** when your file manager asks). From then on, either method works. + +That's it! Use the same launcher each time you want to open the tool. + +--- + +## Using the GUI + +The GUI has several tabs that handle different parts of the translation process: + +### Config Tab +This is where you configure your API settings, wordwrap widths, and other options. Most of these mirror what's in the `.env` file, but you can adjust them visually. + +### Translation Tab +The main tab for translating files. + +1. **Add files** — Place the game files you want to translate into the `files` folder (inside the tool directory). +2. **Select a module** — Pick the engine that matches your game (e.g., RPG Maker MV/MZ, Wolf RPG, Ren'Py, etc.). +3. **Click Translate** — The tool will process each file and output translated versions to the `translated` folder. +4. **Copy the results** — Move the translated files from `translated` back into your game's data folder. + +### Engine Config Tab +Engine-specific settings. RPG Maker has the most mature support — you can toggle exactly which event codes to translate (dialogue, choices, variables, plugin commands, etc.). The defaults cover ~95% of a game's text out of the box. See the [RPG Maker Translation Workflow](#rpg-maker-translation-workflow) section for a detailed step-by-step guide. + +Other engines (Wolf RPG, Ren'Py, Tyrano, etc.) have less granular support and may require more manual tweaking or post-editing depending on the game. + +### RPG Maker / Wolf / CSV Tabs +Specialized tabs with extra options for those specific engines. + +--- + +## Vocab & Prompt + +### data/vocab.txt +This file gives the AI context about your game — character names, genders, recurring terms, etc. The better your vocab file, the more consistent the translation. + +On first run, `data/vocab.txt` is created automatically from `data/vocab_base.txt` if it does not exist yet. Add entries like: + +```plaintext +# Game Characters +水無月 士乃 (Minazuki Shino) - Female +暗黒斎 (Dark Kokusai) - Male +フトシ (Futoshi) - Male +``` + +Format: Japanese name, English name in parentheses, then gender. + +> **Note:** A very large vocab file can increase API costs and potentially reduce quality. Focus on the most important characters and terms. + +### data/prompt.txt +This is the system prompt sent to the AI. A default `data/prompt.txt` is included and works well for most games. You generally don't need to edit it unless you want to customize the translation style. + +--- + +## Tips + +- **Check `log/translations.txt`** after a run to see what was translated. You can copy useful terms from it into `data/vocab.txt` for consistency in future runs. +- **Start small** — Translate a few files first to make sure the output looks good before doing the whole game. +- **Wordwrap** — If text overflows or looks awkward in-game, adjust the `width` setting in `.env` or the Config tab. `60` is a good default for most RPG Maker games. +- **Version control** — Using [Git](https://git-scm.com/) with the game folder is highly recommended. It lets you track every change the translation makes, compare with original files, and roll back if needed. + +--- + +## Mistral API (free tier) + +Mistral's [API is free for now](https://docs.mistral.ai/api) (no credit card), and the tool has proper support for it — not just a generic OpenAI-compatible URL. Mistral rate-limits pretty aggressively per model, so there's an adaptive limiter that reads the live headers and paces requests automatically. That lets you crank up `fileThreads` without the run dying to 429s. + +`mistral-medium-3.5` is the recommended model for translation. Avoid `mistral-medium-latest` — it still points at the older 3.1 release. + +Quick setup in `.env`: + +``` +API_PROVIDER=mistral +api="https://api.mistral.ai/v1/" +key="your-mistral-api-key" +model="mistral-medium-3.5" +``` + +When the API URL points at `api.mistral.ai`, requests are paced automatically. Mistral enforces a **per-minute request limit** and a **per-minute token limit**, both **per-model** — e.g. `mistral-medium` allows 25 req/min while `ministral-3b` allows 750/min. The limiter reads both limits from the live `x-ratelimit-*` response headers, spaces requests so it never overruns the per-minute budget, and honours `Retry-After` on 429s. Override the seeds with `mistralReqPerSec`, `mistralTokPerMin`, and `mistralTokenHeadroom` in `.env` if needed (rarely). + +> **Note:** Batch mode (50% off) is Anthropic/Claude only. Mistral runs live translation through the rate limiter above. + +--- + +## Batch Translation (Anthropic, 50% off) + +Batch mode — see [Credits](#credits). + +When using a Claude model, the CLI offers a third mode that translates through the +[Anthropic Message Batches API](https://platform.claude.com/docs/en/build-with-claude/batch-processing.md) +— every token (input, output, and prompt-cache reads/writes) is billed at **50% of the live price**. +Batches usually finish within an hour (24h worst case), so use it for large jobs where you don't +need results immediately. + +``` +python start.py + -> 3. Batch Translate (Anthropic Batches API, 50% off) +``` + +How it works (all engine modules are supported automatically): + +1. **Pass 1 (collect)** — files are processed normally, but instead of calling the API each + request is queued to `log/batch_requests.json`. Requests are byte-identical to live ones: + the static `prompt.txt` block is cached with a 1h TTL, matched vocab and translation + history ride along per request, and structured output enforces the exact line count. + Speaker/variable names still translate live during this pass (they get embedded into the + dialogue payloads, so both passes must resolve them identically) — they're a tiny share + of the volume. +2. **Cost estimate** — before anything is submitted you get a cost breakdown + (batch + cache / batch worst-case / live price) and a y/n confirmation. +3. **Submit / poll / fetch** — the batch is submitted, polled until it ends + (`batchPollInterval` env var controls the interval, default 60s), and the results are + saved to `log/batch_results.json`. Ctrl-C while polling is safe — the batch keeps + processing server-side. +4. **Pass 2 (consume)** — files are processed again; every payload is filled from the batch + results through the normal validation pipeline (line counts, placeholders, content + checks). Anything the batch missed or that fails validation falls back to the live API + automatically, so the output is always complete. + +Context note: in live mode the rolling translation history contains the previous batch's +English lines; in batch mode requests are independent, so the history carries the previous +batch's *source* lines instead. The model still sees the surrounding scene, and `vocab.txt` +keeps names and terms consistent — so keep your vocab file in good shape for batch runs. + +Cost tracking is exact: per-file and total costs printed after the consume pass use the real +billed token counts (cache reads at 0.1x, cache writes at 2x, output at the output rate) with +the 50% batch discount applied. + +`python selftest_batch.py` round-trips the whole flow offline (no API key needed) if you want +to verify the pipeline after making changes. + +--- + +## Folder Structure + +| Folder | Purpose | +|---|---| +| `files/` | Place game files here before translating | +| `translated/` | Translated output appears here | +| `log/` | Translation logs and cache | +| `modules/` | Engine-specific translation scripts | +| `gui/` | GUI source code | + +--- + +## Finding Untranslated Text (Snipping Tool OCR) + +When playtesting a translated game, you'll inevitably find text that was missed or needs fixing. The fastest way to grab Japanese text from the screen and search for it in the game files is with the **Windows 11 Snipping Tool** — its built-in OCR is far better than most dedicated OCR tools for Japanese text. + +### How to Use It + +1. Press **Win+Shift+S** to open the Snipping Tool and take a screenshot of the untranslated text in-game. +2. The screenshot opens in the Snipping Tool editor. Click the **Text Actions** button (the icon with lines of text) in the toolbar. +3. The tool will detect and highlight all text in the image. You can now **click and drag** to select specific text, or click **Copy all text** to grab everything. +4. Paste the copied Japanese text into VSCode's search (`Ctrl+Shift+F` to search across all files) to find exactly where it lives in the game data. +5. Fix or re-translate that file as needed. + +### Why Snipping Tool? + +- **Built into Windows 11** — no extra software to install. +- **Excellent Japanese OCR** — handles kanji, hiragana, and katakana very accurately, even from stylized game fonts. +- **Quick workflow** — screenshot → copy text → paste into search, all in a few seconds. + +> **Tip:** If you're on Windows 10 or the Text Actions button doesn't appear, make sure Snipping Tool is updated via the Microsoft Store. Alternatively, [ShareX](https://getsharex.com/) with its OCR feature is a good free option. + +--- + +## RPG Maker Translation Workflow + +Here's the recommended step-by-step process for translating an RPG Maker MV/MZ game. This is also shown inside the GUI's RPG Maker tab. + +| Step | Action | +|------|--------| +| **1** | **Parse speakers → vocab** — Use the Parse Speakers feature to pull character names from the game files into `data/vocab.txt`. | +| **2** | **Identify speaker genders** — Figure out which characters are male/female and update `data/vocab.txt` accordingly. This helps the AI use correct pronouns. | +| **3** | **Translate Actors.json, MapInfos.json** — These are small files with character and map names. Good to do first. | +| **4** | **Translate Items, System, Weapons, etc.** — All the data files that aren't maps or events. Place them in `files/`, translate, then copy results back. | +| **5** | **Find speaker names** — Enable CODE 101 (Speakers), check for bracketed names, or use the "First Line = Speaker" option to capture speaker names properly. | +| **6** | **Replace `\n[0-999]` variables** — Some games use variable codes like `\n[1]` for character names. Replace these with the actual actor names so the AI can translate around them. | +| **7** | **Translate Maps & CommonEvents** — The bulk of the game's dialogue. Start with a small map to test, then do the rest. You can use **Estimate** in the GUI to check the cost before running. | +| **8** | **Edit plugins for menus/text** — Some UI text lives in `plugins.js` or plugin parameters. You may need to manually translate these in a text editor. | +| **9** | **Translate CODE 122 vars, 356 plugins as needed** — Enable these codes in the RPG Maker tab if the game stores dialogue in variables or plugin commands. | +| **10** | **Playtest → find issues → fix → repeat** — Play through the game, screenshot any untranslated text, search for it in the game files, and re-translate as needed. | + +> **Note:** Some text (e.g., CODE 122 variables) may only update when starting a new save file. + +--- + +## Wolf RPG (WolfDawn) Translation Workflow + +WOLF RPG Editor games are handled by a dedicated, guided workflow built on the bundled [WolfDawn](https://gitgud.io/zero64801/wolfdawn) `wolf` CLI. +It unpacks the game's `.wolf` archives, extracts every translatable string to JSON, translates it with the same AI pipeline used elsewhere, then injects the results back into the game byte-exact. + +Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine selector at the top. + +| Step | Action | +|------|--------| +| **0 Project** | Select the game root folder (browse or Enter — detection also runs when you reopen the tab). If `wolf_json/` does not exist yet, the tool automatically unpacks `.wolf` archives when needed and extracts text into the game's `wolf_json/` folder (maps, common events, databases, `Game.dat`, Evtext, and `names.json`). A checklist lists every JSON file in `wolf_json/`; tick the ones you want and click **Import** (or leave Step 0 — checked files auto-import into the tool's `files/` folder, matching the RPG Maker workflow). Extraction snapshots pristine binaries into `wolf_json/originals/` for idempotent inject in Step 5. | +| **1 Pre-process** | Optional: **dazedformat** normalises JSON in `wolf_json/` and `files/` (`json.dump`, indent 4) for clean git diffs; **Copy gameupdate/** installs the updater scripts, patch scripts, and `.gitignore` into the game root for git-based patching. Paths auto-fill from Step 0. | +| **2 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. Item/skill/enemy value names (`names.json`) are curated in Step 3 and harvested into `vocab.txt` automatically during Phase 0 — do not list them here. | +| **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. WolfDawn tags each name with its WOLF database **category** (`note`), so you pick which *categories* are safe. Copy the **names-safety prompt**, paste the AI's JSON list, **Save** to `data/wolf_safe_notes.json`, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. | +| **4 Translate** | Run the `Wolf RPG (WolfDawn)` module in three ordered phases: **Phase 0 · Names → vocab.txt**, **Phase 1 · Database text**, **Phase 2 · Maps / events** (with speaker handling and text-wrap settings). Only `text` fields are filled in; `source` is preserved for inject drift checks. | +| **5 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all. | +| **6 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf`. | +| **7 Saves** | *(Optional)* Update existing `.sav` files for the translated build. | + +> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin//`, so no toolchain or build step is needed. If your platform's binary is missing, the tool downloads a prebuilt one from the [WolfDawn release page](https://gitgud.io/zero64801/wolfdawn) and caches it into that same folder for offline reuse. A clear error is shown if no bundled binary is present and the download can't be completed (e.g. no published binary for your platform). + +> **Legacy modules:** The older `Wolf RPG` / `Wolf RPG 2` modules (configured in the Engine Config tab) still exist for edge cases, but the WolfDawn workflow above is the recommended path. + +--- + +## Using Copilot & VSCode + +[VSCode](https://code.visualstudio.com/) is a free code editor, and with [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) you get an AI assistant built right into it. This is incredibly useful for translation work — you can ask the AI to help modify game files or even tweak the tool's modules without needing to know how to code. + +### Setup + +1. Install [VSCode](https://code.visualstudio.com/). +2. Install the **GitHub Copilot** extension (`Ctrl+Shift+X` → search "GitHub Copilot"). +3. Sign in with your GitHub account (Copilot has a free tier). + +### Editing Game Files with AI + +Open your game folder in VSCode (`Right Click` → `Open with Code`) and use Copilot Chat (`Ctrl+Shift+I`) to ask for changes. Examples: + +- *"Replace all `\n[1]` with `Shino` in this file"* +- *"Translate all the Japanese menu text in this plugins.js file to English"* +- *"This dialogue has broken line breaks — fix the formatting"* + +You can also select a block of text, right-click, and choose **Copilot → Fix / Explain / Modify** to work on just that selection. + +### Modifying Tool Modules + +Open the DazedMTLTool folder in VSCode and ask Copilot to make changes to the translation modules. Examples: + +- *"Add a new regex pattern to skip lines that start with //"* +- *"Change the wordwrap logic to break on full-width punctuation"* +- *"Explain what CODE 356 does in rpgmakermvmz.py"* + +Copilot can read the surrounding code and suggest context-aware edits — you just review and accept. This makes it easy to customize the tool for specific games without deep Python knowledge. + +### Tips + +- Use **Ctrl+Shift+I** to open Copilot Chat and ask questions about any file you have open. +- Use **Ctrl+I** for inline editing — select code, describe what you want changed, and Copilot will rewrite it in place. +- Use [Git](https://git-scm.com/) with your game folder so you can always undo changes if something breaks. The [GitLens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) extension makes this even easier. + +--- + +## Version Control with Git + +Git tracks every change you make to your game files, so you can compare translations against the originals and roll back mistakes. This is optional but **highly recommended** — it has saved countless hours of work. + +### Install Git + +1. Download and install [Git](https://git-scm.com/). The default settings during installation are fine. +2. Open a terminal and verify it's installed: `git -v` + +### Set Up Git in Your Game Folder + +1. Open your **game folder** (where `Game.exe` lives) in VSCode — right-click the folder → **Open with Code**. +2. Open the terminal in VSCode (`Ctrl+`` ` ``) and run: + ``` + git init + ``` + This creates a new Git repository in that folder. + +### Create a .gitignore + +Not every file needs to be tracked. Create a file called `.gitignore` in the game folder with contents like this: + +```plaintext +# Ignore everything except text-based game files +*.* +# Allow these file types +!*.json +!*.txt +!*.js +!*.csv +!*.ks +!*.tjs +!*.rb +!*.rvdata2 +# Other useful files +!.gitignore +``` + +This tells Git to only track the file types that matter for translation. + +### Save Your First Commit + +1. Click the **Source Control** icon on the left sidebar (or press `Ctrl+Shift+G`). +2. You'll see all the game files listed. Type `Initial Commit` in the message box and click **Commit** → **Yes** (to stage all files). +3. Your original files are now saved. + +### Create an "original" Branch + +This lets you always compare your translated files against the untouched originals. + +1. Press `Ctrl+Shift+P` → type **Create Branch** → name it `original` → press Enter. +2. Press `Ctrl+Shift+P` → type **Checkout** → select `main` (or `master`). + +Now you're back on the main branch. Any translations you make here can be compared against the `original` branch at any time. + +### Comparing Changes + +After translating and copying files back into the game folder: + +1. Open **Source Control** — you'll see all modified files listed. +2. Click any file to see a side-by-side diff of what changed. +3. Commit your changes with a message like `Translated Items, Weapons, Actors`. + +To compare with the original untranslated files: +- Right-click any file → **Open Changes** → **Open Changes with Branch** → select `original`. + +### Recommended Extension + +Install [GitLens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) (`Ctrl+Shift+X` → search "GitLens") for a much richer Git experience — commit history, file annotations, branch comparisons, and more. + +--- + +## Troubleshooting + +- **`START.bat` closes immediately** — Make sure Python 3.12–3.14 is installed and added to your PATH. Open a terminal and run `python -V` to check. +- **API errors** — Double-check your API key and organization in `.env`. Make sure you have credits/quota with your provider. - **Missing dependencies** — Delete the `.venv` folder and run `START.bat` again. It will recreate the environment and reinstall everything. \ No newline at end of file diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index a874072..bf573cd 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -4,38 +4,35 @@ Wolf RPG (WolfDawn) Workflow Tab - guided pipeline for WOLF RPG Editor games. Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI (see util/wolfdawn): - Step 0 Project - select game folder, unpack .wolf archives, extract text - (strings-extract per file + names-extract) into files/, copy - the gameupdate/ patch files (incl. .gitignore) for git tracking, - and format the extracted JSON to the canonical layout so injects - produce clean git diffs - Step 1 Glossary - build vocab.txt (characters / worldbuilding terms) before - translating so the AI keeps names and voice consistent - Step 2 Names - curate names.json (item/skill/enemy value names). Blindly - translating all of them breaks games that reference names by - value, so a repo-aware AI classifies which categories are safe - to translate and leaves the rest as source. Pick Normal/Batch - translation mode here and run Phase 0 (names -> vocab.txt). - Step 3 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three - ordered phases (RPGMaker's DB-first strategy; mode set in Step 2): - Phase 0 Names - also runnable from Step 2; harvests safe - name translations into vocab.txt - Phase 1 Database - item/skill/state descriptions and system - messages (DataBase/CDataBase.project) - Phase 2 Maps / events - .mps maps, CommonEvent, Game.dat, - Evtext; speaker handling and text-wrap - settings live under this phase in Step 3 - Step 4 Inject - inject translations + curated names back into the Data/ binaries - and refresh the git-tracked wolf_json/ with the translated - JSON; quick-inject picks just a few translated files for - fast in-game / git iteration, or inject everything at once - Step 5 Package - run from a loose Data/ folder, or repack Data.wolf - Step 6 Saves - fix baked strings in existing .sav files (optional) + Step 0 Project - select game folder; import wolf_json/ into files/ (like RPGMaker) + Step 1 Pre-process - optional dazedformat + gameupdate/ copy before translating + Step 2 Glossary - build vocab.txt (characters / worldbuilding terms) before + translating so the AI keeps names and voice consistent + Step 3 Names - curate names.json (item/skill/enemy value names). Blindly + translating all of them breaks games that reference names by + value, so a repo-aware AI classifies which categories are safe + to translate and leaves the rest as source. Pick Normal/Batch + translation mode here and run Phase 0 (names -> vocab.txt). + Step 4 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three + ordered phases (RPGMaker's DB-first strategy; mode set in Step 3): + Phase 0 Names - also runnable from Step 3; harvests safe + name translations into vocab.txt + Phase 1 Database - item/skill/state descriptions and system + messages (DataBase/CDataBase.project) + Phase 2 Maps / events - .mps maps, CommonEvent, Game.dat, + Evtext; speaker handling and text-wrap + settings live under this phase + Step 5 Inject - inject translations + curated names back into the Data/ binaries + and refresh the git-tracked wolf_json/ with the translated + JSON; quick-inject picks just a few translated files for + fast in-game / git iteration, or inject everything at once + Step 6 Package - run from a loose Data/ folder, or repack Data.wolf + Step 7 Saves - fix baked strings in existing .sav files (optional) names.json is staged into files/ but is NOT translated in the bulk phases - many WOLF "value names" double as logic keys (referenced by value in event code), so -translating them all corrupts the game. Step 2 marks the safe subset (by category), -Phase 0 translates only those and harvests them into vocab.txt, and Step 4 injects +translating them all corrupts the game. Step 3 marks the safe subset (by category), +Phase 0 translates only those and harvests them into vocab.txt, and Step 5 injects the result. The extracted JSON is staged in ``/wolf_json/`` (a manifest maps each @@ -53,13 +50,15 @@ import shutil import tempfile from pathlib import Path -from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal -from PyQt5.QtGui import QFont +from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal, QEvent +from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import ( QApplication, + QAbstractItemView, QCheckBox, QComboBox, QFileDialog, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, @@ -68,8 +67,10 @@ from PyQt5.QtWidgets import ( QMessageBox, QPushButton, QScrollArea, + QSizePolicy, QSpinBox, QSplitter, + QStyle, QTabWidget, QTextEdit, QVBoxLayout, @@ -81,9 +82,17 @@ from gui.translation_tab import ( BATCH_MODE_BENEFIT_NOTE, BATCH_MODE_LABEL, ) -from gui.workflow_tab import _make_btn, _make_hr, _make_section_label +from gui.workflow_tab import ( + _FileCopyWorker, + _ImportWorker, + _make_btn, + _make_hr, + _make_icon_btn, + _make_section_label, +) from util import wolf_names -from util.project_scanner import detect_wolf_layout +from util.paths import PROJECT_ROOT +from util.project_scanner import detect_wolf_layout, list_wolf_json_files from util.vocab import read_game_vocab, write_game_vocab # Workflow-level label for the non-batch (live) translation path. The Translation @@ -142,7 +151,7 @@ _WOLF_GLOSSARY_PROMPT = ( " - Game.dat.json — game/system strings (title, terms).\n" " - .mps.json — per-map events: the main story dialogue (can be large).\n" " - Evtext.json — external event text, when present.\n" - " - names.json — item/skill/enemy value names (curated separately in Step 2; do NOT list them).\n" + " - names.json — item/skill/enemy value names (curated separately in Step 3; do NOT list them).\n" "\n" "\n" "\n" @@ -174,7 +183,7 @@ _WOLF_GLOSSARY_PROMPT = ( "- Include: faction/organisation names, locations mentioned in dialogue but not on maps, " "unique magic systems, lore titles, recurring in-universe concepts.\n" "- Exclude: skill names, item names, weapon/armour names (curated separately via " - "names.json in Step 2). Skip generic RPG words. Do not repeat character names here.\n" + "names.json in Step 3). Skip generic RPG words. Do not repeat character names here.\n" "\n" "\n" "\n" @@ -342,10 +351,50 @@ class WolfWorkflowTab(QWidget): self._game_root: str = "" self._layout: dict = {} self._worker: _WolfTaskWorker | None = None + self._import_worker: _ImportWorker | None = None self._buttons: list[QPushButton] = [] + self._import_buttons: list[QPushButton] = [] + self._detected_on_show: bool = False + self._current_step_index: int = 0 + self._file_items: list[dict] = [] + self._last_import_signature: tuple[str, ...] | None = None + self._pending_import_signature: tuple[str, ...] | None = None + self._syncing_file_checks: bool = False + self._gameupdate_path: str = "" self._init_ui() + def showEvent(self, event): + """Auto-detect the saved game folder when the tab is first shown.""" + super().showEvent(event) + if not self._detected_on_show and self._setting("last_game_folder", ""): + self._detected_on_show = True + if self.folder_edit.text().strip(): + QTimer.singleShot(100, self._detect_folder) + + def eventFilter(self, obj, event): + """Toggle workflow file checks when clicking a row outside the checkbox.""" + try: + if ( + hasattr(self, "file_list") + and obj is self.file_list.viewport() + and event.type() == QEvent.MouseButtonRelease + and event.button() == Qt.LeftButton + ): + item = self.file_list.itemAt(event.pos()) + if item is None: + return False + item_rect = self.file_list.visualItemRect(item) + if event.pos().x() <= item_rect.left() + 26: + return False + item.setCheckState( + Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked + ) + return False + except Exception: + pass + return super().eventFilter(obj, event) + # ───────────────────────────────── paths ───────────────────────────────── def _work_dir(self) -> Path: @@ -534,12 +583,13 @@ class WolfWorkflowTab(QWidget): # Names curation comes before Translate so Phase 0 can seed vocab.txt. _tab_defs = [ ("0 Project", self._build_step0), - ("1 Glossary", self._build_step1_glossary), - ("2 Names", self._build_step3_names), - ("3 Translate", self._build_step2_translate), - ("4 Inject", self._build_step4_inject), - ("5 Package", self._build_step5_package), - ("6 Saves", self._build_step6_saves), + ("1 Pre-process", self._build_step1_preprocess), + ("2 Glossary", self._build_step1_glossary), + ("3 Names", self._build_step3_names), + ("4 Translate", self._build_step2_translate), + ("5 Inject", self._build_step4_inject), + ("6 Package", self._build_step5_package), + ("7 Saves", self._build_step6_saves), ] for tab_label, builder in _tab_defs: @@ -697,6 +747,9 @@ class WolfWorkflowTab(QWidget): if self._worker is not None and self._worker.isRunning(): QMessageBox.information(self, "Busy", "A task is already running. Please wait.") return + if self._import_worker is not None and self._import_worker.isRunning(): + QMessageBox.information(self, "Busy", "An import is already running. Please wait.") + return self._set_busy(True) worker = _WolfTaskWorker(task) self._worker = worker @@ -729,110 +782,611 @@ class WolfWorkflowTab(QWidget): # ── Step 0: Project ─────────────────────────────────────────────────────── def _build_step0(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 0 · Select Game & Extract Text")) + layout.addWidget(_make_section_label("Step 0 — Project Folder")) layout.addWidget(self._desc( - "Pick the WOLF game's root folder (the one containing Game.exe and Data.wolf, " - "or a loose Data/ folder). The tool unpacks the .wolf archives with WolfDawn, " - "extracts all translatable text, and stages it in files/ ready to translate.\n\n" - "A prebuilt WolfDawn 'wolf' CLI ships with the tool (Windows and Linux), so no " - "setup is needed. If your platform's binary is missing, it's downloaded once " - "from the WolfDawn release page and cached for offline use." + "Pick the WOLF game root folder (Game.exe plus Data.wolf or a loose Data/ folder). " + "Detection runs when you browse, press Enter, or reopen this tab. If wolf_json/ does " + "not exist yet, the tool unpacks .wolf archives when needed and extracts text into " + f"{WORK_DIR_NAME}/ automatically. Tick the JSON files to import, then leave this step " + "(or click Import) to copy them into files/ for translation." )) - row = QHBoxLayout() + row0 = QHBoxLayout() self.folder_edit = QLineEdit() - self.folder_edit.setPlaceholderText("Path to the WOLF game folder…") + self.folder_edit.setPlaceholderText("Path to game root folder…") self.folder_edit.setText(self._setting("last_game_folder", "") or "") - row.addWidget(self.folder_edit, 1) - browse_btn = self._register(_make_btn("Browse…", "#3a3a3a")) + self.folder_edit.returnPressed.connect(self._detect_folder) + row0.addWidget(self.folder_edit, 1) + browse_btn = _make_icon_btn("📁", "Browse for a game project folder") browse_btn.clicked.connect(self._browse_folder) - row.addWidget(browse_btn) - detect_btn = self._register(_make_btn("Detect", "#007acc")) - detect_btn.clicked.connect(self._detect_folder) - row.addWidget(detect_btn) - layout.addLayout(row) + row0.addWidget(browse_btn) + layout.addLayout(row0) - self.status_label = QLabel("No game folder selected.") - self.status_label.setWordWrap(True) - self.status_label.setStyleSheet("color:#c8c8c8;font-size:12px;padding:6px 0px;") - layout.addWidget(self.status_label) + self.detected_label = QLabel("No folder detected yet.") + self.detected_label.setWordWrap(True) + self.detected_label.setStyleSheet( + "color:#6a9a6a;font-size:13px;padding:4px 8px;" + "background-color:#1f2b1f;border:1px solid #2a4a2a;" + "border-radius:4px;margin:4px 0;" + ) + layout.addWidget(self.detected_label) - layout.addWidget(_make_hr()) + self.file_list = QListWidget() + self.file_list.setMinimumHeight(320) + self.file_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.file_list.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.file_list.itemChanged.connect(self._sync_selected_file_checks) + self.file_list.viewport().installEventFilter(self) + self.file_list.setStyleSheet( + "QListWidget{outline:none;border:1px solid #3c3c3c;" + "background-color:#252526;border-radius:4px;}" + "QListWidget::item{border:none;outline:none;padding:2px 6px;" + "color:#c8c8c8;}" + "QListWidget::item:selected{background-color:#252526;color:#c8c8c8;}" + "QListWidget::item:hover{background-color:#2d2d30;" + "border-left:2px solid #007acc;}" + ) + layout.addWidget(self.file_list, 1) - unpack_btn = self._register(_make_btn("① Unpack .wolf archives", "#007acc")) - unpack_btn.clicked.connect(self._unpack) - layout.addWidget(unpack_btn) - layout.addWidget(self._desc( - "Unpacks every .wolf archive into a loose Data/ folder. Skip this if the game " - "already has an unpacked Data/ folder." + row1 = QHBoxLayout() + row1.setSpacing(6) + select_all_btn = _make_icon_btn("✓", "Select all importable files") + select_all_btn.clicked.connect(self._select_all_files) + row1.addWidget(select_all_btn) + deselect_all_btn = _make_icon_btn("✗", "Deselect all files") + deselect_all_btn.clicked.connect(self._deselect_all_files) + row1.addWidget(deselect_all_btn) + sel_core = _make_icon_btn("◆", "Core only: databases, common events, names") + sel_core.setToolTip("Select core JSON files; deselect map scripts") + sel_core.clicked.connect(self._select_core_only) + row1.addWidget(sel_core) + import_btn = _make_icon_btn("📥", "Import selected files into files/") + import_btn.setEnabled(False) + import_btn.setToolTip("Replace files/ with exactly the checked files above") + import_btn.clicked.connect(lambda _checked=False: self._import_files()) + self._register_import_button(import_btn) + row1.addWidget(import_btn) + row1.addStretch() + layout.addLayout(row1) + + @staticmethod + def _task_box_style() -> str: + return ( + "QWidget#tbox{" + "background-color:#252526;" + "border:1px solid #3c3c3c;" + "border-radius:6px;}" + ) + + def _build_step1_preprocess(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 1 — Pre-process (optional)")) + + header_row = QHBoxLayout() + header_row.addWidget(self._desc( + "Optional preparation before translating. Paths auto-fill when a project folder " + "is detected in Step 0." )) + header_row.addStretch() + layout.addLayout(header_row) - extract_btn = self._register(_make_btn("② Extract text & import into files/", "#00a86b")) - extract_btn.clicked.connect(self._extract_and_import) - layout.addWidget(extract_btn) - layout.addWidget(self._desc( - "Extracts maps, common events, databases, Game.dat, external event text, and the " - "project-wide name glossary, then imports them into files/ for translation." - )) + hint = QLabel( + "Format extracted JSON for clean git diffs, then copy the gameupdate/ patch " + "files into the game root for git tracking." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color:#9d9d9d;font-size:13px;padding-bottom:6px;") + layout.addWidget(hint) - gu_btn = self._register(_make_btn("③ Set up git tracking (copy gameupdate/ files)", "#3a3a3a")) - gu_btn.clicked.connect(self._apply_gameupdate) - layout.addWidget(gu_btn) - layout.addWidget(self._desc( - "Copies the tool's gameupdate/ folder into the game's root folder: the updater " - "scripts (GameUpdate.bat / GameUpdate_linux.sh), the patch scripts (patch.sh / " - "patch.ps1), and a .gitignore that excludes the original game files. Do this early so " - "you can track the translation project with git and later ship it as a git-based patch " - "players apply themselves. Existing files are overwritten." - )) + tasks_box = QGroupBox() + tasks_box.setStyleSheet("QGroupBox{border:none;margin:0;padding:4px 0;}") + tb = QVBoxLayout(tasks_box) + tb.setSpacing(10) + layout.addWidget(tasks_box) - fmt_btn = self._register(_make_btn("④ Format extracted JSON (dazedformat)", "#3a3a3a")) - fmt_btn.clicked.connect(self._format_json) - layout.addWidget(fmt_btn) - layout.addWidget(self._desc( - "Normalises the extracted JSON in wolf_json/ and files/ to a consistent layout " - "(json.dump, indent 4) — the same format the translator writes back. Run this once " - "after extracting and commit it as your baseline, so later injects produce clean, " - "line-level git diffs instead of reformatting the whole file." - )) + ta_title = QLabel("A — Format JSON files (dazedformat)") + ta_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;") + tb.addWidget(ta_title) + ta = QWidget() + ta.setObjectName("tbox") + ta.setStyleSheet(self._task_box_style()) + ta_inner = QVBoxLayout(ta) + ta_inner.setContentsMargins(10, 8, 10, 8) + ta_inner.setSpacing(4) + ta_desc = QLabel( + "Normalises JSON in the game's wolf_json/ folder and the tool's files/ folder " + "by round-tripping through json.load / json.dump (indent 4). Bundled — no install." + ) + ta_desc.setWordWrap(True) + ta_desc.setStyleSheet("color:#9d9d9d;font-size:13px;") + ta_inner.addWidget(ta_desc) + ta_path_row = QHBoxLayout() + ta_path_row.addWidget(QLabel("wolf_json/:")) + self.pp_wolf_json_label = QLabel("(detect a project folder first)") + self.pp_wolf_json_label.setStyleSheet("color:#7a7a7a;font-size:13px;") + ta_path_row.addWidget(self.pp_wolf_json_label, 1) + ta_inner.addLayout(ta_path_row) + ta_btn_row = QHBoxLayout() + run_dazed = _make_btn("► Run dazedformat", "#555") + run_dazed.setFixedWidth(180) + run_dazed.clicked.connect(self._run_dazedformat) + ta_btn_row.addWidget(run_dazed) + ta_btn_row.addStretch() + ta_inner.addLayout(ta_btn_row) + tb.addWidget(ta) + + tc_title = QLabel("B — Apply gameupdate/ patch files") + tc_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;") + tb.addWidget(tc_title) + tc = QWidget() + tc.setObjectName("tbox") + tc.setStyleSheet(self._task_box_style()) + tc_inner = QVBoxLayout(tc) + tc_inner.setContentsMargins(10, 8, 10, 8) + tc_inner.setSpacing(4) + tc_desc = QLabel( + "Copies everything from the gameupdate/ folder into the game's root folder, " + "overwriting existing files (updater scripts, patch scripts, .gitignore)." + ) + tc_desc.setWordWrap(True) + tc_desc.setStyleSheet("color:#9d9d9d;font-size:13px;") + tc_inner.addWidget(tc_desc) + tc_src_row = QHBoxLayout() + tc_src_row.addWidget(QLabel("Source:")) + self.pp_gameupdate_edit = QLineEdit() + self.pp_gameupdate_edit.setPlaceholderText("Path to gameupdate/ folder…") + tc_src_row.addWidget(self.pp_gameupdate_edit, 1) + browse_gu = _make_btn("", "#444") + browse_gu.setIcon(browse_gu.style().standardIcon(QStyle.SP_DirOpenIcon)) + browse_gu.setFixedWidth(34) + browse_gu.clicked.connect(self._browse_gameupdate) + tc_src_row.addWidget(browse_gu) + tc_inner.addLayout(tc_src_row) + tc_dst_row = QHBoxLayout() + tc_dst_row.addWidget(QLabel("Destination:")) + self.pp_gameupdate_dst_label = QLabel("(game root folder auto-filled from project)") + self.pp_gameupdate_dst_label.setStyleSheet("color:#7a7a7a;font-size:13px;") + tc_dst_row.addWidget(self.pp_gameupdate_dst_label, 1) + tc_inner.addLayout(tc_dst_row) + tc_btn_row = QHBoxLayout() + tc_btn_row.setSpacing(8) + run_gu = _make_btn("► Copy gameupdate/", "#555") + run_gu.setFixedWidth(180) + run_gu.clicked.connect(self._run_gameupdate) + tc_btn_row.addWidget(run_gu) + run_all_btn = _make_btn("►► Run Both Tasks", "#007acc") + run_all_btn.setFixedWidth(180) + run_all_btn.setToolTip("Run dazedformat, then copy gameupdate/") + run_all_btn.clicked.connect(self._run_all_preprocess) + tc_btn_row.addWidget(run_all_btn) + tc_btn_row.addStretch() + tc_inner.addLayout(tc_btn_row) + tb.addWidget(tc) + + def _register_import_button(self, button: QPushButton) -> None: + self._import_buttons.append(button) + + def _set_import_buttons_enabled(self, enabled: bool) -> None: + for button in self._import_buttons: + button.setEnabled(enabled) def _browse_folder(self): - start = self.folder_edit.text() or str(Path.home()) - folder = QFileDialog.getExistingDirectory(self, "Select WOLF Game Folder", start) + start = self.folder_edit.text() or self._setting("last_game_folder", "") or str(Path.home()) + folder = QFileDialog.getExistingDirectory(self, "Select Game Root Folder", start) if folder: self.folder_edit.setText(folder) + self._save_setting("last_game_folder", folder) + self._detected_on_show = True + self._ask_clear_old_files() self._detect_folder() - def _detect_folder(self): - root = self.folder_edit.text().strip() - if not root or not Path(root).is_dir(): - self.status_label.setText("❌ Folder not found. Pick a valid game directory.") + def _ask_clear_old_files(self): + """Prompt to clear files/ and translated/ when switching games.""" + msg = QMessageBox(self) + msg.setWindowTitle("Clear Previous Translation Data?") + msg.setText( + "Do you want to clear the files/ and translated/ folders?\n\n" + "This is recommended when switching to a new game project to avoid " + "old translations conflicting with the new one." + ) + msg.setIcon(QMessageBox.Question) + msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + msg.setDefaultButton(QMessageBox.Yes) + if msg.exec_() != QMessageBox.Yes: return - self._game_root = root - self._save_setting("last_game_folder", root) - info = detect_wolf_layout(root) + + base = Path(__file__).resolve().parent.parent + cleared = 0 + errors: list[str] = [] + for folder_name in ("files", "translated"): + target = base / folder_name + if not target.is_dir(): + continue + for child in target.iterdir(): + if child.name == ".gitkeep": + continue + try: + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + cleared += 1 + except Exception as exc: + errors.append(f"{child.name}: {exc}") + if cleared: + self._log(f"🗑 Cleared {cleared} item(s) from files/ and translated/.") + else: + self._log("ℹ files/ and translated/ were already empty.") + for err in errors: + self._log(f"⚠ Could not remove {err}") + + def _detect_folder(self): + folder = self.folder_edit.text().strip() + if not folder: + self._log("⚠ No folder path entered.") + return + if not Path(folder).is_dir(): + self.detected_label.setText("❌ Folder not found. Pick a valid game directory.") + self.detected_label.setStyleSheet( + "color:#f48771;font-size:13px;padding:4px 8px;" + "background-color:#2b1a1a;border:1px solid #5a2a2a;" + "border-radius:4px;margin:4px 0;" + ) + return + + self._save_setting("last_game_folder", folder) + self._game_root = folder + self.detected_label.setText("Scanning…") + self.detected_label.setStyleSheet( + "color:#9d9d9d;font-size:13px;padding:4px 8px;" + "background-color:#252526;border:1px solid #3c3c3c;" + "border-radius:4px;margin:4px 0;" + ) + self.file_list.clear() + self._set_import_buttons_enabled(False) + self._last_import_signature = None + self._pending_import_signature = None + + info = detect_wolf_layout(folder) self._layout = info if info["engine"] != "WOLF": - self.status_label.setText( - "⚠ This does not look like a WOLF game (no .wolf archives or loose Data/ " - "with maps/CommonEvent.dat were found)." + self.detected_label.setText( + "⚠ No WOLF layout found (no .wolf archives or loose Data/ with maps/CommonEvent.dat)." ) - self._log(f"Detect: no WOLF layout found in {root}") + self.detected_label.setStyleSheet( + "color:#e9a12a;font-size:13px;padding:4px 8px;" + "background-color:#2b2010;border:1px solid #5a4010;" + "border-radius:4px;margin:4px 0;" + ) + self._log(f"Detect: no WOLF layout found in {folder}") return - archives = info["archives"] - parts = [f"✅ WOLF game detected at {root}"] - if info["unpacked"]: - parts.append(f"Unpacked Data/ folder: {info['data_dir']}") + parts = [f"Engine: WOLF · Game root: {folder}"] + if info.get("unpacked"): + parts.append(f"Data/: {info['data_dir']}") + archives = info.get("archives") or [] if archives: - parts.append(f"{len(archives)} .wolf archive(s): " + ", ".join(a.name for a in archives)) - if not info["unpacked"] and archives: - parts.append("Run ① Unpack to extract the archives before extracting text.") - self.status_label.setText("\n".join(parts)) + parts.append(f"{len(archives)} .wolf archive(s)") + self.detected_label.setText("\n".join(parts)) + self.detected_label.setStyleSheet( + "color:#6a9a6a;font-size:13px;padding:4px 8px;" + "background-color:#1f2b1f;border:1px solid #2a4a2a;" + "border-radius:4px;margin:4px 0;" + ) self._log("Detect: " + " | ".join(parts)) + self._populate_preprocess_paths() + QTimer.singleShot(50, self._ensure_wolf_json) - def _unpack(self): + def _ensure_wolf_json(self): + """Create wolf_json/ when missing (unpack + extract), then refresh the import list.""" + if not self._game_root: + return + if self._worker is not None and self._worker.isRunning(): + return + + manifest = self._read_manifest() + if manifest and manifest.get("entries"): + self._scan_wolf_files() + return + + info = self._layout or detect_wolf_layout(self._game_root) + self._layout = info + if info.get("archives") and not info.get("unpacked"): + self._log("Auto-setup: unpacking .wolf archives …") + self._unpack(on_done=self._on_auto_unpack_done, interactive=False) + return + if info.get("data_dir"): + self._log("Auto-setup: extracting text into wolf_json/ …") + self._extract_to_work_dir(on_done=lambda ok, _m: self._scan_wolf_files() if ok else None) + return + self._log("⚠ No unpacked Data/ folder and no .wolf archives to unpack.") + + def _on_auto_unpack_done(self, ok: bool, _msg: str): + if not ok: + return + self._layout = detect_wolf_layout(self._game_root) + if self._layout.get("data_dir"): + self._log("Auto-setup: extracting text into wolf_json/ …") + self._extract_to_work_dir(on_done=lambda ok2, _m: self._scan_wolf_files() if ok2 else None) + + def _scan_wolf_files(self): + if not self._game_root: + return + work_dir = self._work_dir() + manifest = self._read_manifest() + if not manifest or not manifest.get("entries"): + self.file_list.clear() + self._set_import_buttons_enabled(False) + return + + items = list_wolf_json_files(work_dir, manifest) + self._file_items = items + self.file_list.clear() + for item in items: + cat = item["category"] + icon = "📄" if cat == "core" else ("🗺" if cat == "map" else "❓") + lw = QListWidgetItem(f"{icon} {item['name']} ({item['size_kb']:.1f} KB)") + lw.setData(Qt.UserRole, item) + lw.setFlags(lw.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) + lw.setCheckState(Qt.Checked if item["default"] else Qt.Unchecked) + if cat == "core": + lw.setForeground(QColor("#9cdcfe")) + elif cat == "map": + lw.setForeground(QColor("#c5c5c0")) + self.file_list.addItem(lw) + + self._set_import_buttons_enabled(bool(items)) + self._populate_preprocess_paths() + self._log(f"Found {len(items)} importable file(s) in {WORK_DIR_NAME}/.") + if items: + self._log("Checked files import into files/ when you leave Step 0 or click 📥.") + + def _select_all_files(self): + if not self.file_list.count(): + return + self._syncing_file_checks = True + try: + for i in range(self.file_list.count()): + self.file_list.item(i).setCheckState(Qt.Checked) + finally: + self._syncing_file_checks = False + + def _deselect_all_files(self): + if not self.file_list.count(): + return + self._syncing_file_checks = True + try: + for i in range(self.file_list.count()): + self.file_list.item(i).setCheckState(Qt.Unchecked) + finally: + self._syncing_file_checks = False + + def _select_core_only(self): + self._syncing_file_checks = True + try: + for i in range(self.file_list.count()): + item = self.file_list.item(i) + data = item.data(Qt.UserRole) + is_core = bool(data and data.get("category") == "core") + item.setCheckState(Qt.Checked if is_core else Qt.Unchecked) + finally: + self._syncing_file_checks = False + + def _sync_selected_file_checks(self, changed_item: QListWidgetItem): + if self._syncing_file_checks: + return + selected = self.file_list.selectedItems() + if len(selected) <= 1 or changed_item not in selected: + return + self._syncing_file_checks = True + try: + new_state = changed_item.checkState() + for item in selected: + if item is not changed_item: + item.setCheckState(new_state) + finally: + self._syncing_file_checks = False + + def _selected_import_items(self) -> list[dict]: + selected = [] + for i in range(self.file_list.count()): + lw = self.file_list.item(i) + if lw.checkState() == Qt.Checked: + selected.append(lw.data(Qt.UserRole)) + return selected + + def _import_signature(self, selected: list[dict] | None = None) -> tuple[str, ...]: + selected = selected if selected is not None else self._selected_import_items() + return tuple(sorted(str(item.get("name", "")) for item in selected if item)) + + def _auto_import_if_needed(self) -> None: + selected = self._selected_import_items() + signature = self._import_signature(selected) + if not signature: + return + if signature in (self._last_import_signature, self._pending_import_signature): + return + self._log("Auto-importing checked project files into files/ before leaving Project.") + self._import_files(confirm=False, selected=selected, signature=signature) + + def _import_files( + self, + confirm: bool = True, + selected: list[dict] | None = None, + signature: tuple[str, ...] | None = None, + ): + selected = selected if selected is not None else self._selected_import_items() + if not selected: + self._log("⚠ No files selected.") + return + + signature = signature if signature is not None else self._import_signature(selected) + if self._pending_import_signature == signature: + self._log("ℹ Import for the current selection is already running.") + return + + if confirm and not self._confirm_import_overwrite(selected): + self._log("ℹ Import cancelled; files/ was left unchanged.") + return + + self._set_import_buttons_enabled(False) + self._pending_import_signature = signature + worker = _ImportWorker(selected, "files") + worker.log.connect(self._log) + worker.done.connect(self._on_import_done) + self._import_worker = worker + worker.start() + + def _confirm_import_overwrite(self, selected: list[dict]) -> bool: + files_dir = Path("files") + existing = [ + item for item in files_dir.iterdir() + if item.name != ".gitkeep" + ] if files_dir.exists() else [] + if not existing: + return True + reply = QMessageBox.warning( + self, + "Import game files", + "Importing selected game files will delete the existing contents of files/ " + "before copying the new files.\n\n" + f"Existing items: {len(existing)}\n" + f"Selected files to import: {len(selected)}\n\n" + "Continue and overwrite files/?", + QMessageBox.Yes | QMessageBox.Cancel, + QMessageBox.Cancel, + ) + return reply == QMessageBox.Yes + + def _on_import_done(self, count: int, errors: list): + self._set_import_buttons_enabled(bool(self.file_list.count())) + if errors: + self._log(f"⚠ {len(errors)} error(s) during import:") + for e in errors[:10]: + self._log(f" {e}") + else: + self._last_import_signature = self._pending_import_signature + self._pending_import_signature = None + self._log(f"✅ Imported {count} file(s) into files/") + + def _populate_preprocess_paths(self): + game_root = self.folder_edit.text().strip() + work_dir = str(self._work_dir()) if self._game_root else "" + if hasattr(self, "pp_wolf_json_label"): + self.pp_wolf_json_label.setText(work_dir or "(no wolf_json/ yet — detect in Step 0)") + self._gameupdate_path = str(PROJECT_ROOT / "gameupdate") + if hasattr(self, "pp_gameupdate_edit"): + self.pp_gameupdate_edit.setText(self._gameupdate_path) + if hasattr(self, "pp_gameupdate_dst_label"): + self.pp_gameupdate_dst_label.setText(game_root or "(no game folder detected)") + + def _browse_gameupdate(self): + start = self.pp_gameupdate_edit.text() or self.folder_edit.text() + folder = QFileDialog.getExistingDirectory(self, "Select gameupdate folder", start) + if folder: + self.pp_gameupdate_edit.setText(folder) + + def _run_dazedformat(self): + if not self._game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + work_dir = self._work_dir() + files_dir = Path("files") + + def task(log): + from util.dazedformat import format_json_files + + total = 0 + errors: list[str] = [] + for label, d in ((f"{WORK_DIR_NAME}/", work_dir), ("files/", files_dir)): + if not Path(d).is_dir(): + continue + log(f"Formatting JSON in {label} …") + count, errs = format_json_files(d, log=log) + total += count + errors.extend(errs) + if errors: + return False, f"dazedformat: {total} formatted, {len(errors)} error(s)." + if total == 0: + return False, "No JSON files found to format." + return True, f"dazedformat: {total} file(s) formatted successfully." + + self._run_task(task) + + def _run_gameupdate(self): + src = self.pp_gameupdate_edit.text().strip() + dst = self.folder_edit.text().strip() + if not src: + self._log("⚠ No gameupdate folder path set.") + return + if not dst: + self._log("⚠ No game root folder set. Complete Step 0 first.") + return + if not Path(src).is_dir(): + self._log(f"⚠ gameupdate folder not found: {src}") + return + w = _FileCopyWorker(src, dst) + w.log.connect(self._log) + w.done.connect(self._on_gameupdate_done) + self._import_worker = w # reuse slot; not WolfTaskWorker + w.start() + + def _on_gameupdate_done(self, count: int, errors: list): + self._log(f"✅ gameupdate: copied {count} file(s).") + for e in errors: + self._log(f" ⚠ {e}") + + def _run_all_preprocess(self): + if not self._game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + + def task(log): + from util.dazedformat import format_json_files + + work_dir = self._work_dir() + files_dir = Path("files") + total = 0 + errors: list[str] = [] + for label, d in ((f"{WORK_DIR_NAME}/", work_dir), ("files/", files_dir)): + if not Path(d).is_dir(): + log(f" ⏭ Skipped dazedformat on {label} (folder missing)") + continue + log(f"[A] Formatting JSON in {label} …") + count, errs = format_json_files(d, log=log) + total += count + errors.extend(errs) + if errors: + return False, f"Pre-process: formatted {total} file(s), {len(errors)} error(s)." + + src = self.pp_gameupdate_edit.text().strip() + dst = self.folder_edit.text().strip() + if not src or not Path(src).is_dir(): + log(" ⏭ Skipped gameupdate copy (source not found)") + elif not dst: + log(" ⏭ Skipped gameupdate copy (no game root)") + else: + log("[B] Copying gameupdate/ …") + copied = 0 + for fp in sorted(Path(src).rglob("*")): + if not fp.is_file(): + continue + rel = fp.relative_to(src) + target = Path(dst) / rel + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(fp, target) + copied += 1 + except Exception as exc: + errors.append(f"{rel}: {exc}") + log(f" Copied {copied} gameupdate file(s).") + if errors: + return False, "Pre-process finished with errors (see log)." + return True, "Pre-process: dazedformat and gameupdate copy finished." + + self._run_task(task) + + def _unpack(self, on_done=None, *, interactive: bool = True): if not self._require_root(): return root = Path(self._game_root) @@ -840,7 +1394,8 @@ class WolfWorkflowTab(QWidget): archives = info["archives"] if not archives: self._log("Unpack: no .wolf archives found (already unpacked?).") - QMessageBox.information(self, "Unpack", "No .wolf archives found to unpack.") + if interactive: + QMessageBox.information(self, "Unpack", "No .wolf archives found to unpack.") return out_dir = root / "Data" @@ -850,21 +1405,24 @@ class WolfWorkflowTab(QWidget): res = wolfdawn.unpack_all([str(a) for a in archives], str(out_dir), log_fn=log) if not res.ok: return False, f"unpack-all exited {res.returncode}" - return True, "Unpacked archives. Now run ② Extract text." + return True, "Unpacked archives into Data/." - self._run_task(task, on_done=lambda ok, _m: self._detect_folder()) + self._run_task(task, on_done=on_done) - def _extract_and_import(self): + def _extract_to_work_dir(self, *, interactive: bool = True, on_done=None): if not self._require_root(): return info = detect_wolf_layout(self._game_root) self._layout = info data_dir = info.get("data_dir") if not data_dir: - QMessageBox.warning( - self, "Extract", - "No unpacked Data/ folder found. Run ① Unpack first.", - ) + if interactive: + QMessageBox.warning( + self, "Extract", + "No unpacked Data/ folder found. Run unpack first.", + ) + else: + self._log("Extract: no unpacked Data/ folder found.") return work_dir = self._work_dir() @@ -888,23 +1446,18 @@ class WolfWorkflowTab(QWidget): else: log(f" ⚠ skipped {base.name} (exit {res.returncode})") - # Common events ce = basic / "CommonEvent.dat" if ce.is_file(): _extract_one(ce, "CommonEvent.dat.json", "common") - # Databases for stem in ("DataBase", "CDataBase", "SysDatabase"): proj = basic / f"{stem}.project" if proj.is_file(): _extract_one(proj, f"{stem}.project.json", "db") - # Game.dat gd = basic / "Game.dat" if gd.is_file(): _extract_one(gd, "Game.dat.json", "gamedat") - # Maps for mps in sorted(maps_dir.glob("*.mps")): _extract_one(mps, f"{mps.name}.json", "map") - # External event text (optional) evtext = Path(data_dir) / "Evtext" if evtext.is_dir() and any(evtext.glob("*.txt")): out = work_dir / "Evtext.json" @@ -913,7 +1466,6 @@ class WolfWorkflowTab(QWidget): if res.ok and out.is_file(): manifest_entries.append({"json": "Evtext.json", "base": str(evtext), "kind": "txt-dir"}) - # Project-wide name glossary log("Extracting name glossary …") names_out = work_dir / NAMES_JSON res = wolfdawn.names_extract(str(data_dir), str(names_out), log_fn=log) @@ -931,89 +1483,22 @@ class WolfWorkflowTab(QWidget): (work_dir / MANIFEST_NAME).write_text( 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/. - files_dir = Path("files") - files_dir.mkdir(exist_ok=True) - removed = 0 - for fp in files_dir.iterdir(): - if fp.name == ".gitkeep": - continue - try: - if fp.is_file(): - fp.unlink() - removed += 1 - elif fp.is_dir(): - shutil.rmtree(fp) - removed += 1 - except Exception as e: - log(f" ⚠ could not clear {fp.name}: {e}") - if removed: - log(f"Cleared {removed} existing file(s) from files/") - - # Stage everything into files/. names.json is included so Phase 0 can - # translate it, but the WolfDawn module only translates the note - # categories marked safe in Step 2 (Names). - copied = 0 - for entry in manifest_entries: - src = work_dir / entry["json"] - if src.is_file(): - shutil.copy2(src, files_dir / entry["json"]) - copied += 1 - return True, ( - f"Extracted {len(manifest_entries)} document(s) and imported {copied} into files/. " - "Curate names in Step 2, then translate in Step 3." + f"Extracted {len(manifest_entries)} document(s) into {WORK_DIR_NAME}/. " + "Import checked files into files/ from Step 0." ) - self._run_task(task) + self._run_task(task, on_done=on_done) - def _format_json(self): - if not self._require_root(): - return - work_dir = self._work_dir() - files_dir = Path("files") - if not work_dir.is_dir() and not files_dir.is_dir(): - QMessageBox.information( - self, "Format JSON", - "Nothing to format yet. Run ② Extract text first.", - ) - return - - def task(log): - from util.dazedformat import format_json_files - - total = 0 - errors: list[str] = [] - for label, d in (("wolf_json/", work_dir), ("files/", files_dir)): - if not Path(d).is_dir(): - continue - log(f"Formatting JSON in {label} …") - count, errs = format_json_files(d, log=log) - total += count - errors.extend(errs) - if errors: - return False, f"Formatted {total} file(s), {len(errors)} error(s) — see log." - return True, ( - f"Formatted {total} JSON file(s). Commit this as your baseline before " - "translating so injects give clean git diffs." - ) - - self._run_task(task) - - # ── Step 1: Glossary ─────────────────────────────────────────────────────── + # ── Step 2: Glossary ─────────────────────────────────────────────────────── def _build_step1_glossary(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 1 · Glossary (build before translating)")) + layout.addWidget(_make_section_label("Step 2 · Glossary (build before translating)")) layout.addWidget(self._desc( "vocab.txt is the project-wide glossary used by every translation batch to keep " "character names, speech register, honorifics, and lore terms consistent. Build it " - "before translating (Step 3) so the AI already knows every character and term. " + "before translating (Step 4) so the AI already knows every character and term. " "Copy the prompt below into Cursor or Copilot Chat with the extracted files/ JSON " "open, let it analyse the game, then paste its output into the editor and save." )) @@ -1055,7 +1540,7 @@ class WolfWorkflowTab(QWidget): note = self._desc( "You don't need to add item / skill / enemy value names here: they're curated in " - "Step 2 and the safe ones are harvested into vocab.txt automatically during Phase 0. " + "Step 3 and the safe ones are harvested into vocab.txt automatically during Phase 0. " "Focus this glossary on characters and worldbuilding terms." ) layout.addWidget(note) @@ -1083,7 +1568,7 @@ class WolfWorkflowTab(QWidget): # ── Step 3: Translate (phased) ───────────────────────────────────────────── def _build_step2_translate(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 3 · Translate (phased)")) + layout.addWidget(_make_section_label("Step 4 · Translate (phased)")) layout.addWidget(self._desc( "Sends the extracted files in files/ to the Translation tab using the Wolf RPG " "(WolfDawn) module. Only the 'text' fields are filled in; 'source' is preserved so " @@ -1099,9 +1584,9 @@ class WolfWorkflowTab(QWidget): layout.addWidget(_make_hr()) layout.addWidget(self._subheading("Phase 0 · Names → vocab.txt")) layout.addWidget(self._desc( - "Translates only the safe name categories you approved in Step 2 and harvests them into " + "Translates only the safe name categories you approved in Step 3 and harvests them into " "vocab.txt (grouped by category, e.g. \"Weapon · 武器\"). Curate safe categories and pick " - "the translation mode in Step 2 first, or nothing is translated. Also runnable from Step 2." + "the translation mode in Step 3 first, or nothing is translated. Also runnable from Step 3." )) p0 = self._register(_make_btn("▶ Phase 0 · Translate names", "#00a86b")) p0.clicked.connect( @@ -1392,7 +1877,7 @@ class WolfWorkflowTab(QWidget): # ── Step 2: Names ────────────────────────────────────────────────────────── def _build_step3_names(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 2 · Curate Name Values (names.json)")) + layout.addWidget(_make_section_label("Step 3 · Curate Name Values (names.json)")) layout.addWidget(self._desc( "names.json lists every item / skill / enemy / variable value name WolfDawn found. " "Translating all of them WILL break the game: many double as logic keys (compared by " @@ -1469,7 +1954,7 @@ class WolfWorkflowTab(QWidget): "other name (leaving it identical to the source) and harvests the translated names into " "vocab.txt (grouped by category) so the later Translate phases stay consistent. This is " "the first translation step - pick Normal or Batch below, then run Phase 0 here or from " - "Step 3 (the same mode applies to all phases)." + "Step 4 (the same mode applies to all phases)." )) self._add_tl_mode_selector(layout) tl_btn = self._register(_make_btn("Translate safe names now (Phase 0)", "#00a86b")) @@ -1598,7 +2083,7 @@ class WolfWorkflowTab(QWidget): wolf_names.save_safe_notes(safe) self._log( f"✅ Saved {len(safe)} safe categor(y/ies) to data/wolf_safe_notes.json. " - "Translate the safe names below, then inject in Step 4." + "Translate the safe names below, then inject in Step 5." ) except Exception as exc: self._log(f"❌ Could not save safe categories: {exc}") @@ -1606,10 +2091,10 @@ class WolfWorkflowTab(QWidget): # ── Step 4: Inject ───────────────────────────────────────────────────────── def _build_step4_inject(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 4 · Inject Translations")) + layout.addWidget(_make_section_label("Step 5 · Inject Translations")) layout.addWidget(self._desc( "Writes the translated text back into the game's Data/ binaries with WolfDawn, " - "byte-exact. The curated name values from Step 2 are applied across Data/ (only the " + "byte-exact. The curated name values from Step 3 are applied across Data/ (only the " "entries you translated change). Lines whose inline codes changed are skipped and " "reported (unless you allow drift). Full inject and any inject that includes " f"{NAMES_JSON} reset live Data/ from {WORK_DIR_NAME}/originals/ first." @@ -1641,7 +2126,7 @@ class WolfWorkflowTab(QWidget): "For iterating: after translating a few files, tick just those here and inject them " "straight into the game's Data/ so you can review the git diff in the game project and " "test in-game. Only files that already have a translation in translated/ are listed. " - f"{NAMES_JSON} appears here once you've curated and saved it in Step 2; tick it to " + f"{NAMES_JSON} appears here once you've curated and saved it in Step 3; tick it to " "apply the safe name values across Data/." )) @@ -1675,8 +2160,8 @@ class WolfWorkflowTab(QWidget): layout.addWidget(_make_hr()) layout.addWidget(self._subheading("Full inject")) layout.addWidget(self._desc( - "Injects every extracted document and applies the curated name values (Step 2) " - "across Data/. Use this once translation is complete before packaging in Step 5." + "Injects every extracted document and applies the curated name values (Step 3) " + "across Data/. Use this once translation is complete before packaging in Step 6." )) inject_btn = self._register(_make_btn("Inject all translations", "#00a86b")) inject_btn.clicked.connect(lambda: self._inject()) @@ -1691,11 +2176,15 @@ class WolfWorkflowTab(QWidget): return None def _on_step_changed(self, idx: int): - # Index 2 == Names: refresh the note-category checklist from names.json. - if idx == 2: + previous = self._current_step_index + self._current_step_index = idx + if previous == 0 and idx != 0: + self._auto_import_if_needed() + # Index 3 == Names: refresh the note-category checklist from names.json. + if idx == 3: self._reload_notes_list() - # Index 4 == Inject: keep the quick-inject picker in sync with translated/. - elif idx == 4: + # Index 5 == Inject: keep the quick-inject picker in sync with translated/. + elif idx == 5: self._refresh_inject_list() def _refresh_inject_list(self): @@ -1722,7 +2211,7 @@ class WolfWorkflowTab(QWidget): self.inject_list.addItem(item) listed += 1 if listed == 0: - item = QListWidgetItem("No translated files in translated/ yet — run Step 3.") + item = QListWidgetItem("No translated files in translated/ yet — run Step 4.") item.setFlags(Qt.ItemIsEnabled) self.inject_list.addItem(item) @@ -1768,7 +2257,7 @@ class WolfWorkflowTab(QWidget): if p: json_files.append(str(p)) if not json_files: - return False, "No translated JSON found. Run Step 3 first." + return False, "No translated JSON found. Run Step 4 first." res = wolfdawn.names_check(json_files, log_fn=log) if res.returncode == 0: return True, "Name usage is consistent across files." @@ -1785,7 +2274,7 @@ class WolfWorkflowTab(QWidget): are applied across Data/ only when NAMES_JSON is included. Name values are applied only when translated/names.json exists (i.e. the - user curated it in Step 2); otherwise names are left untouched. + user curated it in Step 3); otherwise names are left untouched. """ if not self._require_manifest(): return @@ -1894,7 +2383,7 @@ class WolfWorkflowTab(QWidget): log(f" ⚠ inject exit {res.returncode} for {entry['json']}") # Curated name values: apply across the whole data dir, but only when a - # curated translated/names.json exists (Step 2). For a quick inject, only + # curated translated/names.json exists (Step 3). For a quick inject, only # do this if the user explicitly ticked the names file, since it rewrites # many binaries and would add noise to the git diff otherwise. if will_names and names_entry and names_src is not None: @@ -1924,7 +2413,7 @@ class WolfWorkflowTab(QWidget): if quick: msg += ". Review the git diff in the game project and test in-game." else: - msg += ". Continue to Step 5 to package." + msg += ". Continue to Step 6 to package." return failed == 0, msg self._run_task(task) @@ -1932,7 +2421,7 @@ class WolfWorkflowTab(QWidget): # ── Step 5: Package ──────────────────────────────────────────────────────── def _build_step5_package(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 5 · Package the Translated Game")) + layout.addWidget(_make_section_label("Step 6 · Package the Translated Game")) layout.addWidget(self._desc( "Choose how the translated build runs. A loose Data/ folder is simplest for " "playtesting; repacking rebuilds a single Data.wolf archive for distribution." @@ -1956,46 +2445,6 @@ class WolfWorkflowTab(QWidget): "archive's encryption where possible (--like the backed-up original)." )) - def _apply_gameupdate(self): - if not self._require_root(): - return - from util.paths import PROJECT_ROOT - - src = PROJECT_ROOT / "gameupdate" - dst = Path(self._game_root) - if not src.is_dir(): - QMessageBox.warning( - self, "gameupdate", - f"The tool's gameupdate/ folder was not found at {src}.", - ) - return - - def task(log): - import shutil - - copied = 0 - errors: list[str] = [] - log(f"Copying gameupdate patch files: {src} → {dst} …") - for fp in sorted(src.rglob("*")): - if not fp.is_file(): - continue - rel = fp.relative_to(src) - target = dst / rel - try: - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(fp, target) - copied += 1 - log(f" copied {rel}") - except Exception as exc: - errors.append(f"{rel}: {exc}") - for e in errors: - log(f" ⚠ {e}") - if errors: - return False, f"Copied {copied} file(s); {len(errors)} failed (see log)." - return True, f"Copied {copied} gameupdate file(s) into {dst.name}." - - self._run_task(task) - def _package_loose(self): if not self._require_root(): return @@ -2050,7 +2499,7 @@ class WolfWorkflowTab(QWidget): # ── Step 6: Saves ────────────────────────────────────────────────────────── def _build_step6_saves(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 6 · Update Existing Saves (optional)")) + layout.addWidget(_make_section_label("Step 7 · Update Existing Saves (optional)")) layout.addWidget(self._desc( "WOLF .sav files bake in the game title and some strings at save time. This rewrites " "them so old Japanese saves load cleanly in the translated build. It is only needed " diff --git a/util/project_scanner.py b/util/project_scanner.py index ab1d127..c38584f 100644 --- a/util/project_scanner.py +++ b/util/project_scanner.py @@ -181,6 +181,77 @@ def detect_wolf_layout(game_root: str | Path) -> dict: return result +_WOLF_CORE_JSON = frozenset({ + "names.json", + "CommonEvent.dat.json", + "Game.dat.json", + "DataBase.project.json", + "CDataBase.project.json", + "SysDatabase.project.json", + "Evtext.json", +}) + +_WOLF_CORE_KINDS = frozenset({"names", "common", "db", "gamedat", "txt-dir"}) + + +def list_wolf_json_files( + work_dir: str | Path, + manifest: dict | None = None, +) -> list[dict]: + """Return importable WolfDawn JSON descriptors from a game's wolf_json/ folder. + + Each item matches :func:`list_data_files` (name, path, size_kb, category, default). + Uses ``manifest.json`` entries when present; otherwise scans ``*.json`` in *work_dir*. + """ + work_dir = Path(work_dir) + rows: list[tuple[str, Path, str]] = [] + + if manifest and manifest.get("entries"): + for entry in manifest["entries"]: + json_name = entry.get("json") + if not json_name: + continue + fp = work_dir / json_name + if fp.is_file(): + rows.append((json_name, fp, str(entry.get("kind") or ""))) + elif work_dir.is_dir(): + for fp in sorted(work_dir.glob("*.json")): + if fp.name == "manifest.json": + continue + rows.append((fp.name, fp, "")) + + results: list[dict] = [] + for name, fp, kind in rows: + size_kb = fp.stat().st_size / 1024 + if name in _WOLF_CORE_JSON or kind in _WOLF_CORE_KINDS: + cat = "core" + default = True + elif name.endswith(".mps.json") or kind == "map": + cat = "map" + default = True + else: + cat = "other" + default = False + results.append({ + "name": name, + "path": fp, + "size_kb": round(size_kb, 1), + "category": cat, + "default": default, + }) + + def _sort_key(item): + if item["category"] == "core": + return (0, item["name"]) + if item["category"] == "map": + stem = item["name"].removesuffix(".mps.json") + return (1, stem) + return (2, item["name"]) + + results.sort(key=_sort_key) + return results + + def list_data_files(data_path: str | Path, engine: str = "MVMZ") -> list[dict]: """Return a sorted list of importable file descriptors.