Compare commits

...

6 commits

Author SHA1 Message Date
8f744fafbb inject from wolf should sync both 2026-07-09 17:30:33 -05:00
8f5e62ac75 Wolf Game Update Patcher 2026-07-09 17:22:15 -05:00
69f7ca7316 more buttons 2026-07-09 17:02:13 -05:00
8fb9841eec Clean up UI 2026-07-09 16:55:44 -05:00
7fd0bd648b step 9 compact 2026-07-09 16:50:21 -05:00
4abb10df15 Pass 1 should TL Speakers 2026-07-09 16:20:19 -05:00
14 changed files with 1231 additions and 171 deletions

View file

@ -325,7 +325,7 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| 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 7. |
| **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. |
| **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, `.gitignore`, and `UberWolfCli.exe` into the game root for git-based patching (players auto-unpack `Data.wolf` on first GameUpdate). 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 translated in Step 3 and harvested into `vocab.txt` automatically during Phase 0 — do not list them here. |
| **3 Names** | Translate `names.json` (item/skill/enemy/map value names). WolfDawn tags each name with a per-entry **safety** badge (`safe`, `refs`, or `verify`). Phase 0 translates only `safe` entries; `refs` and `verify` names stay Japanese so inject skips them. Review the category breakdown for this game, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. |
| **4 Database** | Review the **discovery summary** to see where this game's text lives (standard RPG sheets vs custom dialogue tables). Database sheets are classified as foundation (items, skills, descriptions — translate first) or narrative (custom event/profile sheets — translate after foundation). Use **Translate foundation DB** then **Translate narrative DB**, or tick specific sheets and run **Translate checked sheets only**. Optional: copy the **DB structure prompt** for an AI audit and import the returned JSON into `wolf_json/db_profile.json`. |

View file

@ -21,6 +21,7 @@
!.gitignore
!README.md
!patch-config.txt
!patch-config.example.txt
!GameUpdate*
!patch*
!Game.dat
@ -29,6 +30,8 @@
!package.nw
!SRPG_Unpacker.exe
!(SRPG_Unpacker Patcher).bat
!UberWolfCli.exe
!UberWolfCli.LICENSE.txt
!Data/BasicData/CommonEvent.dat
!Data/BasicData/DataBase.dat
!Data/BasicData/CDataBase.dat

View file

@ -17,22 +17,67 @@
2. Optional: set `GAMEUPDATE_PROMPT_PWSH=1` before running `GameUpdate.bat` if you want users to be prompted to install PowerShell 7 via winget.
3. Optional: set `GAMEUPDATE_DL_ATTEMPTS` (default `2`) to control retries for API checks/downloads. Lower values fail faster; higher values tolerate flaky networks.
### patch-config.txt
Create `gameupdate/patch-config.txt` next to `patch.ps1` (see `patch-config.example.txt`).
In DazedMTLTool, set **Config → Game Update Defaults** (forge, host, org/username, branch) once. Step 1 **Copy gameupdate/** writes those into each game's `patch-config.txt`. You still set `repo=` per game.
```txt
forge=gitlab
host=gitgud.io
username=YOUR_ORG_OR_USER
repo=YOUR_PATCH_REPO
branch=main
```
| Key | Meaning |
|-----|---------|
| `forge` | `gitlab` (default), `github`, or `forgejo` (`gitea` also accepted) |
| `host` | Hostname only. Defaults: `gitgud.io` / `github.com` / `codeberg.org` |
| `username` | Owner / org / group (`owner=` / `org=` aliases work) |
| `repo` | Repository name |
| `branch` | Branch to track |
Examples:
```txt
# GitLab / gitgud
forge=gitlab
host=gitgud.io
username=myorg
repo=cool-game-en
branch=main
# GitHub
forge=github
host=github.com
username=myorg
repo=cool-game-en
branch=main
# Forgejo / Codeberg
forge=forgejo
host=codeberg.org
username=myorg
repo=cool-game-en
branch=main
```
Older configs with only `username` / `repo` / `branch` still work (assumes GitLab on `gitgud.io`).
# Troubleshooting
**GAMEUPDATE.bat doesn't update and closes immediately**
1. Make sure your path doesn't contain any Japanese characters or lots of whitespace.
2. Make sure you actually have permissions in the folder
3. Auto-update calls GitLabs public HTTP API (`/api/v4/...`), not the web “Download ZIP” URL—no account or token is required for public patch repos.
For WOLF RPG games, if you downloaded the game off of DLSite, you will need to do some extra steps to patch it. This is because there is a "master" file called Data.wolf that will take priority over the english patch files. You will need this file to be a folder before patching will work.
3. Auto-update calls the forge's public HTTP API (GitLab `/api/v4`, GitHub `/repos/...`, Forgejo `/api/v1`), not the web “Download ZIP” button - no account is required for public patch repos.
# Wolf Games
1) Download the latest UberWolf.exe release from the following link:
https://github.com/Sinflower/UberWolf/releases
2) Drag Data.wolf onto UberWolf.exe. This will create a new folder called data.wolf~
3) Rename the new data.wolf~ folder to Data
4) Delete the Data.wolf file
5) Delete previous_patch_sha.txt (this will exist if you ran GameUpdate.bat previously)
6) Run GameUpdate.bat
WOLF RPG installs from DLSite ship a master `Data.wolf` archive that takes priority over loose English patch files. `GameUpdate.bat` detects that and unpacks it automatically with the bundled `UberWolfCli.exe` (MIT, [Sinflower/UberWolf](https://github.com/Sinflower/UberWolf)), then renames `Data.wolf` to `Data.wolf.bak` so the patched loose `Data/` files load.
Just run **`GameUpdate.bat`**. No manual UberWolf download is required.
If unpack fails (rare Pro / protected builds), unpack once with [UberWolf](https://github.com/Sinflower/UberWolf/releases), ensure a loose `Data/` folder exists, rename or remove `Data.wolf`, delete `gameupdate/previous_patch_sha.txt`, and run `GameUpdate.bat` again.
# Edit/Contribute
TLDR 3 steps.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Sinflower
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

BIN
gameupdate/UberWolfCli.exe Normal file

Binary file not shown.

View file

@ -0,0 +1,28 @@
# GameUpdate patch source (copy to patch-config.txt and edit).
# Lines starting with # are comments. Keys are case-insensitive.
#
# Tip: set forge / host / username / branch once in DazedMTLTool
# Config → Game Update Defaults. Step 1 "Copy gameupdate/" writes
# those into this file automatically (you still set repo= per game).
# Where the patch repo lives:
# gitlab - GitLab / gitgud.io / self-hosted GitLab (default)
# github - GitHub.com or GitHub Enterprise
# forgejo - Forgejo (also accepts gitea)
forge=gitlab
# Host only (no https://). Defaults:
# gitlab -> gitgud.io
# github -> github.com
# forgejo -> codeberg.org
# Examples: gitgud.io gitlab.com github.com codeberg.org git.example.com
host=gitgud.io
# Owner / org / group that owns the repo (set in Config; rarely changes)
username=YOUR_ORG_OR_USER
# Repository name (per game)
repo=YOUR_PATCH_REPO
# Branch to track
branch=main

View file

@ -194,7 +194,13 @@ function Test-WrongWorkingDirectory {
function Read-PatchConfig {
param([string]$ConfigPath)
$cfg = @{ username = ''; repo = ''; branch = '' }
$cfg = @{
forge = 'gitlab'
host = ''
username = ''
repo = ''
branch = ''
}
if (-not (Test-Path -LiteralPath $ConfigPath)) {
return $cfg
}
@ -206,7 +212,12 @@ function Read-PatchConfig {
$k = $line.Substring(0, $eq).Trim().ToLowerInvariant()
$v = if ($eq -lt $line.Length - 1) { $line.Substring($eq + 1).Trim() } else { '' }
switch ($k) {
'forge' { $cfg.forge = $v }
'provider' { $cfg.forge = $v }
'host' { $cfg.host = $v }
'username' { $cfg.username = $v }
'owner' { $cfg.username = $v }
'org' { $cfg.username = $v }
'repo' { $cfg.repo = $v }
'branch' { $cfg.branch = $v }
}
@ -214,6 +225,115 @@ function Read-PatchConfig {
return $cfg
}
function Resolve-PatchForge {
param([Parameter(Mandatory = $true)][hashtable]$Cfg)
$raw = ([string]$Cfg.forge).Trim().ToLowerInvariant()
if ([string]::IsNullOrWhiteSpace($raw)) { $raw = 'gitlab' }
$kind = switch -Regex ($raw) {
'^(gitlab|gl|gitgud)$' { 'gitlab' }
'^(github|gh)$' { 'github' }
'^(forgejo|gitea|fj|codeberg)$' { 'forgejo' }
default { $null }
}
if (-not $kind) {
throw ("PATCH_ERR:CONFIG:Unknown forge='{0}'. Use gitlab, github, or forgejo." -f $Cfg.forge)
}
$hostName = ([string]$Cfg.host).Trim()
$hostName = $hostName -replace '^https?://', ''
$hostName = $hostName.TrimEnd('/')
if ($hostName.Contains('/')) {
$hostName = $hostName.Split('/')[0]
}
if ([string]::IsNullOrWhiteSpace($hostName)) {
$hostName = switch ($kind) {
'gitlab' { 'gitgud.io' }
'github' { 'github.com' }
'forgejo' { 'codeberg.org' }
}
}
$webBase = "https://$hostName"
$apiBase = switch ($kind) {
'gitlab' { "$webBase/api/v4" }
'github' {
if ($hostName -eq 'github.com') { 'https://api.github.com' }
else { "$webBase/api/v3" }
}
'forgejo' { "$webBase/api/v1" }
}
return @{
kind = $kind
host = $hostName
webBase = $webBase
apiBase = $apiBase
username = ([string]$Cfg.username).Trim()
repo = ([string]$Cfg.repo).Trim()
branch = ([string]$Cfg.branch).Trim()
}
}
function New-PatchApiHeaders {
param([Parameter(Mandatory = $true)][hashtable]$Resolved)
$headers = @{ 'User-Agent' = 'DazedMTL-Patcher-1.0' }
if ($Resolved.kind -eq 'github') {
$headers['Accept'] = 'application/vnd.github+json'
}
return $headers
}
function Get-PatchLatestSha {
param(
[Parameter(Mandatory = $true)][hashtable]$Resolved,
[Parameter(Mandatory = $true)][hashtable]$Headers
)
$ownerEnc = [uri]::EscapeDataString($Resolved.username)
$repoEnc = [uri]::EscapeDataString($Resolved.repo)
$branchEnc = [uri]::EscapeDataString($Resolved.branch)
switch ($Resolved.kind) {
'gitlab' {
$id = [uri]::EscapeDataString($Resolved.username + '/' + $Resolved.repo)
$uri = "$($Resolved.apiBase)/projects/$id/repository/branches/$branchEnc"
return [string](Invoke-RestMethod -Uri $uri -Headers $Headers).commit.id
}
'github' {
$uri = "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/commits/$branchEnc"
return [string](Invoke-RestMethod -Uri $uri -Headers $Headers).sha
}
'forgejo' {
$uri = "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/branches/$branchEnc"
return [string](Invoke-RestMethod -Uri $uri -Headers $Headers).commit.id
}
}
}
function Get-PatchArchiveUrl {
param(
[Parameter(Mandatory = $true)][hashtable]$Resolved,
[Parameter(Mandatory = $true)][string]$Sha
)
$ownerEnc = [uri]::EscapeDataString($Resolved.username)
$repoEnc = [uri]::EscapeDataString($Resolved.repo)
$shaEnc = [uri]::EscapeDataString($Sha)
switch ($Resolved.kind) {
'gitlab' {
$id = [uri]::EscapeDataString($Resolved.username + '/' + $Resolved.repo)
return "$($Resolved.apiBase)/projects/$id/repository/archive.zip?sha=$shaEnc"
}
'github' {
return "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/zipball/$shaEnc"
}
'forgejo' {
return "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/archive/$shaEnc.zip"
}
}
}
function Invoke-SrpgUnpacker {
param(
[Parameter(Mandatory = $true)][string]$ExePath,
@ -230,6 +350,140 @@ function Invoke-SrpgUnpacker {
}
}
function Test-WolfLooseDataReady {
param([Parameter(Mandatory = $true)][string]$DataDir)
if (-not (Test-Path -LiteralPath $DataDir -PathType Container)) {
return $false
}
$markers = @(
(Join-Path $DataDir 'BasicData\CommonEvent.dat'),
(Join-Path $DataDir 'CommonEvent.dat')
)
foreach ($marker in $markers) {
if (Test-Path -LiteralPath $marker -PathType Leaf) {
return $true
}
}
if (@(Get-ChildItem -LiteralPath $DataDir -Filter '*.mps' -File -ErrorAction SilentlyContinue).Count -gt 0) {
return $true
}
$basic = Join-Path $DataDir 'BasicData'
if (Test-Path -LiteralPath $basic -PathType Container) {
if (@(Get-ChildItem -LiteralPath $basic -Filter '*.dat' -File -ErrorAction SilentlyContinue).Count -gt 0) {
return $true
}
}
return $false
}
function Find-UberWolfCli {
param([Parameter(Mandatory = $true)][string]$Root)
$candidates = @(
(Join-Path $Root 'UberWolfCli.exe'),
(Join-Path $Root 'gameupdate\UberWolfCli.exe')
)
foreach ($path in $candidates) {
if (Test-Path -LiteralPath $path -PathType Leaf) {
return $path
}
}
return $null
}
function Invoke-WolfPreSetup {
<#
WOLF installs often ship Data.wolf, which overrides loose patched Data/ files.
Unpack once with UberWolfCli (bundled in the patch repo), then retire Data.wolf
so GameUpdate can copy injected .mps/.dat on top.
#>
param([Parameter(Mandatory = $true)][string]$Root)
$dataWolf = $null
foreach ($name in @('Data.wolf', 'data.wolf')) {
$candidate = Join-Path $Root $name
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
$dataWolf = $candidate
break
}
}
if (-not $dataWolf) {
return
}
$dataDir = Join-Path $Root 'Data'
if (-not (Test-Path -LiteralPath $dataDir -PathType Container)) {
$dataDirAlt = Join-Path $Root 'data'
if (Test-Path -LiteralPath $dataDirAlt -PathType Container) {
$dataDir = $dataDirAlt
}
}
if (Test-WolfLooseDataReady -DataDir $dataDir) {
$bak = "$dataWolf.bak"
if (-not (Test-Path -LiteralPath $bak)) {
Write-Host '[Pre-Setup] Loose Data/ already present; retiring Data.wolf -> Data.wolf.bak'
Rename-Item -LiteralPath $dataWolf -NewName ([IO.Path]::GetFileName($bak)) -Force
}
else {
Write-Host '[Pre-Setup] Loose Data/ already present; removing leftover Data.wolf'
Remove-Item -LiteralPath $dataWolf -Force
}
return
}
$cli = Find-UberWolfCli -Root $Root
if (-not $cli) {
Write-Host '[Pre-Setup] Data.wolf found but UberWolfCli.exe is missing.'
Write-Host ' Copy UberWolfCli.exe into the game root (Step 1 gameupdate/) and re-run.'
return
}
Write-Host '[Pre-Setup] Unpacking Data.wolf with UberWolfCli (one-time)...'
$code = Invoke-SrpgUnpacker -ExePath $cli -WorkingDirectory $Root -Arguments @('-o', $dataWolf)
if ($code -ne 0) {
Write-Host ('[Pre-Setup] ERROR: UberWolfCli exited with code {0}.' -f $code)
return
}
# UberWolf writes next to the archive as <stem>/ (Data/). Also accept data.wolf~ leftovers.
$unpacked = Join-Path $Root 'Data'
if (-not (Test-Path -LiteralPath $unpacked -PathType Container)) {
$unpacked = Join-Path $Root 'data'
}
if (-not (Test-WolfLooseDataReady -DataDir $unpacked)) {
foreach ($alt in @('data.wolf~', 'Data.wolf~', 'Data~', 'data~')) {
$altPath = Join-Path $Root $alt
if (Test-Path -LiteralPath $altPath -PathType Container) {
Write-Host ("[Pre-Setup] Promoting {0} -> Data\" -f $alt)
if (Test-Path -LiteralPath (Join-Path $Root 'Data')) {
Remove-FileOrDirectoryViaCmd -LiteralPath (Join-Path $Root 'Data')
}
Rename-Item -LiteralPath $altPath -NewName 'Data' -Force
$unpacked = Join-Path $Root 'Data'
break
}
}
}
if (-not (Test-WolfLooseDataReady -DataDir $unpacked)) {
Write-Host '[Pre-Setup] ERROR: Unpack finished but loose Data/ still looks empty.'
return
}
$bak = "$dataWolf.bak"
if (Test-Path -LiteralPath $dataWolf -PathType Leaf) {
if (-not (Test-Path -LiteralPath $bak)) {
Write-Host '[Pre-Setup] Retiring Data.wolf -> Data.wolf.bak'
Rename-Item -LiteralPath $dataWolf -NewName ([IO.Path]::GetFileName($bak)) -Force
}
else {
Write-Host '[Pre-Setup] Removing Data.wolf so loose Data/ takes priority'
Remove-Item -LiteralPath $dataWolf -Force
}
}
Write-Host '[Pre-Setup] Wolf Data/ is ready for patching.'
}
function Invoke-SrpgPreSetup {
param([string]$Root)
$unpacker = Join-Path $Root 'SRPG_Unpacker.exe'
@ -512,9 +766,7 @@ function Invoke-PatchDownloadExtract {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$StateFilePath,
[Parameter(Mandatory = $true)][string]$Username,
[Parameter(Mandatory = $true)][string]$Repo,
[Parameter(Mandatory = $true)][string]$Branch
[Parameter(Mandatory = $true)][hashtable]$Resolved
)
if ($PSVersionTable.PSEdition -ne 'Core') {
@ -526,13 +778,11 @@ function Invoke-PatchDownloadExtract {
Set-Location -LiteralPath $Root
$id = [uri]::EscapeDataString($Username + '/' + $Repo)
$branchEnc = [uri]::EscapeDataString($Branch)
$headers = @{ 'User-Agent' = 'DazedMTL-Patcher-1.0' }
$headers = New-PatchApiHeaders -Resolved $Resolved
$dlAttempts = Get-EnvInt -Name 'GAMEUPDATE_DL_ATTEMPTS' -Default 2
$latestSha = Invoke-WithRetry -Name 'Resolve latest patch SHA' -Attempts $dlAttempts -Action {
(Invoke-RestMethod -Uri ("https://gitgud.io/api/v4/projects/$id/repository/branches/$branchEnc") -Headers $headers).commit.id
Get-PatchLatestSha -Resolved $Resolved -Headers $headers
}
if (-not $latestSha) { throw 'PATCH_ERR:API:Latest commit SHA response was empty.' }
$latestSha = ([string]$latestSha).Trim()
@ -551,8 +801,7 @@ function Invoke-PatchDownloadExtract {
$zipPath = Join-Path $Root ('dazedmtl_patch_' + [Guid]::NewGuid().ToString('N') + '.zip')
Write-Host ('Downloading patch... ' + $zipPath)
$stage = Join-Path ([IO.Path]::GetTempPath()) ('gu_' + [Guid]::NewGuid().ToString('N'))
$archiveSha = [uri]::EscapeDataString($latestSha)
$archiveUrl = "https://gitgud.io/api/v4/projects/$id/repository/archive.zip?sha=$archiveSha"
$archiveUrl = Get-PatchArchiveUrl -Resolved $Resolved -Sha $latestSha
$dlSw = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WithRetry -Name 'Download archive with Invoke-WebRequest' -Attempts $dlAttempts -Action {
@ -599,7 +848,21 @@ function Invoke-PatchDownloadExtract {
if ($dirs.Count -ne 1) {
throw ('PATCH_ERR:ZIP:Expected one root folder in archive, found {0}.' -f $dirs.Count)
}
Copy-Item -Path (Join-Path $dirs[0].FullName '*') -Destination $PWD.Path -Recurse -Force
$stagedRoot = $dirs[0].FullName
# Stage UberWolfCli into the game root before unpack so first-time
# players can convert Data.wolf -> Data/ before English files land.
foreach ($cliName in @('UberWolfCli.exe', 'UberWolfCli.LICENSE.txt')) {
$stagedCli = Join-Path $stagedRoot $cliName
$destCli = Join-Path $Root $cliName
if ((Test-Path -LiteralPath $stagedCli -PathType Leaf) -and
-not (Test-Path -LiteralPath $destCli -PathType Leaf)) {
Copy-Item -LiteralPath $stagedCli -Destination $destCli -Force
}
}
Invoke-WolfPreSetup -Root $Root
Copy-Item -Path (Join-Path $stagedRoot '*') -Destination $PWD.Path -Recurse -Force
}
finally {
if (Test-Path -LiteralPath $zipPath) {
@ -717,8 +980,19 @@ try {
exit 1
}
Write-Host ('Pulling patch from https://gitgud.io/{0}/{1} (branch: {2})' -f $cfg.username, $cfg.repo, $cfg.branch)
try {
$resolved = Resolve-PatchForge -Cfg $cfg
}
catch {
Write-Host $_.Exception.Message
Wait-ConsolePause
exit 1
}
Write-Host ('Pulling patch from {0}/{1}/{2} ({3}, branch: {4})' -f `
$resolved.webBase, $resolved.username, $resolved.repo, $resolved.kind, $resolved.branch)
Invoke-WolfPreSetup -Root $GameRoot
Invoke-SrpgPreSetup -Root $GameRoot
$stateFile = Join-Path $PatchBundleRoot 'previous_patch_sha.txt'
@ -726,7 +1000,7 @@ try {
$patchDlExit = 1
try {
$patchDlExit = Invoke-PatchDownloadExtract -Root $GameRoot -StateFilePath $stateFile `
-Username $cfg.username -Repo $cfg.repo -Branch $cfg.branch
-Resolved $resolved
}
catch {
Write-Host ''
@ -753,6 +1027,9 @@ try {
Write-Host 'Applying patch...'
Invoke-SrpgPostApply -Root $GameRoot
# Safety: if Data/ is ready (from unpack or patch files) but Data.wolf
# remains, retire it so the archive does not override loose English files.
Invoke-WolfPreSetup -Root $GameRoot
Write-Host 'Cleaning up...'
Remove-PatchTempArtifacts -Root $GameRoot

View file

@ -6,7 +6,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(pwd)"
CONFIG_FILE="$SCRIPT_DIR/patch-config.txt"
STATE_FILE="$SCRIPT_DIR/previous_patch_sha.txt"
GITGUD_API="https://gitgud.io/api/v4"
API_HEADERS=( -H "User-Agent: GameUpdate/1.0" )
check_dependency() {
@ -38,19 +37,113 @@ echo "Config file path: $CONFIG_FILE"
# shellcheck disable=SC1090
. "$CONFIG_FILE"
if [ -z "${username:-}" ]; then
# Defaults for older configs that only set username/repo/branch.
forge="${forge:-${provider:-gitlab}}"
host="${host:-}"
username="${username:-${owner:-${org:-}}}"
repo="${repo:-}"
branch="${branch:-}"
if [ -z "$username" ]; then
echo "ERROR: 'username=' is missing in gameupdate/patch-config.txt"
exit 1
fi
if [ -z "${repo:-}" ]; then
if [ -z "$repo" ]; then
echo "ERROR: 'repo=' is missing in gameupdate/patch-config.txt"
exit 1
fi
if [ -z "${branch:-}" ]; then
if [ -z "$branch" ]; then
echo "ERROR: 'branch=' is missing in gameupdate/patch-config.txt"
exit 1
fi
normalize_forge() {
local raw
raw="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$raw" in
''|gitlab|gl|gitgud) echo gitlab ;;
github|gh) echo github ;;
forgejo|gitea|fj|codeberg) echo forgejo ;;
*)
echo "ERROR: Unknown forge='$1'. Use gitlab, github, or forgejo." >&2
return 1
;;
esac
}
FORGE_KIND="$(normalize_forge "$forge")" || exit 1
# Strip scheme / path from host if a user pasted a full URL.
HOST_NAME="$(printf '%s' "$host" | sed -E 's#^https?://##; s#/.*##; s#[[:space:]]##g')"
if [ -z "$HOST_NAME" ]; then
case "$FORGE_KIND" in
gitlab) HOST_NAME="gitgud.io" ;;
github) HOST_NAME="github.com" ;;
forgejo) HOST_NAME="codeberg.org" ;;
esac
fi
WEB_BASE="https://${HOST_NAME}"
case "$FORGE_KIND" in
gitlab) API_BASE="${WEB_BASE}/api/v4" ;;
github)
if [ "$HOST_NAME" = "github.com" ]; then
API_BASE="https://api.github.com"
else
API_BASE="${WEB_BASE}/api/v3"
fi
;;
forgejo) API_BASE="${WEB_BASE}/api/v1" ;;
esac
API_HEADERS=( -H "User-Agent: GameUpdate/1.0" )
if [ "$FORGE_KIND" = "github" ]; then
API_HEADERS+=( -H "Accept: application/vnd.github+json" )
fi
owner_enc=$(jq -nr --arg s "$username" '$s | @uri')
repo_enc=$(jq -nr --arg s "$repo" '$s | @uri')
branch_enc=$(jq -nr --arg b "$branch" '$b | @uri')
project_enc=$(jq -nr --arg ns "$username" --arg rp "$repo" '$ns + "/" + $rp | @uri')
resolve_latest_sha() {
case "$FORGE_KIND" in
gitlab)
curl -fsSL "${API_HEADERS[@]}" \
"${API_BASE}/projects/${project_enc}/repository/branches/${branch_enc}" \
| jq -r '.commit.id'
;;
github)
curl -fsSL "${API_HEADERS[@]}" \
"${API_BASE}/repos/${owner_enc}/${repo_enc}/commits/${branch_enc}" \
| jq -r '.sha'
;;
forgejo)
curl -fsSL "${API_HEADERS[@]}" \
"${API_BASE}/repos/${owner_enc}/${repo_enc}/branches/${branch_enc}" \
| jq -r '.commit.id'
;;
esac
}
archive_url_for_sha() {
local sha_enc
sha_enc=$(jq -nr --arg s "$1" '$s | @uri')
case "$FORGE_KIND" in
gitlab)
echo "${API_BASE}/projects/${project_enc}/repository/archive.zip?sha=${sha_enc}"
;;
github)
echo "${API_BASE}/repos/${owner_enc}/${repo_enc}/zipball/${sha_enc}"
;;
forgejo)
echo "${API_BASE}/repos/${owner_enc}/${repo_enc}/archive/${sha_enc}.zip"
;;
esac
}
echo "Pulling patch from ${WEB_BASE}/${username}/${repo} (${FORGE_KIND}, branch: ${branch})"
RETRIES="${GAMEUPDATE_DL_ATTEMPTS:-2}"
if ! [[ "$RETRIES" =~ ^[1-9][0-9]*$ ]]; then
RETRIES=2
@ -74,23 +167,139 @@ retry_cmd() {
done
}
project_enc=$(jq -nr --arg ns "$username" --arg rp "$repo" '$ns + "/" + $rp | @uri')
branch_enc=$(jq -nr --arg b "$branch" '$b | @uri')
# Get the latest hash
echo "Getting latest commit SHA hash"
latest_patch_sha="$(
retry_cmd "Resolve latest patch SHA" \
curl -fsSL "${API_HEADERS[@]}" \
"${GITGUD_API}/projects/${project_enc}/repository/branches/${branch_enc}" \
| jq -r '.commit.id'
)"
latest_patch_sha=""
attempt=1
while true; do
if latest_patch_sha="$(resolve_latest_sha)"; then
break
fi
if [ "$attempt" -ge "$RETRIES" ]; then
echo "Resolve latest patch SHA failed after $attempt attempt(s)."
exit 1
fi
echo "Resolve latest patch SHA failed ($attempt/$RETRIES), retrying..."
attempt=$((attempt + 1))
sleep 2
done
latest_patch_sha="$(printf '%s' "$latest_patch_sha" | tr -d '[:space:]')"
if [ -z "$latest_patch_sha" ] || [ "$latest_patch_sha" = "null" ]; then
echo "PATCH_ERR:API:Latest commit SHA response was empty."
exit 1
fi
# --------------------------------------------------------
# PRE-SETUP: Wolf Data.wolf -> loose Data/ (before patch copy)
# --------------------------------------------------------
wolf_loose_data_ready() {
local data_dir="$1"
[ -d "$data_dir" ] || return 1
[ -f "$data_dir/BasicData/CommonEvent.dat" ] && return 0
[ -f "$data_dir/CommonEvent.dat" ] && return 0
if compgen -G "$data_dir/*.mps" > /dev/null 2>&1; then
return 0
fi
if compgen -G "$data_dir/BasicData/*.dat" > /dev/null 2>&1; then
return 0
fi
return 1
}
find_uberwolf_cli() {
if [ -f "$ROOT_DIR/UberWolfCli.exe" ]; then
echo "$ROOT_DIR/UberWolfCli.exe"
return 0
fi
if [ -f "$ROOT_DIR/gameupdate/UberWolfCli.exe" ]; then
echo "$ROOT_DIR/gameupdate/UberWolfCli.exe"
return 0
fi
return 1
}
retire_data_wolf() {
local data_wolf="$1"
local bak="${data_wolf}.bak"
if [ ! -f "$data_wolf" ]; then
return 0
fi
if [ ! -e "$bak" ]; then
echo "[Pre-Setup] Retiring $(basename "$data_wolf") -> $(basename "$bak")"
mv -f "$data_wolf" "$bak"
else
echo "[Pre-Setup] Removing leftover $(basename "$data_wolf") so loose Data/ takes priority"
rm -f "$data_wolf"
fi
}
invoke_wolf_pre_setup() {
local data_wolf=""
local name
for name in Data.wolf data.wolf; do
if [ -f "$ROOT_DIR/$name" ]; then
data_wolf="$ROOT_DIR/$name"
break
fi
done
[ -n "$data_wolf" ] || return 0
local data_dir="$ROOT_DIR/Data"
if [ ! -d "$data_dir" ] && [ -d "$ROOT_DIR/data" ]; then
data_dir="$ROOT_DIR/data"
fi
if wolf_loose_data_ready "$data_dir"; then
retire_data_wolf "$data_wolf"
return 0
fi
local cli
cli="$(find_uberwolf_cli || true)"
if [ -z "$cli" ]; then
echo "[Pre-Setup] Data.wolf found but UberWolfCli.exe is missing."
echo " Copy UberWolfCli.exe into the game root (Step 1 gameupdate/) and re-run."
return 0
fi
echo "[Pre-Setup] Unpacking Data.wolf with UberWolfCli (one-time)..."
# Native .exe works under Windows / Git Bash; wine covers Linux hosts.
if ( cd "$ROOT_DIR" && "$cli" -o "$data_wolf" ); then
:
elif command -v wine > /dev/null 2>&1 && ( cd "$ROOT_DIR" && wine "$cli" -o "$data_wolf" ); then
:
else
echo "[Pre-Setup] ERROR: UberWolfCli unpack failed."
return 0
fi
local unpacked="$ROOT_DIR/Data"
if ! wolf_loose_data_ready "$unpacked"; then
unpacked="$ROOT_DIR/data"
fi
if ! wolf_loose_data_ready "$unpacked"; then
local alt
for alt in data.wolf~ Data.wolf~ Data~ data~; do
if [ -d "$ROOT_DIR/$alt" ]; then
echo "[Pre-Setup] Promoting $alt -> Data/"
rm -rf "$ROOT_DIR/Data"
mv -f "$ROOT_DIR/$alt" "$ROOT_DIR/Data"
unpacked="$ROOT_DIR/Data"
break
fi
done
fi
if wolf_loose_data_ready "$unpacked"; then
retire_data_wolf "$data_wolf"
echo "[Pre-Setup] Wolf Data/ is ready for patching."
else
echo "[Pre-Setup] ERROR: Unpack finished but loose Data/ still looks empty."
fi
}
invoke_wolf_pre_setup
# --------------------------------------------------------
# PRE-SETUP: Ensure SRPG data and patch structure exists
# Run Steps 1 and 2 BEFORE pulling repo patch to avoid overwriting updates
@ -132,10 +341,10 @@ fi
download_extract() {
echo "Downloading latest patch..."
archive_sha_enc=$(jq -nr --arg s "$latest_patch_sha" '$s | @uri')
archive_url="$(archive_url_for_sha "$latest_patch_sha")"
if ! retry_cmd "Download archive with curl" \
curl -fSL --progress-bar "${API_HEADERS[@]}" \
"${GITGUD_API}/projects/${project_enc}/repository/archive.zip?sha=${archive_sha_enc}" \
"$archive_url" \
-o "$ROOT_DIR/repo.zip"; then
echo "Download failed!"
rm -f "$ROOT_DIR/repo.zip"
@ -166,6 +375,15 @@ download_extract() {
return 1
fi
# Stage UberWolfCli before unpack so Data.wolf -> Data/ happens before
# English patch files are copied on top.
for cli_name in UberWolfCli.exe UberWolfCli.LICENSE.txt; do
if [ -f "$inner/$cli_name" ] && [ ! -f "$ROOT_DIR/$cli_name" ]; then
cp -f "$inner/$cli_name" "$ROOT_DIR/$cli_name"
fi
done
invoke_wolf_pre_setup
echo "Applying patch..."
if ! cp -r "$inner"/* "$ROOT_DIR/"; then
echo "Patch application failed!"
@ -205,6 +423,9 @@ download_extract() {
fi
fi
# Retire Data.wolf if loose Data/ is ready after the patch copy.
invoke_wolf_pre_setup
echo "$latest_patch_sha" > "$STATE_FILE"
}

View file

@ -585,6 +585,62 @@ class ConfigTab(QWidget):
ui_form.addRow(font_scale_label, self.font_scale_spin)
right_column.addLayout(ui_form)
# Game Update defaults (written into gameupdate/patch-config.txt on copy)
right_column.addWidget(create_section_header("📦 Game Update Defaults"))
gu_form = QFormLayout()
gu_form.setSpacing(6)
gu_form.setContentsMargins(0, 0, 0, 12)
gu_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
gu_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
def _gu_label(text: str) -> QLabel:
lbl = QLabel(text)
lbl.setFixedWidth(150)
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
return lbl
self.gu_forge_combo = QComboBox()
self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab")
self.gu_forge_combo.addItem("GitHub", "github")
self.gu_forge_combo.addItem("Forgejo / Gitea", "forgejo")
self.gu_forge_combo.setFixedWidth(220)
self.gu_forge_combo.setToolTip(
"Where your patch repos live. Used when Step 1 writes patch-config.txt."
)
self.gu_forge_combo.currentIndexChanged.connect(self._on_gu_forge_changed)
gu_form.addRow(_gu_label("Forge:"), self.gu_forge_combo)
self.gu_host_edit = QLineEdit()
self.gu_host_edit.setFixedWidth(220)
self.gu_host_edit.setPlaceholderText("gitgud.io")
self.gu_host_edit.setToolTip(
"Hostname only (no https://). Leave blank to use the forge default."
)
gu_form.addRow(_gu_label("Host:"), self.gu_host_edit)
self.gu_username_edit = QLineEdit()
self.gu_username_edit.setFixedWidth(220)
self.gu_username_edit.setPlaceholderText("your-org-or-user")
self.gu_username_edit.setToolTip(
"Owner / org / group for all patch repos. Set once; rarely changes."
)
gu_form.addRow(_gu_label("Org / username:"), self.gu_username_edit)
self.gu_branch_edit = QLineEdit()
self.gu_branch_edit.setFixedWidth(220)
self.gu_branch_edit.setPlaceholderText("main")
self.gu_branch_edit.setToolTip("Default branch to track (usually main or master).")
gu_form.addRow(_gu_label("Branch:"), self.gu_branch_edit)
gu_hint = QLabel(
"Copied into each game's gameupdate/patch-config.txt when you run "
"Copy gameupdate/ (repo= is still per-game)."
)
gu_hint.setWordWrap(True)
gu_hint.setStyleSheet("color:#7a7a7a;font-size:11px;")
right_column.addLayout(gu_form)
right_column.addWidget(gu_hint)
right_column.addStretch()
# Add columns to layout
@ -725,7 +781,39 @@ class ConfigTab(QWidget):
# UI settings
self.font_scale_spin.setValue(float(_get("font_scale", "1.0")))
# Game Update defaults
from util.gameupdate_config import (
DEFAULT_BRANCH,
DEFAULT_FORGE,
default_host_for_forge,
normalize_forge,
)
forge = normalize_forge(_get("gameUpdateForge", DEFAULT_FORGE))
forge_idx = self.gu_forge_combo.findData(forge)
self.gu_forge_combo.setCurrentIndex(forge_idx if forge_idx >= 0 else 0)
host = _get("gameUpdateHost", "") or default_host_for_forge(forge)
self.gu_host_edit.setText(host)
self.gu_username_edit.setText(_get("gameUpdateUsername", ""))
self.gu_branch_edit.setText(_get("gameUpdateBranch", DEFAULT_BRANCH) or DEFAULT_BRANCH)
def _on_gu_forge_changed(self, _index: int = 0):
"""Fill host with the forge default when the user switches forge."""
from util.gameupdate_config import default_host_for_forge
forge = self.gu_forge_combo.currentData() or "gitlab"
current = self.gu_host_edit.text().strip()
defaults = {
"gitgud.io",
"gitlab.com",
"github.com",
"codeberg.org",
"",
}
if current.lower() in defaults:
self.gu_host_edit.setText(default_host_for_forge(str(forge)))
def connect_auto_save(self):
"""Connect all widgets to auto-save on change."""
# Text fields - use editingFinished to avoid saving on every keystroke
@ -750,6 +838,10 @@ class ConfigTab(QWidget):
self.input_cost_spin.editingFinished.connect(self.auto_save)
self.output_cost_spin.editingFinished.connect(self.auto_save)
self.font_scale_spin.editingFinished.connect(self.auto_save)
self.gu_forge_combo.currentIndexChanged.connect(self.auto_save)
self.gu_host_edit.editingFinished.connect(self.auto_save)
self.gu_username_edit.editingFinished.connect(self.auto_save)
self.gu_branch_edit.editingFinished.connect(self.auto_save)
def disconnect_auto_save(self):
"""Disconnect all widgets from auto-save."""
@ -770,6 +862,10 @@ class ConfigTab(QWidget):
self.input_cost_spin.editingFinished.disconnect(self.auto_save)
self.output_cost_spin.editingFinished.disconnect(self.auto_save)
self.font_scale_spin.editingFinished.disconnect(self.auto_save)
self.gu_forge_combo.currentIndexChanged.disconnect(self.auto_save)
self.gu_host_edit.editingFinished.disconnect(self.auto_save)
self.gu_username_edit.editingFinished.disconnect(self.auto_save)
self.gu_branch_edit.editingFinished.disconnect(self.auto_save)
except (TypeError, RuntimeError):
pass
@ -808,6 +904,10 @@ class ConfigTab(QWidget):
"input_cost": str(self.input_cost_spin.value()),
"output_cost": str(self.output_cost_spin.value()),
"font_scale": str(self.font_scale_spin.value()),
"gameUpdateForge": str(self.gu_forge_combo.currentData() or "gitlab"),
"gameUpdateHost": self.gu_host_edit.text().strip(),
"gameUpdateUsername": self.gu_username_edit.text().strip(),
"gameUpdateBranch": self.gu_branch_edit.text().strip() or "main",
}
# Save to .env file and update os.environ so subprocesses inherit new values
@ -867,6 +967,15 @@ class ConfigTab(QWidget):
# UI settings
self.font_scale_spin.setValue(1.0)
# Game Update defaults
from util.gameupdate_config import DEFAULT_BRANCH, DEFAULT_FORGE, DEFAULT_HOST
forge_idx = self.gu_forge_combo.findData(DEFAULT_FORGE)
self.gu_forge_combo.setCurrentIndex(forge_idx if forge_idx >= 0 else 0)
self.gu_host_edit.setText(DEFAULT_HOST)
self.gu_username_edit.clear()
self.gu_branch_edit.setText(DEFAULT_BRANCH)
self.connect_auto_save()
@ -902,6 +1011,10 @@ class ConfigTab(QWidget):
"input_cost": self.input_cost_spin.value(),
"output_cost": self.output_cost_spin.value(),
"font_scale": self.font_scale_spin.value(),
"gameUpdateForge": str(self.gu_forge_combo.currentData() or "gitlab"),
"gameUpdateHost": self.gu_host_edit.text().strip(),
"gameUpdateUsername": self.gu_username_edit.text().strip(),
"gameUpdateBranch": self.gu_branch_edit.text().strip() or "main",
}
def validate(self):

View file

@ -20,6 +20,8 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Step 7 Inject - inject every JSON into Data/ from translated/ (default)
or from the game's wolf_json/; translated inject also
refreshes wolf_json/ (full pass avoids name/DB wipe bugs);
wolf_json inject optionally copies those JSON into files/
and translated/ so tool progress is not left behind;
optional layout-restore re-applies source whitespace pads
Step 8 Package - run from a loose Data/ folder or repack Data.wolf;
optional save rewrite for existing .sav files
@ -1028,7 +1030,9 @@ class WolfWorkflowTab(QWidget):
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)."
"overwriting existing files (updater scripts, patch scripts, .gitignore, "
"and UberWolfCli.exe). Writes patch-config.txt from Config → Game Update "
"defaults (set your org once there; edit repo= per game)."
)
tc_desc.setWordWrap(True)
tc_desc.setStyleSheet("color:#9d9d9d;font-size:13px;")
@ -1405,6 +1409,10 @@ class WolfWorkflowTab(QWidget):
self._last_import_signature = self._pending_import_signature
self._pending_import_signature = None
self._log(f"✅ Imported {count} file(s) into files/")
# Step 4 discovery scans files/; refresh if we're already on Database
# (auto-import from Step 0 often finishes after the first discovery pass).
if getattr(self, "_current_step_index", None) == 4:
self._refresh_db_discovery()
def _populate_preprocess_paths(self):
game_root = self.folder_edit.text().strip()
@ -1450,6 +1458,16 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
def _write_gameupdate_patch_config(self, game_root: str):
"""Write gameupdate/patch-config.txt from Config → Game Update defaults."""
from util.gameupdate_config import write_patch_config
ok, msg = write_patch_config(game_root)
if ok:
self._log(f"📝 Wrote patch-config.txt from Config defaults → {msg}")
else:
self._log(f" patch-config.txt: {msg}")
def _run_gameupdate(self):
src = self.pp_gameupdate_edit.text().strip()
dst = self.folder_edit.text().strip()
@ -1472,6 +1490,9 @@ class WolfWorkflowTab(QWidget):
self._log(f"✅ gameupdate: copied {count} file(s).")
for e in errors:
self._log(f"{e}")
dst = self.folder_edit.text().strip()
if dst and not errors:
self._write_gameupdate_patch_config(dst)
def _run_all_preprocess(self):
if not self._game_root:
@ -1517,6 +1538,13 @@ class WolfWorkflowTab(QWidget):
except Exception as exc:
errors.append(f"{rel}: {exc}")
log(f" Copied {copied} gameupdate file(s).")
from util.gameupdate_config import write_patch_config
ok, msg = write_patch_config(dst)
if ok:
log(f" Wrote patch-config.txt from Config defaults → {msg}")
else:
log(f" patch-config.txt: {msg}")
if errors:
return False, "Pre-process finished with errors (see log)."
return True, "Pre-process: dazedformat and gameupdate copy finished."
@ -1817,13 +1845,17 @@ class WolfWorkflowTab(QWidget):
# ── Step 4: Database ───────────────────────────────────────────────────────
def _build_step4_database(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 4 · Database (discover + translate)"))
layout.addWidget(self._desc(
layout.setSpacing(6)
title = _make_section_label("Step 4 · Database")
title.setToolTip(
"WOLF database files (DataBase, CDataBase, SysDatabase) hold item/skill "
"descriptions in classic RPG games, but many games store most dialogue in "
"custom database sheets instead of maps. Review the discovery summary below, "
"then translate foundation sheets (items, skills, system text) before "
"narrative custom sheets."
"custom database sheets instead of maps. Review discovery, then translate "
"foundation sheets (items, skills, system text) before narrative custom sheets."
)
layout.addWidget(title)
layout.addWidget(self._desc(
"Review discovery → tick sheets → translate foundation first, then narrative."
))
self.db_discovery_label = QLabel(
@ -1831,17 +1863,21 @@ class WolfWorkflowTab(QWidget):
)
self.db_discovery_label.setWordWrap(True)
self.db_discovery_label.setStyleSheet(
"color:#9cdcfe;font-size:13px;padding:8px 10px;"
"color:#9cdcfe;font-size:12px;padding:6px 8px;"
"background-color:#1a2430;border:1px solid #2a4a6a;border-radius:4px;"
)
layout.addWidget(self.db_discovery_label)
disc_row = QHBoxLayout()
refresh_disc_btn = _make_btn("↻ Refresh discovery", "#555")
disc_row.setSpacing(6)
refresh_disc_btn = _make_btn("↻ Refresh", "#555")
refresh_disc_btn.setToolTip("Re-scan files/ for database / map / CommonEvent line counts.")
refresh_disc_btn.clicked.connect(self._refresh_db_discovery)
audit_btn = _make_btn("📋 Copy DB structure prompt for AI audit", "#5a3a7a")
audit_btn = _make_btn("📋 Copy AI audit prompt", "#5a3a7a")
audit_btn.setToolTip("Copy a DB structure prompt for Cursor/Copilot to audit sheet roles.")
audit_btn.clicked.connect(self._copy_db_audit_prompt)
import_btn = _make_btn("Import AI profile JSON", "#3a3a3a")
import_btn = _make_btn("Import AI profile", "#3a3a3a")
import_btn.setToolTip("Paste JSON returned by the AI audit into db_profile.json.")
import_btn.clicked.connect(self._import_db_profile_dialog)
disc_row.addWidget(refresh_disc_btn)
disc_row.addWidget(audit_btn)
@ -1849,26 +1885,25 @@ class WolfWorkflowTab(QWidget):
disc_row.addStretch()
layout.addLayout(disc_row)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Database sheets"))
layout.addWidget(self._desc(
"Each sheet is classified as foundation (translate first), system, or "
"narrative (custom dialogue - defer until foundation is done). Tick the "
"sheets to include in the next database translation run."
))
self._db_groups_list = QListWidget()
self._db_groups_list.setMinimumHeight(200)
self._db_groups_list.setMinimumHeight(160)
self._db_groups_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._db_groups_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:3px 4px;}"
"color:#cccccc;font-size:12px;padding:1px;}"
"QListWidget::item{padding:2px 4px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
self._db_groups_list.itemChanged.connect(self._save_db_profile_from_ui)
layout.addWidget(self._db_groups_list)
self._db_groups_list.setToolTip(
"Each sheet is foundation (translate first), system, or narrative "
"(custom dialogue - defer until foundation is done). Tick sheets for "
"the next database translation run."
)
layout.addWidget(self._db_groups_list, 1)
grp_row = QHBoxLayout()
grp_row.setSpacing(6)
sel_found_btn = _make_btn("Select foundation", "#3a3a3a")
sel_found_btn.clicked.connect(self._select_db_foundation_groups)
sel_narr_btn = _make_btn("Select narrative", "#3a3a3a")
@ -1884,32 +1919,36 @@ class WolfWorkflowTab(QWidget):
grp_row.addStretch()
layout.addLayout(grp_row)
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Translate database"))
layout.addWidget(self._desc(
"Run after Step 3 (names) so vocab.txt stays consistent. Only checked sheets "
"are sent to the model. Re-running skips lines already translated. Short "
"foundation labels (map names, titles) are merged into vocab.txt."
))
self._add_tl_mode_selector(layout)
found_btn = self._register(_make_btn("▶ Translate foundation DB", "#00a86b"))
tl_row = QHBoxLayout()
tl_row.setSpacing(6)
found_btn = self._register(_make_btn("▶ Foundation DB", "#00a86b"))
found_btn.setToolTip(
"Translate foundation/system sheets (items, skills, system text). "
"Run after Step 3 (names). Re-running skips lines already translated."
)
found_btn.clicked.connect(
lambda: self._translate_db_tiers(wolf_db.FOUNDATION_TIERS, auto_start=True)
)
layout.addWidget(found_btn)
narr_btn = self._register(_make_btn("▶ Translate narrative DB", "#00a86b"))
narr_btn = self._register(_make_btn("▶ Narrative DB", "#00a86b"))
narr_btn.setToolTip(
"Translate narrative custom sheets after foundation is done. "
"Short foundation labels are merged into vocab.txt."
)
narr_btn.clicked.connect(
lambda: self._translate_db_tiers(wolf_db.NARRATIVE_TIERS, auto_start=True)
)
layout.addWidget(narr_btn)
checked_btn = self._register(_make_btn("▶ Translate checked sheets only", "#3a3a3a"))
checked_btn = self._register(_make_btn("▶ Checked sheets", "#3a3a3a"))
checked_btn.setToolTip("Translate only the sheets currently ticked above.")
checked_btn.clicked.connect(
lambda: self._translate_db_checked(auto_start=True)
)
layout.addWidget(checked_btn)
tl_row.addWidget(found_btn)
tl_row.addWidget(narr_btn)
tl_row.addWidget(checked_btn)
tl_row.addStretch()
layout.addLayout(tl_row)
self._db_content_dist: wolf_db.ContentDistribution | None = None
# Defer discovery until the step is shown - files/ may hold RPGMaker JSON.
@ -2435,39 +2474,38 @@ class WolfWorkflowTab(QWidget):
# ── Step 6: Precheck ───────────────────────────────────────────────────────
def _build_step6_precheck(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 6 · Inject Precheck"))
layout.setSpacing(6)
title = _make_section_label("Step 6 · Inject Precheck")
title.setToolTip(
"Reconcile names.json → glossary spellings, report name inconsistencies, "
"then dry-run selected JSON for safety skips (control-code mismatch, or "
"text not Shift-JIS encodable). Fix safety rows here, then Step 7 (Inject)."
)
layout.addWidget(title)
layout.addWidget(self._desc(
"Before inject: reconcile names.json → extract glossary spellings, "
"report name inconsistencies, then dry-run the selected JSON for real "
"safety skips (control-code mismatch, or text not Shift-JIS encodable). "
"Fix safety rows here, then continue to Step 7 (Inject)."
"Pick files → Precheck → fix safety skips below → Step 7 Inject."
))
layout.addWidget(self._subheading("Translated files"))
self.precheck_list = QListWidget()
self.precheck_list.setMaximumHeight(200)
self.precheck_list.setMinimumHeight(72)
self.precheck_list.setMaximumHeight(120)
self.precheck_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
self.precheck_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:3px 4px;}"
"color:#cccccc;font-size:12px;padding:1px;}"
"QListWidget::item{padding:2px 4px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
layout.addWidget(self.precheck_list)
list_row = QHBoxLayout()
list_row.setSpacing(6)
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
refresh_btn.clicked.connect(self._refresh_precheck_list)
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, True))
sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a"))
sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, False))
list_row.addWidget(refresh_btn)
list_row.addWidget(sel_all_btn)
list_row.addWidget(sel_none_btn)
list_row.addStretch()
layout.addLayout(list_row)
precheck_row = QHBoxLayout()
precheck_btn = self._register(_make_btn("Precheck selected", "#007acc"))
precheck_btn.setToolTip(
"Reconcile names → glossary, run names-check, then dry-run inject "
@ -2479,25 +2517,32 @@ class WolfWorkflowTab(QWidget):
"Same as Precheck selected, for every injectable file in translated/."
)
precheck_all_btn.clicked.connect(self._precheck_inject_all)
precheck_row.addWidget(precheck_btn)
precheck_row.addWidget(precheck_all_btn)
precheck_row.addStretch()
layout.addLayout(precheck_row)
list_row.addWidget(refresh_btn)
list_row.addWidget(sel_all_btn)
list_row.addWidget(sel_none_btn)
list_row.addStretch()
list_row.addWidget(precheck_btn)
list_row.addWidget(precheck_all_btn)
layout.addLayout(list_row)
self._inject_precheck_label = QLabel("Run precheck to list safety-guard skips.")
self._inject_precheck_label.setWordWrap(True)
self._inject_precheck_label.setStyleSheet("color:#9cdcfe;font-size:12px;")
self._inject_precheck_label.setStyleSheet(
"color:#9cdcfe;font-size:11px;background:transparent;padding:0;"
)
layout.addWidget(self._inject_precheck_label)
self._inject_issue_list = QListWidget()
self._inject_issue_list.setMaximumHeight(200)
self._inject_issue_list.setMinimumHeight(80)
self._inject_issue_list.setMaximumHeight(140)
self._inject_issue_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
self._inject_issue_list.setWordWrap(True)
self._inject_issue_list.setTextElideMode(Qt.ElideNone)
self._inject_issue_list.setUniformItemSizes(False)
self._inject_issue_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:4px;}"
"color:#cccccc;font-size:12px;padding:1px;}"
"QListWidget::item{padding:3px 6px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
self._inject_issue_list.currentItemChanged.connect(self._on_inject_issue_selected)
@ -2505,22 +2550,26 @@ class WolfWorkflowTab(QWidget):
self._inject_issue_meta = QLabel("")
self._inject_issue_meta.setWordWrap(True)
self._inject_issue_meta.setStyleSheet("color:#808080;font-size:11px;")
self._inject_issue_meta.setStyleSheet(
"color:#808080;font-size:11px;background:transparent;padding:0;"
)
layout.addWidget(self._inject_issue_meta)
self._inject_issue_edit = QTextEdit()
self._inject_issue_edit.setPlaceholderText(
"Select a precheck row to edit its translated text, then Save line."
)
self._inject_issue_edit.setMaximumHeight(120)
self._inject_issue_edit.setMinimumHeight(100)
self._inject_issue_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._inject_issue_edit.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
"border-radius:4px;padding:6px;font-size:12px;}"
"border-left:3px solid #007acc;border-radius:0;padding:4px;font-size:12px;}"
)
layout.addWidget(self._inject_issue_edit)
layout.addWidget(self._inject_issue_edit, 1)
edit_row = QHBoxLayout()
save_line_btn = self._register(_make_btn("Save line", "#3a3a3a"))
edit_row.setSpacing(6)
save_line_btn = self._register(_make_btn("Save line", "#00a86b"))
save_line_btn.clicked.connect(self._save_inject_issue_line)
edit_row.addWidget(save_line_btn)
edit_row.addStretch()
@ -2536,7 +2585,8 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._desc(
"Inject every JSON into the game's Data/ binaries in one pass. "
"Inject all uses translated/; Inject from wolf_json uses the game's "
"wolf_json/ when that folder is the source of truth. "
"wolf_json/ when that folder is the source of truth (and can copy those "
"JSON into files/ and translated/ so tool progress is not left behind). "
"A full inject keeps names.json and DB/map files in sync (partial injects "
"can wipe name-only fields like rumor boards). Run Step 6 (Precheck) first "
"if you want to catch safety-guard skips before writing."
@ -2589,8 +2639,8 @@ class WolfWorkflowTab(QWidget):
inject_wolf_btn.setToolTip(
"Inject every injectable JSON from the game's wolf_json/ folder instead "
"of translated/. Use when wolf_json/ is the source of truth (e.g. after "
"editing or restoring committed game-repo JSON). Does not copy from "
"translated/."
"editing or restoring committed game-repo JSON). Asks whether to copy "
"those JSON into files/ and translated/ so tool progress is not left behind."
)
inject_wolf_btn.clicked.connect(self._inject_all_from_wolf_json)
layout_restore_btn = self._register(_make_btn("Layout-restore", "#3a3a3a"))
@ -2615,15 +2665,21 @@ class WolfWorkflowTab(QWidget):
def _build_step9_relayout(self, layout: QVBoxLayout):
"""Search-first fix wrapping: find sheet by in-game text, wrap, Inject all."""
layout.addWidget(_make_section_label("Step 9 · Fix wrapping"))
layout.setSpacing(6)
title = _make_section_label("Step 9 · Fix wrapping")
title.setToolTip(
"Paste overflowing in-game text to find it in translated JSON. "
"Relayout: cell width + max lines (names.json → wolf names-wrap). "
"Manual: wrap width + body font; emphasis \\f[N] scales with the body. "
"Then Inject all (Step 7) and re-package."
)
layout.addWidget(title)
layout.addWidget(self._desc(
"After packaging and playtesting, paste overflowing in-game text to find "
"it in translated JSON. Relayout uses cell width + max lines "
"(names.json → wolf names-wrap). Manual uses wrap width + body font; "
"emphasis \\f[N] scales with the body. Then Inject all (Step 7) and re-package."
"Paste overflowing text → pick a hit → Relayout or Manual → Wrap → Inject all."
))
search_row = QHBoxLayout()
search_row.setSpacing(6)
self._wrap_search_edit = QLineEdit()
self._wrap_search_edit.setPlaceholderText(
'Paste in-game text, e.g. "there\'s a pure" or "gatekeeper just randomly"'
@ -2636,15 +2692,17 @@ class WolfWorkflowTab(QWidget):
layout.addLayout(search_row)
self._wrap_results_list = QListWidget()
self._wrap_results_list.setMaximumHeight(220)
self._wrap_results_list.setMinimumHeight(72)
self._wrap_results_list.setMaximumHeight(110)
self._wrap_results_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
self._wrap_results_list.setWordWrap(True)
self._wrap_results_list.setTextElideMode(Qt.ElideNone)
self._wrap_results_list.setUniformItemSizes(False)
self._wrap_results_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._wrap_results_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:8px;}"
"color:#cccccc;font-size:12px;padding:1px;}"
"QListWidget::item{padding:3px 6px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
self._wrap_results_list.currentItemChanged.connect(self._on_wrap_hit_selected)
@ -2653,16 +2711,19 @@ class WolfWorkflowTab(QWidget):
self._wrap_detail_label = QLabel("Select a search result to edit.")
self._wrap_detail_label.setWordWrap(True)
self._wrap_detail_label.setStyleSheet(
"color:#9cdcfe;font-size:12px;background:transparent;"
"color:#9cdcfe;font-size:11px;background:transparent;padding:0;"
)
layout.addWidget(self._wrap_detail_label)
mode_row = QHBoxLayout()
# Mode + active controls on one row to cut vertical chrome.
controls_row = QHBoxLayout()
controls_row.setSpacing(8)
mode_lbl = QLabel("Mode:")
mode_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_mode_combo = QComboBox()
self._wrap_mode_combo.addItem("Relayout (cell width + max lines)", "relayout")
self._wrap_mode_combo.addItem("Manual (wrap width + font)", "manual")
self._wrap_mode_combo.addItem("Relayout", "relayout")
self._wrap_mode_combo.addItem("Manual", "manual")
self._wrap_mode_combo.setMinimumWidth(110)
saved_mode = str(self._setting("wrap_fix_mode", "relayout") or "relayout")
idx = self._wrap_mode_combo.findData(saved_mode)
self._wrap_mode_combo.setCurrentIndex(idx if idx >= 0 else 0)
@ -2672,16 +2733,15 @@ class WolfWorkflowTab(QWidget):
"Manual: force wrap width and body font; emphasis \\f[N] scales with the body."
)
self._wrap_mode_combo.currentIndexChanged.connect(self._on_wrap_mode_changed)
mode_row.addWidget(mode_lbl)
mode_row.addWidget(self._wrap_mode_combo)
mode_row.addStretch()
layout.addLayout(mode_row)
controls_row.addWidget(mode_lbl)
controls_row.addWidget(self._wrap_mode_combo)
# Relayout controls: cell width + max lines
self._wrap_relayout_row = QWidget()
relayout_row = QHBoxLayout(self._wrap_relayout_row)
relayout_row.setContentsMargins(0, 0, 0, 0)
w_lbl = QLabel("Cell width (0=auto):")
relayout_row.setSpacing(6)
w_lbl = QLabel("Width:")
w_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_width_spin = QSpinBox()
self._wrap_width_spin.setRange(0, 200)
@ -2689,13 +2749,13 @@ class WolfWorkflowTab(QWidget):
self._wrap_width_spin.setValue(int(self._setting("wrap_fix_width", 36) or 36))
except (TypeError, ValueError):
self._wrap_width_spin.setValue(36)
self._wrap_width_spin.setFixedWidth(70)
self._wrap_width_spin.setFixedWidth(64)
self._wrap_width_spin.setToolTip(
"Halfwidth cells (ASCII=1, fullwidth=2). For names.json Relayout: "
"0 = measure from JP; N > 0 forces wolf names-wrap --width."
"Cell width in halfwidth cells (ASCII=1, fullwidth=2). 0 = auto "
"(names: measure from JP). N > 0 forces wolf names-wrap --width."
)
self._wrap_width_spin.valueChanged.connect(self._on_wrap_width_changed)
lines_lbl = QLabel("Max lines (0=auto):")
lines_lbl = QLabel("Lines:")
lines_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_max_lines_spin = QSpinBox()
self._wrap_max_lines_spin.setRange(0, 20)
@ -2705,11 +2765,10 @@ class WolfWorkflowTab(QWidget):
)
except (TypeError, ValueError):
self._wrap_max_lines_spin.setValue(0)
self._wrap_max_lines_spin.setFixedWidth(70)
self._wrap_max_lines_spin.setFixedWidth(64)
self._wrap_max_lines_spin.setToolTip(
"Max soft lines after Relayout. For names: wolf names-wrap --lines "
"(0 = each entry's JP line count). For DB/dialogue: wrap to width, "
"then shrink \\f[N] until the line count fits. "
"Max soft lines (0 = auto). Names: wolf names-wrap --lines. "
"DB/dialogue: wrap to width, then shrink \\f[N] to fit. "
"Saved per sheet in wolfdawn-roles.json."
)
self._wrap_max_lines_spin.valueChanged.connect(self._on_wrap_max_lines_changed)
@ -2717,14 +2776,14 @@ class WolfWorkflowTab(QWidget):
relayout_row.addWidget(self._wrap_width_spin)
relayout_row.addWidget(lines_lbl)
relayout_row.addWidget(self._wrap_max_lines_spin)
relayout_row.addStretch()
layout.addWidget(self._wrap_relayout_row)
controls_row.addWidget(self._wrap_relayout_row)
# Manual controls: wrap width + font size
self._wrap_manual_row = QWidget()
manual_row = QHBoxLayout(self._wrap_manual_row)
manual_row.setContentsMargins(0, 0, 0, 0)
mw_lbl = QLabel("Wrap width:")
manual_row.setSpacing(6)
mw_lbl = QLabel("Width:")
mw_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_manual_width_spin = QSpinBox()
self._wrap_manual_width_spin.setRange(10, 200)
@ -2734,12 +2793,12 @@ class WolfWorkflowTab(QWidget):
)
except (TypeError, ValueError):
self._wrap_manual_width_spin.setValue(36)
self._wrap_manual_width_spin.setFixedWidth(70)
self._wrap_manual_width_spin.setFixedWidth(64)
self._wrap_manual_width_spin.setToolTip(
"Hard wrap width in halfwidth cells for Manual mode (dazedwrap)."
)
self._wrap_manual_width_spin.valueChanged.connect(self._on_wrap_manual_width_changed)
font_lbl = QLabel("Body font \\f[N]:")
font_lbl = QLabel("Font:")
font_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_font_spin = QSpinBox()
self._wrap_font_spin.setRange(0, 32)
@ -2749,11 +2808,10 @@ class WolfWorkflowTab(QWidget):
except (TypeError, ValueError):
saved_font = 18
self._wrap_font_spin.setValue(max(0, saved_font))
self._wrap_font_spin.setFixedWidth(90)
self._wrap_font_spin.setFixedWidth(80)
self._wrap_font_spin.setToolTip(
"Target body font applied on Wrap. 0 = do not add or change \\f[N] "
"(wrap width only). N > 0 scales emphasis (e.g. \\f[20]…\\f[18]) and "
"sets a leading \\f[body]."
"Body \\f[N] on Wrap. 0 = leave fonts alone (width only). "
"N > 0 scales emphasis and sets a leading \\f[body]."
)
self._wrap_font_spin.valueChanged.connect(self._on_wrap_font_changed)
self._wrap_font_detect_label = QLabel("")
@ -2765,29 +2823,33 @@ class WolfWorkflowTab(QWidget):
manual_row.addWidget(font_lbl)
manual_row.addWidget(self._wrap_font_spin)
manual_row.addWidget(self._wrap_font_detect_label, 1)
layout.addWidget(self._wrap_manual_row)
controls_row.addWidget(self._wrap_manual_row, 1)
controls_row.addStretch()
layout.addLayout(controls_row)
self._sync_wrap_mode_controls()
self._wrap_preview_label = QLabel("Wrap preview")
self._wrap_preview_label.setStyleSheet(
"color:#858585;font-size:11px;background:transparent;"
"color:#858585;font-size:11px;background:transparent;padding:0;"
)
layout.addWidget(self._wrap_preview_label)
self._wrap_preview = QTextEdit()
self._wrap_preview.setReadOnly(True)
self._wrap_preview.setMaximumHeight(200)
self._wrap_preview.setMinimumHeight(140)
self._wrap_preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._wrap_preview.setPlaceholderText(
"Select a search result to preview wrap with simulated \\c / \\f styling."
"Wrap preview — select a hit to see \\c / \\f styling."
)
self._wrap_preview.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;"
"border-left:3px solid #007acc;font-family:monospace;font-size:12px;padding:6px;}"
"border-left:3px solid #007acc;font-family:monospace;font-size:12px;padding:4px;}"
)
layout.addWidget(self._wrap_preview)
layout.addWidget(self._wrap_preview, 1)
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
wrap_row_btn = _make_btn("Wrap this line", "#00a86b")
wrap_row_btn.clicked.connect(self._wrap_current_row)
wrap_group_btn = _make_btn("Wrap all in group", "#00a86b")
@ -2801,15 +2863,13 @@ class WolfWorkflowTab(QWidget):
btn_row.addWidget(wrap_row_btn)
btn_row.addWidget(wrap_group_btn)
btn_row.addWidget(inject_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self._wrap_status_label = QLabel("")
self._wrap_status_label.setWordWrap(True)
self._wrap_status_label.setStyleSheet(
"color:#8fbc8f;font-size:12px;background:transparent;"
"color:#8fbc8f;font-size:11px;background:transparent;"
)
layout.addWidget(self._wrap_status_label)
btn_row.addWidget(self._wrap_status_label, 1)
layout.addLayout(btn_row)
self._current_wrap_hit: wolf_ws.WrapHit | None = None
self._current_wrap_hit_id: dict | None = None
@ -3717,28 +3777,55 @@ class WolfWorkflowTab(QWidget):
except Exception as exc:
return False, str(exc)
def _sync_json_to_dir(
self, json_name: str, source_dir: Path, dest_dir: Path
) -> tuple[bool, str | None]:
"""Copy one JSON from *source_dir* into *dest_dir* (e.g. files/ or translated/)."""
src = source_dir / json_name
if not src.is_file():
return False, "source missing"
dest = dest_dir / json_name
try:
if src.resolve() == dest.resolve():
return True, None
except Exception:
pass
try:
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
return True, None
except Exception as exc:
return False, str(exc)
def _inject(
self,
selected: set[str],
*,
source_dir: Path | None = None,
sync_to_wolf_json: bool = True,
sync_to_files: bool = False,
sync_to_translated: bool = False,
):
"""Inject the selected JSON files into live Data/.
*source_dir* defaults to ``translated/``. When injecting from the game's
``wolf_json/``, pass that path and set *sync_to_wolf_json* False (the
source already is wolf_json/).
source already is wolf_json/). Set *sync_to_files* / *sync_to_translated*
to also copy the injected JSON into the tool's ``files/`` and/or
``translated/`` folders.
"""
if not self._require_manifest():
return
manifest = self._read_manifest()
en_punct = self._inject_flag_en_punct()
game_json_dir = self._work_dir()
tool_root = self._tool_root()
files_dir = tool_root / "files"
translated_dir = tool_root / "translated"
inject_dir = (
Path(source_dir)
if source_dir is not None
else self._tool_root() / "translated"
else translated_dir
)
def task(log, progress=None):
@ -3776,6 +3863,27 @@ class WolfWorkflowTab(QWidget):
)
log(f" ✗ sync {json_name}: {err}")
for label, enabled, dest in (
("files/", sync_to_files, files_dir),
("translated/", sync_to_translated, translated_dir),
):
if not enabled:
continue
synced = 0
for json_name in sorted(selected):
ok, err = self._sync_json_to_dir(
json_name, inject_dir, dest
)
if not ok:
report.sync_failures.append(
(json_name, f"{label}: {err or 'unknown error'}")
)
log(f" ✗ sync {label}{json_name}: {err}")
else:
synced += 1
if synced:
log(f"Synced {synced} file(s) → {label}")
dialog = wolf_inject.format_report_dialog(report)
status = wolf_inject.format_report_status(report)
inject_state["dialog"] = dialog
@ -3842,7 +3950,9 @@ class WolfWorkflowTab(QWidget):
if self._step_tabs.currentIndex() != idx:
self._step_tabs.setCurrentIndex(idx)
else:
self._refresh_step_strip(idx)
# Re-selecting the current step still runs enter hooks (e.g. Step 4
# discovery refresh) so the strip click always feels live.
self._on_step_changed(idx)
def _advance_step(self, from_idx: int):
"""Mark *from_idx* done and move to the next step."""
@ -3855,6 +3965,10 @@ class WolfWorkflowTab(QWidget):
self._refresh_step_strip(idx)
if previous == 0 and idx != 0:
self._auto_import_if_needed()
self._enter_step(idx)
def _enter_step(self, idx: int):
"""Refresh step-local data whenever a step is shown or re-selected."""
# Index 3 == Names: refresh the per-name safety summary.
if idx == 3:
self._refresh_names_summary()
@ -4164,7 +4278,39 @@ class WolfWorkflowTab(QWidget):
f"No injectable JSON found in {work}.",
)
return
self._inject(set(files), source_dir=work, sync_to_wolf_json=False)
reply = QMessageBox.question(
self,
"Inject from wolf_json",
f"About to inject {len(files)} file(s) from wolf_json/.\n\n"
"Also copy those JSON into files/ and translated/ so the tool "
"keeps the same progress (overwrites matching names)?\n\n"
"Yes = inject and sync to files/ + translated/\n"
"No = inject only, leave files/ and translated/ as-is\n"
"Cancel = do nothing",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
QMessageBox.Yes,
)
if reply == QMessageBox.Cancel:
self._log("Inject from wolf_json cancelled.")
return
sync_tool_dirs = reply == QMessageBox.Yes
if sync_tool_dirs:
self._log(
f"Will sync {len(files)} wolf_json/ file(s) → files/ and "
"translated/ after inject."
)
else:
self._log(
"Skipping wolf_json/ → files/ + translated/ sync; "
"leaving those folders as-is."
)
self._inject(
set(files),
source_dir=work,
sync_to_wolf_json=False,
sync_to_files=sync_tool_dirs,
sync_to_translated=sync_tool_dirs,
)
def _layout_restore_all(self):
"""Re-run wolf layout-restore on every injectable translated JSON."""

View file

@ -1716,7 +1716,9 @@ class WorkflowTab(QWidget):
tc_inner.setSpacing(4)
tc_desc = QLabel(
"Copies everything from the <code>gameupdate/</code> folder "
"into the game\'s root folder, overwriting existing files."
"into the game\'s root folder, overwriting existing files. "
"Writes <code>patch-config.txt</code> from Config → Game Update defaults "
"(set your org once there; edit <code>repo=</code> per game)."
)
tc_desc.setTextFormat(Qt.RichText)
tc_desc.setWordWrap(True)
@ -4612,6 +4614,16 @@ class WorkflowTab(QWidget):
self._worker = w
w.start()
def _write_gameupdate_patch_config(self, game_root: str):
"""Write gameupdate/patch-config.txt from Config → Game Update defaults."""
from util.gameupdate_config import write_patch_config
ok, msg = write_patch_config(game_root)
if ok:
self._log(f"📝 Wrote patch-config.txt from Config defaults → {msg}")
else:
self._log(f" patch-config.txt: {msg}")
def _run_gameupdate(self):
src = self.pp_gameupdate_edit.text().strip()
dst = self.folder_edit.text().strip()
@ -4634,6 +4646,9 @@ class WorkflowTab(QWidget):
self._log(f"✅ gameupdate: copied {count} file(s).")
for e in errors:
self._log(f"{e}")
dst = self.folder_edit.text().strip()
if dst and not errors:
self._write_gameupdate_patch_config(dst)
def _run_all_preprocess(self):
"""Launch all three pre-process tasks in sequence, chaining via signals."""
@ -4688,6 +4703,8 @@ class WorkflowTab(QWidget):
self._log(f"✅ gameupdate: copied {count} file(s).")
for e in errors:
self._log(f"{e}")
if not errors and game_root_dst:
self._write_gameupdate_patch_config(game_root_dst)
run_next(rest)
worker.done.connect(on_copy_done)
else:

View file

@ -412,28 +412,37 @@ def _body_after_dropped_tag(text: str, original_speaker: str) -> str:
return text
def _maybe_fix_nameplate(entry, total_tokens: list) -> None:
"""Swap a Japanese first-line nameplate for English on an otherwise-done line."""
if ESTIMATE or _batch_phase() == "collect":
return
def _maybe_fix_nameplate(entry, total_tokens: list) -> bool:
"""Resolve a Japanese first-line nameplate on an otherwise-done line.
Returns True when the nameplate was rewritten. During batch collect the
in-memory rewrite is discarded (translated/ is not written), but
``getSpeaker`` still runs so the English gloss is cached before dialogue
batches are queued - otherwise those live speaker calls only show up in
Pass 2.
"""
if ESTIMATE:
return False
speaker_src = entry.get("speaker_src", "")
if not wolf_speakers.is_firstline_enabled(speaker_src, SPEAKER_CONFIG):
return
return False
txt = entry.get("text")
if not isinstance(txt, str) or not txt.strip():
return
return False
# Only touch finished dialogue (body already English / skip-translated).
if _text_still_needs_translation(entry):
return
return False
split = wolf_speakers.split_source(txt, speaker_src, SPEAKER_CONFIG)
if split is None:
return
return False
prefix, speaker, body = split
if not re.search(LANGREGEX, speaker):
return
return False
speaker_en = _resolve_nameplate(speaker, total_tokens)
if speaker_en and speaker_en != speaker:
entry["text"] = wolf_speakers.restore_source(prefix, speaker_en, body)
return True
return False
def parseDocument(data, filename):
@ -457,9 +466,11 @@ def parseDocument(data, filename):
return False
return True
# Fix Japanese nameplates on lines whose dialogue body is already done, so
# resume / re-wrap runs do not leave ``セルリア\\nPlease hold on...`` behind.
if IGNORETLTEXT and not ESTIMATE and _batch_phase() != "collect":
# Resolve Japanese nameplates on lines whose dialogue body is already done.
# Must run during batch collect too: those lines are skip-translated so they
# never hit the reshape/getSpeaker path, and deferring to consume is what
# made speaker Input/Output spam show up in Pass 2 after the batch returned.
if IGNORETLTEXT and not ESTIMATE:
for entry in entries:
if _translatable(entry):
continue

View file

@ -470,6 +470,52 @@ class TestTranslationWriteback(unittest.TestCase):
# Short-string speaker resolve, then the remaining dialogue batch.
self.assertEqual(captured, ["司祭", ["まだだ"]])
def test_collect_resolves_japanese_nameplate_before_queueing(self):
"""Pass 1 must live-translate skip-translated JP nameplates (not defer to Pass 2)."""
doc = {
"kind": "map",
"scenes": [
{
"event": 1,
"name": "ev",
"lines": [
{
"cmd": 0,
"str": 0,
"speaker": "客F",
"speaker_src": "literal_line1_lowconf",
"source": "客F\nまぁまぁ。",
"text": "客F\nNow, now.",
},
{
"cmd": 1,
"str": 0,
"speaker": "UI",
"speaker_src": "ui",
"source": "まだだ",
"text": "まだだ",
},
],
}
],
}
orig_phase = os.environ.get("BATCH_PHASE")
os.environ["BATCH_PHASE"] = "collect"
try:
(data, _t, err), captured = _WolfTranslateHarness().run(doc, "collect_nameplate.mps.json")
finally:
if orig_phase is None:
os.environ.pop("BATCH_PHASE", None)
else:
os.environ["BATCH_PHASE"] = orig_phase
self.assertIsNone(err)
# Speaker resolved live during collect; dialogue list is also seen (queued).
self.assertEqual(captured[0], "客F")
self.assertEqual(captured[1], ["まだだ"])
# Collect does not persist translated/, but in-memory text is updated.
self.assertEqual(data["scenes"][0]["lines"][0]["text"], "En_客\nNow, now.")
def test_ignore_tl_text_false_retranslates(self):
doc = {
"kind": "map",

132
util/gameupdate_config.py Normal file
View file

@ -0,0 +1,132 @@
"""GameUpdate patch-config defaults from the tool's .env / Config tab.
Translators set forge/host/org once in Config. When Step 1 copies
``gameupdate/`` into a game root, we write ``gameupdate/patch-config.txt``
from those defaults (repo stays per-game).
"""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import dotenv_values
# .env keys (Config tab)
ENV_FORGE = "gameUpdateForge"
ENV_HOST = "gameUpdateHost"
ENV_USERNAME = "gameUpdateUsername"
ENV_BRANCH = "gameUpdateBranch"
DEFAULT_FORGE = "gitlab"
DEFAULT_HOST = "gitgud.io"
DEFAULT_BRANCH = "main"
_FORGE_HOST_DEFAULTS = {
"gitlab": "gitgud.io",
"github": "github.com",
"forgejo": "codeberg.org",
}
def normalize_forge(raw: str | None) -> str:
value = (raw or "").strip().lower()
if value in ("", "gitlab", "gl", "gitgud"):
return "gitlab"
if value in ("github", "gh"):
return "github"
if value in ("forgejo", "gitea", "fj", "codeberg"):
return "forgejo"
return "gitlab"
def default_host_for_forge(forge: str) -> str:
return _FORGE_HOST_DEFAULTS.get(normalize_forge(forge), DEFAULT_HOST)
def load_gameupdate_defaults(env_path: str | Path | None = None) -> dict[str, str]:
"""Return forge/host/username/branch from .env (with process env fallback)."""
path = Path(env_path) if env_path is not None else Path(".env")
file_vals = dotenv_values(path) if path.is_file() else {}
def _get(key: str, default: str = "") -> str:
raw = file_vals.get(key)
if raw is None or str(raw).strip() == "":
raw = os.getenv(key, default)
return str(raw or default).strip()
forge = normalize_forge(_get(ENV_FORGE, DEFAULT_FORGE))
host = _get(ENV_HOST, "") or default_host_for_forge(forge)
host = host.replace("https://", "").replace("http://", "").split("/")[0].strip()
return {
"forge": forge,
"host": host or default_host_for_forge(forge),
"username": _get(ENV_USERNAME, ""),
"branch": _get(ENV_BRANCH, DEFAULT_BRANCH) or DEFAULT_BRANCH,
}
def format_patch_config(
*,
forge: str,
host: str,
username: str,
repo: str = "YOUR_PATCH_REPO",
branch: str = DEFAULT_BRANCH,
) -> str:
"""Render a patch-config.txt body."""
forge = normalize_forge(forge)
host = (host or default_host_for_forge(forge)).strip()
username = (username or "").strip() or "YOUR_ORG_OR_USER"
repo = (repo or "").strip() or "YOUR_PATCH_REPO"
branch = (branch or "").strip() or DEFAULT_BRANCH
lines = [
"# Generated by DazedMTLTool from Config → Game Update defaults.",
"# Edit repo= per game. forge/host/username usually stay the same.",
f"forge={forge}",
f"host={host}",
f"username={username}",
f"repo={repo}",
f"branch={branch}",
"",
]
return "\n".join(lines)
def write_patch_config(
game_root: str | Path,
*,
repo: str | None = None,
env_path: str | Path | None = None,
overwrite: bool = True,
) -> tuple[bool, str]:
"""Write ``<game_root>/gameupdate/patch-config.txt`` from Config defaults.
Returns ``(ok, message)``.
"""
root = Path(game_root)
dest_dir = root / "gameupdate"
dest = dest_dir / "patch-config.txt"
if dest.is_file() and not overwrite:
return False, f"left existing {dest}"
defaults = load_gameupdate_defaults(env_path)
if not defaults["username"]:
return False, (
"skipped patch-config.txt (set Game Update org/username in Config first)"
)
body = format_patch_config(
forge=defaults["forge"],
host=defaults["host"],
username=defaults["username"],
repo=repo or "YOUR_PATCH_REPO",
branch=defaults["branch"],
)
try:
dest_dir.mkdir(parents=True, exist_ok=True)
dest.write_text(body, encoding="utf-8")
except Exception as exc:
return False, f"could not write {dest}: {exc}"
return True, str(dest)