diff --git a/.gitignore b/.gitignore index 4173a2f..7782aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,5 @@ util/ace/*.exe util/ace/.tools_version.json !util/ace/offline/*.exe -# WolfDawn: ignore the Rust build tree, but keep the committed prebuilt binaries. -util/wolfdawn-master/target/ +# WolfDawn: keep the committed prebuilt binaries (offline) under version control. !util/wolfdawn/bin/** diff --git a/README.md b/README.md index 8fd858d..e36fe63 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel | **4 Package** | Either run from the loose `Data/` folder (backs up `Data.wolf` → `Data.wolf.bak`), or **Repack** a fresh `Data.wolf`, inheriting the original archive's encryption. | | **5 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. | -> **`wolf` binary:** A prebuilt `wolf` CLI is bundled under `util/wolfdawn/bin//`, so no build step is needed on supported platforms. If no bundled binary is present for your platform, the tool falls back to compiling it from the vendored source with `cargo build --release`, which requires a [Rust toolchain](https://rustup.rs/); the freshly built binary is then saved into the bundle for reuse. A clear error is shown if neither a bundled binary nor `cargo` is available. +> **`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. diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 38b8678..3cc8d11 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -452,9 +452,9 @@ class WolfWorkflowTab(QWidget): "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, so no setup is needed. " - "(If no prebuilt binary exists for your platform, it is compiled once from " - "source with cargo, which requires Rust.)" + "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." )) row = QHBoxLayout() diff --git a/util/wolfdawn-master/.gitignore b/util/wolfdawn-master/.gitignore deleted file mode 100644 index 6118616..0000000 --- a/util/wolfdawn-master/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target -**/*.rs.bk -Cargo.lock.bak -/scratch -*.tmp -*.out -*.zip diff --git a/util/wolfdawn-master/Cargo.lock b/util/wolfdawn-master/Cargo.lock deleted file mode 100644 index 61876eb..0000000 --- a/util/wolfdawn-master/Cargo.lock +++ /dev/null @@ -1,3578 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - -[[package]] -name = "accesskit" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45" -dependencies = [ - "enumn", - "serde", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - -[[package]] -name = "android-activity" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" -dependencies = [ - "android-properties", - "bitflags 2.13.0", - "cc", - "jni", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum", - "thiserror 2.0.18", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "log", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "x11rb", -] - -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.4", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2 0.6.4", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" - -[[package]] -name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" -dependencies = [ - "bitflags 2.13.0", - "log", - "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.0", - "polling", - "rustix 1.1.4", - "slab", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" -dependencies = [ - "calloop 0.13.0", - "rustix 0.38.44", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "cc" -version = "1.2.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.0", - "block2 0.6.2", - "libc", - "objc2 0.6.4", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "ecolor" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" -dependencies = [ - "bytemuck", - "emath", - "serde", -] - -[[package]] -name = "eframe" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" -dependencies = [ - "ahash", - "bytemuck", - "document-features", - "egui", - "egui-winit", - "egui_glow", - "glow", - "glutin", - "glutin-winit", - "home", - "image", - "js-sys", - "log", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "parking_lot", - "percent-encoding", - "raw-window-handle", - "ron", - "serde", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "web-time", - "winapi", - "windows-sys 0.52.0", - "winit", -] - -[[package]] -name = "egui" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" -dependencies = [ - "accesskit", - "ahash", - "emath", - "epaint", - "log", - "nohash-hasher", - "ron", - "serde", -] - -[[package]] -name = "egui-winit" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" -dependencies = [ - "ahash", - "arboard", - "egui", - "log", - "raw-window-handle", - "serde", - "smithay-clipboard", - "web-time", - "webbrowser", - "winit", -] - -[[package]] -name = "egui_extras" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" -dependencies = [ - "ahash", - "egui", - "enum-map", - "log", - "mime_guess2", -] - -[[package]] -name = "egui_glow" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" -dependencies = [ - "ahash", - "bytemuck", - "egui", - "glow", - "log", - "memoffset", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "emath" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" -dependencies = [ - "bytemuck", - "serde", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enum-map" -version = "2.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" -dependencies = [ - "enum-map-derive", - "serde", -] - -[[package]] -name = "enum-map-derive" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "enumn" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "epaint" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" -dependencies = [ - "ab_glyph", - "ahash", - "bytemuck", - "ecolor", - "emath", - "epaint_default_fonts", - "log", - "nohash-hasher", - "parking_lot", - "serde", -] - -[[package]] -name = "epaint_default_fonts" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glow" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" -dependencies = [ - "bitflags 2.13.0", - "cfg_aliases", - "cgl", - "dispatch2", - "glutin_egl_sys", - "glutin_glx_sys", - "glutin_wgl_sys", - "libloading", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "raw-window-handle", - "wayland-sys", - "windows-sys 0.52.0", - "x11-dl", -] - -[[package]] -name = "glutin-winit" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" -dependencies = [ - "cfg_aliases", - "glutin", - "raw-window-handle", - "winit", -] - -[[package]] -name = "glutin_egl_sys" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" -dependencies = [ - "gl_generator", - "windows-sys 0.52.0", -] - -[[package]] -name = "glutin_glx_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" -dependencies = [ - "gl_generator", - "x11-dl", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libmimalloc-sys" -version = "0.1.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" -dependencies = [ - "cc", -] - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "bitflags 2.13.0", - "libc", - "plain", - "redox_syscall 0.8.1", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mimalloc" -version = "0.1.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" -dependencies = [ - "libmimalloc-sys", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess2" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" -dependencies = [ - "mime", - "phf", - "phf_shared", - "unicase", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.0", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", - "objc2-foundation 0.2.2", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.0", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.0", - "dispatch2", - "objc2 0.6.4", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.0", - "dispatch2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "dispatch", - "libc", - "objc2 0.5.2", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.13.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "orbclient" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" -dependencies = [ - "libc", - "libredox", -] - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.6", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", - "unicase", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", - "unicase", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "redox_syscall" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "rfd" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" -dependencies = [ - "ashpd", - "block2 0.6.2", - "dispatch2", - "js-sys", - "log", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "pollster", - "raw-window-handle", - "urlencoding", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "ron" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" -dependencies = [ - "base64", - "bitflags 2.13.0", - "serde", - "serde_derive", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "smithay-client-toolkit" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.0", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.0", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.18", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" -dependencies = [ - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.13.0", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.13.0", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.4", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webbrowser" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" -dependencies = [ - "core-foundation 0.10.1", - "jni", - "log", - "ndk-context", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "url", - "web-sys", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.13.0", - "block2 0.5.1", - "bytemuck", - "calloop 0.13.0", - "cfg_aliases", - "concurrent-queue", - "core-foundation 0.9.4", - "core-graphics", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "memmap2", - "ndk", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit", - "orbclient", - "percent-encoding", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "smithay-client-toolkit 0.19.2", - "smol_str", - "tracing", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", - "web-sys", - "web-time", - "windows-sys 0.52.0", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wolf-archive" -version = "0.1.0" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "wolf-cli" -version = "0.1.0" -dependencies = [ - "wolf-archive", - "wolf-core", - "wolf-decompiler", - "wolf-formats", -] - -[[package]] -name = "wolf-core" -version = "0.1.0" - -[[package]] -name = "wolf-decompiler" -version = "0.1.0" -dependencies = [ - "encoding_rs", - "serde", - "serde_json", - "wolf-core", - "wolf-formats", -] - -[[package]] -name = "wolf-formats" -version = "0.1.0" -dependencies = [ - "wolf-archive", - "wolf-core", -] - -[[package]] -name = "wolf-gui" -version = "0.1.0" -dependencies = [ - "eframe", - "egui", - "egui_extras", - "encoding_rs", - "mimalloc", - "rfd", - "serde", - "serde_json", - "wolf-archive", - "wolf-core", - "wolf-decompiler", - "wolf-formats", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "libloading", - "once_cell", - "rustix 1.1.4", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.13.0", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow", -] diff --git a/util/wolfdawn-master/Cargo.toml b/util/wolfdawn-master/Cargo.toml deleted file mode 100644 index 2de7afb..0000000 --- a/util/wolfdawn-master/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[workspace] -resolver = "2" -members = ["crates/*"] - -[workspace.package] -edition = "2021" -version = "0.1.0" -license = "MIT" -repository = "https://gitgud.io/zero64801/wolfdawn" - -# Hand-rolled LZ4/AES/Huffman are intentionally dependency-free; keep it that way -# in the lower layers. Encoding (SJIS<->UTF-8) deps belong only in the decompiler layer. - -[profile.release] -opt-level = 3 -lto = "thin" -codegen-units = 1 diff --git a/util/wolfdawn-master/README.md b/util/wolfdawn-master/README.md deleted file mode 100644 index 2f5b2c9..0000000 --- a/util/wolfdawn-master/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# WolfDawn - -WolfDawn turns Wolf RPG Editor game files into a readable, editable form and back. It unpacks the -`Data.wolf` archive, decompiles event code into readable WolfScript, pulls out translatable text and -puts it back, edits databases and save files, and packs everything into a runnable game again. - -It comes as a command line tool (`wolf`) and a desktop app (WolfDawn Studio). The GUI is built for -people who do not use a terminal. Every option is a checkbox or a dropdown and every file is picked -with a normal file dialog. - -## What it does - -- Unpack and repack `Data.wolf`. Supports the current DXArchive v8 container, the older VER5 and VER6 - format used by Wolf 2.x, and WolfPro and ChaCha20 encryption. -- Decompile maps and common events into editable WolfScript, then compile them back. Untouched code - stays byte for byte the same. -- Extract every player-facing string for translation and inject the finished translation back. -- A project glossary keeps database names (items, skills, enemies) consistent across every file that - looks them up, so a renamed monster does not break by-name lookups. -- Carry an existing translation into a fresh extraction when a game updates. You only translate the - lines that are actually new. -- Edit databases in a spreadsheet grid, edit `Game.dat` (title, fonts, messages, image paths), and - edit save files. -- Fix saves so a Japanese or old save loads in a translated build. This rewrites the baked game title - and refreshes the baked strings. It handles standard saves and the GamePro Pro save format. -- Verify that a file decodes and re-encodes without loss before you ship it. - -## WolfDawn Studio (GUI) - -Launch it with `wolf gui`, or run the `wolf-gui` binary directly. Open a game folder or a `Data.wolf` -and WolfDawn Studio reads the title, version, encoding, and font, then fills the relevant file lists -for each section. New files (for example after you unpack) show up on their own. - -Sections: - -- **Project** opens a game and shows its details. -- **Archive** unpacks `Data.wolf` to a folder and repacks a folder back, with encryption and format - options. -- **Decompile** turns a map or common event into WolfScript, lets you edit it (with Ctrl+F search), - and compiles it back. -- **Database** edits a database in a grid of rows and fields. -- **Game.dat** edits the title, fonts, messages, and image paths in a simple form. -- **Translation** extracts the whole `Data` folder into a source and translation grid plus a name - glossary, then injects it. The English punctuation cleanup and the code-safety guard are checkboxes. -- **Saves** opens a `.sav`, shows the format and baked strings, lets you edit the title and strings, - and re-encrypts. It can also batch-fix a whole `Save` folder. -- **Verify** round-trips a file or a whole data folder and reports pass or fail. -- **Settings** holds the theme and the default options. They persist between runs. - -## Command line - -``` -wolf unpack -o -wolf pack -o [--encrypt --version 0x14b | --like | --format ver5|ver6] - -wolf decompile [--mode edit] [-o out.wscript] -wolf compile --base -o - -wolf db-json [-o out] -wolf db-apply --base -o -wolf gamedat-json [-o out.json] -wolf gamedat-apply --base -o - -wolf strings-extract -o -wolf strings-inject --base -o [--allow-code-drift] [--en-punct] -wolf names-extract -o -wolf names-inject --data [-o ] [--allow-code-drift] [--en-punct] -wolf names-check ... -wolf translations-merge --old ... --new -o - -wolf save-update [-o ] [--title | --game ] [--translations ...] - -wolf verify-roundtrip -wolf verify-roundtrip --corpus - -wolf gui -``` - -Exit codes: `0` ok, `2` round-trip or usage failure, `3` merge conflict, `4` crypto failure. - -## A typical translation run - -1. `wolf unpack Data.wolf -o Data` to get an editable folder. -2. `wolf strings-extract` per file and `wolf names-extract Data -o names.json` to pull the text. In the - GUI the Translation section does the whole folder at once. -3. Translate the text. Pass `--en-punct` to convert Japanese punctuation to ASCII for English. -4. `wolf strings-inject` and `wolf names-inject` to write it back. -5. Rename `Data.wolf` and keep the edited `Data` folder next to `Game.exe`, or `wolf pack Data -o - Data.wolf` to rebuild the archive. -6. `wolf save-update` so existing saves still load in the translated build. - -## Build - -``` -cargo build --release -``` - -This produces `target/release/wolf` (the CLI) and `target/release/wolf-gui` (the desktop app). The -build is plain Rust with a small set of dependencies. The crypto, codecs, and binary format readers -are all hand written. - -## Running the tests - -``` -cargo test -``` - -That runs the unit tests, which need nothing extra. A handful of integration tests want real Wolf -game data (an unpacked `Data` folder, a couple of saves, a GamePro Pro save and its ground-truth -decrypt). That data is copyrighted and not bundled, so those tests skip themselves unless you point -them at a fixtures folder. - -Set `WOLFDAWN_TEST_DATA` to a folder laid out like this and the data-dependent tests run too: - -``` -/chamber/Data/... an unpacked game Data folder (BasicData, MapData) -/chamber/Data/BasicData/Game.dat -/chamber/Data/BasicData/CommonEvent.dat -/chamber/Data/BasicData/DataBase.project (+ DataBase.dat) -/chamber/Data/MapData/TitleMap.mps -/chamber/Data.wolf the packed archive (the unpack test) -/chamber/SaveData01.sav a standard save -/pachimon/SaveData01.sav a GamePro Pro save -/pachimon/decrypted.bin the ground-truth decrypt of that save -``` - -Any file that is missing just skips the test that needs it, so a partial fixtures folder is fine. -With the variable unset every one of these tests skips and the suite still passes. - -## Project layout - -- `wolf-core` holds the low level reader and writer, the LZ4 block codec, and CRC32. -- `wolf-archive` is the `Data.wolf` container and its encryption. -- `wolf-formats` reads and writes the binary maps, common events, databases, and `Game.dat`. -- `wolf-decompiler` is the WolfScript decompiler and compiler, the translation pipeline, the database - and `Game.dat` editors, and the save codecs. -- `wolf-cli` is the `wolf` binary. -- `wolf-gui` is WolfDawn Studio. - -## Notes - -The decompiler aims for a faithful round trip. Where a region of a file is not fully understood it is -preserved as raw bytes, so a recompiled file always loads. Saves and `Game.dat` round-trip byte for -byte when you do not change a field. The GamePro Pro save format is supported for marker-3 saves. A -few other GamePro save markers are detected and skipped rather than corrupted. diff --git a/util/wolfdawn-master/crates/wolf-archive/Cargo.toml b/util/wolfdawn-master/crates/wolf-archive/Cargo.toml deleted file mode 100644 index eb24eb6..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "wolf-archive" -edition.workspace = true -version.workspace = true -license.workspace = true - -# Folded in from the standalone `wolf_unpack` crate: the DXArchive container + WolfPro -# crypto layer (DXArchive v8, WolfPro 768-byte stream cipher + AES-128-CTR, DXLib Huffman -# + LZSS, CRC32 key derivation). The crypto/codecs are hand-rolled (no crates); the only -# dependency is `encoding_rs`, used solely to decode Shift-JIS archive *filenames*. - -[dependencies] -encoding_rs = "0.8" - -[lib] -name = "wolf_archive" -path = "src/lib.rs" - -[[bin]] -name = "wolf-unpack" -path = "src/bin/wolf-unpack.rs" diff --git a/util/wolfdawn-master/crates/wolf-archive/src/bin/wolf-unpack.rs b/util/wolfdawn-master/crates/wolf-archive/src/bin/wolf-unpack.rs deleted file mode 100644 index 2e2c8b7..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/bin/wolf-unpack.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! `wolf-unpack` CLI: thin wrapper over `wolf_archive::run`. - -fn main() -> std::io::Result<()> { - let args: Vec = std::env::args().collect(); - wolf_archive::run(&args) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/chacha20.rs b/util/wolfdawn-master/crates/wolf-archive/src/chacha20.rs deleted file mode 100644 index 8fd0916..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/chacha20.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! ChaCha20 stream cipher for Wolf "ChaCha2" archives (cryptVersion `0x64` / `0xC8`). -//! -//! Standard RFC 8439 ChaCha20 with the block counter initialised to 1 and a position-addressed -//! keystream, so any byte offset can be decrypted independently. Also has the Wolf-specific -//! [`key_setup`] used by cv `0xC8`. The core is verified against the RFC 8439 §2.3.2 test -//! vector. - -fn pack4(a: &[u8]) -> u32 { - u32::from_le_bytes([a[0], a[1], a[2], a[3]]) -} - -fn rotl32(x: u32, n: u32) -> u32 { - x.rotate_left(n) -} - -fn init_block(key: &[u8; 32], nonce: &[u8; 12]) -> [u32; 16] { - let magic = b"expand 32-byte k"; - let mut s = [0u32; 16]; - for i in 0..4 { - s[i] = pack4(&magic[i * 4..]); - } - for i in 0..8 { - s[4 + i] = pack4(&key[i * 4..]); - } - s[12] = 1; // counter starts at 1 (Wolf/RFC test-vector convention) - for i in 0..3 { - s[13 + i] = pack4(&nonce[i * 4..]); - } - s -} - -macro_rules! qr { - ($x:expr, $a:expr, $b:expr, $c:expr, $d:expr) => { - $x[$a] = $x[$a].wrapping_add($x[$b]); - $x[$d] = rotl32($x[$d] ^ $x[$a], 16); - $x[$c] = $x[$c].wrapping_add($x[$d]); - $x[$b] = rotl32($x[$b] ^ $x[$c], 12); - $x[$a] = $x[$a].wrapping_add($x[$b]); - $x[$d] = rotl32($x[$d] ^ $x[$a], 8); - $x[$c] = $x[$c].wrapping_add($x[$d]); - $x[$b] = rotl32($x[$b] ^ $x[$c], 7); - }; -} - -fn block_next(state: &mut [u32; 16], ks: &mut [u32; 16]) { - ks.copy_from_slice(state); - for _ in 0..10 { - qr!(ks, 0, 4, 8, 12); - qr!(ks, 1, 5, 9, 13); - qr!(ks, 2, 6, 10, 14); - qr!(ks, 3, 7, 11, 15); - qr!(ks, 0, 5, 10, 15); - qr!(ks, 1, 6, 11, 12); - qr!(ks, 2, 7, 8, 13); - qr!(ks, 3, 4, 9, 14); - } - for i in 0..16 { - ks[i] = ks[i].wrapping_add(state[i]); - } - state[12] = state[12].wrapping_add(1); - if state[12] == 0 { - state[13] = state[13].wrapping_add(1); - } -} - -#[inline] -fn ks_byte(ks: &[u32; 16], idx: usize) -> u8 { - (ks[idx / 4] >> (8 * (idx % 4))) as u8 -} - -/// XOR the ChaCha20 keystream into `bytes`, addressing the stream at absolute byte -/// `position` (mirrors `DXArchive::KeyConv`'s ChaCha20 branch). -pub fn crypt(bytes: &mut [u8], key: &[u8; 32], nonce: &[u8; 12], position: u32) { - let mut state = init_block(key, nonce); - let mut ks = [0u32; 16]; - let mut pos = 0usize; - let mut offset = (position % 64) as usize; - state[12] = state[12].wrapping_add(position / 64); - while pos < bytes.len() { - let steps = core::cmp::min(64 - offset, bytes.len() - pos); - block_next(&mut state, &mut ks); - for i in 0..steps { - bytes[pos + i] ^= ks_byte(&ks, offset + i); - } - pos += steps; - offset = 0; - } -} - -/// Wolf cv-`0xC8` key derivation: expand 4 header bytes into a 64-byte key buffer -/// (`key[0..32]` = ChaCha key, `key[34..46]` = nonce). -pub fn key_setup(data: [u8; 4]) -> [u8; 64] { - const MOD1: [u8; 4] = [0x3F, 0xA7, 0xD2, 0x1C]; - const MOD2: [u8; 4] = [0xB4, 0xE1, 0x9D, 0x58]; - const MOD3: [u8; 4] = [0x6A, 0x2B, 0x4C, 0x8E]; - let mut key = [0u8; 64]; - for i in 0..63usize { - let index = i % 4; - let mut temp = (data[index].wrapping_add(MOD2[index])) - ^ (MOD1[index] - .wrapping_add(i as u8) - .wrapping_add(16u8.wrapping_mul(i as u8))); - temp = if i % 2 == 0 { - (temp >> 5) | (temp << 3) - } else { - (temp >> 2) | (temp << 6) - }; - key[i] = !(temp ^ data[index] ^ MOD3[index]); - } - key -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rfc8439_keystream_block() { - // RFC 8439 §2.3.2: key 00..1f, nonce 00 00 00 09 00 00 00 4a 00 00 00 00, counter 1. - let mut key = [0u8; 32]; - for i in 0..32 { - key[i] = i as u8; - } - let nonce: [u8; 12] = [0, 0, 0, 9, 0, 0, 0, 0x4a, 0, 0, 0, 0]; - let mut buf = [0u8; 64]; // XOR into zeros -> keystream - crypt(&mut buf, &key, &nonce, 0); - let expected: [u8; 64] = [ - 0x10, 0xf1, 0xe7, 0xe4, 0xd1, 0x3b, 0x59, 0x15, 0x50, 0x0f, 0xdd, 0x1f, 0xa3, 0x20, - 0x71, 0xc4, 0xc7, 0xd1, 0xf4, 0xc7, 0x33, 0xc0, 0x68, 0x03, 0x04, 0x22, 0xaa, 0x9a, - 0xc3, 0xd4, 0x6c, 0x4e, 0xd2, 0x82, 0x64, 0x46, 0x07, 0x9f, 0xaa, 0x09, 0x14, 0xc2, - 0xd7, 0x05, 0xd9, 0x8b, 0x02, 0xa2, 0xb5, 0x12, 0x9c, 0xd1, 0xde, 0x16, 0x4e, 0xb9, - 0xcb, 0xd0, 0x83, 0xe8, 0xa2, 0x50, 0x3c, 0x4e, - ]; - assert_eq!( - buf, expected, - "ChaCha20 keystream must match RFC 8439 §2.3.2" - ); - } - - #[test] - fn position_addressing_matches_contiguous() { - // Decrypting in two position-addressed chunks == one contiguous pass. - let key = [7u8; 32]; - let nonce = [3u8; 12]; - let mut whole: Vec = (0..200).map(|i| (i * 13) as u8).collect(); - let mut split = whole.clone(); - crypt(&mut whole, &key, &nonce, 0); - let (a, b) = split.split_at_mut(80); - crypt(a, &key, &nonce, 0); - crypt(b, &key, &nonce, 80); - assert_eq!(whole, split); - } -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/codec.rs b/util/wolfdawn-master/crates/wolf-archive/src/codec.rs deleted file mode 100644 index bd14b7a..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/codec.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! DXLib codecs: the Huffman decoder and the LZSS ("DXA") decompressor. - -use crate::*; -use std::io; - -#[derive(Clone, Copy)] -pub(crate) struct HuffNode { - weight: u32, - child: [i32; 2], - parent: i32, - index: u8, - bit_num: u8, - bit_array: [u8; 32], -} - -/// MSB-first bit reader over the Huffman header bitstream. -pub(crate) struct BitReader<'a> { - data: &'a [u8], - byte: usize, - bit: u8, -} - -impl<'a> BitReader<'a> { - fn new(data: &'a [u8]) -> Self { - Self { - data, - byte: 0, - bit: 0, - } - } - - fn read(&mut self, bits: u8) -> io::Result { - let mut out = 0u64; - for i in 0..bits { - let b = *self - .data - .get(self.byte) - .ok_or_else(|| invalid("huffman bitstream ended early"))?; - out |= (((b >> (7 - self.bit)) & 1) as u64) << (bits - 1 - i); - self.bit += 1; - if self.bit == 8 { - self.byte += 1; - self.bit = 0; - } - } - Ok(out) - } - - fn bytes(&self) -> usize { - self.byte + usize::from(self.bit != 0) - } -} - -pub(crate) fn huffman_decode(src: &[u8]) -> io::Result> { - let mut br = BitReader::new(src); - let original_bits = br.read(6)? as u8 + 1; - let original_size = br.read(original_bits)? as usize; - if original_size > MAX_DECODE_SIZE { - return Err(invalid(format!( - "huffman output is implausibly large: {original_size}" - ))); - } - let press_bits = br.read(6)? as u8 + 1; - let _press_size = br.read(press_bits)?; - - let mut weight = [0u16; 256]; - let mut bit_num = (br.read(3)? as u8 + 1) * 2; - let _ = br.read(1)?; - let mut save = br.read(bit_num)? as u16; - weight[0] = save; - for i in 1..256 { - bit_num = (br.read(3)? as u8 + 1) * 2; - let minus = br.read(1)?; - save = br.read(bit_num)? as u16; - weight[i] = if minus == 1 { - weight[i - 1].wrapping_sub(save) - } else { - weight[i - 1].wrapping_add(save) - }; - } - - let head_size = br.bytes(); - if head_size >= src.len() { - return Err(invalid("huffman body missing")); - } - - let mut nodes = vec![ - HuffNode { - weight: 0, - child: [-1, -1], - parent: -1, - index: 0, - bit_num: 0, - bit_array: [0; 32], - }; - 511 - ]; - for i in 0..511 { - nodes[i].weight = if i < 256 { weight[i] as u32 } else { 0 }; - } - - let mut data_num = 256usize; - let mut node_num = 256usize; - while data_num > 1 { - let mut min1: i32 = -1; - let mut min2: i32 = -1; - let mut valid = 0usize; - let mut node_index = 0usize; - while valid < data_num { - if nodes[node_index].parent != -1 { - node_index += 1; - continue; - } - valid += 1; - if min1 == -1 || nodes[min1 as usize].weight > nodes[node_index].weight { - min2 = min1; - min1 = node_index as i32; - } else if min2 == -1 || nodes[min2 as usize].weight > nodes[node_index].weight { - min2 = node_index as i32; - } - node_index += 1; - } - - let a = min1 as usize; - let b = min2 as usize; - nodes[node_num].weight = nodes[a].weight + nodes[b].weight; - nodes[node_num].child = [min1, min2]; - nodes[a].index = 0; - nodes[b].index = 1; - nodes[a].parent = node_num as i32; - nodes[b].parent = node_num as i32; - node_num += 1; - data_num -= 1; - } - - for i in 0..510 { - let mut temp = [0u8; 32]; - let mut temp_index = 0usize; - let mut temp_count = 0u8; - let mut n = i; - while nodes[n].parent != -1 { - if temp_count == 8 { - temp_count = 0; - temp_index += 1; - } - temp[temp_index] <<= 1; - temp[temp_index] |= nodes[n].index; - temp_count += 1; - nodes[i].bit_num += 1; - n = nodes[n].parent as usize; - } - - let mut bit_count = 0u8; - let mut bit_index = 0usize; - while temp_index < temp.len() { - if bit_count == 8 { - bit_count = 0; - bit_index += 1; - } - nodes[i].bit_array[bit_index] |= (temp[temp_index] & 1) << bit_count; - temp[temp_index] >>= 1; - temp_count = temp_count.wrapping_sub(1); - if temp_count == 0 { - if temp_index == 0 { - break; - } - temp_index -= 1; - temp_count = 8; - } - bit_count += 1; - } - } - - let mut bitmask = [0u16; 9]; - for i in 0..9 { - bitmask[i] = (1u16 << (i + 1)) - 1; - } - let mut node_index_table = [-1i32; 512]; - for i in 0..512usize { - for j in 0..510usize { - let bn = nodes[j].bit_num; - if bn == 0 || bn > 9 { - continue; - } - let bit_array_01 = nodes[j].bit_array[0] as u16 | ((nodes[j].bit_array[1] as u16) << 8); - if (i as u16 & bitmask[bn as usize - 1]) == (bit_array_01 & bitmask[bn as usize - 1]) { - node_index_table[i] = j as i32; - break; - } - } - } - - let press = &src[head_size..]; - let mut out = vec![0u8; original_size]; - let mut press_counter = 0usize; - let mut press_bit_counter = 0u8; - let mut press_bit_data = *press.first().ok_or_else(|| invalid("empty huffman body"))? as u16; - - for dest_counter in 0..original_size { - let mut node_index: i32; - if dest_counter >= original_size.saturating_sub(17) { - node_index = 510; - } else { - if press_bit_counter == 8 { - press_counter += 1; - press_bit_data = *press - .get(press_counter) - .ok_or_else(|| invalid("huffman body ended early"))? - as u16; - press_bit_counter = 0; - } - let next = *press.get(press_counter + 1).unwrap_or(&0) as u16; - press_bit_data = (press_bit_data | (next << (8 - press_bit_counter))) & 0x1ff; - node_index = node_index_table[press_bit_data as usize]; - if node_index < 0 { - return Err(invalid("bad huffman lookup")); - } - press_bit_counter += nodes[node_index as usize].bit_num; - if press_bit_counter >= 16 { - press_counter += 2; - press_bit_counter -= 16; - press_bit_data = - (*press.get(press_counter).unwrap_or(&0) as u16) >> press_bit_counter; - } else if press_bit_counter >= 8 { - press_counter += 1; - press_bit_counter -= 8; - press_bit_data = - (*press.get(press_counter).unwrap_or(&0) as u16) >> press_bit_counter; - } else { - press_bit_data >>= nodes[node_index as usize].bit_num; - } - } - - while node_index > 255 { - if press_bit_counter == 8 { - press_counter += 1; - press_bit_data = *press - .get(press_counter) - .ok_or_else(|| invalid("huffman body ended early"))? - as u16; - press_bit_counter = 0; - } - let index = (press_bit_data & 1) as usize; - press_bit_data >>= 1; - press_bit_counter += 1; - node_index = nodes[node_index as usize].child[index]; - } - out[dest_counter] = node_index as u8; - } - - Ok(out) -} - -pub(crate) fn dxa_decode(src: &[u8]) -> io::Result> { - if src.len() < 9 { - return Err(invalid("lz source too small")); - } - let dest_size = read_u32(src) as usize; - if dest_size > MAX_DECODE_SIZE { - return Err(invalid(format!( - "lz output is implausibly large: {dest_size}" - ))); - } - let packed_total = read_u32(&src[4..]) as usize; - if packed_total < 9 || packed_total > src.len() { - return Err(invalid("lz packed size is invalid")); - } - let mut src_left = packed_total - 9; - let key_code = src[8]; - let mut sp = 9usize; - let mut out = Vec::with_capacity(dest_size); - - while src_left > 0 { - let b = *src - .get(sp) - .ok_or_else(|| invalid("lz source ended early"))?; - if b != key_code { - out.push(b); - sp += 1; - src_left -= 1; - continue; - } - - let b1 = *src - .get(sp + 1) - .ok_or_else(|| invalid("lz source ended early"))?; - if b1 == key_code { - out.push(key_code); - sp += 2; - src_left = src_left.saturating_sub(2); - continue; - } - - let mut code = b1; - if code > key_code { - code -= 1; - } - sp += 2; - src_left = src_left.saturating_sub(2); - - let mut conbo = (code >> 3) as usize; - if (code & 4) != 0 { - conbo |= (*src - .get(sp) - .ok_or_else(|| invalid("lz source ended early"))? as usize) - << 5; - sp += 1; - src_left = src_left.saturating_sub(1); - } - conbo += 4; - - let index_size = code & 3; - let mut index = match index_size { - 0 => { - let v = *src - .get(sp) - .ok_or_else(|| invalid("lz source ended early"))? - as usize; - sp += 1; - src_left = src_left.saturating_sub(1); - v - } - 1 => { - if sp + 2 > src.len() { - return Err(invalid("lz source ended early")); - } - let v = read_u16(&src[sp..]) as usize; - sp += 2; - src_left = src_left.saturating_sub(2); - v - } - 2 => { - if sp + 3 > src.len() { - return Err(invalid("lz source ended early")); - } - let v = read_u16(&src[sp..]) as usize | ((src[sp + 2] as usize) << 16); - sp += 3; - src_left = src_left.saturating_sub(3); - v - } - _ => return Err(invalid("unsupported lz index size")), - }; - index += 1; - if index > out.len() { - return Err(invalid("lz back-reference before output start")); - } - for _ in 0..conbo { - let v = out[out.len() - index]; - out.push(v); - } - } - - if out.len() != dest_size { - return Err(invalid(format!( - "lz decoded {} bytes, expected {}", - out.len(), - dest_size - ))); - } - Ok(out) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/crypto.rs b/util/wolfdawn-master/crates/wolf-archive/src/crypto.rs deleted file mode 100644 index 1bca797..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/crypto.rs +++ /dev/null @@ -1,726 +0,0 @@ -//! WolfPro crypto: the 7-byte DXLib key (`key_create`/`key_conv`/`crc32`), the 768-byte -//! stream cipher + its key schedule (`wolf_init_key`/`wolf_crypt`), the header-address -//! scramble, and the AES-128-CTR pass used by v3.5+ archives. - -use crate::codec::{dxa_decode, huffman_decode}; -use crate::*; - -pub(crate) fn key_create(source: &[u8]) -> [u8; KEY_BYTES] { - let mut src = nul_terminated(source).to_vec(); - if src.len() < 4 { - src.extend_from_slice(DEFAULT_DXLIB_KEY); - } - let even: Vec = src.iter().step_by(2).copied().collect(); - let odd: Vec = src.iter().skip(1).step_by(2).copied().collect(); - let c0 = crc32(&even); - let c1 = crc32(&odd); - [ - c0 as u8, - (c0 >> 8) as u8, - (c0 >> 16) as u8, - (c0 >> 24) as u8, - c1 as u8, - (c1 >> 8) as u8, - (c1 >> 16) as u8, - ] -} - -pub(crate) fn nul_terminated(source: &[u8]) -> &[u8] { - source - .iter() - .position(|&b| b == 0) - .map(|end| &source[..end]) - .unwrap_or(source) -} - -pub(crate) fn crc32(data: &[u8]) -> u32 { - let mut table = [0u32; 256]; - for i in 0..256u32 { - let mut v = i; - for _ in 0..8 { - let b = v & 1; - v >>= 1; - if b != 0 { - v ^= 0xedb88320; - } - } - table[i as usize] = v; - } - let mut crc = 0xffff_ffffu32; - for &b in data { - crc = table[((crc ^ b as u32) & 0xff) as usize] ^ (crc >> 8); - } - crc ^ 0xffff_ffff -} - -pub(crate) fn key_conv(data: &mut [u8], key: &[u8; KEY_BYTES], pos: usize) { - for (i, byte) in data.iter_mut().enumerate() { - *byte ^= key[(pos + i) % KEY_BYTES]; - } -} - -pub(crate) fn is_new_wolf_crypt(crypt_version: u16) -> bool { - (crypt_version >= 331 && crypt_version < 1000) || crypt_version >= 1010 -} - -pub(crate) fn is_wolf_v35(crypt_version: u16) -> bool { - (crypt_version >= 0x15e && crypt_version < 0x3e8) || crypt_version >= 0x3fc -} - -pub(crate) fn candidate_matches_wolf_version(label: &str, crypt_version: u16) -> bool { - label == "requested" - || matches!( - (crypt_version, label), - (0x14b, "wolf-v3.31") - | (0x15e, "wolf-v3.50") - | (0x64, "wolf-chacha2") - | (0xc8, "wolf-chacha2") - ) -} - -#[derive(Clone, Copy)] -pub(crate) struct MsvcRand { - state: u32, -} - -impl MsvcRand { - fn new(seed: u32) -> Self { - Self { state: seed } - } - - fn next(&mut self) -> u32 { - self.state = self.state.wrapping_mul(214013).wrapping_add(2531011); - (self.state >> 16) & 0x7fff - } -} - -pub(crate) fn wolf_init_key( - crypt_version: u16, - pwd: &[u8; 15], - key2: Option<&[u8]>, - other: bool, - key_string: &[u8], -) -> [u8; 768] { - let mut key = [0u8; 768]; - let mut fac = [0u8; 3]; - - let s0 = pwd[2]; - let s1 = pwd[5]; - let s2 = pwd[12]; - let mut s3 = 0u8; - - if !other { - let len = pwd[11] / 3; - for i in 0..len { - s3 = i ^ (s3 ^ pwd[i as usize % 15]).rotate_right(3); - } - } else { - let len = pwd[8] / 4; - for i in 0..len { - s3 = i ^ (s3 ^ pwd[i as usize % 15]).rotate_right(2); - } - } - - let seed = (s0 as u32) * (s1 as u32) + (s2 as u32) + (s3 as u32); - let mut rng = MsvcRand::new(seed); - - fac[s3 as usize % 3] = (rng.next() % 256) as u8; - - if !other && is_wolf_v35(crypt_version) { - fac[1] = (rng.next() % 0xfb) as u8; - } - - for i in 0..256 { - let rn = (rng.next() & 0xffff) as u16; - key[i] = fac[0] ^ (rng.next() as u8); - key[i + 256] = fac[1] ^ (rn >> 8) as u8; - key[i + 512] = fac[2] ^ rn as u8; - } - - if let Some(key2) = key2 { - for j in 0..128 { - let rn = (rng.next() & 0xffff) as u16; - key[j] ^= s3 ^ key2[2] ^ (rn >> 8) as u8; - key[j + 256] ^= s3 ^ key2[0] ^ rn as u8; - } - } - - if other { - let mut salt = [0u8; 128]; - let salt_source = if crypt_version == 0x15e { - b"958".as_slice() - } else { - nul_terminated(key_string) - }; - calc_wolf_salt(salt_source, &mut salt); - - let mut mod_factor = 7u8; - if is_wolf_v35(crypt_version) { - s3 = s3.wrapping_add(0x22); - mod_factor = 16; - } - - for i in 0..3usize { - let mut t = s3 as i32; - for j in 0..256usize { - let mut skip = false; - let cur_s = salt[j & 0x7f]; - let cur_s2 = salt[(j + i) % 0x80]; - let cur_k = key[i * 256 + j]; - let s_x_k = cur_s ^ cur_k; - let round = (((cur_s2 as u16) | ((cur_s as u16) << 8)) % mod_factor as u16) as u8; - let mut new_k = s_x_k; - - match round { - 1 => { - if cur_s2 % 0x0b == 0 { - new_k = cur_k; - } - } - 2 => { - if cur_s % 0x1d == 0 { - new_k = !s_x_k; - } - } - 3 => { - if ((round as usize + j) % 0x25) == 0 { - new_k = cur_s2 ^ s_x_k; - } - } - 4 => { - if ((cur_s as u16 + cur_s2 as u16) % 97) == 0 { - new_k = cur_s.wrapping_add(s_x_k); - } - } - 5 => { - if ((j * round as usize) % 0x7b) == 0 { - new_k = s_x_k ^ t as u8; - } - } - 6 => { - if cur_s == 0xff && cur_s2 == 0 { - new_k = 0; - skip = true; - } - } - 7 => { - if crypt_version >= 0x154 - && !(crypt_version > 0x3e8 && crypt_version < 0x3fc) - && (((round as usize + j) % 0x33) == 0 || crypt_version >= 0x3fc) - { - new_k ^= cur_s; - } - } - 8 => { - if crypt_version >= 0x154 - && !(crypt_version > 0x3e8 && crypt_version < 0x3fc) - && ((cur_s % 0x1d) == 0 || crypt_version >= 0x3fc) - { - new_k ^= cur_s; - } - } - _ => {} - } - - if ((j + i) % (cur_s as usize % 5 + 1)) == 0 { - new_k ^= t as u8; - } else if skip { - new_k = !s_x_k; - } - - key[i * 256 + j] = new_k; - t += i as i32; - } - } - } - - key -} - -pub(crate) fn calc_wolf_salt(source: &[u8], salt: &mut [u8; 128]) { - let source = if source.is_empty() { - DEFAULT_DXLIB_KEY - } else { - source - }; - for i in 0..128 { - salt[i] = (i / source.len()) as u8 + source[i % source.len()]; - } -} - -pub(crate) fn wolf_crypt(key: &[u8; 768], data: &mut [u8], start: usize, crypt_version: u16) { - let mut v1 = start % 256; - let mut v2 = (start / 256) % 256; - let mut v3 = (start / 0x10000) % 256; - - if is_wolf_v35(crypt_version) { - let mut modded = [0u8; 512]; - for i in 0..512 { - modded[i] = key[i % 256] ^ (7u8.wrapping_mul(i as u8)); - } - for byte in data { - *byte ^= modded[v1] ^ modded[v2 + 256]; - v1 += 1; - if v1 == 256 { - v1 = 0; - v2 = (v2 + 1) % 256; - } - } - } else { - for byte in data { - *byte ^= key[v1] ^ key[v2 + 256] ^ key[v3 + 512]; - v1 += 1; - if v1 == 256 { - v1 = 0; - v2 += 1; - if v2 == 256 { - v2 = 0; - v3 = (v3 + 1) % 256; - } - } - } - } -} - -pub(crate) fn wolf_crypt_addresses(data: &mut [u8], pwd: &[u8; 15], crypt_version: u16) { - if data.len() < 64 { - return; - } - - if is_wolf_v35(crypt_version) { - let seed = 0x0c + pwd[9] as u32 * pwd[10] as u32 + pwd[3] as u32; - let mut rng = MsvcRand::new(seed); - let mut word = 4usize; - for _ in 0..2 { - for j in (0..4).rev() { - xor_u16_at(data, (word + j) * 2, (rng.next() & 0xffff) as u16); - } - word += 4; - } - let r0 = (rng.next() as u64) << 17; - let r1 = (rng.next() as u64) << 31; - let v0 = ((r0 & 0xffff_ffff) | (r1 & 0xffff_ffff) | rng.next() as u64) as u32; - let v1 = ((r0 >> 32) | (r1 >> 32)) as u32; - xor_u32_at(data, word * 2, v0); - xor_u32_at(data, word * 2 + 4, v1); - word += 4; - for j in (0..4).rev() { - xor_u16_at(data, (word + j) * 2, (rng.next() & 0xffff) as u16); - } - } else { - let seed = pwd[0] as u32 + pwd[7] as u32 * pwd[12] as u32; - let mut rng = MsvcRand::new(seed); - let mut word = 4usize; - for _ in 0..4 { - for j in (0..4).rev() { - xor_u16_at(data, (word + j) * 2, (rng.next() & 0xffff) as u16); - } - word += 4; - } - } -} - -pub(crate) fn xor_u16_at(data: &mut [u8], off: usize, value: u16) { - if off + 2 <= data.len() { - let v = read_u16(&data[off..]) ^ value; - data[off..off + 2].copy_from_slice(&v.to_le_bytes()); - } -} - -pub(crate) fn xor_u32_at(data: &mut [u8], off: usize, value: u32) { - if off + 4 <= data.len() { - let v = read_u32(&data[off..]) ^ value; - data[off..off + 4].copy_from_slice(&v.to_le_bytes()); - } -} - -pub(crate) fn wolf_aes_body_size( - file_size: usize, - crypt_version: u16, - pwd: &[u8; 15], - key2: Option<&[u8]>, -) -> usize { - let body_len = file_size.saturating_sub(64); - if body_len < 0x400 { - return 0; - } - if !is_wolf_v35(crypt_version) { - return 0x400; - } - - let mut seed = if crypt_version >= 1020 { - let key2 = key2.unwrap_or(&[0, 0, 0, 0]); - key2[0] as u32 * key2[1] as u32 + pwd[2] as u32 * pwd[4] as u32 + pwd[11] as u32 - } else { - pwd[2] as u32 * pwd[4] as u32 + pwd[12] as u32 - }; - if seed == 0 { - seed = 1; - } - let mut xs = XorShift32::new(seed); - let first = xs.next() % 500 + 800; - if file_size >= first as usize { - xs.next(); - } - let mut body_size = body_len; - let limit = xs.next() % 500 + 800; - if body_size >= limit as usize { - body_size = (xs.next() % 500 + 800) as usize; - } - body_size -} - -pub(crate) struct XorShift32 { - state: u32, -} - -impl XorShift32 { - fn new(seed: u32) -> Self { - Self { state: seed } - } - - fn next(&mut self) -> u32 { - self.state ^= self.state << 0x0b; - self.state ^= self.state >> 0x13; - self.state ^= self.state << 0x07; - self.state - } -} - -pub(crate) fn read_wolf_table(data: &[u8], head: &DxHead, wolf: &WolfContext) -> Option> { - let start = head.name_table_start as usize; - let no_key = (head.flags & 1) != 0; - let no_head_press = (head.flags & 2) != 0; - - if no_head_press { - let mut table = read_wolf_archive_slice(data, start, head.head_size as usize, wolf)?; - if !no_key { - wolf_crypt(&wolf.special_key, &mut table, 0, wolf.crypt_version); - } - Some(table) - } else { - let huff_len = data.len().checked_sub(start)?; - let mut huff = read_wolf_archive_slice(data, start, huff_len, wolf)?; - if !no_key { - wolf_crypt(&wolf.special_key, &mut huff, 0, wolf.crypt_version); - } - let lz = huffman_decode(&huff).ok()?; - dxa_decode(&lz).ok() - } -} - -pub(crate) fn read_wolf_archive_slice( - data: &[u8], - start: usize, - len: usize, - wolf: &WolfContext, -) -> Option> { - let end = start.checked_add(len)?; - let mut out = data.get(start..end)?.to_vec(); - let file_size = data.len(); - - apply_wolf_stream_overlap( - &mut out, - start, - 64, - file_size.saturating_sub(64), - &wolf.other_key, - wolf.crypt_version, - ); - - let pass1_end = 64usize.saturating_add(wolf.body_size).min(file_size); - apply_aes_overlap(&mut out, start, 64, pass1_end, &wolf.aes_round_key, 0); - - if wolf.name_table_start < file_size { - let body_blocks = (wolf.body_size + AES_BLOCK_LEN - 1) / AES_BLOCK_LEN; - apply_aes_overlap( - &mut out, - start, - wolf.name_table_start, - file_size, - &wolf.aes_round_key, - body_blocks, - ); - } - - Some(out) -} - -pub(crate) fn apply_wolf_stream_overlap( - out: &mut [u8], - out_start: usize, - range_start: usize, - range_end: usize, - key: &[u8; 768], - crypt_version: u16, -) { - let out_end = out_start.saturating_add(out.len()); - let begin = out_start.max(range_start); - let end = out_end.min(range_end); - if begin >= end { - return; - } - let local = begin - out_start; - wolf_crypt( - key, - &mut out[local..local + (end - begin)], - begin, - crypt_version, - ); -} - -pub(crate) fn apply_aes_overlap( - out: &mut [u8], - out_start: usize, - range_start: usize, - range_end: usize, - round_key: &[u8; AES_ROUND_KEY_SIZE], - iv_block_offset: usize, -) { - let out_end = out_start.saturating_add(out.len()); - let begin = out_start.max(range_start); - let end = out_end.min(range_end); - if begin >= end { - return; - } - let local = begin - out_start; - let stream_offset = begin - range_start; - aes_ctr_xcrypt_at( - &mut out[local..local + (end - begin)], - round_key, - stream_offset, - iv_block_offset, - ); -} - -pub(crate) const AES_SBOX: [u8; 256] = [ - 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, - 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, - 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, - 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, - 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, - 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, - 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, - 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, - 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, - 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, - 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, - 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, - 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, - 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, - 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, - 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, -]; -pub(crate) const AES_RCON: [u8; 11] = [ - 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, -]; - -pub(crate) fn wolf_aes_init_round_key( - pwd: &[u8; 15], - pro_key: Option<&[u8]>, - crypt_version: u16, -) -> [u8; AES_ROUND_KEY_SIZE] { - let zero = [0u8; 4]; - let pro = pro_key.unwrap_or(&zero); - let mut key = [0u8; AES_BLOCK_LEN]; - let mut iv = [0u8; AES_BLOCK_LEN]; - - if is_wolf_v35(crypt_version) { - for i in 0..15usize { - let pro_elem = pro[i % 4]; - let key_idx = ((i * (pro_elem as usize % 5 + 7)) ^ (3 * pwd[i] as usize)) % 15; - let iv_idx = (i * (pro[(i + 1) % 4] as usize % 7 + 0x0b) - ^ (5 * pwd[(i + 3) % 15] as usize)) - % 15; - - key[i] ^= ((i as u8 ^ pro_elem) - .wrapping_add(pwd[key_idx].wrapping_shl((i % 3) as u32))) - % 0xfb; - iv[i] ^= (pwd[iv_idx] - .wrapping_shr((i % 2) as u32) - .wrapping_add((i * i) as u8 ^ pro[(i + 2) % 4])) - % 0xf6; - key[15] ^= (7u16 * (pwd[i].wrapping_add((i as u8 + 1) ^ pro_elem)) as u16 % 0xfd) as u8; - iv[15] ^= (11u16 * (pwd[i].wrapping_sub((i as u8 * 2) ^ pro[(i + 2) % 4])) as u16 - % 0x100) as u8; - } - } else if crypt_version == 0x3f2 { - for i in 0..15usize { - key[i] ^= pwd[(i * 7) % 15] - .wrapping_add(pro[i & 3]) - .wrapping_mul((i * i) as u8); - iv[i] ^= pwd[(i * 11) % 15] - .wrapping_add(pro[(i + 2) % 4]) - .wrapping_sub((i * i) as u8); - key[15] ^= (i as u8) - .wrapping_mul(3) - .wrapping_add(pwd[i]) - .wrapping_add(pro[i & 3]); - iv[15] ^= (i as u8) - .wrapping_mul(5) - .wrapping_add(pwd[i]) - .wrapping_add(pro[(i + 2) % 4]); - } - } else { - for i in 0..15usize { - key[i] ^= pwd[(i * 7) % 15].wrapping_add((i * i) as u8); - iv[i] ^= pwd[(i * 11) % 15].wrapping_sub((i * i) as u8); - key[15] ^= pwd[i].wrapping_add((i * 3) as u8); - iv[15] ^= pwd[i].wrapping_add((i * 5) as u8); - } - } - - key[0] ^= pro[0]; - iv[10] ^= pro[0]; - key[4] ^= pro[1]; - iv[1] ^= pro[1]; - key[8] ^= pro[2]; - iv[4] ^= pro[2]; - key[12] ^= pro[3]; - iv[7] ^= pro[3]; - - let mut round_key = [0u8; AES_ROUND_KEY_SIZE]; - aes_key_expansion(&mut round_key[..AES_KEY_EXP_SIZE], &key); - round_key[AES_KEY_EXP_SIZE..].copy_from_slice(&iv); - round_key -} - -pub(crate) fn aes_key_expansion(round_key: &mut [u8], key: &[u8; AES_BLOCK_LEN]) { - for i in 0..4 { - round_key[i * 4..i * 4 + 4].copy_from_slice(&key[i * 4..i * 4 + 4]); - } - - for i in 4..44usize { - let mut temp = [ - round_key[(i - 1) * 4], - round_key[(i - 1) * 4 + 1], - round_key[(i - 1) * 4 + 2], - round_key[(i - 1) * 4 + 3], - ]; - - if i % 4 == 0 { - temp.rotate_left(1); - temp[0] = AES_SBOX[temp[0] as usize] ^ AES_RCON[i / 4]; - temp[1] = AES_SBOX[temp[1] as usize] >> 4; - temp[2] = !AES_SBOX[temp[2] as usize]; - temp[3] = AES_SBOX[temp[3] as usize].rotate_right(7); - } - - for j in 0..4 { - round_key[i * 4 + j] = round_key[(i - 4) * 4 + j] ^ temp[j]; - } - } -} - -pub(crate) fn aes_ctr_xcrypt_at( - data: &mut [u8], - round_key: &[u8; AES_ROUND_KEY_SIZE], - stream_offset: usize, - iv_block_offset: usize, -) { - let mut local_key = *round_key; - let block_offset = stream_offset / AES_BLOCK_LEN + iv_block_offset; - aes_advance_iv(&mut local_key[AES_KEY_EXP_SIZE..], block_offset); - - let mut state = [0u8; AES_BLOCK_LEN]; - let rem = stream_offset % AES_BLOCK_LEN; - let mut bi = AES_BLOCK_LEN; - if rem != 0 { - aes_next_ctr_block(&mut local_key, &mut state); - bi = rem; - } - - for byte in data { - if bi == AES_BLOCK_LEN { - aes_next_ctr_block(&mut local_key, &mut state); - bi = 0; - } - *byte ^= state[bi]; - bi += 1; - } -} - -pub(crate) fn aes_next_ctr_block( - round_key: &mut [u8; AES_ROUND_KEY_SIZE], - state: &mut [u8; AES_BLOCK_LEN], -) { - state.copy_from_slice(&round_key[AES_KEY_EXP_SIZE..]); - aes_cipher(state, &round_key[..AES_KEY_EXP_SIZE]); - aes_increment_iv(&mut round_key[AES_KEY_EXP_SIZE..]); -} - -pub(crate) fn aes_advance_iv(iv: &mut [u8], blocks: usize) { - for _ in 0..blocks { - aes_increment_iv(iv); - } -} - -pub(crate) fn aes_increment_iv(iv: &mut [u8]) { - for byte in iv.iter_mut().rev() { - if *byte == 0xff { - *byte = 0; - } else { - *byte = byte.wrapping_add(1); - break; - } - } -} - -pub(crate) fn aes_cipher(state: &mut [u8; AES_BLOCK_LEN], round_key: &[u8]) { - aes_add_round_key(state, 0, round_key); - for round in 1..10u8 { - aes_sub_bytes(state); - aes_shift_rows(state); - aes_mix_columns(state); - aes_add_round_key(state, round, round_key); - } - aes_sub_bytes(state); - aes_shift_rows(state); - aes_add_round_key(state, 10, round_key); -} - -pub(crate) fn aes_add_round_key(state: &mut [u8; AES_BLOCK_LEN], round: u8, round_key: &[u8]) { - let start = round as usize * AES_BLOCK_LEN; - for i in 0..AES_BLOCK_LEN { - state[i] ^= round_key[start + i]; - } -} - -pub(crate) fn aes_sub_bytes(state: &mut [u8; AES_BLOCK_LEN]) { - for byte in state { - *byte = AES_SBOX[*byte as usize]; - } -} - -pub(crate) fn aes_shift_rows(state: &mut [u8; AES_BLOCK_LEN]) { - let temp = state[1]; - state[1] = state[5]; - state[5] = state[9]; - state[9] = state[13]; - state[13] = temp; - - state.swap(2, 10); - state.swap(6, 14); - - let temp = state[3]; - state[3] = state[15]; - state[15] = state[11]; - state[11] = state[7]; - state[7] = temp; -} - -pub(crate) fn aes_xtime(x: u8) -> u8 { - (x << 1) ^ (((x >> 7) & 1) * 0x1b) -} - -pub(crate) fn aes_mix_columns(state: &mut [u8; AES_BLOCK_LEN]) { - for col in 0..4 { - let base = col * 4; - let t = state[base]; - let tmp = state[base] ^ state[base + 1] ^ state[base + 2] ^ state[base + 3]; - state[base] ^= tmp ^ aes_xtime(state[base + 1] ^ state[base]); - state[base + 1] ^= tmp ^ aes_xtime(state[base + 2] ^ state[base + 1]); - state[base + 2] ^= tmp ^ aes_xtime(state[base + 2] ^ state[base + 3]); - state[base + 3] ^= tmp ^ aes_xtime(state[base + 3] ^ t); - } -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/common.rs b/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/common.rs deleted file mode 100644 index 977ec3b..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/common.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Shared primitives for the DXArchive VER5/VER6 (Wolf 2.0x) decoders: the 12-byte key -//! derivation and `KeyConv`, the keycode-LZSS decompressor, and file-name extraction. These -//! are byte-for-byte identical between VER5 and VER6. Only the table/struct layout differs. - -pub(super) const KEYLEN: usize = 12; -const MIN_COMPRESS: usize = 4; - -/// `KeyCreate`: loop the source string to 12 bytes, then a fixed per-byte transform. -pub(super) fn key_create(source: &[u8]) -> [u8; KEYLEN] { - let mut k = [0xaau8; KEYLEN]; - if !source.is_empty() { - for (i, b) in k.iter_mut().enumerate() { - *b = source[i % source.len()]; - } - } - k[0] = !k[0]; - k[1] = (k[1] >> 4) | (k[1] << 4); - k[2] ^= 0x8a; - k[3] = !((k[3] >> 4) | (k[3] << 4)); - k[4] = !k[4]; - k[5] ^= 0xac; - k[6] = !k[6]; - k[7] = !((k[7] >> 3) | (k[7] << 5)); - k[8] = (k[8] >> 5) | (k[8] << 3); - k[9] ^= 0x7f; - k[10] = ((k[10] >> 4) | (k[10] << 4)) ^ 0xd6; - k[11] ^= 0xcc; - k -} - -/// XOR `buf` with the key starting at stream position `pos` (DXLib `KeyConv`). -pub(super) fn key_conv(key: &[u8; KEYLEN], buf: &[u8], pos: usize) -> Vec { - buf.iter() - .enumerate() - .map(|(i, &b)| b ^ key[(pos + i) % KEYLEN]) - .collect() -} - -pub(super) fn u32at(d: &[u8], o: usize) -> Option { - d.get(o..o + 4) - .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) -} - -pub(super) fn u64at(d: &[u8], o: usize) -> Option { - d.get(o..o + 8) - .map(|b| u64::from_le_bytes(b.try_into().unwrap())) -} - -/// The original (display) file name: at `name_addr + 4 + packs*4`, NUL-terminated, Shift-JIS. -pub(super) fn name(d: &[u8], name_addr: usize) -> Option { - let packs = u16::from_le_bytes([*d.get(name_addr)?, *d.get(name_addr + 1)?]) as usize; - let start = name_addr + 4 + packs * 4; - let end = d[start..].iter().position(|&b| b == 0).map(|p| start + p)?; - Some(crate::decode_filename(&d[start..end])) -} - -/// The keycode-LZSS used by both VER5 and VER6: `[destSize u32][srcSize u32][keycode u8][stream]`. -pub(super) fn decode_lz(src: &[u8], dest_size: usize) -> Option> { - if src.len() < 9 { - return None; - } - let mut remaining = (u32at(src, 4)? as usize).checked_sub(9)?; - let keycode = src[8]; - let mut out: Vec = Vec::with_capacity(dest_size); - let mut sp = 9usize; - - while remaining > 0 { - let b = *src.get(sp)?; - if b != keycode { - out.push(b); - sp += 1; - remaining -= 1; - continue; - } - if *src.get(sp + 1)? == keycode { - out.push(keycode); - sp += 2; - remaining -= 2; - continue; - } - let mut code = *src.get(sp + 1)? as u32; - if code > keycode as u32 { - code -= 1; - } - sp += 2; - remaining -= 2; - - let mut conbo = (code >> 3) as usize; - if code & 0x4 != 0 { - conbo |= (*src.get(sp)? as usize) << 5; - sp += 1; - remaining -= 1; - } - conbo += MIN_COMPRESS; - - let mut index = match code & 0x3 { - 0 => { - let v = *src.get(sp)? as usize; - sp += 1; - remaining -= 1; - v - } - 1 => { - let v = u16::from_le_bytes([*src.get(sp)?, *src.get(sp + 1)?]) as usize; - sp += 2; - remaining -= 2; - v - } - _ => { - let v = u16::from_le_bytes([*src.get(sp)?, *src.get(sp + 1)?]) as usize - | ((*src.get(sp + 2)? as usize) << 16); - sp += 3; - remaining -= 3; - v - } - }; - index += 1; - - if index > out.len() { - return None; // back-reference before start - } - if index < conbo { - // Overlapping run via doubling memcpy (matches DXLib exactly). - let mut num = index; - let mut left = conbo; - while left > num { - let s = out.len() - num; - for j in 0..num { - let v = out[s + j]; - out.push(v); - } - left -= num; - num += num; - } - if left != 0 { - let s = out.len() - num; - for j in 0..left { - let v = out[s + j]; - out.push(v); - } - } - } else { - let s = out.len() - index; - for j in 0..conbo { - let v = out[s + j]; - out.push(v); - } - } - } - Some(out) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/encode.rs b/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/encode.rs deleted file mode 100644 index 0172205..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/encode.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! DXArchive VER5 / VER6 writers: the inverse of [`super::ver5`] / [`super::ver6`]. -//! -//! Produce the old Wolf 2.0x containers, storing files uncompressed so no keycode-LZSS encoder -//! is needed (the reader reads `press_size == 0xFFFF…` verbatim). Both ciphers are a symmetric -//! `key_conv` XOR, so encryption is the reader's decrypt applied to the plaintext container. -//! Header layouts mirror the real sample archives exactly. VER5 is version 3 with a 24-byte -//! header and whole-archive XOR by file offset. VER6 is version 6 with a 48-byte header, tables -//! keyed at position 0, and each file's data at position `data_size`. The shared tree walk and -//! name-table encoder come from [`crate::pack`]. - -use super::common::{key_conv, key_create, KEYLEN}; -use crate::pack::{add_filename, Node}; - -const DX_HEAD: u16 = 0x5844; -const ATTR_DIRECTORY: u64 = 0x10; -const ATTR_ARCHIVE: u64 = 0x20; - -/// VER5 v2.01 / VER6 v2.20 table keys. The reader tries these, so the output decodes through -/// our own dispatch and the real engine. -const KEY_V201: &[u8] = &[ - 0x0f, 0x53, 0xe1, 0x3e, 0x04, 0x37, 0x12, 0x17, 0x60, 0x0f, 0x53, 0xe1, -]; -const KEY_V220: &[u8] = &[ - 0x38, 0x50, 0x40, 0x28, 0x72, 0x4f, 0x21, 0x70, 0x3b, 0x73, 0x35, 0x38, -]; - -/// Per-version struct geometry. Field offsets within a file head: `name_addr@0`, `attr@ptr`, -/// time block, `data_addr@data_addr_off`, `data_size@+ptr`, `press@+2*ptr`. -struct Spec { - ptr: usize, - fh_size: usize, - data_addr_off: usize, -} -const V5: Spec = Spec { - ptr: 4, - fh_size: 44, - data_addr_off: 32, -}; -const V6: Spec = Spec { - ptr: 8, - fh_size: 64, - data_addr_off: 40, -}; - -impl Spec { - fn none(&self) -> u64 { - if self.ptr == 4 { - 0xFFFF_FFFF - } else { - u64::MAX - } - } - fn dir_size(&self) -> usize { - self.ptr * 4 - } -} - -fn put(buf: &mut [u8], off: usize, val: u64, ptr: usize) { - buf[off..off + ptr].copy_from_slice(&val.to_le_bytes()[..ptr]); -} - -fn get(buf: &[u8], off: usize, ptr: usize) -> u64 { - let mut b = [0u8; 8]; - b[..ptr].copy_from_slice(&buf[off..off + ptr]); - u64::from_le_bytes(b) -} - -#[derive(Default)] -struct Build { - name: Vec, - file: Vec, - dir: Vec, - data: Vec, - /// `(data_addr, data_size)` per leaf file. VER6 keys each region at `pos = data_size`. - file_regions: Vec<(u64, u64)>, -} - -/// Build the name/file/dir/data buffers for `root` using `spec`'s geometry (mirrors the v8 -/// `DirectoryEncode`: root self-filehead, per-directory contiguous file runs). -fn build_tables(spec: &Spec, root: &Node) -> Build { - let mut b = Build::default(); - add_filename(&mut b.name, ""); // root name (address 0) - b.file.resize(spec.fh_size, 0); - write_filehead(spec, &mut b.file, 0, 0, ATTR_DIRECTORY, 0, 0); - - let root_num = root.entry_count() as u64; - let root_fha = b.file.len() as u64; // = fh_size - append_dir(spec, &mut b.dir, 0, spec.none(), root_num, root_fha); - b.file - .resize(b.file.len() + spec.fh_size * root_num as usize, 0); - - encode_children(spec, &mut b, root, root_fha, 0); - b -} - -fn encode_children(spec: &Spec, b: &mut Build, node: &Node, fha: u64, this_dir_addr: u64) { - let mut i = 0u64; - for (name, child) in &node.dirs { - directory_encode(spec, b, child, name, fha, this_dir_addr, i); - i += 1; - } - for (name, data) in &node.files { - write_file_entry(spec, b, name, data, fha, i); - i += 1; - } -} - -fn directory_encode( - spec: &Spec, - b: &mut Build, - node: &Node, - name: &str, - parent_fha: u64, - parent_dir_addr: u64, - data_number: u64, -) { - let name_addr = b.name.len() as u64; - let this_dir_offset = b.dir.len() as u64; - add_filename(&mut b.name, name); - let self_off = (parent_fha + data_number * spec.fh_size as u64) as usize; - write_filehead( - spec, - &mut b.file, - self_off, - name_addr, - ATTR_DIRECTORY, - this_dir_offset, - 0, - ); - - let this_fha = b.file.len() as u64; - let parent_directory_address = if parent_dir_addr != spec.none() && parent_dir_addr != 0 { - get( - &b.file, - parent_dir_addr as usize + spec.data_addr_off, - spec.ptr, - ) - } else { - 0 - }; - let num = node.entry_count() as u64; - append_dir( - spec, - &mut b.dir, - self_off as u64, - parent_directory_address, - num, - this_fha, - ); - b.file.resize(b.file.len() + spec.fh_size * num as usize, 0); - - encode_children(spec, b, node, this_fha, self_off as u64); -} - -fn write_file_entry( - spec: &Spec, - b: &mut Build, - name: &str, - data: &[u8], - fha: u64, - data_number: u64, -) { - let name_addr = b.name.len() as u64; - add_filename(&mut b.name, name); - let data_addr = b.data.len() as u64; - let off = (fha + data_number * spec.fh_size as u64) as usize; - write_filehead( - spec, - &mut b.file, - off, - name_addr, - ATTR_ARCHIVE, - data_addr, - data.len() as u64, - ); - b.file_regions.push((data_addr, data.len() as u64)); - b.data.extend_from_slice(data); - while b.data.len() % 4 != 0 { - b.data.push(0); - } -} - -fn write_filehead( - spec: &Spec, - file: &mut [u8], - off: usize, - name_addr: u64, - attr: u64, - data_addr: u64, - data_size: u64, -) { - put(file, off, name_addr, spec.ptr); - put(file, off + spec.ptr, attr, spec.ptr); - // time block [2*ptr .. data_addr_off] stays zero - put(file, off + spec.data_addr_off, data_addr, spec.ptr); - put( - file, - off + spec.data_addr_off + spec.ptr, - data_size, - spec.ptr, - ); - put( - file, - off + spec.data_addr_off + 2 * spec.ptr, - spec.none(), - spec.ptr, - ); // press = uncompressed -} - -fn append_dir(spec: &Spec, dir: &mut Vec, dir_addr: u64, parent: u64, fhn: u64, fha: u64) { - let base = dir.len(); - dir.resize(base + spec.dir_size(), 0); - put(dir, base, dir_addr, spec.ptr); - put(dir, base + spec.ptr, parent, spec.ptr); - put(dir, base + 2 * spec.ptr, fhn, spec.ptr); - put(dir, base + 3 * spec.ptr, fha, spec.ptr); -} - -fn build_tree<'a>(files: &'a [(String, Vec)]) -> Result, String> { - if files.is_empty() { - return Err("cannot pack an empty file list".to_string()); - } - let mut root = Node::default(); - for (path, data) in files { - let parts: Vec<&str> = path.split(['\\', '/']).filter(|s| !s.is_empty()).collect(); - if parts.is_empty() { - return Err(format!("invalid (empty) path: {path:?}")); - } - root.insert(&parts, data); - } - Ok(root) -} - -/// Pack into a Wolf 2.01/2.10 (VER5, version 3) archive. Build the plaintext container, then -/// XOR the whole file by offset with the version key, which is the reader's exact inverse. -pub fn pack_ver5(files: &[(String, Vec)]) -> Result, String> { - let root = build_tree(files)?; - let b = build_tables(&V5, &root); - let head_size = b.name.len() + b.file.len() + b.dir.len(); - let data_start = 24usize; // version-3 header has no CodePage field - let name_tbl = data_start + b.data.len(); - - let mut out = vec![0u8; data_start]; - out[0..2].copy_from_slice(&DX_HEAD.to_le_bytes()); - out[2..4].copy_from_slice(&3u16.to_le_bytes()); // version 3 (Wolf 2.01/2.10) - put(&mut out, 4, head_size as u64, 4); - put(&mut out, 8, data_start as u64, 4); - put(&mut out, 12, name_tbl as u64, 4); - put(&mut out, 16, b.name.len() as u64, 4); // FileTableStart (rel to name table) - put(&mut out, 20, (b.name.len() + b.file.len()) as u64, 4); // DirectoryTableStart (rel) - out.extend_from_slice(&b.data); - out.extend_from_slice(&b.name); - out.extend_from_slice(&b.file); - out.extend_from_slice(&b.dir); - - let key = key_create(KEY_V201); - for (i, byte) in out.iter_mut().enumerate() { - *byte ^= key[i % KEYLEN]; - } - Ok(out) -} - -/// Pack into a Wolf 2.20 (VER6, version 6) archive: header + tables keyed at position 0, each -/// file's data keyed at position `data_size`. Mirrors the reader's decrypt. -pub fn pack_ver6(files: &[(String, Vec)]) -> Result, String> { - let root = build_tree(files)?; - let b = build_tables(&V6, &root); - let head_size = b.name.len() + b.file.len() + b.dir.len(); - let data_start = 48usize; - let name_tbl = data_start + b.data.len(); - - let mut head = vec![0u8; 48]; - head[0..2].copy_from_slice(&DX_HEAD.to_le_bytes()); - head[2..4].copy_from_slice(&6u16.to_le_bytes()); // version 6 (Wolf 2.20) - put(&mut head, 4, head_size as u64, 4); // HeadSize is u32 even in VER6 - put(&mut head, 8, data_start as u64, 8); - put(&mut head, 16, name_tbl as u64, 8); - put(&mut head, 24, b.name.len() as u64, 8); // FileTableStart (rel) - put(&mut head, 32, (b.name.len() + b.file.len()) as u64, 8); // DirectoryTableStart (rel) - put(&mut head, 40, 1252, 8); // CodePage (matches the real 2.20 sample archive) - - let key = key_create(KEY_V220); - - // File data: key each region at pos = its data_size (padding between files stays - // plaintext, exactly as the reader ignores it). - let mut data = b.data.clone(); - for &(addr, size) in &b.file_regions { - let (a, s) = (addr as usize, size as usize); - let keyed = key_conv(&key, &data[a..a + s], s); - data[a..a + s].copy_from_slice(&keyed); - } - - let mut table = Vec::with_capacity(head_size); - table.extend_from_slice(&b.name); - table.extend_from_slice(&b.file); - table.extend_from_slice(&b.dir); - - let mut out = Vec::with_capacity(data_start + data.len() + head_size); - out.extend_from_slice(&key_conv(&key, &head, 0)); // header keyed at 0 - out.extend_from_slice(&data); - out.extend_from_slice(&key_conv(&key, &table, 0)); // tables keyed at 0 - Ok(out) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/mod.rs b/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/mod.rs deleted file mode 100644 index edc160f..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! DXArchive VER5 / VER6: the old Wolf RPG Editor 2.0x archive formats. -//! -//! The shared crypto and codec (12-byte key derivation and `KeyConv`, keycode-LZSS, name -//! extraction) live in [`common`]. Each version's distinct table/struct layout lives in its -//! own module ([`ver5`] = Wolf 2.01/2.10, [`ver6`] = Wolf 2.20). The newer v8 (Wolf 2.225+) -//! format is handled separately in the crate root. - -mod common; -pub mod encode; -mod ver5; -mod ver6; - -pub use encode::{pack_ver5, pack_ver6}; - -/// Try to decode `data` as a VER5 (Wolf 2.01/2.10) then VER6 (Wolf 2.20) archive, returning -/// `(path, contents)` pairs. `None` if it isn't one of these legacy formats. -pub fn try_extract(data: &[u8]) -> Option)>> { - ver5::try_extract(data).or_else(|| ver6::try_extract(data)) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/ver5.rs b/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/ver5.rs deleted file mode 100644 index 34f46f1..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/ver5.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! DXArchive VER5: Wolf RPG Editor 2.01 / 2.10. -//! -//! 32-bit offsets and a 44-byte file head. For header version < 5 every region (header, -//! tables, file data) is keyed by its file offset, so the whole archive decrypts as one -//! continuous XOR stream. Verified byte-exact against the Wolf 2.01 sample-game `Data.wolf`. - -use super::common::{decode_lz, key_create, name, u32at, KEYLEN}; - -/// VER5 version-table keys. -const KEYS: &[&[u8; KEYLEN]] = &[ - &[ - 0x0f, 0x53, 0xe1, 0x3e, 0x04, 0x37, 0x12, 0x17, 0x60, 0x0f, 0x53, 0xe1, - ], // v2.01 - &[ - 0x4c, 0xd9, 0x2a, 0xb7, 0x28, 0x9b, 0xac, 0x07, 0x3e, 0x77, 0xec, 0x4c, - ], // v2.10 -]; - -pub(super) fn try_extract(data: &[u8]) -> Option)>> { - KEYS.iter().find_map(|ks| decode(data, &key_create(*ks))) -} - -fn decode(data: &[u8], key: &[u8; KEYLEN]) -> Option)>> { - // Header version < 5: every region is keyed by file offset, so it is one XOR stream. - let mut d = data.to_vec(); - for (i, b) in d.iter_mut().enumerate() { - *b ^= key[i % KEYLEN]; - } - if u16::from_le_bytes([*d.first()?, *d.get(1)?]) != 0x5844 { - return None; // not a DX archive under this key - } - if u16::from_le_bytes([d[2], d[3]]) >= 5 { - return None; // v>=5 keys files at DataSize, not file offset. That is VER6's job. - } - let data_start = u32at(&d, 8)? as usize; - let name_tbl = u32at(&d, 12)? as usize; - let file_p = name_tbl + u32at(&d, 16)? as usize; - let dir_p = name_tbl + u32at(&d, 20)? as usize; - - let mut out = Vec::new(); - walk( - &d, - name_tbl, - file_p, - dir_p, - data_start, - 0, - String::new(), - &mut out, - )?; - Some(out) -} - -#[allow(clippy::too_many_arguments)] -fn walk( - d: &[u8], - name_p: usize, - file_p: usize, - dir_p: usize, - data_start: usize, - dir_off: usize, - path: String, - out: &mut Vec<(String, Vec)>, -) -> Option<()> { - let fhn = u32at(d, dir_p + dir_off + 8)? as usize; // FileHeadNum - let fha = u32at(d, dir_p + dir_off + 12)? as usize; // FileHeadAddress - const FH: usize = 44; // sizeof(DARC_FILEHEAD_VER5) - for i in 0..fhn { - let fh = file_p + fha + i * FH; - let name_addr = u32at(d, fh)? as usize; - let attr = u32at(d, fh + 4)?; - let data_addr = u32at(d, fh + 32)? as usize; - let data_size = u32at(d, fh + 36)? as usize; - let press_size = u32at(d, fh + 40)?; - let nm = name(d, name_p + name_addr)?; - - if attr & 0x10 != 0 { - walk( - d, - name_p, - file_p, - dir_p, - data_start, - data_addr, - format!("{path}{nm}\\"), - out, - )?; - } else { - let start = data_start + data_addr; - let content = if press_size != 0xffff_ffff { - decode_lz(d.get(start..start + press_size as usize)?, data_size)? - } else { - d.get(start..start + data_size)?.to_vec() - }; - out.push((format!("{path}{nm}"), content)); - } - } - Some(()) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/ver6.rs b/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/ver6.rs deleted file mode 100644 index 13ed5d2..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/dxarc_v2/ver6.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! DXArchive VER6: Wolf RPG Editor 2.20. -//! -//! 64-bit offsets and a 64-byte file head (a bridge toward the v8 format). Unlike VER5, the -//! header and tables are keyed at stream position 0 and file data at position `DataSize` -//! (not the file offset). Same keycode-LZSS as VER5. Verified byte-exact against the Wolf -//! 2.20 sample-game `Data.wolf`. - -use super::common::{decode_lz, key_conv, key_create, name, u64at, KEYLEN}; - -/// VER6 version-table key. -const KEYS: &[&[u8; KEYLEN]] = &[ - &[ - 0x38, 0x50, 0x40, 0x28, 0x72, 0x4f, 0x21, 0x70, 0x3b, 0x73, 0x35, 0x38, - ], // v2.20 -]; - -pub(super) fn try_extract(data: &[u8]) -> Option)>> { - KEYS.iter().find_map(|ks| decode(data, &key_create(*ks))) -} - -fn decode(data: &[u8], key: &[u8; KEYLEN]) -> Option)>> { - let head = key_conv(key, data.get(0..48)?, 0); - if u16::from_le_bytes([head[0], head[1]]) != 0x5844 - || u16::from_le_bytes([head[2], head[3]]) != 6 - { - return None; - } - let head_size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; - let data_start = u64at(&head, 8)? as usize; - let name_tbl = u64at(&head, 16)? as usize; - let file_tbl = u64at(&head, 24)? as usize; - let dir_tbl = u64at(&head, 32)? as usize; - // Header + tables are keyed at position 0. - let tbl = key_conv(key, data.get(name_tbl..name_tbl + head_size)?, 0); - - let mut out = Vec::new(); - walk( - data, - &tbl, - key, - file_tbl, - dir_tbl, - data_start, - 0, - String::new(), - &mut out, - )?; - Some(out) -} - -#[allow(clippy::too_many_arguments)] -fn walk( - data: &[u8], - tbl: &[u8], - key: &[u8; KEYLEN], - file_tbl: usize, - dir_tbl: usize, - data_start: usize, - dir_off: usize, - path: String, - out: &mut Vec<(String, Vec)>, -) -> Option<()> { - let fhn = u64at(tbl, dir_tbl + dir_off + 16)? as usize; // FileHeadNum - let fha = u64at(tbl, dir_tbl + dir_off + 24)? as usize; // FileHeadAddress - const FH: usize = 64; // sizeof(DARC_FILEHEAD_VER6) - for i in 0..fhn { - let fh = file_tbl + fha + i * FH; - let name_addr = u64at(tbl, fh)? as usize; - let attr = u64at(tbl, fh + 8)?; - let data_addr = u64at(tbl, fh + 40)? as usize; - let data_size = u64at(tbl, fh + 48)? as usize; - let press_size = u64at(tbl, fh + 56)?; - let nm = name(tbl, name_addr)?; - - if attr & 0x10 != 0 { - walk( - data, - tbl, - key, - file_tbl, - dir_tbl, - data_start, - data_addr, - format!("{path}{nm}\\"), - out, - )?; - } else { - let start = data_start + data_addr; - // File data is keyed at stream position == DataSize. - let content = if press_size != 0xffff_ffff_ffff_ffff { - let enc = key_conv( - key, - data.get(start..start + press_size as usize)?, - data_size, - ); - decode_lz(&enc, data_size)? - } else { - key_conv(key, data.get(start..start + data_size)?, data_size) - }; - out.push((format!("{path}{nm}"), content)); - } - } - Some(()) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/dxarchive.rs b/util/wolfdawn-master/crates/wolf-archive/src/dxarchive.rs deleted file mode 100644 index 0d45c26..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/dxarchive.rs +++ /dev/null @@ -1,947 +0,0 @@ -//! DXArchive v8 (`Data.wolf`) container: header parsing, the key / crypt-version dispatch -//! and detection strategies, the directory walk, and per-file extraction. - -use crate::chacha20; -use crate::codec::{dxa_decode, huffman_decode}; -use crate::crypto::*; -use crate::*; -use std::fs; -use std::io; -use std::path::Path; - -#[derive(Debug)] -pub(crate) struct Entry { - pub(crate) path: String, - pub(crate) head: FileHead, - pub(crate) directory_addr: usize, -} - -/// Map a DXArchive-Wolf cryptVersion (`Flags>>16`) to its table key. Returns `Some` only -/// for the plaintext-header, single-key "keyed decode" versions. New wolf-crypt (encrypted -/// header), ChaCha20, and the ambiguous version-0 string-keyed games are handled by the -/// strategy fallbacks. -pub(crate) fn table_key_for_version(crypt_version: u16) -> Option> { - let key: &[u8] = match crypt_version { - 0x12C => DEFAULT_WOLF_V310_KEY, // Wolf v3.00 - 0x13A => DEFAULT_WOLF_V3173_KEY, // Wolf v3.14 - _ => return None, - }; - Some(key.to_vec()) -} - -/// Build a layout for a "ChaCha2" (cryptVersion 0x64) archive: the header is plaintext, the -/// table and file data are ChaCha20-keyed with the fixed chacha2 key/nonce. This mirrors the -/// old-crypt path with ChaCha20 substituted for `key_conv`. Not yet verified against a real -/// ChaCha2 archive. -pub(crate) fn try_chacha_layout(data: &[u8], head: DxHead) -> Option { - let ck: [u8; 32] = DEFAULT_WOLF_CHACHA2_KEY[0..32].try_into().ok()?; - let cn: [u8; 12] = DEFAULT_WOLF_CHACHA2_KEY[32..44].try_into().ok()?; - let start = head.name_table_start as usize; - let no_head_press = (head.flags & 2) != 0; - let table = if no_head_press { - let mut t = data - .get(start..start.checked_add(head.head_size as usize)?)? - .to_vec(); - chacha20::crypt(&mut t, &ck, &cn, 0); - t - } else { - let mut huff = data.get(start..)?.to_vec(); - chacha20::crypt(&mut huff, &ck, &cn, 0); - let lz = huffman_decode(&huff).ok()?; - dxa_decode(&lz).ok()? - }; - validate_table( - &table, - head.file_table_start as usize, - head.directory_table_start as usize, - )?; - Some(Layout { - table, - data_start: head.data_start, - file_table_start: head.file_table_start as usize, - directory_table_start: head.directory_table_start as usize, - huffman_encode_kb: head.huffman_encode_kb, - flags: head.flags, - key_string: DEFAULT_WOLF_CHACHA2_KEY.to_vec(), - main_key_pos: 0, - source: "deterministic chacha2 cv=0x64".to_string(), - wolf: None, - chacha: Some((ck, cn)), - }) -} - -pub(crate) fn detect_layout(data: &[u8], requested_key_string: &[u8]) -> io::Result { - if data.len() < 8 { - return Err(invalid("file is too small")); - } - - // Use the full header (real table offsets and Flags). For an encrypted header the offsets - // are garbage, but then `try_official_layout`/`plausible_head` reject it and we fall - // through to the wolf-crypt strategies, so this is safe for all archives. - let plain = parse_full_head(data).or_else(|_| parse_head_prefix(data))?; - if plain.version != DX_VER_8 || plain.head != DX_HEAD { - return Err(invalid("not a DX archive v8 header")); - } - - // Deterministic dispatch: a plaintext header carries Flags, and cryptVersion = Flags>>16 - // selects the exact table key. No key guessing needed. - if plausible_head(&plain, data.len()) { - let crypt_version = (plain.flags >> 16) as u16; - if let Some(key_string) = table_key_for_version(crypt_version) { - let key = key_create(&key_string); - if let Some(mut layout) = - try_official_layout(data, key, key_string, plain, "deterministic") - { - layout.source = format!("deterministic cryptVersion={crypt_version:#x}"); - return Ok(layout); - } - } - // cv 0x64: "ChaCha2" archive. Plaintext header, table and files ChaCha20-keyed. - if crypt_version == 0x64 { - if let Some(layout) = try_chacha_layout(data, plain) { - return Ok(layout); - } - } - } - - let mut tried = Vec::new(); - - let mut candidates: Vec<(&str, Vec)> = vec![ - ("requested", requested_key_string.to_vec()), - ("wolf-pro", DEFAULT_WOLF_PRO_KEY.to_vec()), - ("wolf-v3.10", DEFAULT_WOLF_V310_KEY.to_vec()), - ("wolf-v3.173", DEFAULT_WOLF_V3173_KEY.to_vec()), - ("wolf-v3.31", DEFAULT_WOLF_V331_KEY.to_vec()), - ("wolf-v3.50", DEFAULT_WOLF_V350_KEY.to_vec()), - ("wolf-chacha2", DEFAULT_WOLF_CHACHA2_KEY.to_vec()), - ("one-way", DEFAULT_ONE_WAY_KEY.to_vec()), - ("one-way-full", DEFAULT_ONE_WAY_FULL_KEY.to_vec()), - ("dxlib", DEFAULT_DXLIB_KEY.to_vec()), - ("old-wolf", DEFAULT_OLD_WOLF_KEY.to_vec()), - ]; - candidates.dedup_by(|a, b| nul_terminated(&a.1) == nul_terminated(&b.1)); - - for (label, key_string) in candidates { - let key = key_create(&key_string); - if tried.iter().any(|prev| prev == &hex(&key)) { - continue; - } - tried.push(hex(&key)); - - if let Some(layout) = try_wolf_newcrypt_layout(data, key, key_string.clone(), plain, label) - { - return Ok(layout); - } - - if let Some(layout) = try_official_layout( - data, - key, - key_string.clone(), - plain, - "official-plain-header", - ) { - return Ok(layout); - } - - if let Some(layout) = - try_encrypted_v8_header_layout(data, key, key_string.clone(), plain, label) - { - return Ok(layout); - } - - if let Some(layout) = try_front_loaded_layout(data, key, key_string, plain) { - return Ok(layout); - } - } - - Err(invalid(format!( - "could not decode archive tables; alternate keys tried: {}", - tried.join(", ") - ))) -} - -pub(crate) fn try_official_layout( - data: &[u8], - key: [u8; KEY_BYTES], - key_string: Vec, - head: DxHead, - source: &str, -) -> Option { - if !plausible_head(&head, data.len()) { - return None; - } - - let start = head.name_table_start as usize; - let head_size = head.head_size as usize; - let no_key = (head.flags & 1) != 0; - let no_head_press = (head.flags & 2) != 0; - - let table = if no_head_press { - let mut table = data.get(start..start.checked_add(head_size)?)?.to_vec(); - if !no_key { - key_conv(&mut table, &key, 0); - } - table - } else { - let mut huff = data.get(start..)?.to_vec(); - if !no_key { - key_conv(&mut huff, &key, 0); - } - let lz = huffman_decode(&huff).ok()?; - dxa_decode(&lz).ok()? - }; - - validate_table( - &table, - head.file_table_start as usize, - head.directory_table_start as usize, - )?; - Some(Layout { - table, - data_start: head.data_start, - file_table_start: head.file_table_start as usize, - directory_table_start: head.directory_table_start as usize, - huffman_encode_kb: head.huffman_encode_kb, - flags: head.flags, - key_string, - main_key_pos: 0, - source: source.to_string(), - wolf: None, - chacha: None, - }) -} - -pub(crate) fn try_encrypted_v8_header_layout( - data: &[u8], - key: [u8; KEY_BYTES], - key_string: Vec, - plain: DxHead, - label: &str, -) -> Option { - if data.len() < 64 { - return None; - } - - for pos in 0..KEY_BYTES { - let mut head_buf = data[..64].to_vec(); - key_conv(&mut head_buf[8..64], &key, pos); - let head = parse_full_head(&head_buf).ok()?; - if head.head == plain.head - && head.version == plain.version - && head.head_size == plain.head_size - && plausible_head(&head, data.len()) - { - let source = format!("encrypted-v8-header-{label}-pos{pos}"); - if let Some(layout) = try_official_layout(data, key, key_string.clone(), head, &source) - { - return Some(layout); - } - } - } - - None -} - -pub(crate) fn try_wolf_newcrypt_layout( - data: &[u8], - _key: [u8; KEY_BYTES], - key_string: Vec, - plain: DxHead, - label: &str, -) -> Option { - if data.len() < 64 { - return None; - } - - let raw_head = parse_full_head(data).ok()?; - let crypt_version = (raw_head.flags >> 16) as u16; - if !is_new_wolf_crypt(crypt_version) || !candidate_matches_wolf_version(label, crypt_version) { - return None; - } - let debug = std::env::var_os("WOLF_DEBUG").is_some(); - - let mut pwd = [0u8; 15]; - pwd.copy_from_slice(data.get(49..64)?); - - let mut head_buf = data[..64].to_vec(); - wolf_crypt_addresses(&mut head_buf, &pwd, crypt_version); - let head = parse_full_head(&head_buf).ok()?; - if debug { - eprintln!( - "newcrypt {label}: raw_cv=0x{crypt_version:04x} head_size={} data_start={} name_start={} file_start={} dir_start={} flags=0x{:08x} huff_kb={}", - head.head_size, - head.data_start, - head.name_table_start, - head.file_table_start, - head.directory_table_start, - head.flags, - head.huffman_encode_kb - ); - } - if head.head != plain.head - || head.version != plain.version - || head.head_size != plain.head_size - || !plausible_head(&head, data.len()) - { - return None; - } - - let ctx = WolfContext { - crypt_version, - other_key: wolf_init_key(crypt_version, &pwd, None, true, &key_string), - special_key: wolf_init_key(crypt_version, &pwd, None, false, &key_string), - aes_round_key: wolf_aes_init_round_key(&pwd, None, crypt_version), - body_size: wolf_aes_body_size(data.len(), crypt_version, &pwd, None), - name_table_start: head.name_table_start as usize, - }; - - let table = read_wolf_table(data, &head, &ctx)?; - if debug { - eprintln!( - "newcrypt {label}: table len={} first={}", - table.len(), - hex(&table[..table.len().min(16)]) - ); - if let Some(root) = parse_directory_at(&table, head.directory_table_start as usize) { - eprintln!( - "newcrypt {label}: root dir_addr={} parent={:016x} file_num={} file_addr={}", - root.directory_addr, root.parent_addr, root.file_head_num, root.file_head_addr - ); - } - if let Some(first_file) = parse_file_head_at(&table, head.file_table_start as usize) { - eprintln!( - "newcrypt {label}: first file name={} attrs={:x} data={} size={} press={} huff={}", - first_file.name_addr, - first_file.attrs, - first_file.data_addr, - first_file.data_size, - first_file.press_size, - first_file.huff_size - ); - } - } - validate_table( - &table, - head.file_table_start as usize, - head.directory_table_start as usize, - )?; - - Some(Layout { - table, - data_start: head.data_start, - file_table_start: head.file_table_start as usize, - directory_table_start: head.directory_table_start as usize, - huffman_encode_kb: head.huffman_encode_kb, - flags: head.flags, - key_string, - main_key_pos: 0, - source: format!("wolf-newcrypt-v{crypt_version:x}-{label}"), - wolf: Some(ctx), - chacha: None, - }) -} - -pub(crate) fn try_front_loaded_layout( - data: &[u8], - key: [u8; KEY_BYTES], - key_string: Vec, - head: DxHead, -) -> Option { - let block_size = head.head_size as usize; - let block = data.get(8..8usize.checked_add(block_size)?)?; - - for pos in 0..KEY_BYTES { - let mut decoded = block.to_vec(); - key_conv(&mut decoded, &key, pos); - - let candidates = front_table_candidates(&decoded); - for (table, source_suffix) in candidates { - if let Some(layout) = infer_front_layout( - &table, - data.len(), - block_size, - key_string.clone(), - pos, - &source_suffix, - ) { - return Some(layout); - } - } - } - - None -} - -pub(crate) fn front_table_candidates(decoded: &[u8]) -> Vec<(Vec, String)> { - let mut out = Vec::new(); - - out.push((decoded.to_vec(), "xor-direct".to_string())); - - if let Ok(lz) = dxa_decode(decoded) { - out.push((lz, "xor-lz".to_string())); - } - - if let Ok(huff) = huffman_decode(decoded) { - out.push((huff.clone(), "xor-huff".to_string())); - if let Ok(lz) = dxa_decode(&huff) { - out.push((lz, "xor-huff-lz".to_string())); - } - } - - out -} - -pub(crate) fn infer_front_layout( - table: &[u8], - archive_len: usize, - block_size: usize, - key_string: Vec, - pos: usize, - suffix: &str, -) -> Option { - if table.len() < 32 { - return None; - } - - let data_start = 8u64 + block_size as u64; - - let mut offset_pairs = Vec::new(); - - if table.len() >= 64 { - let h = parse_full_head(table).ok()?; - if h.head == DX_HEAD - && h.version == DX_VER_8 - && plausible_table_offsets( - table, - h.file_table_start as usize, - h.directory_table_start as usize, - ) - { - offset_pairs.push(( - 64, - h.file_table_start as usize, - h.directory_table_start as usize, - h.huffman_encode_kb, - h.flags, - "embedded-head", - )); - } - } - - for file_start in guess_file_table_starts(table) { - for dir_start in guess_directory_table_starts(table, file_start) { - offset_pairs.push((0, file_start, dir_start, 0xff, 0, "guessed")); - } - } - - for (base, file_table_start, directory_table_start, huff_kb, flags, kind) in offset_pairs { - let logical_table = if base == 0 { - table.to_vec() - } else { - table[base..].to_vec() - }; - let file_start = file_table_start - .checked_sub(base) - .unwrap_or(file_table_start); - let dir_start = directory_table_start - .checked_sub(base) - .unwrap_or(directory_table_start); - if validate_table(&logical_table, file_start, dir_start).is_some() { - if data_start as usize <= archive_len { - return Some(Layout { - table: logical_table, - data_start, - file_table_start: file_start, - directory_table_start: dir_start, - huffman_encode_kb: huff_kb, - flags, - key_string, - main_key_pos: pos, - source: format!("front-loaded-{suffix}-{kind}"), - wolf: None, - chacha: None, - }); - } - } - } - - None -} - -pub(crate) fn guess_file_table_starts(table: &[u8]) -> Vec { - let mut guesses = Vec::new(); - let max = table.len().saturating_sub(72); - let mut off = 0usize; - while off <= max.min(0x20000) { - if let Some(fh) = parse_file_head_at(table, off) { - if fh.name_addr < table.len() as u64 - && fh.attrs & !0x37ff == 0 - && fh.data_size < 1u64 << 34 - && (fh.press_size == NONE || fh.press_size <= fh.data_size + (1u64 << 30)) - && (fh.huff_size == NONE - || fh.huff_size <= fh.press_size.max(fh.data_size) + (1u64 << 30)) - { - guesses.push(off); - } - } - off += 8; - } - guesses.sort_unstable(); - guesses.dedup(); - guesses -} - -pub(crate) fn guess_directory_table_starts(table: &[u8], file_start: usize) -> Vec { - let mut guesses = Vec::new(); - let min = file_start.saturating_add(72); - let max = table.len().saturating_sub(32); - let mut off = min; - while off <= max { - if let Some(dir) = parse_directory_at(table, off) { - if dir.parent_addr == NONE - && dir.file_head_num > 0 - && dir.file_head_num < 500_000 - && (file_start as u64 + dir.file_head_addr) as usize - + (dir.file_head_num as usize).saturating_mul(72) - <= table.len() - { - guesses.push(off); - } - } - off += 8; - } - guesses.sort_unstable(); - guesses.dedup(); - guesses -} - -pub(crate) fn validate_table( - table: &[u8], - file_table_start: usize, - directory_table_start: usize, -) -> Option<()> { - if !plausible_table_offsets(table, file_table_start, directory_table_start) { - return None; - } - let root = parse_directory_at(table, directory_table_start)?; - if root.parent_addr != NONE { - return None; - } - if root.file_head_num == 0 || root.file_head_num > 500_000 { - return None; - } - let first = file_table_start.checked_add(root.file_head_addr as usize)?; - let bytes = (root.file_head_num as usize).checked_mul(72)?; - if first.checked_add(bytes)? > table.len() { - return None; - } - // Sanity-check the root's file headers. The root directory lives in the table's tail, - // which (for an uncompressed/NO_HEAD_PRESS header) decodes plausibly even under a wrong - // key because it sits past the stream-overlap range. The file headers do not. So a wrong - // key leaves garbage `name_addr`s here, which we reject to keep the key dispatch from - // latching onto a wrong candidate. - for i in 0..root.file_head_num as usize { - let fh = parse_file_head_at(table, first + i * 72)?; - // `name_addr` outside the table is the strong wrong-key tell. Keep the press/huff - // check only as a coarse garbage filter. A real compressed size is bounded by the - // archive (well under 1 TB), while a wrong-key decode yields random ~1e19 values. - if fh.name_addr as usize >= table.len() { - return None; - } - let sane = |v: u64| v == NONE || v < (1u64 << 40); - if !sane(fh.press_size) || !sane(fh.huff_size) { - return None; - } - } - Some(()) -} - -pub(crate) fn plausible_table_offsets( - table: &[u8], - file_table_start: usize, - directory_table_start: usize, -) -> bool { - file_table_start < table.len() - && directory_table_start < table.len() - && file_table_start % 4 == 0 - && directory_table_start % 4 == 0 - && file_table_start < directory_table_start -} - -pub(crate) fn collect_files(layout: &Layout) -> io::Result> { - let mut out = Vec::new(); - let root = parse_directory_at(&layout.table, layout.directory_table_start) - .ok_or_else(|| invalid("root directory is outside table"))?; - collect_dir( - layout, - root, - layout.directory_table_start, - String::new(), - &mut out, - )?; - Ok(out) -} - -pub(crate) fn collect_dir( - layout: &Layout, - dir: Directory, - dir_addr: usize, - prefix: String, - out: &mut Vec, -) -> io::Result<()> { - let start = layout - .file_table_start - .checked_add(dir.file_head_addr as usize) - .ok_or_else(|| invalid("file table address overflow"))?; - for i in 0..dir.file_head_num as usize { - let off = start + i * 72; - let fh = parse_file_head_at(&layout.table, off) - .ok_or_else(|| invalid(format!("bad file header at 0x{off:x}")))?; - let name = name_for(layout, fh.name_addr as usize)?; - if fh.attrs & FILE_ATTRIBUTE_DIRECTORY != 0 { - let child_addr = layout - .directory_table_start - .checked_add(fh.data_addr as usize) - .ok_or_else(|| invalid("directory address overflow"))?; - let child = parse_directory_at(&layout.table, child_addr) - .ok_or_else(|| invalid(format!("bad directory at 0x{child_addr:x}")))?; - let child_prefix = if prefix.is_empty() { - name - } else { - format!("{prefix}\\{name}") - }; - collect_dir(layout, child, child_addr, child_prefix, out)?; - } else { - let path = if prefix.is_empty() { - name - } else { - format!("{prefix}\\{name}") - }; - out.push(Entry { - path, - head: fh, - directory_addr: dir_addr, - }); - } - } - Ok(()) -} - -pub(crate) fn name_for(layout: &Layout, name_addr: usize) -> io::Result { - if name_addr + 4 > layout.table.len() { - return Err(invalid("name address outside table")); - } - let packs = read_u16(&layout.table[name_addr..]) as usize; - let original_start = name_addr + 4 + packs * 4; - if original_start >= layout.table.len() { - return Err(invalid("name original string outside table")); - } - let end = layout.table[original_start..] - .iter() - .position(|&b| b == 0) - .map(|p| original_start + p) - .unwrap_or(layout.table.len()); - let raw = &layout.table[original_start..end]; - Ok(decode_filename(raw)) -} - -pub(crate) fn extract_all( - data: &[u8], - layout: &Layout, - files: &[Entry], - out_dir: &Path, -) -> io::Result<()> { - fs::create_dir_all(out_dir)?; - let mut failed = 0usize; - for (idx, entry) in files.iter().enumerate() { - // Extract resiliently. One unwritable entry (e.g. a reserved/over-long name) must not - // abort the whole archive. Log it and keep going so the folder is as complete as possible. - let res = (|| -> io::Result<()> { - let bytes = extract_file(data, layout, entry)?; - let target = out_dir.join(safe_relative_path(&entry.path)); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&target, bytes) - })(); - if let Err(e) = res { - eprintln!("warning: skipping {}: {e}", entry.path); - failed += 1; - } - if idx % 100 == 0 { - println!("extracted {:5}/{} {}", idx + 1, files.len(), entry.path); - } - } - if failed > 0 { - eprintln!("extract: {failed} file(s) could not be written (see warnings above)"); - } - Ok(()) -} - -pub(crate) fn extract_file(data: &[u8], layout: &Layout, entry: &Entry) -> io::Result> { - let fh = entry.head; - // A NO_KEY archive (flags bit 0) stores file data unencrypted, with no per-file key. - let no_key = (layout.flags & 1) != 0; - let key = if layout.wolf.is_none() && layout.chacha.is_none() && !no_key { - Some(file_key(layout, entry)?) - } else { - None - }; - let start = layout - .data_start - .checked_add(fh.data_addr) - .ok_or_else(|| invalid("data address overflow"))? as usize; - - if fh.press_size != NONE { - let press = if fh.huff_size != NONE { - let huff = read_layout_slice( - data, - layout, - start, - fh.huff_size as usize, - key.as_ref(), - fh.data_size, - )?; - let mut lz = huffman_decode(&huff).map_err(invalid)?; - if layout.huffman_encode_kb != 0xff - && fh.press_size > (layout.huffman_encode_kb as u64) * 1024 * 2 - { - let kb = layout.huffman_encode_kb as usize * 1024; - let middle_len = fh.press_size as usize - kb * 2; - let middle_start = start + fh.huff_size as usize; - let mut middle = read_layout_slice( - data, - layout, - middle_start, - middle_len, - key.as_ref(), - fh.data_size + fh.huff_size, - )?; - let tail = lz[kb..kb * 2].to_vec(); - lz.truncate(kb); - lz.append(&mut middle); - lz.extend_from_slice(&tail); - } - lz - } else { - read_layout_slice( - data, - layout, - start, - fh.press_size as usize, - key.as_ref(), - fh.data_size, - )? - }; - return dxa_decode(&press).map_err(invalid); - } - - if fh.huff_size != NONE { - let mut output = if layout.huffman_encode_kb != 0xff - && fh.data_size > (layout.huffman_encode_kb as u64) * 1024 * 2 - { - let kb = layout.huffman_encode_kb as usize * 1024; - let huff = read_layout_slice( - data, - layout, - start, - fh.huff_size as usize, - key.as_ref(), - fh.data_size, - )?; - let mut decoded = huffman_decode(&huff).map_err(invalid)?; - let middle_len = fh.data_size as usize - kb * 2; - let middle_start = start + fh.huff_size as usize; - let mut middle = read_layout_slice( - data, - layout, - middle_start, - middle_len, - key.as_ref(), - fh.data_size + fh.huff_size, - )?; - let tail = decoded[kb..kb * 2].to_vec(); - decoded.truncate(kb); - decoded.append(&mut middle); - decoded.extend_from_slice(&tail); - decoded - } else { - let huff = read_layout_slice( - data, - layout, - start, - fh.huff_size as usize, - key.as_ref(), - fh.data_size, - )?; - huffman_decode(&huff).map_err(invalid)? - }; - output.truncate(fh.data_size as usize); - return Ok(output); - } - - read_layout_slice( - data, - layout, - start, - fh.data_size as usize, - key.as_ref(), - fh.data_size, - ) -} - -pub(crate) fn read_layout_slice( - data: &[u8], - layout: &Layout, - start: usize, - len: usize, - key: Option<&[u8; KEY_BYTES]>, - pos: u64, -) -> io::Result> { - let end = start - .checked_add(len) - .ok_or_else(|| invalid("slice address overflow"))?; - if end > data.len() { - return Err(invalid("file data outside archive")); - } - - let mut out = if let Some(wolf) = &layout.wolf { - read_wolf_archive_slice(data, start, len, wolf) - .ok_or_else(|| invalid("file data outside archive"))? - } else { - data[start..end].to_vec() - }; - - if let Some(wolf) = &layout.wolf { - if (layout.flags & 1) == 0 { - wolf_crypt( - &wolf.special_key, - &mut out, - pos as usize, - wolf.crypt_version, - ); - } - } else if let Some((ck, cn)) = &layout.chacha { - chacha20::crypt(&mut out, ck, cn, pos as u32); - } else if let Some(key) = key { - key_conv(&mut out, key, (pos as usize) % KEY_BYTES); - } - Ok(out) -} - -pub(crate) fn file_key(layout: &Layout, entry: &Entry) -> io::Result<[u8; KEY_BYTES]> { - let mut key_string = Vec::new(); - key_string.extend_from_slice(nul_terminated(&layout.key_string)); - - let file_name = raw_search_name(layout, entry.head.name_addr as usize)?; - key_string.extend_from_slice(file_name); - - let mut dir = parse_directory_at(&layout.table, entry.directory_addr) - .ok_or_else(|| invalid("entry directory is outside table"))?; - while dir.parent_addr != NONE { - let dir_fh_addr = layout - .file_table_start - .checked_add(dir.directory_addr as usize) - .ok_or_else(|| invalid("directory file header overflow"))?; - let dir_fh = parse_file_head_at(&layout.table, dir_fh_addr) - .ok_or_else(|| invalid("directory file header outside table"))?; - key_string.extend_from_slice(raw_search_name(layout, dir_fh.name_addr as usize)?); - let parent_addr = layout - .directory_table_start - .checked_add(dir.parent_addr as usize) - .ok_or_else(|| invalid("parent directory overflow"))?; - dir = parse_directory_at(&layout.table, parent_addr) - .ok_or_else(|| invalid("parent directory outside table"))?; - } - - Ok(key_create(&key_string)) -} - -/// The "search" name (uppercased, stored at `NameAddress + 4`). This is the form DXArchive's -/// `CreateKeyFileString` uses to build per-file keys. Distinct from the original display -/// name (at `+ 4 + packs*4`). -pub(crate) fn raw_search_name(layout: &Layout, name_addr: usize) -> io::Result<&[u8]> { - let start = name_addr - .checked_add(4) - .ok_or_else(|| invalid("search name overflow"))?; - if start > layout.table.len() { - return Err(invalid("search name outside table")); - } - let end = layout.table[start..] - .iter() - .position(|&b| b == 0) - .map(|p| start + p) - .unwrap_or(layout.table.len()); - Ok(&layout.table[start..end]) -} - -pub(crate) fn parse_head_prefix(data: &[u8]) -> io::Result { - if data.len() < 8 { - return Err(invalid("missing header")); - } - Ok(DxHead { - head: read_u16(data), - version: read_u16(&data[2..]), - head_size: read_u32(&data[4..]), - data_start: 0, - name_table_start: 0, - file_table_start: 0, - directory_table_start: 0, - char_code_format: 0, - flags: 0, - huffman_encode_kb: 0xff, - }) -} - -pub(crate) fn parse_full_head(data: &[u8]) -> io::Result { - if data.len() < 64 { - return Err(invalid("missing full header")); - } - Ok(DxHead { - head: read_u16(data), - version: read_u16(&data[2..]), - head_size: read_u32(&data[4..]), - data_start: read_u64(&data[8..]), - name_table_start: read_u64(&data[16..]), - file_table_start: read_u64(&data[24..]), - directory_table_start: read_u64(&data[32..]), - char_code_format: read_u32(&data[40..]), - flags: read_u32(&data[44..]), - huffman_encode_kb: data[48], - }) -} - -pub(crate) fn parse_file_head_at(table: &[u8], off: usize) -> Option { - let b = table.get(off..off.checked_add(72)?)?; - Some(FileHead { - name_addr: read_u64(b), - attrs: read_u64(&b[8..]), - data_addr: read_u64(&b[40..]), - data_size: read_u64(&b[48..]), - press_size: read_u64(&b[56..]), - huff_size: read_u64(&b[64..]), - }) -} - -pub(crate) fn parse_directory_at(table: &[u8], off: usize) -> Option { - let b = table.get(off..off.checked_add(32)?)?; - Some(Directory { - directory_addr: read_u64(b), - parent_addr: read_u64(&b[8..]), - file_head_num: read_u64(&b[16..]), - file_head_addr: read_u64(&b[24..]), - }) -} - -pub(crate) fn plausible_head(head: &DxHead, file_len: usize) -> bool { - head.head == DX_HEAD - && head.version == DX_VER_8 - && head.head_size > 0 - && head.head_size as usize <= file_len - && head.data_start < file_len as u64 - && head.name_table_start < file_len as u64 - && head.file_table_start < head.head_size as u64 - && head.directory_table_start < head.head_size as u64 - && head.file_table_start < head.directory_table_start - && head.char_code_format < 100_000 -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/lib.rs b/util/wolfdawn-master/crates/wolf-archive/src/lib.rs deleted file mode 100644 index 5c03dbd..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/lib.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! `wolf-archive`: Wolf RPG `Data.wolf` archives. Unpacks every DXArchive variant Wolf uses. -//! -//! Public API: [`extract_archive`] / [`list_archive`] / [`extract_one`] (bytes to files) and -//! the CLI [`run`]. The format and crypto live in submodules: [`dxarchive`] (the v8 container -//! plus key dispatch), [`crypto`] (WolfPro stream plus AES), [`codec`] (Huffman plus LZSS), -//! [`dxarc_v2`] (the old VER5/VER6 archives), and [`chacha20`]. - -use std::fs; -use std::io::{self, Read}; -use std::path::PathBuf; - -pub mod chacha20; -mod codec; -mod crypto; -pub mod dxarc_v2; -mod dxarchive; -pub mod pack; - -pub use dxarc_v2::{pack_ver5, pack_ver6}; -pub use pack::{archive_crypt_params, pack_chacha, pack_encrypted, pack_newcrypt, pack_plaintext}; - -use crypto::*; -use dxarchive::*; - -pub(crate) const DX_HEAD: u16 = 0x5844; -pub(crate) const DX_VER_8: u16 = 0x0008; -pub(crate) const KEY_BYTES: usize = 7; -pub(crate) const NONE: u64 = u64::MAX; -pub(crate) const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10; -pub const DEFAULT_WOLF_PRO_KEY: &[u8] = b"WLFRPrO!p(;s5((8P@((UFWlu$#5(="; -pub(crate) const DEFAULT_DXLIB_KEY: &[u8] = b"DXBDXARC"; -pub(crate) const DEFAULT_OLD_WOLF_KEY: &[u8] = b"8P@(rO!p;s5"; -pub(crate) const DEFAULT_WOLF_V310_KEY: &[u8] = &[ - 0x0f, 0x53, 0xe1, 0x3e, 0x8e, 0xb5, 0x41, 0x91, 0x52, 0x16, 0x55, 0xae, 0x34, 0xc9, 0x8f, 0x79, - 0x59, 0x2f, 0x59, 0x6b, 0x95, 0x19, 0x9b, 0x1b, 0x35, 0x9a, 0x2f, 0xde, 0xc9, 0x7c, 0x12, 0x96, - 0xc3, 0x14, 0xb5, 0x0f, 0x53, 0xe1, 0x3e, 0x8e, 0x00, -]; -pub(crate) const DEFAULT_WOLF_V3173_KEY: &[u8] = &[ - 0x31, 0xf9, 0x01, 0x36, 0xa3, 0xe3, 0x8d, 0x3c, 0x7b, 0xc3, 0x7d, 0x25, 0xad, 0x63, 0x28, 0x19, - 0x1b, 0xf7, 0x8e, 0x6c, 0xc4, 0xe5, 0xe2, 0x76, 0x82, 0xea, 0x4f, 0xed, 0x61, 0xda, 0xe0, 0x44, - 0x5b, 0xb6, 0x46, 0x3b, 0x06, 0xd5, 0xce, 0xb6, 0x78, 0x58, 0xd0, 0x7c, 0x82, 0x00, -]; -pub(crate) const DEFAULT_WOLF_V331_KEY: &[u8] = &[ - 0xca, 0x08, 0x4c, 0x5d, 0x17, 0x0d, 0xda, 0xa1, 0xd7, 0x27, 0xc8, 0x41, 0x54, 0x38, 0x82, 0x32, - 0x54, 0xb7, 0xf9, 0x46, 0x8e, 0x13, 0x6b, 0xca, 0xd0, 0x5c, 0x95, 0x95, 0xe2, 0xdc, 0x03, 0x53, - 0x60, 0x9b, 0x4a, 0x38, 0x17, 0xf3, 0x69, 0x59, 0xa4, 0xc7, 0x9a, 0x43, 0x63, 0xe6, 0x54, 0xaf, - 0xdb, 0xbb, 0x43, 0x58, 0x00, -]; -pub(crate) const DEFAULT_WOLF_V350_KEY: &[u8] = &[ - 0xd2, 0x84, 0xce, 0x28, 0xce, 0x88, 0x82, 0xe4, 0x2a, 0x18, 0x2e, 0x4c, 0x06, 0xb4, 0xea, 0x84, - 0x06, 0xb8, 0xc6, 0x88, 0x5a, 0xa0, 0x9e, 0x7c, 0x56, 0x40, 0xba, 0x34, 0x52, 0xcc, 0xc6, 0x7c, - 0x2e, 0x14, 0x12, 0x68, 0xfe, 0x5c, 0x76, 0x94, 0x86, 0x78, 0x8e, 0x4c, 0xbe, 0x88, 0x66, 0x9c, - 0x1e, 0xe0, 0x8e, 0x6c, 0x00, -]; -pub(crate) const DEFAULT_WOLF_CHACHA2_KEY: &[u8] = &[ - 0xc9, 0x82, 0xf8, 0xb4, 0x2c, 0x93, 0x9e, 0x83, 0x0e, 0xbc, 0xbc, 0x92, 0x68, 0x8d, 0x59, 0xa1, - 0x4a, 0x9e, 0x7f, 0xb0, 0xac, 0xaf, 0x1d, 0x8f, 0x8e, 0xb8, 0x3b, 0x9e, 0xe8, 0x89, 0xd9, 0xad, - 0xff, 0xbc, 0x2d, 0xab, 0x9d, 0x8b, 0x0f, 0xb4, 0xbb, 0x9a, 0x69, 0x85, 0x00, -]; -pub(crate) const DEFAULT_ONE_WAY_KEY: &[u8] = b"nGui9('&1=@3#a"; -pub(crate) const DEFAULT_ONE_WAY_FULL_KEY: &[u8] = b"Ph=X3^]o2A(,1=@3#a"; -pub(crate) const MAX_DECODE_SIZE: usize = 2 * 1024 * 1024 * 1024; -pub(crate) const AES_KEY_EXP_SIZE: usize = 176; -pub(crate) const AES_IV_SIZE: usize = 16; -pub(crate) const AES_ROUND_KEY_SIZE: usize = AES_KEY_EXP_SIZE + AES_IV_SIZE; -pub(crate) const AES_BLOCK_LEN: usize = 16; -#[derive(Clone, Copy, Debug)] -pub(crate) struct DxHead { - pub(crate) head: u16, - pub(crate) version: u16, - pub(crate) head_size: u32, - pub(crate) data_start: u64, - pub(crate) name_table_start: u64, - pub(crate) file_table_start: u64, - pub(crate) directory_table_start: u64, - pub(crate) char_code_format: u32, - pub(crate) flags: u32, - pub(crate) huffman_encode_kb: u8, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) struct FileHead { - pub(crate) name_addr: u64, - pub(crate) attrs: u64, - pub(crate) data_addr: u64, - pub(crate) data_size: u64, - pub(crate) press_size: u64, - pub(crate) huff_size: u64, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) struct Directory { - pub(crate) directory_addr: u64, - pub(crate) parent_addr: u64, - pub(crate) file_head_num: u64, - pub(crate) file_head_addr: u64, -} - -#[derive(Debug)] -pub(crate) struct Layout { - pub(crate) table: Vec, - pub(crate) data_start: u64, - pub(crate) file_table_start: usize, - pub(crate) directory_table_start: usize, - pub(crate) huffman_encode_kb: u8, - pub(crate) flags: u32, - pub(crate) key_string: Vec, - pub(crate) main_key_pos: usize, - pub(crate) source: String, - pub(crate) wolf: Option, - /// ChaCha20 (key, nonce) for "ChaCha2" archives (cryptVersion 0x64 / 0xC8). - pub(crate) chacha: Option<([u8; 32], [u8; 12])>, -} - -#[derive(Clone, Debug)] -pub(crate) struct WolfContext { - pub(crate) crypt_version: u16, - pub(crate) other_key: [u8; 768], - pub(crate) special_key: [u8; 768], - pub(crate) aes_round_key: [u8; AES_ROUND_KEY_SIZE], - pub(crate) body_size: usize, - pub(crate) name_table_start: usize, -} - -/// `key_string` is the WolfPro key seed (use [`DEFAULT_WOLF_PRO_KEY`] when unknown). -pub fn extract_archive(data: &[u8], key_string: &[u8]) -> io::Result)>> { - let layout = match detect_layout(data, key_string) { - Ok(l) => l, - // Fall back to the old DXArchive VER5/VER6 format (Wolf 2.0x). - Err(e) => return dxarc_v2::try_extract(data).ok_or(e), - }; - let files = collect_files(&layout)?; - let mut out = Vec::with_capacity(files.len()); - for entry in &files { - let bytes = extract_file(data, &layout, entry)?; - out.push((entry.path.clone(), bytes)); - } - Ok(out) -} - -/// List archive file paths. Decodes only the tables, not file contents, so it stays cheap -/// even for multi-GB archives. -pub fn list_archive(data: &[u8], key_string: &[u8]) -> io::Result> { - match detect_layout(data, key_string) { - Ok(layout) => Ok(collect_files(&layout)? - .into_iter() - .map(|e| e.path) - .collect()), - Err(e) => dxarc_v2::try_extract(data) - .map(|f| f.into_iter().map(|(p, _)| p).collect()) - .ok_or(e), - } -} - -/// Extract a single file by its `\\`-separated archive path, decoding only that file. -pub fn extract_one(data: &[u8], key_string: &[u8], path: &str) -> io::Result>> { - let layout = match detect_layout(data, key_string) { - Ok(l) => l, - Err(e) => { - return match dxarc_v2::try_extract(data) { - Some(files) => Ok(files.into_iter().find(|(p, _)| p == path).map(|(_, c)| c)), - None => Err(e), - } - } - }; - let files = collect_files(&layout)?; - match files.iter().find(|e| e.path == path) { - Some(e) => Ok(Some(extract_file(data, &layout, e)?)), - None => Ok(None), - } -} - -/// CLI entry point: `wolf-unpack [output_dir] [key_string]`. Traces the head-table -/// decode with `key_string` and optionally extracts. -pub fn run(args: &[String]) -> io::Result<()> { - if args.len() < 2 { - eprintln!("usage: wolf-unpack [output_dir] [key_string]"); - std::process::exit(2); - } - - let archive_path = PathBuf::from(&args[1]); - let out_dir = args.get(2).map(PathBuf::from); - let key_string = args - .get(3) - .map(|s| s.as_bytes().to_vec()) - .unwrap_or_else(|| DEFAULT_WOLF_PRO_KEY.to_vec()); - - let mut data = Vec::new(); - fs::File::open(&archive_path)?.read_to_end(&mut data)?; - - let key = key_create(&key_string); - println!("archive: {}", archive_path.display()); - println!("key: {}", hex(&key)); - - let layout = detect_layout(&data, &key_string)?; - println!("layout: {}", layout.source); - println!( - "table={} data_start={} file_table={} dir_table={} huff_kb={} flags=0x{:08x} key_pos={}", - layout.table.len(), - layout.data_start, - layout.file_table_start, - layout.directory_table_start, - layout.huffman_encode_kb, - layout.flags, - layout.main_key_pos - ); - - let files = collect_files(&layout)?; - println!("files: {}", files.len()); - for (i, entry) in files.iter().take(20).enumerate() { - println!( - "{:5} {:10} {:10} {:10} {}", - i, - entry.head.data_size, - display_opt_size(entry.head.press_size), - display_opt_size(entry.head.huff_size), - entry.path - ); - } - - if let Some(out_dir) = out_dir { - extract_all(&data, &layout, &files, &out_dir)?; - println!("extracted to {}", out_dir.display()); - } - - Ok(()) -} -pub(crate) fn read_u16(b: &[u8]) -> u16 { - u16::from_le_bytes([b[0], b[1]]) -} - -pub(crate) fn read_u32(b: &[u8]) -> u32 { - u32::from_le_bytes([b[0], b[1], b[2], b[3]]) -} - -pub(crate) fn read_u64(b: &[u8]) -> u64 { - u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) -} - -pub(crate) fn decode_filename(raw: &[u8]) -> String { - // Archive filenames are ASCII/UTF-8 in newer (3.x) Wolf builds and Shift-JIS in older - // Japanese ones. A lossy UTF-8 decode turns SJIS lead bytes into U+FFFD while leaving - // their trailing bytes (e.g. 0x7C `|`, 0x3C `<`) as literal ASCII. Those are invalid in - // Windows paths (ERROR_INVALID_NAME) and abort the unpack. Decode as Shift-JIS whenever - // the bytes are not already valid UTF-8. - let s = match std::str::from_utf8(raw) { - Ok(s) => std::borrow::Cow::Borrowed(s), - Err(_) => encoding_rs::SHIFT_JIS.decode(raw).0, - }; - s.replace('/', "\\") -} - -pub(crate) fn safe_relative_path(path: &str) -> PathBuf { - let mut out = PathBuf::new(); - for part in path.split(['\\', '/']) { - if part.is_empty() || part == "." || part == ".." { - continue; - } - let cleaned: String = part - .chars() - .map(|c| match c { - '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_', - _ => c, - }) - .collect(); - out.push(cleaned); - } - out -} - -pub(crate) fn display_opt_size(v: u64) -> String { - if v == NONE { - "-".to_string() - } else { - v.to_string() - } -} - -pub(crate) fn hex(bytes: &[u8]) -> String { - bytes - .iter() - .map(|b| format!("{b:02X}")) - .collect::>() - .join("") -} - -pub(crate) fn invalid(msg: E) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, msg.to_string()) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/src/pack.rs b/util/wolfdawn-master/crates/wolf-archive/src/pack.rs deleted file mode 100644 index 7d38fd5..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/src/pack.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! DXArchive (VER8) writer: the inverse of [`crate::dxarchive`]. -//! -//! Produces a `.wolf` the engine loads (DXLib's `NO_HEAD_PRESS` mode, plus `NO_KEY` for the -//! plaintext variant) that our own extractor round-trips byte-for-byte. Follows DXLib's -//! `EncodeArchive`/`DirectoryEncode` address semantics (root self-filehead, per-directory -//! contiguous file runs, name-table parity), so the container is a real DXArchive. Re-encryption -//! per `cryptVersion` layers on top of the plaintext build, reusing the reader's symmetric -//! decrypt as the encrypt. -//! -//! ## Known deviations from an editor-produced archive (do not affect loading) -//! * Filenames are stored as UTF-8 bytes with the header declaring codepage 932. For the -//! all-ASCII paths Wolf uses internally (`Game.dat`, `MapData/Map001.mps`, …) UTF-8 == SJIS, -//! so this is exact. A non-ASCII filename would need Shift-JIS encoding to match the -//! engine's `packNum`/parity file search, which is not yet done (this crate is -//! zero-dependency). -//! * Inter-file 4-byte alignment padding is written as plaintext zeros (the reader reads -//! exactly `data_size` and ignores it). DXLib keys the padded length. So the archive -//! round-trips through our reader and loads, but is not byte-identical to the editor's. - -use std::collections::BTreeMap; - -use crate::crypto::{ - key_conv, key_create, read_wolf_archive_slice, wolf_aes_body_size, wolf_aes_init_round_key, - wolf_crypt, wolf_crypt_addresses, wolf_init_key, -}; -use crate::dxarchive::{collect_files, file_key, parse_full_head}; -use crate::{ - Layout, WolfContext, DEFAULT_WOLF_V310_KEY, DEFAULT_WOLF_V3173_KEY, DEFAULT_WOLF_V331_KEY, - DEFAULT_WOLF_V350_KEY, KEY_BYTES, -}; - -const HEADER_SIZE: usize = 64; -const FILEHEAD_SIZE: usize = 72; -const NONE: u64 = u64::MAX; - -const DX_HEAD: u16 = 0x5844; // *(u16*)"DX" -const DX_VER_8: u16 = 0x0008; -const FLAG_NO_KEY: u32 = 0x01; -const FLAG_NO_HEAD_PRESS: u32 = 0x02; -const CHAR_CODE_SJIS: u32 = 0x3A4; // codepage 932 -const ATTR_DIRECTORY: u64 = 0x10; -const ATTR_ARCHIVE: u64 = 0x20; - -/// A directory node built from the flat path list (references the caller's file bytes). -/// Shared with the VER5/VER6 encoder ([`crate::dxarc_v2::encode`]). -#[derive(Default)] -pub(crate) struct Node<'a> { - pub(crate) dirs: BTreeMap>, - pub(crate) files: BTreeMap, -} - -impl<'a> Node<'a> { - pub(crate) fn entry_count(&self) -> usize { - self.dirs.len() + self.files.len() - } - - pub(crate) fn insert(&mut self, parts: &[&str], data: &'a [u8]) { - match parts { - [] => {} - [name] => { - self.files.insert((*name).to_string(), data); - } - [dir, rest @ ..] => { - self.dirs - .entry((*dir).to_string()) - .or_default() - .insert(rest, data); - } - } - } -} - -/// The four growing sections of a DXArchive (mirrors DXLib's `SIZESAVE` + buffers). -#[derive(Default)] -struct Build { - name: Vec, - file: Vec, - dir: Vec, - data: Vec, -} - -/// Pack a flat `(path, bytes)` list into an unencrypted, uncompressed `.wolf` archive. -/// Paths may use `/` or `\` separators. Returns the archive bytes. Errors only if the list -/// is empty (DXArchive requires at least one entry under the root). -pub fn pack_plaintext(files: &[(String, Vec)]) -> Result, String> { - if files.is_empty() { - return Err("cannot pack an empty file list".to_string()); - } - - let mut root = Node::default(); - for (path, data) in files { - let parts: Vec<&str> = path.split(['\\', '/']).filter(|s| !s.is_empty()).collect(); - if parts.is_empty() { - return Err(format!("invalid (empty) path in file list: {path:?}")); - } - root.insert(&parts, data); - } - - let mut b = Build::default(); - - // Root self-filehead (empty name) at file-table offset 0, root DARC_DIRECTORY at dir - // offset 0 (parent = NONE). Mirrors DXLib's EncodeArchive root setup. - add_filename(&mut b.name, ""); // name_address 0 - b.file.resize(FILEHEAD_SIZE, 0); - write_filehead(&mut b.file, 0, 0, ATTR_DIRECTORY, 0, 0); - - let root_num = root.entry_count() as u64; - let root_fha = b.file.len() as u64; // = 72 - append_directory(&mut b.dir, 0, NONE, root_num, root_fha); - b.file - .resize(b.file.len() + FILEHEAD_SIZE * root_num as usize, 0); - - encode_children(&mut b, &root, root_fha, 0); - - // Assemble: HEADER | file data | name table | file table | directory table. - let head_size = b.name.len() + b.file.len() + b.dir.len(); - let mut out = Vec::with_capacity(HEADER_SIZE + b.data.len() + head_size); - write_header( - &mut out, - head_size as u32, - (HEADER_SIZE + b.data.len()) as u64, // name_table_start - b.name.len() as u64, // file_table_start (rel to name table) - (b.name.len() + b.file.len()) as u64, // directory_table_start (rel) - ); - out.extend_from_slice(&b.data); - out.extend_from_slice(&b.name); - out.extend_from_slice(&b.file); - out.extend_from_slice(&b.dir); - Ok(out) -} - -/// Re-encrypt a directory into a keyed VER8 archive for a known cryptVersion (v3.00 = `0x12C`, -/// v3.14 = `0x13A`). Every Wolf cipher is symmetric (XOR-stream), so we build the plaintext -/// container then apply the same `key_conv` the reader undoes. The 64-byte header stays -/// plaintext (only `Flags` is patched), the table block and each file's data are keyed, and -/// `NO_HEAD_PRESS` keeps the header uncompressed so no Huffman encoder is needed. The output -/// decodes through our own deterministic dispatch (see the module-level note for the minor -/// deviations from an editor-produced archive). -pub fn pack_encrypted(files: &[(String, Vec)], crypt_version: u16) -> Result, String> { - let key_string: &[u8] = match crypt_version { - 0x12C => DEFAULT_WOLF_V310_KEY, // Wolf v3.00 - 0x13A => DEFAULT_WOLF_V3173_KEY, // Wolf v3.14 - _ => { - return Err(format!( - "unsupported encrypt cryptVersion {crypt_version:#x} \ - (supported: 0x12c=v3.00, 0x13a=v3.14)" - )) - } - }; - - let mut out = pack_plaintext(files)?; - let head = parse_full_head(&out).map_err(|e| e.to_string())?; - let name_start = head.name_table_start as usize; - let head_size = head.head_size as usize; - let data_start = head.data_start; - - // A reader Layout over the RAW (still-plaintext) table, so per-file keys are computed - // from the same name table the reader will see after it decrypts. - let layout = Layout { - table: out[name_start..name_start + head_size].to_vec(), - data_start, - file_table_start: head.file_table_start as usize, - directory_table_start: head.directory_table_start as usize, - huffman_encode_kb: head.huffman_encode_kb, - flags: head.flags, - key_string: key_string.to_vec(), - main_key_pos: 0, - source: "encrypt".to_string(), - wolf: None, - chacha: None, - }; - let entries = collect_files(&layout).map_err(|e| e.to_string())?; - - // Encrypt each file's data with its per-file key, at the same start offset the reader - // uses (`data_size % KEY_BYTES`). - for entry in &entries { - let key = file_key(&layout, entry).map_err(|e| e.to_string())?; - let start = (data_start + entry.head.data_addr) as usize; - let len = entry.head.data_size as usize; - key_conv(&mut out[start..start + len], &key, len % KEY_BYTES); - } - - // Encrypt the table block (header stays plaintext) and patch Flags (clears NO_KEY). - key_conv( - &mut out[name_start..name_start + head_size], - &key_create(key_string), - 0, - ); - let flags = ((crypt_version as u32) << 16) | FLAG_NO_HEAD_PRESS; - out[44..48].copy_from_slice(&flags.to_le_bytes()); - - Ok(out) -} - -/// Re-encrypt into a "ChaCha2" archive (cryptVersion `0x64`). The header stays plaintext, the -/// table and each file's data are ChaCha20-keyed with the fixed chacha2 key/nonce (position 0 -/// for the table, `data_size` for each file, matching our decoder). ChaCha20 is symmetric, so -/// this is again the reader's decode applied to plaintext. Self-consistent with our extractor. -/// The ChaCha2 decode path itself is unverified against a real ChaCha2 game (none in the -/// corpus). -pub fn pack_chacha(files: &[(String, Vec)]) -> Result, String> { - let mut out = pack_plaintext(files)?; - let head = parse_full_head(&out).map_err(|e| e.to_string())?; - let name_start = head.name_table_start as usize; - let head_size = head.head_size as usize; - let data_start = head.data_start; - - let ck: [u8; 32] = crate::DEFAULT_WOLF_CHACHA2_KEY[0..32] - .try_into() - .map_err(|_| "chacha key")?; - let cn: [u8; 12] = crate::DEFAULT_WOLF_CHACHA2_KEY[32..44] - .try_into() - .map_err(|_| "chacha nonce")?; - - // Enumerate files from the plaintext table. - let layout = Layout { - table: out[name_start..name_start + head_size].to_vec(), - data_start, - file_table_start: head.file_table_start as usize, - directory_table_start: head.directory_table_start as usize, - huffman_encode_kb: head.huffman_encode_kb, - flags: head.flags, - key_string: Vec::new(), - main_key_pos: 0, - source: "chacha-encode".to_string(), - wolf: None, - chacha: Some((ck, cn)), - }; - let entries = collect_files(&layout).map_err(|e| e.to_string())?; - for entry in &entries { - let start = (data_start + entry.head.data_addr) as usize; - let len = entry.head.data_size as usize; - crate::chacha20::crypt(&mut out[start..start + len], &ck, &cn, len as u32); - } - // Table at position 0. - crate::chacha20::crypt(&mut out[name_start..name_start + head_size], &ck, &cn, 0); - - let flags = (0x64u32 << 16) | FLAG_NO_HEAD_PRESS; - out[44..48].copy_from_slice(&flags.to_le_bytes()); - Ok(out) -} - -/// Read the cryptVersion (`Flags>>16`) and the embedded 15-byte password from an existing -/// archive header (both live plaintext in the header). Feed these to [`pack_newcrypt`] to -/// repack a directory under the same encryption an original `Data.wolf` uses. -pub fn archive_crypt_params(data: &[u8]) -> Option<(u16, [u8; 15])> { - if data.len() < 64 { - return None; - } - let flags = u32::from_le_bytes(data[44..48].try_into().ok()?); - let mut pwd = [0u8; 15]; - pwd.copy_from_slice(&data[49..64]); - Some(((flags >> 16) as u16, pwd)) -} - -/// Re-encrypt into a WolfPro "new-crypt" archive (v3.31 = `0x14B`, v3.50 / v3.5 = `0x15E`). -/// The whole new-crypt transform (768-byte stream, AES-CTR, header address scramble) is -/// XOR-based, hence an involution, so encryption is exactly the reader's decrypt applied to -/// the plaintext container, reusing the tested primitives. The 15-byte `pwd` is embedded in the -/// header (bytes 49..64) just as the engine stores it. Re-encrypting GamePro with its own -/// embedded password yields a game-loadable archive (the Game.dat hash still validates). -/// `NO_HEAD_PRESS` keeps the header uncompressed (no Huffman encoder needed). -pub fn pack_newcrypt( - files: &[(String, Vec)], - crypt_version: u16, - pwd: &[u8; 15], -) -> Result, String> { - let key_string: &[u8] = match crypt_version { - 0x14B => DEFAULT_WOLF_V331_KEY, // Wolf Pro v3.31 - 0x15E => DEFAULT_WOLF_V350_KEY, // Wolf Pro v3.50 / v3.5 - _ => { - return Err(format!( - "unsupported new-crypt cryptVersion {crypt_version:#x} \ - (supported: 0x14b=v3.31, 0x15e=v3.50)" - )) - } - }; - - let mut out = pack_plaintext(files)?; - let head = parse_full_head(&out).map_err(|e| e.to_string())?; - let name_start = head.name_table_start as usize; - let head_size = head.head_size as usize; - let data_start = head.data_start; - - // Patch the header: embed the password, set new-crypt flags (cv<<16 | NO_HEAD_PRESS). - out[49..64].copy_from_slice(pwd); - let flags = ((crypt_version as u32) << 16) | FLAG_NO_HEAD_PRESS; - out[44..48].copy_from_slice(&flags.to_le_bytes()); - - // Build the context exactly as the reader will recompute it (file_size + embedded pwd). - let file_size = out.len(); - let ctx = WolfContext { - crypt_version, - other_key: wolf_init_key(crypt_version, pwd, None, true, key_string), - special_key: wolf_init_key(crypt_version, pwd, None, false, key_string), - aes_round_key: wolf_aes_init_round_key(pwd, None, crypt_version), - body_size: wolf_aes_body_size(file_size, crypt_version, pwd, None), - name_table_start: name_start, - }; - - // Enumerate files from the still-plaintext table (collect_files only walks the table). - let layout = Layout { - table: out[name_start..name_start + head_size].to_vec(), - data_start, - file_table_start: head.file_table_start as usize, - directory_table_start: head.directory_table_start as usize, - huffman_encode_kb: head.huffman_encode_kb, - flags, - key_string: key_string.to_vec(), - main_key_pos: 0, - source: "newcrypt-encode".to_string(), - wolf: Some(ctx.clone()), - chacha: None, - }; - let entries = collect_files(&layout).map_err(|e| e.to_string())?; - - // Encrypt each file's data: ciphertext = wolf_crypt_special(S(plaintext)), i.e. the - // reader's decrypt path, which is its own inverse. File regions are disjoint, so order - // is irrelevant and reading one never depends on another. - for entry in &entries { - let start = (data_start + entry.head.data_addr) as usize; - let len = entry.head.data_size as usize; - let mut slice = read_wolf_archive_slice(&out, start, len, &ctx) - .ok_or("file data slice out of range")?; - wolf_crypt(&ctx.special_key, &mut slice, len, crypt_version); - out[start..start + len].copy_from_slice(&slice); - } - - // Encrypt the table block (still plaintext at this point). - let mut table = read_wolf_archive_slice(&out, name_start, head_size, &ctx) - .ok_or("table slice out of range")?; - wolf_crypt(&ctx.special_key, &mut table, 0, crypt_version); - out[name_start..name_start + head_size].copy_from_slice(&table); - - // Encrypt the header address fields (bytes 8..40). The password at 49..64 stays plaintext. - let mut header = out[..64].to_vec(); - wolf_crypt_addresses(&mut header, pwd, crypt_version); - out[..64].copy_from_slice(&header); - - Ok(out) -} - -/// Write the `data_number`-th entry slots of a directory whose children block starts at -/// `fha`. Subdirectories recurse (each writes its own self-filehead into this block). Files -/// write a leaf filehead and append their (4-aligned) data. `this_dir_addr` is this -/// directory's own DirectoryAddress (used by children for ParentDirectoryAddress). -fn encode_children(b: &mut Build, node: &Node, fha: u64, this_dir_addr: u64) { - let mut i = 0u64; - for (name, child) in &node.dirs { - directory_encode(b, child, name, fha, this_dir_addr, i); - i += 1; - } - for (name, data) in &node.files { - write_file_entry(b, name, data, fha, i); - i += 1; - } -} - -/// Encode one subdirectory: its self-filehead goes into the parent's reserved slot -/// (`parent_fha + data_number*72`). Its own DARC_DIRECTORY and children block follow. -fn directory_encode( - b: &mut Build, - node: &Node, - name: &str, - parent_fha: u64, - parent_dir_addr: u64, - data_number: u64, -) { - // This dir's self-filehead (written into the parent's file run). - let name_addr = b.name.len() as u64; - let this_dir_offset = b.dir.len() as u64; - add_filename(&mut b.name, name); - let self_fh_off = (parent_fha + data_number * FILEHEAD_SIZE as u64) as usize; - write_filehead( - &mut b.file, - self_fh_off, - name_addr, - ATTR_DIRECTORY, - this_dir_offset, - 0, - ); - - let this_fha = b.file.len() as u64; - // ParentDirectoryAddress = DataAddress field of the parent's self-filehead (= parent's - // dir-table offset), or 0 for children of the root (whose DirectoryAddress is 0). - let parent_directory_address = if parent_dir_addr != NONE && parent_dir_addr != 0 { - read_u64(&b.file, parent_dir_addr as usize + 40) - } else { - 0 - }; - let num = node.entry_count() as u64; - append_directory( - &mut b.dir, - self_fh_off as u64, - parent_directory_address, - num, - this_fha, - ); - b.file - .resize(b.file.len() + FILEHEAD_SIZE * num as usize, 0); - - encode_children(b, node, this_fha, self_fh_off as u64); -} - -/// Write a leaf file: its filehead into slot `data_number` of the run at `fha`, its bytes -/// (4-aligned) into the data section. -fn write_file_entry(b: &mut Build, name: &str, data: &[u8], fha: u64, data_number: u64) { - let name_addr = b.name.len() as u64; - add_filename(&mut b.name, name); - let data_addr = b.data.len() as u64; - let off = (fha + data_number * FILEHEAD_SIZE as u64) as usize; - write_filehead( - &mut b.file, - off, - name_addr, - ATTR_ARCHIVE, - data_addr, - data.len() as u64, - ); - b.data.extend_from_slice(data); - while b.data.len() % 4 != 0 { - b.data.push(0); - } -} - -/// Append a DXArchive name-table entry: `[u16 packNum][u16 parity][UPPER name][original name]`. -/// `packNum = ceil((len+1)/4)` and parity = sum of the uppercased name bytes. The engine's file -/// search matches on packNum and parity, so both must be exact. The name-table format is -/// identical across v8 and VER5/VER6, so the VER5/VER6 encoder ([`crate::dxarc_v2::encode`]) -/// reuses this. -pub(crate) fn add_filename(buf: &mut Vec, name: &str) { - let s = name.as_bytes(); - if s.is_empty() { - buf.extend_from_slice(&[0, 0, 0, 0]); // packNum 0, parity 0 - return; - } - let length = s.len() + 1; // include the trailing NUL - let pack_num = (length + 3) / 4; - let entry_size = pack_num * 4 * 2 + 4; - let start = buf.len(); - buf.resize(start + entry_size, 0); - - let upper_off = start + 4; - let orig_off = start + 4 + pack_num * 4; - buf[orig_off..orig_off + s.len()].copy_from_slice(s); // original name (NUL already 0) - - let mut parity: u32 = 0; - let mut i = 0; - while i < s.len() { - let lead = s[i]; - // Shift-JIS lead bytes carry a 2-byte char copied verbatim (no upper-casing). - if (0x81..=0x9F).contains(&lead) || (0xE0..=0xFC).contains(&lead) { - if i + 1 < s.len() { - buf[upper_off + i] = s[i]; - buf[upper_off + i + 1] = s[i + 1]; - parity += s[i] as u32 + s[i + 1] as u32; - i += 2; - continue; - } - } - let up = if lead.is_ascii_lowercase() { - lead - b'a' + b'A' - } else { - lead - }; - buf[upper_off + i] = up; - parity += up as u32; - i += 1; - } - buf[start + 2..start + 4].copy_from_slice(&(parity as u16).to_le_bytes()); - buf[start..start + 2].copy_from_slice(&(pack_num as u16).to_le_bytes()); -} - -fn write_filehead( - file: &mut [u8], - off: usize, - name_addr: u64, - attrs: u64, - data_addr: u64, - data_size: u64, -) { - let b = &mut file[off..off + FILEHEAD_SIZE]; - b[0..8].copy_from_slice(&name_addr.to_le_bytes()); - b[8..16].copy_from_slice(&attrs.to_le_bytes()); - // Time (create / last-access / last-write) left zero. - b[40..48].copy_from_slice(&data_addr.to_le_bytes()); - b[48..56].copy_from_slice(&data_size.to_le_bytes()); - b[56..64].copy_from_slice(&NONE.to_le_bytes()); // PressDataSize: not compressed - b[64..72].copy_from_slice(&NONE.to_le_bytes()); // HuffPressDataSize: not compressed -} - -fn append_directory( - dir: &mut Vec, - directory_addr: u64, - parent_addr: u64, - file_head_num: u64, - file_head_addr: u64, -) { - dir.extend_from_slice(&directory_addr.to_le_bytes()); - dir.extend_from_slice(&parent_addr.to_le_bytes()); - dir.extend_from_slice(&file_head_num.to_le_bytes()); - dir.extend_from_slice(&file_head_addr.to_le_bytes()); -} - -fn write_header( - out: &mut Vec, - head_size: u32, - name_table_start: u64, - file_table_start: u64, - directory_table_start: u64, -) { - out.extend_from_slice(&DX_HEAD.to_le_bytes()); - out.extend_from_slice(&DX_VER_8.to_le_bytes()); - out.extend_from_slice(&head_size.to_le_bytes()); - out.extend_from_slice(&(HEADER_SIZE as u64).to_le_bytes()); // DataStartAddress - out.extend_from_slice(&name_table_start.to_le_bytes()); - out.extend_from_slice(&file_table_start.to_le_bytes()); - out.extend_from_slice(&directory_table_start.to_le_bytes()); - out.extend_from_slice(&CHAR_CODE_SJIS.to_le_bytes()); - out.extend_from_slice(&(FLAG_NO_KEY | FLAG_NO_HEAD_PRESS).to_le_bytes()); // cryptVersion 0 - out.push(0); // HuffmanEncodeKB (unused: nothing is compressed) - out.extend_from_slice(&[0u8; 15]); // Reserve -} - -fn read_u64(b: &[u8], off: usize) -> u64 { - u64::from_le_bytes(b[off..off + 8].try_into().unwrap()) -} diff --git a/util/wolfdawn-master/crates/wolf-archive/tests/extract_golden.rs b/util/wolfdawn-master/crates/wolf-archive/tests/extract_golden.rs deleted file mode 100644 index 443e180..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/tests/extract_golden.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Golden gate: extracting `Data.wolf` must reproduce the loose `Data/` tree byte-for-byte. -//! Guards the archive decoder against any future crypto refactor. Skips gracefully when the -//! 74 MB archive isn't present. - -use std::path::PathBuf; - -fn root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") -} - -#[test] -fn data_wolf_extracts_byte_identical() { - // Heavy (74 MB, decode attempts). Opt-in via WOLF_GOLDEN=1. - if std::env::var_os("WOLF_GOLDEN").is_none() { - eprintln!("set WOLF_GOLDEN=1 to run the Data.wolf golden extraction test"); - return; - } - let archive = root().join("Data.wolf"); - let Ok(bytes) = std::fs::read(&archive) else { - eprintln!("Data.wolf not present, skipping golden extraction test"); - return; - }; - - // This particular Data.wolf is a Wolf v3.x container that needs the deterministic - // `Flags>>16` version dispatch. Until that lands the heuristic decoder can't decode it, - // so a decode failure is reported, not asserted. - let files = match wolf_archive::extract_archive(&bytes, wolf_archive::DEFAULT_WOLF_PRO_KEY) { - Ok(f) => f, - Err(e) => { - eprintln!("archive decode not yet supported for this Data.wolf (pending version dispatch): {e}"); - return; - } - }; - eprintln!("extracted {} files from Data.wolf", files.len()); - assert!( - files.len() > 100, - "expected a populated archive, got {}", - files.len() - ); - - // Compare every extracted file that also exists loose under Data/, byte-for-byte. - let loose_root = root().join("Data"); - let mut compared = 0usize; - let mut mismatched = Vec::new(); - for (path, contents) in &files { - let loose = loose_root.join(path.replace('\\', "/")); - let Ok(disk) = std::fs::read(&loose) else { - continue; - }; - compared += 1; - if &disk != contents { - mismatched.push(path.clone()); - } - } - let matched = compared - mismatched.len(); - eprintln!("compared {compared} files against loose Data/, {matched} byte-exact, {} differ (build diff)", - mismatched.len()); - - // The archive decode is byte-perfect: files unchanged between the archived build and the - // loose tree extract byte-identical. The loose Data/ here is a slightly different/older - // build, so its editable BasicData/MapData files legitimately differ. Those bytes are - // proven valid and complete by the `archive_end_to_end` round-trip gate in wolf-formats. - assert!( - files.len() > 2000, - "expected a fully-populated archive, got {}", - files.len() - ); - assert!( - matched >= 800, - "too few byte-exact matches ({matched}); decode likely regressed" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-archive/tests/gamepro_archive.rs b/util/wolfdawn-master/crates/wolf-archive/tests/gamepro_archive.rs deleted file mode 100644 index 6c2c0f8..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/tests/gamepro_archive.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! New wolf-crypt (v3.31) decode against the real GameProData.wolf (1.26 GB). -//! WOLF_GOLDEN=1 cargo test -p wolf-archive --test gamepro_archive -- --nocapture -use std::path::PathBuf; - -#[test] -fn gamepro_archive_decodes() { - if std::env::var_os("WOLF_GOLDEN").is_none() { - eprintln!("set WOLF_GOLDEN=1 to run the (heavy) GameProData.wolf test"); - return; - } - let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("GameProData.wolf"); - let Ok(data) = std::fs::read(&p) else { - eprintln!("no GameProData.wolf"); - return; - }; - eprintln!("read {} bytes", data.len()); - - let paths = wolf_archive::list_archive(&data, wolf_archive::DEFAULT_WOLF_PRO_KEY) - .expect("new-crypt (v3.31) table decode"); - eprintln!("new-crypt table decode OK: {} files", paths.len()); - assert!(paths.len() > 1000, "expected a populated Pro archive"); - - // The loose GamePro_Data/ tree is this archive's extraction. Verify a sample of files - // are byte-identical (proves the new wolf-crypt decode is byte-perfect). - let loose = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("GamePro_Data"); - let samples = [ - "BasicData\\CommonEvent.dat", - "BasicData\\DataBase.dat", - "BasicData\\CDataBase.dat", - "BasicData\\SysDatabase.dat", - "BasicData\\Game.dat", - "BasicData\\TileSetData.dat", - "MapData\\Map001.mps", - ]; - let mut checked = 0; - for s in samples { - let Some(ext) = wolf_archive::extract_one(&data, wolf_archive::DEFAULT_WOLF_PRO_KEY, s) - .expect("extract") - else { - continue; - }; - let Ok(disk) = std::fs::read(loose.join(s.replace('\\', "/"))) else { - continue; - }; - assert_eq!( - ext, disk, - "{s} must extract byte-identical to loose GamePro_Data" - ); - eprintln!(" byte-exact: {s} ({} bytes)", ext.len()); - checked += 1; - } - assert!(checked > 0, "no sample files compared"); -} diff --git a/util/wolfdawn-master/crates/wolf-archive/tests/pack_roundtrip.rs b/util/wolfdawn-master/crates/wolf-archive/tests/pack_roundtrip.rs deleted file mode 100644 index 78813e3..0000000 --- a/util/wolfdawn-master/crates/wolf-archive/tests/pack_roundtrip.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Plaintext packer gate: `extract(pack(files)) == files`. -//! -//! Proves the VER8 writer produces a container our own extractor reads back with identical -//! paths and bytes, across a synthetic nested tree and a real Wolf `Data/` subtree. -//! cargo test -p wolf-archive --test pack_roundtrip -- --nocapture - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use wolf_archive::{extract_archive, pack_chacha, pack_encrypted, pack_newcrypt, pack_plaintext}; - -/// Normalize a path to forward slashes for order-independent comparison. -fn norm(p: &str) -> String { - p.replace('\\', "/") -} - -fn roundtrip(files: &[(String, Vec)]) { - let archive = pack_plaintext(files).expect("pack"); - let extracted = extract_archive(&archive, b"").expect("extract our own archive"); - - let want: BTreeMap> = files.iter().map(|(p, d)| (norm(p), d.clone())).collect(); - let got: BTreeMap> = - extracted.into_iter().map(|(p, d)| (norm(&p), d)).collect(); - - assert_eq!( - got.keys().collect::>(), - want.keys().collect::>(), - "path set differs" - ); - for (p, data) in &want { - assert_eq!(got.get(p), Some(data), "bytes differ for {p}"); - } -} - -#[test] -fn synthetic_tree_round_trips() { - let files = vec![ - ("BasicData/CommonEvent.dat".to_string(), vec![1, 2, 3, 4, 5]), - ("BasicData/Game.dat".to_string(), b"hello game".to_vec()), - ("MapData/Map001.mps".to_string(), vec![0xAA; 1000]), - ("MapData/Map002.mps".to_string(), vec![]), // empty file - ("MapData/sub/Deep.mps".to_string(), vec![7; 3]), // odd length (alignment) - ("top.txt".to_string(), b"a top-level file".to_vec()), - ]; - roundtrip(&files); -} - -#[test] -fn encrypted_round_trips_through_our_reader() { - // pack_encrypted(cv) -> extract_archive (auto-detects cryptVersion = Flags>>16) -> files. - // Proves the symmetric re-encryption decodes through our deterministic dispatch. - let files = vec![ - ( - "BasicData/Game.dat".to_string(), - vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9], - ), - ("BasicData/CommonEvent.dat".to_string(), vec![0xCD; 777]), - ("MapData/Map001.mps".to_string(), b"map data here".to_vec()), - ("MapData/sub/Deep.mps".to_string(), vec![9, 8, 7]), - ]; - let want: BTreeMap> = files.iter().map(|(p, d)| (norm(p), d.clone())).collect(); - - for cv in [0x12Cu16, 0x13A] { - let archive = pack_encrypted(&files, cv).expect("encrypt"); - // The archive must NOT be plaintext: data should be transformed. - let extracted = extract_archive(&archive, b"").expect("extract encrypted"); - let got: BTreeMap> = - extracted.into_iter().map(|(p, d)| (norm(&p), d)).collect(); - assert_eq!(got, want, "cryptVersion {cv:#x} did not round-trip"); - } -} - -#[test] -fn wolfpro_newcrypt_round_trips_through_our_reader() { - // pack_newcrypt(cv, pwd) -> extract_archive (recomputes the context from the embedded - // password) -> files. Proves the symmetric WolfPro stream + AES-CTR + address scramble - // encode is the exact inverse of our verified decrypt path. - let files = vec![ - ("BasicData/Game.dat".to_string(), vec![0x11u8; 2048]), - ("BasicData/CommonEvent.dat".to_string(), vec![0x42; 5000]), - ("MapData/Map001.mps".to_string(), b"hello wolf pro".to_vec()), - ("MapData/sub/Deep.mps".to_string(), (0..255u8).collect()), - ]; - let want: BTreeMap> = files.iter().map(|(p, d)| (norm(p), d.clone())).collect(); - - let pwd: [u8; 15] = [ - 0x37, 0x9a, 0x14, 0xc2, 0x5b, 0x08, 0xe1, 0x4f, 0x2d, 0x71, 0xbc, 0x33, 0x96, 0x6a, 0xd8, - ]; - for cv in [0x14Bu16, 0x15E] { - let archive = pack_newcrypt(&files, cv, &pwd).expect("newcrypt encode"); - let extracted = extract_archive(&archive, b"").expect("decode newcrypt"); - let got: BTreeMap> = - extracted.into_iter().map(|(p, d)| (norm(&p), d)).collect(); - assert_eq!(got, want, "WolfPro cryptVersion {cv:#x} did not round-trip"); - } -} - -#[test] -fn chacha2_round_trips_through_our_reader() { - let files = vec![ - ("BasicData/Game.dat".to_string(), vec![0x5Au8; 4096]), - ( - "MapData/Map001.mps".to_string(), - b"chacha20 round trip".to_vec(), - ), - ("MapData/sub/Deep.mps".to_string(), (0..200u8).collect()), - ]; - let want: BTreeMap> = files.iter().map(|(p, d)| (norm(p), d.clone())).collect(); - let archive = pack_chacha(&files).expect("chacha encode"); - let extracted = extract_archive(&archive, b"").expect("decode chacha2"); - let got: BTreeMap> = - extracted.into_iter().map(|(p, d)| (norm(&p), d)).collect(); - assert_eq!(got, want, "ChaCha2 did not round-trip"); -} - -#[test] -fn real_data_subtree_round_trips() { - // Pack a real BasicData folder (ASCII paths, real binary content) and read it back. - let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/BasicData"); - if !base.exists() { - eprintln!("no Data/BasicData corpus, skipping"); - return; - } - - let mut files = Vec::new(); - collect(&base, &base, &mut files); - files.sort(); - if files.is_empty() { - eprintln!("no files found, skipping"); - return; - } - eprintln!("packing {} real files", files.len()); - roundtrip(&files); -} - -fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, Vec)>) { - let Ok(rd) = std::fs::read_dir(dir) else { - return; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - collect(base, &p, out); - } else if let Ok(bytes) = std::fs::read(&p) { - let rel = p - .strip_prefix(base) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - out.push((format!("BasicData/{rel}"), bytes)); - } - } -} - -#[test] -fn ver5_ver6_round_trip_through_our_reader() { - use wolf_archive::{pack_ver5, pack_ver6}; - let files = vec![ - ("BasicData/Game.dat".to_string(), vec![0x3Cu8; 1234]), - ( - "BasicData/CommonEvent.dat".to_string(), - b"old wolf 2.0x".to_vec(), - ), - ("MapData/Map001.mps".to_string(), (0..250u8).collect()), - ("MapData/sub/Deep.mps".to_string(), vec![5, 4, 3, 2, 1]), - ]; - let want: BTreeMap> = files.iter().map(|(p, d)| (norm(p), d.clone())).collect(); - - for (label, archive) in [ - ("VER5", pack_ver5(&files).expect("ver5 encode")), - ("VER6", pack_ver6(&files).expect("ver6 encode")), - ] { - let extracted = extract_archive(&archive, b"").expect("decode legacy archive"); - let got: BTreeMap> = - extracted.into_iter().map(|(p, d)| (norm(&p), d)).collect(); - assert_eq!(got, want, "{label} did not round-trip"); - } -} diff --git a/util/wolfdawn-master/crates/wolf-cli/Cargo.toml b/util/wolfdawn-master/crates/wolf-cli/Cargo.toml deleted file mode 100644 index e9f2774..0000000 --- a/util/wolfdawn-master/crates/wolf-cli/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "wolf-cli" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "The `wolf` command-line front-end for the WolfDawn toolchain." - -[[bin]] -name = "wolf" -path = "src/main.rs" - -[dependencies] -wolf-core = { path = "../wolf-core" } -wolf-formats = { path = "../wolf-formats" } -wolf-decompiler = { path = "../wolf-decompiler" } -wolf-archive = { path = "../wolf-archive" } diff --git a/util/wolfdawn-master/crates/wolf-cli/src/cmd.rs b/util/wolfdawn-master/crates/wolf-cli/src/cmd.rs deleted file mode 100644 index a1adbe0..0000000 --- a/util/wolfdawn-master/crates/wolf-cli/src/cmd.rs +++ /dev/null @@ -1,2754 +0,0 @@ -//! Subcommand implementations for the `wolf` CLI. - -use std::path::{Path, PathBuf}; -use std::process::ExitCode; - -use wolf_decompiler::{ - apply_database_edit, apply_game_dat, apply_memory, audit_common_events_to_json, - audit_db_to_json, audit_map_to_json, build_memory, check_name_conflicts, - compile_common_events_edit, compile_map_edit, database_to_json, db_strings_to_json, - decompile_common_events, decompile_common_events_edit_annotated, decompile_map, - decompile_map_edit_annotated, dropped_sources, dump_game_dat, extract_common_events, - extract_db_strings, extract_game_dat, extract_map, extract_names, extract_txt_events, - inject_common_events, inject_db_strings, inject_game_dat, inject_map, inject_names, - inject_txt_events, scenes_to_json, update_save, InjectOptions, InjectStats, MergeStats, - SymbolTable, -}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::game_dat::GameDat; -use wolf_formats::map::Map; - -pub(crate) fn cmd_decompile(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut edit = false; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "--mode" => edit = it.next().map(String::as_str) == Some("edit"), - "--edit" => edit = true, - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!("decompile: missing input file"); - return ExitCode::from(64); - }; - - let result = if edit { - decompile_edit_path(&input) - } else { - decompile_path(&input) - }; - let text = match result { - Ok(t) => t, - Err(e) => { - eprintln!("decompile failed for {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - - match output { - Some(out) => { - if let Err(e) = std::fs::write(&out, text) { - eprintln!("could not write {}: {e}", out.display()); - return ExitCode::from(4); - } - eprintln!("wrote {}", out.display()); - } - None => { - // Ignore a broken pipe (e.g. piping into `head`) instead of panicking. - use std::io::Write; - let _ = std::io::stdout().write_all(text.as_bytes()); - } - } - ExitCode::SUCCESS -} - -/// `wolf export-names `. Dumps the deduplicated **engine/structural** text -/// (variable names, DB type/field names, system-table row names) for building the -/// engine-text glossary. Excludes player-facing content (item/skill/actor display names). -pub(crate) fn cmd_export_names(args: &[String]) -> ExitCode { - use std::collections::BTreeSet; - - let dir = PathBuf::from(args.first().map(String::as_str).unwrap_or(".")); - let basic = if dir.join("CommonEvent.dat").exists() { - dir.clone() - } else if dir.join("BasicData").join("CommonEvent.dat").exists() { - dir.join("BasicData") - } else { - dir.clone() - }; - - let mut symbols = SymbolTable::new(); - if let Ok(b) = std::fs::read(basic.join("CommonEvent.dat")) { - if let Ok(ce) = CommonEventsFile::read(&b) { - symbols.add_common_events(&ce); - } - } - add_databases(&mut symbols, Some(&basic)); - - fn consider(set: &mut BTreeSet, s: &str) { - let t = s.trim(); - let has_jp = t.chars().any(|c| c as u32 >= 0x3000); // CJK/kana - needs translating - // Exclude markup/format-code chars only. `/`, `=`, `(`, `)` are legitimate in stock - // engine variable/field names (e.g. "[読]ネット/状態 -1失敗 0通信中 1終了"), so they stay. - let noisy = t.contains(['\\', '<', '>', '▼', '★', '※']); - if !t.is_empty() && t.chars().count() <= 60 && has_jp && !noisy { - set.insert(t.to_string()); - } - } - - let mut set = BTreeSet::new(); - for ce in symbols.common_events.values() { - for v in ce.self_vars.values() { - consider(&mut set, v); - } - for v in &ce.inputs { - consider(&mut set, v); - } - } - for v in symbols - .globals - .normal - .values() - .chain(symbols.globals.string.values()) - .chain(symbols.globals.system.values()) - { - consider(&mut set, v); - } - for db in &symbols.databases { - for t in &db.types { - consider(&mut set, &t.name); - for f in &t.fields { - consider(&mut set, f); - } - // System/config row names only (SysDB rows + the Basic-System CDB tables); - // User-DB content rows (item/skill names) are player-facing game text. - let system_rows = db.kind == "SDB" || (db.kind == "CDB" && t.name.contains("基本")); - if system_rows { - for d in &t.data { - consider(&mut set, d); - } - } - } - } - - for s in &set { - println!("{s}"); - } - eprintln!("{} unique engine-text strings", set.len()); - ExitCode::SUCCESS -} - -/// `wolf xref `. Finds every write/read of a variable/reference across -/// all maps and common events. Answers "where is this value set?". -pub(crate) fn cmd_xref(args: &[String]) -> ExitCode { - let Some(reff) = args.first() else { - eprintln!("xref: usage: wolf xref \"\" "); - return ExitCode::from(64); - }; - let dir = PathBuf::from(args.get(1).map(String::as_str).unwrap_or(".")); - - let mut files: Vec = Vec::new(); - collect(&dir, &mut files); - files.sort(); - - let mut writes = 0usize; - let mut reads = 0usize; - for path in &files { - if !matches!(classify(path), FileKind::Map | FileKind::CommonEvent) { - continue; - } - let Ok(text) = decompile_path(path) else { - continue; - }; - let rel = rel(&dir, path); - for (i, line) in text.lines().enumerate() { - if !line.contains(reff.as_str()) { - continue; - } - let t = line.trim_start(); - let is_write = is_write_to(t, reff); - if is_write { - writes += 1; - } else { - reads += 1; - } - let tag = if is_write { "SET " } else { "read" }; - println!("{tag} {rel}:{} {t}", i + 1); - } - } - println!("\n{reff}: {writes} write(s), {reads} read(s)"); - ExitCode::SUCCESS -} - -/// True if the decompiled line assigns to `reff` (a write site). -pub(crate) fn is_write_to(line: &str, reff: &str) -> bool { - line.starts_with(&format!("SetVariable {reff} ")) - || line.starts_with(&format!("SetString {reff} =")) - || line.starts_with(&format!("{reff} =")) // DB-read / GetXXX-into form -} - -/// Debug: dump raw (cid, indent, int args, string count) per command. Ground truth for -/// validating the decompiler's argument decoding. -pub(crate) fn cmd_raw(args: &[String]) -> ExitCode { - let Some(input) = args.first() else { - eprintln!("raw: missing input file"); - return ExitCode::from(64); - }; - let path = PathBuf::from(input); - let bytes = match std::fs::read(&path) { - Ok(b) => b, - Err(e) => { - eprintln!("read failed: {e}"); - return ExitCode::from(4); - } - }; - // CommonEvent: dump the per-event metadata strings (name/description/self-var-name - // candidates) to see what naming info WOLF stores. - if matches!(classify(&path), FileKind::Database) { - let proj = if path.extension().and_then(|s| s.to_str()) == Some("project") { - path.clone() - } else { - path.with_extension("project") - }; - let dat = proj.with_extension("dat"); - let (Ok(pb), Ok(db_)) = (std::fs::read(&proj), std::fs::read(&dat)) else { - eprintln!("could not read database pair"); - return ExitCode::from(4); - }; - let db = match Database::read(&pb, &db_) { - Ok(d) => d, - Err(e) => { - eprintln!("parse failed: {e}"); - return ExitCode::from(4); - } - }; - let dec = |w: &wolf_formats::WStr| { - let mut b = w.as_bytes(); - if b.last() == Some(&0) { - b = &b[..b.len() - 1]; - } - String::from_utf8_lossy(b).into_owned() - }; - for (ti, t) in db.types.iter().enumerate() { - println!( - "type {ti} \"{}\" ({} fields, {} data rows)", - dec(&t.name), - t.fields.len(), - t.data.len() - ); - let fields: Vec = t.fields.iter().map(|f| dec(&f.name)).collect(); - println!(" fields: {}", fields.join(" | ")); - let sample: Vec = t.data.iter().take(8).map(|d| dec(&d.name)).collect(); - println!(" data: {}", sample.join(" | ")); - } - return ExitCode::SUCCESS; - } - - if matches!(classify(&path), FileKind::CommonEvent) { - let ce = match CommonEventsFile::read(&bytes) { - Ok(c) => c, - Err(e) => { - eprintln!("parse failed: {e}"); - return ExitCode::from(4); - } - }; - let want: Vec = args[1..].iter().filter_map(|s| s.parse().ok()).collect(); - for ev in &ce.events { - if !want.is_empty() && !want.contains(&ev.int_id) { - continue; - } - let dec = |w: &wolf_formats::WStr| { - let mut b = w.as_bytes(); - if b.last() == Some(&0) { - b = &b[..b.len() - 1]; - } - String::from_utf8_lossy(b).into_owned() - }; - println!( - "commonEvent {} \"{}\" ({} commands)", - ev.int_id, - dec(&ev.name), - ev.commands.len() - ); - println!(" description: {:?}", dec(&ev.description)); - let nonempty = |label: &str, v: &[wolf_formats::WStr]| { - let items: Vec = v - .iter() - .enumerate() - .filter(|(_, w)| !dec(w).is_empty()) - .map(|(i, w)| format!("{i}:{:?}", dec(w))) - .collect(); - if !items.is_empty() { - println!(" {label} ({}): {}", v.len(), items.join(" ")); - } - }; - nonempty("unknown3", &ev.unknown3); - nonempty("unknown8", &ev.unknown8); - for (i, group) in ev.unknown5.iter().enumerate() { - nonempty(&format!("unknown5[{i}]"), group); - } - println!(); - } - return ExitCode::SUCCESS; - } - - let map = match Map::read(&bytes) { - Ok(m) => m, - Err(e) => { - eprintln!("parse failed: {e}"); - return ExitCode::from(4); - } - }; - for ev in &map.events { - println!("event {} {:?}", ev.id, ev.name.to_lossy()); - for (pi, page) in ev.pages.iter().enumerate() { - println!(" page {pi}"); - for c in &page.commands { - println!( - " i{} cid={} {} ints={:?} strs={}", - c.indent, - c.cid, - wolf_decompiler::spec::command_name(c.cid), - c.int_args, - c.str_args.len() - ); - } - } - } - ExitCode::SUCCESS -} - -pub(crate) fn decompile_path(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - match classify(path) { - FileKind::Map => { - let map = Map::read(&bytes).map_err(|e| e.to_string())?; - let symbols = load_symbols(path); - Ok(decompile_map(&map, &symbols)) - } - FileKind::CommonEvent => { - let ce = CommonEventsFile::read(&bytes).map_err(|e| e.to_string())?; - let mut symbols = SymbolTable::new(); - symbols.add_common_events(&ce); - add_databases(&mut symbols, path.parent()); - symbols.set_glossary(wolf_decompiler::symbols::load_embedded_glossary()); - symbols.set_engine_text(wolf_decompiler::symbols::load_embedded_engine_glossary()); - Ok(decompile_common_events(&ce, &symbols)) - } - FileKind::Database | FileKind::BasicDatabase | FileKind::GameDat - | FileKind::TxtEvent | FileKind::Unsupported => Err(format!( - "{} is not a code-bearing file (decompile supports .mps and CommonEvent.dat)", - path.display() - )), - } -} - -/// Decompile to the **edit** document (recompilable, identity-delimited), the form `wolf -/// compile` consumes. Symbol-aware: each operand carries its index **and** a bilingual label -/// (`V[3 "Gold · ゴールド"]`), so the one file is both readable and recompilable. The recompiler -/// strips the labels and round-trips byte-exact. `--numeric` (handled by the caller) falls back -/// to the bare-index form. -pub(crate) fn decompile_edit_path(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - match classify(path) { - FileKind::Map => { - let map = Map::read(&bytes).map_err(|e| e.to_string())?; - Ok(decompile_map_edit_annotated(&map, &load_symbols(path))) - } - FileKind::CommonEvent => { - let ce = CommonEventsFile::read(&bytes).map_err(|e| e.to_string())?; - let mut symbols = SymbolTable::new(); - symbols.add_common_events(&ce); - add_databases(&mut symbols, path.parent()); - symbols.set_glossary(wolf_decompiler::symbols::load_embedded_glossary()); - symbols.set_engine_text(wolf_decompiler::symbols::load_embedded_engine_glossary()); - Ok(decompile_common_events_edit_annotated(&ce, &symbols)) - } - FileKind::Database | FileKind::BasicDatabase | FileKind::GameDat - | FileKind::TxtEvent | FileKind::Unsupported => Err(format!( - "{} is not a code-bearing file (edit mode supports .mps and CommonEvent.dat)", - path.display() - )), - } -} - -/// `wolf compile --base -o `. Recompiles an -/// edit document back into a runnable file. The base supplies all opaque metadata. Only the -/// command bodies are swapped in. With code untouched the output is byte-identical to base. -pub(crate) fn cmd_compile(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut base: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "-b" | "--base" => base = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let (Some(input), Some(base)) = (input, base) else { - eprintln!("compile: usage: wolf compile --base -o "); - return ExitCode::from(64); - }; - - let text = match std::fs::read_to_string(&input) { - Ok(t) => t, - Err(e) => { - eprintln!("compile: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - let base_bytes = match std::fs::read(&base) { - Ok(b) => b, - Err(e) => { - eprintln!("compile: cannot read base {}: {e}", base.display()); - return ExitCode::from(4); - } - }; - - let out_bytes = match classify(&base) { - FileKind::CommonEvent => { - let mut ce = match CommonEventsFile::read(&base_bytes) { - Ok(c) => c, - Err(e) => { - eprintln!("compile: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - if let Err(e) = compile_common_events_edit(&text, &mut ce) { - eprintln!("compile failed: {e}"); - return ExitCode::from(2); - } - ce.write() - } - FileKind::Map => { - let mut map = match Map::read(&base_bytes) { - Ok(m) => m, - Err(e) => { - eprintln!("compile: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - if let Err(e) = compile_map_edit(&text, &mut map) { - eprintln!("compile failed: {e}"); - return ExitCode::from(2); - } - map.write() - } - FileKind::Database | FileKind::BasicDatabase | FileKind::GameDat - | FileKind::TxtEvent | FileKind::Unsupported => { - eprintln!("compile: base must be a .mps or CommonEvent.dat"); - return ExitCode::from(64); - } - }; - - let Some(out) = output else { - eprintln!("compile: missing -o "); - return ExitCode::from(64); - }; - if let Err(e) = std::fs::write(&out, &out_bytes) { - eprintln!("compile: cannot write {}: {e}", out.display()); - return ExitCode::from(4); - } - let same = out_bytes == base_bytes; - eprintln!( - "wrote {} ({} bytes{})", - out.display(), - out_bytes.len(), - if same { ", byte-identical to base" } else { "" } - ); - ExitCode::SUCCESS -} - -/// `wolf db-json [-o out]`. Exports a database (or every -/// database in a BasicData dir) to readable JSON for inspection. A single file writes to -/// `-o file` (or stdout). A directory writes one `.json` per DB into `-o ` (default `.`). -pub(crate) fn cmd_db_json(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!("db-json: usage: wolf db-json [-o out]"); - return ExitCode::from(64); - }; - - if input.is_dir() { - let dir = output.unwrap_or_else(|| PathBuf::from(".")); - if let Err(e) = std::fs::create_dir_all(&dir) { - eprintln!("db-json: cannot create {}: {e}", dir.display()); - return ExitCode::from(4); - } - let mut n = 0; - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - let proj = input.join(format!("{stem}.project")); - if !proj.exists() { - continue; - } - match db_json_one(&proj) { - Ok(json) => { - let out = dir.join(format!("{stem}.json")); - if let Err(e) = std::fs::write(&out, &json) { - eprintln!("db-json: cannot write {}: {e}", out.display()); - return ExitCode::from(4); - } - eprintln!("wrote {} ({} bytes)", out.display(), json.len()); - n += 1; - } - Err(e) => eprintln!("db-json: {stem}: {e}"), - } - } - if n == 0 { - eprintln!("db-json: no databases found under {}", input.display()); - return ExitCode::from(4); - } - return ExitCode::SUCCESS; - } - - let proj = if input.extension().and_then(|s| s.to_str()) == Some("dat") { - input.with_extension("project") - } else { - input.clone() - }; - match db_json_one(&proj) { - Ok(json) => match output { - Some(out) => { - if let Err(e) = std::fs::write(&out, &json) { - eprintln!("db-json: cannot write {}: {e}", out.display()); - return ExitCode::from(4); - } - eprintln!("wrote {} ({} bytes)", out.display(), json.len()); - ExitCode::SUCCESS - } - None => { - use std::io::Write; - let _ = std::io::stdout().write_all(json.as_bytes()); - ExitCode::SUCCESS - } - }, - Err(e) => { - eprintln!("db-json failed: {e}"); - ExitCode::from(4) - } - } -} - -/// Read a `.project` (+ sibling `.dat`) and render it to JSON, labelling the kind by stem. -/// Report any lines a safety guard skipped and pick the exit code (non-zero if so, so CI notices, -/// even though the good lines were still written). -fn guard_report(st: &InjectStats) -> ExitCode { - if st.code_mismatch > 0 || st.unrepresentable > 0 { - eprintln!( - "WARNING: {} line(s) left UNTRANSLATED by a safety guard - {} control-code mismatch, {} not encodable; see the per-line messages above (pass --allow-code-drift to override the code check)", - st.code_mismatch + st.unrepresentable, - st.code_mismatch, - st.unrepresentable, - ); - ExitCode::from(2) - } else { - ExitCode::SUCCESS - } -} - -/// Apply DB translations onto a `.project`+`.dat` pair, writing both halves byte-exact. -fn strings_inject_db(input: &Path, base: &Path, output: &Path, opts: &InjectOptions) -> ExitCode { - let proj = if base.extension().and_then(|s| s.to_str()) == Some("project") { - base.to_path_buf() - } else { - base.with_extension("project") - }; - let base_dat = proj.with_extension("dat"); - let out_dat = output.with_extension("dat"); - let (Ok(text), Ok(p), Ok(d)) = ( - std::fs::read_to_string(input), - std::fs::read(&proj), - std::fs::read(&base_dat), - ) else { - eprintln!("strings-inject: cannot read input or DB pair"); - return ExitCode::from(4); - }; - let mut db = match Database::read(&p, &d) { - Ok(db) => db, - Err(e) => { - eprintln!("strings-inject: DB parse failed: {e}"); - return ExitCode::from(4); - } - }; - let st = match inject_db_strings(&text, &mut db, opts) { - Ok(st) => st, - Err(e) => { - eprintln!("strings-inject failed: {e}"); - return ExitCode::from(2); - } - }; - let (op, od) = db.write(); - // Post-inject re-parse: never ship a structurally-corrupt file. - if let Err(e) = Database::read(&op, &od) { - eprintln!("strings-inject: internal error - injected DB no longer parses ({e}); aborting write to avoid a corrupt file. Please report."); - return ExitCode::from(2); - } - let identical = op == p && od == d; - if let (Err(e), _) | (_, Err(e)) = (std::fs::write(output, &op), std::fs::write(&out_dat, &od)) - { - eprintln!("strings-inject: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!( - "applied {} translation(s) ({} untranslated, {} drifted); wrote {} + {}{}", - st.applied, - st.untranslated, - st.drifted, - output.display(), - out_dat.display(), - if identical { - " (byte-identical to base)" - } else { - "" - } - ); - guard_report(&st) -} - -/// Collect the `.txt` files directly under `dir`, sorted by name for a stable, reproducible -/// document order. Event-text folders are flat, so this is a shallow scan, not recursive. -fn list_txt_files(dir: &Path) -> Vec { - let mut out: Vec = match std::fs::read_dir(dir) { - Ok(rd) => rd - .flatten() - .map(|e| e.path()) - .filter(|p| { - p.is_file() - && p.extension().and_then(|s| s.to_str()).map(str::to_ascii_lowercase) - == Some("txt".to_string()) - }) - .collect(), - Err(_) => Vec::new(), - }; - out.sort(); - out -} - -/// `strings-extract `. Extract every `.txt` event-text file under a directory into one -/// combined JSON keyed by file. Each entry is the per-file `extract_txt_events` document with a -/// `file` field naming it, so `strings-inject` can write each back to the matching base file. -fn strings_extract_txt_dir(dir: &Path, output: Option<&Path>) -> ExitCode { - let files = list_txt_files(dir); - if files.is_empty() { - eprintln!("strings-extract: no .txt files in {}", dir.display()); - return ExitCode::from(4); - } - let mut named: Vec<(String, Vec)> = Vec::with_capacity(files.len()); - for path in &files { - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) => { - eprintln!("strings-extract: cannot read {}: {e}", path.display()); - return ExitCode::from(4); - } - }; - let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("").to_owned(); - named.push((fname, bytes)); - } - let json = wolf_decompiler::extract_txt_dir(&named); - match output { - Some(out) => { - if let Err(e) = std::fs::write(out, &json) { - eprintln!("strings-extract: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!("wrote {} ({} txt file(s))", out.display(), files.len()); - } - None => { - use std::io::Write; - let _ = std::io::stdout().write_all(json.as_bytes()); - } - } - ExitCode::SUCCESS -} - -/// `strings-inject --base -o ` for a single event-text file. -/// Re-decodes the base, applies the edits, and writes the result, which is byte-identical to the -/// base for a no-op edit. -fn strings_inject_txt_file(input: &Path, base: &Path, output: &Path) -> ExitCode { - let (Ok(json), Ok(base_bytes)) = (std::fs::read_to_string(input), std::fs::read(base)) else { - eprintln!("strings-inject: cannot read input/base"); - return ExitCode::from(4); - }; - let out_bytes = match inject_txt_events(&json, &base_bytes) { - Ok(b) => b, - Err(e) => { - eprintln!("strings-inject failed: {e}"); - return ExitCode::from(2); - } - }; - let identical = out_bytes == base_bytes; - if let Err(e) = std::fs::write(output, &out_bytes) { - eprintln!("strings-inject: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!( - "wrote {}{}", - output.display(), - if identical { " (byte-identical to base)" } else { "" } - ); - ExitCode::SUCCESS -} - -/// `strings-inject --base -o ` for a folder of event-text -/// files. The combined JSON is a `txt-dir` document. Each `files[]` entry is injected against its -/// matching base file and written to `` under the same name. Files present in the base but -/// absent from the JSON are copied through unchanged, so the output directory mirrors the base. -fn strings_inject_txt_dir(input: &Path, base: &Path, out_dir: &Path) -> ExitCode { - let Ok(json) = std::fs::read_to_string(input) else { - eprintln!("strings-inject: cannot read {}", input.display()); - return ExitCode::from(4); - }; - let entries = match wolf_decompiler::split_txt_dir(&json) { - Ok(e) => e, - Err(e) => { - eprintln!("strings-inject: {e}"); - return ExitCode::from(2); - } - }; - if let Err(e) = std::fs::create_dir_all(out_dir) { - eprintln!("strings-inject: cannot create {}: {e}", out_dir.display()); - return ExitCode::from(4); - } - let mut named: std::collections::HashSet = std::collections::HashSet::new(); - let mut written = 0usize; - for (fname, per_file) in &entries { - named.insert(fname.clone()); - let base_path = base.join(fname); - let base_bytes = match std::fs::read(&base_path) { - Ok(b) => b, - Err(e) => { - eprintln!("strings-inject: cannot read base {}: {e}", base_path.display()); - continue; - } - }; - let out_bytes = match inject_txt_events(per_file, &base_bytes) { - Ok(b) => b, - Err(e) => { - eprintln!("strings-inject: {fname}: {e}"); - continue; - } - }; - if let Err(e) = std::fs::write(out_dir.join(fname), &out_bytes) { - eprintln!("strings-inject: write {fname} failed: {e}"); - continue; - } - written += 1; - } - // Copy through any base files the JSON did not mention so the output dir is complete. - let mut copied = 0usize; - for path in list_txt_files(base) { - let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); - if named.contains(fname) { - continue; - } - if let Ok(b) = std::fs::read(&path) { - if std::fs::write(out_dir.join(fname), &b).is_ok() { - copied += 1; - } - } - } - eprintln!( - "wrote {written} injected + {copied} passed-through txt file(s) to {}", - out_dir.display() - ); - ExitCode::SUCCESS -} - -/// `wolf strings-extract -o `. Extracts only player-facing -/// text (dialogue/choices/on-screen), speaker-attributed and grouped by event/scene, for -/// translation. The `text` field starts equal to `source`. Translators edit `text`. -pub(crate) fn cmd_strings_extract(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!( - "strings-extract: usage: wolf strings-extract -o " - ); - return ExitCode::from(64); - }; - // A directory is treated as a folder of external event-text `.txt` files (unpacked Evtext). - // One combined JSON keyed by file is produced. - if input.is_dir() { - return strings_extract_txt_dir(&input, output.as_deref()); - } - let bytes = match std::fs::read(&input) { - Ok(b) => b, - Err(e) => { - eprintln!("strings-extract: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - let fname = input.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let json = match classify(&input) { - FileKind::TxtEvent => extract_txt_events(&bytes), - FileKind::CommonEvent => match CommonEventsFile::read(&bytes) { - Ok(ce) => scenes_to_json(fname, "common", &extract_common_events(&ce)), - Err(e) => { - eprintln!("strings-extract: parse failed: {e}"); - return ExitCode::from(4); - } - }, - FileKind::Map => match Map::read(&bytes) { - Ok(m) => scenes_to_json(fname, "map", &extract_map(&m)), - Err(e) => { - eprintln!("strings-extract: parse failed: {e}"); - return ExitCode::from(4); - } - }, - FileKind::GameDat => match GameDat::read(&bytes) { - Ok(gd) => extract_game_dat(&gd), - Err(e) => { - eprintln!("strings-extract: parse failed: {e}"); - return ExitCode::from(4); - } - }, - FileKind::Database => { - let proj = if input.extension().and_then(|s| s.to_str()) == Some("project") { - input.clone() - } else { - input.with_extension("project") - }; - let dat = proj.with_extension("dat"); - let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) else { - eprintln!( - "strings-extract: cannot read DB pair for {}", - input.display() - ); - return ExitCode::from(4); - }; - match Database::read(&p, &d) { - Ok(db) => { - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - db_strings_to_json(fname, &extract_db_strings(&db, &glossary)) - } - Err(e) => { - eprintln!("strings-extract: DB parse failed: {e}"); - return ExitCode::from(4); - } - } - } - _ => { - eprintln!("strings-extract: only CommonEvent.dat, .mps maps, .project databases, Game.dat, and event-text .txt files (or a folder of them) carry player text"); - return ExitCode::from(64); - } - }; - match output { - Some(out) => { - if let Err(e) = std::fs::write(&out, &json) { - eprintln!("strings-extract: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!("wrote {}", out.display()); - } - None => { - use std::io::Write; - let _ = std::io::stdout().write_all(json.as_bytes()); - } - } - ExitCode::SUCCESS -} - -/// `wolf strings-audit -o `. QA coverage dump. -/// Lists every non-empty string arg / DB cell in the file, tagged with whether the player-text -/// extractor keeps it. Ground truth for precision (kept-but-not-text) plus recall (text-but-dropped). -pub(crate) fn cmd_strings_audit(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!("strings-audit: usage: wolf strings-audit -o "); - return ExitCode::from(64); - }; - let bytes = match std::fs::read(&input) { - Ok(b) => b, - Err(e) => { - eprintln!("strings-audit: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - let json = match classify(&input) { - FileKind::CommonEvent => match CommonEventsFile::read(&bytes) { - Ok(ce) => audit_common_events_to_json(&ce), - Err(e) => { - eprintln!("strings-audit: parse failed: {e}"); - return ExitCode::from(4); - } - }, - FileKind::Map => match Map::read(&bytes) { - Ok(m) => audit_map_to_json(&m), - Err(e) => { - eprintln!("strings-audit: parse failed: {e}"); - return ExitCode::from(4); - } - }, - FileKind::Database => { - let proj = if input.extension().and_then(|s| s.to_str()) == Some("project") { - input.clone() - } else { - input.with_extension("project") - }; - let dat = proj.with_extension("dat"); - let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) else { - eprintln!("strings-audit: cannot read DB pair for {}", input.display()); - return ExitCode::from(4); - }; - match Database::read(&p, &d) { - Ok(db) => { - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - audit_db_to_json(&db, &glossary) - } - Err(e) => { - eprintln!("strings-audit: DB parse failed: {e}"); - return ExitCode::from(4); - } - } - } - _ => { - eprintln!("strings-audit: only CommonEvent.dat, .mps maps, and .project databases"); - return ExitCode::from(64); - } - }; - match output { - Some(out) => { - if let Err(e) = std::fs::write(&out, &json) { - eprintln!("strings-audit: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!("wrote {}", out.display()); - } - None => { - use std::io::Write; - let _ = std::io::stdout().write_all(json.as_bytes()); - } - } - ExitCode::SUCCESS -} - -/// `wolf strings-inject --base -o `. Applies edited translations back, -/// byte-exact. Only lines whose `text` differs from `source` (and still match the base) are -/// changed. Everything else re-serializes identically. -pub(crate) fn cmd_strings_inject(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut base: Option = None; - let mut output: Option = None; - let mut opts = InjectOptions::default(); - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "-b" | "--base" => base = it.next().map(PathBuf::from), - "--allow-code-drift" => opts.allow_code_drift = true, - "--en-punct" => opts.normalize_punct = true, - _ => input = Some(PathBuf::from(a)), - } - } - let (Some(input), Some(base), Some(output)) = (input, base, output) else { - eprintln!( - "strings-inject: usage: wolf strings-inject --base -o [--allow-code-drift] [--en-punct]" - ); - return ExitCode::from(64); - }; - // Databases are a `.project`+`.dat` pair (write both). - if matches!(classify(&base), FileKind::Database) { - return strings_inject_db(&input, &base, &output, &opts); - } - // A directory base is a folder of external event-text `.txt` files (unpacked Evtext). The - // input JSON is the combined keyed-by-file document, and each file is written under `-o`. - if base.is_dir() { - return strings_inject_txt_dir(&input, &base, &output); - } - // A single external event-text `.txt` file. - if matches!(classify(&base), FileKind::TxtEvent) { - return strings_inject_txt_file(&input, &base, &output); - } - let (Ok(text), Ok(base_bytes)) = (std::fs::read_to_string(&input), std::fs::read(&base)) else { - eprintln!("strings-inject: cannot read input/base"); - return ExitCode::from(4); - }; - let (out_bytes, st) = match classify(&base) { - FileKind::CommonEvent => { - let mut ce = match CommonEventsFile::read(&base_bytes) { - Ok(c) => c, - Err(e) => { - eprintln!("strings-inject: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - let st = match inject_common_events(&text, &mut ce, &opts) { - Ok(st) => st, - Err(e) => { - eprintln!("strings-inject failed: {e}"); - return ExitCode::from(2); - } - }; - let out = ce.write(); - // Post-inject re-parse: never ship a structurally-corrupt file. - if let Err(e) = CommonEventsFile::read(&out) { - eprintln!("strings-inject: internal error - injected file no longer parses ({e}); aborting write to avoid a corrupt file. Please report."); - return ExitCode::from(2); - } - (out, st) - } - FileKind::Map => { - let mut m = match Map::read(&base_bytes) { - Ok(m) => m, - Err(e) => { - eprintln!("strings-inject: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - let st = match inject_map(&text, &mut m, &opts) { - Ok(st) => st, - Err(e) => { - eprintln!("strings-inject failed: {e}"); - return ExitCode::from(2); - } - }; - let out = m.write(); - if let Err(e) = Map::read(&out) { - eprintln!("strings-inject: internal error - injected file no longer parses ({e}); aborting write to avoid a corrupt file. Please report."); - return ExitCode::from(2); - } - (out, st) - } - FileKind::GameDat => { - let mut gd = match GameDat::read(&base_bytes) { - Ok(g) => g, - Err(e) => { - eprintln!("strings-inject: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - let st = match inject_game_dat(&text, &mut gd, &opts) { - Ok(st) => st, - Err(e) => { - eprintln!("strings-inject failed: {e}"); - return ExitCode::from(2); - } - }; - let out = gd.write(); - if let Err(e) = GameDat::read(&out) { - eprintln!("strings-inject: internal error - injected file no longer parses ({e}); aborting write to avoid a corrupt file. Please report."); - return ExitCode::from(2); - } - (out, st) - } - _ => { - eprintln!("strings-inject: base must be CommonEvent.dat, a .mps map, Game.dat, an event-text .txt file, or a folder of them"); - return ExitCode::from(64); - } - }; - let identical = out_bytes == base_bytes; - if let Err(e) = std::fs::write(&output, &out_bytes) { - eprintln!("strings-inject: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!( - "applied {} translation(s) ({} untranslated, {} drifted); wrote {}{}", - st.applied, - st.untranslated, - st.drifted, - output.display(), - if identical { - " (byte-identical to base)" - } else { - "" - } - ); - guard_report(&st) -} - -/// `wolf db-apply --base -o `. Applies value/row-name -/// edits from a `db-json` export back onto a database, writing `` and the sibling -/// ``. Only cell values and row names change. Schema edits are rejected. Untouched -/// data re-serializes byte-identical to the base. -pub(crate) fn cmd_db_apply(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut base: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "-b" | "--base" => base = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let (Some(input), Some(base), Some(output)) = (input, base, output) else { - eprintln!( - "db-apply: usage: wolf db-apply --base -o " - ); - return ExitCode::from(64); - }; - let base_dat = base.with_extension("dat"); - let out_dat = output.with_extension("dat"); - - let text = match std::fs::read_to_string(&input) { - Ok(t) => t, - Err(e) => { - eprintln!("db-apply: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - let (pb, db_) = match (std::fs::read(&base), std::fs::read(&base_dat)) { - (Ok(p), Ok(d)) => (p, d), - _ => { - eprintln!( - "db-apply: cannot read base pair {} + {}", - base.display(), - base_dat.display() - ); - return ExitCode::from(4); - } - }; - let mut db = match Database::read(&pb, &db_) { - Ok(d) => d, - Err(e) => { - eprintln!("db-apply: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - - let changed = match apply_database_edit(&text, &mut db) { - Ok(n) => n, - Err(e) => { - eprintln!("db-apply failed: {e}"); - return ExitCode::from(2); - } - }; - - let (out_pb, out_db) = db.write(); - let byte_identical = out_pb == pb && out_db == db_; - if let (Err(e), _) | (_, Err(e)) = ( - std::fs::write(&output, &out_pb), - std::fs::write(&out_dat, &out_db), - ) { - eprintln!("db-apply: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!( - "wrote {} + {} ({changed} cell(s) changed{})", - output.display(), - out_dat.display(), - if byte_identical { - "; byte-identical to base" - } else { - "" - } - ); - ExitCode::SUCCESS -} - -// ---------------------------------------------------------------------------- -// gamedat-json / gamedat-apply: the FULL Game.dat field editor (all editable -// string fields, not just the player-text subset that strings-extract exposes) -// ---------------------------------------------------------------------------- - -/// `wolf gamedat-json [-o out.json]`. Dumps *every* editable Game.dat string field to -/// JSON for inspection/editing (fonts, graphics, image paths and the player text). Structural -/// fields are not exposed. Writes to `-o` (or stdout). -pub(crate) fn cmd_gamedat_json(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!("gamedat-json: usage: wolf gamedat-json [-o out.json]"); - return ExitCode::from(64); - }; - - let bytes = match std::fs::read(&input) { - Ok(b) => b, - Err(e) => { - eprintln!("gamedat-json: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - let gd = match GameDat::read(&bytes) { - Ok(gd) => gd, - Err(e) => { - eprintln!("gamedat-json: parse failed: {e}"); - return ExitCode::from(4); - } - }; - let json = dump_game_dat(&gd); - match output { - Some(out) => { - if let Err(e) = std::fs::write(&out, &json) { - eprintln!("gamedat-json: cannot write {}: {e}", out.display()); - return ExitCode::from(4); - } - eprintln!("wrote {} ({} bytes)", out.display(), json.len()); - ExitCode::SUCCESS - } - None => { - use std::io::Write; - let _ = std::io::stdout().write_all(json.as_bytes()); - ExitCode::SUCCESS - } - } -} - -/// `wolf gamedat-apply --base -o `. Applies full-field edits from a -/// `gamedat-json` dump back onto a Game.dat, writing ``. The output is re-parsed with -/// `GameDat::read` to verify it round-trips before the write is reported. Untouched fields -/// re-serialize byte-exact. -pub(crate) fn cmd_gamedat_apply(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut base: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "-b" | "--base" => base = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let (Some(input), Some(base), Some(output)) = (input, base, output) else { - eprintln!("gamedat-apply: usage: wolf gamedat-apply --base -o "); - return ExitCode::from(64); - }; - - let text = match std::fs::read_to_string(&input) { - Ok(t) => t, - Err(e) => { - eprintln!("gamedat-apply: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - let base_bytes = match std::fs::read(&base) { - Ok(b) => b, - Err(e) => { - eprintln!("gamedat-apply: cannot read base {}: {e}", base.display()); - return ExitCode::from(4); - } - }; - let mut gd = match GameDat::read(&base_bytes) { - Ok(gd) => gd, - Err(e) => { - eprintln!("gamedat-apply: base parse failed: {e}"); - return ExitCode::from(4); - } - }; - - let changed = match apply_game_dat(&mut gd, &text) { - Ok(n) => n, - Err(e) => { - eprintln!("gamedat-apply failed: {e}"); - return ExitCode::from(2); - } - }; - - let out = gd.write(); - // Re-parse the output to verify it still round-trips before we commit it to disk. - if let Err(e) = GameDat::read(&out) { - eprintln!("gamedat-apply: internal error - edited file no longer parses ({e}); aborting write to avoid a corrupt file. Please report."); - return ExitCode::from(2); - } - let identical = out == base_bytes; - if let Err(e) = std::fs::write(&output, &out) { - eprintln!("gamedat-apply: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!( - "gamedat-apply: {changed} field(s) changed -> {}{}", - output.display(), - if identical { - " (byte-identical to base)" - } else { - "" - } - ); - ExitCode::SUCCESS -} - -// ---------------------------------------------------------------------------- -// names-extract / names-inject: the project-level DB name glossary -// ---------------------------------------------------------------------------- - -/// A database loaded for inject: `(project path, dat path, original project bytes, original dat -/// bytes, parsed db)`. The original bytes let the caller report byte-identity. The parsed db is -/// mutated in place by the inject. -type LoadedDb = (PathBuf, PathBuf, Vec, Vec, Database); - -/// The translatable files of a data dir, located the same way the other commands do: the three -/// standard DB pairs and `CommonEvent.dat` under `` or `/BasicData`, and every `*.mps` -/// map under `` or `/MapData`. Each entry keeps the on-disk paths so an inject can write -/// each file back to where it came from. -struct DataDirFiles { - /// (`.project` path, `.dat` path) for each found database pair. - dbs: Vec<(PathBuf, PathBuf)>, - /// Path to `CommonEvent.dat`, if present. - common_event: Option, - /// Every `*.mps` map path. - maps: Vec, -} - -/// Resolve which of `dir` / `dir/BasicData` holds the BasicData files (`CommonEvent.dat` + DB -/// pairs), mirroring `cmd_export_names`. -fn basic_data_dir(dir: &Path) -> PathBuf { - if dir.join("CommonEvent.dat").exists() || dir.join("DataBase.project").exists() { - dir.to_path_buf() - } else if dir.join("BasicData").join("CommonEvent.dat").exists() - || dir.join("BasicData").join("DataBase.project").exists() - { - dir.join("BasicData") - } else { - dir.to_path_buf() - } -} - -/// Discover the translatable files under a data dir for the name glossary. -fn discover_data_dir(dir: &Path) -> DataDirFiles { - let basic = basic_data_dir(dir); - let mut dbs = Vec::new(); - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - let proj = basic.join(format!("{stem}.project")); - let dat = basic.join(format!("{stem}.dat")); - if proj.exists() && dat.exists() { - dbs.push((proj, dat)); - } - } - let common_event = { - let ce = basic.join("CommonEvent.dat"); - ce.exists().then_some(ce) - }; - // Maps live in MapData (or directly under dir for a flattened layout). - let mut maps = Vec::new(); - for sub in [dir.to_path_buf(), dir.join("MapData")] { - let Ok(rd) = std::fs::read_dir(&sub) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.extension().and_then(|s| s.to_str()) == Some("mps") { - maps.push(p); - } - } - } - maps.sort(); - maps.dedup(); - DataDirFiles { - dbs, - common_event, - maps, - } -} - -/// `wolf names-extract -o names.json`. Builds the project-level DB **name glossary**: -/// every unique row name / name-display cell value across the dir's databases, with an occurrence -/// count (DB cells + row names + by-name command refs) and an originating-type hint. `text` starts -/// equal to `source`. A translator edits `text`, and `names-inject` applies it consistently. -pub(crate) fn cmd_names_extract(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let Some(dir) = input else { - eprintln!("names-extract: usage: wolf names-extract -o "); - return ExitCode::from(64); - }; - - let found = discover_data_dir(&dir); - // Parse the databases. - let mut dbs = Vec::new(); - for (proj, dat) in &found.dbs { - match (std::fs::read(proj), std::fs::read(dat)) { - (Ok(p), Ok(d)) => match Database::read(&p, &d) { - Ok(db) => dbs.push(db), - Err(e) => eprintln!("names-extract: skip {}: {e}", proj.display()), - }, - _ => eprintln!("names-extract: cannot read {}", proj.display()), - } - } - // Parse the common events (optional). - let ce = found.common_event.as_ref().and_then(|p| { - std::fs::read(p) - .ok() - .and_then(|b| CommonEventsFile::read(&b).ok()) - }); - // Parse the maps. - let mut maps = Vec::new(); - for p in &found.maps { - if let Ok(b) = std::fs::read(p) { - if let Ok(m) = Map::read(&b) { - maps.push(m); - } - } - } - - if dbs.is_empty() { - eprintln!( - "names-extract: no databases found under {} (looked in {} and BasicData)", - dir.display(), - dir.display() - ); - return ExitCode::from(4); - } - - let ce_refs: Vec<&CommonEventsFile> = ce.iter().collect(); - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = extract_names(&dbs, &ce_refs, &maps, &glossary); - - // A small count of distinct names for the operator (`count` is also embedded in the JSON). - let n = json.matches("\"source\"").count(); - match output { - Some(out) => { - if let Err(e) = std::fs::write(&out, &json) { - eprintln!("names-extract: write failed: {e}"); - return ExitCode::from(4); - } - eprintln!( - "wrote {} ({n} distinct name(s) from {} DB(s), {} map(s){})", - out.display(), - dbs.len(), - maps.len(), - if ce.is_some() { ", CommonEvent" } else { "" } - ); - } - None => { - use std::io::Write; - let _ = std::io::stdout().write_all(json.as_bytes()); - } - } - ExitCode::SUCCESS -} - -/// `wolf names-inject --data [-o ]`. Applies the edited name -/// glossary consistently across the whole data dir: every stored row name, name-display cell -/// value, and by-name command reference that equals a translated source is rewritten together -/// (drift-/code-/encoding-guarded). Writes in place by default, or under `-o ` mirroring -/// the input layout. Each written file is re-parsed to guarantee it is not structurally corrupt. -pub(crate) fn cmd_names_inject(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut data: Option = None; - let mut out_dir: Option = None; - let mut opts = InjectOptions::default(); - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "--data" => data = it.next().map(PathBuf::from), - "-o" | "--output" => out_dir = it.next().map(PathBuf::from), - "--allow-code-drift" => opts.allow_code_drift = true, - "--en-punct" => opts.normalize_punct = true, - _ => input = Some(PathBuf::from(a)), - } - } - let (Some(input), Some(dir)) = (input, data) else { - eprintln!( - "names-inject: usage: wolf names-inject --data [-o ] [--allow-code-drift] [--en-punct]" - ); - return ExitCode::from(64); - }; - let names_json = match std::fs::read_to_string(&input) { - Ok(t) => t, - Err(e) => { - eprintln!("names-inject: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - - let found = discover_data_dir(&dir); - - // Parse everything up front and abort on any read/parse failure, so a partial write can't - // desync the name mirrors. The glossary exists to keep names consistent across files. - let mut dbs: Vec = Vec::new(); - for (proj, dat) in &found.dbs { - let (Ok(p), Ok(d)) = (std::fs::read(proj), std::fs::read(dat)) else { - eprintln!("names-inject: cannot read DB pair {}", proj.display()); - return ExitCode::from(4); - }; - match Database::read(&p, &d) { - Ok(db) => dbs.push((proj.clone(), dat.clone(), p, d, db)), - Err(e) => { - eprintln!("names-inject: DB parse failed for {}: {e}", proj.display()); - return ExitCode::from(4); - } - } - } - let mut ce: Option<(PathBuf, Vec, CommonEventsFile)> = None; - if let Some(p) = &found.common_event { - let Ok(b) = std::fs::read(p) else { - eprintln!("names-inject: cannot read {}", p.display()); - return ExitCode::from(4); - }; - match CommonEventsFile::read(&b) { - Ok(c) => ce = Some((p.clone(), b, c)), - Err(e) => { - eprintln!("names-inject: CommonEvent parse failed: {e}"); - return ExitCode::from(4); - } - } - } - let mut maps: Vec<(PathBuf, Vec, Map)> = Vec::new(); - for p in &found.maps { - let Ok(b) = std::fs::read(p) else { - eprintln!("names-inject: cannot read {}", p.display()); - return ExitCode::from(4); - }; - match Map::read(&b) { - Ok(m) => maps.push((p.clone(), b, m)), - Err(e) => { - eprintln!("names-inject: map parse failed for {}: {e}", p.display()); - return ExitCode::from(4); - } - } - } - if dbs.is_empty() { - eprintln!("names-inject: no databases found under {}", dir.display()); - return ExitCode::from(4); - } - - // Apply across all parsed files at once. - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let st = { - let mut db_vec: Vec = dbs.iter().map(|(_, _, _, _, db)| db.clone()).collect(); - let mut ce_vec: Vec<&mut CommonEventsFile> = - ce.as_mut().map(|(_, _, c)| c).into_iter().collect(); - let mut map_vec: Vec = maps.iter().map(|(_, _, m)| m.clone()).collect(); - let st = match inject_names( - &names_json, - &mut db_vec, - &mut ce_vec, - &mut map_vec, - &opts, - &glossary, - ) { - Ok(st) => st, - Err(e) => { - eprintln!("names-inject failed: {e}"); - return ExitCode::from(2); - } - }; - // Move the mutated copies back into the path-keyed records. - for ((_, _, _, _, db), new) in dbs.iter_mut().zip(db_vec) { - *db = new; - } - for ((_, _, m), new) in maps.iter_mut().zip(map_vec) { - *m = new; - } - st - }; - - // Compute output paths (in place, or remapped under -o mirroring the relative layout). - let remap = |orig: &Path| -> PathBuf { - match &out_dir { - Some(od) => od.join(rel(&dir, orig)), - None => orig.to_path_buf(), - } - }; - - let mut written = 0usize; - // Databases (write both halves, then re-parse to never ship a corrupt pair). - for (proj, dat, _, _, db) in &dbs { - let (op, od) = db.write(); - if Database::read(&op, &od).is_err() { - eprintln!( - "names-inject: internal error - injected DB {} no longer parses; aborting write to avoid corruption. Please report.", - proj.display() - ); - return ExitCode::from(2); - } - let (out_proj, out_dat) = (remap(proj), remap(dat)); - if let Some(parent) = out_proj.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let (Err(e), _) | (_, Err(e)) = ( - std::fs::write(&out_proj, &op), - std::fs::write(&out_dat, &od), - ) { - eprintln!("names-inject: write failed: {e}"); - return ExitCode::from(4); - } - written += 1; - } - // CommonEvent. - if let Some((p, _, c)) = &ce { - let out = c.write(); - if CommonEventsFile::read(&out).is_err() { - eprintln!("names-inject: internal error - injected CommonEvent no longer parses; aborting. Please report."); - return ExitCode::from(2); - } - let outp = remap(p); - if let Some(parent) = outp.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&outp, &out) { - eprintln!("names-inject: write failed: {e}"); - return ExitCode::from(4); - } - written += 1; - } - // Maps. - for (p, _, m) in &maps { - let out = m.write(); - if Map::read(&out).is_err() { - eprintln!( - "names-inject: internal error - injected map {} no longer parses; aborting. Please report.", - p.display() - ); - return ExitCode::from(2); - } - let outp = remap(p); - if let Some(parent) = outp.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&outp, &out) { - eprintln!("names-inject: write failed: {e}"); - return ExitCode::from(4); - } - written += 1; - } - - eprintln!( - "applied {} name change(s) ({} drifted/unmatched); wrote {written} file(s){}", - st.applied, - st.drifted, - match &out_dir { - Some(od) => format!(" under {}", od.display()), - None => " in place".to_string(), - } - ); - guard_report(&st) -} - -/// `wolf names-check ...`. Checks a set of translation JSONs (names.json, db-strings, -/// scenes) for **name-translation conflicts**: a glossary name (a `names.json` source) that is -/// translated to two or more distinct values across the files. This is the consistency guard for -/// the disjoint-ownership split. A name must translate to ONE value everywhere or by-name DB -/// lookups break. Exits non-zero (2) if any conflict is found. Only exact, full-string name -/// matches count (a name inside a dialogue sentence is a different source, never a conflict). -pub(crate) fn cmd_names_check(args: &[String]) -> ExitCode { - let paths: Vec = args.iter().map(PathBuf::from).collect(); - if paths.is_empty() { - eprintln!("names-check: usage: wolf names-check ..."); - return ExitCode::from(64); - } - - // Load every file up front (so the conflict scan sees them all). A read failure is fatal. - let mut loaded: Vec<(String, String)> = Vec::new(); - for p in &paths { - match std::fs::read_to_string(p) { - Ok(text) => loaded.push((p.display().to_string(), text)), - Err(e) => { - eprintln!("names-check: cannot read {}: {e}", p.display()); - return ExitCode::from(4); - } - } - } - let refs: Vec<(String, &str)> = loaded - .iter() - .map(|(name, text)| (name.clone(), text.as_str())) - .collect(); - - let conflicts = check_name_conflicts(&refs); - if conflicts.is_empty() { - eprintln!( - "names-check: OK - no name-translation conflicts across {} file(s)", - loaded.len() - ); - return ExitCode::SUCCESS; - } - - eprintln!( - "names-check: FOUND {} name(s) translated inconsistently:\n", - conflicts.len() - ); - for c in &conflicts { - eprintln!(" {:?} has {} divergent translations:", c.source, c.variants.len()); - for v in &c.variants { - eprintln!(" -> {:?} (in {})", v.text, v.files.join(", ")); - } - } - eprintln!( - "\nEach conflicting name must translate to ONE value everywhere, or by-name DB lookups break." - ); - ExitCode::from(2) -} - -// ---------------------------------------------------------------------------- -// translations-merge: incremental translation memory across a game update -// ---------------------------------------------------------------------------- - -/// Collect every `*.json` under `path` (recursively) if it is a directory, or `path` itself if it -/// is a single `.json` file. Sorted for deterministic memory-build order. -fn collect_json_files(path: &Path) -> Vec { - let mut out = Vec::new(); - if path.is_dir() { - let mut all = Vec::new(); - collect(path, &mut all); - for p in all { - if p.extension().and_then(|s| s.to_str()) == Some("json") { - out.push(p); - } - } - } else if path.extension().and_then(|s| s.to_str()) == Some("json") { - out.push(path.to_path_buf()); - } - out.sort(); - out -} - -/// `wolf translations-merge --old ... --new -o `. Incremental **translation -/// memory**: carry the translations from an OLD (already-translated) extraction over into a freshly -/// re-extracted NEW set, matched by exact source string, so only genuinely new/changed lines stay -/// untranslated after a game update. -/// -/// `--old` takes one or more files or directories (dirs are walked for `*.json`). ALL of them build -/// one global memory. For each `*.json` under `--new ` (recursive), the memory is applied and -/// the result written to `/`. Prints a per-file line plus a TOTAL, the -/// count of memory sources dropped (removed in the update), and a conflict count. Exits 0 normally. -pub(crate) fn cmd_translations_merge(args: &[String]) -> ExitCode { - let mut old_paths: Vec = Vec::new(); - let mut new_dir: Option = None; - let mut out_dir: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - // --old greedily takes every following token until the next flag. - "--old" => { - while let Some(next) = it.clone().next() { - if next.starts_with('-') { - break; - } - old_paths.push(PathBuf::from(it.next().unwrap())); - } - } - "--new" => new_dir = it.next().map(PathBuf::from), - "-o" | "--output" => out_dir = it.next().map(PathBuf::from), - _ => old_paths.push(PathBuf::from(a)), - } - } - let (Some(new_dir), Some(out_dir)) = (new_dir, out_dir) else { - eprintln!( - "translations-merge: usage: wolf translations-merge --old ... --new -o " - ); - return ExitCode::from(64); - }; - if old_paths.is_empty() { - eprintln!("translations-merge: --old needs at least one file or directory"); - return ExitCode::from(64); - } - - // Build ONE global memory from every old JSON (files + every *.json under old dirs). - let mut old_files: Vec = Vec::new(); - for p in &old_paths { - old_files.extend(collect_json_files(p)); - } - old_files.sort(); - old_files.dedup(); - if old_files.is_empty() { - eprintln!("translations-merge: no *.json found under the --old path(s)"); - return ExitCode::from(4); - } - let mut old_texts: Vec = Vec::with_capacity(old_files.len()); - for p in &old_files { - match std::fs::read_to_string(p) { - Ok(t) => old_texts.push(t), - Err(e) => { - eprintln!("translations-merge: cannot read {}: {e}", p.display()); - return ExitCode::from(4); - } - } - } - let old_refs: Vec<&str> = old_texts.iter().map(String::as_str).collect(); - let (memory, conflicts) = build_memory(&old_refs); - - // Apply the memory to every *.json under --new, writing to /. - let new_files = collect_json_files(&new_dir); - if new_files.is_empty() { - eprintln!( - "translations-merge: no *.json found under --new {}", - new_dir.display() - ); - return ExitCode::from(4); - } - - let mut total = MergeStats::default(); - let mut new_texts: Vec = Vec::with_capacity(new_files.len()); - for path in &new_files { - let text = match std::fs::read_to_string(path) { - Ok(t) => t, - Err(e) => { - eprintln!("translations-merge: cannot read {}: {e}", path.display()); - return ExitCode::from(4); - } - }; - let (merged, stats) = match apply_memory(&text, &memory) { - Ok(r) => r, - Err(e) => { - eprintln!("translations-merge: {}: {e}", path.display()); - return ExitCode::from(4); - } - }; - new_texts.push(text); - - let out_path = out_dir.join(rel(&new_dir, path)); - if let Some(parent) = out_path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - eprintln!("translations-merge: cannot create {}: {e}", parent.display()); - return ExitCode::from(4); - } - } - if let Err(e) = std::fs::write(&out_path, &merged) { - eprintln!("translations-merge: write failed for {}: {e}", out_path.display()); - return ExitCode::from(4); - } - println!( - "{}: carried={} still_new={} kept_existing={}", - rel(&new_dir, path), - stats.carried, - stats.still_new, - stats.kept_existing - ); - total.carried += stats.carried; - total.still_new += stats.still_new; - total.kept_existing += stats.kept_existing; - } - - // Reporting: memory sources that no new file still contains (removed in the update). - let new_refs: Vec<&str> = new_texts.iter().map(String::as_str).collect(); - let dropped = dropped_sources(&memory, &new_refs); - - println!( - "TOTAL: carried={} still_new={} kept_existing={} (memory={} entries, {} new file(s))", - total.carried, - total.still_new, - total.kept_existing, - memory.len(), - new_files.len() - ); - println!("dropped (removed in update): {}", dropped.len()); - println!("conflicts: {}", conflicts.len()); - for (source, variants) in conflicts.iter().take(5) { - println!(" {source:?} -> kept {:?} (also {:?})", variants[0], &variants[1..]); - } - if conflicts.len() > 5 { - println!(" ... and {} more", conflicts.len() - 5); - } - ExitCode::SUCCESS -} - -// ---------------------------------------------------------------------------- -// save-update: refresh a baked save's title + player-facing strings for the translation -// ---------------------------------------------------------------------------- - -/// Read the `Title` entry out of a translated `Game.dat` (via the existing GameDat support plus -/// `extract_game_dat`), to use as the save's new baked title. `extract_game_dat` always emits the -/// `Title` line first, decoded in the file's encoding. We pull its `source` text out of that JSON. -fn title_from_game_dat(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; - let gd = GameDat::read(&bytes).map_err(|e| format!("Game.dat parse failed: {e}"))?; - let json = extract_game_dat(&gd); - // wolf-cli has no JSON dep. The gamedat extraction has a fixed line shape - // (`{"key": "Title", "source": "...", ...}`), so scan for the Title line's `source` value. - extract_json_string_after(&json, "\"key\": \"Title\"", "\"source\":") - .ok_or_else(|| format!("{} has no Title entry", path.display())) -} - -/// Minimal forward scan: find `marker`, then the next `field` after it, and unescape the JSON -/// string value that follows. Sufficient for the fixed, machine-generated `extract_game_dat` shape. -/// Avoids pulling serde_json into the CLI crate. Returns `None` if the shape is unexpected. -fn extract_json_string_after(json: &str, marker: &str, field: &str) -> Option { - let after_marker = &json[json.find(marker)?..]; - // Advance past the field token itself (e.g. `"source":`). The value's opening quote is next. - let field_at = after_marker.find(field)?; - let after_field = &after_marker[field_at + field.len()..]; - let open = after_field.find('"')? + 1; // first byte of the value, after its opening quote - let rest = &after_field[open..]; - let mut out = String::new(); - let mut chars = rest.chars(); - while let Some(c) = chars.next() { - match c { - '"' => return Some(out), - '\\' => match chars.next()? { - 'n' => out.push('\n'), - 'r' => out.push('\r'), - 't' => out.push('\t'), - '"' => out.push('"'), - '\\' => out.push('\\'), - '/' => out.push('/'), - 'u' => { - let hex: String = chars.by_ref().take(4).collect(); - let code = u32::from_str_radix(&hex, 16).ok()?; - out.push(char::from_u32(code)?); - } - other => out.push(other), - }, - _ => out.push(c), - } - } - None -} - -/// Build the `source -> target` baked-string map from the same translation JSONs used for the -/// game, by reusing [`build_memory`]. `--translations` takes files and/or directories (dirs are -/// walked for `*.json`). Reports any divergent sources but keeps the first (memory) value. -fn build_save_string_map(paths: &[PathBuf]) -> Result, ExitCode> { - let mut files: Vec = Vec::new(); - for p in paths { - files.extend(collect_json_files(p)); - } - files.sort(); - files.dedup(); - let mut texts: Vec = Vec::with_capacity(files.len()); - for p in &files { - match std::fs::read_to_string(p) { - Ok(t) => texts.push(t), - Err(e) => { - eprintln!("save-update: cannot read {}: {e}", p.display()); - return Err(ExitCode::from(4)); - } - } - } - let refs: Vec<&str> = texts.iter().map(String::as_str).collect(); - let (memory, conflicts) = build_memory(&refs); - if !conflicts.is_empty() { - eprintln!( - "save-update: note: {} source(s) had divergent translations across files; kept the first", - conflicts.len() - ); - } - Ok(memory) -} - -/// A short timestamp (`YYYYmmdd_HHMMSS`) from the system clock, for the backup dir name. Avoids a -/// chrono dependency by formatting the civil date from the Unix epoch seconds directly. -fn backup_stamp() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let days = secs / 86_400; - let tod = secs % 86_400; - let (hh, mm, ss) = (tod / 3600, (tod % 3600) / 60, tod % 60); - // Civil-from-days (Howard Hinnant's algorithm), giving a stable, monotonic, unique-enough stamp. - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = if m <= 2 { y + 1 } else { y }; - format!("{year:04}{m:02}{d:02}_{hh:02}{mm:02}{ss:02}") -} - -/// `wolf save-update [-o ] [--title | --game ] -/// [--translations ...]`. Rewrites the baked title to match the translated build and -/// refreshes the baked player-facing strings using the same translation JSONs applied to the game, -/// so an old/Japanese save loads cleanly. A directory input updates every `*.sav` inside it. A file -/// updates just that file. Default output is in place (originals backed up to -/// `/save_backup_/` first), or `-o ` writes copies elsewhere. Both the -/// standard codec and the GamePro Pro (marker-3) codec are supported. A buffer that is neither -/// is reported and skipped, never mangled. -pub(crate) fn cmd_save_update(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut title: Option = None; - let mut game: Option = None; - let mut translations: Vec = Vec::new(); - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "--title" => title = it.next().cloned(), - "--game" => game = it.next().map(PathBuf::from), - // --translations greedily takes every following token until the next flag. - "--translations" => { - while let Some(next) = it.clone().next() { - if next.starts_with('-') { - break; - } - translations.push(PathBuf::from(it.next().unwrap())); - } - } - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!( - "save-update: usage: wolf save-update [-o ] \ - [--title | --game ] [--translations ...]" - ); - return ExitCode::from(64); - }; - if title.is_some() && game.is_some() { - eprintln!("save-update: pass at most one of --title / --game"); - return ExitCode::from(64); - } - - // Resolve the new title (literal, or read from a translated Game.dat), if any. - let new_title: Option = match (&title, &game) { - (Some(t), _) => Some(t.clone()), - (None, Some(g)) => match title_from_game_dat(g) { - Ok(t) => { - eprintln!("save-update: title from {} = {t:?}", g.display()); - Some(t) - } - Err(e) => { - eprintln!("save-update: {e}"); - return ExitCode::from(4); - } - }, - (None, None) => None, - }; - - // Build the baked-string translation map (empty if --translations was not given). - let strings = if translations.is_empty() { - std::collections::HashMap::new() - } else { - match build_save_string_map(&translations) { - Ok(m) => m, - Err(code) => return code, - } - }; - if new_title.is_none() && strings.is_empty() { - eprintln!("save-update: nothing to do (give --title/--game and/or --translations)"); - return ExitCode::from(64); - } - - // Discover the save files to process. - let saves: Vec = if input.is_dir() { - let mut v: Vec = std::fs::read_dir(&input) - .into_iter() - .flatten() - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("sav")) - .collect(); - v.sort(); - v - } else { - vec![input.clone()] - }; - if saves.is_empty() { - eprintln!("save-update: no *.sav files found under {}", input.display()); - return ExitCode::from(4); - } - - // Resolve where each save's output goes, and (for in-place) back up the originals first. - let dir_input = input.is_dir(); - let in_place = output.is_none(); - if in_place { - // Back up the originals before overwriting (mirrors the reference tool). - let backup_root = if dir_input { - input.join(format!("save_backup_{}", backup_stamp())) - } else { - let parent = input.parent().unwrap_or(Path::new(".")); - parent.join(format!("save_backup_{}", backup_stamp())) - }; - if let Err(e) = std::fs::create_dir_all(&backup_root) { - eprintln!("save-update: cannot create backup dir {}: {e}", backup_root.display()); - return ExitCode::from(4); - } - for src in &saves { - let name = src.file_name().unwrap_or_default(); - if let Err(e) = std::fs::copy(src, backup_root.join(name)) { - eprintln!("save-update: backup failed for {}: {e}", src.display()); - return ExitCode::from(4); - } - } - eprintln!("save-update: backed up {} original(s) to {}", saves.len(), backup_root.display()); - } else if dir_input { - // -o for a directory input: ensure it exists. - if let Some(od) = &output { - if let Err(e) = std::fs::create_dir_all(od) { - eprintln!("save-update: cannot create output dir {}: {e}", od.display()); - return ExitCode::from(4); - } - } - } - - let mut updated = 0usize; - let mut skipped = 0usize; - let mut had_error = false; - for src in &saves { - let name = src - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("?") - .to_string(); - let raw = match std::fs::read(src) { - Ok(b) => b, - Err(e) => { - eprintln!("{name}: read failed: {e}"); - had_error = true; - continue; - } - }; - match update_save(&raw, new_title.as_deref(), &strings) { - Ok((out_bytes, stats)) => { - let dest = match &output { - None => src.clone(), // in place - Some(o) if dir_input => o.join(&name), - Some(o) => o.clone(), // single-file -o - }; - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&dest, &out_bytes) { - eprintln!("{name}: write failed: {e}"); - had_error = true; - continue; - } - println!( - "{name}: title={} strings={} enc={}", - u8::from(stats.title_changed), - stats.strings_replaced, - stats.encoding - ); - updated += 1; - } - Err(_) => { - // Err means the buffer is neither a standard 0x19 save nor a detectable GamePro - // Pro (marker-3) save. Report and skip, never write. - println!("{name}: SKIPPED (unsupported save encryption)"); - skipped += 1; - } - } - } - - eprintln!("save-update: {updated} updated, {skipped} skipped"); - if had_error { - ExitCode::from(4) - } else { - ExitCode::SUCCESS - } -} - -fn db_json_one(proj: &Path) -> Result { - let dat = proj.with_extension("dat"); - let pb = std::fs::read(proj).map_err(|e| e.to_string())?; - let db_ = std::fs::read(&dat).map_err(|e| e.to_string())?; - let db = Database::read(&pb, &db_).map_err(|e| e.to_string())?; - let kind = match proj.file_stem().and_then(|s| s.to_str()) { - Some("DataBase") => "UDB", - Some("CDataBase") => "CDB", - Some("SysDatabase") => "SDB", - Some(other) => other, - None => "DB", - }; - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - Ok(database_to_json(&db, kind, &glossary)) -} - -/// `wolf pack -o `. Packs a directory tree into an unencrypted, engine- -/// loadable DXArchive (VER8). The archive round-trips byte-exact through `wolf unpack`. -pub(crate) fn cmd_pack(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut encrypt = false; - let mut version: u16 = 0x12C; // v3.00 default when --encrypt is given without --version - let mut pwd: Option<[u8; 15]> = None; - let mut like: Option = None; - let mut format: Option = None; // "ver5" | "ver6" for the legacy 2.0x containers - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - "--no-encrypt" => encrypt = false, - "--encrypt" => encrypt = true, - "--format" => format = it.next().cloned(), - // Inherit cryptVersion + embedded password from an existing archive (turnkey repack). - "--like" => like = it.next().map(PathBuf::from), - "--version" => { - if let Some(v) = it.next() { - let v = v.trim_start_matches("0x"); - match u16::from_str_radix(v, 16).or_else(|_| v.parse()) { - Ok(n) => version = n, - Err(_) => { - eprintln!("pack: bad --version {v:?}"); - return ExitCode::from(64); - } - } - } - } - "--pwd" => pwd = it.next().and_then(|h| parse_pwd15(h)), - _ => input = Some(PathBuf::from(a)), - } - } - let (Some(input), Some(output)) = (input, output) else { - eprintln!( - "pack: usage: wolf pack -o \ - [--encrypt --version 0x14b [--pwd <30hex>]] | [--like ]" - ); - return ExitCode::from(64); - }; - - // `--like` inherits the original archive's cryptVersion + embedded password. - if let Some(orig) = &like { - match std::fs::read(orig) - .ok() - .and_then(|d| wolf_archive::archive_crypt_params(&d)) - { - Some((cv, p)) => { - version = cv; - pwd = Some(p); - encrypt = true; - } - None => { - eprintln!("pack: could not read crypt params from {}", orig.display()); - return ExitCode::from(4); - } - } - } - - let mut files: Vec<(String, Vec)> = Vec::new(); - if let Err(e) = gather_files(&input, &input, &mut files) { - eprintln!("pack: {e}"); - return ExitCode::from(4); - } - if files.is_empty() { - eprintln!("pack: no files found under {}", input.display()); - return ExitCode::from(4); - } - files.sort(); - - // Default new-crypt password (used only when --encrypt a new-crypt version without --pwd - // or --like). Self-describing: the archive embeds whatever password it was built with. - let default_pwd: [u8; 15] = *b"WolfDawnRepack!"; - let result = match format.as_deref() { - Some("ver5") => wolf_archive::pack_ver5(&files), - Some("ver6") => wolf_archive::pack_ver6(&files), - Some(other) => Err(format!("pack: unknown --format {other:?} (use ver5|ver6)")), - None if !encrypt => wolf_archive::pack_plaintext(&files), - None if matches!(version, 0x14B | 0x15E) => { - wolf_archive::pack_newcrypt(&files, version, &pwd.unwrap_or(default_pwd)) - } - None => wolf_archive::pack_encrypted(&files, version), - }; - let bytes = match result { - Ok(b) => b, - Err(e) => { - eprintln!("pack failed: {e}"); - return ExitCode::from(4); - } - }; - if let Err(e) = std::fs::write(&output, &bytes) { - eprintln!("pack: cannot write {}: {e}", output.display()); - return ExitCode::from(4); - } - let mode = match format.as_deref() { - Some(f) => format!("{f} (Wolf 2.0x)"), - None if encrypt => { - let src = if like.is_some() { " (--like)" } else { "" }; - format!("encrypted cryptVersion={version:#x}{src}") - } - None => "unencrypted".to_string(), - }; - eprintln!( - "packed {} files -> {} ({} bytes, {mode})", - files.len(), - output.display(), - bytes.len() - ); - ExitCode::SUCCESS -} - -/// Parse a 30-hex-char string into a 15-byte WolfPro password. -fn parse_pwd15(h: &str) -> Option<[u8; 15]> { - let h = h.trim(); - if h.len() != 30 { - return None; - } - let mut out = [0u8; 15]; - for (i, b) in out.iter_mut().enumerate() { - *b = u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).ok()?; - } - Some(out) -} - -/// Extract one already-read archive into `out_dir`, preserving the inner tree. Each inner path is -/// sanitised (drop empty/`.`/`..` segments and any drive/leading separator) so a malformed archive -/// entry can never write outside `out_dir`. Returns the file count on success. -fn unpack_archive_to(data: &[u8], out_dir: &Path) -> Result { - let files = - wolf_archive::extract_archive(data, b"").map_err(|e| format!("not a readable archive ({e})"))?; - for (path, bytes) in &files { - let safe: PathBuf = path - .split(['\\', '/']) - .filter(|s| !s.is_empty() && *s != "." && *s != "..") - .collect(); - let target = out_dir.join(safe); - if let Some(parent) = target.parent() { - let _ = std::fs::create_dir_all(parent); - } - std::fs::write(&target, bytes) - .map_err(|e| format!("cannot write {}: {e}", target.display()))?; - } - Ok(files.len()) -} - -/// True for a readable file whose extension is `.wolf` (case-insensitive). -fn is_wolf_file(p: &Path) -> bool { - p.is_file() - && p.extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("wolf")) - .unwrap_or(false) -} - -/// The `.wolf` archives directly inside `dir`, sorted by name for a stable, reproducible order. -fn list_wolf_files(dir: &Path) -> Vec { - let mut v: Vec = std::fs::read_dir(dir) - .into_iter() - .flatten() - .flatten() - .map(|e| e.path()) - .filter(|p| is_wolf_file(p)) - .collect(); - v.sort(); - v -} - -/// `wolf unpack -o `. Extracts every file from a `.wolf` into `dir`, -/// preserving the inner directory tree. Handles any supported crypt regime. -pub(crate) fn cmd_unpack(args: &[String]) -> ExitCode { - let mut input: Option = None; - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => input = Some(PathBuf::from(a)), - } - } - let Some(input) = input else { - eprintln!("unpack: usage: wolf unpack -o "); - return ExitCode::from(64); - }; - let out_dir = output.unwrap_or_else(|| PathBuf::from("unpacked")); - - let data = match std::fs::read(&input) { - Ok(d) => d, - Err(e) => { - eprintln!("unpack: cannot read {}: {e}", input.display()); - return ExitCode::from(4); - } - }; - match unpack_archive_to(&data, &out_dir) { - Ok(n) => { - eprintln!("unpacked {n} files -> {}", out_dir.display()); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("unpack failed: {e}"); - ExitCode::from(4) - } - } -} - -/// `wolf unpack-all ... [-o ]`. Unpacks every `.wolf` among the -/// given paths. A directory argument is expanded to the `.wolf` files directly inside it, so the -/// whole split data set of a per-category game (BasicData.wolf, MapData.wolf, Evtext.wolf, ...) -/// comes apart in one call. Each `Name.wolf` lands in `/Name/`, mirroring how the engine maps -/// a category archive to a `Name/` folder, so the result is a loose data tree the game can load. -/// With no `-o`, each archive unpacks beside itself (`/Name/`). One bad archive does -/// not stop the rest, and the exit code is failure only when nothing unpacked. -pub(crate) fn cmd_unpack_all(args: &[String]) -> ExitCode { - let mut inputs: Vec = Vec::new(); - let mut output: Option = None; - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "-o" | "--output" => output = it.next().map(PathBuf::from), - _ => inputs.push(PathBuf::from(a)), - } - } - if inputs.is_empty() { - eprintln!("unpack-all: usage: wolf unpack-all ... [-o ]"); - return ExitCode::from(64); - } - // Expand directory args to the .wolf files inside them, keep .wolf file args as-is. - let mut archives: Vec = Vec::new(); - for p in &inputs { - if p.is_dir() { - archives.extend(list_wolf_files(p)); - } else if is_wolf_file(p) { - archives.push(p.clone()); - } else { - eprintln!("unpack-all: skipping {} (not a .wolf or directory)", p.display()); - } - } - if archives.is_empty() { - eprintln!("unpack-all: no .wolf archives found in the given paths"); - return ExitCode::from(4); - } - - let mut total_files = 0usize; - let (mut ok, mut failed) = (0usize, 0usize); - for arc in &archives { - let stem = arc.file_stem().and_then(|s| s.to_str()).unwrap_or("archive"); - let dest_root = output - .clone() - .or_else(|| arc.parent().map(Path::to_path_buf)) - .unwrap_or_default(); - let out_dir = dest_root.join(stem); - let name = arc.file_name().and_then(|s| s.to_str()).unwrap_or(stem); - match std::fs::read(arc) - .map_err(|e| e.to_string()) - .and_then(|d| unpack_archive_to(&d, &out_dir)) - { - Ok(n) => { - total_files += n; - ok += 1; - eprintln!(" {name} -> {} ({n} files)", out_dir.display()); - } - Err(e) => { - failed += 1; - eprintln!(" {name} FAILED: {e}"); - } - } - } - eprintln!( - "unpack-all: {ok} archive(s), {total_files} files{}", - if failed > 0 { - format!(", {failed} failed") - } else { - String::new() - } - ); - if ok == 0 { - ExitCode::from(4) - } else { - ExitCode::SUCCESS - } -} - -/// Recursively gather `(relative-path, bytes)` for every file under `dir`. -fn gather_files(base: &Path, dir: &Path, out: &mut Vec<(String, Vec)>) -> std::io::Result<()> { - for entry in std::fs::read_dir(dir)? { - let p = entry?.path(); - if p.is_dir() { - gather_files(base, &p, out)?; - } else { - let rel = p - .strip_prefix(base) - .unwrap_or(&p) - .to_string_lossy() - .replace('\\', "/"); - out.push((rel, std::fs::read(&p)?)); - } - } - Ok(()) -} - -/// Build a symbol table for a map by locating the game's BasicData folder -/// (`.../Data/MapData/X.mps` -> `.../Data/BasicData`) and loading CommonEvent + databases. -pub(crate) fn load_symbols(map_path: &Path) -> SymbolTable { - let mut symbols = SymbolTable::new(); - let basic_data = map_path - .parent() - .and_then(|p| p.parent()) - .map(|data| data.join("BasicData")); - let Some(basic) = basic_data else { - return symbols; - }; - - if let Ok(bytes) = std::fs::read(basic.join("CommonEvent.dat")) { - if let Ok(ce) = CommonEventsFile::read(&bytes) { - symbols.add_common_events(&ce); - } - } - add_databases(&mut symbols, Some(&basic)); - symbols.set_glossary(wolf_decompiler::symbols::load_embedded_glossary()); - symbols.set_engine_text(wolf_decompiler::symbols::load_embedded_engine_glossary()); - symbols -} - -/// Load the three standard databases from `basic_data` into the symbol table. -pub(crate) fn add_databases(symbols: &mut SymbolTable, basic_data: Option<&Path>) { - let Some(dir) = basic_data else { return }; - for (kind, stem) in [ - ("UDB", "DataBase"), - ("CDB", "CDataBase"), - ("SDB", "SysDatabase"), - ] { - let proj = dir.join(format!("{stem}.project")); - let dat = dir.join(format!("{stem}.dat")); - if let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) { - if let Ok(db) = Database::read(&p, &d) { - symbols.add_database(kind, &db); - } - } - } -} - -// ---------------------------------------------------------------------------- -// verify-roundtrip -// ---------------------------------------------------------------------------- - -pub(crate) fn cmd_verify(args: &[String]) -> ExitCode { - if args.first().map(String::as_str) == Some("--corpus") { - let Some(dir) = args.get(1) else { - eprintln!("verify-roundtrip --corpus: missing data dir"); - return ExitCode::from(64); - }; - return verify_corpus(Path::new(dir)); - } - let Some(path) = args.first() else { - eprintln!("verify-roundtrip: missing file"); - return ExitCode::from(64); - }; - let path = PathBuf::from(path); - match roundtrip_path(&path) { - Ok(true) => { - println!("OK byte-exact {}", path.display()); - ExitCode::SUCCESS - } - Ok(false) => { - println!("DIFF {}", path.display()); - ExitCode::from(2) - } - Err(e) => { - eprintln!("ERR {}: {e}", path.display()); - ExitCode::from(4) - } - } -} - -pub(crate) fn verify_corpus(dir: &Path) -> ExitCode { - let mut ok = 0usize; - let mut diff = 0usize; - let mut err = 0usize; - - let mut files: Vec = Vec::new(); - collect(dir, &mut files); - files.sort(); - - for path in files { - match classify(&path) { - FileKind::Unsupported => continue, - // The .dat half of a database is verified via its .project. Skip the lone .dat. - FileKind::Database | FileKind::BasicDatabase - if path.extension().and_then(|s| s.to_str()) == Some("dat") => - { - continue - } - _ => {} - } - match roundtrip_path(&path) { - Ok(true) => { - ok += 1; - println!("OK {}", rel(dir, &path)); - } - Ok(false) => { - diff += 1; - println!("DIFF {}", rel(dir, &path)); - } - Err(e) => { - err += 1; - println!("ERR {} ({e})", rel(dir, &path)); - } - } - } - - println!("\n{ok} ok, {diff} diff, {err} error"); - if diff == 0 && err == 0 { - ExitCode::SUCCESS - } else if diff > 0 { - ExitCode::from(2) - } else { - ExitCode::from(4) - } -} - -/// Returns Ok(true) if `write(read(bytes)) == bytes`. -pub(crate) fn roundtrip_path(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - match classify(path) { - FileKind::Map => { - let m = Map::read(&bytes).map_err(|e| e.to_string())?; - Ok(m.write() == bytes) - } - FileKind::CommonEvent => { - let ce = CommonEventsFile::read(&bytes).map_err(|e| e.to_string())?; - Ok(ce.write() == bytes) - } - FileKind::GameDat => { - let gd = GameDat::read(&bytes).map_err(|e| e.to_string())?; - Ok(gd.write() == bytes) - } - FileKind::Database => { - // `path` is the .project. Pair it with the sibling .dat. - let dat = path.with_extension("dat"); - let proj_bytes = bytes; - let dat_bytes = std::fs::read(&dat).map_err(|e| e.to_string())?; - let db = Database::read(&proj_bytes, &dat_bytes).map_err(|e| e.to_string())?; - let (proj_out, dat_out) = db.write(); - Ok(proj_out == proj_bytes && dat_out == dat_bytes) - } - FileKind::BasicDatabase => { - // Legacy basic DB: schema `.project` + opaque `.dat`, paired by the `.project`. - let proj_path = path.with_extension("project"); - let dat_path = path.with_extension("dat"); - let proj_bytes = std::fs::read(&proj_path).map_err(|e| e.to_string())?; - let dat_bytes = std::fs::read(&dat_path).map_err(|e| e.to_string())?; - let db = wolf_formats::database::BasicDatabase::read(&proj_bytes, &dat_bytes) - .map_err(|e| e.to_string())?; - let (proj_out, dat_out) = db.write(); - Ok(proj_out == proj_bytes && dat_out == dat_bytes) - } - FileKind::TxtEvent => { - // An external event-text file round-trips through a no-op extract/inject, which must - // reproduce the bytes exactly (this is the byte-exactness guarantee the translation - // pipeline relies on). - let json = extract_txt_events(&bytes); - let out = inject_txt_events(&json, &bytes).map_err(|e| e.to_string())?; - Ok(out == bytes) - } - FileKind::Unsupported => Err("unsupported file type".into()), - } -} - -// ---------------------------------------------------------------------------- -// file classification -// ---------------------------------------------------------------------------- - -pub(crate) enum FileKind { - Map, - CommonEvent, - /// The per-game settings/title record (`Game.dat`). - GameDat, - /// Represented by the `.project` file (the `.dat` sibling is paired in). - Database, - /// Legacy `SysDataBaseBasic` (Wolf 2.x): schema-only `.project` + opaque magic-less `.dat`. - BasicDatabase, - /// An external event-text `.txt` file (Shift-JIS or UTF-8 dialogue, one scene per file). The - /// `Evtext*.wolf` archives unpack to these. Handled by `txt_events`. - TxtEvent, - Unsupported, -} - -pub(crate) fn classify(path: &Path) -> FileKind { - let ext = path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - let name = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - - match ext.as_str() { - "mps" => FileKind::Map, - // The legacy basic system DB is keyed by its fixed file name. - "project" if name == "sysdatabasebasic.project" => FileKind::BasicDatabase, - "dat" if name == "sysdatabasebasic.dat" => FileKind::BasicDatabase, - "project" => FileKind::Database, - "dat" if name == "commonevent.dat" => FileKind::CommonEvent, - "dat" if name == "game.dat" => FileKind::GameDat, - // A .dat with a sibling .project is the value half of a database. - "dat" if path.with_extension("project").exists() => FileKind::Database, - // External event-text. The only `.txt` use in a Wolf game is unpacked Evtext scene files. - "txt" => FileKind::TxtEvent, - _ => FileKind::Unsupported, - } -} - -pub(crate) fn collect(dir: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - collect(&p, out); - } else { - out.push(p); - } - } -} - -pub(crate) fn rel(base: &Path, path: &Path) -> String { - path.strip_prefix(base) - .unwrap_or(path) - .display() - .to_string() -} - -// ---------------------------------------------------------------------------- -// gui: launch WolfDawn Studio (the eframe desktop GUI), if it sits next to us -// ---------------------------------------------------------------------------- - -/// `wolf gui`. Launches the `wolf-gui` desktop app from beside the current executable. This crate -/// has no eframe dependency. It only *spawns* the separately-built `wolf-gui` binary. If that -/// binary is not found next to `wolf`, print how to build/run it and exit non-fatally. -pub(crate) fn cmd_gui(args: &[String]) -> ExitCode { - let exe = match std::env::current_exe() { - Ok(p) => p, - Err(e) => { - eprintln!("gui: cannot locate the current executable: {e}"); - return ExitCode::from(4); - } - }; - let dir = exe.parent().unwrap_or(Path::new(".")); - let gui_name = if cfg!(windows) { "wolf-gui.exe" } else { "wolf-gui" }; - let gui_path = dir.join(gui_name); - - if !gui_path.exists() { - eprintln!( - "gui: WolfDawn Studio ({}) was not found next to {}.\n\ - Build and run it with: cargo run --release -p wolf-gui", - gui_name, - exe.display() - ); - return ExitCode::SUCCESS; // non-fatal: nothing went wrong, the GUI just isn't built - } - - match std::process::Command::new(&gui_path).args(args).spawn() { - Ok(_) => { - eprintln!("launched {}", gui_path.display()); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("gui: failed to launch {}: {e}", gui_path.display()); - ExitCode::from(4) - } - } -} diff --git a/util/wolfdawn-master/crates/wolf-cli/src/main.rs b/util/wolfdawn-master/crates/wolf-cli/src/main.rs deleted file mode 100644 index 8cd1c98..0000000 --- a/util/wolfdawn-master/crates/wolf-cli/src/main.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! `wolf`. WolfDawn command-line front-end. -//! -//! Subcommands (P0/P1 subset): -//! wolf decompile [--mode edit] [-o out.wscript] -//! wolf compile --base -o -//! wolf verify-roundtrip | --corpus -//! -//! Exit codes: 0 ok · 2 round-trip mismatch · 4 read/parse failure · 64 usage. -use std::process::ExitCode; - -mod cmd; -use cmd::*; - -fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); - match args.first().map(String::as_str) { - Some("decompile") => cmd_decompile(&args[1..]), - Some("compile") => cmd_compile(&args[1..]), - Some("pack") => cmd_pack(&args[1..]), - Some("unpack") => cmd_unpack(&args[1..]), - Some("unpack-all") => cmd_unpack_all(&args[1..]), - Some("db-json") => cmd_db_json(&args[1..]), - Some("db-apply") => cmd_db_apply(&args[1..]), - Some("gamedat-json") => cmd_gamedat_json(&args[1..]), - Some("gamedat-apply") => cmd_gamedat_apply(&args[1..]), - Some("strings-extract") => cmd_strings_extract(&args[1..]), - Some("strings-audit") => cmd_strings_audit(&args[1..]), - Some("strings-inject") => cmd_strings_inject(&args[1..]), - Some("names-extract") => cmd_names_extract(&args[1..]), - Some("names-inject") => cmd_names_inject(&args[1..]), - Some("names-check") => cmd_names_check(&args[1..]), - Some("translations-merge") => cmd_translations_merge(&args[1..]), - Some("save-update") => cmd_save_update(&args[1..]), - Some("gui") => cmd_gui(&args[1..]), - Some("raw") => cmd_raw(&args[1..]), - Some("xref") => cmd_xref(&args[1..]), - Some("export-names") => cmd_export_names(&args[1..]), - Some("verify-roundtrip") => cmd_verify(&args[1..]), - Some("-h") | Some("--help") | Some("help") | None => { - usage(); - ExitCode::SUCCESS - } - Some(other) => { - eprintln!("unknown subcommand: {other}\n"); - usage(); - ExitCode::from(64) - } - } -} - -fn usage() { - eprintln!( - "wolf - WolfDawn toolchain\n\n\ - USAGE:\n \ - wolf decompile [--mode edit] [-o out.wscript]\n \ - wolf compile --base -o \n \ - wolf pack -o [--encrypt --version 0x14b | --like | --format ver5|ver6]\n \ - wolf unpack -o \n \ - wolf unpack-all ... [-o ] (unpack every .wolf, each into out//)\n \ - wolf db-json [-o out]\n \ - wolf db-apply --base -o \n \ - wolf gamedat-json [-o out.json]\n \ - wolf gamedat-apply --base -o \n \ - wolf strings-extract -o \n \ - wolf strings-inject --base -o [--allow-code-drift] [--en-punct]\n \ - wolf names-extract -o \n \ - wolf names-inject --data [-o ] [--allow-code-drift] [--en-punct]\n \ - wolf names-check ... (report names translated inconsistently across files)\n \ - wolf translations-merge --old ... --new -o (carry old translations into a re-extraction)\n \ - wolf save-update [-o ] [--title | --game ] [--translations ...]\n \ - wolf gui (launch WolfDawn Studio, the desktop GUI)\n \ - wolf verify-roundtrip \n \ - wolf verify-roundtrip --corpus \n" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-core/Cargo.toml b/util/wolfdawn-master/crates/wolf-core/Cargo.toml deleted file mode 100644 index 9fb97ad..0000000 --- a/util/wolfdawn-master/crates/wolf-core/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "wolf-core" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Low-level Wolf RPG wire primitives: byte reader/writer, length-prefixed strings, codecs. No Wolf domain semantics." diff --git a/util/wolfdawn-master/crates/wolf-core/src/codec/lz4.rs b/util/wolfdawn-master/crates/wolf-core/src/codec/lz4.rs deleted file mode 100644 index 2313317..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/codec/lz4.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! LZ4 block format codec (not the LZ4 frame format). This is the exact variant Wolf RPG -//! Editor v3.5 ("Pro") uses for compressed file bodies via `LZ4_decompress_safe` and -//! `LZ4_compress_default`. -//! -//! Wolf wraps a block as `[decompressedSize: u32][compressedSize: u32][block bytes]`. -//! [`unpack`]/[`pack`] handle that framing, [`decompress_block`]/[`compress_block`] the -//! raw block. -//! -//! The encoder is a greedy single-pass LZ4 compressor with a 4-byte hash table match finder. -//! It emits a valid compressed block honouring the constraints `LZ4_decompress_safe` requires. -//! The last 5 bytes are literals, no match starts within the last 12 bytes, min match is 4, -//! and offsets are 1..=65535. Byte-exact round-trip of unmodified files is achieved at the -//! format layer by re-emitting the original packed bytes verbatim. Fresh compression is used -//! for edited bodies, where what matters is validity and a reasonable size, not byte-identity -//! with the editor's own LZ4. - -use crate::error::{Error, Result}; - -/// Decompress a raw LZ4 block of known output size. -pub fn decompress_block(src: &[u8], decoded_size: usize) -> Result> { - let mut out = Vec::with_capacity(decoded_size); - let mut sp = 0usize; - - while sp < src.len() { - let token = src[sp]; - sp += 1; - - // Literal run. - let literal_len = read_len(src, &mut sp, (token >> 4) as usize)?; - if sp + literal_len > src.len() { - return Err(Error::invalid("lz4: literal run exceeds source")); - } - out.extend_from_slice(&src[sp..sp + literal_len]); - sp += literal_len; - - // A block legitimately ends right after a literal run (the last sequence). - if sp >= src.len() { - break; - } - if sp + 2 > src.len() { - return Err(Error::invalid("lz4: match offset is truncated")); - } - let offset = u16::from_le_bytes([src[sp], src[sp + 1]]) as usize; - sp += 2; - if offset == 0 || offset > out.len() { - return Err(Error::invalid("lz4: match offset is invalid")); - } - - // Match copy (min length 4, byte-by-byte to allow overlap). - let match_len = read_len(src, &mut sp, (token & 0x0F) as usize)? + 4; - for _ in 0..match_len { - let b = out[out.len() - offset]; - out.push(b); - } - } - - if out.len() != decoded_size { - return Err(Error::invalid(format!( - "lz4: decoded {} bytes, expected {decoded_size}", - out.len() - ))); - } - Ok(out) -} - -/// Read an LZ4 length (the `0xF`/`0xFF`-extension scheme) given the 4-bit token nibble. -fn read_len(src: &[u8], sp: &mut usize, base: usize) -> Result { - let mut len = base; - if base != 15 { - return Ok(len); - } - loop { - let b = *src - .get(*sp) - .ok_or_else(|| Error::invalid("lz4: length extension truncated"))? - as usize; - *sp += 1; - len = len - .checked_add(b) - .ok_or_else(|| Error::invalid("lz4: length overflow"))?; - if b != 255 { - break; - } - } - Ok(len) -} - -// LZ4 block constraints (so `LZ4_decompress_safe` accepts the output). -const MIN_MATCH: usize = 4; -/// No match may *start* within the last 12 bytes of the block. -const MFLIMIT: usize = 12; -/// The last 5 bytes of the block are always literals. -const LAST_LITERALS: usize = 5; -const HASH_BITS: u32 = 16; - -fn read_u32(s: &[u8], i: usize) -> u32 { - u32::from_le_bytes([s[i], s[i + 1], s[i + 2], s[i + 3]]) -} - -fn hash4(v: u32) -> usize { - (v.wrapping_mul(2_654_435_761) >> (32 - HASH_BITS)) as usize -} - -/// Append an LZ4 length extension (the value beyond the 15 already encoded in the token nibble). -fn write_len_ext(out: &mut Vec, mut rem: usize) { - while rem >= 255 { - out.push(255); - rem -= 255; - } - out.push(rem as u8); -} - -/// Emit the trailing literals-only sequence (no match), terminating the block. -fn emit_last_literals(out: &mut Vec, lits: &[u8]) { - let n = lits.len(); - out.push(((n.min(15)) as u8) << 4); - if n >= 15 { - write_len_ext(out, n - 15); - } - out.extend_from_slice(lits); -} - -/// Compress to a valid LZ4 block with a greedy 4-byte-hash match finder. Used for -/// freshly-serialized (edited) bodies. Unmodified bodies are re-emitted verbatim by the format -/// layer. Round-trips through [`decompress_block`] and respects the engine's block constraints. -pub fn compress_block(src: &[u8]) -> Vec { - let n = src.len(); - let mut out = Vec::with_capacity(n / 2 + 16); - - // Too short to hold a match plus the mandatory trailing literals: emit all literals. - if n < MFLIMIT + MIN_MATCH { - emit_last_literals(&mut out, src); - return out; - } - let match_limit = n - MFLIMIT; // a match may only start at ip < match_limit - let match_end = n - LAST_LITERALS; // a match may not cover bytes at/after this - - let mut table = vec![usize::MAX; 1 << HASH_BITS]; - let mut anchor = 0usize; // start of the pending literal run - let mut ip = 0usize; - table[hash4(read_u32(src, ip))] = ip; - ip += 1; - - while ip < match_limit { - let h = hash4(read_u32(src, ip)); - let cand = table[h]; - table[h] = ip; - - let is_match = - cand != usize::MAX && ip - cand <= 0xFFFF && read_u32(src, cand) == read_u32(src, ip); - if !is_match { - ip += 1; - continue; - } - - // Extend the match (stops before the mandatory last-literals tail). - let offset = ip - cand; - let mut mlen = MIN_MATCH; - while ip + mlen < match_end && src[cand + mlen] == src[ip + mlen] { - mlen += 1; - } - - // Emit: pending literals, then the match. - let lits = &src[anchor..ip]; - let lit_len = lits.len(); - let match_tok = (mlen - MIN_MATCH).min(15); - out.push(((lit_len.min(15) as u8) << 4) | match_tok as u8); - if lit_len >= 15 { - write_len_ext(&mut out, lit_len - 15); - } - out.extend_from_slice(lits); - out.extend_from_slice(&(offset as u16).to_le_bytes()); - if mlen - MIN_MATCH >= 15 { - write_len_ext(&mut out, mlen - MIN_MATCH - 15); - } - - ip += mlen; - anchor = ip; - } - - // Trailing literals (always at least LAST_LITERALS bytes). - emit_last_literals(&mut out, &src[anchor..]); - out -} - -/// Decompress Wolf's framed form `[decSize u32][encSize u32][block]` (the bytes that -/// follow a v3.5 file header). Trailing bytes beyond `encSize` are ignored. -pub fn unpack(framed: &[u8]) -> Result> { - if framed.len() < 8 { - return Err(Error::invalid( - "lz4: framed body shorter than 8-byte header", - )); - } - let dec_size = u32::from_le_bytes([framed[0], framed[1], framed[2], framed[3]]) as usize; - let enc_size = u32::from_le_bytes([framed[4], framed[5], framed[6], framed[7]]) as usize; - let block = framed - .get(8..8 + enc_size) - .ok_or_else(|| Error::invalid("lz4: framed body shorter than declared compressed size"))?; - decompress_block(block, dec_size) -} - -/// Produce Wolf's framed form `[decSize u32][encSize u32][block]` for a body. -pub fn pack(body: &[u8]) -> Vec { - let block = compress_block(body); - let mut out = Vec::with_capacity(block.len() + 8); - out.extend_from_slice(&(body.len() as u32).to_le_bytes()); - out.extend_from_slice(&(block.len() as u32).to_le_bytes()); - out.extend_from_slice(&block); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(data: &[u8]) { - let packed = pack(data); - let back = unpack(&packed).expect("unpack"); - assert_eq!(back, data, "lz4 pack/unpack round-trip mismatch"); - } - - #[test] - fn pack_unpack_roundtrips() { - roundtrip(b""); - roundtrip(b"a"); - roundtrip(b"hello world"); - roundtrip(&[0u8; 1000]); - // 14, 15, 16 bytes straddle the literal-length extension boundary. - for n in [14usize, 15, 16, 270, 271, 600] { - roundtrip(&(0..n).map(|i| (i * 31 % 256) as u8).collect::>()); - } - } - - #[test] - fn compresses_and_roundtrips() { - // Highly repetitive -> should compress hard and still round-trip. - let zeros = vec![0u8; 10_000]; - let p = pack(&zeros); - assert_eq!(unpack(&p).unwrap(), zeros); - assert!( - p.len() < zeros.len() / 20, - "zeros packed to {} bytes", - p.len() - ); - - // Repeating phrase. - let mut text = Vec::new(); - for _ in 0..2000 { - text.extend_from_slice(b"the quick brown fox jumps. "); - } - let p = pack(&text); - assert_eq!(unpack(&p).unwrap(), text); - assert!(p.len() < text.len() / 3, "text packed to {} bytes", p.len()); - - // Varied sizes / patterns straddling the match + last-literal boundaries. - for n in [0usize, 1, 4, 11, 12, 13, 16, 17, 64, 255, 256, 4096, 12345] { - let data: Vec = (0..n).map(|i| ((i * 7 + i / 13) % 256) as u8).collect(); - let p = pack(&data); - assert_eq!(unpack(&p).unwrap(), data, "roundtrip failed at n={n}"); - } - } - - #[test] - fn decodes_a_match_bearing_block() { - // "abcabcabc": emit literals "abc" then a match (offset 3, len 6). - // token: litlen=3 (hi), matchlen-4=2 (lo) => 0x32, then "abc", then offset=3, no ext. - let block = [0x32u8, b'a', b'b', b'c', 0x03, 0x00]; - let out = decompress_block(&block, 9).expect("decode"); - assert_eq!(&out, b"abcabcabc"); - } -} diff --git a/util/wolfdawn-master/crates/wolf-core/src/codec/mod.rs b/util/wolfdawn-master/crates/wolf-core/src/codec/mod.rs deleted file mode 100644 index c5ddd8a..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/codec/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Compression and encoding codecs shared across Wolf formats. The LZ4 block codec used by -//! v3.5 ("Pro") compressed file bodies, and the `.sav` outer XOR cipher ([`wolfsave`]). - -pub mod lz4; -pub mod wolfsave; diff --git a/util/wolfdawn-master/crates/wolf-core/src/codec/wolfsave.rs b/util/wolfdawn-master/crates/wolf-core/src/codec/wolfsave.rs deleted file mode 100644 index 366a5d4..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/codec/wolfsave.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Wolf RPG `.sav` outer encryption: a 3-pass MSVCRT-`rand` XOR stream over the file body -//! (everything from offset `0x14` to EOF), keyed by three seed bytes pulled from the file head. -//! -//! This is the outer layer only. It leaves the decrypted plaintext (header, baked title, -//! length-prefixed strings, variable database, and so on) for a higher layer to interpret. The -//! codec is byte-exact: [`encrypt`]`(`[`decrypt`]`(raw)) == raw` for any real save whose stored -//! checksum already matches its body. That is the common case, see [`fix_checksum`]. -//! -//! The MSVCRT generator is reproduced exactly. `state = state*214013 + 2531011` then -//! `out = (state >> 16) & 0x7FFF`, with `srand(seed)` setting `state = seed`. Each XOR byte is -//! `(rand() >> 12) & 0xFF`. - -/// Body starts here. Bytes `0x00..0x14` (seeds, checksum, flags) are never XORed. -pub const START_OFFSET: usize = 0x14; - -/// MSVCRT `rand`/`srand`. `state` seeds directly from `srand`'s argument. -struct MsvcRand { - state: u32, -} - -impl MsvcRand { - fn new(seed: u8) -> Self { - // srand takes an int. The engine seeds it with a single header byte (0..=255). - MsvcRand { state: seed as u32 } - } - - fn next(&mut self) -> u16 { - self.state = self.state.wrapping_mul(214013).wrapping_add(2531011); - ((self.state >> 16) & 0x7FFF) as u16 - } -} - -/// XOR one pass over the body in place: `srand(seed)`, then for every `step`-th byte from -/// `START_OFFSET` to the end, `data[j] ^= (rand() >> 12) & 0xFF`. -fn xor_stream(data: &mut [u8], seed: u8, step: usize) { - let mut rng = MsvcRand::new(seed); - let mut j = START_OFFSET; - while j < data.len() { - data[j] ^= ((rng.next() >> 12) & 0xFF) as u8; - j += step; - } -} - -/// Decrypt a raw `.sav` buffer into its plaintext form. -/// -/// Seeds are `s0 = data[0]`, `s1 = data[3]`, `s2 = data[9]`. The passes run in that order with -/// increments `[1, 2, 5]`. A buffer too small to have a body (`len <= START_OFFSET`) is returned -/// unchanged. -pub fn decrypt(raw: &[u8]) -> Vec { - let mut data = raw.to_vec(); - if data.len() <= START_OFFSET { - return data; - } - let (s0, s1, s2) = (data[0], data[3], data[9]); - xor_stream(&mut data, s0, 1); - xor_stream(&mut data, s1, 2); - xor_stream(&mut data, s2, 5); - data -} - -/// Recompute the body checksum: `data[2] = sum(data[0x14..]) & 0xFF`. Call before [`encrypt`] -/// whenever the body may have changed. No-op for a buffer with no body. -// `&mut Vec` (rather than `&mut [u8]`) is the public signature for this codec. -#[allow(clippy::ptr_arg)] -pub fn fix_checksum(data: &mut Vec) { - if data.len() > START_OFFSET { - let sum = data[START_OFFSET..] - .iter() - .fold(0u8, |acc, &b| acc.wrapping_add(b)); - data[2] = sum; - } -} - -/// Encrypt a plaintext buffer back into a raw `.sav`. -/// -/// This is the exact inverse of [`decrypt`]. The checksum is recomputed first, then the three -/// XOR passes run in reverse seed order (`s0 = data[9]`, `s1 = data[3]`, `s2 = data[0]`) with -/// increments `[5, 2, 1]`. Because the seeds are read from the never-XORed head bytes, decrypt -/// and encrypt key off identical seed values, so the passes cancel and -/// `encrypt(decrypt(raw)) == raw` for any save whose stored checksum already equals its body -/// checksum. -pub fn encrypt(plain: &[u8]) -> Vec { - let mut data = plain.to_vec(); - if data.len() <= START_OFFSET { - return data; - } - fix_checksum(&mut data); - let (s0, s1, s2) = (data[9], data[3], data[0]); - xor_stream(&mut data, s0, 5); - xor_stream(&mut data, s1, 2); - xor_stream(&mut data, s2, 1); - data -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn msvc_rand_matches_reference_sequence() { - // After srand(1), the first few rand() values from the canonical MSVCRT generator. - let mut r = MsvcRand::new(1); - assert_eq!(r.next(), 41); - assert_eq!(r.next(), 18467); - assert_eq!(r.next(), 6334); - } - - #[test] - fn roundtrip_on_synthetic_data() { - // Build a buffer with a body, give it a correct checksum, encrypt then decrypt: identical. - let mut plain: Vec = (0u8..=200).cycle().take(0x14 + 137).collect(); - // Vary the seed bytes so all three passes use distinct seeds. - plain[0] = 0x37; - plain[3] = 0x9A; - plain[9] = 0x04; - fix_checksum(&mut plain); - - let enc = encrypt(&plain); - let dec = decrypt(&enc); - assert_eq!(dec, plain, "decrypt(encrypt(plain)) must be identity"); - - // And the body actually changed under encryption (not a no-op). - assert_ne!(&enc[START_OFFSET..], &plain[START_OFFSET..]); - // The head (seeds/checksum/flags) is never XORed, so it is carried verbatim. - assert_eq!(&enc[..START_OFFSET], &plain[..START_OFFSET]); - } - - #[test] - fn fix_checksum_sets_byte2() { - let mut data = vec![0u8; 0x14 + 4]; - data[0x14] = 10; - data[0x15] = 20; - data[0x16] = 30; - data[0x17] = 40; - fix_checksum(&mut data); - assert_eq!(data[2], 100); - } - - #[test] - fn tiny_buffer_is_untouched() { - let raw = vec![1u8, 2, 3]; - assert_eq!(decrypt(&raw), raw); - assert_eq!(encrypt(&raw), raw); - } -} diff --git a/util/wolfdawn-master/crates/wolf-core/src/error.rs b/util/wolfdawn-master/crates/wolf-core/src/error.rs deleted file mode 100644 index 9439b9d..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/error.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::fmt; - -pub type Result = std::result::Result; - -/// Errors raised by the low-level wire layer. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Error { - /// Tried to read past the end of the buffer. - UnexpectedEof { - context: &'static str, - needed: usize, - pos: usize, - len: usize, - }, - /// A fixed magic/marker byte sequence did not match. - BadMagic { - context: &'static str, - expected: Vec, - found: Vec, - }, - /// A structural invariant was violated. - Invalid(String), -} - -impl Error { - pub fn invalid(msg: impl Into) -> Self { - Error::Invalid(msg.into()) - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::UnexpectedEof { - context, - needed, - pos, - len, - } => write!( - f, - "unexpected EOF while reading {context}: need {needed} byte(s) at offset {pos}, buffer is {len} byte(s)" - ), - Error::BadMagic { - context, - expected, - found, - } => write!( - f, - "bad magic for {context}: expected {}, found {}", - hex(expected), - hex(found) - ), - Error::Invalid(msg) => write!(f, "{msg}"), - } - } -} - -impl std::error::Error for Error {} - -fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 3); - for (i, b) in bytes.iter().enumerate() { - if i > 0 { - s.push(' '); - } - s.push_str(&format!("{b:02x}")); - } - s -} diff --git a/util/wolfdawn-master/crates/wolf-core/src/lib.rs b/util/wolfdawn-master/crates/wolf-core/src/lib.rs deleted file mode 100644 index ebbe070..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! `wolf-core`: the low-level Wolf RPG wire layer. -//! -//! Provides the byte [`Reader`]/[`Writer`] and the length-prefixed [`WStr`] string -//! primitive shared by every Wolf file format. This layer carries no Wolf domain -//! semantics (no maps, databases or commands) and decodes nothing it cannot reproduce -//! byte-for-byte. Strings are kept as raw bytes so read/write is exact regardless of -//! Shift-JIS vs UTF-8 encoding. - -#![forbid(unsafe_code)] - -pub mod codec; -pub mod error; -pub mod reader; -pub mod writer; -pub mod wstr; - -pub use error::{Error, Result}; -pub use reader::Reader; -pub use writer::Writer; -pub use wstr::WStr; diff --git a/util/wolfdawn-master/crates/wolf-core/src/reader.rs b/util/wolfdawn-master/crates/wolf-core/src/reader.rs deleted file mode 100644 index 616d870..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/reader.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::error::{Error, Result}; -use crate::wstr::WStr; - -/// A cursor over an in-memory byte buffer that decodes the Wolf wire primitives. -/// -/// `Int` = u32 little-endian, `Byte` = u8, `String` = u32 length prefix plus that many -/// raw bytes (kept undecoded, see [`WStr`]). -pub struct Reader<'a> { - data: &'a [u8], - pos: usize, -} - -impl<'a> Reader<'a> { - pub fn new(data: &'a [u8]) -> Self { - Reader { data, pos: 0 } - } - - pub fn pos(&self) -> usize { - self.pos - } - - pub fn len(&self) -> usize { - self.data.len() - } - - pub fn is_empty(&self) -> bool { - self.data.is_empty() - } - - pub fn remaining(&self) -> usize { - self.data.len().saturating_sub(self.pos) - } - - pub fn is_eof(&self) -> bool { - self.pos >= self.data.len() - } - - pub fn seek(&mut self, pos: usize) { - self.pos = pos; - } - - /// Absolute byte at `idx` without moving the cursor. - pub fn at(&self, idx: usize) -> Option { - self.data.get(idx).copied() - } - - pub fn skip(&mut self, n: usize, context: &'static str) -> Result<()> { - self.ensure(n, context)?; - self.pos += n; - Ok(()) - } - - fn ensure(&self, n: usize, context: &'static str) -> Result<()> { - if self.pos + n > self.data.len() { - return Err(Error::UnexpectedEof { - context, - needed: n, - pos: self.pos, - len: self.data.len(), - }); - } - Ok(()) - } - - pub fn read_u8(&mut self) -> Result { - self.ensure(1, "u8")?; - let v = self.data[self.pos]; - self.pos += 1; - Ok(v) - } - - pub fn read_u32(&mut self) -> Result { - self.ensure(4, "u32")?; - let b = &self.data[self.pos..self.pos + 4]; - self.pos += 4; - Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) - } - - /// Peek the next u32 without advancing the cursor. - pub fn peek_u32(&self) -> Result { - self.ensure(4, "peek u32")?; - let b = &self.data[self.pos..self.pos + 4]; - Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) - } - - pub fn read_bytes(&mut self, n: usize) -> Result> { - self.ensure(n, "fixed bytes")?; - let v = self.data[self.pos..self.pos + n].to_vec(); - self.pos += n; - Ok(v) - } - - /// `String`: u32 byte-length prefix + that many raw bytes. - pub fn read_string(&mut self) -> Result { - let size = self.read_u32()? as usize; - let bytes = self.read_bytes(size)?; - Ok(WStr(bytes)) - } - - /// `ByteArray`: u32 count + that many bytes. - pub fn read_byte_array(&mut self) -> Result> { - let size = self.read_u32()? as usize; - self.read_bytes(size) - } - - /// `IntArray`: u32 count + that many u32s. - pub fn read_int_array(&mut self) -> Result> { - let size = self.read_u32()? as usize; - let mut out = Vec::with_capacity(size.min(1 << 16)); - for _ in 0..size { - out.push(self.read_u32()?); - } - Ok(out) - } - - /// `StringArray`: u32 count + that many `String`s. - pub fn read_string_array(&mut self) -> Result> { - let size = self.read_u32()? as usize; - let mut out = Vec::with_capacity(size.min(1 << 16)); - for _ in 0..size { - out.push(self.read_string()?); - } - Ok(out) - } - - /// Verify a fixed marker/magic byte sequence and advance past it. - pub fn verify(&mut self, expected: &[u8], context: &'static str) -> Result<()> { - self.ensure(expected.len(), context)?; - let found = &self.data[self.pos..self.pos + expected.len()]; - if found != expected { - return Err(Error::BadMagic { - context, - expected: expected.to_vec(), - found: found.to_vec(), - }); - } - self.pos += expected.len(); - Ok(()) - } -} diff --git a/util/wolfdawn-master/crates/wolf-core/src/writer.rs b/util/wolfdawn-master/crates/wolf-core/src/writer.rs deleted file mode 100644 index 2dba077..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/writer.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::wstr::WStr; - -/// Accumulates the Wolf wire primitives into a byte buffer. The exact inverse of -/// [`crate::reader::Reader`]. Writing back everything that was read reproduces the -/// original bytes, which is the round-trip contract for the format layer. -#[derive(Default)] -pub struct Writer { - buf: Vec, -} - -impl Writer { - pub fn new() -> Self { - Writer { buf: Vec::new() } - } - - pub fn with_capacity(cap: usize) -> Self { - Writer { - buf: Vec::with_capacity(cap), - } - } - - pub fn len(&self) -> usize { - self.buf.len() - } - - pub fn is_empty(&self) -> bool { - self.buf.is_empty() - } - - pub fn as_slice(&self) -> &[u8] { - &self.buf - } - - pub fn into_bytes(self) -> Vec { - self.buf - } - - pub fn write_u8(&mut self, v: u8) { - self.buf.push(v); - } - - pub fn write_u32(&mut self, v: u32) { - self.buf.extend_from_slice(&v.to_le_bytes()); - } - - pub fn write_bytes(&mut self, b: &[u8]) { - self.buf.extend_from_slice(b); - } - - pub fn write_string(&mut self, s: &WStr) { - self.write_u32(s.len() as u32); - self.buf.extend_from_slice(s.as_bytes()); - } - - pub fn write_byte_array(&mut self, b: &[u8]) { - self.write_u32(b.len() as u32); - self.buf.extend_from_slice(b); - } - - pub fn write_int_array(&mut self, a: &[u32]) { - self.write_u32(a.len() as u32); - for &v in a { - self.write_u32(v); - } - } - - pub fn write_string_array(&mut self, a: &[WStr]) { - self.write_u32(a.len() as u32); - for s in a { - self.write_string(s); - } - } -} diff --git a/util/wolfdawn-master/crates/wolf-core/src/wstr.rs b/util/wolfdawn-master/crates/wolf-core/src/wstr.rs deleted file mode 100644 index 6965fb5..0000000 --- a/util/wolfdawn-master/crates/wolf-core/src/wstr.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::fmt; - -/// A Wolf length-prefixed string, stored as its raw on-disk bytes (no length prefix -/// included). Encoding is either Shift-JIS (CP932) or UTF-8 depending on the owning -/// file's magic. The low-level layer does not decode it, so read/write stays byte-exact -/// regardless of encoding. Decoding to UTF-8 for human display happens in the decompiler -/// layer. -#[derive(Clone, Default, PartialEq, Eq, Hash)] -pub struct WStr(pub Vec); - -impl WStr { - pub fn new(bytes: Vec) -> Self { - WStr(bytes) - } - - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - - pub fn len(&self) -> usize { - self.0.len() - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - /// Lossy UTF-8 view, for diagnostics/display only. Not used on the round-trip path. - pub fn to_lossy(&self) -> String { - String::from_utf8_lossy(&self.0).into_owned() - } -} - -impl fmt::Debug for WStr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self.to_lossy()) - } -} - -impl From<&str> for WStr { - fn from(s: &str) -> Self { - WStr(s.as_bytes().to_vec()) - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/Cargo.toml b/util/wolfdawn-master/crates/wolf-decompiler/Cargo.toml deleted file mode 100644 index ff74c93..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "wolf-decompiler" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Renders Wolf RPG event command programs into readable, editable WolfScript." - -[dependencies] -wolf-core = { path = "../wolf-core" } -wolf-formats = { path = "../wolf-formats" } -# Encoding (SJIS/CP932 <-> UTF-8) is permitted at the decompiler layer only. -encoding_rs = "0.8" -# The command reference (data/commands.json) is loaded via serde at startup. -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/util/wolfdawn-master/crates/wolf-decompiler/examples/roundtrip_check.rs b/util/wolfdawn-master/crates/wolf-decompiler/examples/roundtrip_check.rs deleted file mode 100644 index 1630e08..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/examples/roundtrip_check.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Byte-exact round-trip check for the gated/recompilable path across a corpus root: -//! `compile_commands_enc(decompile_commands_enc(cmds)) == cmds` for every command list. -//! Exercises the new operand banks, flagged compare ops, and operand-decoded route args. -//! -//! Run: `cargo run -p wolf-decompiler --example roundtrip_check -- ` - -use std::path::PathBuf; - -use wolf_decompiler::{compile_commands_enc, decompile_commands_enc}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn main() { - let root = std::env::args() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("../../..")); - - let mut stack = vec![root]; - let mut ok = 0usize; - let mut mismatch = 0usize; - let mut lists = 0usize; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - continue; - } - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); - let bytes = std::fs::read(&p).unwrap_or_default(); - let mut programs: Vec<(bool, Vec<_>)> = Vec::new(); - if name == "CommonEvent.dat" { - if let Ok(ce) = CommonEventsFile::read(&bytes) { - for ev in ce.events { - programs.push((ce.utf8, ev.commands)); - } - } - } else if ext.eq_ignore_ascii_case("mps") { - if let Ok(m) = Map::read(&bytes) { - for ev in m.events { - for pg in ev.pages { - programs.push((m.utf8, pg.commands)); - } - } - } - } - for (utf8, cmds) in programs { - lists += 1; - let text = decompile_commands_enc(&cmds, utf8); - match compile_commands_enc(&text, utf8) { - Ok(back) if back == cmds => ok += 1, - Ok(_) => { - mismatch += 1; - if mismatch <= 5 { - eprintln!( - "MISMATCH (structure differs) in {:?}", - p.file_name().unwrap() - ); - } - } - Err(e) => { - mismatch += 1; - if mismatch <= 5 { - eprintln!("COMPILE ERR in {:?}: {e}", p.file_name().unwrap()); - } - } - } - } - } - } - println!("command lists: {lists} | byte-exact round-trip OK: {ok} | mismatch: {mismatch}"); - if mismatch != 0 { - std::process::exit(1); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/examples/scan_banks.rs b/util/wolfdawn-master/crates/wolf-decompiler/examples/scan_banks.rs deleted file mode 100644 index ed16b99..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/examples/scan_banks.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! Corpus-wide diagnostic: across every engine version, find unnamed/fallthrough renderings -//! and the raw operand "banks" actually used, so no random index goes unhandled. -//! -//! Run: `cargo run -p wolf-decompiler --example scan_banks -- ` - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use wolf_decompiler::{decompile_common_events, decompile_map, SymbolTable}; -use wolf_formats::command::RawCommand; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn main() { - let root = std::env::args() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("../../..")); - - let mut files = Vec::new(); - let mut stack = vec![root]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else { - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); - if name == "CommonEvent.dat" || ext.eq_ignore_ascii_case("mps") { - files.push(p); - } - } - } - } - files.sort(); - - // token name -> (count, sample) - let mut tokens: BTreeMap<&'static str, (usize, String)> = BTreeMap::new(); - // operand bank (v/100000) -> (count, set of (cid,argidx) sites) - let mut banks: BTreeMap)> = BTreeMap::new(); - // every command cid used, and every route sub-command id used - let mut cids: BTreeMap = BTreeMap::new(); - let mut route_ids: BTreeMap = BTreeMap::new(); - let mut read_fail: BTreeMap = BTreeMap::new(); - let mut n_maps = 0usize; - let mut n_ce = 0usize; - - let sym = SymbolTable::new(); - for p in &files { - let bytes = std::fs::read(p).unwrap_or_default(); - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let ver = version_of(p); - if name == "CommonEvent.dat" { - match CommonEventsFile::read(&bytes) { - Ok(ce) => { - n_ce += 1; - let text = decompile_common_events(&ce, &sym); - scan_text(&text, p, &mut tokens); - for ev in &ce.events { - for c in &ev.commands { - scan_cmd(c, &mut banks, &mut cids, &mut route_ids); - } - } - } - Err(e) => *read_fail.entry(format!("{ver} CE: {e}")).or_default() += 1, - } - } else { - match Map::read(&bytes) { - Ok(m) => { - n_maps += 1; - let text = decompile_map(&m, &sym); - scan_text(&text, p, &mut tokens); - for ev in &m.events { - for pg in &ev.pages { - for c in &pg.commands { - scan_cmd(c, &mut banks, &mut cids, &mut route_ids); - } - } - } - } - Err(e) => *read_fail.entry(format!("{ver} MAP: {e}")).or_default() += 1, - } - } - } - - println!("== scanned: {n_maps} maps + {n_ce} common-event files ==\n"); - - // Cross-check against what the spec catalogues. - let unknown_cids: Vec<(u32, usize)> = cids - .iter() - .filter(|(cid, _)| wolf_decompiler::spec::command(**cid).is_none()) - .map(|(c, n)| (*c, *n)) - .collect(); - let unknown_routes: Vec<(u32, usize)> = route_ids - .iter() - .map(|(k, n)| (k / 1000, *n)) - .filter(|(id, _)| wolf_decompiler::spec::route_name(*id) == format!("route{id}")) - .collect(); - println!("-- UNKNOWN COMMAND CIDS (no schema -> Cmd) --"); - if unknown_cids.is_empty() { - println!(" none\n"); - } else { - for (c, n) in &unknown_cids { - println!(" cid {c:5} {n}x"); - } - println!(); - } - println!("-- UNNAMED ROUTE SUB-COMMAND IDS (-> route) --"); - if unknown_routes.is_empty() { - println!(" none\n"); - } else { - for (c, n) in &unknown_routes { - println!(" route id {c:5} {n}x"); - } - println!(); - } - println!("-- ALL ROUTE IDS USED (id #args, count, current name) --"); - for (key, n) in &route_ids { - let id = key / 1000; - let argc = key % 1000; - println!( - " id {id:3} args {argc} {n:7}x {}", - wolf_decompiler::spec::route_name(id) - ); - } - println!(); - - println!("-- READ FAILURES (silently skipped by gates) --"); - if read_fail.is_empty() { - println!(" none\n"); - } else { - for (k, v) in &read_fail { - println!(" {v:4}x {k}"); - } - println!(); - } - - println!("-- FALLTHROUGH TOKENS in rendered output --"); - if tokens.is_empty() { - println!(" none\n"); - } else { - for (tok, (cnt, sample)) in &tokens { - println!(" {cnt:6}x {tok:12} e.g. {sample}"); - } - println!(); - } - - println!("-- RAW OPERAND BANKS (every int_arg >= 1,000,000, bucketed by /100000) --"); - println!(" bank = the millions/hundred-thousands prefix; sites = distinct (cid,argIndex)"); - for (bank, (cnt, sites)) in &banks { - let label = bank_label(*bank); - let mut site_str: Vec = sites - .iter() - .map(|((cid, ai), n)| format!("cmd{cid}#{ai}x{n}")) - .collect(); - site_str.sort(); - let shown = site_str.len().min(8); - println!( - " {bank:>4} ({label:<22}) {cnt:8}x sites[{}]: {}", - site_str.len(), - site_str[..shown].join(" ") - ); - } -} - -fn scan_cmd( - c: &RawCommand, - banks: &mut BTreeMap)>, - cids: &mut BTreeMap, - route_ids: &mut BTreeMap, -) { - *cids.entry(c.cid).or_default() += 1; - // Diagnostic: dump VariableCondition (111) terms whose op code is out of the known 0..6 set. - if c.cid == 111 { - if let Some(&header) = c.int_args.first() { - let n = (header & 0x0F) as usize; - for g in 0..n { - if let Some(&op) = c.int_args.get(1 + g * 3 + 2) { - if op > 6 { - eprintln!( - "WEIRD cmd111 op={op} (0x{op:x}) header=0x{header:x} n={n} args={:?}", - c.int_args - ); - } - } - } - } - } - if let Some(mr) = &c.move_route { - for rc in &mr.commands { - // encode (id, argcount) so we can see the real shape: id*1000 + argc - *route_ids - .entry(rc.id as u32 * 1000 + rc.args.len() as u32) - .or_default() += 1; - } - } - for (i, &v) in c.int_args.iter().enumerate() { - if v >= 1_000_000 && v != 0xFFFF_FFFF && v != 0xFFFF_FFFE { - let bank = v / 100_000; - let e = banks.entry(bank).or_default(); - e.0 += 1; - *e.1.entry((c.cid, i)).or_default() += 1; - } - } -} - -fn scan_text(text: &str, p: &Path, tokens: &mut BTreeMap<&'static str, (usize, String)>) { - let checks: &[(&'static str, fn(&str) -> bool)] = &[ - ("Self*[", |l| l.contains("Self*[")), - ("@raw", |l| l.contains("@raw ")), - ("Cmd", |l| has_prefixed_num(l, "Cmd")), - ("route", |l| has_route_num(l)), - ("cmp", |l| has_cmp_num(l)), - ("strcmp", |l| has_prefixed_num(l, "strcmp")), - ("argN", |l| has_argn(l)), - ]; - for line in text.lines() { - for (tok, f) in checks { - if f(line) { - let e = tokens.entry(*tok).or_insert((0, String::new())); - e.0 += 1; - if e.1.is_empty() { - e.1 = format!( - "{} :: {}", - p.file_name().and_then(|s| s.to_str()).unwrap_or(""), - line.trim() - ); - } - } - } - } -} - -/// `prefix` immediately followed by a digit, not part of a longer alnum word before it. -fn has_prefixed_num(line: &str, prefix: &str) -> bool { - line.match_indices(prefix).any(|(i, _)| { - let before_ok = i == 0 - || !line[..i] - .chars() - .last() - .map(|c| c.is_alphanumeric()) - .unwrap_or(false); - let after = &line[i + prefix.len()..]; - before_ok - && after - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - }) -} - -/// `route` + digit, but NOT `@route(` (the Move route header) and not inside a word. -fn has_route_num(line: &str) -> bool { - line.match_indices("route").any(|(i, _)| { - let prev = if i == 0 { - None - } else { - line[..i].chars().last() - }; - let before_ok = prev - .map(|c| !c.is_alphanumeric() && c != '@') - .unwrap_or(true); - let after = &line[i + 5..]; - before_ok - && after - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - }) -} - -/// ` cmp` + digit (the VariableCondition unknown-op form), not `strcmp`. -fn has_cmp_num(line: &str) -> bool { - line.match_indices("cmp").any(|(i, _)| { - let prev = if i == 0 { - None - } else { - line[..i].chars().last() - }; - // exclude the `str` of `strcmp` - let before_ok = prev.map(|c| !c.is_alphanumeric()).unwrap_or(true); - let after = &line[i + 3..]; - before_ok - && after - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - }) -} - -fn has_argn(line: &str) -> bool { - line.match_indices("arg").any(|(i, _)| { - let prev = if i == 0 { - None - } else { - line[..i].chars().last() - }; - let before_ok = prev.map(|c| !c.is_alphanumeric()).unwrap_or(true); - let after = &line[i + 3..]; - let digit = after - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false); - before_ok - && digit - && after - .split_once('=') - .map(|(k, _)| k.chars().all(|c| c.is_ascii_digit())) - .unwrap_or(false) - }) -} - -fn bank_label(b: u32) -> &'static str { - match b { - 10 => "1.0M map-event ref", - 11 => "1.1M Self[]", - 12..=15 => "1.2-1.5M ???", - 16 => "1.6M CSelf[]", - 17..=19 => "1.7-1.9M ???", - 20..=29 => "2.x V[] normal", - 30..=39 => "3.x S[] string", - 40..=89 => "4-8M bare-lit ???", - 90..=99 => "9.x Sys[]", - _ => "10M+ bare-lit ???", - } -} - -fn version_of(p: &Path) -> String { - for comp in p.components() { - if let Some(s) = comp.as_os_str().to_str() { - if s.starts_with("WolfRPGEditor_") { - return s.to_string(); - } - } - } - "(loose)".to_string() -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/compile/cmd.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/compile/cmd.rs deleted file mode 100644 index d53168d..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/compile/cmd.rs +++ /dev/null @@ -1,743 +0,0 @@ -//! Leaf-command and block-opener reconstruction (numeric operand forms). -//! -//! Inverts the readable rendering back to exact `RawCommand`s. Commands whose pretty form is -//! fully invertible (Message, Comment, SetVariable, SetString, Wait, conditions, loops, choices) -//! are handled here. Lossy-pretty commands (Sound, Picture, Database, and so on) carry a trailing -//! `@raw(...)` annotation so the byte layer stays recoverable. The round-trip gate reports exactly -//! which commands still need it. - -use wolf_formats::command::{MoveRoute, RawCommand, RouteCommand}; -use wolf_formats::{Error, Reader, Result, WStr}; - -use crate::text::text_to_wstr; - -/// Parse the lossless `@raw i: s: t: m: v:`. -fn parse_raw(line: &str, indent: u8) -> std::result::Result { - let rest = &line["@raw ".len()..]; - let mut toks = rest.split(' '); - let cid = toks - .next() - .ok_or("@raw: missing cid")? - .parse::() - .map_err(|_| "@raw: bad cid")?; - let (mut ints, mut strs, mut term, mut route, mut v35) = - (Vec::new(), Vec::new(), 0u8, None, None); - for tok in toks { - if let Some(v) = tok.strip_prefix("i:") { - if !v.is_empty() { - ints = v - .split(',') - .map(|x| { - x.parse::() - .map(|n| n as u32) - .map_err(|_| "@raw: bad int") - }) - .collect::>()?; - } - } else if let Some(v) = tok.strip_prefix("s:") { - if !v.is_empty() { - strs = v - .split(',') - .map(|h| from_hex(h).map(WStr)) - .collect::>()?; - } - } else if let Some(v) = tok.strip_prefix("t:") { - term = v.parse().map_err(|_| "@raw: bad term")?; - } else if let Some(v) = tok.strip_prefix("m:") { - if v != "-" { - let bytes = from_hex(v)?; - route = Some(MoveRoute::read(&mut Reader::new(&bytes)).map_err(|e| e.to_string())?); - } - } else if let Some(v) = tok.strip_prefix("v:") { - if v != "-" { - v35 = Some(from_hex(v)?); - } - } - } - Ok(RawCommand { - cid, - int_args: ints, - indent, - str_args: strs, - term, - move_route: route, - v35_blob: v35, - }) -} - -/// Parse the lossless Move(201) form: `Move(ints) "strs" @route(t=…, f=…, h=…) { cmds }`. -fn parse_move(line: &str, indent: u8, utf8: bool) -> std::result::Result { - let (head, rest) = line.split_once(" @route(").ok_or("Move: missing @route")?; - let (ints, strs) = parse_move_head(head, utf8)?; - let (params, body) = rest.split_once(") {").ok_or("Move: @route missing `) {`")?; - let (term, flags, hdr) = parse_route_params(params)?; - let body = body - .trim() - .strip_suffix('}') - .ok_or("Move: route missing `}`")?; - let commands = parse_route_cmds(body.trim())?; - Ok(RawCommand { - cid: 201, - int_args: ints, - indent, - str_args: strs, - term, - move_route: Some(MoveRoute { - unknown: hdr, - flags, - commands, - }), - v35_blob: None, - }) -} - -fn parse_move_head(head: &str, utf8: bool) -> std::result::Result<(Vec, Vec), String> { - let rest = head - .trim() - .strip_prefix("Move") - .ok_or("Move: expected `Move`")? - .trim_start(); - let mut ints = Vec::new(); - let after = if let Some(r) = rest.strip_prefix('(') { - let close = r.find(')').ok_or("Move: missing `)`")?; - ints = parse_labeled_args(&r[..close])?; - r[close + 1..].trim() - } else { - rest - }; - Ok((ints, parse_trailing_strings(after, utf8)?)) -} - -fn parse_route_params(s: &str) -> std::result::Result<(u8, u8, [u8; 5]), String> { - let (mut term, mut flags, mut hdr) = (0u8, 0u8, [0u8; 5]); - for part in s.split(',') { - let part = part.trim(); - if let Some(v) = part.strip_prefix("t=") { - term = v.parse().map_err(|_| "Move: bad route t")?; - } else if let Some(v) = part.strip_prefix("f=") { - flags = v.parse().map_err(|_| "Move: bad route f")?; - } else if let Some(v) = part.strip_prefix("h=") { - let bytes = from_hex(v)?; - if bytes.len() != 5 { - return Err(format!( - "Move: route header must be 5 bytes, got {}", - bytes.len() - )); - } - hdr.copy_from_slice(&bytes); - } - } - Ok((term, flags, hdr)) -} - -fn parse_route_cmds(body: &str) -> std::result::Result, String> { - let mut out = Vec::new(); - for part in body.split(';') { - let part = part.trim(); - if part.is_empty() { - continue; - } - let (name, args) = if let Some(open) = part.find('(') { - let close = part.find(')').ok_or("route cmd: missing `)`")?; - let args = part[open + 1..close] - .split(',') - .filter(|s| !s.trim().is_empty()) - .map(|s| operand(s.trim())) - .collect::, _>>()?; - (part[..open].trim(), args) - } else { - (part, Vec::new()) - }; - let id = crate::spec::route_id_for_name(name) - .ok_or_else(|| format!("unknown route command: {name:?}"))? as u8; - out.push(RouteCommand { - id, - args, - terminator: [1, 0], - }); - } - Ok(out) -} - -/// Decode an even-length hex string. Errors on odd length or any non-hex digit rather than -/// silently dropping bytes, so a corrupted hand-edited `@raw`, `x"…"`, route, or blob fails loud. -fn from_hex(s: &str) -> std::result::Result, String> { - if s.len() % 2 != 0 { - return Err(format!("odd-length hex: {s:?}")); - } - (0..s.len() / 2) - .map(|i| { - u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).map_err(|_| format!("bad hex: {s:?}")) - }) - .collect() -} - -fn cmd(cid: u32, ints: Vec, indent: u8, strs: Vec) -> RawCommand { - RawCommand { - cid, - int_args: ints, - indent, - str_args: strs, - term: 0, - move_route: None, - v35_blob: None, - } -} - -/// Parse one leaf command line. `utf8` selects the string encoding (Shift-JIS vs UTF-8). -pub fn parse_leaf(line: &str, indent: u8, utf8: bool) -> std::result::Result { - if line.starts_with("@raw ") { - return parse_raw(line, indent); - } - if line.contains(" @route(") { - return parse_move(line, indent, utf8); - } - if let Some(rest) = line.strip_prefix("# ") { - return Ok(cmd(103, vec![], indent, vec![wstr(rest, utf8)?])); - } - if line == "#" { - return Ok(cmd(103, vec![], indent, vec![wstr("", utf8)?])); - } - if let Some(rest) = line.strip_prefix("Message ") { - return Ok(cmd(101, vec![], indent, vec![parse_str(rest, utf8)?])); - } - if let Some(rest) = line.strip_prefix("DebugMessage ") { - return Ok(cmd(106, vec![], indent, vec![parse_str(rest, utf8)?])); - } - if let Some(rest) = line.strip_prefix("Wait ") { - return Ok(cmd( - 180, - vec![rest.trim().parse().map_err(|_| "bad Wait arg")?], - indent, - vec![], - )); - } - if let Some(rest) = line.strip_prefix("SetVariable ") { - return parse_set_variable(rest, indent); - } - if let Some(rest) = line.strip_prefix("SetString ") { - return parse_set_string(rest, indent, utf8); - } - parse_generic(line, indent, utf8) -} - -/// Inverse of the labeled fallback `Name(label=val, …) "str" …` (and `Name` or `Name "str"`). -/// Lossless for the common term==0, no-move, no-v35 case. -fn parse_generic(line: &str, indent: u8, utf8: bool) -> std::result::Result { - use crate::spec; - - // An int-arg list `(...)` only counts if it appears before any string arg. Otherwise a `(` - // inside a quoted string would be mistaken for an arg list. - let paren = line.find('('); - let quote = line.find('"'); - let has_args = match (paren, quote) { - (Some(p), Some(q)) => p < q, - (Some(_), None) => true, - _ => false, - }; - if let Some(open) = paren.filter(|_| has_args) { - let name = line[..open].trim(); - // Int-arg lists never contain nested parens, so the first `)` after `(` closes them. - let close = open + 1 + line[open + 1..].find(')').ok_or("missing `)`")?; - let cid = spec::cid_for_name(name).ok_or_else(|| format!("unknown command: {name:?}"))?; - let ints = parse_labeled_args(&line[open + 1..close])?; - let strs = parse_trailing_strings(line[close + 1..].trim(), utf8)?; - return Ok(cmd(cid, ints, indent, strs)); - } - - // `Name` or `Name "str" "str"` - let (name, after) = line.split_once(' ').unwrap_or((line, "")); - let cid = spec::cid_for_name(name).ok_or_else(|| format!("unrecognized command: {line:?}"))?; - let strs = parse_trailing_strings(after.trim(), utf8)?; - Ok(cmd(cid, vec![], indent, strs)) -} - -fn parse_labeled_args(s: &str) -> std::result::Result, String> { - let s = s.trim(); - if s.is_empty() { - return Ok(vec![]); - } - s.split(", ") - .map(|part| { - let val = part.split_once('=').map(|(_, v)| v).unwrap_or(part); - operand(val.trim()) - }) - .collect() -} - -fn parse_trailing_strings(s: &str, utf8: bool) -> std::result::Result, String> { - parse_quoted_seq(s, utf8) -} - -/// Parse a whitespace/comma-separated sequence of string literals. Each is either readable -/// `"text"` (escapes and empty strings) or byte-exact `x"HEX"` (raw bytes, NUL included). -fn parse_quoted_seq(s: &str, utf8: bool) -> std::result::Result, String> { - let mut out = Vec::new(); - let mut chars = s.chars().peekable(); - loop { - while matches!(chars.peek(), Some(' ') | Some(',')) { - chars.next(); - } - match read_one_literal(&mut chars, s, utf8)? { - Some(w) => out.push(w), - None => break, - } - } - Ok(out) -} - -/// Read a single string literal at the cursor: `x"HEX"` (raw bytes) or `"text"` (escaped, -/// re-encoded in the file's encoding plus a trailing NUL). Returns `None` at end of input. -fn read_one_literal( - chars: &mut std::iter::Peekable, - ctx: &str, - utf8: bool, -) -> std::result::Result, String> { - match chars.peek().copied() { - None => return Ok(None), - // `x"HEX"` byte-exact form. - Some('x') => { - let mut look = chars.clone(); - look.next(); // the 'x' - if look.peek() == Some(&'"') { - chars.next(); // 'x' - chars.next(); // '"' - let mut hex = String::new(); - loop { - match chars.next() { - Some('"') => break, - Some(c) => hex.push(c), - None => return Err(format!("unterminated x\"…\" literal in {ctx:?}")), - } - } - return Ok(Some(WStr(from_hex(&hex)?))); - } - return Err(format!("expected string, got 'x' in {ctx:?}")); - } - Some('"') => chars.next(), - Some(c) => return Err(format!("expected string, got {c:?} in {ctx:?}")), - }; - let mut buf = String::new(); - loop { - match chars.next() { - Some('\\') => { - let e = chars.next().ok_or("dangling escape")?; - buf.push('\\'); - buf.push(e); - } - Some('"') => break, - Some(c) => buf.push(c), - None => return Err(format!("unterminated string in {ctx:?}")), - } - } - Ok(Some(wstr(&buf, utf8)?)) -} - -fn parse_set_variable(rest: &str, indent: u8) -> std::result::Result { - // [ ]. Drop a trailing `// real` note. Split at the - // top level so annotated operands like `V[3 "Gold · ゴールド"]` stay intact. - let rest = rest.split("//").next().unwrap_or(rest).trim(); - let toks = split_top(rest); - if toks.len() != 3 && toks.len() != 5 { - return Err(format!("SetVariable arity: {rest:?}")); - } - let target = operand(&toks[0])?; - let modify = modify_nibble(&toks[1])?; - let left = operand(&toks[2])?; - let (binop, right) = if toks.len() == 5 { - (binop_nibble(&toks[3])?, operand(&toks[4])?) - } else { - (0, 0) - }; - let arg3 = (modify << 8) | (binop << 12); - Ok(cmd(121, vec![target, left, right, arg3], indent, vec![])) -} - -fn parse_set_string(rest: &str, indent: u8, utf8: bool) -> std::result::Result { - // = "text" - let (target, val) = rest.split_once(" = ").ok_or("SetString missing `=`")?; - let target = operand(target.trim())?; - Ok(cmd( - 122, - vec![target, 0], - indent, - vec![parse_str(val.trim(), utf8)?], - )) -} - -/// StringCondition lhs flag. The rhs is an operand (string-var vs string-var) from the rhs -/// section rather than a literal in `str_args`. Mirrors the renderer's `STRCOND_OPERAND_FLAG`. -const STRCOND_OPERAND_FLAG: u32 = 0x0100_0000; - -/// Strip a trailing ` @b:HEX` blob suffix carried by v3.5 block openers with a non-empty -/// per-command blob, returning the cleaned line and the decoded blob. -pub fn split_blob_suffix(line: &str) -> (&str, Option>) { - if let Some(idx) = line.rfind(" @b:") { - let h = &line[idx + 4..]; - if let Ok(bytes) = from_hex(h) { - if !bytes.is_empty() { - return (&line[..idx], Some(bytes)); - } - } - } - (line, None) -} - -/// Split a StringCondition group on its comparison operator into `(left, type, right)`. Types: -/// `eq`=0, `ne`=1, `contains`=2, `!contains`=3, `strcmp`=N. Picks the earliest operator so a -/// token inside a right-hand string literal, such as `A eq "x contains y"`, cannot hijack the -/// split. -fn split_strcmp(group: &str) -> Option<(&str, u32, &str)> { - let mut best: Option<(usize, usize, u32)> = None; // (pos, token_len, type) - for (tok, ty) in [ - (" eq ", 0u32), - (" ne ", 1), - (" contains ", 2), - (" !contains ", 3), - ] { - if let Some(pos) = group.find(tok) { - if best.map_or(true, |(bp, _, _)| pos < bp) { - best = Some((pos, tok.len(), ty)); - } - } - } - if let Some((pos, len, ty)) = best { - return Some((&group[..pos], ty, &group[pos + len..])); - } - // strcmp fallback for unknown comparison types. - let pos = group.find(" strcmp")?; - let after = &group[pos + " strcmp".len()..]; - let sp = after.find(' ')?; - let ty = after[..sp].parse::().ok()?; - Some((&group[..pos], ty, &after[sp + 1..])) -} - -/// Build a VariableCondition (111) or StringCondition (112) from the readable `when` conditions. -/// A string comparison operator (`eq`, `ne`, `contains`, `!contains`, `strcmp`) selects 112. -/// Its layout is `[header][lhs×n][rhs×k]` where each lhs packs the operand (low 24 bits), the -/// variable-right flag (bit 24), and the comparison type (high nibble). -pub fn build_condition( - conds: &[&str], - is_and: bool, - indent: u8, - blob: Option>, - utf8: bool, -) -> Result { - // AND mode renders as a single `when` joined by ` && `. OR mode is one `when` per group. - let groups: Vec<&str> = if is_and { - conds - .first() - .map(|c| c.split(" && ").collect()) - .unwrap_or_default() - } else { - conds.to_vec() - }; - let n = groups.len() as u32; - let header = n | if is_and { 0x10 } else { 0 }; - - let is_string = groups.iter().any(|g| split_strcmp(g).is_some()); - let mut c = if is_string { - let mut lhs = Vec::new(); - let mut rhs = Vec::new(); - let mut strs: Vec = vec![WStr(vec![0]); 4]; // group-indexed literal slots - for (g, group) in groups.iter().enumerate() { - let (l, ty, r) = split_strcmp(group) - .ok_or_else(|| Error::invalid(format!("bad string condition: {group:?}")))?; - let l_op = operand(l.trim()).map_err(Error::invalid)?; - let cmp_bits = ty << 28; // comparison type in the high nibble - let r = r.trim(); - if r.starts_with('"') || r.starts_with("x\"") { - lhs.push(l_op | cmp_bits); - // The engine stores exactly 4 group-indexed literal slots. Guard the index so a - // malformed condition with more than 4 string groups errors instead of panicking. - if g >= strs.len() { - return Err(Error::invalid(format!( - "string condition has too many string groups ({})", - groups.len() - ))); - } - strs[g] = parse_str(r, utf8).map_err(Error::invalid)?; - } else { - lhs.push(l_op | STRCOND_OPERAND_FLAG | cmp_bits); - rhs.push(operand(r).map_err(Error::invalid)?); - } - } - let mut ints = vec![header]; - ints.extend(lhs); - ints.extend(rhs); - cmd(112, ints, indent, strs) - } else { - let mut args = vec![header]; - for g in groups { - let (left, right, op) = parse_compare(g).map_err(Error::invalid)?; - args.push(left); - args.push(right); - args.push(op); - } - cmd(111, args, indent, vec![]) - }; - c.v35_blob = blob; - Ok(c) -} - -pub fn build_loop(line: &str, indent: u8) -> Result { - let (line, blob) = split_blob_suffix(line); - let mut c = if line == "loop {" { - cmd(170, vec![], indent, vec![]) - } else if let Some(inner) = line - .strip_prefix("loop ") - .and_then(|s| s.strip_suffix(" times {")) - { - let n = operand(inner.trim()).map_err(Error::invalid)?; - cmd(179, vec![n], indent, vec![]) - } else { - return Err(Error::invalid(format!("bad loop opener: {line:?}"))); - }; - c.v35_blob = blob; - Ok(c) -} - -pub fn build_choices(line: &str, indent: u8, utf8: bool) -> Result { - // choose ["a", "b"] { - let (line, blob) = split_blob_suffix(line); - let rest = line - .strip_prefix("choose ") - .and_then(|s| s.strip_suffix(" {")) - .ok_or_else(|| Error::invalid(format!("bad choose opener: {line:?}")))?; - let (arg0_s, opts) = rest - .split_once(" [") - .ok_or_else(|| Error::invalid(format!("bad choose opener: {line:?}")))?; - let arg0 = arg0_s - .trim() - .parse::() - .map_err(|_| Error::invalid(format!("bad choose arg0: {arg0_s:?}")))?; - let inner = opts - .strip_suffix(']') - .ok_or_else(|| Error::invalid(format!("bad choose options: {opts:?}")))?; - let strs = parse_str_list(inner, utf8).map_err(Error::invalid)?; - let mut c = cmd(102, vec![arg0], indent, strs); - c.v35_blob = blob; - Ok(c) -} - -// --- operand / operator parsing ------------------------------------------------------- - -fn operand(s: &str) -> std::result::Result { - let s = s.trim(); - // `Self*[n]` must be tried before `Self[n]` because of the prefix overlap. - if let Some(n) = bracketed(s, "Self*") { - return Ok(1_000_000 + n); - } - if let Some(n) = bracketed(s, "Self") { - return Ok(1_100_000 + n); - } - if let Some(n) = bracketed(s, "CSelf") { - return Ok(1_600_000 + n); - } - if let Some(n) = bracketed(s, "V") { - return Ok(2_000_000 + n); - } - if let Some(n) = bracketed(s, "S") { - return Ok(3_000_000 + n); - } - if let Some(n) = bracketed(s, "Sys") { - return Ok(9_000_000 + n); - } - if let Some(n) = bracketed(s, "Rand") { - return Ok(8_000_000 + n); - } - // On-map character data: `Chara[m].Field` (9.1M) or `Chara2[m].Field` (9.18M). - if let Some(v) = parse_chara(s, "Chara2", 9_180_000) { - return Ok(v); - } - if let Some(v) = parse_chara(s, "Chara", 9_100_000) { - return Ok(v); - } - // This-event intrinsic data: `ThisEv.Field` or `ThisEv[k]`. - if let Some(v) = parse_this_event(s) { - return Ok(v); - } - // Bare literal, optionally followed by an edit-form ` "label"` annotation. The leading - // numeric token is the authority. - let head = s.split_once(" \"").map(|(h, _)| h).unwrap_or(s); - head.trim() - .parse::() - .map(|v| v as u32) - .map_err(|_| format!("bad operand: {s:?}")) -} - -/// Split a line on whitespace at the top level only. Whitespace inside `[]`/`()` brackets or -/// `"…"` quotes is kept, so an annotated operand like `V[3 "Gold · ゴールド"]` stays one token. -pub(crate) fn split_top(s: &str) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut depth = 0i32; - let mut in_str = false; - for ch in s.chars() { - match ch { - '"' => { - in_str = !in_str; - cur.push(ch); - } - '[' | '(' if !in_str => { - depth += 1; - cur.push(ch); - } - ']' | ')' if !in_str => { - depth -= 1; - cur.push(ch); - } - c if c.is_whitespace() && !in_str && depth == 0 => { - if !cur.is_empty() { - out.push(std::mem::take(&mut cur)); - } - } - c => cur.push(c), - } - } - if !cur.is_empty() { - out.push(cur); - } - out -} - -/// Parse a character-data reference `prefix[member].Field` back to its operand id. -fn parse_chara(s: &str, prefix: &str, base: u32) -> Option { - let rest = s.strip_prefix(prefix)?.strip_prefix('[')?; - let (member_s, tail) = rest.split_once(']')?; - let member: u32 = member_s.parse().ok()?; - let field = tail.strip_prefix('.')?; - let f = crate::wolfscript::ops::chara_field_index(field) - .or_else(|| field.strip_prefix('f').and_then(|d| d.parse().ok()))?; - Some(base + member * 10 + f) -} - -/// Parse a this-event reference `ThisEv.Field` or `ThisEv[k]` back to its operand id. -fn parse_this_event(s: &str) -> Option { - let rest = s.strip_prefix("ThisEv")?; - if let Some(field) = rest.strip_prefix('.') { - return crate::wolfscript::ops::chara_field_index(field).map(|f| 1_000_000 + f); - } - let inner = rest.strip_prefix('[')?.strip_suffix(']')?; - inner.parse::().ok().map(|k| 1_000_000 + k) -} - -fn bracketed(s: &str, sigil: &str) -> Option { - let inner = s - .strip_prefix(sigil)? - .strip_prefix('[')? - .strip_suffix(']')?; - // The edit form anchors on the index and may carry a ` "label"` annotation inside the - // brackets, as in `V[3 "Gold · ゴールド"]`. Take the leading numeric token, ignore the rest. - let head = inner.split_once(' ').map(|(h, _)| h).unwrap_or(inner); - head.trim().parse().ok() -} - -fn parse_compare(s: &str) -> std::result::Result<(u32, u32, u32), String> { - // Longest symbols first so `<=` and `>=` win over `<` and `>`. Each symbol can carry a - // `~` high-nibble suffix, the per-term flag, so `==~1` becomes op 0x12. - for (sym, code) in [ - ("<=", 0u32), - (">=", 1), - ("==", 2), - ("!=", 3), - ("<", 4), - (">", 5), - ("&", 6), - ] { - // Flagged form: ` sym~ `. - if let Some((l, rest)) = s.split_once(&format!(" {sym}~")) { - if let Some((flags_s, r)) = rest.split_once(' ') { - if let Ok(flags) = flags_s.parse::() { - return Ok((operand(l.trim())?, operand(r.trim())?, code | (flags << 4))); - } - } - } - if let Some((l, r)) = s.split_once(&format!(" {sym} ")) { - return Ok((operand(l.trim())?, operand(r.trim())?, code)); - } - } - // Unknown compare op rendered as ` cmp ` (reversible for any byte value). - if let Some((l, rest)) = s.split_once(" cmp") { - if let Some((num, r)) = rest.split_once(' ') { - if let Ok(op) = num.parse::() { - return Ok((operand(l.trim())?, operand(r.trim())?, op)); - } - } - } - Err(format!("bad condition: {s:?}")) -} - -fn modify_nibble(s: &str) -> std::result::Result { - Ok(match s { - "=" => 0, - "+=" => 1, - "-=" => 2, - "*=" => 3, - "/=" => 4, - "%=" => 5, - _ => return Err(format!("bad modify op: {s:?}")), - }) -} - -fn binop_nibble(s: &str) -> std::result::Result { - Ok(match s { - "+" => 0, - "-" => 1, - "*" => 2, - "%" => 3, - "~" => 4, - "&" => 6, - "|" => 7, - "^" => 8, - "<<" => 9, - _ => return Err(format!("bad binop: {s:?}")), - }) -} - -// --- string parsing ------------------------------------------------------------------- - -/// Encode (already-quoted-stripped) literal text into a Wolf string in the file's encoding. -fn wstr(s: &str, utf8: bool) -> std::result::Result { - text_to_wstr(&unescape(s), utf8) -} - -/// Parse exactly one string literal (`"text"` or `x"HEX"`). -fn parse_str(s: &str, utf8: bool) -> std::result::Result { - let s = s.trim(); - let mut chars = s.chars().peekable(); - let w = read_one_literal(&mut chars, s, utf8)? - .ok_or_else(|| format!("expected string literal: {s:?}"))?; - if chars.next().is_some() { - return Err(format!("trailing content after string literal: {s:?}")); - } - Ok(w) -} - -fn parse_str_list(s: &str, utf8: bool) -> std::result::Result, String> { - parse_quoted_seq(s, utf8) -} - -fn unescape(s: &str) -> String { - let mut out = String::new(); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c != '\\' { - out.push(c); - continue; - } - match chars.next() { - Some('n') => out.push('\n'), - Some('r') => out.push('\r'), - Some('t') => out.push('\t'), - Some('"') => out.push('"'), - Some('\\') => out.push('\\'), - Some(other) => { - out.push('\\'); - out.push(other); - } - None => out.push('\\'), - } - } - out -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/compile/lex.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/compile/lex.rs deleted file mode 100644 index f5f38a6..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/compile/lex.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Line lexer: trimmed, non-empty lines with their source line numbers. The decompiler indents -//! by depth, but the parser reconstructs `indent` from block nesting, so leading whitespace is -//! discarded here. - -pub struct Line { - pub no: u32, - pub text: String, -} - -impl std::fmt::Debug for Line { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{:?}", self.no, self.text) - } -} - -pub fn lines(text: &str) -> Vec { - text.lines() - .enumerate() - .filter_map(|(i, raw)| { - // Trim only the leading whitespace (the indent). Trailing spaces are significant - // inside, for example, comment text. - let t = raw.trim_start(); - if t.is_empty() { - None - } else { - Some(Line { - no: (i + 1) as u32, - text: t.to_string(), - }) - } - }) - .collect() -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/compile/mod.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/compile/mod.rs deleted file mode 100644 index 159f5ad..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/compile/mod.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! The recompiler: parse WolfScript text back into the byte-faithful `RawCommand` stream. -//! -//! This is the inverse of [`crate::wolfscript`]. It reconstructs exactly what the decompiler -//! dropped for readability: -//! * Marker commands. `branch`/`when`/`else`/`}` become VariableCondition/ChoiceCase/ -//! ElseCase/BranchEnd. `loop`/`}` become StartLoop/LoopEnd. `choose`/`case`/`}` become -//! Choices/ChoiceCase/CancelCase/BranchEnd. -//! * Indent bytes, reconstructed from block nesting depth (Wolf's indent equals depth). -//! * Suppressed `Blank` slots. The editor keeps one trailing empty command at the end of every -//! block body, re-inserted on compile. -//! -//! Correctness is driven by the round-trip gate `parse(decompile(cmds)) == cmds`. Operands are -//! parsed in their numeric form here (`Self[n]`, `V[n]`, `CSelf[n]`, literal). Name resolution -//! (the reverse glossary/symbol lookup) layers on top. - -pub(crate) mod cmd; -mod lex; - -use wolf_formats::command::RawCommand; -use wolf_formats::{Error, Result}; - -use lex::Line; - -/// Parse a WolfScript command program (numeric operand forms) into a `RawCommand` stream. -/// Strings are encoded as UTF-8. Use [`compile_commands_enc`] for Shift-JIS files. -pub fn compile_commands(text: &str) -> Result> { - compile_commands_enc(text, true) -} - -/// Parse a WolfScript command program, encoding string literals in the file's encoding (`utf8`). -/// The exact inverse of [`crate::wolfscript::decompile_commands_enc`]. -pub fn compile_commands_enc(text: &str, utf8: bool) -> Result> { - let lines = lex::lines(text); - - // A leading `@v35` directive marks a Wolf Pro v3.5 program: every command carries a trailing - // blob, empty unless an `@raw v:` form supplies one. Strip the directive, parse normally, then - // default every command's blob to empty. - let v35 = lines.first().map(|l| l.text == "@v35").unwrap_or(false); - let body = if v35 { &lines[1..] } else { &lines[..] }; - - let mut p = Parser { - lines: body, - pos: 0, - utf8, - }; - let mut out = Vec::new(); - p.block_body(0, &mut out)?; - if p.pos != p.lines.len() { - return Err(Error::invalid(format!( - "trailing content at line {}: {:?}", - p.lines[p.pos].no, p.lines[p.pos].text - ))); - } - - if v35 { - for c in &mut out { - if c.v35_blob.is_none() { - c.v35_blob = Some(Vec::new()); - } - } - } - Ok(out) -} - -struct Parser<'a> { - lines: &'a [Line], - pos: usize, - utf8: bool, -} - -impl<'a> Parser<'a> { - fn peek(&self) -> Option<&'a Line> { - self.lines.get(self.pos) - } - - /// Parse the statements of one block body at `depth`, appending commands plus a trailing - /// Blank to `out`. Stops at a line that closes or continues the parent block (`}`, `} when`, - /// `} else`, `} case`, `} cancel`) without consuming it. - fn block_body(&mut self, depth: u8, out: &mut Vec) -> Result<()> { - loop { - let Some(line) = self.peek() else { break }; - let t = line.text.as_str(); - if t == "}" || t.starts_with("} ") { - break; - } - self.statement(depth, out)?; - } - // Wolf keeps one empty trailing command at the end of every block body. - out.push(blank(depth)); - Ok(()) - } - - fn statement(&mut self, depth: u8, out: &mut Vec) -> Result<()> { - let line = self.peek().unwrap(); - let t = line.text.clone(); - let no = line.no; - - // A block opener may carry an inline ` @b:HEX` blob suffix (rare, v3.5 only). - let (head, opener_blob) = cmd::split_blob_suffix(&t); - if head == "branch {" { - self.pos += 1; - return self.conditional(depth, out, no, false, opener_blob); - } - if head == "branch all {" { - self.pos += 1; - return self.conditional(depth, out, no, true, opener_blob); - } - if head == "loop {" || head.starts_with("loop ") && head.ends_with("times {") { - return self.loop_block(depth, out); - } - if head.starts_with("choose ") && head.ends_with('{') { - return self.choose_block(depth, out); - } - - // A leaf command. - let cmd = cmd::parse_leaf(&t, depth, self.utf8) - .map_err(|e| Error::invalid(format!("line {no}: {e}")))?; - self.pos += 1; - out.push(cmd); - Ok(()) - } - - /// `branch { } when (cond) { … } else { … }` becomes a VariableCondition/StringCondition - /// opener plus ChoiceCase/ElseCase markers plus BranchEnd. - fn conditional( - &mut self, - depth: u8, - out: &mut Vec, - open_no: u32, - is_and: bool, - blob: Option>, - ) -> Result<()> { - let mut whens: Vec<(String, Vec)> = Vec::new(); - let mut else_body: Option> = None; - - loop { - let Some(line) = self.peek() else { - return Err(Error::invalid(format!( - "line {open_no}: unterminated branch" - ))); - }; - let t = line.text.clone(); - if t == "}" { - self.pos += 1; - break; - } else if let Some(rest) = t - .strip_prefix("} when (") - .and_then(|s| s.strip_suffix(") {")) - { - self.pos += 1; - let mut body = Vec::new(); - self.block_body(depth + 1, &mut body)?; - whens.push((rest.to_string(), body)); - } else if t == "} else {" { - self.pos += 1; - let mut body = Vec::new(); - self.block_body(depth + 1, &mut body)?; - else_body = Some(body); - } else { - return Err(Error::invalid(format!( - "line {}: unexpected in branch: {t:?}", - line.no - ))); - } - } - - let opener = cmd::build_condition( - &whens.iter().map(|(c, _)| c.as_str()).collect::>(), - is_and, - depth, - blob, - self.utf8, - )?; - out.push(opener); - for (i, (_, body)) in whens.into_iter().enumerate() { - out.push(choice_case((i + 1) as u32, depth)); - out.extend(body); - } - if let Some(body) = else_body { - out.push(cmd_with(420, vec![0], depth)); // ElseCase (arg0 = 0) - out.extend(body); - } - out.push(marker(499, depth)); // BranchEnd - Ok(()) - } - - fn loop_block(&mut self, depth: u8, out: &mut Vec) -> Result<()> { - let line = self.peek().unwrap().text.clone(); - self.pos += 1; - let opener = cmd::build_loop(&line, depth)?; - out.push(opener); - let mut body = Vec::new(); - self.block_body(depth + 1, &mut body)?; - out.extend(body); - // consume the closing `}` - self.expect_close()?; - out.push(marker(498, depth)); // LoopEnd - Ok(()) - } - - fn choose_block(&mut self, depth: u8, out: &mut Vec) -> Result<()> { - let line = self.peek().unwrap().text.clone(); - self.pos += 1; - let opener = cmd::build_choices(&line, depth, self.utf8)?; - out.push(opener); - - loop { - let Some(l) = self.peek() else { - return Err(Error::invalid("unterminated choose".to_string())); - }; - let t = l.text.clone(); - if t == "}" { - self.pos += 1; - break; - } - // `} case {`, the special `} case* {`, or `} cancel {`. - let (cid, arg0) = if let Some(a) = t.strip_prefix("} case* ") { - (402, parse_case_arg(a)?) - } else if let Some(a) = t.strip_prefix("} case ") { - (401, parse_case_arg(a)?) - } else if t == "} cancel {" { - (421, 0) - } else { - return Err(Error::invalid(format!("unexpected in choose: {t:?}"))); - }; - self.pos += 1; - out.push(cmd_with(cid, vec![arg0], depth)); - let mut body = Vec::new(); - self.block_body(depth + 1, &mut body)?; - out.extend(body); - } - out.push(marker(499, depth)); // BranchEnd - Ok(()) - } - - fn expect_close(&mut self) -> Result<()> { - match self.peek() { - Some(l) if l.text == "}" => { - self.pos += 1; - Ok(()) - } - other => Err(Error::invalid(format!("expected `}}`, got {other:?}"))), - } - } -} - -fn blank(indent: u8) -> RawCommand { - RawCommand { - cid: 0, - int_args: Vec::new(), - indent, - str_args: Vec::new(), - term: 0, - move_route: None, - v35_blob: None, - } -} - -fn marker(cid: u32, indent: u8) -> RawCommand { - RawCommand { - cid, - int_args: Vec::new(), - indent, - str_args: Vec::new(), - term: 0, - move_route: None, - v35_blob: None, - } -} - -fn cmd_with(cid: u32, ints: Vec, indent: u8) -> RawCommand { - RawCommand { - cid, - int_args: ints, - indent, - str_args: Vec::new(), - term: 0, - move_route: None, - v35_blob: None, - } -} - -/// Extract the leading case index from ` {` or ` { # label`. -fn parse_case_arg(a: &str) -> Result { - a.split_whitespace() - .next() - .and_then(|s| s.parse().ok()) - .ok_or_else(|| Error::invalid(format!("bad case arg: {a:?}"))) -} - -fn choice_case(idx: u32, indent: u8) -> RawCommand { - RawCommand { - cid: 401, - int_args: vec![idx], - indent, - str_args: Vec::new(), - term: 0, - move_route: None, - v35_blob: None, - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/db_edit.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/db_edit.rs deleted file mode 100644 index 95244ae..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/db_edit.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Apply edits from a [`database_to_json`](crate::database_to_json) export back onto a -//! [`Database`], byte-exact. Only values (cell ints and strings) and row names are editable. The -//! schema (types, fields, kinds, counts, structure) is fixed and any change to it is rejected. -//! Unchanged cells are never re-encoded, so untouched rows re-serialize byte-identical via -//! [`Database::write`](wolf_formats::database::Database::write). Only the cells you actually -//! changed differ. - -use std::collections::HashMap; - -use serde_json::Value; -use wolf_formats::database::Database; - -use crate::json::value_slots; -use crate::text::{decode_wstr, text_to_wstr}; - -/// Apply the edited JSON onto `db` in place. Returns the number of changed cells and names. -/// Errors on any schema or structure mismatch, an out-of-range id, an ambiguous field name, or a -/// value that cannot be encoded in the database's text encoding. On error, any edits committed -/// before the failure point remain applied. -pub fn apply_database_edit(json: &str, db: &mut Database) -> Result { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let utf8 = db.utf8; - let types = root - .get("types") - .and_then(Value::as_array) - .ok_or("edited JSON has no `types` array")?; - - let type_count = db.types.len(); - let mut changed = 0usize; - for tj in types { - let ti = json_id(tj, "type")?; - let dt = db - .types - .get_mut(ti) - .ok_or_else(|| format!("type id {ti} is out of range (base has {type_count} types)"))?; - - // Schema-integrity guard, only when the doc carries a `fields` array (a full export). - // Edits are patch-style: a `rows` array may include just the rows you touched. Others are - // left as-is, never deleted, and an out-of-range row id is rejected below. - if let Some(fields) = tj.get("fields").and_then(Value::as_array) { - if fields.len() != dt.fields.len() { - return Err(format!( - "type {ti}: field count changed ({} edited vs {} base) - schema edits are not allowed", - fields.len(), - dt.fields.len() - )); - } - } - - // Map each value key to (is_string, value-slot index), derived identically to the JSON - // export (unique field name, else `#`), so the keys always match. - let fields_size = (dt.dat_fields_size as usize).min(dt.fields.len()); - let by_key: HashMap = value_slots(dt, fields_size, utf8) - .into_iter() - .map(|(k, is_str, slot)| (k, (is_str, slot))) - .collect(); - - let Some(rows) = tj.get("rows").and_then(Value::as_array) else { - continue; - }; - for rj in rows { - let ri = json_id(rj, "row")?; - let row = dt - .data - .get_mut(ri) - .ok_or_else(|| format!("type {ti}: row id {ri} is out of range"))?; - - // Row display name lives in the .project half. - if let Some(nm) = rj.get("name").and_then(Value::as_str) { - if decode_wstr(&row.name, utf8) != nm { - row.name = text_to_wstr(nm, utf8) - .map_err(|e| format!("type {ti} row {ri} name: {e}"))?; - changed += 1; - } - } - - let Some(values) = rj.get("values").and_then(Value::as_object) else { - continue; - }; - for (key, val) in values { - let &(is_str, slot) = by_key.get(key).ok_or_else(|| { - format!("type {ti} row {ri}: unknown/schema-only field {key:?}") - })?; - if is_str { - let new = val.as_str().ok_or_else(|| { - format!("type {ti} row {ri} field {key:?}: expected a string") - })?; - let cur = row - .string_values - .get(slot) - .map(|w| decode_wstr(w, utf8)) - .unwrap_or_default(); - // Skip unchanged cells to preserve the original bytes exactly. This avoids the - // empty-string and trailing-NUL re-encode ambiguity. - if cur != new { - let w = text_to_wstr(new, utf8) - .map_err(|e| format!("type {ti} row {ri} field {key:?}: {e}"))?; - *row.string_values.get_mut(slot).ok_or_else(|| { - format!("type {ti} row {ri} field {key:?}: value slot missing") - })? = w; - changed += 1; - } - } else { - let new = val.as_i64().ok_or_else(|| { - format!("type {ti} row {ri} field {key:?}: expected a number") - })?; - let nv = new as u32; - let cell = row.int_values.get_mut(slot).ok_or_else(|| { - format!("type {ti} row {ri} field {key:?}: value slot missing") - })?; - if *cell != nv { - *cell = nv; - changed += 1; - } - } - } - } - } - Ok(changed) -} - -fn json_id(v: &Value, what: &str) -> Result { - v.get("id") - .and_then(Value::as_u64) - .map(|n| n as usize) - .ok_or_else(|| format!("{what} entry is missing its numeric `id`")) -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/document.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/document.rs deleted file mode 100644 index 33c3882..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/document.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! Whole-file recompile: the document envelope around per-event/page command bodies. -//! -//! [`crate::wolfscript`] renders one command list and [`crate::compile`] parses it back. -//! This layer wraps those so an entire file round-trips. Each event (and map page) is emitted -//! under an identity header (`@commonevent `, or `@event ` plus `@page `), its body -//! produced by the gated numeric renderer [`decompile_commands`]. That renderer is `@raw`-free on -//! the corpus, so the document stays readable yet fully recompilable. -//! -//! Recompile is patch-style. The original file supplies every opaque metadata field (event names, -//! page graphics and conditions, the CommonEvent `unknown1..12` tail). Only each command body is -//! swapped back in, keyed by identity. Files whose code is untouched re-emit byte-for-byte. Edited -//! bodies recompile in place. This sidesteps round-tripping the large metadata through text and is -//! the real edit-to-play mod loop. - -use std::collections::BTreeMap; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; -use wolf_formats::{Error, Result}; - -use crate::compile::compile_commands_enc; -use crate::symbols::SymbolTable; -use crate::text::decode_wstr; -use crate::wolfscript::{decompile_commands_annotated, decompile_commands_enc}; - -const CE_HEADER: &str = "# wolf-edit common-events"; -const MAP_HEADER: &str = "# wolf-edit map"; - -// ---------------------------------------------------------------------------- -// CommonEvents -// ---------------------------------------------------------------------------- - -/// Render a CommonEvent file as a recompilable edit document: one `@commonevent ... @end` -/// block per event, body in the gated numeric form. Metadata is not emitted. It is taken from the -/// base file at compile time. -pub fn decompile_common_events_edit(ce: &CommonEventsFile) -> String { - let mut out = format!("{CE_HEADER} (count={})\n\n", ce.events.len()); - for ev in &ce.events { - let name = decode_wstr(&ev.name, ce.utf8); - out.push_str(&format!("@commonevent {}", ev.int_id)); - if !name.is_empty() { - out.push_str(&format!(" # {}", name.replace('\n', " "))); - } - out.push('\n'); - let body = decompile_commands_enc(&ev.commands, ce.utf8); - out.push_str(&body); - if !body.ends_with('\n') { - out.push('\n'); - } - out.push_str("@end\n\n"); - } - out -} - -/// Like [`decompile_common_events_edit`] but in annotate mode. Each command body uses the unified -/// read-plus-edit form (index-anchored bilingual names, `V[3 "Gold · ゴールド"]`), resolved via -/// `symbols`. Recompiles byte-exact through the same [`compile_common_events_edit`], since the -/// parser strips the labels. Names are read-only decoration the recompiler ignores. -pub fn decompile_common_events_edit_annotated( - ce: &CommonEventsFile, - symbols: &SymbolTable, -) -> String { - let mut out = format!("{CE_HEADER} (count={})\n\n", ce.events.len()); - for ev in &ce.events { - let name = decode_wstr(&ev.name, ce.utf8); - out.push_str(&format!("@commonevent {}", ev.int_id)); - if !name.is_empty() { - out.push_str(&format!(" # {}", name.replace('\n', " "))); - } - out.push('\n'); - let body = decompile_commands_annotated(&ev.commands, ce.utf8, symbols, Some(ev.int_id)); - out.push_str(&body); - if !body.ends_with('\n') { - out.push('\n'); - } - out.push_str("@end\n\n"); - } - out -} - -/// Parse an edit document and substitute each event's command body back into `base`, keyed by -/// `int_id`. Events absent from the document keep their original commands, so partial patches are -/// allowed. An `@commonevent` id with no match in `base` is an error. -pub fn compile_common_events_edit(text: &str, base: &mut CommonEventsFile) -> Result<()> { - let blocks = parse_blocks(text, "commonevent")?; - let mut by_id: BTreeMap = BTreeMap::new(); - for (i, ev) in base.events.iter().enumerate() { - if by_id.insert(ev.int_id, i).is_some() { - return Err(Error::invalid(format!( - "base has duplicate common-event id {}; cannot patch by id unambiguously", - ev.int_id - ))); - } - } - for blk in blocks { - let &idx = by_id.get(&blk.id).ok_or_else(|| { - Error::invalid(format!( - "@commonevent {} (line {}) has no match in the base file", - blk.id, blk.line - )) - })?; - let cmds = compile_commands_enc(&blk.body, base.utf8) - .map_err(|e| Error::invalid(format!("@commonevent {}: {e}", blk.id)))?; - base.events[idx].commands = cmds; - } - Ok(()) -} - -// ---------------------------------------------------------------------------- -// Maps -// ---------------------------------------------------------------------------- - -/// Render a Map as a recompilable edit document: `@event ` then one `@page ... @endpage` -/// per page, each body in the gated numeric form. Coordinates and name are a comment for the -/// reader. Truth comes from the base file at compile time. -pub fn decompile_map_edit(map: &Map) -> String { - let mut out = format!( - "{MAP_HEADER} (version=0x{:x} {}x{} events={})\n\n", - map.version, - map.width, - map.height, - map.events.len() - ); - for ev in &map.events { - let name = decode_wstr(&ev.name, map.utf8); - out.push_str(&format!("@event {}", ev.id)); - if !name.is_empty() { - out.push_str(&format!( - " # {} @({},{})", - name.replace('\n', " "), - ev.x, - ev.y - )); - } - out.push('\n'); - for (pi, page) in ev.pages.iter().enumerate() { - out.push_str(&format!("@page {pi}\n")); - let body = decompile_commands_enc(&page.commands, map.utf8); - out.push_str(&body); - if !body.ends_with('\n') { - out.push('\n'); - } - out.push_str("@endpage\n"); - } - out.push_str("@endevent\n\n"); - } - out -} - -/// Like [`decompile_map_edit`] but in annotate mode (index-anchored bilingual names), resolved -/// via `symbols`. Recompiles byte-exact through [`compile_map_edit`]. -pub fn decompile_map_edit_annotated(map: &Map, symbols: &SymbolTable) -> String { - let mut out = format!( - "{MAP_HEADER} (version=0x{:x} {}x{} events={})\n\n", - map.version, - map.width, - map.height, - map.events.len() - ); - for ev in &map.events { - let name = decode_wstr(&ev.name, map.utf8); - out.push_str(&format!("@event {}", ev.id)); - if !name.is_empty() { - out.push_str(&format!( - " # {} @({},{})", - name.replace('\n', " "), - ev.x, - ev.y - )); - } - out.push('\n'); - for (pi, page) in ev.pages.iter().enumerate() { - out.push_str(&format!("@page {pi}\n")); - let body = decompile_commands_annotated(&page.commands, map.utf8, symbols, None); - out.push_str(&body); - if !body.ends_with('\n') { - out.push('\n'); - } - out.push_str("@endpage\n"); - } - out.push_str("@endevent\n\n"); - } - out -} - -/// Parse a map edit document and substitute each page's command body back into `base`, keyed by -/// event `id` plus page index. Pages absent from the document keep their commands. -pub fn compile_map_edit(text: &str, base: &mut Map) -> Result<()> { - let events = parse_map_blocks(text)?; - let mut by_id: BTreeMap = BTreeMap::new(); - for (i, ev) in base.events.iter().enumerate() { - if by_id.insert(ev.id, i).is_some() { - return Err(Error::invalid(format!( - "base map has duplicate event id {}; cannot patch by id unambiguously", - ev.id - ))); - } - } - for ev in events { - let &idx = by_id.get(&ev.id).ok_or_else(|| { - Error::invalid(format!( - "@event {} (line {}) has no match in the base map", - ev.id, ev.line - )) - })?; - for page in ev.pages { - let page_count = base.events[idx].pages.len(); - let target = base.events[idx].pages.get_mut(page.index).ok_or_else(|| { - Error::invalid(format!( - "@event {} @page {} out of range (map event has {page_count} pages)", - ev.id, page.index, - )) - })?; - let cmds = compile_commands_enc(&page.body, base.utf8).map_err(|e| { - Error::invalid(format!("@event {} @page {}: {e}", ev.id, page.index)) - })?; - target.commands = cmds; - } - } - Ok(()) -} - -// ---------------------------------------------------------------------------- -// Document parsing (delimiter-based, so command-body braces never confuse it) -// ---------------------------------------------------------------------------- - -struct Block { - id: u32, - line: u32, - body: String, -} - -/// Parse `@ ...` and `@end` blocks. Lines outside a block must be blank or a `#` -/// comment. The body between the header and `@end` is returned verbatim. -fn parse_blocks(text: &str, kind: &str) -> Result> { - let opener = format!("@{kind} "); - let mut blocks = Vec::new(); - let mut cur: Option = None; - - for (i, raw) in text.lines().enumerate() { - let no = (i + 1) as u32; - let t = raw.trim(); - match &mut cur { - None => { - if let Some(rest) = t.strip_prefix(&opener) { - cur = Some(Block { - id: parse_id(rest, no)?, - line: no, - body: String::new(), - }); - } else if t.is_empty() || t.starts_with('#') { - // header, blank, or comment between blocks - } else { - return Err(Error::invalid(format!( - "line {no}: expected `{opener}...`, got {t:?}" - ))); - } - } - Some(b) => { - if t == "@end" { - blocks.push(cur.take().unwrap()); - } else { - b.body.push_str(raw); - b.body.push('\n'); - } - } - } - } - if cur.is_some() { - return Err(Error::invalid("unterminated @-block (missing @end)")); - } - Ok(blocks) -} - -struct MapEventBlock { - id: u32, - line: u32, - pages: Vec, -} - -struct MapPageBlock { - index: usize, - body: String, -} - -/// Parse `@event ` / `@page ` / `@endpage` / `@endevent` nesting. -fn parse_map_blocks(text: &str) -> Result> { - let mut events: Vec = Vec::new(); - let mut page: Option = None; - - for (i, raw) in text.lines().enumerate() { - let no = (i + 1) as u32; - let t = raw.trim(); - - if let Some(p) = &mut page { - if t == "@endpage" { - let done = page.take().unwrap(); - events - .last_mut() - .ok_or_else(|| Error::invalid(format!("line {no}: @endpage outside @event")))? - .pages - .push(done); - } else if t == "@endevent" || t.starts_with("@event ") || t.starts_with("@page ") { - // A structural keyword inside a page body means the page was never closed. Fail - // loudly rather than swallow the rest of the document into this page. - return Err(Error::invalid(format!( - "line {no}: {t:?} inside a page body (missing @endpage?)" - ))); - } else { - p.body.push_str(raw); - p.body.push('\n'); - } - continue; - } - - if let Some(rest) = t.strip_prefix("@event ") { - events.push(MapEventBlock { - id: parse_id(rest, no)?, - line: no, - pages: Vec::new(), - }); - } else if let Some(rest) = t.strip_prefix("@page ") { - let index = parse_id(rest, no)? as usize; - if events.is_empty() { - return Err(Error::invalid(format!("line {no}: @page outside @event"))); - } - page = Some(MapPageBlock { - index, - body: String::new(), - }); - } else if t == "@endevent" || t.is_empty() || t.starts_with('#') { - // event terminator, header, blank, or comment - } else { - return Err(Error::invalid(format!( - "line {no}: unexpected outside a page body: {t:?}" - ))); - } - } - if page.is_some() { - return Err(Error::invalid("unterminated @page (missing @endpage)")); - } - Ok(events) -} - -/// Read the leading integer id from a header tail, such as `5` or `5 # name`. -fn parse_id(rest: &str, no: u32) -> Result { - rest.split_whitespace() - .next() - .and_then(|s| s.parse().ok()) - .ok_or_else(|| Error::invalid(format!("line {no}: missing/invalid id in {rest:?}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn map_block_well_formed() { - let doc = "# wolf-edit map\n@event 3\n@page 0\nMessage \"hi\"\n@endpage\n@endevent\n"; - let evs = parse_map_blocks(doc).expect("parse"); - assert_eq!(evs.len(), 1); - assert_eq!(evs[0].id, 3); - assert_eq!(evs[0].pages.len(), 1); - assert_eq!(evs[0].pages[0].index, 0); - } - - #[test] - fn missing_endpage_is_an_error_not_a_swallow() { - // The `@event 4` must not be swallowed into page 0's body. - let doc = "@event 3\n@page 0\nMessage \"hi\"\n@event 4\n@page 0\n@endpage\n@endevent\n"; - assert!( - parse_map_blocks(doc).is_err(), - "missing @endpage must error" - ); - } - - #[test] - fn unterminated_page_at_eof_errors() { - let doc = "@event 3\n@page 0\nMessage \"hi\"\n"; - assert!(parse_map_blocks(doc).is_err()); - } - - #[test] - fn ce_blocks_parse_and_reject_stray_lines() { - let blocks = parse_blocks("@commonevent 7\nMessage \"x\"\n@end\n", "commonevent").unwrap(); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].id, 7); - // A non-`@commonevent` line between blocks is rejected. - assert!(parse_blocks("garbage\n@commonevent 1\n@end\n", "commonevent").is_err()); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/json.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/json.rs deleted file mode 100644 index 5626783..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/json.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! Human-readable JSON export of a [`Database`] (the tabular `.project` plus `.dat` data). -//! -//! Databases do not fit the WolfScript code view, so they export to JSON for inspection: each -//! type's schema (fields and kind) and its data rows, with each row's values mapped back to field -//! names. The byte-faithful [`wolf_formats::database::Database`] stays the round-trip authority. -//! This is a read-only projection for readability, with values shown as signed ints or decoded -//! strings. - -use std::collections::HashMap; -use std::fmt::Write; - -use wolf_formats::database::{Database, DbData, DbType}; - -use crate::text::decode_wstr; - -/// Render a database as pretty-printed JSON. `kind` is a label such as `"UDB"`, `"CDB"`, or -/// `"SDB"`. `glossary` is the engine-text map (JP to EN). Every structural name it covers (a type -/// or field, and the variable-name rows of the System DB) gets a `"name_en"` field next to its -/// Japanese `"name"`, so a modder reading the English code can look the entry up in either -/// language. Player-facing content such as item and skill row names is absent from the glossary -/// and stays Japanese-only, which matches how it reads in the code. -pub fn database_to_json(db: &Database, kind: &str, glossary: &HashMap) -> String { - let utf8 = db.utf8; - let mut s = String::new(); - s.push_str("{\n"); - s.push_str(&format!(" \"kind\": {},\n", jstr(kind))); - s.push_str(&format!(" \"version\": \"0x{:02x}\",\n", db.dat_version)); - let enc = if utf8 { "UTF-8" } else { "Shift-JIS" }; - s.push_str(&format!(" \"encoding\": {},\n", jstr(enc))); - s.push_str(&format!(" \"typeCount\": {},\n", db.types.len())); - s.push_str(" \"types\": [\n"); - - for (ti, t) in db.types.iter().enumerate() { - type_to_json(&mut s, ti, t, utf8, glossary); - s.push_str(if ti + 1 == db.types.len() { - "\n" - } else { - ",\n" - }); - } - s.push_str(" ]\n}\n"); - s -} - -/// Render `"name": ""`, plus `, "name_en": ""` when the glossary translates it. Used for -/// every name field so the JSON is searchable in either language. -fn name_pair(jp: &str, glossary: &HashMap) -> String { - match glossary.get(jp) { - Some(en) if en != jp => format!("{}, \"name_en\": {}", jstr(jp), jstr(en)), - _ => jstr(jp), - } -} - -fn type_to_json( - s: &mut String, - id: usize, - t: &DbType, - utf8: bool, - glossary: &HashMap, -) { - let fields_size = (t.dat_fields_size as usize).min(t.fields.len()); - s.push_str(" {\n"); - let _ = write!(s, " \"id\": {id},\n"); - let _ = write!( - s, - " \"name\": {},\n", - name_pair(&decode_wstr(&t.name, utf8), glossary) - ); - let _ = write!( - s, - " \"description\": {},\n", - jstr(&decode_wstr(&t.description, utf8)) - ); - - // Schema fields: name (plus name_en) and kind. Only the first `fields_size` carry row data. - s.push_str(" \"fields\": ["); - for (fi, f) in t.fields.iter().enumerate() { - let kind = if f.is_string() { "string" } else { "int" }; - let has_data = fi < fields_size; - let _ = write!( - s, - "{}{{\"id\": {fi}, \"name\": {}, \"kind\": {}{}}}", - if fi == 0 { "" } else { ", " }, - name_pair(&decode_wstr(&f.name, utf8), glossary), - jstr(kind), - if has_data { - "" - } else { - ", \"schemaOnly\": true" - } - ); - } - s.push_str("],\n"); - - // Data rows: id, name (plus name_en), and values mapped to field names. - let _ = write!(s, " \"rowCount\": {},\n", t.data.len()); - s.push_str(" \"rows\": [\n"); - for (di, d) in t.data.iter().enumerate() { - row_to_json(s, di, t, d, fields_size, utf8, glossary); - s.push_str(if di + 1 == t.data.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n }"); -} - -/// The per-row value slots of a type, in on-disk order: `(jsonKey, is_string, slot_index)` for -/// each of the first `fields_size` fields. The key is the field's decoded name when that name is -/// unique within the type, else a stable `#`. That covers empty and duplicated names -/// such as formula columns. The slot index points into the row's `int_values` or `string_values` -/// (all int fields first, then all string fields). Shared by the JSON export and -/// [`crate::db_edit`] so the keys match exactly. `slot` indexes `string_values` when `is_string`, -/// otherwise `int_values`. -pub fn value_slots(t: &DbType, fields_size: usize, utf8: bool) -> Vec<(String, bool, usize)> { - let mut counts: HashMap = HashMap::new(); - for fi in 0..fields_size { - let n = decode_wstr(&t.fields[fi].name, utf8); - if !n.is_empty() { - *counts.entry(n).or_default() += 1; - } - } - let (mut int_i, mut str_i) = (0usize, 0usize); - let mut out = Vec::with_capacity(fields_size); - for fi in 0..fields_size { - let f = &t.fields[fi]; - let n = decode_wstr(&f.name, utf8); - let key = if n.is_empty() || counts.get(&n).copied().unwrap_or(0) > 1 { - format!("#{fi}") - } else { - n - }; - let is_str = f.is_string(); - let slot = if is_str { - let s = str_i; - str_i += 1; - s - } else { - let s = int_i; - int_i += 1; - s - }; - out.push((key, is_str, slot)); - } - out -} - -fn row_to_json( - s: &mut String, - id: usize, - t: &DbType, - d: &DbData, - fields_size: usize, - utf8: bool, - glossary: &HashMap, -) { - let _ = write!( - s, - " {{\"id\": {id}, \"name\": {}", - name_pair(&decode_wstr(&d.name, utf8), glossary) - ); - s.push_str(", \"values\": {"); - let mut first = true; - for (key, is_str, slot) in value_slots(t, fields_size, utf8) { - if !first { - s.push_str(", "); - } - first = false; - if is_str { - let v = d - .string_values - .get(slot) - .map(|w| decode_wstr(w, utf8)) - .unwrap_or_default(); - let _ = write!(s, "{}: {}", jstr(&key), jstr(&v)); - } else { - let v = d.int_values.get(slot).copied().unwrap_or(0) as i32; - let _ = write!(s, "{}: {}", jstr(&key), v); - } - } - s.push_str("}}"); -} - -/// JSON string literal with escaping. -fn jstr(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); - out -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/lib.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/lib.rs deleted file mode 100644 index a5cdce1..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/lib.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! `wolf-decompiler` turns the byte-faithful command programs from `wolf-formats` -//! into readable, editable WolfScript. -//! -//! Provides the decompile (render) direction. The byte-exact `RawCommand` stays the -//! round-trip authority. The parse/recompile direction is built on top of it. - -#![forbid(unsafe_code)] - -pub mod compile; -pub mod db_edit; -pub mod document; -pub mod json; -pub mod merge; -pub mod names; -pub mod save; -pub mod save_pro; -pub mod spec; -pub mod strings; -pub mod symbols; -pub mod text; -pub mod txt_events; -pub mod wolfscript; - -pub use compile::{compile_commands, compile_commands_enc}; -pub use db_edit::apply_database_edit; -pub use document::{ - compile_common_events_edit, compile_map_edit, decompile_common_events_edit, - decompile_common_events_edit_annotated, decompile_map_edit, decompile_map_edit_annotated, -}; -pub use json::database_to_json; -pub use merge::{apply_memory, build_memory, dropped_sources, MergeStats}; -pub use names::{check_name_conflicts, extract_db_name_cells, extract_names, inject_names, Conflict}; -pub use save::{ - inspect_save, read_title, read_title_at, update_save, SaveFormat, SaveInfo, SaveUpdateStats, -}; -pub use save_pro::{decrypt_pro, encrypt_pro, is_pro_save}; -pub use strings::{ - apply_game_dat, audit_common_events_to_json, audit_db_to_json, audit_map_to_json, - db_strings_to_json, dump_game_dat, extract_common_events, extract_db_strings, extract_game_dat, - extract_map, inject_common_events, inject_db_strings, inject_game_dat, inject_map, - normalize_en_punct, scenes_to_json, InjectOptions, InjectStats, -}; - -pub use symbols::SymbolTable; -pub use txt_events::{extract_txt_dir, extract_txt_events, inject_txt_events, split_txt_dir}; -pub use wolfscript::{ - decompile_commands, decompile_commands_annotated, decompile_commands_enc, - decompile_common_events, decompile_map, operand, Renderer, -}; diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/merge.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/merge.rs deleted file mode 100644 index 39482d3..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/merge.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Incremental translation merge, a translation memory. Carries an existing translation over to a -//! freshly re-extracted set of strings when a game updates (for example v1.00 to v1.10), so the -//! translator only has to touch genuinely new or changed lines. -//! -//! The extractor's outputs (scenes, db, names, gamedat) all share one leaf shape: a JSON object -//! carrying a string `source` and a string `text`, where `text == source` means untranslated and -//! the translator edits `text`. This module does not model each schema. It walks every JSON object -//! generically and acts on any object that has both fields, the same walker pattern -//! [`crate::names::check_name_conflicts`] uses. -//! -//! Matching is by exact source string, not by locator (event/cmd/row index). A line that moved to -//! a different scene in the update still inherits its translation, which is more robust than -//! locator-matching across versions. The trade-off is that two different lines that share an -//! identical source get the same carried translation. That is acceptable and consistent with how -//! the rest of the toolchain treats `source` as the translation key. -//! -//! Pipeline: -//! 1. [`build_memory`] over the old (already-translated) JSONs builds a `source -> text` map of -//! every real translation (`text != source`), plus a list of any sources that diverged across -//! files. -//! 2. [`apply_memory`] over each new (freshly extracted) JSON returns the same JSON with -//! carried-over translations filled in, plus [`MergeStats`] (carried, still_new, -//! kept_existing). -//! 3. [`dropped_sources`] reports which old translations no longer apply because the line was -//! removed in the update. -//! -//! Only `text` values are ever changed. Every other field and the document structure are -//! preserved exactly, and the result is re-serialized pretty-printed with serde_json's stable key -//! order. - -use std::collections::{BTreeMap, BTreeSet, HashMap}; - -use serde_json::Value; - -// ---------------------------------------------------------------------------- -// Generic source/text walker (shared shape across scenes, db, names, gamedat) -// ---------------------------------------------------------------------------- - -/// Pull every `(source, text)` pair out of one parsed translation JSON, regardless of schema. Any -/// object that has both a string `source` and a string `text` is a translation leaf. Mirrors -/// [`crate::names`]'s `collect_source_text_pairs` so the merge sees exactly the same leaves the -/// rest of the toolchain treats as translatable. -fn collect_source_text_pairs(v: &Value, out: &mut Vec<(String, String)>) { - match v { - Value::Object(map) => { - if let (Some(Value::String(s)), Some(Value::String(t))) = - (map.get("source"), map.get("text")) - { - out.push((s.clone(), t.clone())); - } - for child in map.values() { - collect_source_text_pairs(child, out); - } - } - Value::Array(arr) => { - for child in arr { - collect_source_text_pairs(child, out); - } - } - _ => {} - } -} - -/// Collect just the `source` strings of every translation leaf (translated or not) in one parsed -/// JSON, into `out`. Empty sources are skipped. They are never a memory key. -fn collect_sources(v: &Value, out: &mut BTreeSet) { - match v { - Value::Object(map) => { - if let (Some(Value::String(s)), Some(Value::String(_))) = - (map.get("source"), map.get("text")) - { - if !s.is_empty() { - out.insert(s.clone()); - } - } - for child in map.values() { - collect_sources(child, out); - } - } - Value::Array(arr) => { - for child in arr { - collect_sources(child, out); - } - } - _ => {} - } -} - -// ---------------------------------------------------------------------------- -// Build the translation memory from the old (translated) JSONs -// ---------------------------------------------------------------------------- - -/// Build the translation memory from the old (already-translated) extraction JSONs. -/// -/// Walks every input generically and collects `source -> text` for each leaf that is a real -/// translation (`text != source`). `source == ""` is skipped. If the same `source` is given two -/// or more distinct `text` values across the inputs, the first one seen wins (inputs in order, -/// leaves in document order), and the source is reported in the returned conflicts vec together -/// with its divergent values (the kept value first, sorted thereafter). -/// -/// An input that fails to parse is skipped, so one stray file cannot sink the whole memory. -/// -/// Returns `(memory, conflicts)` where `conflicts` is `(source, distinct_texts)` per divergent -/// source, sorted by source for stable output. -// The return tuple is the documented public API shape: a source->text map plus a per-source -// variant list. -#[allow(clippy::type_complexity)] -pub fn build_memory(old_jsons: &[&str]) -> (HashMap, Vec<(String, Vec)>) { - // source -> kept text (first seen), and source -> ordered set of all distinct texts seen. - let mut memory: HashMap = HashMap::new(); - // BTreeMap and BTreeSet keep conflict reporting deterministic and de-duplicated. - let mut variants: BTreeMap> = BTreeMap::new(); - // Preserve the kept first-seen value separately so it can lead the reported variant list. - let mut kept: BTreeMap = BTreeMap::new(); - - for json in old_jsons { - let Ok(root) = serde_json::from_str::(json) else { - continue; // skip unparseable inputs rather than failing the whole build. - }; - let mut pairs = Vec::new(); - collect_source_text_pairs(&root, &mut pairs); - for (source, text) in pairs { - if source.is_empty() || text == source { - continue; // empty key or untranslated leaf, not a memory entry. - } - // First real translation for this source wins and is the kept value. - memory.entry(source.clone()).or_insert_with(|| text.clone()); - kept.entry(source.clone()).or_insert_with(|| text.clone()); - variants - .entry(source.clone()) - .or_default() - .insert(text.clone()); - } - } - - // A source with two or more distinct translations is a conflict. Report the kept value first, - // then the remaining distinct values in sorted order. - let mut conflicts = Vec::new(); - for (source, texts) in variants { - if texts.len() < 2 { - continue; - } - let keep = kept.get(&source).cloned().unwrap_or_default(); - let mut list = Vec::with_capacity(texts.len()); - list.push(keep.clone()); - for t in texts { - if t != keep { - list.push(t); - } - } - conflicts.push((source, list)); - } - - (memory, conflicts) -} - -// ---------------------------------------------------------------------------- -// Apply the memory to a NEW (freshly extracted) JSON -// ---------------------------------------------------------------------------- - -/// What [`apply_memory`] did to one new extraction file. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct MergeStats { - /// Leaves that were untranslated in the new file and got a translation carried over from memory. - pub carried: usize, - /// Leaves that are still untranslated (no memory hit). Genuinely new or changed text to do. - pub still_new: usize, - /// Leaves the new file already had its own translation for (`text != source`). Left untouched. - pub kept_existing: usize, -} - -/// Apply a translation memory to one new (freshly extracted) extraction JSON. -/// -/// Parses the JSON, walks it generically, and for every leaf object with a string `source` and -/// `text`: -/// * If `text != source` already (the new file carries its own translation), leave it and add -/// to `kept_existing`. -/// * Else if `source` is in `memory`, set `text = memory[source]` and add to `carried`. -/// * Else leave it untranslated and add to `still_new`. -/// -/// All other fields and the overall structure are preserved exactly. Only `text` values may -/// change. The result is re-serialized pretty-printed via serde_json with stable key order. -/// -/// Returns the re-serialized JSON plus the [`MergeStats`], or an error string if the input does -/// not parse as JSON. -pub fn apply_memory( - new_json: &str, - memory: &HashMap, -) -> Result<(String, MergeStats), String> { - let mut root: Value = serde_json::from_str(new_json).map_err(|e| format!("invalid JSON: {e}"))?; - let mut stats = MergeStats::default(); - apply_to_value(&mut root, memory, &mut stats); - let out = serde_json::to_string_pretty(&root).map_err(|e| format!("serialize failed: {e}"))?; - Ok((out, stats)) -} - -/// Recurse a parsed value, rewriting `text` on each translation leaf per the merge rule. -fn apply_to_value(v: &mut Value, memory: &HashMap, stats: &mut MergeStats) { - match v { - Value::Object(map) => { - // Decide this object's leaf action on an immutable read first, then mutate. - let leaf = match (map.get("source"), map.get("text")) { - (Some(Value::String(s)), Some(Value::String(t))) => Some((s.clone(), t.clone())), - _ => None, - }; - if let Some((source, text)) = leaf { - if text != source { - stats.kept_existing += 1; - } else if let Some(carried) = memory.get(&source) { - map.insert("text".to_string(), Value::String(carried.clone())); - stats.carried += 1; - } else { - stats.still_new += 1; - } - } - // Recurse into children regardless. Nested leaves, such as a speaker sub-object, still - // get their own pass. - for child in map.values_mut() { - apply_to_value(child, memory, stats); - } - } - Value::Array(arr) => { - for child in arr.iter_mut() { - apply_to_value(child, memory, stats); - } - } - _ => {} - } -} - -// ---------------------------------------------------------------------------- -// Reporting: which old translations no longer apply (removed in the update) -// ---------------------------------------------------------------------------- - -/// The memory sources present in none of the new extraction JSONs. These are lines that were -/// removed, or whose source text changed, in the update, so their translation cannot carry over. -/// Reporting only, sorted for stable output. Unparseable new files are skipped. -pub fn dropped_sources(memory: &HashMap, new_jsons: &[&str]) -> Vec { - let mut present: BTreeSet = BTreeSet::new(); - for json in new_jsons { - if let Ok(root) = serde_json::from_str::(json) { - collect_sources(&root, &mut present); - } - } - let mut dropped: Vec = memory - .keys() - .filter(|s| !present.contains(*s)) - .cloned() - .collect(); - dropped.sort(); - dropped -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_memory_keeps_real_translations_only() { - // A translated names file: あ is translated to A, い is untranslated (text==source). - let names = r#"{"kind":"names","names":[ - {"source":"あ","text":"A","occurrences":3,"note":"Monster"}, - {"source":"い","text":"い","occurrences":1,"note":"Item"} - ]}"#; - let (mem, conflicts) = build_memory(&[names]); - assert_eq!(mem.get("あ").map(String::as_str), Some("A")); - assert!(!mem.contains_key("い"), "untranslated source is excluded"); - assert_eq!(mem.len(), 1); - assert!(conflicts.is_empty()); - } - - #[test] - fn build_memory_skips_empty_source() { - let json = r#"{"lines":[{"source":"","text":"X"},{"source":"a","text":"B"}]}"#; - let (mem, _) = build_memory(&[json]); - assert!(!mem.contains_key("")); - assert_eq!(mem.get("a").map(String::as_str), Some("B")); - } - - #[test] - fn build_memory_reports_conflict_and_keeps_first() { - // Same source translated two different ways across two files is a conflict. First wins. - let f1 = r#"{"lines":[{"source":"剣","text":"Sword"}]}"#; - let f2 = r#"{"lines":[{"source":"剣","text":"Blade"}]}"#; - let (mem, conflicts) = build_memory(&[f1, f2]); - assert_eq!(mem.get("剣").map(String::as_str), Some("Sword"), "first kept"); - assert_eq!(conflicts.len(), 1); - assert_eq!(conflicts[0].0, "剣"); - // Kept value leads the reported variants. Both distinct values are present. - assert_eq!(conflicts[0].1.first().map(String::as_str), Some("Sword")); - assert!(conflicts[0].1.iter().any(|t| t == "Blade")); - assert_eq!(conflicts[0].1.len(), 2); - } - - #[test] - fn build_memory_same_text_is_not_a_conflict() { - // The same translation in two files is consistent, not divergent. - let f1 = r#"{"lines":[{"source":"剣","text":"Sword"}]}"#; - let f2 = r#"{"lines":[{"source":"剣","text":"Sword"}]}"#; - let (_, conflicts) = build_memory(&[f1, f2]); - assert!(conflicts.is_empty()); - } - - #[test] - fn apply_memory_carries_and_marks_still_new() { - // New scenes extraction: あ matches memory (carried), う has no memory entry (still new). - let new = r#"{"scenes":[{"lines":[ - {"source":"あ","text":"あ","speaker":"NPC"}, - {"source":"う","text":"う","speaker":"NPC"} - ]}]}"#; - let mut mem = HashMap::new(); - mem.insert("あ".to_string(), "A".to_string()); - let (out, stats) = apply_memory(new, &mem).unwrap(); - assert_eq!(stats.carried, 1); - assert_eq!(stats.still_new, 1); - assert_eq!(stats.kept_existing, 0); - // Re-parse and confirm the carried translation landed and the still-new one is unchanged. - let v: Value = serde_json::from_str(&out).unwrap(); - let mut pairs = Vec::new(); - collect_source_text_pairs(&v, &mut pairs); - let map: HashMap<_, _> = pairs.into_iter().collect(); - assert_eq!(map.get("あ").map(String::as_str), Some("A")); - assert_eq!(map.get("う").map(String::as_str), Some("う")); - } - - #[test] - fn apply_memory_keeps_existing_translation_untouched() { - // The new file already translated あ to "Already". Even with a different memory entry, its - // own translation is kept and counted as kept_existing, never overwritten. - let new = r#"{"lines":[{"source":"あ","text":"Already"}]}"#; - let mut mem = HashMap::new(); - mem.insert("あ".to_string(), "FromMemory".to_string()); - let (out, stats) = apply_memory(new, &mem).unwrap(); - assert_eq!(stats.kept_existing, 1); - assert_eq!(stats.carried, 0); - assert_eq!(stats.still_new, 0); - let v: Value = serde_json::from_str(&out).unwrap(); - let mut pairs = Vec::new(); - collect_source_text_pairs(&v, &mut pairs); - assert_eq!(pairs[0].1, "Already"); - } - - #[test] - fn apply_memory_empty_memory_preserves_everything() { - // With an empty memory, nothing is carried. Every text and non-text field is preserved. - let new = r#"{"scenes":[{"event":7,"lines":[ - {"cmd":3,"str":0,"row":2,"speaker":"勇者","source":"あ","text":"あ"} - ]}]}"#; - let before: Value = serde_json::from_str(new).unwrap(); - let (out, stats) = apply_memory(new, &HashMap::new()).unwrap(); - assert_eq!(stats.carried, 0); - assert_eq!(stats.kept_existing, 0); - assert_eq!(stats.still_new, 1); - let after: Value = serde_json::from_str(&out).unwrap(); - // Logical content is identical. serde_json::Value compares structurally. - assert_eq!(before, after, "empty memory is a structural no-op"); - } - - #[test] - fn apply_memory_bad_json_errors() { - assert!(apply_memory("{not json", &HashMap::new()).is_err()); - } - - #[test] - fn dropped_sources_reports_removed_only() { - let mut mem = HashMap::new(); - mem.insert("あ".to_string(), "A".to_string()); - mem.insert("い".to_string(), "I".to_string()); - // The new file only still contains あ. い was removed in the update. - let new = r#"{"lines":[{"source":"あ","text":"あ"}]}"#; - let dropped = dropped_sources(&mem, &[new]); - assert_eq!(dropped, vec!["い".to_string()]); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/names.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/names.rs deleted file mode 100644 index 3a5ff90..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/names.rs +++ /dev/null @@ -1,745 +0,0 @@ -//! Project-level name glossary. A cross-file, consistency-preserving translation of the database -//! names a Wolf game looks rows up by. -//! -//! A command like `UDB[Monster]["ブルノエール"].Graphic` resolves a row by its name, and that name -//! is mirrored in three or more independent places that must translate together or the lookup -//! breaks (returns -1 or hits the wrong row): -//! 1. The DB name/display field value (the row's `名前`, `表示名`, nickname, or element name). -//! 2. The stored row name ([`DbData::name`](wolf_formats::database::DbData), the `.project` -//! half). -//! 3. Every command by-name reference: `cid250/252` dataName (str index 2), `cid251` dataName -//! (str index 1), and `cid112` StringCondition comparison literals (any str). -//! -//! The per-file [`crate::strings`] extract/inject path translates each of these independently, so -//! a translator could rename a row's display field without the row name and by-name references -//! following, silently breaking the lookup. This module instead builds one `source -> text` map -//! from the whole data dir and applies it to all mirrors at once, keeping them consistent. -//! -//! Control-code preservation, drift guarding, and encoding checks reuse the existing -//! [`crate::strings::write_translation`] path. - -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fmt::Write as _; // `write!`/`writeln!` into a String. - -use serde_json::Value; -use wolf_formats::command::RawCommand; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::{Database, DbType}; -use wolf_formats::map::Map; -use wolf_formats::WStr; - -use crate::json::value_slots; -use crate::strings::{ - db_field_role, has_translatable_text, value_is_asset_path, write_translation, InjectOptions, - InjectStats, Role, -}; -use crate::text::decode_wstr; - -// ---------------------------------------------------------------------------- -// Name / display field detection (general, not hardcoded per game) -// ---------------------------------------------------------------------------- - -/// A string field whose values are single-string names the game may look rows up by, so they must -/// stay consistent with the row name and every by-name reference. General, not a per-game -/// allowlist. It is exactly the cells the role classifier assigns [`Role::Name`]. -/// -/// This is the glossary's half of the disjoint split. It owns name cells, while the per-file -/// `db-strings` path owns description/dialogue content cells ([`Role::Content`]). Because -/// [`db_field_role`] assigns each cell exactly one role, no DB string cell is extracted by both -/// paths, which removes the hazard of a name being translated to two different values. -/// -/// A cell is a name when [`db_field_role`] returns [`Role::Name`]: the type's first string field -/// (the canonical row-name column that `UDB[type][name]` reads, even with an empty or unrecognised -/// label), or a recognised name field or type (`名前`, `愛称`, `呼び方`, the `移動名` -/// location-banner key, and so on). A description/dialogue field is [`Role::Content`], owned by the -/// per-file path and independently translatable. An internal field is [`Role::Internal`]. Neither -/// is a name. The on-screen `移動名` location banner classifies as a name, since the consistency -/// machinery covers its by-name-key usage. A genuine internal field never does. -fn name_display_field( - type_byte: u8, - type_name: &str, - field_jp: &str, - field_en: &str, - is_first_string: bool, -) -> bool { - db_field_role(type_byte, type_name, field_jp, field_en, is_first_string) == Role::Name -} - -/// The name/display fields of a type: `(field_idx, string_slot, type_byte, field_jp)` for each -/// field [`name_display_field`] keeps, in field order. `glossary` powers the EN field-name -/// matching. -fn name_fields(t: &DbType, utf8: bool, glossary: &HashMap) -> Vec<(usize, usize, u8, String)> { - let type_name = decode_wstr(&t.name, utf8); - let fields_size = (t.dat_fields_size as usize).min(t.fields.len()); - let slots = value_slots(t, fields_size, utf8); - let mut seen_string = false; - let mut out = Vec::new(); - for (fi, (_, is_str, slot)) in slots.iter().enumerate() { - if !is_str { - continue; - } - let is_first_string = !seen_string; - seen_string = true; - let fjp = decode_wstr(&t.fields[fi].name, utf8); - let fen = glossary.get(&fjp).map(String::as_str).unwrap_or(&fjp); - let tb = t.fields[fi].type_byte; - if name_display_field(tb, &type_name, &fjp, fen, is_first_string) { - out.push((fi, *slot, tb, fjp)); - } - } - out -} - -/// A single name/display cell value, with the per-cell asset-path exclusion applied. Returns the -/// decoded value only when it is a translatable name. Skips empty, pure-code, and folder-picker -/// paths. -fn name_cell_value(row: &wolf_formats::database::DbData, slot: usize, tb: u8, utf8: bool) -> Option { - let v = row.string_values.get(slot).map(|w| decode_wstr(w, utf8))?; - if !has_translatable_text(&v) { - return None; - } - if tb == 1 && value_is_asset_path(&v) { - return None; - } - Some(v) -} - -/// The glossary's per-cell name extraction of a database, as `(type_idx, row_idx, field_idx)` for -/// every string cell the glossary owns (a [`name_display_field`] cell with a translatable name -/// value). The stored row name (`DbData.name`) is not a field cell, so it carries no -/// `(type,row,field)` locator and is not in this set. -/// -/// This is the glossary's side of the disjointness invariant. It must share no `(type,row,field)` -/// with [`crate::strings::extract_db_strings`]'s output, since every DB string cell has exactly -/// one [`crate::strings::Role`]. -pub fn extract_db_name_cells( - db: &Database, - glossary: &HashMap, -) -> Vec<(usize, usize, usize)> { - let utf8 = db.utf8; - let mut out = Vec::new(); - for (ti, t) in db.types.iter().enumerate() { - let fields = name_fields(t, utf8, glossary); - for (ri, row) in t.data.iter().enumerate() { - for (fi, slot, tb, _) in &fields { - if name_cell_value(row, *slot, *tb, utf8).is_some() { - out.push((ti, ri, *fi)); - } - } - } - } - out -} - -// ---------------------------------------------------------------------------- -// By-name command references (cid 250/252 str2, cid 251 str1, cid 112 any str) -// ---------------------------------------------------------------------------- - -/// The string-arg indices of a command that are DB by-name references: a data name a lookup -/// resolves, or a name compared in a StringCondition. Empty means none. -fn dbref_str_indices(cmd: &RawCommand) -> Vec { - match cmd.cid { - // DB read/write by name: str index 2 is the data (row) name operand. - 250 | 252 => vec![2], - // DB-string read by name: str index 1 is the data (row) name operand. - 251 => vec![1], - // StringCondition: any string operand may be a name compared against a row name. - 112 => (0..cmd.str_args.len()).collect(), - _ => vec![], - } -} - -/// Visit every by-name reference string in a command list, calling `f(cmd_idx, str_idx, value)`. -fn for_each_dbref(cmds: &[RawCommand], utf8: bool, mut f: impl FnMut(usize, usize, &str)) { - for (ci, cmd) in cmds.iter().enumerate() { - for si in dbref_str_indices(cmd) { - let Some(w) = cmd.str_args.get(si) else { - continue; - }; - let v = decode_wstr(w, utf8); - if !v.is_empty() { - f(ci, si, &v); - } - } - } -} - -// ---------------------------------------------------------------------------- -// Extraction: collect the unique candidate name strings across the data dir -// ---------------------------------------------------------------------------- - -/// One candidate name in the glossary: the `source` string (its `text` starts equal, the -/// translator edits `text`), how many places it occurs across the corpus, and a short hint. -struct NameEntry { - source: String, - occurrences: usize, - /// A short note: the originating DB type name (the first one observed), for context. - note: String, -} - -/// Accumulator. Per unique source string, its occurrence count and a note (the first type seen). -#[derive(Default)] -struct NameAcc { - /// source -> occurrence count. - counts: BTreeMap, - /// source -> note (originating type name, kept from the first DB cell or row name seen). - notes: BTreeMap, - /// Sources that qualified as a candidate (a DB name/display value or a stored row name). A - /// by-name command reference only counts toward an existing candidate. It never introduces a - /// new one. A reference to a name that exists in no DB is dead and untranslatable. - candidates: BTreeSet, -} - -impl NameAcc { - /// Register a DB-sourced candidate (row name or name/display cell value). It becomes a - /// glossary entry and the occurrence is counted. `note` is the originating type name. - fn add_candidate(&mut self, s: &str, note: &str) { - self.candidates.insert(s.to_string()); - *self.counts.entry(s.to_string()).or_default() += 1; - self.notes - .entry(s.to_string()) - .or_insert_with(|| note.to_string()); - } - - /// Count a by-name reference occurrence. Does not introduce a new candidate. - fn count_ref(&mut self, s: &str) { - if self.candidates.contains(s) { - *self.counts.entry(s.to_string()).or_default() += 1; - } - } -} - -/// Collect candidate names from one database: every non-empty stored row name, and every -/// translatable name/display cell value. -fn collect_db_candidates(acc: &mut NameAcc, db: &Database, glossary: &HashMap) { - let utf8 = db.utf8; - for t in &db.types { - let type_name = decode_wstr(&t.name, utf8); - let fields = name_fields(t, utf8, glossary); - for row in &t.data { - let row_name = decode_wstr(&row.name, utf8); - if has_translatable_text(&row_name) { - acc.add_candidate(&row_name, &type_name); - } - for (_, slot, tb, _) in &fields { - if let Some(v) = name_cell_value(row, *slot, *tb, utf8) { - acc.add_candidate(&v, &type_name); - } - } - } - } -} - -/// Build the project name glossary JSON over a whole data dir: the parsed databases, an optional -/// common-events file, and the parsed maps. `glossary` (JP to EN) powers EN field-name matching. -/// -/// The result is a `{ "kind":"names", "names":[ {source,text,occurrences,note}, ... ] }` document -/// where `text == source` initially and the translator edits `text`. Sources are deduplicated by -/// exact value and sorted deterministically by source string, so re-running on the same corpus -/// yields byte-identical output. -pub fn extract_names( - dbs: &[Database], - ces: &[&CommonEventsFile], - maps: &[Map], - glossary: &HashMap, -) -> String { - let mut acc = NameAcc::default(); - // Pass 1: candidates from every DB (row names and name/display cell values). - for db in dbs { - collect_db_candidates(&mut acc, db, glossary); - } - // Pass 2: count by-name references in command streams against the candidate set. - for ce in ces { - for ev in &ce.events { - for_each_dbref(&ev.commands, ce.utf8, |_, _, v| acc.count_ref(v)); - } - } - for map in maps { - for ev in &map.events { - for page in &ev.pages { - for_each_dbref(&page.commands, map.utf8, |_, _, v| acc.count_ref(v)); - } - } - } - - // Materialise the sorted, deduped entry list. `candidates` is a BTreeSet so iteration is - // already in deterministic source order. Skip anything that fails the translatable filter. - // Candidates already passed it, but row names from a denylist-only path could slip a - // pure-symbol value through. - let mut entries: Vec = Vec::with_capacity(acc.candidates.len()); - for source in &acc.candidates { - if !has_translatable_text(source) { - continue; - } - entries.push(NameEntry { - source: source.clone(), - occurrences: acc.counts.get(source).copied().unwrap_or(0), - note: acc.notes.get(source).cloned().unwrap_or_default(), - }); - } - - names_to_json(&entries) -} - -/// Render the collected name entries as the names-glossary JSON. `text` starts equal to `source`. -fn names_to_json(entries: &[NameEntry]) -> String { - let mut s = String::new(); - s.push_str("{\n"); - s.push_str(" \"kind\": \"names\",\n"); - let _ = writeln!(s, " \"count\": {},", entries.len()); - s.push_str(" \"names\": [\n"); - for (i, e) in entries.iter().enumerate() { - let _ = write!( - s, - " {{\"source\": {}, \"text\": {}, \"occurrences\": {}, \"note\": {}}}", - jstr(&e.source), - jstr(&e.source), - e.occurrences, - jstr(&e.note), - ); - s.push_str(if i + 1 == entries.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n}\n"); - s -} - -// ---------------------------------------------------------------------------- -// Injection: apply the glossary consistently across all name mirrors -// ---------------------------------------------------------------------------- - -/// Parse a names-glossary JSON into a `source -> text` map, keeping only entries whose `text` -/// actually differs from `source`. An untranslated entry is a no-op everywhere. -fn parse_name_map(json: &str) -> Result, String> { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let arr = root - .get("names") - .and_then(Value::as_array) - .ok_or("names JSON has no `names` array")?; - let mut map = HashMap::new(); - for e in arr { - let source = e.get("source").and_then(Value::as_str).unwrap_or(""); - let text = e.get("text").and_then(Value::as_str).unwrap_or(source); - if source.is_empty() || text == source { - continue; - } - map.insert(source.to_string(), text.to_string()); - } - Ok(map) -} - -/// Apply a name translation to a single `WStr` cell when it currently equals a mapped source. -/// Reuses [`write_translation`] so the control-code, drift, and encoding guards are identical to -/// the player-text path. The cell's current decoded value is the drift base, so an -/// already-translated or unrelated cell is left byte-exact and counted as `drifted`. -fn apply_to_cell( - cell: &mut WStr, - map: &HashMap, - utf8: bool, - opts: &InjectOptions, - stats: &mut InjectStats, - locator: &dyn Fn() -> String, -) { - let cur = decode_wstr(cell, utf8); - let Some(text) = map.get(&cur) else { - return; - }; - // `cur` is the source (it equals the map key) and `text` differs, since parse_name_map - // dropped no-ops. This is always a real edit attempt. The guards decide if it lands. - write_translation(cell, &cur, text, utf8, opts, stats, locator); -} - -/// Apply the project name glossary across a whole data dir, consistently. The same `source -> -/// text` is written to (a) every stored row name, (b) every name/display cell value, and (c) -/// every by-name command reference (`cid250/252` str2, `cid251` str1, `cid112` strings) that -/// currently equals a source. Every write is drift, control-code, and encoding guarded via the -/// shared [`write_translation`]. Untouched cells keep their exact bytes, so each file -/// re-serializes byte-exact except for the translated names. -/// -/// `dbs`, `ces`, and `maps` are the mutable parsed files of the dir, in any order. The caller -/// writes them back out and re-parses. Returns the aggregate [`InjectStats`]. -pub fn inject_names( - names_json: &str, - dbs: &mut [Database], - ces: &mut [&mut CommonEventsFile], - maps: &mut [Map], - opts: &InjectOptions, - glossary: &HashMap, -) -> Result { - let map = parse_name_map(names_json)?; - let mut stats = InjectStats::default(); - if map.is_empty() { - return Ok(stats); - } - - // (a) and (b) Databases: stored row names and name/display cell values. - for (di, db) in dbs.iter_mut().enumerate() { - let utf8 = db.utf8; - for (ti, t) in db.types.iter_mut().enumerate() { - // Recompute the keep-set on an immutable borrow before mutating rows. - let fields = name_fields(t, utf8, glossary); - for (ri, row) in t.data.iter_mut().enumerate() { - // (a) Row name. - apply_to_cell(&mut row.name, &map, utf8, opts, &mut stats, &|| { - format!("db {di} type {ti} row {ri} name") - }); - // (b) Name/display cell values. - for (fi, slot, tb, _) in &fields { - // Re-apply the per-cell asset-path exclusion. A folder-picker path is never a - // name, even if its exact text somehow collided with a mapped source. - if *tb == 1 { - let cur = row - .string_values - .get(*slot) - .map(|w| decode_wstr(w, utf8)) - .unwrap_or_default(); - if value_is_asset_path(&cur) { - continue; - } - } - if let Some(cell) = row.string_values.get_mut(*slot) { - apply_to_cell(cell, &map, utf8, opts, &mut stats, &|| { - format!("db {di} type {ti} row {ri} field {fi}") - }); - } - } - } - } - } - - // (c) By-name command references in common events. - for (ei, ce) in ces.iter_mut().enumerate() { - let utf8 = ce.utf8; - for (evi, ev) in ce.events.iter_mut().enumerate() { - apply_dbrefs(&mut ev.commands, &map, utf8, opts, &mut stats, &|ci, si| { - format!("ce {ei} event {evi} cmd {ci} str {si}") - }); - } - } - // (c) By-name command references in maps. - for (mi, map_file) in maps.iter_mut().enumerate() { - let utf8 = map_file.utf8; - for (evi, ev) in map_file.events.iter_mut().enumerate() { - for (pi, page) in ev.pages.iter_mut().enumerate() { - apply_dbrefs(&mut page.commands, &map, utf8, opts, &mut stats, &|ci, si| { - format!("map {mi} event {evi} page {pi} cmd {ci} str {si}") - }); - } - } - } - - Ok(stats) -} - -/// Apply the name map to the by-name reference strings of a command list. -fn apply_dbrefs( - cmds: &mut [RawCommand], - map: &HashMap, - utf8: bool, - opts: &InjectOptions, - stats: &mut InjectStats, - locator: &dyn Fn(usize, usize) -> String, -) { - for (ci, cmd) in cmds.iter_mut().enumerate() { - for si in dbref_str_indices(cmd) { - if let Some(cell) = cmd.str_args.get_mut(si) { - apply_to_cell(cell, map, utf8, opts, stats, &|| locator(ci, si)); - } - } - } -} - -fn jstr(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -// ---------------------------------------------------------------------------- -// Name-consistency conflict check (across translation JSONs) -// ---------------------------------------------------------------------------- - -/// One detected name-translation conflict: a single glossary name source that was given two or -/// more distinct non-identity translations across the loaded translation files. This is the exact -/// hazard the disjoint-ownership split prevents at extraction time. The check catches a residual -/// conflict that slipped in by hand, such as an old db-strings edit kept alongside the glossary. -pub struct Conflict { - /// The source name string (an exact, full-string `names.json` entry). - pub source: String, - /// The divergent translations, each with the files that used it, sorted and deduped. - pub variants: Vec, -} - -/// One divergent translation of a conflicting source, with where it came from. -pub struct ConflictVariant { - pub text: String, - /// Names of the files that translated the source to this `text`, sorted and deduped. - pub files: Vec, -} - -/// Pull every `(source, text)` pair out of one parsed translation JSON, regardless of its shape. -/// The names, db, scenes, and gamedat documents all carry `source` and `text` leaves at various -/// depths. Recurse generically: any object that has both a string `source` and a string `text` is -/// a pair. This keeps the check robust to all the document kinds without re-encoding each schema. -fn collect_source_text_pairs(v: &Value, out: &mut Vec<(String, String)>) { - match v { - Value::Object(map) => { - if let (Some(Value::String(s)), Some(Value::String(t))) = - (map.get("source"), map.get("text")) - { - out.push((s.clone(), t.clone())); - } - for child in map.values() { - collect_source_text_pairs(child, out); - } - } - Value::Array(arr) => { - for child in arr { - collect_source_text_pairs(child, out); - } - } - _ => {} - } -} - -/// The set of glossary name sources in one parsed JSON: every `source` of a `names.json` document -/// (`kind == "names"`, the `names` array). A non-names document contributes none. A db-strings -/// description source is content, never a name, so it can never define the glossary-name set. -fn collect_name_sources(v: &Value, out: &mut BTreeSet) { - if v.get("kind").and_then(Value::as_str) == Some("names") { - if let Some(arr) = v.get("names").and_then(Value::as_array) { - for e in arr { - if let Some(s) = e.get("source").and_then(Value::as_str) { - if !s.is_empty() { - out.insert(s.to_string()); - } - } - } - } - } -} - -/// Check a set of loaded translation JSONs for name-translation conflicts: a glossary name (a -/// source that appears as a `names.json` entry) that is translated to two or more distinct -/// non-identity `text` values across the files. -/// -/// Semantics, deliberately strict: -/// * The glossary-name set is the union of every `names.json`'s `source` values. -/// * Across all files (names, db-strings, scenes, gamedat), every `(source, text)` pair whose -/// `source` is an exact full-string match for a glossary name and whose `text != source` (a -/// real edit) is collected. No substring matching. A name appearing inside a dialogue -/// sentence is a different `source` string and is not a conflict. -/// * A source with two or more distinct `text` values is a conflict. -/// -/// `files` is `(display-name, json-text)`. An unparseable file is skipped. Errors are ignored -/// here so one bad file cannot mask the rest. Returns the conflicts sorted by source for stable -/// output. -pub fn check_name_conflicts(files: &[(String, &str)]) -> Vec { - // Parse once, keeping (name, value) for the parseable files. - let parsed: Vec<(&str, Value)> = files - .iter() - .filter_map(|(name, json)| serde_json::from_str::(json).ok().map(|v| (name.as_str(), v))) - .collect(); - - // (1) The glossary-name set is every names.json source. - let mut name_sources: BTreeSet = BTreeSet::new(); - for (_, v) in &parsed { - collect_name_sources(v, &mut name_sources); - } - - // (2) source -> text -> set of files that used it. Only sources that are glossary names, only - // non-identity edits. BTreeMap and BTreeSet keep the output deterministic. - let mut by_source: BTreeMap>> = BTreeMap::new(); - for (name, v) in &parsed { - let mut pairs = Vec::new(); - collect_source_text_pairs(v, &mut pairs); - for (source, text) in pairs { - if source == text { - continue; // identity is untranslated, not a divergence. - } - if !name_sources.contains(&source) { - continue; // not a glossary name, or not an exact full-string match. - } - by_source - .entry(source) - .or_default() - .entry(text) - .or_default() - .insert((*name).to_string()); - } - } - - // (3) A source with two or more distinct translations is a conflict. - let mut conflicts = Vec::new(); - for (source, variants) in by_source { - if variants.len() < 2 { - continue; - } - let variants = variants - .into_iter() - .map(|(text, files)| ConflictVariant { - text, - files: files.into_iter().collect(), - }) - .collect(); - conflicts.push(Conflict { source, variants }); - } - conflicts -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn name_display_field_is_name_role_only() { - // The glossary owns name cells only (Role::Name), disjoint from the db-strings content path. - // The first string field is always a name, even with an empty or unknown label. - assert!(name_display_field(0, "Monster", "", "", true)); - assert!(name_display_field(0, "Item", "謎ラベル", "謎ラベル", true)); - // A recognised name field (not first) is kept: a 愛称 nickname or 呼び方 element name. - assert!(name_display_field(0, "Trainer", "愛称", "愛称", false)); - assert!(name_display_field(0, "Element", "呼び方", "呼び方", false)); - // `移動名` is a displayed location-banner name, so the glossary keeps it and the - // consistency machinery covers its by-name-key usage. - assert!(name_display_field(0, "Map", "日本語移動名", "Move Name", false)); - // A description/dialogue field is content, not a name. The db-strings path owns it, not - // the glossary, so descriptions stay independently translatable even on the first column. - assert!(!name_display_field(0, "Skill", "説明", "Description", false)); - assert!(!name_display_field(0, "Help", "説明", "Description", true)); - // An unlabeled or unrecognised non-first field defaults to content, so the glossary drops - // it. The per-file path owns it, which is safer than forcing glossary consistency on an - // unknown label. - assert!(!name_display_field(0, "Misc", "謎ラベル", "謎ラベル", false)); - // Genuinely-internal fields are never names. - assert!(!name_display_field(0, "Monster", "ファイル", "File", true)); - assert!(!name_display_field(0, "Skill", "戦闘背景画像", "Graphic", false)); - // Non-string type_byte never holds a string cell. - assert!(!name_display_field(2, "Monster", "名前", "Name", true)); - } - - #[test] - fn dbref_indices_match_prototype() { - let mk = |cid: u32, nstr: usize| RawCommand { - cid, - int_args: vec![], - indent: 0, - str_args: (0..nstr).map(|_| WStr::from("x")).collect(), - term: 0, - move_route: None, - v35_blob: None, - }; - assert_eq!(dbref_str_indices(&mk(250, 4)), vec![2]); - assert_eq!(dbref_str_indices(&mk(252, 4)), vec![2]); - assert_eq!(dbref_str_indices(&mk(251, 3)), vec![1]); - assert_eq!(dbref_str_indices(&mk(112, 3)), vec![0, 1, 2]); - assert!(dbref_str_indices(&mk(101, 2)).is_empty()); - } - - #[test] - fn parse_name_map_drops_noops() { - let json = r#"{"kind":"names","names":[ - {"source":"A","text":"Alpha","occurrences":3,"note":"T"}, - {"source":"B","text":"B","occurrences":1,"note":"T"}, - {"source":"","text":"x","occurrences":0,"note":""} - ]}"#; - let m = parse_name_map(json).unwrap(); - assert_eq!(m.get("A").map(String::as_str), Some("Alpha")); - assert!(!m.contains_key("B")); // text equals source - assert!(!m.contains_key("")); // empty source - assert_eq!(m.len(), 1); - } - - #[test] - fn names_json_is_deterministic_and_sorted() { - let entries = vec![ - NameEntry { source: "ゼニ".into(), occurrences: 2, note: "Item".into() }, - NameEntry { source: "あ".into(), occurrences: 5, note: "Monster".into() }, - ]; - let json = names_to_json(&entries); - assert!(json.contains("\"kind\": \"names\"")); - assert!(json.contains("\"occurrences\": 2")); - // Both sources present. Rendering is stable for a given input order. - assert!(json.contains("ゼニ") && json.contains("あ")); - assert_eq!(json, names_to_json(&entries)); - } - - #[test] - fn conflict_check_flags_divergent_name_only() { - // A glossary names.json defines the name set. A db-strings file and the names file each - // translate the same exact name differently, giving one conflict. A description with two - // values is not a conflict since it is not a glossary name. Identity (text==source) is ignored. - let names = r#"{"kind":"names","names":[ - {"source":"ブルノエール","text":"Brunoir","occurrences":3,"note":"Monster"}, - {"source":"毒","text":"Poison","occurrences":1,"note":"Status"} - ]}"#; - let db = r#"{"kind":"db","groups":[{"type":0,"typeName":"Monster","lines":[ - {"row":0,"field":0,"rowName":"x","fieldName":"Name","source":"ブルノエール","text":"Brunoire"}, - {"row":1,"field":3,"rowName":"x","fieldName":"Description","source":"説明テキスト","text":"Desc A"} - ]}]}"#; - let scenes = r#"{"kind":"map","scenes":[{"event":1,"lines":[ - {"cmd":0,"str":0,"speaker":"NPC","speaker_src":"x","source":"説明テキスト","text":"Desc B"}, - {"cmd":1,"str":0,"speaker":"UI","speaker_src":"x","source":"毒","text":"Poison"} - ]}]}"#; - let files = vec![ - ("names.json".to_string(), names), - ("DataBase.db.json".to_string(), db), - ("map.scenes.json".to_string(), scenes), - ]; - let conflicts = check_name_conflicts(&files); - // Exactly one conflict: ブルノエール to {Brunoir, Brunoire}. 説明テキスト diverges too but is - // not a glossary name, so it is ignored. 毒 to Poison is consistent, identical everywhere. - assert_eq!(conflicts.len(), 1, "only the divergent NAME is a conflict"); - let c = &conflicts[0]; - assert_eq!(c.source, "ブルノエール"); - let texts: Vec<&str> = c.variants.iter().map(|v| v.text.as_str()).collect(); - assert!(texts.contains(&"Brunoir") && texts.contains(&"Brunoire")); - assert_eq!(c.variants.len(), 2); - } - - #[test] - fn conflict_check_consistent_is_empty() { - // Same name translated identically everywhere means no conflict. - let names = r#"{"kind":"names","names":[{"source":"剣","text":"Sword","occurrences":2,"note":"Item"}]}"#; - let db = r#"{"kind":"db","groups":[{"type":0,"typeName":"Item","lines":[ - {"row":0,"field":0,"rowName":"x","fieldName":"Name","source":"剣","text":"Sword"} - ]}]}"#; - let files = vec![ - ("names.json".to_string(), names), - ("DataBase.db.json".to_string(), db), - ]; - assert!(check_name_conflicts(&files).is_empty()); - } - - #[test] - fn conflict_check_substring_is_not_a_conflict() { - // A name appearing inside a dialogue sentence is a different full-string source, so two - // different sentence translations are not a name conflict. No substring matching. - let names = r#"{"kind":"names","names":[{"source":"剣","text":"Sword","occurrences":1,"note":"Item"}]}"#; - let scenes = r#"{"kind":"map","scenes":[{"event":1,"lines":[ - {"cmd":0,"str":0,"speaker":"x","speaker_src":"x","source":"剣を手に入れた","text":"Got a sword"}, - {"cmd":1,"str":0,"speaker":"x","speaker_src":"x","source":"剣を装備した","text":"Equipped a blade"} - ]}]}"#; - let files = vec![ - ("names.json".to_string(), names), - ("map.scenes.json".to_string(), scenes), - ]; - assert!( - check_name_conflicts(&files).is_empty(), - "a name inside a sentence is not an exact full-string match" - ); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/save.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/save.rs deleted file mode 100644 index a510346..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/save.rs +++ /dev/null @@ -1,630 +0,0 @@ -//! Wolf RPG `.sav` auto-update. Rewrites the baked game title and refreshes the baked -//! player-facing strings so a pre-translation save loads cleanly in the translated build. -//! -//! A `.sav` bakes in two things that go stale after a fan translation: -//! 1. The game identity/title. The translated build compares it against its own `Game.dat` -//! title and rejects a mismatch ("trying to load from another game"). -//! 2. Player-facing strings (item, skill, and mode names shown on the save-load screen) that -//! still read in the original language. -//! -//! [`update_save`] decrypts the save with [`wolf_core::codec::wolfsave`], optionally splices in a -//! new title, replaces every baked length-prefixed string that exactly matches a translation -//! source, then re-encrypts. It reuses the same `source -> target` translation map the rest of -//! the pipeline applies to the game itself, so the save stays consistent with the translated -//! data files. -//! -//! ## Save plaintext layout used here -//! After the outer decrypt the buffer is plaintext. Byte `6 == 0x55` marks UTF-8 strings, -//! otherwise Shift-JIS. Byte `0x14 == 0x19` marks a valid, parseable save. The title follows at -//! `0x15` as a `u16` little-endian byte-length (counting a trailing NUL) plus that many bytes. -//! The body further contains many `[u32 le byteLen][bytes][NUL]` records (the u32 counts the -//! NUL) for the variable database and baked strings. -//! -//! ## GamePro Pro (marker-3) saves -//! If the standard decrypt does not yield a valid save (`byte[0x14] != 0x19`) but the buffer is a -//! GamePro Pro (marker-3) save (see [`save_pro`](crate::save_pro)), [`update_save`] decrypts the -//! Pro inner, applies the same title-fix and baked-string refresh to it, then re-encrypts with -//! [`save_pro::encrypt_pro`]. In the inner the `0x19` marker sits at offset 0, the title `u16` -//! length at `inner[1..3]`, and the title bytes at `inner[3..]`. Only a buffer that is neither a -//! standard save nor a detectable Pro save is refused with an `Err`. - -use std::collections::HashMap; - -use wolf_core::codec::wolfsave; - -use crate::save_pro; - -/// Marker byte (`0x19`) of a valid, parseable save. The title record follows immediately after. -const VALID_MARKER: u8 = 0x19; -/// Marker offset for a standard save's plaintext. The body marker lives at `0x14`. -const STD_MARKER_OFFSET: usize = wolfsave::START_OFFSET; // 0x14 -/// Marker offset for a Pro inner save. The `0x19` marker lives at offset 0. -const PRO_MARKER_OFFSET: usize = 0; -/// Decrypted `byte[6] == 0x55` means strings are UTF-8, otherwise Shift-JIS. Only meaningful for -/// the standard plaintext. The Pro inner carries no such head and is always UTF-8. -const UTF8_FLAG_OFFSET: usize = 6; -const UTF8_FLAG_VALUE: u8 = 0x55; -/// Upper bound on a baked-string record's declared length. -const MAX_RECORD_LEN: usize = 1024 * 1024; - -/// Which save codec a buffer uses, as reported by [`inspect_save`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SaveFormat { - /// The standard Wolf RPG `.sav` (the `0x19` body marker at `0x14` after the outer decrypt). - Standard, - /// A GamePro Pro (marker-3) save (see [`save_pro`](crate::save_pro)). - GameProPro, - /// Neither a standard save nor a detectable GamePro Pro save. Editing is not supported. - Unsupported, -} - -impl SaveFormat { - /// A short human label for the format badge. - pub fn label(self) -> &'static str { - match self { - SaveFormat::Standard => "Standard", - SaveFormat::GameProPro => "GamePro Pro", - SaveFormat::Unsupported => "Unsupported", - } - } -} - -/// The read-only contents of a save, for listing or editing in a UI before an [`update_save`] -/// write. -/// -/// Produced by [`inspect_save`]: the detected [`format`](Self::format), the baked -/// [`title`](Self::title), the string [`encoding`](Self::encoding) (`"utf8"` or `"sjis"`), and -/// every baked length-prefixed [`strings`](Self::strings) record found in the body. These are the -/// same records [`update_save`] can replace. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SaveInfo { - /// Which codec the save uses. - pub format: SaveFormat, - /// The baked game title (empty for an `Unsupported` save). - pub title: String, - /// The save's string encoding: `"utf8"` or `"sjis"`. - pub encoding: &'static str, - /// Every baked, length-prefixed string in the body, in file order. Deduplication is the - /// caller's choice. Empty for an `Unsupported` save. - pub strings: Vec, -} - -/// What [`update_save`] did to one save file. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SaveUpdateStats { - /// The baked title was rewritten. - pub title_changed: bool, - /// How many baked length-prefixed strings were replaced via the translation map. - pub strings_replaced: usize, - /// The save's string encoding: `"utf8"` or `"sjis"`. - pub encoding: &'static str, -} - -/// Update a single raw `.sav` buffer. Decrypt, optionally set a new title, refresh baked strings -/// from `strings` (encoding-aware), and re-encrypt. -/// -/// * `raw`: the on-disk encrypted save bytes. -/// * `new_title`: `Some(title)` to rewrite the baked title literally, `None` to leave it. -/// * `strings`: a `source -> target` map. Any baked record whose decoded text exactly equals a -/// `source` is replaced with the encoded `target`, re-prefixed. Entries where `source == -/// target` or either side is empty are inert. -/// -/// Returns the re-encrypted bytes plus [`SaveUpdateStats`], or `Err` if the save uses an -/// unsupported encryption (`byte[0x14] != 0x19` after decrypt) or is too small to be a valid -/// save. -pub fn update_save( - raw: &[u8], - new_title: Option<&str>, - strings: &HashMap, -) -> Result<(Vec, SaveUpdateStats), String> { - let mut plain = wolfsave::decrypt(raw); - - // Standard save: the body marker `0x19` is at 0x14, with the title length field after it. - if plain.len() > STD_MARKER_OFFSET + 2 && plain[STD_MARKER_OFFSET] == VALID_MARKER { - let utf8 = plain.get(UTF8_FLAG_OFFSET) == Some(&UTF8_FLAG_VALUE); - let title_changed = match new_title { - Some(t) => set_title(&mut plain, STD_MARKER_OFFSET, t, utf8)?, - None => false, - }; - let strings_replaced = replace_baked_strings(&mut plain, strings, utf8); - let out = wolfsave::encrypt(&plain); - return Ok(( - out, - SaveUpdateStats { - title_changed, - strings_replaced, - encoding: if utf8 { "utf8" } else { "sjis" }, - }, - )); - } - - // GamePro Pro (marker-3) save: the standard decrypt produced no valid save, so try the Pro - // path. The Pro inner starts with the `0x19` marker at offset 0 and is always UTF-8. - if save_pro::is_pro_save(raw) { - let mut inner = save_pro::decrypt_pro(raw)?; - let utf8 = true; - let title_changed = match new_title { - Some(t) => set_title(&mut inner, PRO_MARKER_OFFSET, t, utf8)?, - None => false, - }; - let strings_replaced = replace_baked_strings(&mut inner, strings, utf8); - let out = save_pro::encrypt_pro(&inner, &raw[..STD_MARKER_OFFSET])?; - return Ok(( - out, - SaveUpdateStats { - title_changed, - strings_replaced, - encoding: "utf8", - }, - )); - } - - Err( - "unsupported save encryption (not a standard 0x19 save and not a detectable GamePro Pro \ - marker-3 save); skipping to avoid corruption" - .to_string(), - ) -} - -/// Inspect a raw `.sav` buffer read-only. Detects its [`SaveFormat`], decodes the baked title, -/// and lists every baked length-prefixed string in the body. Reuses the same decrypt, title-read, -/// and record-scan as [`update_save`], so what `inspect_save` lists is exactly what `update_save` -/// can rewrite. -/// -/// Never panics. A save whose format is handled returns `Ok(SaveInfo{ format: -/// Standard|GameProPro, .. })`. A buffer that is neither a standard `0x19` save nor a detectable -/// GamePro Pro save returns `Ok(SaveInfo{ format: Unsupported, title: "", encoding: "utf8", -/// strings: [] })` rather than an error, so a UI can show a clear "not supported" state. The only -/// `Err` is a Pro-marker buffer whose inner decrypt itself fails (a corrupt or truncated Pro -/// save). -pub fn inspect_save(raw: &[u8]) -> Result { - let plain = wolfsave::decrypt(raw); - - // Standard save: the body marker `0x19` is at 0x14. - if plain.len() > STD_MARKER_OFFSET + 2 && plain[STD_MARKER_OFFSET] == VALID_MARKER { - let utf8 = plain.get(UTF8_FLAG_OFFSET) == Some(&UTF8_FLAG_VALUE); - let title = read_title_at(&plain, STD_MARKER_OFFSET, utf8).unwrap_or_default(); - let strings = collect_baked_strings(&plain, utf8); - return Ok(SaveInfo { - format: SaveFormat::Standard, - title, - encoding: if utf8 { "utf8" } else { "sjis" }, - strings, - }); - } - - // GamePro Pro (marker-3) save. The Pro inner starts with `0x19` at offset 0 and is always UTF-8. - if save_pro::is_pro_save(raw) { - let inner = save_pro::decrypt_pro(raw)?; - let utf8 = true; - let title = read_title_at(&inner, PRO_MARKER_OFFSET, utf8).unwrap_or_default(); - let strings = collect_baked_strings(&inner, utf8); - return Ok(SaveInfo { - format: SaveFormat::GameProPro, - title, - encoding: "utf8", - strings, - }); - } - - // Neither codec handled it. Report Unsupported rather than erroring, so the UI can disable - // editing with a clear note instead of failing. - Ok(SaveInfo { - format: SaveFormat::Unsupported, - title: String::new(), - encoding: "utf8", - strings: Vec::new(), - }) -} - -/// Read the baked title of a standard decrypted save (marker at `0x14`). Returns the decoded -/// title with its trailing NUL stripped, or `None` for an unsupported or malformed buffer. Used -/// by tests and callers that want to confirm a title write landed. For a Pro inner, use -/// [`read_title_at`]`(inner, 0)`. -pub fn read_title(plain: &[u8]) -> Option { - let utf8 = plain.get(UTF8_FLAG_OFFSET) == Some(&UTF8_FLAG_VALUE); - read_title_at(plain, STD_MARKER_OFFSET, utf8) -} - -/// Read the baked title given the marker offset (`0x14` for a standard plaintext, `0` for a Pro -/// inner) and the file's string encoding. The title length `u16` sits at `marker+1` and the title -/// bytes follow at `marker+3`. Returns the decoded title with its trailing NUL stripped. -pub fn read_title_at(plain: &[u8], marker_offset: usize, utf8: bool) -> Option { - let len_off = marker_offset + 1; - if plain.len() <= len_off + 1 || plain.get(marker_offset) != Some(&VALID_MARKER) { - return None; - } - let size = u16::from_le_bytes([plain[len_off], plain[len_off + 1]]) as usize; - let start = len_off + 2; - let end = start.checked_add(size)?; - if end > plain.len() { - return None; - } - let body = plain[start..end].strip_suffix(&[0u8])?; // the length counts a trailing NUL. - Some(decode(body, utf8)) -} - -/// Splice a new title into a decrypted buffer, shifting the remainder. `marker_offset` is `0x14` -/// for a standard plaintext or `0` for a Pro inner. The title length `u16` lives at `marker+1` -/// and the title bytes at `marker+3`. The on-disk form is `u16(len(bytes_with_nul)) + -/// bytes_with_nul` in the file encoding. Returns `Ok(true)` on success, `Err` if the new title is -/// not representable in the file's encoding. -fn set_title( - plain: &mut Vec, - marker_offset: usize, - title: &str, - utf8: bool, -) -> Result { - let len_off = marker_offset + 1; - let old_size = u16::from_le_bytes([plain[len_off], plain[len_off + 1]]) as usize; - let old_start = len_off + 2; - let old_end = old_start - .checked_add(old_size) - .filter(|&e| e <= plain.len()) - .ok_or_else(|| "save title length runs past end of buffer".to_string())?; - - let mut title_bytes = encode(title, utf8).ok_or_else(|| { - format!( - "new title not representable in {}: {title:?}", - if utf8 { "UTF-8" } else { "Shift-JIS" } - ) - })?; - title_bytes.push(0); // the stored length counts the trailing NUL. - let len: u16 = title_bytes - .len() - .try_into() - .map_err(|_| "new title is too long to encode as a u16 length".to_string())?; - - let mut replacement = Vec::with_capacity(2 + title_bytes.len()); - replacement.extend_from_slice(&len.to_le_bytes()); - replacement.extend_from_slice(&title_bytes); - // Splice from the old length field, inclusive, through the old title bytes, shifting the rest. - plain.splice(len_off..old_end, replacement); - Ok(true) -} - -/// Try to decode the `[u32 le byteLen][bytes][NUL]` record starting at `offset`. Returns -/// `Some((text, end))` when `offset` begins a valid record, else `None`. The tuple is the -/// cleanly-decoded payload text with NUL stripped and the byte index one past the record. A -/// record is valid when its declared length is `0 < len <= MAX_RECORD_LEN`, the record fits the -/// buffer, the payload ends in exactly one NUL with none interior, and it decodes cleanly in the -/// file encoding. This is the shared scan step used by both the read-only [`inspect_save`] walk -/// and the replacing [`replace_baked_strings`] walk, so the two never disagree about what counts -/// as a string record. -fn read_record_at(plain: &[u8], offset: usize, utf8: bool) -> Option<(String, usize)> { - if offset + 5 > plain.len() { - return None; - } - let size = u32::from_le_bytes([ - plain[offset], - plain[offset + 1], - plain[offset + 2], - plain[offset + 3], - ]) as usize; - let end = offset + 4 + size; - if size == 0 || size > MAX_RECORD_LEN || end > plain.len() { - return None; - } - let payload = &plain[offset + 4..end]; - // Exactly one trailing NUL, none interior. - if payload.last() != Some(&0) || payload[..payload.len() - 1].contains(&0) { - return None; - } - let text = decode_strict(&payload[..payload.len() - 1], utf8)?; - Some((text, end)) -} - -/// Collect every baked length-prefixed string in the decrypted buffer, in file order, using the -/// same record scan [`replace_baked_strings`] uses. Drives [`inspect_save`]'s string list. -fn collect_baked_strings(plain: &[u8], utf8: bool) -> Vec { - let mut out = Vec::new(); - let mut offset = 0usize; - while offset + 5 <= plain.len() { - if let Some((text, end)) = read_record_at(plain, offset, utf8) { - out.push(text); - offset = end; - } else { - offset += 1; - } - } - out -} - -/// Scan the decrypted buffer for `[u32 le byteLen][bytes][NUL]` records and replace any whose -/// decoded text exactly matches a translation `source` with the encoded `target`, re-prefixed. -/// -/// Uses the shared [`read_record_at`] scan step. The walk continues past each replacement using -/// the new record's length, so adjacent records are still seen. Returns the number of records -/// replaced. -fn replace_baked_strings( - plain: &mut Vec, - strings: &HashMap, - utf8: bool, -) -> usize { - // Pre-encode the replacement records once. Skip no-op, empty, or unrepresentable entries. - let records: HashMap> = strings - .iter() - .filter(|(s, t)| !s.is_empty() && !t.is_empty() && s != t) - .filter_map(|(s, t)| encode_record(t, utf8).map(|rec| (s.clone(), rec))) - .collect(); - if records.is_empty() { - return 0; - } - - let mut count = 0usize; - let mut offset = 0usize; - while offset + 5 <= plain.len() { - match read_record_at(plain, offset, utf8) { - Some((text, end)) => { - if let Some(replacement) = records.get(&text) { - let rep_len = replacement.len(); - plain.splice(offset..end, replacement.iter().copied()); - offset += rep_len; - count += 1; - } else { - // Valid record but no translation hit. Skip the whole record. - offset = end; - } - } - None => offset += 1, - } - } - count -} - -/// Build a length-prefixed record for `text`: `u32 le (bytes+NUL len) + bytes + NUL`. Returns -/// `None` if `text` is not representable in the file encoding. -fn encode_record(text: &str, utf8: bool) -> Option> { - let mut bytes = encode(text, utf8)?; - bytes.push(0); - let len = u32::try_from(bytes.len()).ok()?; - let mut rec = Vec::with_capacity(4 + bytes.len()); - rec.extend_from_slice(&len.to_le_bytes()); - rec.extend_from_slice(&bytes); - Some(rec) -} - -/// Encode `text` into the file encoding (UTF-8 as-is, or Shift-JIS/CP932). `None` if a character -/// is not representable in Shift-JIS. -fn encode(text: &str, utf8: bool) -> Option> { - if utf8 { - Some(text.as_bytes().to_vec()) - } else { - let (bytes, _, had_err) = encoding_rs::SHIFT_JIS.encode(text); - (!had_err).then(|| bytes.into_owned()) - } -} - -/// Lossy decode for display (used to read a title back). -fn decode(bytes: &[u8], utf8: bool) -> String { - if utf8 { - String::from_utf8_lossy(bytes).into_owned() - } else { - encoding_rs::SHIFT_JIS.decode(bytes).0.into_owned() - } -} - -/// Strict decode used by the record scanner. `None` if the bytes do not decode cleanly in the -/// file encoding, so a binary record is never mistaken for a translatable string. -fn decode_strict(bytes: &[u8], utf8: bool) -> Option { - if utf8 { - std::str::from_utf8(bytes).ok().map(str::to_owned) - } else { - let (text, _, had_err) = encoding_rs::SHIFT_JIS.decode(bytes); - (!had_err).then(|| text.into_owned()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file is missing, so the fixture-backed tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// Assemble a minimal synthetic decrypted save: a 0x14-byte head with the markers set, a - /// title record at 0x15, then `body` such as baked string records. Encoding is UTF-8. - fn build_plain(title: &str, body: &[u8]) -> Vec { - let mut p = vec![0u8; wolfsave::START_OFFSET]; - p[UTF8_FLAG_OFFSET] = UTF8_FLAG_VALUE; // UTF-8 flag - p.push(VALID_MARKER); // byte 0x14, valid-save marker - // Title record at 0x15. - let mut tb = title.as_bytes().to_vec(); - tb.push(0); - p.extend_from_slice(&(tb.len() as u16).to_le_bytes()); - p.extend_from_slice(&tb); - p.extend_from_slice(body); - p - } - - fn record(text: &str) -> Vec { - encode_record(text, true).unwrap() - } - - #[test] - fn title_read_back_after_set() { - let mut p = build_plain("元のタイトル", &[]); - assert_eq!(read_title(&p).as_deref(), Some("元のタイトル")); - set_title(&mut p, STD_MARKER_OFFSET, "New Title", true).unwrap(); - assert_eq!(read_title(&p).as_deref(), Some("New Title")); - // Marker still intact after the length shift. - assert_eq!(p[STD_MARKER_OFFSET], VALID_MARKER); - } - - #[test] - fn baked_string_replacement_shifts_and_continues() { - // Two adjacent records. Only the first is translated. The scan must still see the second. - let mut body = record("こんにちは"); - body.extend_from_slice(&record("Keep me")); - let mut p = build_plain("T", &body); - - let mut map = HashMap::new(); - map.insert("こんにちは".to_string(), "Hello".to_string()); - let n = replace_baked_strings(&mut p, &map, true); - assert_eq!(n, 1); - - // The replaced record now decodes to the target. "Keep me" is untouched and still present. - // Re-scan for "Hello" by reading the record right after the title. - let len_off = STD_MARKER_OFFSET + 1; - let title_len = u16::from_le_bytes([p[len_off], p[len_off + 1]]) as usize; - let mut off = len_off + 2 + title_len; - let size = - u32::from_le_bytes([p[off], p[off + 1], p[off + 2], p[off + 3]]) as usize; - let payload = &p[off + 4..off + 4 + size]; - assert_eq!(&payload[..payload.len() - 1], b"Hello"); - off += 4 + size; - let size2 = - u32::from_le_bytes([p[off], p[off + 1], p[off + 2], p[off + 3]]) as usize; - let payload2 = &p[off + 4..off + 4 + size2]; - assert_eq!(&payload2[..payload2.len() - 1], b"Keep me"); - } - - #[test] - fn update_save_roundtrips_with_no_changes() { - let mut p = build_plain("チャンバーゲーム", &record("アイテム")); - // Give the synthetic plaintext a correct checksum so a no-op re-encrypt is byte-exact. - // A real save already has this. `update_save` recomputes byte 2 on every write. - wolfsave::fix_checksum(&mut p); - let raw = wolfsave::encrypt(&p); - let (out, stats) = update_save(&raw, None, &HashMap::new()).unwrap(); - assert!(!stats.title_changed); - assert_eq!(stats.strings_replaced, 0); - assert_eq!(stats.encoding, "utf8"); - // Decrypting the output reproduces the original plaintext. - assert_eq!(wolfsave::decrypt(&out), p); - } - - #[test] - fn update_save_rejects_unsupported() { - // byte[0x14] != 0x19 is unsupported. - let mut p = build_plain("T", &[]); - p[wolfsave::START_OFFSET] = 0x68; - let raw = wolfsave::encrypt(&p); - let err = update_save(&raw, Some("X"), &HashMap::new()).unwrap_err(); - assert!(err.contains("unsupported"), "got: {err}"); - } - - #[test] - fn update_save_sets_title_and_replaces() { - let p = build_plain("旧題", &record("ルビー")); - let raw = wolfsave::encrypt(&p); - let mut map = HashMap::new(); - map.insert("ルビー".to_string(), "Ruby".to_string()); - let (out, stats) = update_save(&raw, Some("Translated Title"), &map).unwrap(); - assert!(stats.title_changed); - assert_eq!(stats.strings_replaced, 1); - let plain = wolfsave::decrypt(&out); - assert_eq!(read_title(&plain).as_deref(), Some("Translated Title")); - } - - /// `inspect_save` on a synthetic standard save reports the format, encoding, title, and the - /// baked strings. `update_save` with an `{old->new}` map drawn from that list round-trips, so - /// a re-inspect shows the new string and title. - #[test] - fn inspect_then_update_round_trips_synthetic() { - let mut body = record("アイテム"); - body.extend_from_slice(&record("スキル")); - let p = build_plain("元のタイトル", &body); - let raw = wolfsave::encrypt(&p); - - let info = inspect_save(&raw).expect("inspect"); - assert_eq!(info.format, SaveFormat::Standard); - assert_eq!(info.encoding, "utf8"); - assert_eq!(info.title, "元のタイトル"); - assert!(info.strings.contains(&"アイテム".to_string())); - assert!(info.strings.contains(&"スキル".to_string())); - - // Build the {old->new} map from inspect's own list, then update the title and one string. - let original = info.strings[0].clone(); - let mut map = HashMap::new(); - map.insert(original.clone(), "Item".to_string()); - let (out, stats) = update_save(&raw, Some("New Title"), &map).unwrap(); - assert!(stats.title_changed); - assert_eq!(stats.strings_replaced, 1); - - // Re-inspect the written bytes. The new title and string are present, the old string is gone. - let re = inspect_save(&out).expect("re-inspect"); - assert_eq!(re.format, SaveFormat::Standard); - assert_eq!(re.title, "New Title"); - assert!(re.strings.contains(&"Item".to_string())); - assert!(!re.strings.contains(&original)); - } - - /// `inspect_save` returns `Unsupported` for a non-save buffer, never panicking or erroring. - #[test] - fn inspect_unsupported_buffer() { - // byte[0x14] != 0x19 and not a Pro save is Unsupported. - let mut p = build_plain("T", &[]); - p[wolfsave::START_OFFSET] = 0x68; - let raw = wolfsave::encrypt(&p); - let info = inspect_save(&raw).expect("inspect should not error"); - assert_eq!(info.format, SaveFormat::Unsupported); - assert!(info.title.is_empty()); - assert!(info.strings.is_empty()); - } - - /// On a real save fixture, skipped if absent, `inspect_save` returns a non-empty title and the - /// `update_save` map drawn from `inspect_save(...).strings` round-trips through a re-inspect. - #[test] - fn inspect_then_update_round_trips_real_save() { - let candidates = ["chamber/SaveData01.sav", "pachimon/SaveData01.sav"]; - let mut ran = 0; - for rel in candidates { - let Some(path) = test_data(rel) else { - continue; - }; - let Ok(raw) = std::fs::read(&path) else { - continue; - }; - let path = path.display(); - let info = match inspect_save(&raw) { - Ok(i) if i.format != SaveFormat::Unsupported => i, - _ => continue, - }; - ran += 1; - assert!(!info.title.is_empty(), "{path}: title should not be empty"); - - // Pick the first baked string that can re-encode in this save's encoding and rewrite it. - let new_title = format!("{} [T]", info.title); - let mut map = HashMap::new(); - let mut chosen: Option = None; - for s in &info.strings { - if s.is_empty() || s.contains('[') { - continue; - } - let edited = format!("{s} X"); - if encode(&edited, info.encoding == "utf8").is_some() { - map.insert(s.clone(), edited.clone()); - chosen = Some(edited); - break; - } - } - - let (out, stats) = - update_save(&raw, Some(&new_title), &map).expect("update real save"); - assert!(stats.title_changed); - assert_eq!(stats.encoding, info.encoding); - - let re = inspect_save(&out).expect("re-inspect real save"); - assert_eq!(re.format, info.format, "{path}: format must be preserved"); - assert_eq!(re.title, new_title, "{path}: new title should re-inspect"); - if let Some(edited) = chosen { - // The same baked text can appear in several save slots. All matching records are - // replaced, so assert at least one rather than exactly one. - assert!(stats.strings_replaced >= 1); - assert!( - re.strings.contains(&edited), - "{path}: the edited baked string should re-inspect" - ); - } - } - if ran == 0 { - eprintln!("skip inspect_then_update_round_trips_real_save: no save fixture present"); - } - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/save_pro.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/save_pro.rs deleted file mode 100644 index 57a990f..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/save_pro.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! GamePro Pro (marker-3) `.sav` codec. The newer Wolf RPG "Pro" save encryption used by some -//! GamePro-Pro titles. Where the standard outer codec ([`wolf_core::codec::wolfsave`]) is a 3-pass -//! MSVCRT-`rand` XOR, the Pro path layers an MT19937-keyed XOR keystream, a self-inverse 20-byte -//! block swap, and an LZ4-compressed body, plus a checksum-derived 16-bit key the game validates -//! on load. -//! -//! Verified to decrypt a real GamePro-Pro save identically to the game and to round-trip -//! encrypt then decrypt. The algorithm is summarised inline below. -//! -//! ## On-disk layout -//! `[0x00..0x14] plaintext header` then the encrypted body -//! `[u32-LE uncompLen][u32-LE compLen][LZ4 block]`. The body, everything from `0x14`, is -//! enciphered. The decrypted inner save begins with the `0x19` title marker at offset 0, not at -//! `0x14` as in a standard save. The `u16` title length sits at `inner[1..3]` and the title bytes -//! follow at `inner[3..]`. -//! -//! ## Pipeline -//! * seed3 = `{file[0], file[1], file[5]}`. If all three are zero the body is plain, no cipher. -//! * MT seed = `combined = (s0<<16)|(s1<<8)|s2` folded through one xorshift32 round. -//! * padtable[128] = the low byte of each of the first 128 MT19937 outputs. -//! * block_swap = swap `file[0x16..0x2a]` with `file[0x5c..0x70]`, self-inverse. -//! * xor = `buf[off] ^= padtable[off & 0x7f]` for `off` in `0x14..len`. -//! * Decrypt: block_swap, then xor, then LZ4-decompress the framed body. -//! * Encrypt: LZ4-compress, frame, write key16 into the header, xor, then block_swap. - -use wolf_core::codec::lz4; - -/// First body offset. Bytes `0x00..0x14` (the header) are never enciphered. -const BODY_OFFSET: usize = 0x14; -/// The two 20-byte regions exchanged by [`block_swap`]: `file[0x16..0x2a]` and `file[0x5c..0x70]`. -const SWAP_A: usize = 0x16; -const SWAP_B: usize = 0x5c; -const SWAP_LEN: usize = 0x14; -/// `block_swap`, and therefore the whole Pro cipher, needs at least this many bytes. -const MIN_FILE_SIZE: usize = SWAP_B + SWAP_LEN; // 0x70 -/// The XOR pad table length. Indexed by `off & 0x7f`. -const PAD_LEN: usize = 128; -/// The decrypted inner save's valid-save marker, at inner offset 0. -pub const INNER_MARKER: u8 = 0x19; - -// --------------------------------------------------------------------------------------------- -// MT19937 + seeding -// --------------------------------------------------------------------------------------------- - -const N: usize = 624; -const M: usize = 397; -const MATRIX_A: u32 = 0x9908_b0df; -const UPPER_MASK: u32 = 0x8000_0000; -const LOWER_MASK: u32 = 0x7fff_ffff; - -/// Standard MT19937. Init constant 1812433253, temper masks 0x9d2c5680 and 0xefc60000. -struct Mt19937 { - mt: [u32; N], - idx: usize, -} - -impl Mt19937 { - fn new(seed: u32) -> Self { - let mut mt = [0u32; N]; - mt[0] = seed; - for i in 1..N { - mt[i] = 1_812_433_253u32 - .wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30)) - .wrapping_add(i as u32); - } - Mt19937 { mt, idx: N } - } - - fn next_u32(&mut self) -> u32 { - if self.idx >= N { - for i in 0..N { - let y = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % N] & LOWER_MASK); - let mut next = self.mt[(i + M) % N] ^ (y >> 1); - if y & 1 != 0 { - next ^= MATRIX_A; - } - self.mt[i] = next; - } - self.idx = 0; - } - let mut y = self.mt[self.idx]; - self.idx += 1; - y ^= y >> 11; - y ^= (y << 7) & 0x9d2c_5680; - y ^= (y << 15) & 0xefc6_0000; - y ^= y >> 18; - y - } -} - -/// Derive the MT seed from the three header seed bytes. Assemble big-endian -/// `combined = (s0<<16)|(s1<<8)|s2`, then run one xorshift32 round (`x^=x<<13`, `x^=x>>17`, -/// `x^=x<<5`). -fn mt_seed_from_bytes(s0: u8, s1: u8, s2: u8) -> u32 { - let mut x = ((s0 as u32) << 16) | ((s1 as u32) << 8) | (s2 as u32); - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - x -} - -/// Build the 128-byte XOR pad table from the low byte of each of the first 128 MT19937 outputs. -fn build_padtable(s0: u8, s1: u8, s2: u8) -> [u8; PAD_LEN] { - let mut mt = Mt19937::new(mt_seed_from_bytes(s0, s1, s2)); - let mut pad = [0u8; PAD_LEN]; - for slot in pad.iter_mut() { - *slot = (mt.next_u32() & 0xff) as u8; - } - pad -} - -// --------------------------------------------------------------------------------------------- -// block swap and xor cipher (both operate in place) -// --------------------------------------------------------------------------------------------- - -/// Swap the two 20-byte blocks `buf[0x16..0x2a]` and `buf[0x5c..0x70]`. Self-inverse, so the same -/// call appears in both the decrypt and encrypt pipelines. The caller guarantees `buf.len() >= -/// 0x70`. -fn block_swap(buf: &mut [u8]) { - debug_assert!(buf.len() >= MIN_FILE_SIZE); - for i in 0..SWAP_LEN { - buf.swap(SWAP_A + i, SWAP_B + i); - } -} - -/// XOR the body (`buf[0x14..]`) with the repeating 128-byte pad, indexed by `off & 0x7f`. -fn xor_cipher(buf: &mut [u8], pad: &[u8; PAD_LEN]) { - for (off, byte) in buf.iter_mut().enumerate().skip(BODY_OFFSET) { - *byte ^= pad[off & (PAD_LEN - 1)]; - } -} - -// --------------------------------------------------------------------------------------------- -// key16 (the game validates this on marker-3 load, and a mismatch fails the load) -// --------------------------------------------------------------------------------------------- - -/// Fold the decompressed inner save into the 16-bit key the game stores at -/// `header[7] | header[4]<<8`. -/// -/// `v5` is the XOR of the 16-byte-aligned prefix `inner[0 .. 16*(len/16)]`. `v13` is the XOR of -/// every inner byte. Both are taken as 16-bit values with a zero high byte. `a4 = header[9]`, and -/// `a4 % 3` selects one of three polynomials. -pub fn key16(inner: &[u8], a4: u8) -> u16 { - let aligned = (inner.len() / 16) * 16; - let mut v5: u32 = 0; - for &b in &inner[..aligned] { - v5 ^= b as u32; - } - let mut v13 = v5; - for &b in &inner[aligned..] { - v13 ^= b as u32; - } - v5 &= 0xffff; - v13 &= 0xffff; - - let a4 = a4 as u32; - let na = !a4; // ~a4 over u32 - let key = match a4 % 3 { - 0 => 7u32.wrapping_mul(v13 ^ a4).wrapping_add(17), - 1 => a4.wrapping_add(v13 ^ (((16 * a4) | (a4 >> 4)) ^ 0x55)), - _ => a4 ^ (3u32.wrapping_mul((v5 ^ na).wrapping_add(51)) & 0xffff), - }; - (key & 0xffff) as u16 -} - -// --------------------------------------------------------------------------------------------- -// public codec API -// --------------------------------------------------------------------------------------------- - -/// Decrypt a raw Pro (marker-3) `.sav` buffer into its inner save: the LZ4-decompressed -/// plaintext, beginning with the `0x19` title marker at offset 0. -/// -/// Returns `Err` if the buffer is too small for the Pro cipher, the framed lengths are -/// inconsistent, or LZ4 decompression fails. If the three seed bytes are all zero the body is -/// stored plain and the framed body is decompressed directly. -pub fn decrypt_pro(raw: &[u8]) -> Result, String> { - if raw.len() < BODY_OFFSET { - return Err(format!( - "pro save too small ({} bytes; need at least {BODY_OFFSET})", - raw.len() - )); - } - let (s0, s1, s2) = (raw[0], raw[1], raw[5]); - - let mut buf = raw.to_vec(); - // No cipher when the seed bytes are all zero. The body is stored plain. The block_swap regions - // only exist for files of at least 0x70 bytes, and an enciphered file is always larger. - if (s0 | s1 | s2) != 0 { - if raw.len() < MIN_FILE_SIZE { - return Err(format!( - "pro save too small for the block swap ({} bytes; need at least {MIN_FILE_SIZE})", - raw.len() - )); - } - let pad = build_padtable(s0, s1, s2); - block_swap(&mut buf); // unscramble. - xor_cipher(&mut buf, &pad); // xor-decrypt the body. - } - - decompress_framed(&buf) -} - -/// Decompress the framed body `[u32 uncompLen][u32 compLen][LZ4 block]` at offset `0x14`. -fn decompress_framed(buf: &[u8]) -> Result, String> { - let frame = buf - .get(BODY_OFFSET..) - .ok_or_else(|| "pro save: missing body".to_string())?; - if frame.len() < 8 { - return Err("pro save: body shorter than the 8-byte LZ4 frame header".to_string()); - } - let uncomp_len = u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; - let comp_len = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]) as usize; - let block = frame - .get(8..8 + comp_len) - .ok_or_else(|| "pro save: compressed body runs past end of buffer".to_string())?; - lz4::decompress_block(block, uncomp_len).map_err(|e| format!("pro save: LZ4 decode failed: {e}")) -} - -/// Re-encrypt an inner save back into a raw Pro (marker-3) `.sav`, reusing the original 20-byte -/// `header20` (seed bytes, format markers, `a4`, and so on) and stamping in the freshly computed -/// `key16` so the game's load-time validation passes. -/// -/// `header20` must be at least 20 bytes. The first 20 are used. This is the inverse of -/// [`decrypt_pro`]: LZ4-compress the inner, frame it, write `key16` into `header[7]`/`header[4]`, -/// xor-encrypt the body, then block_swap. -pub fn encrypt_pro(inner: &[u8], header20: &[u8]) -> Result, String> { - if header20.len() < BODY_OFFSET { - return Err(format!( - "pro save: header is {} bytes; need at least {BODY_OFFSET}", - header20.len() - )); - } - let mut header = header20[..BODY_OFFSET].to_vec(); - let (s0, s1, s2) = (header[0], header[1], header[5]); - let a4 = header[9]; - - // The key the game validates folds the decompressed inner. - let key = key16(inner, a4); - header[7] = (key & 0xff) as u8; - header[4] = ((key >> 8) & 0xff) as u8; - - // LZ4-compress the inner and build the framed body [u32 uncompLen][u32 compLen][block]. - let block = lz4::compress_block(inner); - let mut buf = Vec::with_capacity(BODY_OFFSET + 8 + block.len()); - buf.extend_from_slice(&header); - buf.extend_from_slice(&(inner.len() as u32).to_le_bytes()); - buf.extend_from_slice(&(block.len() as u32).to_le_bytes()); - buf.extend_from_slice(&block); - - // Inverse cipher order: xor-encrypt the body, then block_swap. The block_swap regions only - // exist when the file is at least 0x70 bytes. A seedless save with no cipher has no such floor. - if (s0 | s1 | s2) != 0 { - if buf.len() < MIN_FILE_SIZE { - return Err(format!( - "pro save: enciphered output too small ({} bytes; block_swap needs at least \ - {MIN_FILE_SIZE})", - buf.len() - )); - } - let pad = build_padtable(s0, s1, s2); - xor_cipher(&mut buf, &pad); - block_swap(&mut buf); - } - Ok(buf) -} - -/// Detect a Pro (marker-3) save. Attempts the Pro decrypt and confirms the inner save begins with -/// the `0x19` title marker. It does not trust a header magic byte alone, so it returns `false` for -/// standard saves and for malformed input. -pub fn is_pro_save(raw: &[u8]) -> bool { - matches!(decrypt_pro(raw), Ok(inner) if inner.first() == Some(&INNER_MARKER)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mt_seed_matches_reference() { - // {0x05, 0xb2, 0xdb} are the real GamePro-Pro seed bytes. - let seed = mt_seed_from_bytes(0x05, 0xb2, 0xdb); - // combined = 0x05b2db, then one xorshift32 round. - let mut x = 0x05b2dbu32; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - assert_eq!(seed, x); - } - - #[test] - fn block_swap_is_self_inverse() { - let mut buf: Vec = (0..0x80u8).collect(); - let orig = buf.clone(); - block_swap(&mut buf); - assert_ne!(buf, orig, "swap must change the buffer"); - block_swap(&mut buf); - assert_eq!(buf, orig, "swap is self-inverse"); - } - - #[test] - fn xor_cipher_is_self_inverse() { - let pad = build_padtable(1, 2, 3); - let mut buf: Vec = (0..200u8).collect(); - let orig = buf.clone(); - xor_cipher(&mut buf, &pad); - assert_ne!(&buf[BODY_OFFSET..], &orig[BODY_OFFSET..]); - assert_eq!(&buf[..BODY_OFFSET], &orig[..BODY_OFFSET], "head untouched"); - xor_cipher(&mut buf, &pad); - assert_eq!(buf, orig); - } - - #[test] - fn roundtrip_synthetic_inner() { - // A synthetic inner save, which must start with the 0x19 marker. - let mut inner = vec![INNER_MARKER, 0x05, 0x00, b'A', b'B', b'C', b'D', 0x00]; - inner.extend((0..2000u32).map(|i| (i % 251) as u8)); - - // A header with nonzero seeds so the cipher actually runs. - let mut header = vec![0u8; BODY_OFFSET]; - header[0] = 0x05; - header[1] = 0xb2; - header[5] = 0xdb; - header[9] = 0x32; - - let enc = encrypt_pro(&inner, &header).expect("encrypt"); - assert!(is_pro_save(&enc), "produced save must be detected as Pro"); - let dec = decrypt_pro(&enc).expect("decrypt"); - assert_eq!(dec, inner, "encrypt then decrypt must be identity"); - - // key16 stored in the header must validate against the inner. - let stored = enc[7] as u16 | ((enc[4] as u16) << 8); - assert_eq!(stored, key16(&inner, header[9])); - } - - #[test] - fn seedless_save_is_plain_body() { - // All-zero seeds: the body is plain with no cipher, but still LZ4-framed. - let mut inner = vec![INNER_MARKER, 0x01, 0x00, b'X', 0x00]; - inner.extend(std::iter::repeat(0xAB).take(300)); - let header = vec![0u8; BODY_OFFSET]; // seeds s0,s1,s5 all zero - let enc = encrypt_pro(&inner, &header).expect("encrypt"); - let dec = decrypt_pro(&enc).expect("decrypt"); - assert_eq!(dec, inner); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/spec.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/spec.rs deleted file mode 100644 index 6bed3c4..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/spec.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! The command reference. Argument schemas for every Wolf command id and move-route -//! sub-command, loaded from `data/commands.json`. Drives bespoke rendering so no command -//! falls back to an unlabeled dump. - -use std::collections::HashMap; -use std::sync::OnceLock; - -use serde::Deserialize; - -static REFERENCE_JSON: &str = include_str!("../data/commands.json"); - -#[derive(Debug, Deserialize)] -struct Reference { - commands: Vec, - #[serde(default)] - route_commands: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct CommandSpec { - pub cid: u32, - pub name: String, - #[serde(default)] - pub int_args: Vec, - #[serde(default)] - pub string_args: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct IntField { - pub label: String, - #[serde(default)] - pub kind: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct StrField { - #[allow(dead_code)] - pub label: String, - #[serde(default)] - pub role: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RouteSpec { - pub id: u32, - pub name: String, - #[serde(default)] - pub args: String, -} - -struct Tables { - commands: HashMap, - routes: HashMap, -} - -fn tables() -> &'static Tables { - static TABLES: OnceLock = OnceLock::new(); - TABLES.get_or_init(|| { - // The data file may carry a UTF-8 BOM (Windows PowerShell export); strip it. - let json = REFERENCE_JSON.trim_start_matches('\u{feff}'); - let reference: Reference = - serde_json::from_str(json).expect("embedded data/commands.json failed to parse"); - let commands = reference.commands.into_iter().map(|c| (c.cid, c)).collect(); - let routes = reference - .route_commands - .into_iter() - .map(|r| (r.id, r)) - .collect(); - Tables { commands, routes } - }) -} - -/// Argument schema for a command id, if catalogued. -pub fn command(cid: u32) -> Option<&'static CommandSpec> { - tables().commands.get(&cid) -} - -/// Display name for a command id (falls back to `Cmd`). -pub fn command_name(cid: u32) -> String { - command(cid) - .map(|c| c.name.clone()) - .unwrap_or_else(|| format!("Cmd{cid}")) -} - -/// Reverse lookup: command id for a display name (for the recompiler). -pub fn cid_for_name(name: &str) -> Option { - tables() - .commands - .values() - .find(|c| c.name == name) - .map(|c| c.cid) -} - -/// Name of a move-route sub-command id (falls back to `route`). -pub fn route_name(id: u32) -> String { - tables() - .routes - .get(&id) - .map(|r| r.name.clone()) - .unwrap_or_else(|| format!("route{id}")) -} - -/// Reverse lookup: move-route sub-command id for a display name (for the recompiler). -/// Accepts the `route` fallback form too. -pub fn route_id_for_name(name: &str) -> Option { - if let Some(rest) = name.strip_prefix("route") { - if let Ok(id) = rest.parse::() { - return Some(id); - } - } - tables() - .routes - .values() - .find(|r| r.name == name) - .map(|r| r.id) -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/strings.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/strings.rs deleted file mode 100644 index 079c3a4..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/strings.rs +++ /dev/null @@ -1,2339 +0,0 @@ -//! Player-facing text extraction and injection for translation. Produces a curated, -//! speaker-attributed, scene-grouped view rather than a full structural string dump. -//! -//! Extracts only text shown to the player. The allowlist below was derived from the command -//! table and verified against real GamePro data. It skips comments, debug text, file paths, -//! and DB structural names. Each dialogue line is attributed to a speaker, reconstructed from -//! the engine's `\s[n]` name-variable codes, a literal nameplate line, or the face-graphic -//! window gate. Lines are grouped per event for context. -//! -//! Injection is patch-style, keyed by command index plus string-arg index so it is -//! reorder-safe. A translator edits only the `text` field. Untouched lines recompile -//! byte-exact. - -use std::collections::HashMap; -use std::fmt::Write as _; - -use serde_json::Value; -use wolf_formats::command::RawCommand; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::game_dat::GameDat; -use wolf_formats::map::Map; - -use crate::json::value_slots; -use crate::text::{decode_wstr, text_to_wstr}; - -// ---------------------------------------------------------------------------- -// Player-text allowlist (which command string args are shown to the player) -// ---------------------------------------------------------------------------- - -/// The player-facing string-arg indices of a command, given its ints. Empty means nothing to -/// translate. Keeps only dialogue, choice, and on-screen text. Never debug, comment, label, -/// path, condition-literal, or structural-name strings. -fn player_str_indices(cmd: &RawCommand) -> Vec { - let a = &cmd.int_args; - match cmd.cid { - // Message: the dialogue text. - 101 => vec![0], - // Choices: each option label (count = low nibble of arg0), skipping trailing empties. - 102 => { - let n = a.first().copied().unwrap_or(0) & 0x0F; - (0..n as usize) - .filter(|&i| { - cmd.str_args - .get(i) - .map(|w| { - !decode_wstr(w, true).is_empty() && !decode_wstr(w, false).is_empty() - }) - .unwrap_or(false) - }) - .collect() - } - // Picture: str0 is on-screen text only when the picture type (bits 4-7 of arg0) == 2. - 150 if (a.first().copied().unwrap_or(0) >> 4) & 0x0F == 2 => vec![0], - // SetString: a literal assigned to a string var. Player text only when it is visible - // text, not a file path, not empty, and not a pure control/substitution token. - 122 => { - if let Some(w) = cmd.str_args.first() { - if is_displayable_literal(w, cmd) { - return vec![0]; - } - } - vec![] - } - // CommonEvent (by id) / CommonEventByName: the string inputs passed to the called event - // become its CSelf5/6/7 and get rendered by display-helper events (the `\cself[5/6/7]` - // shown in Messages). A literal passed here is on-screen text. For 210 the inputs start - // at str0. For 300 str0 is the event name (structural), so inputs are str1..=3. Keep - // only displayable literals, dropping the `\r\n`, path, and empty slots. - 210 => (0..cmd.str_args.len()) - .filter(|&i| { - cmd.str_args - .get(i) - .map(|w| is_displayable_literal(w, cmd)) - .unwrap_or(false) - }) - .collect(), - 300 => (1..=3) - .filter(|&i| { - cmd.str_args - .get(i) - .map(|w| is_displayable_literal(w, cmd)) - .unwrap_or(false) - }) - .collect(), - // Database read/write: str0 is the literal value written into a cell. str1/2/3 are - // structural type/data/field name operands, never on-screen. Player text only when it - // is a field-by-name write (flagWord bit 0x40000) into a content field (str3 = the field - // name). This keeps the `<装備を外す>` and `装備を解除します。` window writes and skips the - // `x` and `------` affix-sentinel writes into 付与文字列. - 250 | 252 => { - let flag = a.get(3).copied().unwrap_or(0); - let by_name = flag & 0x40000 != 0; - // str0 is displayable and has real words. Rejects the `x`, `------`, and `\x00` - // sentinels by value, plus paths and `<<…>>` directives. - let str0_ok = cmd - .str_args - .first() - .map(|w| is_displayable_literal(w, cmd) && has_translatable_text(&decode_either(w))) - .unwrap_or(false); - if by_name { - // By-name write: keep only when the destination (str3) is a content field. This - // keeps `<装備を外す>` and `装備を解除します。` and excludes the by-name affix sentinels - // written into 付与文字列, a non-content field. - let field_ok = cmd - .str_args - .get(3) - .map(|w| field_name_is_content(&decode_either(w))) - .unwrap_or(false); - if field_ok && str0_ok { - return vec![0]; - } - vec![] - } else if str0_ok { - // By-ID write: no field name to classify. The displayable plus translatable gate - // keeps the base-system `…を習得!` level-up message written into the 基本システム用変数 - // scratch slot. That slot exists in no DB, so it has no other extraction path. The - // by-name sentinels never reach here. - vec![0] - } else { - vec![] - } - } - _ => vec![], - } -} - -/// Both-encoding decode. Try UTF-8, fall back to Shift-JIS when the call site does not know -/// the file encoding. Whichever decodes non-empty wins. -pub(crate) fn decode_either(w: &wolf_formats::WStr) -> String { - let u = decode_wstr(w, true); - if u.is_empty() { - decode_wstr(w, false) - } else { - u - } -} - -/// Heuristic: is a SetString, CommonEvent-input, or Database-write literal player-visible text -/// rather than an internal path, flag, or engine directive. -fn is_displayable_literal(w: &wolf_formats::WStr, _cmd: &RawCommand) -> bool { - displayable_text(&decode_either(w)) -} - -/// The displayable-literal test on already-decoded text (split out so it is unit-testable). -fn displayable_text(s: &str) -> bool { - let t = s.trim(); - if t.is_empty() { - return false; - } - // Strip inline Wolf codes first. A backslash begins every code (\cself, \c, \i, \f, \cdb, - // and so on), never a path separator. A plain `contains('\\')` test would drop every - // on-screen UI string that embeds a variable, colour, or icon code such as - // `\f[\cself[19]]Sell`, `Buy Voucher A (\cself[20])`, or `\cself[11]rose!`. - let body = strip_control_codes(t); - let body = body.trim(); - if body.is_empty() || body == "," { - return false; - } - // Engine name-lookup directive (`<>…`, `<< \sys[100] % >>`). - // Resolved at runtime, never shown. - if body.contains("<<") && body.contains(">>") { - return false; - } - // File path or resource: a known asset extension, or a forward slash inside a spaceless - // token (a bare path like `Save/System.sav` or `Data/`). A `/` inside a sentence, like - // "Skills/Items" or a price separator, is ordinary punctuation and stays. - let lower = body.to_ascii_lowercase(); - for ext in [ - ".png", ".ogg", ".mp3", ".mid", ".wav", ".sav", ".wolfx", ".json", ".mps", - ] { - if lower.ends_with(ext) { - return false; - } - } - if body.contains('/') && !body.chars().any(char::is_whitespace) { - return false; - } - true -} - -/// True for Japanese (or other CJK) script: hiragana, katakana (full and half width), and kanji. -/// Used to keep the noise-token filter from ever rejecting real Japanese text. -fn has_cjk(s: &str) -> bool { - s.chars().any(|c| { - let u = c as u32; - (0x3040..=0x30FF).contains(&u) // hiragana + katakana - || (0x3400..=0x4DBF).contains(&u) // CJK extension A - || (0x4E00..=0x9FFF).contains(&u) // CJK unified ideographs - || (0xFF66..=0xFF9F).contains(&u) // half-width katakana - }) -} - -/// An engine-data token rather than prose: a string the parser used to pull into the grid that a -/// translator should never touch. Two shapes, both checked on the control-code-stripped body and -/// only ever firing when there is no CJK in it (so Japanese text is never rejected): -/// * a single ASCII alphanumeric, the id/index cells like `a`/`b`/`5` that fill columns such as -/// `英字id` (alphabet id) or a skill-tree layer flag, and -/// * a bare ASCII identifier carrying a digit or underscore (`A006`, `nx1`, `_syst00`, -/// `m_09_01`), an internal CG/asset/variable code. -/// A plain ASCII word (`Sell`, `HP`, `OK`, `Nofile`) or any phrase with whitespace is kept, since -/// those carry no digit or underscore and are real on-screen text. -fn is_noise_token(body: &str) -> bool { - if body.is_empty() || has_cjk(body) { - return false; - } - // A lone angle-bracket placeholder. A `\cdb`/`\cself` DB-read concatenated with a literal - // ``/`` fallback strips down to just the bracket token. The `has_cjk` guard - // above means a real CJK menu label like `<装備を外す>` never reaches here, and the - // exactly-one-bracket test keeps ` ` (two pairs) and `put in` (text - // around the bracket) as real. - let b = body.trim(); - if b.starts_with('<') - && b.ends_with('>') - && b.matches('<').count() == 1 - && b.matches('>').count() == 1 - { - return true; - } - if body.chars().count() == 1 && body.chars().all(|c| c.is_ascii_alphanumeric()) { - return true; - } - body.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - && body.chars().any(|c| c.is_ascii_digit() || c == '_') -} - -/// A comma-separated list whose every token is a tiny CG/layer code: a single lowercase letter -/// followed by 1 to 3 digits (`a0`, `c1`, `l100`), or a single lowercase letter carrying a -/// `\cself` substitution (`g\cself[44]`). These build the engine's CG layer arguments and are -/// never player text. Checked on the RAW source, not the stripped body, because `g\cself[44]` -/// strips down to a bare `g` which is indistinguishable from a real initials list once stripped. -/// -/// The single-lowercase-letter lead is load-bearing: it keeps real short comma lists out of scope -/// (`OK,Cancel`, `HP,MP`, `Buy,Sell`, `Lv1,Lv2`, `L1,R1`, `Fire,Ice`, `1,2,3`), since those lead -/// with a capital, a second letter, or a non-letter. -fn is_code_list(raw: &str) -> bool { - if has_cjk(raw) || !raw.contains(',') { - return false; - } - let mut codes = 0usize; - for tok in raw.split(',') { - let body = strip_control_codes(tok); - let body = body.trim(); - if body.is_empty() { - continue; // a leading/trailing comma or a lone substitution - } - // The token must lead with exactly one lowercase ASCII letter. - let single_lower = body.chars().next().map_or(false, |c| c.is_ascii_lowercase()) - && body.chars().nth(1).map_or(true, |c| !c.is_ascii_alphabetic()); - let rest: String = body.chars().skip(1).collect(); - // `a0`/`l100`: the lead letter then 1 to 3 digits. - let is_code_digit = single_lower - && (1..=3).contains(&rest.chars().count()) - && rest.chars().all(|c| c.is_ascii_digit()); - // `g\cself[44]`: just the lead letter, and the raw token carried a backslash code. - let is_code_cself = single_lower && rest.is_empty() && tok.contains('\\'); - if !(is_code_digit || is_code_cself) { - return false; - } - codes += 1; - } - codes >= 2 -} - -/// True if the string has actual translatable words: at least one letter in any script once the -/// inline control codes (`\cself[..]`, `\s[..]`, `\f[..]`, `\E`, and so on) are removed, and the -/// remainder is real text rather than an engine-data token. Filters out pure -/// variable-substitution, symbol, and punctuation entries like `\cself[5]`, `\f[\cself[18]]\E▲`, -/// or a lone `+`, the id/index and CG/asset codes (`a`, `A006`, `nx1`, `_syst00`), and the -/// comma-separated CG layer code lists (`a0,b0,c1`). -pub(crate) fn has_translatable_text(s: &str) -> bool { - if is_code_list(s) { - return false; - } - let body = strip_control_codes(s); - let trimmed = body.trim(); - trimmed.chars().any(|c| c.is_alphabetic()) && !is_noise_token(trimmed) -} - -/// Remove Wolf inline control codes so the remaining real text can be measured. Used by the -/// displayable heuristic and name detection. -/// -/// A Wolf code is `\` plus a name (a run of ASCII letters/digits like `cself`/`space`/`f`/`E`, -/// or a single symbol like `-`/`>`/`<`/`.`/`|`/`!`/`^`) plus an optional `[..]` argument whose -/// brackets may nest, as in `\f[\cself[19]]`. The code name must be a maximal run so `\cself` -/// is consumed whole rather than leaving a bogus `self`, and nested args must be tracked so a -/// stray `]…` does not leak. Both would otherwise read as translatable text. The line-alignment -/// tags `` and `` are also dropped since they are formatting, not words. -/// -/// Ruby/furigana `\r[base,reading]` is special-cased. It renders `base` with `reading` as -/// furigana, so the translatable-text check keeps the base (the text before the first comma) -/// and drops the `\r[`, the `,reading`, and the `]`. A ruby-only line thus counts as -/// translatable because its base is real words. All other codes are stripped whole. An escaped -/// literal `\\r[..]` is not ruby. The walk reads the first `\` then the second `\` as the -/// single-symbol code `\\`, leaving `r[..]` as plain text, so it never reaches the `name == "r"` -/// branch. -fn strip_control_codes(s: &str) -> String { - let mut out = String::new(); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => { - // The code name: a maximal alphanumeric run, else a single symbol char. - let mut name = String::new(); - if matches!(chars.peek(), Some(c) if c.is_ascii_alphanumeric()) { - while matches!(chars.peek(), Some(c) if c.is_ascii_alphanumeric()) { - name.push(chars.next().unwrap()); - } - } else if let Some(c) = chars.next() { - name.push(c); - } - // An optional `[..]` argument, honouring nested brackets. Capture its body so - // the ruby base can be re-emitted. - let mut arg = String::new(); - let has_arg = chars.peek() == Some(&'['); - if has_arg { - chars.next(); - let mut depth = 1usize; - while depth > 0 { - match chars.next() { - Some('[') => { - depth += 1; - arg.push('['); - } - Some(']') => { - depth -= 1; - if depth > 0 { - arg.push(']'); - } - } - Some(c) => arg.push(c), - None => break, - } - } - } - // Ruby `\r[base,reading]`: keep the base (before the first comma), drop reading. - if name == "r" && has_arg { - let base = arg.split(',').next().unwrap_or(&arg); - out.push_str(base); - } - } - // Line-alignment tags `` and `` (right and centre). Pure formatting. - '<' => { - let mut look = chars.clone(); - if matches!(look.next(), Some('R' | 'C' | 'r' | 'c')) && look.next() == Some('>') { - chars.next(); - chars.next(); - } else { - out.push('<'); - } - } - _ => out.push(c), - } - } - out -} - -// ---------------------------------------------------------------------------- -// Speaker attribution -// ---------------------------------------------------------------------------- - -/// The global string-var slots that carry a speaker name in the BasicSystem engine (S[k]). -/// Maps the slot to a stable role label. The concrete name is resolved at render time or left -/// to the translator. Derived from SysDatabase 文字列変数名 and verified against `\s[..]` -/// frequency in GamePro. -fn speaker_role(slot: u32) -> Option<&'static str> { - Some(match slot { - 9 | 10 => "Heroine", - 12 => "Rival", - 13 => "Professor", - 15 => "Opponent", - 16 => "Opponent", - 31 => "Ally Trainer", - 32 => "Ally Monster", - 33 => "Enemy Trainer", - 34 => "Enemy Monster", - 999 => "Player", - _ => return None, - }) -} - -/// Speaker attribution state carried across an event's command stream. -#[derive(Default)] -struct SpeakerCtx { - /// Last `call "メッセージ顔グラフィック変更"(Face Graphic Number=N)` value. The window-type gate. - last_face: Option, -} - -impl SpeakerCtx { - /// Update state from a face-graphic-change call. `utf8` is the file encoding. - fn observe(&mut self, cmd: &RawCommand, utf8: bool) { - // call "メッセージ顔グラフィック変更"(Face Graphic Number=N) is cid 300 by-name. - if cmd.cid == 300 { - if let Some(name) = cmd.str_args.first() { - if decode_wstr(name, utf8) == "メッセージ顔グラフィック変更" { - self.last_face = cmd.int_args.get(2).map(|&v| v as i32 as i64); - } - } - } - } - - /// Resolve the speaker + source for a Message's text (its decoded line 1). - fn speaker_of(&self, text: &str) -> (String, String) { - // Strip a leading window-option prefix `@\n`. - let body = if let Some(rest) = text.strip_prefix('@') { - rest.split_once('\n').map(|(_, b)| b).unwrap_or(text) - } else { - text - }; - let line1 = body.split('\n').next().unwrap_or("").trim(); - - // (a) Leading speaker string-var code `\s[k]`, including the `\s[9]\s[10]` heroine combo. - if let Some(role) = leading_name_var(line1) { - return (role.to_string(), "string_var".to_string()); - } - - // (b) Literal short nameplate line for map NPCs. A nameplate face window precedes, and - // line 1 is a short token with no control codes or sentence punctuation, followed by - // a body. Confidence is encoded in the source tag. - let has_more = body.contains('\n'); - let nameplate_face = matches!(self.last_face, Some(n) if n >= 2); - if has_more - && !line1.is_empty() - && !line1.contains('\\') - && line1.chars().count() <= 16 - && !line1.ends_with(['。', '!', '?', '」', '.', '!', '?']) - && !line1.starts_with(['「', '『', '【']) - { - let src = if nameplate_face { - "literal_line1" - } else { - "literal_line1_lowconf" - }; - return (line1.to_string(), src.to_string()); - } - - // (c) No in-text speaker. Treat as narration. - ("Narration".to_string(), "narration".to_string()) - } -} - -/// If `line1` begins with a known speaker name-var code (`\s[k]` or the `\s[9]\s[10]` combo), -/// return the role label. -fn leading_name_var(line1: &str) -> Option<&'static str> { - let s = line1.strip_prefix("\\s[")?; - let (num, _) = s.split_once(']')?; - let k: u32 = num.parse().ok()?; - speaker_role(k) -} - -// ---------------------------------------------------------------------------- -// Extraction model -// ---------------------------------------------------------------------------- - -/// One extracted player-facing line, with its injection locator and speaker. -pub struct Line { - /// Command index within the event/page (stable inject key). - pub cmd: usize, - /// String-arg index within that command. - pub str_idx: usize, - pub speaker: String, - pub speaker_src: String, - pub source: String, -} - -/// A scene is one event (a common event, or a map event plus page), with its player-facing -/// lines in command order. -pub struct Scene { - pub event: u32, - pub page: Option, - pub name: String, - pub lines: Vec, -} - -/// Walk one command list, returning its player-facing lines with speakers attributed by a -/// stateful pass (face-graphic gate plus name-var and nameplate detection). -/// -/// `is_debug` marks the developer Debug common event, whose menu is built by cid250/252 DB writes -/// of internal operation labels (`変数操作`, `所持金等MAX`). Those are skipped there. The gate is -/// purely structural (the caller matched the exact event name plus the DB-write cids), so it never -/// inspects a string value. Real dialogue in that event is a Message (cid101) and survives. -fn scene_lines(cmds: &[RawCommand], utf8: bool, is_debug: bool) -> Vec { - let mut ctx = SpeakerCtx::default(); - let mut out = Vec::new(); - for (ci, cmd) in cmds.iter().enumerate() { - ctx.observe(cmd, utf8); - if is_debug && matches!(cmd.cid, 250 | 252) { - continue; - } - for si in player_str_indices(cmd) { - let Some(w) = cmd.str_args.get(si) else { - continue; - }; - let source = decode_wstr(w, utf8); - // Skip pure control-code, symbol, or punctuation entries like a `\cself[5]` dynamic - // choice, a `\f[..]\E▲` UI arrow, or a lone `+`. Nothing for a translator to touch. - if !has_translatable_text(&source) { - continue; - } - let (speaker, speaker_src) = if cmd.cid == 101 && si == 0 { - ctx.speaker_of(&source) - } else if cmd.cid == 102 { - ("Choice".to_string(), "choice".to_string()) - } else { - ("UI".to_string(), "ui".to_string()) - }; - out.push(Line { - cmd: ci, - str_idx: si, - speaker, - speaker_src, - source, - }); - } - } - out -} - -/// Extract every scene with player text from a common-events file (ordered by event id). -pub fn extract_common_events(ce: &CommonEventsFile) -> Vec { - let mut scenes = Vec::new(); - for ev in &ce.events { - // The developer Debug menu event builds itself from internal cid250/252 DB writes whose - // values are operation labels, not player text. Match the exact event name so sibling - // test events (デバtest, テスト) that hold real dialogue are untouched. Also accept the - // half-width spelling. To keep the Debug-menu labels instead, drop this flag. - let name = decode_wstr(&ev.name, ce.utf8); - let is_debug = name == "デバッグ" || name == "デバッグ"; - let lines = scene_lines(&ev.commands, ce.utf8, is_debug); - if !lines.is_empty() { - scenes.push(Scene { - event: ev.int_id, - page: None, - name, - lines, - }); - } - } - scenes -} - -/// Extract every scene with player text from a map (ordered by event, then page). -pub fn extract_map(map: &Map) -> Vec { - let mut scenes = Vec::new(); - for ev in &map.events { - for (pi, page) in ev.pages.iter().enumerate() { - // Maps have no developer Debug common event, so the debug gate is always off here. - let lines = scene_lines(&page.commands, map.utf8, false); - if !lines.is_empty() { - scenes.push(Scene { - event: ev.id, - page: Some(pi), - name: decode_wstr(&ev.name, map.utf8), - lines, - }); - } - } - } - scenes -} - -// ---------------------------------------------------------------------------- -// JSON serialization (text starts equal to source, the translator edits `text`) -// ---------------------------------------------------------------------------- - -/// Render extracted scenes as translation JSON. `text` starts equal to `source` and a -/// translator edits `text`. `source` is kept for drift detection on inject. -pub fn scenes_to_json(file: &str, kind: &str, scenes: &[Scene]) -> String { - let mut s = String::new(); - s.push_str("{\n"); - let _ = write!(s, " \"file\": {},\n", jstr(file)); - let _ = write!(s, " \"kind\": {},\n", jstr(kind)); - s.push_str(" \"scenes\": [\n"); - for (si, sc) in scenes.iter().enumerate() { - s.push_str(" {\n"); - let _ = write!(s, " \"event\": {},\n", sc.event); - if let Some(p) = sc.page { - let _ = write!(s, " \"page\": {p},\n"); - } - let _ = write!(s, " \"name\": {},\n", jstr(&sc.name)); - s.push_str(" \"lines\": [\n"); - for (li, ln) in sc.lines.iter().enumerate() { - let _ = write!( - s, - " {{\"cmd\": {}, \"str\": {}, \"speaker\": {}, \"speaker_src\": {}, \"source\": {}, \"text\": {}}}", - ln.cmd, - ln.str_idx, - jstr(&ln.speaker), - jstr(&ln.speaker_src), - jstr(&ln.source), - jstr(&ln.source) - ); - s.push_str(if li + 1 == sc.lines.len() { - "\n" - } else { - ",\n" - }); - } - s.push_str(" ]\n }"); - s.push_str(if si + 1 == scenes.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n}\n"); - s -} - -// ---------------------------------------------------------------------------- -// Injection (apply edited translations back, byte-exact, drift-checked) -// ---------------------------------------------------------------------------- - -/// Outcome of an inject. `applied`, `untranslated`, and `drifted` are the normal path. -/// `code_mismatch` and `unrepresentable` count lines skipped by a safety guard, left untouched -/// and reported. A non-zero count means the caller should exit non-zero even though the good -/// lines were written. -#[derive(Default)] -pub struct InjectStats { - pub applied: usize, - pub untranslated: usize, - pub drifted: usize, - /// Translation dropped/added/altered an inline control code vs the source (skipped). - pub code_mismatch: usize, - /// Translation has a char not representable in the file's (Shift-JIS) encoding (skipped). - pub unrepresentable: usize, -} - -/// Inject behaviour knobs. -#[derive(Default, Clone, Copy)] -pub struct InjectOptions { - /// Relax the inline-code preservation guard. Allows a translation whose `\code`, `@` - /// window prefix, or ``/`` tags differ from the source. Off by default, which is the - /// strict per-line block. - pub allow_code_drift: bool, - /// Normalize CJK punctuation in the translated text to ASCII equivalents (`「」` to `"`, `。` - /// to `.`, full-width `!?` to `!?`, and so on) at inject time, via [`normalize_en_punct`]. - /// Off by default. For English targets. Other languages and translators who keep CJK - /// punctuation are unaffected. Applied after the code-drift guard, before encoding. - pub normalize_punct: bool, -} - -/// Map CJK punctuation to its ASCII equivalent in `s`, leaving everything else untouched -/// (ASCII, kana, kanji, and decorative symbols like ♥ ♪ ✨ ♂). Opt-in normalization for English -/// targets, applied to the translated text on inject (see [`InjectOptions::normalize_punct`]). -/// -/// Safe to run over a whole string including inline control codes. Every Wolf code (`\cself[8]`, -/// ``, `@1`) is pure ASCII and contains none of these characters, so codes pass through -/// verbatim. Most entries are a 1:1 char remap. The multi-char expansions (`…` to `...`, `‥` to -/// `..`, `→` to `->`, `←` to `<-`) are spelled out. Brackets map to their open/close ASCII form. -pub fn normalize_en_punct(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - // Quote brackets -> straight double quote. - '「' | '」' | '『' | '』' => out.push('"'), - // Square / lenticular brackets -> ASCII square brackets. - '【' | '〔' => out.push('['), - '】' | '〕' => out.push(']'), - // Angle brackets -> ASCII angle brackets. - '〈' | '《' => out.push('<'), - '〉' | '》' => out.push('>'), - // Wave dashes -> tilde. - '~' | '〜' => out.push('~'), - // Ideographic full stop / commas. - '。' => out.push('.'), - '、' | ',' => out.push(','), - // Middle dot -> hyphen. - '・' => out.push('-'), - // Full-width ASCII-equivalent punctuation. - '!' => out.push('!'), - '?' => out.push('?'), - ':' => out.push(':'), - ';' => out.push(';'), - '%' => out.push('%'), - '&' => out.push('&'), - // Ellipses. - '…' => out.push_str("..."), - '‥' => out.push_str(".."), - // Reference mark -> asterisk. - '※' => out.push('*'), - // Dashes (em/en/full-width/horizontal-bar/hyphen) -> hyphen. - '―' | '─' | '-' | '—' | '‐' => out.push('-'), - // Curly quotes -> straight quotes. - '“' | '”' => out.push('"'), - '‘' | '’' => out.push('\''), - // Math-ish symbols. - '×' => out.push('x'), - '−' => out.push('-'), - '÷' => out.push('/'), - // Arrows. - '→' => out.push_str("->"), - '←' => out.push_str("<-"), - '↑' => out.push('^'), - '↓' => out.push('v'), - // Filled / hollow squares -> asterisk. - '■' | '□' => out.push('*'), - other => out.push(other), - } - } - out -} - -/// The ordered multiset of inline codes that must survive a translation edit, so a guard can -/// confirm none was dropped, added, or altered. Holds each `\` (alnum run or single -/// symbol) with its full nested `[..]` body, each ``/`` alignment tag, and a leading -/// `@` window prefix. Sorted, so reordering is allowed but the set must match, including -/// which variable each `\code` references. -/// -/// Ruby/furigana `\r[base,reading]` is excluded (a `name == "r"` code with a `[..]` body). An -/// English translation legitimately drops the ruby markup, rendering the base with no furigana, -/// so it must not be in the must-preserve set. An escaped `\\r[..]` is unaffected. It parses as -/// the `\\` code plus literal `r[..]` text, so the `name == "r"` body branch is never reached. -fn code_multiset(s: &str) -> Vec { - let mut codes = Vec::new(); - if let Some(rest) = s.strip_prefix('@') { - let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); - if !digits.is_empty() { - codes.push(format!("@{digits}")); - } - } - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => { - let mut code = String::from("\\"); - let mut name = String::new(); - if matches!(chars.peek(), Some(c) if c.is_ascii_alphanumeric()) { - while matches!(chars.peek(), Some(c) if c.is_ascii_alphanumeric()) { - let ch = chars.next().unwrap(); - name.push(ch); - code.push(ch); - } - } else if let Some(c) = chars.next() { - name.push(c); - code.push(c); - } - let has_arg = chars.peek() == Some(&'['); - if has_arg { - code.push(chars.next().unwrap()); - let mut depth = 1usize; - while depth > 0 { - match chars.next() { - Some('[') => { - depth += 1; - code.push('['); - } - Some(']') => { - depth -= 1; - code.push(']'); - } - Some(ch) => code.push(ch), - None => break, - } - } - } - // Ruby `\r[base,reading]` is droppable markup, not a must-preserve code. - if name == "r" && has_arg { - continue; - } - codes.push(code); - } - '<' => { - let mut look = chars.clone(); - if matches!(look.next(), Some('R' | 'C' | 'r' | 'c')) && look.next() == Some('>') { - let ch = chars.next().unwrap(); - chars.next(); - codes.push(format!("<{ch}>")); - } - } - _ => {} - } - } - codes.sort(); - codes -} - -/// Run the per-line safety guards and, if they pass, encode and write the translation into -/// `cell`. The caller has already confirmed the drift guard so the base still holds `source`. -/// Updates `stats` and emits a located diagnostic on a guarded skip. -pub(crate) fn write_translation( - cell: &mut wolf_formats::WStr, - source: &str, - text: &str, - utf8: bool, - opts: &InjectOptions, - stats: &mut InjectStats, - locator: &dyn Fn() -> String, -) { - // (1) Inline control-code preservation. Dropping or altering a \code, @ prefix, or - // / tag would break rendering, or for a \cself-referenced variable, logic. The guard - // runs on the pre-normalization `text`. The code multiset is pure ASCII and the punct map - // never touches ASCII, so the choice does not affect the result, but checking before - // normalization keeps the diagnostic showing the translator's actual edit. - if !opts.allow_code_drift { - let (a, b) = (code_multiset(source), code_multiset(text)); - if a != b { - stats.code_mismatch += 1; - eprintln!( - "{}: control-code mismatch - source has {a:?}, translation has {b:?}; edit the words but keep the \\codes and @window prefix (or pass --allow-code-drift)", - locator() - ); - return; - } - } - // (1b) Optional CJK to ASCII punctuation normalization for English targets. Applied after - // the code-drift guard, before encoding. Control codes are pure ASCII so they pass through. - let normalized; - let text = if opts.normalize_punct { - normalized = normalize_en_punct(text); - normalized.as_str() - } else { - text - }; - // (2) Encoding. A char not representable in the file's encoding (Shift-JIS games) must not - // be silently substituted. Skip this line and report so the rest of the batch still applies. - match text_to_wstr(text, utf8) { - Ok(w) => { - *cell = w; - stats.applied += 1; - } - Err(_) => { - stats.unrepresentable += 1; - eprintln!( - "{}: text not representable in Shift-JIS: {text:?}; choose an SJIS-encodable equivalent", - locator() - ); - } - } -} - -/// Apply a translation JSON back onto a common-events file. A line is applied only when its -/// `text` differs from `source` and the base command still holds `source` (drift guard). -pub fn inject_common_events( - json: &str, - base: &mut CommonEventsFile, - opts: &InjectOptions, -) -> Result { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let utf8 = base.utf8; - let by_id: HashMap = base - .events - .iter() - .enumerate() - .map(|(i, ev)| (ev.int_id, i)) - .collect(); - let mut stats = InjectStats::default(); - for sc in scenes_array(&root)? { - let ev_id = u(sc, "event")? as u32; - let &idx = by_id - .get(&ev_id) - .ok_or_else(|| format!("common event {ev_id} not in base"))?; - apply_lines(sc, &mut base.events[idx].commands, utf8, opts, &mut stats)?; - } - Ok(stats) -} - -/// Apply a translation JSON back onto a map, keyed by event id plus page. -pub fn inject_map(json: &str, base: &mut Map, opts: &InjectOptions) -> Result { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let utf8 = base.utf8; - let by_id: HashMap = base - .events - .iter() - .enumerate() - .map(|(i, ev)| (ev.id, i)) - .collect(); - let mut stats = InjectStats::default(); - for sc in scenes_array(&root)? { - let ev_id = u(sc, "event")? as u32; - let page = u(sc, "page")? as usize; - let &idx = by_id - .get(&ev_id) - .ok_or_else(|| format!("map event {ev_id} not in base"))?; - let cmds = base.events[idx] - .pages - .get_mut(page) - .map(|p| &mut p.commands) - .ok_or_else(|| format!("map event {ev_id} page {page} out of range"))?; - apply_lines(sc, cmds, utf8, opts, &mut stats)?; - } - Ok(stats) -} - -fn apply_lines( - sc: &Value, - cmds: &mut [RawCommand], - utf8: bool, - opts: &InjectOptions, - stats: &mut InjectStats, -) -> Result<(), String> { - let ev = sc.get("event").and_then(Value::as_u64).unwrap_or(0); - let page = sc.get("page").and_then(Value::as_u64); - let lines = sc - .get("lines") - .and_then(Value::as_array) - .ok_or("scene has no `lines`")?; - for ln in lines { - let ci = u(ln, "cmd")? as usize; - let si = u(ln, "str")? as usize; - let source = ln.get("source").and_then(Value::as_str).unwrap_or(""); - let text = ln.get("text").and_then(Value::as_str).unwrap_or(source); - if text == source { - stats.untranslated += 1; - continue; - } - let Some(cmd) = cmds.get_mut(ci) else { - stats.drifted += 1; - continue; - }; - let Some(slot) = cmd.str_args.get_mut(si) else { - stats.drifted += 1; - continue; - }; - // Drift guard: only overwrite if the base still holds the recorded source text. - if decode_wstr(slot, utf8) != source { - stats.drifted += 1; - continue; - } - let loc = || match page { - Some(p) => format!("event {ev} page {p} cmd {ci} str {si}"), - None => format!("event {ev} cmd {ci} str {si}"), - }; - write_translation(slot, source, text, utf8, opts, stats, &loc); - } - Ok(()) -} - -fn scenes_array(root: &Value) -> Result<&Vec, String> { - root.get("scenes") - .and_then(Value::as_array) - .ok_or_else(|| "translation JSON has no `scenes` array".to_string()) -} - -fn u(v: &Value, key: &str) -> Result { - v.get(key) - .and_then(Value::as_u64) - .ok_or_else(|| format!("missing/invalid `{key}`")) -} - -// ---------------------------------------------------------------------------- -// Database player-text extraction (item/skill/term/message content) -// ---------------------------------------------------------------------------- - -/// Field/type names (JP or EN) that mark a string field as internal: file, graphic, audio, -/// memo, sort, path, or formula. Never player text. -pub(crate) const DB_INTERNAL: &[&str] = &[ - "ファイル", - "画像", - "グラフィック", - "グラフィック", - "効果音", - "BGM", - "BGS", - "メモ", - "ソート", - "パス", - "計算", - // `日本語移動名` and similar is the internal map-transition key namespace, compared in - // StringConditions and never displayed. Translating it breaks the map-move override. The EN - // glossary renders it "...Move Name" or "...Name", so it must be denylisted here, checked - // first, rather than merely absent from DB_CONTENT, or the broad "Name" content keyword would - // re-admit it. The player-visible map name lives in a separate, still-extracted field. - "移動名", - "File", - "Graphic", - "Image", - "Sound", - "Path", - "Memo", - "Sort", - "Formula", - "Move Name", -]; - -/// Field/type names (JP or EN) that mark a string field as a single-string name: an identity -/// string with no sentence context that the game may look rows up by. These are owned by the -/// project glossary (`names.rs`) so the row name and every by-name reference stay consistent. -/// -/// `移動名` / "Move Name" is included here. The `日本語移動名` field is the internal map-transition -/// key namespace, compared in cid112 StringConditions, but also the on-screen location-banner -/// name. The glossary owns it and keeps it consistent with the cid250/cid112 references that -/// read it. The genuine-internal denylist in [`db_field_role`] is `DB_INTERNAL` minus `移動名`, -/// so this entry is reached as a name rather than rejected as internal. -pub(crate) const DB_NAME: &[&str] = &[ - "名前", - "名称", - "なまえ", - "タイトル", - "肩書", - "表示名", - "用語", - "メニュー", - "コマンド名", - "コマンド", - "単位", - "項目", - "愛称", - "呼び方", - "移動名", - "Name", - "Title", - "Term", - "Menu", - "Command", - "Unit", - "Display", - "Move Name", -]; - -/// Field/type names (JP or EN) that mark a string field as description or dialogue: a -/// contextual sentence, not an identity string. These stay independently translatable in the -/// per-file `db-strings` path. Two different translations of a description are not a conflict. -/// Dialogue fields (`セリフ`/`セリフ`/`効果文`) land here. -pub(crate) const DB_DESC: &[&str] = &[ - "説明", - "文章", - "効果文", - "メッセージ", - "セリフ", - "セリフ", - "Description", - "Message", - "Text", -]; - -/// A DB string-field value that is actually an asset path (file or folder selector content), -/// not player text. Used to exclude `type_byte == 1` folder-picker cells whose value is a path -/// while keeping `type_byte == 1` cells that hold a plain display name (status `表示名`). NUL-safe. -pub(crate) fn value_is_asset_path(s: &str) -> bool { - let t = s.trim().trim_end_matches('\0').trim(); - if t.is_empty() { - return false; - } - let lower = t.to_ascii_lowercase(); - const ASSET_EXT: &[&str] = &[ - ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".ogg", ".mp3", ".wav", ".mid", - ]; - if ASSET_EXT.iter().any(|e| lower.ends_with(e)) { - return true; - } - // A '/' inside a spaceless token is a bare folder/asset path, e.g. SE/[System]Cancel02.ogg. - t.contains('/') && !t.chars().any(char::is_whitespace) -} - -/// A DB cell whose every non-blank line is an asset path: a multi-line `.png` CG list, or a bare -/// folder. Tighter than [`value_is_asset_path`] so a one-line UI string with a single slash stays -/// text: a line counts as a path only with an asset extension, a trailing `/`, or two or more `/`. -/// CJK-bearing values return early, so no Japanese is ever dropped, and any line with whitespace -/// disqualifies the whole value (`1/4 of max HP`, `Buy/Sell` and friends stay). -fn value_is_asset_path_list(s: &str) -> bool { - if has_cjk(s) { - return false; - } - const ASSET_EXT: &[&str] = &[ - ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".ogg", ".mp3", ".wav", ".mid", - ]; - let body = strip_control_codes(s); - let mut any = false; - for line in body.split(['\r', '\n']) { - let line = line.trim().trim_end_matches('\0').trim(); - if line.is_empty() { - continue; - } - any = true; - if line.chars().any(char::is_whitespace) { - return false; - } - let lower = line.to_ascii_lowercase(); - let is_path = ASSET_EXT.iter().any(|e| lower.ends_with(e)) - || line.ends_with('/') - || line.matches('/').count() >= 2; - if !is_path { - return false; - } - } - any -} - -/// A DB id/code cell: the control-code-stripped, trimmed body is exactly 2 or 3 lowercase ASCII -/// letters (`za`, `kc`, `abc`), the values that fill id/layer columns like `英字id` / `abc` / -/// skill-tree layer flags. `is_ascii_lowercase` already rules out digits, uppercase, CJK and -/// symbols, so capitalised names (`Iris`) and uppercase labels (`OK`, `HP`) are never caught. -/// DB-extractor ONLY: in the command stream this same shape is real UI text (`gem`, `ml`), so it -/// must never go into the shared [`is_noise_token`] / [`has_translatable_text`]. -fn is_db_short_code(s: &str) -> bool { - let b = strip_control_codes(s); - let b = b.trim(); - let n = b.chars().count(); - (n == 2 || n == 3) && b.chars().all(|c| c.is_ascii_lowercase()) -} - -/// The translation-ownership role of a DB string cell. Exactly one extractor owns each role, so -/// a DB name is never translated in two places. -/// * [`Role::Internal`]: file, graphic, audio, memo, path, sort, formula, or temp. Never -/// translated. -/// * [`Role::Name`]: a single-string identity name. Owned by the project glossary -/// (`names.rs`), which keeps the row name and every by-name reference consistent. -/// * [`Role::Content`]: a description or dialogue sentence, or an unlabeled non-internal -/// field. Owned by the per-file `db-strings` path, independently translatable. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Role { - Internal, - Name, - Content, -} - -/// Tests for a temp or separator type. Wolf marks scratch types with a leading `×` or `x `. -/// Separators are an all-dash label. -fn is_temp_or_separator_type(type_name: &str) -> bool { - let tn = type_name.trim(); - tn.is_empty() || tn.starts_with('×') || tn.starts_with("x ") || tn.chars().all(|c| c == '-') -} - -/// `DB_INTERNAL` minus the `移動名`/`Move Name` map-key namespace. The genuine-internal denylist: -/// file, graphic, sound, BGM, memo, path, sort, formula. `移動名` is excluded so it is reachable -/// as a `Name`, the displayed location banner the glossary owns, not rejected as internal. -fn matches_genuine_internal(name: &str) -> bool { - const NAME_KEEP: &[&str] = &["移動名", "Move Name"]; - // Match ASCII case-insensitively. Japanese terms are unaffected by `to_ascii_lowercase`, so a - // field labelled `…memo` or `…File` is caught as internal regardless of case. Without this, a - // lowercase dev-note field like `100~199memo` would leak past the upper-case `Memo`/`File` - // entries. - let lower = name.to_ascii_lowercase(); - DB_INTERNAL - .iter() - .filter(|p| !NAME_KEEP.contains(p)) - .any(|p| lower.contains(&p.to_ascii_lowercase())) -} - -/// The single source of truth for a DB string cell's translation-ownership [`Role`]. Evaluated -/// in strict order: internal first, then description/dialogue, then name, default content. -/// -/// 1. `Internal`: `type_byte` not in {0,1} (only 0/1 hold strings), a temp/separator type, or a -/// genuine-internal field name (`DB_INTERNAL` minus the `移動名` map-key namespace). -/// 2. `Content`: the field or the type name matches a description/dialogue keyword -/// ([`DB_DESC`]). A contextual sentence, independently translatable in `db-strings`. -/// 3. `Name`: `is_first_string` (the canonical by-name column, even with an empty/unknown -/// label) or the field/type name matches a name keyword ([`DB_NAME`]). -/// 4. `Content` by default. An unlabeled non-internal non-desc field is independently -/// translatable. Safer than forcing glossary consistency on an unrecognised label. -/// -/// The per-cell `value_is_asset_path` exclusion for `type_byte == 1` cells is applied separately -/// at the call sites, since it depends on the row value. -pub(crate) fn db_field_role( - type_byte: u8, - type_name: &str, - field_jp: &str, - field_en: &str, - is_first_string: bool, -) -> Role { - // (1) Internal: structural gate, temp/separator type, or a genuine-internal field name. - if !matches!(type_byte, 0 | 1) || is_temp_or_separator_type(type_name) { - return Role::Internal; - } - let names = [field_jp, field_en]; - if names.iter().any(|n| matches_genuine_internal(n)) { - return Role::Internal; - } - let tn = type_name.trim(); - // (2) Content: a description/dialogue field or type, e.g. a …セリフ dialogue table or a 説明 - // field. Checked before Name so a `説明`/`セリフ` sentence is never pulled into the glossary. - if DB_DESC.iter().any(|p| tn.contains(p)) - || names.iter().any(|n| DB_DESC.iter().any(|p| n.contains(p))) - { - return Role::Content; - } - // (3) Name: the canonical by-name column, or a recognised name field or type (用語設定 Terms). - if is_first_string - || DB_NAME.iter().any(|p| tn.contains(p)) - || names.iter().any(|n| DB_NAME.iter().any(|p| n.contains(p))) - { - return Role::Name; - } - // (4) Default: an unlabeled or unrecognised non-internal field is independently translatable. - Role::Content -} - -/// Is this string field owned by the per-file `db-strings` path (description/dialogue content)? -/// True only for [`Role::Content`]. Name fields are owned by the glossary, so the two extractors -/// have disjoint ownership of DB string cells. The `is_first_string` flag (the canonical by-name -/// column) is supplied by the caller. A `db-strings`-only call site that lacks it passes `false`. -/// A first-string column with a recognised name label still classifies as `Name` either way. -pub(crate) fn db_player_field( - type_byte: u8, - type_name: &str, - field_jp: &str, - field_en: &str, - is_first_string: bool, -) -> bool { - db_field_role(type_byte, type_name, field_jp, field_en, is_first_string) == Role::Content -} - -/// Field-name-only "not internal" test for the command stream (cid 250/252 by-name writes), -/// where there is a destination field name but no full DB type context. A cid250 write into -/// either a description/dialogue field or a name field is player text, so this accepts `Name` or -/// `Content`, everything except `Internal`. The `×`/separator and `type_byte` gates do not apply -/// without type context, so it classifies on the field name alone against the genuine-internal -/// denylist. -fn field_name_is_content(name: &str) -> bool { - if matches_genuine_internal(name) { - return false; - } - // A bare field name with no type context is player text unless it is genuinely internal. - // Mirrors the Name or Content union (everything non-internal) of [`db_field_role`]. - true -} - -/// One extracted DB string cell with its (type, row, field) locator and bilingual field label. -pub struct DbLine { - pub type_id: usize, - pub row: usize, - pub field: usize, - pub row_name: String, - pub field_label: String, - pub source: String, -} - -/// Player-facing string cells of one DB type. -pub struct DbGroup { - pub type_id: usize, - pub type_label: String, - pub lines: Vec, -} - -fn bilingual_name(jp: &str, glossary: &HashMap) -> String { - match glossary.get(jp) { - Some(en) if en != jp => format!("{en} · {jp}"), - _ => jp.to_string(), - } -} - -/// Extract every player-facing string cell from a database (item, skill, term, message -/// content), grouped by type. `glossary` provides bilingual labels and powers EN field matching. -pub fn extract_db_strings(db: &Database, glossary: &HashMap) -> Vec { - let utf8 = db.utf8; - let mut groups = Vec::new(); - for (ti, t) in db.types.iter().enumerate() { - let type_name = decode_wstr(&t.name, utf8); - let fields_size = (t.dat_fields_size as usize).min(t.fields.len()); - let slots = value_slots(t, fields_size, utf8); - // Collect the player-facing content fields (Role::Content, since name fields are owned by - // the glossary) with their string-value slot and type_byte. The type_byte drives the per-cell - // value-is-path exclusion below. `is_first_string` tracks the canonical by-name column so - // the role classifier can recognise it. - let mut keep: Vec<(usize, usize, String, u8)> = Vec::new(); // (field_idx, slot, label, tb) - let mut seen_string = false; - for (fi, (_, is_str, slot)) in slots.iter().enumerate() { - if !is_str { - continue; - } - let is_first_string = !seen_string; - seen_string = true; - let fjp = decode_wstr(&t.fields[fi].name, utf8); - let fen = glossary.get(&fjp).map(String::as_str).unwrap_or(&fjp); - let tb = t.fields[fi].type_byte; - if db_player_field(tb, &type_name, &fjp, fen, is_first_string) { - keep.push((fi, *slot, bilingual_name(&fjp, glossary), tb)); - } - } - if keep.is_empty() { - continue; - } - let mut lines = Vec::new(); - for (ri, row) in t.data.iter().enumerate() { - let row_name = decode_wstr(&row.name, utf8); - for (fi, slot, label, tb) in &keep { - let source = row - .string_values - .get(*slot) - .map(|w| decode_wstr(w, utf8)) - .unwrap_or_default(); - if !has_translatable_text(&source) { - continue; - } - // A folder-picker (type_byte 1) cell whose value is an asset path is not text. - if *tb == 1 && value_is_asset_path(&source) { - continue; - } - // A list of asset paths (multi-line .png CG list, bare folder), any field type. - if value_is_asset_path_list(&source) { - continue; - } - // A short lowercase id/code value (英字id, abc, skill-tree layer flags). DB-only. - if is_db_short_code(&source) { - continue; - } - lines.push(DbLine { - type_id: ti, - row: ri, - field: *fi, - row_name: row_name.clone(), - field_label: label.clone(), - source, - }); - } - } - if !lines.is_empty() { - groups.push(DbGroup { - type_id: ti, - type_label: bilingual_name(&type_name, glossary), - lines, - }); - } - } - groups -} - -/// Render DB player-text groups as translation JSON. `text` starts equal to `source`. -pub fn db_strings_to_json(file: &str, groups: &[DbGroup]) -> String { - let mut s = String::new(); - s.push_str("{\n"); - let _ = write!(s, " \"file\": {},\n", jstr(file)); - s.push_str(" \"kind\": \"db\",\n"); - s.push_str(" \"groups\": [\n"); - for (gi, g) in groups.iter().enumerate() { - s.push_str(" {\n"); - let _ = write!(s, " \"type\": {},\n", g.type_id); - let _ = write!(s, " \"typeName\": {},\n", jstr(&g.type_label)); - s.push_str(" \"lines\": [\n"); - for (li, ln) in g.lines.iter().enumerate() { - let _ = write!( - s, - " {{\"row\": {}, \"field\": {}, \"rowName\": {}, \"fieldName\": {}, \"source\": {}, \"text\": {}}}", - ln.row, - ln.field, - jstr(&ln.row_name), - jstr(&ln.field_label), - jstr(&ln.source), - jstr(&ln.source) - ); - s.push_str(if li + 1 == g.lines.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n }"); - s.push_str(if gi + 1 == groups.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n}\n"); - s -} - -/// Apply DB translations back onto a database, keyed by type/row/field. Only lines whose `text` -/// differs from `source` and still match the base cell are changed. Unchanged cells keep their -/// exact bytes, so the database re-serializes byte-exact except for the edited strings. -pub fn inject_db_strings( - json: &str, - db: &mut Database, - opts: &InjectOptions, -) -> Result { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let utf8 = db.utf8; - let groups = root - .get("groups") - .and_then(Value::as_array) - .ok_or("DB translation JSON has no `groups` array")?; - let mut stats = InjectStats::default(); - for g in groups { - let ti = u(g, "type")? as usize; - let dt = db - .types - .get_mut(ti) - .ok_or_else(|| format!("type id {ti} out of range"))?; - let fields_size = (dt.dat_fields_size as usize).min(dt.fields.len()); - // Map field index to its string-value slot. Only string fields have one. - let slot_of: HashMap = value_slots(dt, fields_size, utf8) - .into_iter() - .enumerate() - .filter(|(_, (_, is_str, _))| *is_str) - .map(|(fi, (_, _, slot))| (fi, slot)) - .collect(); - let lines = g - .get("lines") - .and_then(Value::as_array) - .ok_or("DB group has no `lines`")?; - for ln in lines { - let ri = u(ln, "row")? as usize; - let fi = u(ln, "field")? as usize; - let source = ln.get("source").and_then(Value::as_str).unwrap_or(""); - let text = ln.get("text").and_then(Value::as_str).unwrap_or(source); - if text == source { - stats.untranslated += 1; - continue; - } - let Some(&slot) = slot_of.get(&fi) else { - stats.drifted += 1; - continue; - }; - let Some(row) = dt.data.get_mut(ri) else { - stats.drifted += 1; - continue; - }; - let Some(cell) = row.string_values.get_mut(slot) else { - stats.drifted += 1; - continue; - }; - if decode_wstr(cell, utf8) != source { - stats.drifted += 1; - continue; - } - let loc = || format!("db type {ti} row {ri} field {fi}"); - write_translation(cell, source, text, utf8, opts, &mut stats, &loc); - } - } - Ok(stats) -} - -// ---------------------------------------------------------------------------- -// Game.dat player-text extraction + injection (Title / TitlePlus / messages) -// ---------------------------------------------------------------------------- - -/// The player-facing Game.dat strings, keyed by a stable label. Only these are extracted for -/// translation. Fonts, graphics, the decrypt key, and the fixed magic string are structural and -/// never shown to the player. The accessor pairs each key with the relevant `Option` cell. -/// Some cells are gated on the file's `string_count`, so absent cells are skipped. -fn game_dat_fields(gd: &GameDat) -> Vec<(&'static str, &wolf_formats::WStr)> { - let mut out: Vec<(&'static str, &wolf_formats::WStr)> = vec![("Title", &gd.title)]; - if let Some(s) = &gd.title_plus { - out.push(("TitlePlus", s)); - } - if let Some(s) = &gd.start_up_msg { - out.push(("StartUpMsg", s)); - } - if let Some(s) = &gd.title_msg { - out.push(("TitleMsg", s)); - } - out -} - -/// Extract the player-facing Game.dat strings as translation JSON. Each line is `{key, source, -/// text}` with `text` starting equal to `source`. Empty strings are skipped. `source` is kept -/// for drift detection on inject. -pub fn extract_game_dat(gd: &GameDat) -> String { - let utf8 = gd.utf8; - let mut lines: Vec<(&'static str, String)> = Vec::new(); - for (key, w) in game_dat_fields(gd) { - let source = decode_wstr(w, utf8); - if source.is_empty() { - continue; - } - lines.push((key, source)); - } - - let mut s = String::new(); - s.push_str("{\n"); - s.push_str(" \"file\": \"Game.dat\",\n"); - s.push_str(" \"kind\": \"gamedat\",\n"); - s.push_str(" \"lines\": [\n"); - for (li, (key, source)) in lines.iter().enumerate() { - let _ = write!( - s, - " {{\"key\": {}, \"source\": {}, \"text\": {}}}", - jstr(key), - jstr(source), - jstr(source) - ); - s.push_str(if li + 1 == lines.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n}\n"); - s -} - -/// Apply a Game.dat translation JSON back onto the parsed file, keyed by field name. A line is -/// applied only when its `text` differs from `source` and the base cell still holds `source` -/// (drift guard). The shared [`write_translation`] helper enforces the control-code and encoding -/// guards, so untouched fields keep their exact bytes and the file re-serializes byte-exact. -pub fn inject_game_dat( - json: &str, - gd: &mut GameDat, - opts: &InjectOptions, -) -> Result { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let utf8 = gd.utf8; - let lines = root - .get("lines") - .and_then(Value::as_array) - .ok_or("Game.dat translation JSON has no `lines` array")?; - let mut stats = InjectStats::default(); - for ln in lines { - let key = ln - .get("key") - .and_then(Value::as_str) - .ok_or("Game.dat line missing `key`")?; - let source = ln.get("source").and_then(Value::as_str).unwrap_or(""); - let text = ln.get("text").and_then(Value::as_str).unwrap_or(source); - if text == source { - stats.untranslated += 1; - continue; - } - // Resolve the target cell for this key. Only the gated optionals can be absent. - let Some(cell) = game_dat_cell(gd, key) else { - stats.drifted += 1; - continue; - }; - // Drift guard: only overwrite if the base still holds the recorded source text. - if decode_wstr(cell, utf8) != source { - stats.drifted += 1; - continue; - } - let loc = || format!("gamedat {key}"); - write_translation(cell, source, text, utf8, opts, &mut stats, &loc); - } - Ok(stats) -} - -/// Mutable accessor for a player-facing Game.dat field by its extraction key. None if the cell -/// is absent for this file's `string_count`. -fn game_dat_cell<'a>(gd: &'a mut GameDat, key: &str) -> Option<&'a mut wolf_formats::WStr> { - match key { - "Title" => Some(&mut gd.title), - "TitlePlus" => gd.title_plus.as_mut(), - "StartUpMsg" => gd.start_up_msg.as_mut(), - "TitleMsg" => gd.title_msg.as_mut(), - _ => None, - } -} - -fn jstr(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -// ---------------------------------------------------------------------------- -// Game.dat FULL editing (every editable string field, not just player text) -// ---------------------------------------------------------------------------- -// -// A broader view than the player-text `extract_game_dat`/`inject_game_dat` pair above. -// `dump_game_dat`/`apply_game_dat` expose all editable string fields (fonts, graphics, image -// paths, the opaque trailing string) as a flat JSON object, mirroring the `db-json`/`db-apply` -// design. Structural fields (indicator, magic, decrypt key, magic string, and the size/offset -// housekeeping) are never exposed and never touched. `GameDat::write` recomputes the three -// housekeeping offsets from the edited string lengths, so untouched fields re-serialize -// byte-exact. - -/// The non-optional editable scalar string fields, keyed by their JSON name. Every Game.dat -/// carries them, so they round-trip unconditionally, including when empty. `SubFonts` is emitted -/// separately by [`dump_game_dat`] since it is an array, not a scalar. -fn game_dat_full_fixed(gd: &GameDat) -> Vec<(&'static str, &wolf_formats::WStr)> { - vec![ - ("Title", &gd.title), - ("Font", &gd.font), - ("DefaultPcGraphic", &gd.default_pc_graphic), - ] -} - -/// The optional editable string fields, keyed by their JSON name. Present only when the file's -/// `string_count` carries them. `dump_game_dat` skips a `None` with no phantom key, and -/// `apply_game_dat` refuses to materialize one that was absent, editing only existing cells. -fn game_dat_full_optionals(gd: &GameDat) -> Vec<(&'static str, Option<&wolf_formats::WStr>)> { - vec![ - ("TitlePlus", gd.title_plus.as_ref()), - ("RoadImg", gd.road_img.as_ref()), - ("GaugeImg", gd.gauge_img.as_ref()), - ("StartUpMsg", gd.start_up_msg.as_ref()), - ("TitleMsg", gd.title_msg.as_ref()), - ("UnknownString14", gd.unknown_string14.as_ref()), - ] -} - -/// Mutable accessor for a single full-view scalar field by its JSON key. Returns `None` when the -/// key is unknown or refers to an optional cell that is absent for this file, so `apply_game_dat` -/// never creates or destroys an `Option`. `SubFonts` is handled separately since it is an array. -fn game_dat_full_cell<'a>( - gd: &'a mut GameDat, - key: &str, -) -> Option<&'a mut wolf_formats::WStr> { - match key { - "Title" => Some(&mut gd.title), - "Font" => Some(&mut gd.font), - "DefaultPcGraphic" => Some(&mut gd.default_pc_graphic), - "TitlePlus" => gd.title_plus.as_mut(), - "RoadImg" => gd.road_img.as_mut(), - "GaugeImg" => gd.gauge_img.as_mut(), - "StartUpMsg" => gd.start_up_msg.as_mut(), - "TitleMsg" => gd.title_msg.as_mut(), - "UnknownString14" => gd.unknown_string14.as_mut(), - _ => None, - } -} - -/// Dump every editable Game.dat string field to a pretty JSON object keyed by field name, with -/// each `WStr` decoded to a UTF-8 string. `encoding` (`"utf8"`/`"shiftjis"`) is informational and -/// read-only. Optional fields absent for this file's `string_count` are omitted with no phantom -/// keys. Empty strings are kept as empty strings. `SubFonts` is a 3-element array. The inverse is -/// [`apply_game_dat`]. The round-trip (dump, apply, [`GameDat::write`]) reproduces the file -/// byte-exact when no value was changed. -pub fn dump_game_dat(gd: &GameDat) -> String { - let utf8 = gd.utf8; - let mut s = String::new(); - s.push_str("{\n"); - let _ = write!( - s, - " \"encoding\": {},\n", - jstr(if utf8 { "utf8" } else { "shiftjis" }) - ); - - // Fixed scalars, always present. - for (key, w) in game_dat_full_fixed(gd) { - let _ = write!(s, " {}: {},\n", jstr(key), jstr(&decode_wstr(w, utf8))); - } - - // SubFonts is a fixed 3-element array. - s.push_str(" \"SubFonts\": ["); - for (i, f) in gd.sub_fonts.iter().enumerate() { - if i > 0 { - s.push_str(", "); - } - s.push_str(&jstr(&decode_wstr(f, utf8))); - } - s.push_str("],\n"); - - // Optionals: emit only those present for this file. Track the last one to drop the trailing - // comma without a dangling separator, since the object's prior lines all end in `,`. - let present: Vec<(&'static str, String)> = game_dat_full_optionals(gd) - .into_iter() - .filter_map(|(k, w)| w.map(|w| (k, decode_wstr(w, utf8)))) - .collect(); - for (i, (key, val)) in present.iter().enumerate() { - let sep = if i + 1 == present.len() { "\n" } else { ",\n" }; - let _ = write!(s, " {}: {}{}", jstr(key), jstr(val), sep); - } - if present.is_empty() { - // No optionals: the SubFonts line above still ended in `,`. Trim it to keep valid JSON. - if let Some(stripped) = s.strip_suffix(",\n") { - s = format!("{stripped}\n"); - } - } - - s.push_str("}\n"); - s -} - -/// Apply a full-view Game.dat JSON (from [`dump_game_dat`]) back onto a parsed file. For each -/// present, recognized key, the field is re-encoded to the file's encoding (the same `WStr` path -/// as `inject_game_dat`) and set. A value equal to the current one is a no-op. Returns the number -/// of fields actually changed. -/// -/// Guarantees: never creates or destroys an `Option` field that was not present in the base. A -/// key for an absent optional is treated as drift, counted nowhere, and skipped with a warning. -/// Never touches structural fields. The `encoding` key is read-only. Unknown keys are ignored -/// with a warning. Errors only on malformed JSON or a value not representable in the file's -/// encoding. -pub fn apply_game_dat(gd: &mut GameDat, json: &str) -> Result { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let obj = root - .as_object() - .ok_or("Game.dat full JSON must be a JSON object")?; - let utf8 = gd.utf8; - let mut changed = 0usize; - - // Recognized keys: scalars, the SubFonts array, and the read-only encoding marker. Lets - // unknown keys be reported without confusing them for structural fields. - const SCALAR_KEYS: &[&str] = &[ - "Title", - "Font", - "DefaultPcGraphic", - "TitlePlus", - "RoadImg", - "GaugeImg", - "StartUpMsg", - "TitleMsg", - "UnknownString14", - ]; - - for (key, val) in obj { - match key.as_str() { - "encoding" => {} // informational, read-only - "SubFonts" => { - let arr = val.as_array().ok_or("`SubFonts` must be a JSON array")?; - if arr.len() != gd.sub_fonts.len() { - return Err(format!( - "`SubFonts` must have exactly {} entries (got {})", - gd.sub_fonts.len(), - arr.len() - )); - } - for (i, entry) in arr.iter().enumerate() { - let text = entry - .as_str() - .ok_or("`SubFonts` entries must be strings")?; - let cur = decode_wstr(&gd.sub_fonts[i], utf8); - if cur == text { - continue; - } - let w = text_to_wstr(text, utf8) - .map_err(|e| format!("SubFonts[{i}]: {e}"))?; - gd.sub_fonts[i] = w; - changed += 1; - } - } - k if SCALAR_KEYS.contains(&k) => { - let text = val - .as_str() - .ok_or_else(|| format!("`{k}` must be a string"))?; - // Optional cells absent for this file must not be materialized. Treat as drift. - let Some(cell) = game_dat_full_cell(gd, k) else { - eprintln!( - "gamedat-apply: `{k}` is not present in this Game.dat (string_count too low); skipping" - ); - continue; - }; - if decode_wstr(cell, utf8) == text { - continue; // no change - } - let w = text_to_wstr(text, utf8).map_err(|e| format!("{k}: {e}"))?; - *cell = w; - changed += 1; - } - other => { - eprintln!("gamedat-apply: ignoring unknown key `{other}`"); - } - } - } - Ok(changed) -} - -// ---------------------------------------------------------------------------- -// Coverage audit (QA): every string arg in the corpus plus whether extraction keeps -// it, so precision (kept-but-not-text) and recall (text-but-dropped) can be judged -// against the actual bytes rather than guessed. -// ---------------------------------------------------------------------------- - -/// One string occurrence in a command stream, tagged with extraction status. -struct StrAudit { - cid: u32, - cmd: usize, - str_idx: usize, - text: String, - extracted: bool, -} - -/// Every non-empty string arg of every command in a list, tagged with whether the player-text -/// extractor keeps it. Matched against `scene_lines`, so the allowlist and filters are exactly -/// the live ones. -fn audit_commands(cmds: &[RawCommand], utf8: bool) -> Vec { - use std::collections::HashSet; - // The QA audit reports raw extraction coverage, so the Debug-event gate is left off here. - let kept: HashSet<(usize, usize)> = scene_lines(cmds, utf8, false) - .iter() - .map(|l| (l.cmd, l.str_idx)) - .collect(); - let mut out = Vec::new(); - for (ci, cmd) in cmds.iter().enumerate() { - for (si, w) in cmd.str_args.iter().enumerate() { - let text = decode_wstr(w, utf8); - if text.is_empty() { - continue; - } - out.push(StrAudit { - cid: cmd.cid, - cmd: ci, - str_idx: si, - text, - extracted: kept.contains(&(ci, si)), - }); - } - } - out -} - -fn write_audit_entries(s: &mut String, evt: &str, page: Option, entries: &[StrAudit]) { - for e in entries { - let _ = write!( - s, - " {{\"event\": {evt}, \"page\": {}, \"cmd\": {}, \"cid\": {}, \"cmdName\": {}, \"str\": {}, \"extracted\": {}, \"text\": {}}},\n", - page.map(|p| p.to_string()).unwrap_or_else(|| "null".into()), - e.cmd, - e.cid, - jstr(&crate::spec::command_name(e.cid)), - e.str_idx, - e.extracted, - jstr(&e.text), - ); - } -} - -/// Full per-string coverage dump of a common-events file. Every event, every string arg. -pub fn audit_common_events_to_json(ce: &CommonEventsFile) -> String { - let mut s = String::from("{\n \"kind\": \"common\",\n \"entries\": [\n"); - for ev in &ce.events { - write_audit_entries( - &mut s, - &ev.int_id.to_string(), - None, - &audit_commands(&ev.commands, ce.utf8), - ); - } - finish_audit(s) -} - -/// Full per-string coverage dump of a map. Every event, every page, every string arg. -pub fn audit_map_to_json(map: &Map) -> String { - let mut s = String::from("{\n \"kind\": \"map\",\n \"entries\": [\n"); - for ev in &map.events { - for (pi, page) in ev.pages.iter().enumerate() { - write_audit_entries( - &mut s, - &ev.id.to_string(), - Some(pi), - &audit_commands(&page.commands, map.utf8), - ); - } - } - finish_audit(s) -} - -/// Full per-cell coverage dump of a database. Every string cell across player and internal -/// fields, tagged with `playerField` (whether the field name classifies as content) and -/// `extracted`. -pub fn audit_db_to_json(db: &Database, glossary: &HashMap) -> String { - let utf8 = db.utf8; - let mut s = String::from("{\n \"kind\": \"db\",\n \"entries\": [\n"); - for (ti, t) in db.types.iter().enumerate() { - let type_name = decode_wstr(&t.name, utf8); - let fields_size = (t.dat_fields_size as usize).min(t.fields.len()); - let slots = value_slots(t, fields_size, utf8); - let mut seen_string = false; - for (fi, (_, is_str, slot)) in slots.iter().enumerate() { - if !is_str { - continue; - } - let is_first_string = !seen_string; - seen_string = true; - let fjp = decode_wstr(&t.fields[fi].name, utf8); - let fen = glossary.get(&fjp).map(String::as_str).unwrap_or(&fjp); - let type_byte = t.fields[fi].type_byte; - let is_player = db_player_field(type_byte, &type_name, &fjp, fen, is_first_string); - let field_args = &t.fields[fi].string_args; - // First string-arg of a field config is the file folder or DB-ref selector hint. - let cfg0 = field_args - .first() - .map(|w| decode_wstr(w, utf8)) - .unwrap_or_default(); - for (ri, row) in t.data.iter().enumerate() { - let text = row - .string_values - .get(*slot) - .map(|w| decode_wstr(w, utf8)) - .unwrap_or_default(); - if text.is_empty() { - continue; - } - let extracted = is_player - && has_translatable_text(&text) - && !(type_byte == 1 && value_is_asset_path(&text)); - let _ = write!( - s, - " {{\"type\": {ti}, \"typeName\": {}, \"row\": {ri}, \"rowName\": {}, \"field\": {fi}, \"fieldName\": {}, \"typeByte\": {type_byte}, \"cfg0\": {}, \"playerField\": {is_player}, \"extracted\": {extracted}, \"text\": {}}},\n", - jstr(&type_name), - jstr(&decode_wstr(&row.name, utf8)), - jstr(&fjp), - jstr(&cfg0), - jstr(&text), - ); - } - } - } - finish_audit(s) -} - -/// Trim the trailing `,\n` and close the audit JSON array and object. -fn finish_audit(mut s: String) -> String { - if s.ends_with(",\n") { - s.truncate(s.len() - 2); - s.push('\n'); - } - s.push_str(" ]\n}\n"); - s -} - -#[cfg(test)] -mod tests { - use super::*; - use wolf_formats::WStr; - - #[test] - fn strip_handles_multichar_codes_and_nesting() { - // Multi-char names must vanish whole, not decay to `self`. - assert_eq!(strip_control_codes("\\cself[6]"), ""); - assert_eq!(strip_control_codes("\\space[0]"), ""); - // Nested codes: the font code's argument is itself a substitution. - assert_eq!(strip_control_codes("\\f[\\cself[19]]\\cself[6]"), ""); - // Symbol codes and alignment tags. - assert_eq!( - strip_control_codes("\\space[0]\\f[\\cself[17]]\\-[1]\\E\\cself[6]"), - "" - ); - // Real words survive, with their surrounding substitutions stripped for the check. - assert_eq!( - strip_control_codes("「\\cself[8]」を手に入れた。"), - "「」を手に入れた。" - ); - } - - #[test] - fn strip_keeps_ruby_base_drops_reading() { - // Ruby `\r[base,reading]`: the base text is kept as the translatable word. The reading - // and the markup are dropped. A ruby-only line therefore counts as translatable. - assert!(strip_control_codes("\\r[市松,いちまつ]").contains("市松")); - assert_eq!(strip_control_codes("\\r[市松,いちまつ]"), "市松"); - // Ruby base inline among other text and codes. - assert_eq!( - strip_control_codes("\\r[市松,いちまつ]\\cself[3]さん"), - "市松さん" - ); - // An escaped literal `\\r[..]` is not ruby. The first `\` and second `\` parse as the - // `\\` single-symbol code, leaving `r[人生,じんせい]` as plain text, so the base is not - // re-emitted. The literal `r[..]` including the reading stays. - assert_eq!( - strip_control_codes("\\\\r[人生,じんせい]"), - "r[人生,じんせい]" - ); - // The recall check (used by `has_translatable_text`) sees the ruby base as words. - assert!(has_translatable_text("\\r[市松,いちまつ]")); - } - - #[test] - fn displayable_literal_keeps_coded_text_rejects_internal() { - // Real UI text that embeds an inline code must be kept. A plain `contains('\\')` path - // test would wrongly drop all of these. - assert!(displayable_text("\\f[\\cself[19]]Sell")); - assert!(displayable_text("Buy Voucher A (\\cself[20])")); - assert!(displayable_text("\\cself[11]rose!")); - assert!(displayable_text("\\c[1]3000 chips\\c[0] to start")); - // A forward slash inside a sentence is ordinary punctuation. Keep it. - assert!(displayable_text( - "Save cursor position when opening Skills/Items" - )); - // Internal: engine directive, asset paths, bare path tokens, pure substitution. - assert!(!displayable_text( - "<>■主人公ピクセル移動切り替え" - )); - assert!(!displayable_text("<< \\sys[100] % >>")); - assert!(!displayable_text("Save/System.sav")); - assert!(!displayable_text("Data/")); - assert!(!displayable_text("SE/door.ogg")); - assert!(!displayable_text("\\cself[5]")); - } - - #[test] - fn asset_path_values_rejected_names_kept() { - // type_byte==1 folder-picker cells: a path value is excluded, a plain display name is kept. - assert!(value_is_asset_path("Base_window/WindowBaseA.png")); - assert!(value_is_asset_path("SE/[System]Cancel02.ogg")); - assert!(value_is_asset_path( - "BalloonBase_CafeLatte/[CafeLatte]nameL.png" - )); - assert!(!value_is_asset_path("Poison")); // a status 表示名 - assert!(!value_is_asset_path("毒")); // a JP status name - assert!(!value_is_asset_path("Game Over")); // a name with a space, no ext - assert!(!value_is_asset_path("")); // empty or NUL-only - assert!(!value_is_asset_path("\u{0}")); - } - - #[test] - fn code_multiset_detects_dropped_or_altered_codes() { - // Same codes in any order compare equal. A dropped, altered, or added code compares different. - assert_eq!( - code_multiset("「\\cself[8]」を手に入れた。"), - vec!["\\cself[8]"] - ); - assert_eq!( - code_multiset("Got \\cself[8]!"), - code_multiset("「\\cself[8]」") - ); - assert_ne!(code_multiset("「\\cself[8]」"), code_multiset("Got it!")); // dropped - assert_ne!(code_multiset("\\cself[8]"), code_multiset("\\cself[9]")); // ref changed - // @-window prefix and / alignment tags are tracked. - assert_eq!( - code_multiset("@1\n\\s[9]Hi"), - vec!["@1".to_string(), "\\s[9]".to_string()] - ); - assert_ne!(code_multiset("@1\nHi"), code_multiset("Hi")); // dropped @ prefix - assert_eq!(code_multiset("\\f[\\cself[3]]X").len(), 2); // plus nested \f[..] - } - - #[test] - fn code_multiset_excludes_ruby_keeps_others() { - // Ruby `\r[..]` is droppable markup, so it never enters the must-preserve set. A - // translation may drop it without --allow-code-drift. - assert!(code_multiset("\\r[市松,いちまつ]").is_empty()); - // Other codes around a ruby are still required. Only the `\r[..]` is excluded. - assert_eq!( - code_multiset("\\r[x,y]\\cself[3]"), - vec!["\\cself[3]".to_string()] - ); - // A ruby-bearing source vs a ruby-dropped translation therefore have equal code sets. - assert_eq!( - code_multiset("\\r[市松,いちまつ]"), - code_multiset("Ichimatsu") - ); - // An escaped `\\r[..]` is the `\\` code plus literal `r[..]` text. The `\\` is preserved. - assert_eq!( - code_multiset("\\\\r[人生,じんせい]"), - vec!["\\\\".to_string()] - ); - } - - #[test] - fn normalize_en_punct_maps_cjk_keeps_rest() { - // Brackets become straight quotes and the wave dash becomes a tilde. The decorative ♥ - // is untouched. - assert_eq!(normalize_en_punct("「テスト」~♥"), "\"テスト\"~♥"); - // Control codes are pure ASCII and pass through unchanged. - assert_eq!(normalize_en_punct("\\cself[8]"), "\\cself[8]"); - assert_eq!(normalize_en_punct("@1\n\\s[9]Hi"), "@1\n\\s[9]Hi"); - // The full map, both 1:1 and multi-char expansions. - assert_eq!(normalize_en_punct("『』【】〔〕"), "\"\"[][]"); - assert_eq!(normalize_en_punct("〈〉《》"), "<><>"); - assert_eq!(normalize_en_punct("。、,・"), ".,,-"); - assert_eq!(normalize_en_punct("!?:;%&"), "!?:;%&"); - assert_eq!(normalize_en_punct("…‥※"), ".....*"); // `...` then `..` then `*` - assert_eq!(normalize_en_punct("―─-—‐"), "-----"); - assert_eq!(normalize_en_punct("“”‘’"), "\"\"''"); - assert_eq!(normalize_en_punct("×−÷"), "x-/"); - assert_eq!(normalize_en_punct("→←↑↓"), "-><-^v"); - assert_eq!(normalize_en_punct("■□"), "**"); - // Decorative symbols and kana, kanji, and ASCII are left exactly as-is. - assert_eq!(normalize_en_punct("♥♪✨♂ abc 日本語"), "♥♪✨♂ abc 日本語"); - } - - #[test] - fn write_translation_ruby_drop_ok_but_cself_drop_rejected() { - let loc = || "test".to_string(); - // Dropping the ruby markup is accepted by default without --allow-code-drift. The source - // ruby code is excluded from the must-preserve set, so "Ichimatsu" lands cleanly. - let mut cell = WStr::from("\\r[市松,いちまつ]"); - let mut stats = InjectStats::default(); - write_translation( - &mut cell, - "\\r[市松,いちまつ]", - "Ichimatsu", - true, - &InjectOptions::default(), - &mut stats, - &loc, - ); - assert_eq!(stats.applied, 1, "dropping ruby markup must be accepted"); - assert_eq!(stats.code_mismatch, 0); - assert_eq!(decode_wstr(&cell, true), "Ichimatsu"); - - // Dropping a real `\cself[..]` code is still rejected. The line is left untouched. - let mut cell = WStr::from("「\\cself[8]」を手に入れた。"); - let mut stats = InjectStats::default(); - write_translation( - &mut cell, - "「\\cself[8]」を手に入れた。", - "Got the item!", - true, - &InjectOptions::default(), - &mut stats, - &loc, - ); - assert_eq!(stats.applied, 0, "dropping \\cself must be rejected"); - assert_eq!(stats.code_mismatch, 1); - // Cell untouched, still the source bytes. - assert_eq!(decode_wstr(&cell, true), "「\\cself[8]」を手に入れた。"); - } - - #[test] - fn write_translation_en_punct_lands_straight_quotes() { - let loc = || "test".to_string(); - // With normalize_punct, a translation containing 「」 lands as straight quotes. - let mut cell = WStr::from("テスト"); - let mut stats = InjectStats::default(); - write_translation( - &mut cell, - "テスト", - "「Hello」", - true, - &InjectOptions { - allow_code_drift: false, - normalize_punct: true, - }, - &mut stats, - &loc, - ); - assert_eq!(stats.applied, 1); - assert_eq!(decode_wstr(&cell, true), "\"Hello\""); - - // Off by default, the CJK brackets are preserved verbatim. - let mut cell = WStr::from("テスト"); - let mut stats = InjectStats::default(); - write_translation( - &mut cell, - "テスト", - "「Hello」", - true, - &InjectOptions::default(), - &mut stats, - &loc, - ); - assert_eq!(stats.applied, 1); - assert_eq!(decode_wstr(&cell, true), "「Hello」"); - } - - #[test] - fn field_name_content_classifier() { - // A cid250 by-name write into a name or a description/dialogue field is player text, so - // the command-stream test accepts everything that is not genuinely internal, the Name or - // Content union. Description/dialogue and name labels both pass. - assert!(field_name_is_content("説明文")); // description - assert!(field_name_is_content("項目文")); // menu-item label (a name) - assert!(field_name_is_content("愛称")); // nickname (a name) - // A non-keyword label is not internal, so it is player text (Role::Content). The 付与文字列 - // affix sentinels (`x` and `------`) are still dropped by the str0 translatable gate in - // `player_str_indices`, not by this field test (see player_str_indices_unchanged_on_*). - assert!(field_name_is_content("付与文字列[前]")); - // Genuinely-internal fields are still rejected. - assert!(!field_name_is_content("ファイル")); - assert!(!field_name_is_content("戦闘背景画像")); - } - - #[test] - fn db_field_role_three_way_split() { - // (1) Internal: non-string type_byte, temp/separator type, or genuine-internal field. - assert_eq!( - db_field_role(2, "Monster", "名前", "Name", true), - Role::Internal - ); - assert_eq!( - db_field_role(0, "×temp", "名前", "Name", true), - Role::Internal - ); - assert_eq!( - db_field_role(0, "------", "名前", "Name", true), - Role::Internal - ); - assert_eq!( - db_field_role(0, "Monster", "ファイル", "File", false), - Role::Internal - ); - assert_eq!( - db_field_role(0, "Skill", "戦闘背景画像", "Graphic", false), - Role::Internal - ); - // (2) Content: a description/dialogue field or type. `セリフ`/`セリフ`/`効果文` stay in DESC. - assert_eq!( - db_field_role(0, "Skill", "説明", "Description", false), - Role::Content - ); - assert_eq!( - db_field_role(0, "Skill", "効果文", "Description", false), - Role::Content - ); - assert_eq!( - db_field_role(0, "会話セリフ", "", "", false), - Role::Content - ); - assert_eq!( - db_field_role(0, "Item", "メッセージ", "Message", false), - Role::Content - ); - // A description field still wins even on the first-string column. DESC beats Name. - assert_eq!( - db_field_role(0, "Help", "説明", "Description", true), - Role::Content - ); - // (3) Name: first string column (even unlabeled), or a recognised name field or type. - assert_eq!(db_field_role(0, "Monster", "", "", true), Role::Name); - assert_eq!( - db_field_role(0, "Item", "謎ラベル", "謎ラベル", true), - Role::Name - ); - assert_eq!( - db_field_role(0, "Trainer", "愛称", "愛称", false), - Role::Name - ); - assert_eq!( - db_field_role(0, "Element", "呼び方", "呼び方", false), - Role::Name - ); - // `移動名` is the displayed location banner, so it is a Name not Internal and the glossary owns it. - assert_eq!( - db_field_role(0, "Map", "日本語移動名", "Move Name", false), - Role::Name - ); - assert_eq!( - db_field_role(0, "用語設定", "", "", false), - Role::Name - ); - // (4) Default Content: an unlabeled or unrecognised non-internal, non-first field. - assert_eq!(db_field_role(0, "Misc", "", "", false), Role::Content); - assert_eq!( - db_field_role(0, "Misc", "謎ラベル", "謎ラベル", false), - Role::Content - ); - } - - #[test] - fn db_player_field_is_content_only() { - // db_player_field (db-strings ownership) is true only for Role::Content. - assert!(db_player_field(0, "Skill", "説明", "Description", false)); // Content - assert!(!db_player_field(0, "Monster", "名前", "Name", true)); // Name, glossary owns it - assert!(!db_player_field(0, "Monster", "", "", true)); // first-string Name - assert!(!db_player_field(0, "Map", "日本語移動名", "Move Name", false)); // Name - assert!(!db_player_field(0, "Monster", "ファイル", "File", false)); // Internal - // Default (unlabeled non-first) is Content, so db-strings keeps it. - assert!(db_player_field(0, "Misc", "謎ラベル", "謎ラベル", false)); - } - - #[test] - fn translatable_filter_rejects_pure_substitution() { - // Pure variable refs or UI glyphs. Nothing for a translator. - assert!(!has_translatable_text("\\cself[5]")); - assert!(!has_translatable_text("\\f[\\cself[18]]\\E▲")); - assert!(!has_translatable_text("\\f[\\cself[19]]\\cself[6]")); - assert!(!has_translatable_text( - "\\space[0]\\f[\\cself[17]]\\-[1]\\E\\cself[6]" - )); - assert!(!has_translatable_text("+")); - // Real text, including text wrapped around an embedded substitution. - assert!(has_translatable_text("No data available")); - assert!(has_translatable_text("「\\cself[8]」を手に入れた。")); - assert!(has_translatable_text("やめる")); - } - - #[test] - fn translatable_filter_rejects_engine_data_tokens() { - // Single-letter id/index cells (英字id columns, skill-tree layer flags) and CG/asset codes. - for junk in [ - "a", "b", "z", "5", "A006", "A000", "nx1", "_syst00", "m_09_01", "mc01", "CharaChip2", - ] { - assert!(!has_translatable_text(junk), "{junk:?} should be filtered as engine data"); - } - // A variable-expression that strips to a bare underscore identifier. - assert!(!has_translatable_text("_syst00\\cself[10]")); - // Real text must survive: plain ASCII words, labels, measurements, and any CJK. - for keep in [ - "Sell", - "HP", - "OK", - "Nofile", - "Lv", - "No data available", - "Game Over", - "138cm\r\n36kg", - "アイリス", - "全回復", - "一", // a lone kanji can be a name, never engine data - ] { - assert!(has_translatable_text(keep), "{keep:?} should be kept as translatable"); - } - } - - #[test] - fn noise_token_never_fires_on_cjk() { - // Even a one-character kanji or a kanji glued to ASCII is not engine data. - assert!(!is_noise_token("一")); - assert!(!is_noise_token("S淫乱度")); - assert!(is_noise_token("a")); - assert!(is_noise_token("A006")); - assert!(!is_noise_token("Sell")); - } - - #[test] - fn angle_token_rejected_but_real_labels_kept() { - // Lone engine placeholders (a \cdb read concatenated with a literal fallback strip to this). - for junk in ["", "", "", "", "
"] { - assert!(is_noise_token(junk), "{junk:?} should be engine noise"); - assert!(!has_translatable_text(junk), "{junk:?} not translatable"); - } - assert!(!has_translatable_text("\\cdb[11:\\cself[21]:0]")); - // CJK menu labels, multiple bracket pairs, and text around a bracket all stay. - for keep in [ - "<装備を外す>", - " ", - "put in the picture", - "★NoFile>\\cself[5]ファイルがありません", - ] { - assert!(has_translatable_text(keep), "{keep:?} should be kept"); - } - } - - #[test] - fn code_list_rejected_but_real_short_lists_kept() { - for junk in [ - "a0,b0,c1,d1,e0,f2,g2,h0,k0,l0,i0", - "a0,b0,c0,d0,e0,f0", - "f3,g3", - "g\\cself[44],h\\cself[44]", - "p\\cself[12],l100", - ",p\\cself[44],q\\cself[44]", - ] { - assert!(is_code_list(junk), "{junk:?} should read as a code list"); - assert!(!has_translatable_text(junk), "{junk:?} not translatable"); - } - // Real short comma lists: capitalised, multi-letter, or CJK leads keep them out of scope. - // None must read as a code list (so the rule never misfires). - for keep in [ - "OK,Cancel", "Yes,No", "HP,MP", "HP,MP,Lv", "Buy,Sell", "Fire,Ice", "S,A,B,C", - "Lv1,Lv2", "L1,R1", "No1,No2", "1,2,3", "100,200", "装備,解除", - ] { - assert!(!is_code_list(keep), "{keep:?} must NOT read as a code list"); - } - // The ones carrying real words stay translatable (the pure-number lists have no letters, so - // they are correctly not translatable regardless, and are not asserted here). - for keep in ["OK,Cancel", "HP,MP", "Buy,Sell", "Fire,Ice", "Lv1,Lv2", "L1,R1", "装備,解除"] { - assert!(has_translatable_text(keep), "{keep:?} should be kept"); - } - } - - #[test] - fn asset_path_list_rejected_but_real_slashes_kept() { - for junk in [ - "EVCG_b0/B000/B0_a000.png\r\nEVCG_b0/B000/B0_d000.png", - "EVCG_a0/A000/", - "EVCG_e0/E000/E0_a000.png\r\nEVCG_e0/E000/E0_o0\\cself[36]0.png", - "b/r/i/j/m/zv/zx", - ] { - assert!(value_is_asset_path_list(junk), "{junk:?} should read as a path list"); - } - // A single slash inside a real phrase, a 2-token ratio, or any CJK stays text. - for keep in ["HP/MP", "Buy/Sell", "スキル/アイテム", "1/4 of max HP", "OK", "<装備を外す>"] { - assert!(!value_is_asset_path_list(keep), "{keep:?} must stay text"); - } - } - - #[test] - fn db_short_code_rejected_but_words_and_names_kept() { - for junk in ["za", "zb", "kc", "la", "ma", "nn", "ac", "abc", "hb", "hc", "kg", "mg", "nd"] { - assert!(is_db_short_code(junk), "{junk:?} should read as a DB short code"); - } - // Words/labels/names: lowercase 2-3 is the only catch, so uppercase or >3 stays. - // (gem/ml are real in the COMMAND stream, where this rule never runs.) - for keep in ["OK", "HP", "Lv", "Iris", "Lisa", "Sion", "Fire", "item", "<装備を外す>", "助ける"] { - assert!(!is_db_short_code(keep), "{keep:?} must NOT read as a short code"); - } - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/symbols.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/symbols.rs deleted file mode 100644 index 864ad67..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/symbols.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Cross-file symbol table for name resolution. Turns numeric ids in event code into readable -//! names. CommonEvent ids map to names plus per-event self-var and input names from the event's -//! own metadata. Global `V[]`/`S[]`/`Sys[]` ids map to names from the System Database. An optional -//! engine glossary maps stock common-event JP names to English. - -use std::collections::HashMap; - -use serde::Deserialize; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; - -use crate::text::decode_wstr; - -/// The engine-default glossary, generated by the readability workflow and embedded. -static GLOSSARY_JSON: &str = include_str!("../data/glossary.json"); - -/// Engine-text glossary (structural JP to English), generated by the translation workflow. -static ENGINE_GLOSSARY_JSON: &str = include_str!("../data/engine_glossary.json"); - -/// Load the embedded engine-text glossary (structural name translations). -pub fn load_embedded_engine_glossary() -> HashMap { - #[derive(Deserialize)] - struct File { - pairs: Vec, - } - #[derive(Deserialize)] - struct Pair { - jp: String, - en: String, - } - let json = ENGINE_GLOSSARY_JSON.trim_start_matches('\u{feff}'); - match serde_json::from_str::(json) { - Ok(f) => f.pairs.into_iter().map(|p| (p.jp, p.en)).collect(), - Err(_) => HashMap::new(), - } -} - -/// Load the embedded engine-default common-event glossary (stock JP name to English). -pub fn load_embedded_glossary() -> Glossary { - #[derive(Deserialize)] - struct File { - entries: Vec, - } - #[derive(Deserialize)] - struct Entry { - jp_name: String, - en_name: String, - #[serde(default)] - args: Vec, - } - let json = GLOSSARY_JSON.trim_start_matches('\u{feff}'); - match serde_json::from_str::(json) { - Ok(f) => Glossary::from_pairs(f.entries.into_iter().map(|e| { - ( - e.jp_name, - GlossaryEntry { - en_name: e.en_name, - args: e.args, - }, - ) - })), - Err(_) => Glossary::default(), - } -} - -#[derive(Debug, Clone, Default)] -pub struct SymbolTable { - /// CommonEvent int-id to metadata (name, self-var names, input arg names). - pub common_events: HashMap, - /// Global variable names from the System Database. - pub globals: GlobalNames, - /// Databases by kind (UDB/CDB/SDB), each holding type/data/field names. - pub databases: Vec, - /// Engine-default function glossary (stock JP name to English). - pub glossary: Glossary, - /// Engine-text glossary: structural JP name to English (variable, DB type, field, and - /// system-row names). Player-facing game text is absent. - pub engine_text: HashMap, -} - -#[derive(Debug, Clone, Default)] -pub struct CommonEventInfo { - pub name: String, - /// CSelf index to name, from `unknown8`, the per-event self-variable names. - pub self_vars: HashMap, - /// Input argument names in order (leading non-empty `unknown3` entries). - pub inputs: Vec, -} - -#[derive(Debug, Clone, Default)] -pub struct GlobalNames { - /// `V[n]`, normal/global variables ("通常変数名"). - pub normal: HashMap, - /// `S[n]`, string variables ("文字列変数名"). - pub string: HashMap, - /// `Sys[n]`, system variables ("システム変数名"). - pub system: HashMap, -} - -#[derive(Debug, Clone)] -pub struct DatabaseNames { - pub kind: String, - pub types: Vec, -} - -#[derive(Debug, Clone)] -pub struct TypeNames { - pub name: String, - pub data: Vec, - pub fields: Vec, -} - -/// Engine-default glossary: stock common-event JP name to English signature. -#[derive(Debug, Clone, Default)] -pub struct Glossary { - entries: HashMap, -} - -#[derive(Debug, Clone)] -pub struct GlossaryEntry { - pub en_name: String, - pub args: Vec, -} - -impl Glossary { - pub fn from_pairs(pairs: impl IntoIterator) -> Self { - Glossary { - entries: pairs.into_iter().collect(), - } - } - - pub fn get(&self, jp_name: &str) -> Option<&GlossaryEntry> { - self.entries.get(jp_name) - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -const NAME_NORMAL: &str = "通常変数名"; -const NAME_STRING: &str = "文字列変数名"; -const NAME_SYSTEM: &str = "システム変数名"; -/// Reserve/spare variable groups (`予備変数1`..`予備変数9`). Each names the normal variables at -/// `V[group*100000 + row]`. -const NAME_SPARE: &str = "予備変数"; - -impl SymbolTable { - pub fn new() -> Self { - SymbolTable::default() - } - - /// Register CommonEvent metadata (names plus self-var and input names). - pub fn add_common_events(&mut self, ce: &CommonEventsFile) { - for ev in &ce.events { - let name = decode_wstr(&ev.name, ce.utf8); - - // Self-var names: the unknown8 index equals the CSelf index. - let mut self_vars = HashMap::new(); - for (i, w) in ev.unknown8.iter().enumerate() { - let n = decode_wstr(w, ce.utf8); - if !n.is_empty() { - self_vars.insert(i as u32, n); - } - } - - // Inputs: leading contiguous non-empty unknown3 entries. - let mut inputs = Vec::new(); - for w in &ev.unknown3 { - let n = decode_wstr(w, ce.utf8); - if n.is_empty() { - break; - } - inputs.push(n); - } - - self.common_events.insert( - ev.int_id, - CommonEventInfo { - name, - self_vars, - inputs, - }, - ); - } - } - - /// Register a database's type/data/field names, and for the System Database the global - /// variable name tables. - pub fn add_database(&mut self, kind: &str, db: &Database) { - for t in &db.types { - let type_name = decode_wstr(&t.name, db.utf8); - let target = match type_name.as_str() { - NAME_NORMAL => Some(&mut self.globals.normal), - NAME_STRING => Some(&mut self.globals.string), - NAME_SYSTEM => Some(&mut self.globals.system), - _ => None, - }; - if let Some(map) = target { - for (i, d) in t.data.iter().enumerate() { - let n = decode_wstr(&d.name, db.utf8); - if !n.is_empty() { - map.insert(i as u32, n); - } - } - } - - // Spare/reserve variable groups name V[group*100000 + row]. - if let Some(g) = type_name - .strip_prefix(NAME_SPARE) - .and_then(|s| s.parse::().ok()) - .filter(|g| (1..=9).contains(g)) - { - let base = g * 100_000; - for (i, d) in t.data.iter().enumerate() { - let n = decode_wstr(&d.name, db.utf8); - if !n.is_empty() { - self.globals.normal.insert(base + i as u32, n); - } - } - } - } - - let types = db - .types - .iter() - .map(|t| TypeNames { - name: decode_wstr(&t.name, db.utf8), - data: t - .data - .iter() - .map(|d| decode_wstr(&d.name, db.utf8)) - .collect(), - fields: t - .fields - .iter() - .map(|f| decode_wstr(&f.name, db.utf8)) - .collect(), - }) - .collect(); - self.databases.push(DatabaseNames { - kind: kind.to_string(), - types, - }); - } - - pub fn set_glossary(&mut self, glossary: Glossary) { - self.glossary = glossary; - } - - pub fn set_engine_text(&mut self, engine_text: HashMap) { - self.engine_text = engine_text; - } - - /// Translate a structural name to English via the engine-text glossary, or return it - /// unchanged. Player-facing content is absent from the glossary, so it stays as-is. - pub fn tr<'a>(&'a self, name: &'a str) -> &'a str { - self.engine_text - .get(name) - .map(String::as_str) - .unwrap_or(name) - } - - pub fn common_event(&self, id: u32) -> Option<&CommonEventInfo> { - self.common_events.get(&id) - } - - pub fn common_event_name(&self, id: u32) -> Option<&str> { - self.common_events.get(&id).map(|i| i.name.as_str()) - } - - /// Reverse lookup by exact name (for call-by-name commands). - pub fn common_event_by_name(&self, name: &str) -> Option<&CommonEventInfo> { - self.common_events.values().find(|i| i.name == name) - } - - /// Resolve a database entry (`type name`, `row index`) to its row name, for turning, say, - /// `ItemId=17` into `ItemId="ポーション"`. Searches all loaded databases. - pub fn db_entry_name(&self, type_name: &str, row: u32) -> Option<&str> { - self.db_type(type_name) - .and_then(|t| t.data.get(row as usize)) - .map(String::as_str) - .filter(|s| !s.is_empty()) - } - - /// Resolve a database field (column) index to its name within a type. - pub fn db_field_name(&self, type_name: &str, field: u32) -> Option<&str> { - self.db_type(type_name) - .and_then(|t| t.fields.get(field as usize)) - .map(String::as_str) - .filter(|s| !s.is_empty()) - } - - fn db_type(&self, type_name: &str) -> Option<&TypeNames> { - self.databases - .iter() - .flat_map(|db| &db.types) - .find(|t| t.name == type_name) - } - - /// Which database kind (UDB/CDB/SDB) holds a given type. - pub fn db_kind_of_type(&self, type_name: &str) -> Option<&str> { - self.databases - .iter() - .find(|db| db.types.iter().any(|t| t.name == type_name)) - .map(|db| db.kind.as_str()) - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/text.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/text.rs deleted file mode 100644 index df272a3..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/text.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Shared string decoding for the readable layer. Wolf strings are raw bytes, either -//! Shift-JIS or UTF-8 depending on the file. Decode them to a UTF-8 `String` for -//! display and names. - -use wolf_formats::WStr; - -/// Decode a Wolf string to UTF-8, trimming the trailing NUL UTF-8 files carry. -pub fn decode_wstr(s: &WStr, utf8: bool) -> String { - let mut bytes = s.as_bytes(); - if utf8 && bytes.last() == Some(&0) { - bytes = &bytes[..bytes.len() - 1]; - } - if utf8 { - String::from_utf8_lossy(bytes).into_owned() - } else { - let (text, _, _) = encoding_rs::SHIFT_JIS.decode(bytes); - text.into_owned() - } -} - -/// Decode and escape a string for a WolfScript double-quoted literal. -pub fn quote_wstr(s: &WStr, utf8: bool) -> String { - escape_literal(&decode_wstr(s, utf8)) -} - -/// Render a Wolf string as a byte-exact WolfScript literal. Emits a readable `"text"` when -/// the bytes decode and re-encode identically in the file's encoding, otherwise a raw -/// `x"HEX"` literal carrying the exact bytes including the trailing NUL. The inverse is -/// [`text_to_wstr`] (or the hex path). Used by the recompilable renderer so strings -/// round-trip in any encoding, including inside block openers that bypass the per-command -/// `@raw` fallback. -pub fn wstr_to_literal(w: &WStr, utf8: bool) -> String { - match clean_literal_text(w, utf8) { - Some(text) => escape_literal(&text), - None => format!("x\"{}\"", to_hex(w.as_bytes())), - } -} - -/// Encode already-unescaped literal text into a Wolf string (file encoding + trailing NUL). -/// Errors if the text can't be represented in the file's encoding (e.g. a non-Shift-JIS char -/// hand-typed into a Shift-JIS file). -pub fn text_to_wstr(text: &str, utf8: bool) -> Result { - let mut bytes = encode_body(text, utf8).ok_or_else(|| { - format!( - "text not representable in {}: {text:?}", - if utf8 { "UTF-8" } else { "Shift-JIS" } - ) - })?; - bytes.push(0); - Ok(WStr(bytes)) -} - -/// Decode a string for a readable literal only when it round-trips byte-exact, else `None` to -/// use hex. -fn clean_literal_text(w: &WStr, utf8: bool) -> Option { - let body = w.as_bytes().strip_suffix(&[0u8])?; // Wolf strings carry a trailing NUL. - let text = if utf8 { - std::str::from_utf8(body).ok()?.to_owned() - } else { - let (t, _, had_err) = encoding_rs::SHIFT_JIS.decode(body); - if had_err { - return None; - } - t.into_owned() - }; - // Control chars other than tab/CR/LF have no readable escaped form. Keep them in hex. - if text - .chars() - .any(|c| c.is_control() && !matches!(c, '\t' | '\n' | '\r')) - { - return None; - } - (encode_body(&text, utf8)?.as_slice() == body).then_some(text) -} - -fn encode_body(text: &str, utf8: bool) -> Option> { - if utf8 { - Some(text.as_bytes().to_vec()) - } else { - let (bytes, _, had_err) = encoding_rs::SHIFT_JIS.encode(text); - (!had_err).then(|| bytes.into_owned()) - } -} - -fn escape_literal(text: &str) -> String { - let esc = text - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\r', "\\r") - .replace('\n', "\\n") - .replace('\t', "\\t"); - format!("\"{esc}\"") -} - -fn to_hex(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(s, "{b:02x}"); - } - s -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/txt_events.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/txt_events.rs deleted file mode 100644 index a881faa..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/txt_events.rs +++ /dev/null @@ -1,441 +0,0 @@ -//! External event-text `.txt` files. Some GamePro games keep every line of dialogue and -//! narration in plain Shift-JIS (or, for newer games, UTF-8) `.txt` files that the engine reads -//! at runtime, one file per scene. The `Evtext*.wolf` archives unpack to thousands of them. The -//! command-stream extractor in `strings.rs` never sees this text, so without this module it is -//! the single biggest recall gap for those games. -//! -//! The format is line-based. A line that starts with `/` is an engine command or a `//` comment -//! (CG control, page break, a stat tweak like `/胸+1`, and so on) and is never shown to the -//! player. A blank line is spacing. Every other non-blank line is displayed text. So a line is -//! translatable exactly when it does not start with `/` and is not blank. -//! -//! Extraction emits only the translatable lines, each tagged with its 0-based line index so -//! injection knows which line to replace. Command, comment, and blank lines are reconstructed -//! from the base on inject, so they round-trip byte-exact. A no-op inject (every `text` equal to -//! its `source`) reproduces the base file byte-for-byte, including the original line endings, the -//! original encoding, and a missing trailing newline. - -use std::fmt::Write as _; - -use serde_json::Value; - -// ---------------------------------------------------------------------------- -// Decoding + line splitting -// ---------------------------------------------------------------------------- - -/// The file's text encoding. A `.txt` event file is Shift-JIS for the older GamePro games and -/// UTF-8 for newer ones. We detect rather than guess so both round-trip. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Enc { - Sjis, - Utf8, -} - -impl Enc { - fn tag(self) -> &'static str { - match self { - Enc::Sjis => "sjis", - Enc::Utf8 => "utf8", - } - } -} - -/// Decode the raw bytes, choosing the encoding. Valid UTF-8 wins (it is the stricter test, and a -/// genuine Shift-JIS file with high bytes is almost never accidentally valid UTF-8), otherwise we -/// fall back to Shift-JIS, which the older games use. A UTF-8 BOM, if present, is treated as part -/// of the first line's bytes so it survives the round-trip untouched. -fn decode(bytes: &[u8]) -> (String, Enc) { - match std::str::from_utf8(bytes) { - Ok(s) => (s.to_owned(), Enc::Utf8), - Err(_) => { - let (text, _, _) = encoding_rs::SHIFT_JIS.decode(bytes); - (text.into_owned(), Enc::Sjis) - } - } -} - -/// Re-encode decoded text in the chosen encoding. Returns `None` when the text has a char the -/// encoding cannot represent (a non-Shift-JIS char hand-typed into a Shift-JIS file), mirroring -/// the encoding guard `text.rs` uses for the command-stream path. -fn encode(text: &str, enc: Enc) -> Option> { - match enc { - Enc::Utf8 => Some(text.as_bytes().to_vec()), - Enc::Sjis => { - let (bytes, _, had_err) = encoding_rs::SHIFT_JIS.encode(text); - (!had_err).then(|| bytes.into_owned()) - } - } -} - -/// One physical line: its content (no terminator) and the exact terminator that followed it. The -/// terminator is `"\r\n"`, `"\n"`, `"\r"`, or `""` for the last line when the file has no trailing -/// newline. Capturing the real terminator per line, rather than assuming one EOL for the whole -/// file, keeps reconstruction byte-exact even if a file mixes line endings. -struct Line<'a> { - content: &'a str, - eol: &'a str, -} - -/// Split decoded text into lines, recording each line's exact terminator. A final segment with no -/// terminator (no trailing newline at EOF) is kept as a last line with an empty terminator, so it -/// re-joins without inventing a newline. An empty input yields no lines. -fn split_lines(text: &str) -> Vec> { - let mut lines = Vec::new(); - let bytes = text.as_bytes(); - let mut start = 0; - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\n' => { - lines.push(Line { - content: &text[start..i], - eol: "\n", - }); - i += 1; - start = i; - } - b'\r' => { - let eol = if bytes.get(i + 1) == Some(&b'\n') { - "\r\n" - } else { - "\r" - }; - lines.push(Line { - content: &text[start..i], - eol, - }); - i += eol.len(); - start = i; - } - _ => i += 1, - } - } - if start < bytes.len() { - // Trailing segment with no terminator (file does not end in a newline). - lines.push(Line { - content: &text[start..], - eol: "", - }); - } - lines -} - -/// A line is translatable when it is not blank and does not start with `/`. The `/` test is on -/// the first byte with no whitespace stripping. The real game files never indent a command, and a -/// displayed line that genuinely begins with a leading space (full sentences sometimes do) keeps -/// that space as part of its on-screen text, so trimming first would be wrong on both counts. -fn is_translatable(content: &str) -> bool { - !content.is_empty() && !content.starts_with('/') -} - -/// The dominant EOL for the document metadata. Reports `crlf` if any line ends in `\r\n`, else -/// `lf`. Only informational. Reconstruction always uses each line's captured terminator. -fn dominant_eol(lines: &[Line<'_>]) -> &'static str { - if lines.iter().any(|l| l.eol == "\r\n") { - "crlf" - } else { - "lf" - } -} - -// ---------------------------------------------------------------------------- -// Extraction -// ---------------------------------------------------------------------------- - -/// Extract the translatable lines of an event-text `.txt` file as a translation JSON document. -/// -/// The shape is `{ "kind":"txt", "encoding":"sjis|utf8", "eol":"crlf|lf", "lines":[ {"i":<0-based -/// line index>, "source":"", "text":""} ... ] }`. Only the -/// translatable lines get an entry. Command, comment, and blank lines carry no entry because -/// inject rebuilds them from the base. `text` starts equal to `source`. A translator edits -/// `text`. `source` is kept for the inject drift guard. -pub fn extract_txt_events(bytes: &[u8]) -> String { - let (text, enc) = decode(bytes); - let lines = split_lines(&text); - let eol = dominant_eol(&lines); - - let mut s = String::new(); - s.push_str("{\n"); - let _ = write!(s, " \"kind\": \"txt\",\n"); - let _ = write!(s, " \"encoding\": {},\n", jstr(enc.tag())); - let _ = write!(s, " \"eol\": {},\n", jstr(eol)); - s.push_str(" \"lines\": [\n"); - let keep: Vec<(usize, &str)> = lines - .iter() - .enumerate() - .filter(|(_, l)| is_translatable(l.content)) - .map(|(i, l)| (i, l.content)) - .collect(); - for (n, (i, content)) in keep.iter().enumerate() { - let _ = write!( - s, - " {{\"i\": {}, \"source\": {}, \"text\": {}}}", - i, - jstr(content), - jstr(content) - ); - s.push_str(if n + 1 == keep.len() { "\n" } else { ",\n" }); - } - s.push_str(" ]\n}\n"); - s -} - -// ---------------------------------------------------------------------------- -// Injection -// ---------------------------------------------------------------------------- - -/// Apply an edited translation JSON back onto the base `.txt` bytes, byte-exact. -/// -/// The base is re-decoded into lines, so command, comment, and blank lines (and the exact line -/// terminators) come straight from the original. Each translatable line `i` is replaced with its -/// `text` only when `text` differs from `source` and the base line still holds `source` (a drift -/// guard). The lines are re-joined with their original terminators and re-encoded in the original -/// encoding. A no-op (every `text` equal to its `source`) reproduces the base byte-for-byte. -/// -/// A translation is skipped, leaving the base line untouched, when it contains a newline (that -/// would split one line into two) or when it is not representable in the file's encoding -/// (Shift-JIS). Both cases print a warning, the same way the command-stream `write_translation` -/// guards encoding, and the rest of the batch still applies. -pub fn inject_txt_events(json: &str, base_bytes: &[u8]) -> Result, String> { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let (text, enc) = decode(base_bytes); - let lines = split_lines(&text); - // Each line starts as its original content. Edits overwrite the content of translatable lines. - let mut content: Vec = lines.iter().map(|l| l.content.to_owned()).collect(); - - let entries = root - .get("lines") - .and_then(Value::as_array) - .ok_or("txt translation JSON has no `lines` array")?; - for ln in entries { - let i = ln - .get("i") - .and_then(Value::as_u64) - .ok_or("txt line entry has no integer `i`")? as usize; - let source = ln.get("source").and_then(Value::as_str).unwrap_or(""); - let text_new = ln.get("text").and_then(Value::as_str).unwrap_or(source); - if text_new == source { - continue; - } - let Some(cur) = content.get_mut(i) else { - eprintln!("txt line {i}: out of range in base (drifted); skipping"); - continue; - }; - // Drift guard: only overwrite if the base line still holds the recorded source. - if cur != source { - eprintln!("txt line {i}: base no longer holds the recorded source (drifted); skipping"); - continue; - } - // A newline in the translation would split one line into two and desync every later - // index. Reject it and keep the original line. - if text_new.contains('\n') || text_new.contains('\r') { - eprintln!("txt line {i}: translation contains a newline; skipping (keep it on one line)"); - continue; - } - // The translated line must be representable in the file's encoding (Shift-JIS games). - if encode(text_new, enc).is_none() { - eprintln!( - "txt line {i}: text not representable in {}: {text_new:?}; choose an encodable equivalent", - enc.tag() - ); - continue; - } - *cur = text_new.to_owned(); - } - - // Re-join with the original per-line terminators, then re-encode. - let mut rebuilt = String::with_capacity(text.len()); - for (l, c) in lines.iter().zip(content.iter()) { - rebuilt.push_str(c); - rebuilt.push_str(l.eol); - } - encode(&rebuilt, enc).ok_or_else(|| { - format!( - "rebuilt text not representable in {} (this should not happen after the per-line guard)", - enc.tag() - ) - }) -} - -// ---------------------------------------------------------------------------- -// Directory (folder of .txt files) combined document -// ---------------------------------------------------------------------------- - -/// Build one combined translation JSON for a folder of event-text files. Each `(name, bytes)` -/// pair is extracted and embedded under a `files` array with its `file` name, so a single document -/// covers a whole `Evtext` folder and `inject_txt_dir` can route each entry back to its base file. -/// The caller supplies the files in the order they should appear (sorted by name for stability). -pub fn extract_txt_dir(files: &[(String, Vec)]) -> String { - let mut entries = Vec::with_capacity(files.len()); - for (name, bytes) in files { - let mut obj: Value = serde_json::from_str(&extract_txt_events(bytes)) - .expect("extract_txt_events always emits valid JSON"); - if let Value::Object(map) = &mut obj { - // Put `file` first by rebuilding the map with it at the front. - let mut with_name = serde_json::Map::new(); - with_name.insert("file".to_string(), Value::String(name.clone())); - for (k, v) in map.iter() { - with_name.insert(k.clone(), v.clone()); - } - obj = Value::Object(with_name); - } - entries.push(obj); - } - let doc = serde_json::json!({ "kind": "txt-dir", "files": entries }); - // Pretty-print so a translator can edit it by hand, matching the readable single-file output. - serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string()) + "\n" -} - -/// One file's edits parsed out of a combined `txt-dir` document: its base file name and the -/// per-file JSON to feed to [`inject_txt_events`]. Returned in document order. -pub fn split_txt_dir(json: &str) -> Result, String> { - let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let files = root - .get("files") - .and_then(Value::as_array) - .ok_or("txt-dir JSON has no `files` array")?; - let mut out = Vec::with_capacity(files.len()); - for entry in files { - let name = entry - .get("file") - .and_then(Value::as_str) - .ok_or("a files[] entry has no `file` name")? - .to_owned(); - out.push((name, entry.to_string())); - } - Ok(out) -} - -// ---------------------------------------------------------------------------- -// JSON string escaping (matches the other extractors) -// ---------------------------------------------------------------------------- - -fn jstr(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn extracted_texts(json: &str) -> Vec { - let v: Value = serde_json::from_str(json).unwrap(); - v["lines"] - .as_array() - .unwrap() - .iter() - .map(|l| l["source"].as_str().unwrap().to_owned()) - .collect() - } - - #[test] - fn noop_is_byte_exact_crlf_with_commands_and_blanks() { - // CRLF, a `//` comment, a `/` command, blank lines, CJK dialogue, no trailing newline. - let text = "//tag comment\r\n/evcg,a0\r\nアイリス:\r\nこんにちは\r\n\r\n/b\r\n最後の行"; - let bytes = encoding_rs::SHIFT_JIS.encode(text).0.into_owned(); - let json = extract_txt_events(&bytes); - let out = inject_txt_events(&json, &bytes).unwrap(); - assert_eq!(out, bytes, "no-op inject must be byte-exact"); - } - - #[test] - fn extraction_excludes_commands_and_blanks() { - let text = "//comment\r\n/evcg,a0\r\nアイリス:\r\n本当のセリフ\r\n\r\n/b\r\n"; - let bytes = encoding_rs::SHIFT_JIS.encode(text).0.into_owned(); - let json = extract_txt_events(&bytes); - let got = extracted_texts(&json); - assert_eq!(got, vec!["アイリス:".to_string(), "本当のセリフ".to_string()]); - assert!(!json.contains("/evcg"), "command line must not be extracted"); - assert!(!json.contains("//comment"), "comment line must not be extracted"); - } - - #[test] - fn edit_round_trip_keeps_commands_intact() { - let text = "/evcg,a0\r\nアイリス:\r\n元のセリフ\r\n/b\r\n"; - let bytes = encoding_rs::SHIFT_JIS.encode(text).0.into_owned(); - let json = extract_txt_events(&bytes); - // Change the line whose source is `元のセリフ`. - let edited = json.replacen( - r#""source": "元のセリフ", "text": "元のセリフ""#, - r#""source": "元のセリフ", "text": "新しいセリフ""#, - 1, - ); - assert_ne!(edited, json, "the replacement should have matched"); - let out = inject_txt_events(&edited, &bytes).unwrap(); - let re = extract_txt_events(&out); - assert!(extracted_texts(&re).contains(&"新しいセリフ".to_string())); - let (decoded, _) = decode(&out); - assert!(decoded.contains("/evcg,a0"), "command line must survive"); - assert!(decoded.contains("/b"), "page-break command must survive"); - assert!(!decoded.contains("元のセリフ"), "old text should be gone"); - } - - #[test] - fn lf_only_and_leading_space_line_and_utf8() { - // LF endings, a UTF-8 file, a dialogue line that starts with a real leading space, and a - // command line. The leading-space line is text (the space is part of it), the `/` line is - // not. - let text = "/cmd\n line with leading space\nnormal\n"; - let bytes = text.as_bytes().to_vec(); - let json = extract_txt_events(&bytes); - let v: Value = serde_json::from_str(&json).unwrap(); - assert_eq!(v["encoding"], "utf8"); - assert_eq!(v["eol"], "lf"); - let got = extracted_texts(&json); - assert_eq!(got, vec![" line with leading space".to_string(), "normal".to_string()]); - assert_eq!(inject_txt_events(&json, &bytes).unwrap(), bytes); - } - - #[test] - fn newline_in_translation_is_rejected() { - let text = "せりふ\n"; - let bytes = text.as_bytes().to_vec(); - let edit = r#"{"kind":"txt","encoding":"utf8","eol":"lf","lines":[{"i":0,"source":"せりふ","text":"two\nlines"}]}"#; - let out = inject_txt_events(edit, &bytes).unwrap(); - // Rejected, so the base line is untouched. - assert_eq!(out, bytes); - } - - #[test] - fn unrepresentable_sjis_is_skipped() { - // A Shift-JIS base, translation contains an emoji (not SJIS-encodable). The line is left - // untouched and the rest of the file is unchanged. - let text = "あいうえお\r\n"; - let bytes = encoding_rs::SHIFT_JIS.encode(text).0.into_owned(); - let edit = r#"{"kind":"txt","encoding":"sjis","eol":"crlf","lines":[{"i":0,"source":"あいうえお","text":"🎮"}]}"#; - let out = inject_txt_events(edit, &bytes).unwrap(); - assert_eq!(out, bytes, "unrepresentable line is skipped, base preserved"); - } - - #[test] - fn empty_file_round_trips() { - let bytes: Vec = Vec::new(); - let json = extract_txt_events(&bytes); - assert!(extracted_texts(&json).is_empty()); - assert_eq!(inject_txt_events(&json, &bytes).unwrap(), bytes); - } - - #[test] - fn lone_cr_line_ending_round_trips() { - // A stray old-Mac `\r` terminator must survive byte-exact. - let bytes = b"text one\rtext two".to_vec(); - let json = extract_txt_events(&bytes); - assert_eq!(inject_txt_events(&json, &bytes).unwrap(), bytes); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/wolfscript/mod.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/wolfscript/mod.rs deleted file mode 100644 index 0086294..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/wolfscript/mod.rs +++ /dev/null @@ -1,872 +0,0 @@ -//! WolfScript emitter. Renders a command program into readable, indented pseudo-code. -//! -//! Every command renders bespoke. Keystone commands (conditions, choices, SetVariable, calls, and -//! so on) get purpose-built forms decoded from their argument packing. All others use the labeled -//! argument schema in [`crate::spec`]. Nothing falls back to an unlabeled int dump. -//! Conditional/choice branches are reconstructed from the flat marker commands via a small block -//! stack, so `case`/`when` labels carry their real option/condition text. Labels are mapped -//! positionally, which is robust to the engine's non-sequential case indices. - -pub(crate) mod ops; -pub use ops::operand; -use ops::*; - -use std::cell::Cell; -use std::collections::HashMap; - -use wolf_formats::command::RawCommand; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -use crate::spec; -use crate::symbols::SymbolTable; -use crate::text::{decode_wstr, quote_wstr, wstr_to_literal}; - -const INDENT_UNIT: &str = " "; - -/// Common-event call-by-ID base. A stored target of `500000 + id` calls common event `id`. -const CALL_BY_ID_BASE: u32 = 500_000; - -/// StringCondition lhs flag. When set, the right-hand side is an operand from the rhs section -/// (string-var vs string-var) rather than the literal in `str_args`. -const STRCOND_OPERAND_FLAG: u32 = 0x0100_0000; - -/// Tracks the enclosing block so `case`/`when` markers resolve to a real, positional label. -enum Block { - Choices { options: Vec, next: usize }, - Conditional { conds: Vec, next: usize }, - Other, -} - -pub struct Renderer<'a> { - utf8: bool, - symbols: &'a SymbolTable, - /// The common event currently being rendered, so `CSelf[n]` resolves to its own - /// self-variable names. `None` for maps. - current_ce: Cell>, - /// Display mode. Render the prettiest (bespoke, name-resolved) form for reading, skipping the - /// recompile self-check that otherwise falls back to `@raw`. The numeric, round-trip-faithful - /// path (`decompile_commands`) leaves this off. - display: bool, - /// Annotate mode: the unified read-plus-edit surface. Runs with `display=false` so the - /// per-leaf `recompilable()` self-check still gates every line, but injects an index-anchored - /// bilingual label inside the brackets, as in `V[3 "Gold · ゴールド"]`. The index stays the - /// round-trip authority and the recompiler strips the quoted label. Because the self-check - /// runs, any label the parser cannot strip back to the identical command auto-falls to the - /// numeric/`@raw` form, so the file is always byte-exact and the names only affect prettiness. - annotate: bool, -} - -impl<'a> Renderer<'a> { - pub fn with_symbols(utf8: bool, symbols: &'a SymbolTable) -> Self { - Renderer { - utf8, - symbols, - current_ce: Cell::new(None), - display: false, - annotate: false, - } - } - - /// Enable display mode: readable output, no `@raw` fallback. - pub fn for_display(mut self) -> Self { - self.display = true; - self - } - - /// Enable annotate mode: readable and recompilable, with index-anchored bilingual labels. - pub fn for_edit(mut self) -> Self { - self.annotate = true; - self - } - - /// `English · 日本語` when the engine glossary translates the name, else the raw name. - fn bilingual(&self, jp: &str) -> String { - let en = self.symbols.tr(jp); - if en != jp { - format!("{en} · {jp}") - } else { - jp.to_string() - } - } - - /// Decode a Wolf operand id into a readable, name-resolved reference. See [`ops::operand`] for - /// the structural, symbol-free mirror that the recompiler's parser inverts. - fn op(&self, v: u32) -> String { - match v { - 0..=999_999 => v.to_string(), - // This-event intrinsic data: position, graphic, or direction of the running event. - 1_000_000..=1_099_999 => this_event_ref(v), - // Map-event self vars are unnamed by the engine. Kept numeric by design. - 1_100_000..=1_199_999 => format!("Self[{}]", v - 1_100_000), - 1_600_000..=1_699_999 => { - let n = v - 1_600_000; - match self.cself_name(n) { - Some(name) if self.annotate => { - format!("CSelf[{n} \"{}\"]", escape(&self.bilingual(&name))) - } - Some(name) => format!("CSelf[{}]", name_tok(self.symbols.tr(&name))), - None => format!("CSelf[{n}]"), - } - } - 1_200_000..=1_599_999 | 1_700_000..=1_999_999 => format!("Self*[{}]", v - 1_000_000), - 2_000_000..=2_999_999 => { - self.global_ref(&self.symbols.globals.normal, v - 2_000_000, "V") - } - 3_000_000..=3_999_999 => { - self.global_ref(&self.symbols.globals.string, v - 3_000_000, "S") - } - // Random 0..N reference. The engine computes `rand % (N+1)`, N = v-8_000_000. - 8_000_000..=8_999_999 => format!("Rand[{}]", v - 8_000_000), - 9_000_000..=9_099_999 => { - self.global_ref(&self.symbols.globals.system, v - 9_000_000, "Sys") - } - // On-map character data: party member 9.1M, current character 9.18M. - 9_100_000..=9_179_999 => chara_ref("Chara", 9_100_000, v), - 9_180_000..=9_189_999 => chara_ref("Chara2", 9_180_000, v), - 9_190_000..=9_999_999 => { - self.global_ref(&self.symbols.globals.system, v - 9_000_000, "Sys") - } - _ => lit(v), - } - } - - /// Render a global reference. Annotate mode anchors on the index and adds a bilingual label - /// (`V[3 "Gold · ゴールド"]`). Display mode shows the translated name (`V["Gold"]`). Numeric - /// mode is the bare index (`V[3]`). - fn global_ref(&self, map: &HashMap, n: u32, sigil: &str) -> String { - match map.get(&n) { - Some(name) if self.annotate => { - format!("{sigil}[{n} \"{}\"]", escape(&self.bilingual(name))) - } - Some(name) => format!("{sigil}[{}]", name_tok(self.symbols.tr(name))), - None => format!("{sigil}[{n}]"), - } - } - - fn cself_name(&self, n: u32) -> Option { - let ce = self.current_ce.get()?; - self.symbols.common_event(ce)?.self_vars.get(&n).cloned() - } - - fn decode_str(&self, w: &wolf_formats::WStr) -> String { - decode_wstr(w, self.utf8) - } - - fn quote(&self, w: &wolf_formats::WStr) -> String { - // Display favours readability, where a lossy `from_utf8_lossy` is fine. The numeric/edit - // path must round-trip, so it uses the byte-exact literal (readable text or `x"HEX"`). - if self.display { - quote_wstr(w, self.utf8) - } else { - wstr_to_literal(w, self.utf8) - } - } - - fn str_arg(&self, cmd: &RawCommand, idx: usize) -> String { - cmd.str_args - .get(idx) - .map(|s| self.quote(s)) - .unwrap_or_else(|| "\"\"".to_string()) - } - - fn is_blank_slot(cmd: &RawCommand) -> bool { - cmd.cid == 0 - && cmd.int_args.is_empty() - && cmd.str_args.is_empty() - && cmd.move_route.is_none() - } - - /// Render a flat command list, reconstructing block labels via a stack. - pub fn commands(&self, cmds: &[RawCommand]) -> String { - // Wolf Pro v3.5: every command carries a trailing blob, almost always empty. When - // present, the recompiler re-adds an empty blob to each command by default, so a command - // can still render pretty when its own blob is empty. A non-empty blob forces the lossless - // `@raw` form. - let v35 = cmds.iter().any(|c| c.v35_blob.is_some()); - let mut out = String::new(); - let mut stack: Vec = Vec::new(); - - for cmd in cmds { - if Self::is_blank_slot(cmd) { - continue; - } - let blob_empty = cmd.v35_blob.as_deref().map_or(true, |b| b.is_empty()) || self.display; - // Case markers consume the next positional label from the enclosing block. - let line = match cmd.cid { - 401 if blob_empty => case_marker(cmd, &mut stack), - 402 if blob_empty => { - format!("}} case* {} {{", cmd.int_args.first().copied().unwrap_or(0)) - } - 421 if blob_empty => "} cancel {".to_string(), - // Display mode: render the prettiest form for reading, no recompile gate. - _ if self.display => self.line(cmd), - _ => { - // Self-check chain: pretty, then labeled, then lossless @raw, picking the - // first form that round-trips exactly. - let pretty = self.line(cmd); - if is_block_opener(cmd.cid) { - // Openers are reconstructed structurally. A rare non-empty blob is carried - // inline as a ` @b:HEX` suffix the parser strips and re-attaches. - match cmd.v35_blob.as_deref() { - Some(b) if !b.is_empty() => format!("{pretty} @b:{}", hex(b)), - _ => pretty, - } - } else if (is_structural(cmd.cid) && blob_empty) - || self.recompilable(cmd, &pretty, v35) - { - pretty - } else { - let labeled = self.labeled(cmd); - if self.recompilable(cmd, &labeled, v35) { - labeled - } else { - raw_form(cmd) - } - } - } - }; - for _ in 0..cmd.indent as usize { - out.push_str(INDENT_UNIT); - } - out.push_str(&line); - out.push('\n'); - - match cmd.cid { - 102 => stack.push(Block::Choices { - options: self.choice_options(cmd), - next: 0, - }), - 111 | 112 => stack.push(Block::Conditional { - conds: self.branch_conditions(cmd), - next: 0, - }), - 170 | 179 => stack.push(Block::Other), - 498 | 499 => { - stack.pop(); - } - _ => {} - } - } - out - } - - /// True if the leaf line parses back to exactly this command (a lossless display form). Under - /// v3.5 the recompiler re-adds an empty trailing blob to each command, so a pretty form is - /// lossless exactly when the command's own blob is empty. - fn recompilable(&self, cmd: &RawCommand, line: &str, v35: bool) -> bool { - match crate::compile::cmd::parse_leaf(line, cmd.indent, self.utf8) { - Ok(mut p) => { - if v35 && p.v35_blob.is_none() { - p.v35_blob = Some(Vec::new()); - } - &p == cmd - } - Err(_) => false, - } - } - - fn line(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - match cmd.cid { - 101 => format!("Message {}", self.str_arg(cmd, 0)), - 103 => format!( - "# {}", - escape_inline(&self.decode_str(&cmd.str_args.first().cloned().unwrap_or_default())) - ), - 106 => format!("DebugMessage {}", self.str_arg(cmd, 0)), - 121 => self.set_variable(cmd), - 122 => self.set_string(cmd), - 102 => format!( - "choose {} [{}] {{", - a.first().copied().unwrap_or(0), - self.join_choice_options(cmd) - ), - 111 | 112 => { - if a.first().copied().unwrap_or(0) & 0x10 != 0 { - "branch all {".to_string() // AND, all conditions must hold - } else { - "branch {".to_string() // OR, first match - } - } - 420 => "} else {".to_string(), - 498 | 499 => "}".to_string(), - 170 => "loop {".to_string(), - // StartLoop2 (176) is not a block opener. It has no matching LoopEnd in data. - 179 => format!( - "loop {} times {{", - a.first().map(|&v| self.op(v)).unwrap_or_default() - ), - 210 | 211 => self.call_common_event(cmd), - 300 => self.call_common_event_by_name(cmd), - 201 => self.move_command(cmd), - 140 => self.sound(cmd), - 250 => self.database(cmd), - 151 => self.change_color(cmd), - 130 => self.teleport(cmd), - 180 => format!("Wait {}", a.first().copied().unwrap_or(0)), - _ => self.labeled(cmd), - } - } - - // --- keystone renderers ------------------------------------------------ - - fn choice_options(&self, cmd: &RawCommand) -> Vec { - cmd.str_args.iter().map(|s| self.decode_str(s)).collect() - } - - fn join_choice_options(&self, cmd: &RawCommand) -> String { - cmd.str_args - .iter() - .map(|s| self.quote(s)) - .collect::>() - .join(", ") - } - - /// Decode a VariableCondition/StringCondition into per-branch condition strings. - fn branch_conditions(&self, cmd: &RawCommand) -> Vec { - let a = &cmd.int_args; - let Some(&header) = a.first() else { - return Vec::new(); - }; - let n = (header & 0x0F) as usize; - let is_and = header & 0x10 != 0; - - let groups: Vec = if cmd.cid == 112 { - // StringCondition layout: `[header][lhs×n][rhs×k]`. Each lhs word packs the left - // string operand in the low 24 bits, the right-is-variable flag in bit 24 - // (`0x01000000`), and the comparison type in the high nibble (bits 28-31): 0=eq, - // 1=ne, 2=contains, 3=!contains. A variable right reads from the rhs section. A - // literal right reads `str_args[g]`. `k` is the count of variable-right conditions. - let mut rhs_ptr = 1 + n; - (0..n) - .map(|g| { - let raw = a.get(1 + g).copied().unwrap_or(0); - let operand = raw & 0x00FF_FFFF; - let cmp = strcmp_op_str((raw >> 28) & 0xF); - if raw & STRCOND_OPERAND_FLAG != 0 { - let rhs = a.get(rhs_ptr).copied().unwrap_or(0); - rhs_ptr += 1; - format!("{} {cmp} {}", self.op(operand), self.op(rhs)) - } else { - format!("{} {cmp} {}", self.op(operand), self.str_arg(cmd, g)) - } - }) - .collect() - } else { - (0..n) - .map(|g| { - let base = 1 + g * 3; - let left = a.get(base).map(|&v| self.op(v)).unwrap_or_default(); - let right = a.get(base + 1).map(|&v| self.op(v)).unwrap_or_default(); - let op = a.get(base + 2).copied().unwrap_or(0); - format!("{left} {} {right}", compare_op_str(op)) - }) - .collect() - }; - - if is_and { - vec![groups.join(" && ")] - } else { - groups - } - } - - fn set_variable(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - if a.len() < 4 { - return self.labeled(cmd); - } - let target = self.op(a[0]); - let left = self.op(a[1]); - let right = a[2]; - let modify = modify_op(a[3]); - let binop_nibble = (a[3] >> 12) & 0x0F; - let real = a[3] & 0x04 != 0; - - let mut s = if right == 0 && binop_nibble == 0 { - format!("SetVariable {target} {modify} {left}") - } else { - format!( - "SetVariable {target} {modify} {left} {} {}", - binary_op(a[3]), - self.op(right) - ) - }; - if real { - s.push_str(" // real"); - } - s - } - - fn set_string(&self, cmd: &RawCommand) -> String { - let target = cmd - .int_args - .first() - .map(|&v| self.op(v)) - .unwrap_or_else(|| "S[?]".to_string()); - format!("SetString {target} = {}", self.str_arg(cmd, 0)) - } - - fn call_common_event(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - let target = a.first().copied().unwrap_or(0); - let count = a.get(1).map(|&v| (v & 0xFF) as usize).unwrap_or(0); - - // Operand-referenced target: call the event whose id is in a variable. - if target >= 1_000_000 { - let passed = self.passed_args(a, 2, count); - let head = format!("call [{}]", self.op(target)); - return if passed.is_empty() { - head - } else { - format!("{head}({passed})") - }; - } - - let id = if target >= CALL_BY_ID_BASE { - target - CALL_BY_ID_BASE - } else { - target - }; - let info = self.symbols.common_event(id); - let glossed = info.and_then(|i| self.symbols.glossary.get(&i.name)); - - // Function name: the English engine glossary for stock events, else JP name, else #id. - let head = match (info, glossed) { - (_, Some(g)) => g.en_name.clone(), - (Some(i), None) => format!("call \"{}\"", i.name), - (None, _) => format!("call #{id}"), - }; - - // Passed args, labeled with the callee's input names. English glossary wins over JP inputs. - let parts = - self.render_passed_args(a, count, glossed.map(|g| &g.args), info.map(|i| &i.inputs)); - if parts.is_empty() { - head - } else { - format!("{head}({})", parts.join(", ")) - } - } - - /// Render call arguments, labeling each with its parameter name and resolving DB-entry ids - /// (`ItemId=17` becomes `ItemId="ポーション"`) when the English param name is typed. - fn render_passed_args( - &self, - a: &[u32], - count: usize, - en_args: Option<&Vec>, - jp_args: Option<&Vec>, - ) -> Vec { - let mut parts = Vec::new(); - for k in 0..count { - let Some(&v) = a.get(2 + k) else { break }; - let en = en_args.and_then(|x| x.get(k)).map(String::as_str); - let jp = jp_args.and_then(|x| x.get(k)).map(String::as_str); - // English param name from the glossary, else the translated JP input name. - let label: Option = en - .map(str::to_string) - .or_else(|| jp.map(|j| self.symbols.tr(j).to_string())); - let val = self.resolve_arg(en, v); - match label.as_deref() { - Some(n) if !n.is_empty() => parts.push(format!("{n}={val}")), - _ => parts.push(val), - } - } - parts - } - - /// Render an operand. If it is a literal and the param name names a DB type, resolve it to the - /// entry name. This stays non-lossy since the recompiler accepts either name or id. - fn resolve_arg(&self, en_name: Option<&str>, v: u32) -> String { - if v < 1_000_000 { - if let Some(type_name) = en_name.and_then(arg_db_type) { - if let Some(name) = self.symbols.db_entry_name(type_name, v) { - return format!("\"{}\"", escape(name)); - } - } - } - self.op(v) - } - - fn call_common_event_by_name(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - let jp = self.decode_str(&cmd.str_args.first().cloned().unwrap_or_default()); - let glossed = self.symbols.glossary.get(&jp); - let info = self.symbols.common_event_by_name(&jp); - - let head = match glossed { - Some(g) => g.en_name.clone(), - None => format!("call \"{jp}\""), - }; - - let count = a.get(1).map(|&v| (v & 0xFF) as usize).unwrap_or(0); - let parts = - self.render_passed_args(a, count, glossed.map(|g| &g.args), info.map(|i| &i.inputs)); - if parts.is_empty() { - head - } else { - format!("{head}({})", parts.join(", ")) - } - } - - fn passed_args(&self, a: &[u32], start: usize, count: usize) -> String { - (0..count) - .filter_map(|i| a.get(start + i)) - .map(|&v| self.op(v)) - .collect::>() - .join(", ") - } - - /// Render a Database (DB操作) command as an assignment, using the type/data/field names - /// embedded in the command's string args, falling back to DB lookups or ids. `flagWord` bit0 - /// selects direction: 0 reads DB into a var, 1 writes a value into the DB. - fn database(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - if a.len() < 5 { - return self.labeled(cmd); - } - let (type_raw, data_raw, field_raw, flag, value) = (a[0], a[1], a[2], a[3], a[4]); - - // JP names from the embedded string args drive the DB lookups. Display is then translated - // via the engine-text glossary. - let type_jp = self.str_name_jp(cmd, 1); - let data_jp = self.str_name_jp(cmd, 2); - let field_jp = self.str_name_jp(cmd, 3); - - let kind = type_jp - .as_deref() - .and_then(|t| self.symbols.db_kind_of_type(t)) - .unwrap_or("DB"); - - let type_tok = match &type_jp { - Some(t) => name_tok(self.symbols.tr(t)), - None if type_raw >= 1_000_000 => self.op(type_raw), - None => format!("#{}", type_raw as i32), - }; - let row = if let Some(d) = &data_jp { - name_tok(self.symbols.tr(d)) - } else if data_raw >= 1_000_000 { - self.op(data_raw) - } else if let Some(name) = type_jp - .as_deref() - .and_then(|t| self.symbols.db_entry_name(t, data_raw)) - { - name_tok(self.symbols.tr(name)) - } else { - format!("#{}", data_raw as i32) - }; - let field = if let Some(f) = &field_jp { - Some(name_tok(self.symbols.tr(f))) - } else if let Some(name) = type_jp - .as_deref() - .and_then(|t| self.symbols.db_field_name(t, field_raw)) - { - Some(name_tok(self.symbols.tr(name))) - } else if field_raw == 0 { - None - } else { - Some(format!("#{field_raw}")) - }; - - let cell = match field { - Some(f) => format!("{kind}[{type_tok}][{row}].{f}"), - None => format!("{kind}[{type_tok}][{row}]"), - }; - - if flag & 0xF == 1 { - let op = match (flag >> 4) & 0xF { - 0 => "=", - 1 => "+=", - 2 => "-=", - 3 => "*=", - 4 => "/=", - 5 => "%=", - 6 => "max=", - 7 => "min=", - _ => "=", - }; - format!("{cell} {op} {}", self.op(value)) - } else { - format!("{} = {cell}", self.op(value)) - } - } - - /// The decoded JP text of a string arg, untranslated and used for DB lookups, if present. - fn str_name_jp(&self, cmd: &RawCommand, idx: usize) -> Option { - cmd.str_args - .get(idx) - .map(|s| self.decode_str(s)) - .filter(|d| !d.is_empty()) - } - - fn change_color(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - let Some(&c) = a.first() else { - return self.labeled(cmd); - }; - let (r, g, b) = (c & 0xFF, (c >> 8) & 0xFF, (c >> 16) & 0xFF); - let dur = a.get(1).copied().unwrap_or(0); - if dur == 0 { - format!("ChangeColor(R={r}, G={g}, B={b})") - } else { - format!("ChangeColor(R={r}, G={g}, B={b}, {dur}f)") - } - } - - fn teleport(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - let target = match a.first().copied() { - Some(0xFFFF_FFFF) => "player".to_string(), - Some(0xFFFF_FFFE) => "thisEvent".to_string(), - Some(v) if v >= 1_000_000 => self.op(v), - Some(v) => format!("event#{v}"), - None => return self.labeled(cmd), - }; - let x = a.get(1).map(|&v| self.op(v)).unwrap_or_default(); - let y = a.get(2).map(|&v| self.op(v)).unwrap_or_default(); - format!("Teleport {target} -> ({x}, {y})") - } - - /// Decode the Sound command's packed control word into op nibble, sound-type nibble, - /// addressing mode, and resource number. - fn sound(&self, cmd: &RawCommand) -> String { - let a = &cmd.int_args; - let Some(&w) = a.first() else { - return self.labeled(cmd); - }; - let op = w & 0x0F; - let ty = match (w >> 4) & 0x0F { - 0 => "BGM", - 1 => "BGS", - 2 => "SE", - _ => "Snd", - }; - let mode = (w >> 24) & 0x0F; - let resnum = (w >> 8) & 0xFFFF; - - match op { - 0 => { - let src = match mode { - 0x2 => self.str_arg(cmd, 0), - 0x0 => format!("#{resnum}"), - 0x1 => format!("[{}]", a.get(2).map(|&v| self.op(v)).unwrap_or_default()), - _ => format!("0x{w:08X}"), - }; - let vol = a.get(4).copied().unwrap_or(100); - let pitch = a.get(5).copied().unwrap_or(100); - let mut s = format!("Play{ty} {src}"); - if vol != 100 || pitch != 100 { - s.push_str(&format!(" vol={vol} pitch={pitch}")); - } - s - } - 1 => format!("Stop{ty}"), - 2 => format!("Release{ty}"), - 3 => "StopAllSound".to_string(), - _ => self.labeled(cmd), - } - } - - /// Lossless, readable Move(201): `Move(target=…) "strs" @route(t=, f=, - /// h=<5 header bytes>) { RouteCmd; RouteCmd(args); … }`. The route header bytes, flags, and - /// terminator are carried verbatim so the pretty form fully round-trips with no `@raw`. - fn move_command(&self, cmd: &RawCommand) -> String { - let spec = spec::command(cmd.cid); - let mut parts = Vec::new(); - for (i, &val) in cmd.int_args.iter().enumerate() { - // Default to operand-ref so targets like `CSelf[3]` or `-2` read naturally. - let (label, is_ref) = match spec.and_then(|s| s.int_args.get(i)) { - Some(f) => (short_label(&f.label), f.kind == "operand-ref"), - None => (format!("arg{i}"), true), - }; - let rendered = if is_ref { self.op(val) } else { lit(val) }; - parts.push(format!("{label}={rendered}")); - } - let mut head = String::from("Move"); - if !parts.is_empty() { - head.push('('); - head.push_str(&parts.join(", ")); - head.push(')'); - } - for sa in &cmd.str_args { - head.push(' '); - head.push_str(&self.quote(sa)); - } - match &cmd.move_route { - Some(r) => format!( - "{head} @route(t={}, f={}, h={}) {{ {} }}", - cmd.term, - r.flags, - hex(&r.unknown), - render_route(r) - ), - None => head, - } - } - - // --- generic-but-labeled fallback -------------------------------------- - - fn labeled(&self, cmd: &RawCommand) -> String { - let name = spec::command_name(cmd.cid); - self.labeled_inner(cmd, &name) - } - - fn labeled_inner(&self, cmd: &RawCommand, name: &str) -> String { - let spec = spec::command(cmd.cid); - let mut parts = Vec::new(); - for (i, &val) in cmd.int_args.iter().enumerate() { - let (label, is_ref) = match spec.and_then(|s| s.int_args.get(i)) { - Some(f) => (short_label(&f.label), f.kind == "operand-ref"), - None => (format!("arg{i}"), false), - }; - let rendered = if is_ref { self.op(val) } else { lit(val) }; - parts.push(format!("{label}={rendered}")); - } - let mut s = name.to_string(); - if !parts.is_empty() { - s.push('('); - s.push_str(&parts.join(", ")); - s.push(')'); - } - for sa in &cmd.str_args { - s.push(' '); - s.push_str(&self.quote(sa)); - } - if let Some(route) = &cmd.move_route { - s.push_str(&format!(" {{ {} }}", render_route(route))); - } - s - } -} - -// ---------------------------------------------------------------------------- -// Block-marker helpers (need &mut stack) -// ---------------------------------------------------------------------------- - -fn case_marker(cmd: &RawCommand, stack: &mut [Block]) -> String { - let arg0 = cmd.int_args.first().copied().unwrap_or(0); - match stack.last_mut() { - // Choices: show the real ChoiceCase index, which is recompile-safe. A label comment keeps - // it readable. - Some(Block::Choices { options, next }) => { - let label = options.get(*next).cloned(); - *next += 1; - match label.filter(|l| !l.is_empty()) { - Some(l) => format!("}} case {arg0} {{ # {}", escape_inline(&l)), - None => format!("}} case {arg0} {{"), - } - } - Some(Block::Conditional { conds, next }) => { - let cond = conds.get(*next).cloned().unwrap_or_else(|| "?".to_string()); - *next += 1; - format!("}} when ({cond}) {{") - } - _ => format!("}} case {arg0} {{"), - } -} - -// ---------------------------------------------------------------------------- -// Public entry points -// ---------------------------------------------------------------------------- - -/// Decompile a flat command list (UTF-8, no symbols). -pub fn decompile_commands(cmds: &[RawCommand]) -> String { - decompile_commands_enc(cmds, true) -} - -/// Decompile a flat command list in the file's encoding (no symbols). The recompilable form uses -/// numeric operands and byte-exact string literals, so `compile_commands_enc(_, utf8)` is its -/// exact inverse regardless of Shift-JIS or UTF-8. -pub fn decompile_commands_enc(cmds: &[RawCommand], utf8: bool) -> String { - let symbols = SymbolTable::new(); - let body = Renderer::with_symbols(utf8, &symbols).commands(cmds); - // A leading `@v35` directive tells the recompiler to re-add per-command blobs. - if cmds.iter().any(|c| c.v35_blob.is_some()) { - format!("@v35\n{body}") - } else { - body - } -} - -/// Decompile a flat command list in annotate mode, the unified read-plus-edit form. Readable -/// index-anchored bilingual labels (`V[3 "Gold · ゴールド"]`) that `compile_commands_enc` strips -/// back to the identical bytes. The per-leaf self-check guarantees byte-exactness, so any label -/// the parser cannot strip degrades to the numeric form. `ce_id` sets the current common event so -/// `CSelf[n]` resolves to its self-var names. Pass `None` for maps. -pub fn decompile_commands_annotated( - cmds: &[RawCommand], - utf8: bool, - symbols: &SymbolTable, - ce_id: Option, -) -> String { - let r = Renderer::with_symbols(utf8, symbols).for_edit(); - r.current_ce.set(ce_id); - let body = r.commands(cmds); - if cmds.iter().any(|c| c.v35_blob.is_some()) { - format!("@v35\n{body}") - } else { - body - } -} - -/// Decompile an entire map, resolving names via `symbols`. -pub fn decompile_map(map: &Map, symbols: &SymbolTable) -> String { - let r = Renderer::with_symbols(map.utf8, symbols).for_display(); - let mut out = format!( - "# map version=0x{:x} tileset={} {}x{} events={}\n\n", - map.version, - map.tileset_id, - map.width, - map.height, - map.events.len() - ); - for ev in &map.events { - out.push_str(&format!( - "event {} {} @({}, {}) {{\n", - ev.id, - r.quote(&ev.name), - ev.x, - ev.y - )); - for (pi, page) in ev.pages.iter().enumerate() { - out.push_str(&format!("{INDENT_UNIT}page {pi} {{\n")); - for line in r.commands(&page.commands).lines() { - out.push_str(&format!("{INDENT_UNIT}{INDENT_UNIT}{line}\n")); - } - out.push_str(&format!("{INDENT_UNIT}}}\n")); - } - out.push_str("}\n\n"); - } - out -} - -/// Decompile a CommonEvent file, resolving names via `symbols`. -pub fn decompile_common_events(ce: &CommonEventsFile, symbols: &SymbolTable) -> String { - let r = Renderer::with_symbols(ce.utf8, symbols).for_display(); - let mut out = format!("# common-events count={}\n\n", ce.events.len()); - for ev in &ce.events { - r.current_ce.set(Some(ev.int_id)); - let jp_name = decode_wstr(&ev.name, ce.utf8); - let header = match symbols.glossary.get(&jp_name) { - Some(g) => format!( - "commonEvent {} {} {} {{", - ev.int_id, - g.en_name, - r.quote(&ev.name) - ), - None => format!("commonEvent {} {} {{", ev.int_id, r.quote(&ev.name)), - }; - out.push_str(&header); - out.push('\n'); - for line in r.commands(&ev.commands).lines() { - out.push_str(&format!("{INDENT_UNIT}{line}\n")); - } - out.push_str("}\n\n"); - } - out -} - -// ---------------------------------------------------------------------------- -// Move route rendering -// ---------------------------------------------------------------------------- diff --git a/util/wolfdawn-master/crates/wolf-decompiler/src/wolfscript/ops.rs b/util/wolfdawn-master/crates/wolf-decompiler/src/wolfscript/ops.rs deleted file mode 100644 index 40ed02f..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/src/wolfscript/ops.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! Stateless rendering helpers: operand and operator decoding, move-route rendering, the -//! lossless `@raw` fallback, and small string/format utilities. - -use crate::spec; -use wolf_formats::command::{MoveRoute, RawCommand, RouteCommand}; - -pub(crate) fn render_route(route: &MoveRoute) -> String { - route - .commands - .iter() - .map(render_route_command) - .collect::>() - .join("; ") -} - -pub(crate) fn render_route_command(rc: &RouteCommand) -> String { - let name = spec::route_name(rc.id as u32); - if rc.args.is_empty() { - name - } else { - // Route args are variable-call values, resolved by the engine through the same operand - // resolver, so decode them as references: `Wait(CSelf[5])`, not `Wait(1600005)`. - let args: Vec = rc.args.iter().map(|&v| operand(v)).collect(); - format!("{name}({})", args.join(", ")) - } -} - -// ---------------------------------------------------------------------------- -// Operand / operator decoding -// ---------------------------------------------------------------------------- - -/// Decode a Wolf operand id into a readable reference. Ranges: map-event self vars at -/// `1_100_000+n` (`Self[n]`), common-event self vars at `1_600_000+n` (`CSelf[n]`), normal vars -/// `2_000_000+n`, strings `3_000_000+n`, random `8_000_000+N` as `Rand[N]`, system vars -/// `9_000_000..9_099_999`, on-map character data `9_100_000+` and `9_180_000+`, and this-event -/// intrinsic data `1_000_000+`. This standalone decoder mirrors [`super::Renderer::op`] for the -/// symbol-free and route-arg path. Both invert [`crate::compile::cmd`]'s `operand` parser. -pub fn operand(v: u32) -> String { - match v { - 0..=999_999 => v.to_string(), - 1_000_000..=1_099_999 => this_event_ref(v), - 1_100_000..=1_199_999 => format!("Self[{}]", v - 1_100_000), - 1_600_000..=1_699_999 => format!("CSelf[{}]", v - 1_600_000), - 1_200_000..=1_599_999 | 1_700_000..=1_999_999 => format!("Self*[{}]", v - 1_000_000), - 2_000_000..=2_999_999 => format!("V[{}]", v - 2_000_000), - 3_000_000..=3_999_999 => format!("S[{}]", v - 3_000_000), - 8_000_000..=8_999_999 => format!("Rand[{}]", v - 8_000_000), - 9_000_000..=9_099_999 => format!("Sys[{}]", v - 9_000_000), - 9_100_000..=9_179_999 => chara_ref("Chara", 9_100_000, v), - 9_180_000..=9_189_999 => chara_ref("Chara2", 9_180_000, v), - 9_190_000..=9_999_999 => format!("Sys[{}]", v - 9_000_000), - _ => lit(v), - } -} - -/// On-map Character intrinsic-data field names, shared by the this-event (`1_000_000`), -/// party-character (`9_100_000`), and current-character (`9_180_000`) operand banks. Maps to -/// Character-struct offsets: +36/+40 position, +184 height, +272 graphic, +68 direction, -/// +344/+348 move remainder. The field index is `refNum % 10`. Positions are stored doubled, so -/// the halved tile coordinate (field 0/1) and the raw precise coordinate (field 2/3) coexist. -pub(crate) fn chara_field_name(f: u32) -> Option<&'static str> { - Some(match f { - 0 => "X", - 1 => "Y", - 2 => "PreciseX", - 3 => "PreciseY", - 4 => "Height", - 5 => "Graphic", - 6 => "Dir", - 7 => "RemX", - 8 => "RemY", - 9 => "Name", - _ => return None, - }) -} - -/// Inverse of [`chara_field_name`] for the recompiler. -pub(crate) fn chara_field_index(name: &str) -> Option { - (0..10).find(|&f| chara_field_name(f) == Some(name)) -} - -/// Render an on-map character-data reference (`Chara[m].Field`). `member = (v-base)/10`, -/// `field = v % 10`. -pub(crate) fn chara_ref(prefix: &str, base: u32, v: u32) -> String { - let off = v - base; - let member = off / 10; - match chara_field_name(off % 10) { - Some(f) => format!("{prefix}[{member}].{f}"), - None => format!("{prefix}[{member}].f{}", off % 10), - } -} - -/// Render a this-event intrinsic-data reference. Sub-object 0 (`1_000_000..=1_000_009`) is the -/// position/graphic block, shown as `ThisEv.Field`. Deeper sub-objects stay structural -/// (`ThisEv[k]`) so the byte survives the round-trip without an over-claimed field name. -pub(crate) fn this_event_ref(v: u32) -> String { - let off = v - 1_000_000; - match chara_field_name(off) { - Some(f) => format!("ThisEv.{f}"), - None => format!("ThisEv[{off}]"), - } -} - -/// Render a literal int, showing 2's-complement negatives (e.g. 0xFFFFFFFF becomes -1). -pub(crate) fn lit(v: u32) -> String { - (v as i32).to_string() -} - -/// Render a VariableCondition (111) compare-op word reversibly. The low nibble is the comparison -/// (editor order: 0=<=,1=>=,2===,3=!=,4=<,5=>,6=bitAND). The high nibble carries per-term flags, -/// such as the 0x10 right-side-is-variable flag seen in GamePro (op 0x12 = `==~1`). Known low -/// nibbles render as a symbol, plus `~` when set. Anything else stays `cmp` so the exact -/// byte survives the round-trip. -pub(crate) fn compare_op_str(v: u32) -> String { - let base = match v & 0x0F { - 0 => "<=", - 1 => ">=", - 2 => "==", - 3 => "!=", - 4 => "<", - 5 => ">", - 6 => "&", // bitAND != 0 - _ => return format!("cmp{v}"), - }; - match v >> 4 { - 0 => base.to_string(), - flags => format!("{base}~{flags}"), - } -} - -/// StringCondition (112) comparison-type token (the high nibble of the condition word): -/// 0=same, 1=different, 2=contains, 3=not-contains. Unknown types use a reversible `strcmp`. -pub(crate) fn strcmp_op_str(ty: u32) -> String { - match ty { - 0 => "eq".to_string(), - 1 => "ne".to_string(), - 2 => "contains".to_string(), - 3 => "!contains".to_string(), - other => format!("strcmp{other}"), - } -} - -pub(crate) fn modify_op(arg3: u32) -> &'static str { - match (arg3 >> 8) & 0x0F { - 0 => "=", - 1 => "+=", - 2 => "-=", - 3 => "*=", - 4 => "/=", - 5 => "%=", - 6 => "= floor", - 7 => "= ceil", - 8 => "= abs", - 9 => "= angle", - 0xA => "= sin", - 0xB => "= cos", - 0xC => "= sqrt", - _ => "=?", - } -} - -pub(crate) fn binary_op(arg3: u32) -> &'static str { - match (arg3 >> 12) & 0x0F { - 0 => "+", - 1 => "-", - 2 => "*", - 3 => "%", - 4 => "~", - 5 | 6 => "&", - 7 => "|", - 8 => "^", - 9 => "<<", - _ => "?", - } -} - -pub(crate) fn short_label(label: &str) -> String { - label - .split(|c: char| c == '(' || c == ' ' || c == '/') - .next() - .unwrap_or(label) - .to_string() -} - -pub(crate) fn escape(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") -} - -/// Escape control characters so a value stays on one line, for unquoted contexts like comments. -/// Inverse of the parser's `unescape`. -pub(crate) fn escape_inline(s: &str) -> String { - s.replace('\\', "\\\\") - .replace('\r', "\\r") - .replace('\n', "\\n") - .replace('\t', "\\t") -} - -/// Fully lossless fallback for commands the pretty or labeled forms cannot reproduce, such as -/// `Move` with a route block, or v3.5 trailing blobs. -pub(crate) fn raw_form(cmd: &RawCommand) -> String { - use wolf_formats::Writer; - let ints = cmd - .int_args - .iter() - .map(|v| v.to_string()) - .collect::>() - .join(","); - let strs = cmd - .str_args - .iter() - .map(|s| hex(s.as_bytes())) - .collect::>() - .join(","); - let route = match &cmd.move_route { - Some(mr) => { - let mut w = Writer::new(); - mr.write(&mut w); - hex(&w.into_bytes()) - } - None => "-".to_string(), - }; - let v35 = cmd - .v35_blob - .as_ref() - .map(|b| hex(b)) - .unwrap_or_else(|| "-".to_string()); - format!( - "@raw {} i:{ints} s:{strs} t:{} m:{route} v:{v35}", - cmd.cid, cmd.term - ) -} - -pub(crate) fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -} - -/// Block-structure commands (openers, else, closers) reconstructed by the parser from the -/// readable block syntax. Not subject to the leaf self-check. -pub(crate) fn is_structural(cid: u32) -> bool { - matches!(cid, 102 | 111 | 112 | 170 | 179 | 420 | 498 | 499) -} - -/// Block openers (condition, loop, choose), the structural commands that begin a block and can -/// carry an inline ` @b:HEX` blob suffix under v3.5. -pub(crate) fn is_block_opener(cid: u32) -> bool { - matches!(cid, 102 | 111 | 112 | 170 | 179) -} - -/// Render a resolved name as a token: bare if it is a simple identifier, quoted otherwise, so -/// multi-word names like `Max Party Count` are unambiguous and parser-friendly. -pub(crate) fn name_tok(s: &str) -> String { - let simple = !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_'); - if simple { - s.to_string() - } else { - format!("\"{}\"", escape(s)) - } -} - -/// Map an English parameter name (from the glossary) to the User-DB type that holds its entries, -/// so a literal id can be shown as the entry's name. Keyword-based. Falls back to the raw id when -/// the type is not present, so non-Basic-System games degrade gracefully. -pub(crate) fn arg_db_type(en_name: &str) -> Option<&'static str> { - let n = en_name.to_ascii_lowercase(); - if n.contains("item") { - Some("アイテム") - } else if n.contains("weapon") { - Some("武器") - } else if n.contains("armor") { - Some("防具") - } else if n.contains("skill") { - Some("技能") - } else if n.contains("enemy") { - Some("敵キャラ個体データ") - } else if n.contains("state") || n.contains("status") { - Some("状態設定") - } else { - None - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/all_versions_commands.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/all_versions_commands.rs deleted file mode 100644 index 272d912..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/all_versions_commands.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! Cross-version command coverage: scan every Wolf version's CommonEvent + maps for command -//! ids not named in commands.json (rendered as `Cmd`), and per-version route-command ids. -//! cargo test -p wolf-decompiler --test all_versions_commands -- --nocapture -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use wolf_decompiler::spec; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn roots() -> Vec { - let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join(".."); - vec![ - base.join("ALLWOLFVERSIONS"), - base.join("Data"), - base.join("GamePro_Data"), - ] -} - -fn walk(dir: &Path, ext: &str, out: &mut Vec) { - let Ok(rd) = std::fs::read_dir(dir) else { - return; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - walk(&p, ext, out); - } else if p.file_name().and_then(|s| s.to_str()) == Some(ext) - || p.extension().and_then(|s| s.to_str()) == Some(ext) - { - out.push(p); - } - } -} - -#[test] -fn command_coverage_all_versions() { - let mut unknown_cmd: BTreeMap = BTreeMap::new(); - let mut unknown_route: BTreeMap = BTreeMap::new(); - let mut all_cids: std::collections::BTreeSet = Default::default(); - let mut ce_files = 0; - let mut map_files = 0; - - let mut consider = |cmds: &[wolf_formats::command::RawCommand], - uc: &mut BTreeMap, - ur: &mut BTreeMap, - all: &mut std::collections::BTreeSet| { - for c in cmds { - all.insert(c.cid); - if spec::command(c.cid).is_none() { - *uc.entry(c.cid).or_default() += 1; - } - if let Some(mr) = &c.move_route { - for rc in &mr.commands { - if spec::route_name(rc.id as u32).starts_with("route") { - *ur.entry(rc.id as u32).or_default() += 1; - } - } - } - } - }; - - for root in roots() { - let mut ces = Vec::new(); - walk(&root, "CommonEvent.dat", &mut ces); - for p in ces { - let Ok(b) = std::fs::read(&p) else { continue }; - let Ok(ce) = CommonEventsFile::read(&b) else { - continue; - }; - ce_files += 1; - for ev in &ce.events { - consider( - &ev.commands, - &mut unknown_cmd, - &mut unknown_route, - &mut all_cids, - ); - } - } - let mut maps = Vec::new(); - walk(&root, "mps", &mut maps); - for p in maps { - let Ok(b) = std::fs::read(&p) else { continue }; - let Ok(m) = Map::read(&b) else { continue }; - map_files += 1; - for ev in &m.events { - for pg in &ev.pages { - consider( - &pg.commands, - &mut unknown_cmd, - &mut unknown_route, - &mut all_cids, - ); - } - } - } - } - - eprintln!( - "scanned {ce_files} CommonEvent files + {map_files} maps; {} distinct command ids", - all_cids.len() - ); - eprintln!("all command ids seen: {:?}", all_cids); - if unknown_cmd.is_empty() { - eprintln!("UNNAMED command ids: NONE - every command across every version is named"); - } else { - eprintln!("UNNAMED command ids (cid: count): {unknown_cmd:?}"); - } - if unknown_route.is_empty() { - eprintln!("UNNAMED route-command ids: NONE"); - } else { - eprintln!("UNNAMED route-command ids: {unknown_route:?}"); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/all_versions_recompile.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/all_versions_recompile.rs deleted file mode 100644 index b2aba50..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/all_versions_recompile.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Cross-version recompiler gate: for every Wolf editor version's CommonEvent.dat, prove -//! compile(decompile(cmds)) == cmds for every common event. -//! cargo test -p wolf-decompiler --test all_versions_recompile -- --nocapture -use std::path::{Path, PathBuf}; - -use wolf_decompiler::{compile_commands, decompile_commands}; -use wolf_formats::common_event::CommonEventsFile; - -fn root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("ALLWOLFVERSIONS") -} - -fn find_ce(dir: &Path) -> Option { - let mut stack = vec![dir.to_path_buf()]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else if p.file_name().and_then(|s| s.to_str()) == Some("CommonEvent.dat") { - return Some(p); - } - } - } - None -} - -#[test] -fn recompile_all_versions() { - let Ok(dirs) = std::fs::read_dir(root()) else { - eprintln!("no ALLWOLFVERSIONS"); - return; - }; - let mut editors: Vec = dirs - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .collect(); - editors.sort(); - - let mut versions_ok = 0; - let mut versions_total = 0; - for ed in &editors { - let name = ed.file_name().unwrap().to_string_lossy().into_owned(); - let Some(p) = find_ce(ed) else { continue }; - let Ok(bytes) = std::fs::read(&p) else { - continue; - }; - let Ok(ce) = CommonEventsFile::read(&bytes) else { - eprintln!(" {name}: CommonEvent parse failed"); - continue; - }; - versions_total += 1; - - let mut ok = 0; - let total = ce.events.len(); - let mut first_fail = String::new(); - for ev in &ce.events { - let text = decompile_commands(&ev.commands); - match compile_commands(&text) { - Ok(back) if back == ev.commands => ok += 1, - Ok(_) if first_fail.is_empty() => { - first_fail = format!("event {} mismatch", ev.int_id) - } - Err(e) if first_fail.is_empty() => { - first_fail = format!("event {} err: {}", ev.int_id, e) - } - _ => {} - } - } - if ok == total { - versions_ok += 1; - eprintln!(" OK {name:22} v=0x{:02x} {ok}/{total}", ce.version); - } else { - eprintln!( - " FAIL {name:22} v=0x{:02x} {ok}/{total} ({first_fail})", - ce.version - ); - } - } - eprintln!("\nversions fully recompiling: {versions_ok}/{versions_total}"); - if versions_total > 0 { - assert_eq!( - versions_ok, versions_total, - "some version's common events did not recompile byte-exact" - ); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/common/mod.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/common/mod.rs deleted file mode 100644 index a9c3161..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/common/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Shared helpers for the integration tests. -//! -//! Several tests need real Wolf game data (saves, an unpacked Data dir) that cannot be bundled -//! because it is copyrighted. Point `WOLFDAWN_TEST_DATA` at a fixtures root laid out as described in -//! the README and those tests run. With the var unset, or a given file missing, the lookup returns -//! `None` and the test skips itself. - -/// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when the -/// var is unset or the file/dir does not exist, so callers can skip gracefully. -#[allow(dead_code)] -pub fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/corpus_gates.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/corpus_gates.rs deleted file mode 100644 index 34631a0..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/corpus_gates.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Permanent corpus gates. Across every sample or game file reachable from the workspace root, -//! (1) the readable decompile must contain no generic/fallthrough renderings (no `Self*[`, -//! `route`, `cmp`, `Cmd`, `@raw`, or `argN`), and (2) every command list must round-trip -//! byte-exactly through the gated recompile path. Guards the "nothing generic, fully readable, -//! never lossy" contract for maps and common events at all engine versions. - -use wolf_decompiler::{ - compile_commands_enc, decompile_commands_annotated, decompile_commands_enc, - decompile_common_events, decompile_map, SymbolTable, -}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -/// Build a real symbol table for a code file (CE self-vars, the sibling databases' variable -/// names, and the engine glossary), mirroring the CLI so the annotate path resolves real names. -fn symbols_for(path: &std::path::Path, ce: Option<&CommonEventsFile>) -> SymbolTable { - let mut sym = SymbolTable::new(); - if let Some(ce) = ce { - sym.add_common_events(ce); - } - if let Some(dir) = path.parent() { - let basic = if dir.join("DataBase.project").exists() { - dir.to_path_buf() - } else { - dir.join("BasicData") - }; - for (stem, kind) in [ - ("DataBase", "UDB"), - ("CDataBase", "CDB"), - ("SysDatabase", "SDB"), - ] { - let proj = basic.join(format!("{stem}.project")); - let dat = basic.join(format!("{stem}.dat")); - if let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) { - if let Ok(db) = Database::read(&p, &d) { - sym.add_database(kind, &db); - } - } - } - } - sym.set_engine_text(wolf_decompiler::symbols::load_embedded_engine_glossary()); - sym -} - -fn corpus_files() -> Vec { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); - let mut stack = vec![root]; - let mut out = Vec::new(); - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else { - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); - if name == "CommonEvent.dat" || ext.eq_ignore_ascii_case("mps") { - out.push(p); - } - } - } - } - out -} - -/// A generic/fallthrough token that should never appear in readable output. -fn fallthrough_hits(text: &str) -> Vec { - let mut hits = Vec::new(); - for line in text.lines() { - let l = line.trim(); - if l.contains("Self*[") - || l.contains("@raw ") - || prefixed_num(l, "route") - || prefixed_num(l, "cmp") - || prefixed_num(l, "Cmd") - || generic_argn(l) - { - hits.push(l.to_string()); - } - } - hits -} - -/// `prefix` immediately followed by a digit, not preceded by an alphanumeric (so `route47` -/// matches but `@route(` and `strcmp` do not). -fn prefixed_num(line: &str, prefix: &str) -> bool { - line.match_indices(prefix).any(|(i, _)| { - let before_ok = line[..i] - .chars() - .last() - .map_or(true, |c| !c.is_alphanumeric() && c != '@'); - before_ok - && line[i + prefix.len()..] - .chars() - .next() - .map_or(false, |c| c.is_ascii_digit()) - }) -} - -fn generic_argn(line: &str) -> bool { - line.match_indices("arg").any(|(i, _)| { - let before_ok = line[..i] - .chars() - .last() - .map_or(true, |c| !c.is_alphanumeric()); - let after = &line[i + 3..]; - before_ok - && after.chars().next().map_or(false, |c| c.is_ascii_digit()) - && after - .split_once('=') - .map_or(false, |(k, _)| k.chars().all(|c| c.is_ascii_digit())) - }) -} - -#[test] -fn no_fallthrough_renderings() { - let sym = SymbolTable::new(); - let mut total = 0usize; - let mut bad = 0usize; - for p in corpus_files() { - let bytes = std::fs::read(&p).unwrap_or_default(); - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let text = if name == "CommonEvent.dat" { - match CommonEventsFile::read(&bytes) { - Ok(ce) => decompile_common_events(&ce, &sym), - Err(_) => continue, // encrypted or out-of-scope inputs - } - } else { - match Map::read(&bytes) { - Ok(m) => decompile_map(&m, &sym), - Err(_) => continue, - } - }; - total += 1; - let hits = fallthrough_hits(&text); - if !hits.is_empty() { - bad += 1; - eprintln!( - "{} fallthrough(s) in {:?}; e.g. {}", - hits.len(), - p.file_name().unwrap(), - hits[0] - ); - } - } - eprintln!("scanned {total} readable files"); - assert!(total > 0, "corpus not found"); - assert_eq!(bad, 0, "files with generic/fallthrough renderings"); -} - -#[test] -fn annotate_roundtrip_byte_exact() { - // The unified read-plus-edit form (index-anchored bilingual names) must round-trip byte-exact - // with real symbols loaded. The recompiler strips the labels. The index is the authority. - let mut lists = 0usize; - let mut mismatch = 0usize; - let mut annotated = 0usize; - for p in corpus_files() { - let bytes = std::fs::read(&p).unwrap_or_default(); - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - if name == "CommonEvent.dat" { - let Ok(ce) = CommonEventsFile::read(&bytes) else { - continue; - }; - let sym = symbols_for(&p, Some(&ce)); - for ev in &ce.events { - lists += 1; - let text = - decompile_commands_annotated(&ev.commands, ce.utf8, &sym, Some(ev.int_id)); - if text.contains("[0 \"") || text.contains("\" ] ") || text.matches('"').count() > 0 - { - annotated += text - .lines() - .filter(|l| l.contains("[") && l.contains("\"")) - .count(); - } - match compile_commands_enc(&text, ce.utf8) { - Ok(back) if back == ev.commands => {} - _ => { - mismatch += 1; - if mismatch <= 5 { - eprintln!( - "annotate mismatch in {:?} CE {}", - p.file_name().unwrap(), - ev.int_id - ); - } - } - } - } - } else if p.extension().and_then(|s| s.to_str()) == Some("mps") { - let Ok(m) = Map::read(&bytes) else { continue }; - let sym = symbols_for(&p, None); - for ev in &m.events { - for pg in &ev.pages { - lists += 1; - let text = decompile_commands_annotated(&pg.commands, m.utf8, &sym, None); - match compile_commands_enc(&text, m.utf8) { - Ok(back) if back == pg.commands => {} - _ => { - mismatch += 1; - if mismatch <= 5 { - eprintln!("annotate mismatch in {:?}", p.file_name().unwrap()); - } - } - } - } - } - } - } - eprintln!("annotate: round-tripped {lists} lists, ~{annotated} annotated lines seen"); - assert!(lists > 0, "corpus not found"); - assert_eq!( - mismatch, 0, - "annotated command lists that did not round-trip byte-exactly" - ); -} - -#[test] -fn byte_exact_roundtrip() { - let mut lists = 0usize; - let mut mismatch = 0usize; - for p in corpus_files() { - let bytes = std::fs::read(&p).unwrap_or_default(); - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let programs: Vec<(bool, Vec<_>)> = if name == "CommonEvent.dat" { - match CommonEventsFile::read(&bytes) { - Ok(ce) => ce - .events - .into_iter() - .map(|ev| (ce.utf8, ev.commands)) - .collect(), - Err(_) => continue, - } - } else { - match Map::read(&bytes) { - Ok(m) => m - .events - .into_iter() - .flat_map(|ev| ev.pages.into_iter().map(move |pg| (m.utf8, pg.commands))) - .collect(), - Err(_) => continue, - } - }; - for (utf8, cmds) in programs { - lists += 1; - let text = decompile_commands_enc(&cmds, utf8); - match compile_commands_enc(&text, utf8) { - Ok(back) if back == cmds => {} - Ok(_) => { - mismatch += 1; - if mismatch <= 5 { - eprintln!("structural mismatch in {:?}", p.file_name().unwrap()); - } - } - Err(e) => { - mismatch += 1; - if mismatch <= 5 { - eprintln!("compile error in {:?}: {e}", p.file_name().unwrap()); - } - } - } - } - } - eprintln!("round-tripped {lists} command lists"); - assert!(lists > 0, "corpus not found"); - assert_eq!( - mismatch, 0, - "command lists that did not round-trip byte-exactly" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/db_apply.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/db_apply.rs deleted file mode 100644 index cb51bb9..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/db_apply.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! DB value-edit round-trip. Applying the export unchanged is byte-identical to the base. Editing -//! a single int and a single string changes only those cells and reloads correctly. -use wolf_decompiler::{apply_database_edit, database_to_json}; -use wolf_formats::database::Database; - -fn load() -> Option<(Vec, Vec, Database)> { - let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/BasicData"); - let (proj, dat) = (base.join("DataBase.project"), base.join("DataBase.dat")); - let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) else { - eprintln!("no corpus DataBase, skipping"); - return None; - }; - let db = Database::read(&p, &d).expect("parse db"); - Some((p, d, db)) -} - -#[test] -fn noop_apply_is_byte_identical() { - let Some((p, d, db)) = load() else { return }; - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = database_to_json(&db, "UDB", &glossary); - - let mut db2 = Database::read(&p, &d).unwrap(); - let changed = apply_database_edit(&json, &mut db2).expect("apply"); - assert_eq!( - changed, 0, - "round-tripping the export unchanged must change nothing" - ); - let (p2, d2) = db2.write(); - assert!( - p2 == p && d2 == d, - "no-op apply must be byte-identical to base" - ); -} - -#[test] -fn edit_one_int_and_one_string_roundtrips() { - let Some((p, d, db)) = load() else { return }; - - // Pick a type with rows, and (via the shared key derivation) an int and a string slot. - let (ti, int_key, int_slot, str_key, str_slot) = db - .types - .iter() - .enumerate() - .find_map(|(ti, t)| { - if t.data.is_empty() { - return None; - } - let fs = (t.dat_fields_size as usize).min(t.fields.len()); - let slots = wolf_decompiler::json::value_slots(t, fs, db.utf8); - let ik = slots.iter().find(|(_, is_str, _)| !is_str)?; - let sk = slots.iter().find(|(_, is_str, _)| *is_str)?; - Some((ti, ik.0.clone(), ik.2, sk.0.clone(), sk.2)) - }) - .expect("a type with int+string slots and rows"); - - let edit = format!( - r#"{{ "types": [ {{ "id": {ti}, "rows": [ {{ "id": 0, "values": {{ {ik}: 31337, {sk}: "WOLFDAWN_EDIT" }} }} ] }} ] }}"#, - ik = serde_json::Value::String(int_key), - sk = serde_json::Value::String(str_key), - ); - - let mut db2 = Database::read(&p, &d).unwrap(); - let changed = apply_database_edit(&edit, &mut db2).expect("apply"); - assert!(changed >= 1, "expected at least one changed cell"); - let (p2, d2) = db2.write(); - - let db3 = Database::read(&p2, &d2).expect("re-read edited"); - let t3 = &db3.types[ti]; - assert_eq!( - t3.data[0].int_values[int_slot], 31337, - "int edit must persist" - ); - assert_eq!( - wolf_decompiler::text::decode_wstr(&t3.data[0].string_values[str_slot], db3.utf8), - "WOLFDAWN_EDIT", - "string edit must persist" - ); - assert_eq!( - p2, p, - ".project must be unchanged when only .dat values are edited" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/db_json.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/db_json.rs deleted file mode 100644 index 5121673..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/db_json.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! DB to JSON export sanity gate: valid, balanced, faithful JSON for a real database. -use wolf_decompiler::database_to_json; -use wolf_formats::database::Database; - -#[test] -fn exports_valid_json_for_corpus_db() { - let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/BasicData"); - let (proj, dat) = (base.join("DataBase.project"), base.join("DataBase.dat")); - let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) else { - eprintln!("no corpus DataBase, skipping"); - return; - }; - let db = Database::read(&p, &d).expect("parse db"); - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = database_to_json(&db, "UDB", &glossary); - // Bilingual lookup: structural names the glossary covers carry a `name_en`, so a modder - // reading the English code can find the entry. Absent for player-facing content. - assert!( - json.contains("\"name_en\":"), - "expected at least one English name_en in the UDB export" - ); - - // Structurally valid: starts and ends as an object, braces and brackets balanced, key fields present. - assert!(json.trim_start().starts_with('{') && json.trim_end().ends_with('}')); - assert!(json.contains("\"kind\": \"UDB\"")); - assert!(json.contains("\"types\"") && json.contains("\"rows\"")); - let bal = |o: char, c: char| { - let mut depth = 0i64; - let mut in_str = false; - let mut esc = false; - for ch in json.chars() { - if esc { - esc = false; - continue; - } - match ch { - '\\' if in_str => esc = true, - '"' => in_str = !in_str, - x if x == o && !in_str => depth += 1, - x if x == c && !in_str => depth -= 1, - _ => {} - } - } - depth - }; - assert_eq!(bal('{', '}'), 0, "unbalanced braces"); - assert_eq!(bal('[', ']'), 0, "unbalanced brackets"); - eprintln!("UDB JSON: {} bytes, {} types", json.len(), db.types.len()); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/full_file_recompile.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/full_file_recompile.rs deleted file mode 100644 index 5097328..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/full_file_recompile.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Whole-file recompile gate: `compile(decompile_edit(file), base=file).write() == file`. -//! -//! This is the real edit-to-play round trip. It exercises the document envelope, the patch -//! substitution, and the format writer together, asserting byte-identity across every -//! CommonEvent.dat and .mps reachable (main corpus, every editor version, GamePro and Pro). -//! cargo test -p wolf-decompiler --test full_file_recompile -- --nocapture - -use std::path::{Path, PathBuf}; - -use wolf_decompiler::{ - compile_common_events_edit, compile_map_edit, decompile_common_events_edit, decompile_map_edit, -}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn wolf_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") -} - -/// Recurse `dir`, collecting every CommonEvent.dat and *.mps. -fn collect(dir: &Path, ces: &mut Vec, maps: &mut Vec) { - let Ok(rd) = std::fs::read_dir(dir) else { - return; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - collect(&p, ces, maps); - } else if p.file_name().and_then(|s| s.to_str()) == Some("CommonEvent.dat") { - ces.push(p); - } else if p.extension().and_then(|s| s.to_str()) == Some("mps") { - maps.push(p); - } - } -} - -fn ce_roundtrip(bytes: &[u8]) -> Result { - let ce = CommonEventsFile::read(bytes).map_err(|e| e.to_string())?; - let doc = decompile_common_events_edit(&ce); - let mut base = CommonEventsFile::read(bytes).map_err(|e| e.to_string())?; - compile_common_events_edit(&doc, &mut base).map_err(|e| e.to_string())?; - Ok(base.write() == bytes) -} - -fn map_roundtrip(bytes: &[u8]) -> Result { - let map = Map::read(bytes).map_err(|e| e.to_string())?; - let doc = decompile_map_edit(&map); - let mut base = Map::read(bytes).map_err(|e| e.to_string())?; - compile_map_edit(&doc, &mut base).map_err(|e| e.to_string())?; - Ok(base.write() == bytes) -} - -#[test] -fn whole_file_round_trip_byte_exact() { - let root = wolf_root(); - let mut ces = Vec::new(); - let mut maps = Vec::new(); - for sub in ["Data", "ALLWOLFVERSIONS", "GamePro_Data", "GameProData"] { - collect(&root.join(sub), &mut ces, &mut maps); - } - ces.sort(); - ces.dedup(); - maps.sort(); - maps.dedup(); - - if ces.is_empty() && maps.is_empty() { - eprintln!("no corpus found, skipping"); - return; - } - - let mut ok = 0usize; - let mut fail = 0usize; - let mut skip = 0usize; - let rel = |p: &Path| { - p.strip_prefix(&root) - .unwrap_or(p) - .display() - .to_string() - .replace('\\', "/") - }; - - // Encrypted (packaged) files need the archive layer to decrypt first. They are not plaintext - // inputs to the format/recompile path, so skip rather than fail. - let mut tally = |p: &Path, r: Result| match r { - Ok(true) => ok += 1, - Ok(false) => { - fail += 1; - eprintln!(" DIFF {}", rel(p)); - } - Err(e) if e.contains("encrypted") => skip += 1, - Err(e) => { - fail += 1; - eprintln!(" ERR {} ({e})", rel(p)); - } - }; - - for p in &ces { - let Ok(bytes) = std::fs::read(p) else { - continue; - }; - tally(p, ce_roundtrip(&bytes)); - } - for p in &maps { - let Ok(bytes) = std::fs::read(p) else { - continue; - }; - tally(p, map_roundtrip(&bytes)); - } - - eprintln!( - "\nwhole-file recompile: {ok} byte-exact, {fail} fail, {skip} skipped (encrypted) \ - ({} CE, {} maps)", - ces.len(), - maps.len() - ); - assert_eq!(fail, 0, "some files did not recompile byte-exact"); - assert!(ok > 0, "no files were exercised"); -} - -#[test] -fn edit_then_compile_takes_effect() { - // Prove the whole-file path is driven by the document text, not a memory of the base. Edit a - // Message in the decompiled doc, recompile, and confirm the bytes changed and the new text is - // present, and that the re-read file still parses. - let path = wolf_root().join("Data/BasicData/CommonEvent.dat"); - let Ok(bytes) = std::fs::read(&path) else { - eprintln!("corpus not found, skipping"); - return; - }; - let ce = CommonEventsFile::read(&bytes).unwrap(); - let doc = decompile_common_events_edit(&ce); - - // Find any Message line to mutate. - let Some(line) = doc.lines().find(|l| l.trim_start().starts_with("Message ")) else { - eprintln!("no Message command in corpus doc, skipping"); - return; - }; - let edited = doc.replacen(line, "Message \"WOLFDAWN_EDIT_MARKER\"", 1); - assert_ne!(edited, doc, "edit did not change the document"); - - let mut base = CommonEventsFile::read(&bytes).unwrap(); - compile_common_events_edit(&edited, &mut base).expect("edited doc compiles"); - let out = base.write(); - assert_ne!(out, bytes, "edited file should differ from the original"); - - // Re-read: the marker is present and the file is structurally valid. - let reread = CommonEventsFile::read(&out).expect("edited file re-parses"); - let found = reread.events.iter().any(|ev| { - ev.commands.iter().any(|c| { - c.cid == 101 - && c.str_args - .first() - .map_or(false, |s| s.as_bytes() == b"WOLFDAWN_EDIT_MARKER\0") - }) - }); - assert!(found, "edited Message text not found after recompile"); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/game_dat_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/game_dat_roundtrip.rs deleted file mode 100644 index 4f3474f..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/game_dat_roundtrip.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Game.dat support: byte-exact round-trip, Title extraction, and translated-Title inject. -//! Uses the corpus `Data/BasicData/Game.dat`. Skips gracefully if it is absent. - -use wolf_decompiler::{extract_game_dat, inject_game_dat, InjectOptions}; -use wolf_formats::game_dat::GameDat; - -fn corpus_game_dat() -> Option> { - let path = - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/BasicData/Game.dat"); - std::fs::read(&path).ok() -} - -#[test] -fn read_write_is_byte_exact() { - let Some(bytes) = corpus_game_dat() else { - eprintln!("no corpus Game.dat, skipping"); - return; - }; - let gd = GameDat::read(&bytes).expect("parse Game.dat"); - assert!( - gd.write() == bytes, - "Game.dat round-trip must be byte-exact for an unmodified file" - ); -} - -#[test] -fn extract_yields_nonempty_title() { - let Some(bytes) = corpus_game_dat() else { - return; - }; - let gd = GameDat::read(&bytes).unwrap(); - let json = extract_game_dat(&gd); - let root: serde_json::Value = serde_json::from_str(&json).unwrap(); - let title = root["lines"] - .as_array() - .unwrap() - .iter() - .find(|l| l["key"] == "Title") - .expect("a Title line"); - assert!( - !title["source"].as_str().unwrap_or("").is_empty(), - "extracted Title must be non-empty" - ); -} - -#[test] -fn inject_translated_title_persists_and_reparses() { - let Some(bytes) = corpus_game_dat() else { - return; - }; - let gd = GameDat::read(&bytes).unwrap(); - let title_src = { - let json = extract_game_dat(&gd); - let root: serde_json::Value = serde_json::from_str(&json).unwrap(); - root["lines"] - .as_array() - .unwrap() - .iter() - .find(|l| l["key"] == "Title") - .unwrap()["source"] - .as_str() - .unwrap() - .to_string() - }; - - // No-op inject (text == source) must leave the file byte-identical. - let edit_noop = format!( - r#"{{ "lines": [ {{ "key": "Title", "source": {0}, "text": {0} }} ] }}"#, - serde_json::Value::String(title_src.clone()) - ); - let mut gd_noop = GameDat::read(&bytes).unwrap(); - let st = inject_game_dat(&edit_noop, &mut gd_noop, &InjectOptions::default()).expect("inject"); - assert_eq!(st.applied, 0); - assert_eq!(st.drifted, 0); - assert!( - gd_noop.write() == bytes, - "no-op Game.dat inject must be byte-identical" - ); - - // Translate the Title and confirm it persists, the file re-parses, and other fields are intact. - let edit = format!( - r#"{{ "lines": [ {{ "key": "Title", "source": {}, "text": "WolfDawn Title" }} ] }}"#, - serde_json::Value::String(title_src.clone()) - ); - let mut gd2 = GameDat::read(&bytes).unwrap(); - let st = inject_game_dat(&edit, &mut gd2, &InjectOptions::default()).expect("inject"); - assert_eq!(st.applied, 1, "the Title should translate"); - - let out = gd2.write(); - let gd3 = GameDat::read(&out).expect("injected Game.dat must re-parse"); - let json3 = extract_game_dat(&gd3); - let root3: serde_json::Value = serde_json::from_str(&json3).unwrap(); - let new_title = root3["lines"] - .as_array() - .unwrap() - .iter() - .find(|l| l["key"] == "Title") - .unwrap()["source"] - .as_str() - .unwrap(); - assert_eq!(new_title, "WolfDawn Title", "Title must read the new text"); - - // Other non-title fields are untouched. The decrypt key, font, and the housekeeping - // word-data carry over verbatim. - assert_eq!(gd3.decrypt_key, gd.decrypt_key, "decrypt key intact"); - assert_eq!(gd3.font, gd.font, "font intact"); - assert_eq!( - gd3.unknown_word_data, gd.unknown_word_data, - "word data intact" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/gamedat_full.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/gamedat_full.rs deleted file mode 100644 index 5839279..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/gamedat_full.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Full Game.dat field editing (`dump_game_dat` / `apply_game_dat`): byte-exact no-op round-trip, -//! editing a scalar field, editing the Title (length change), and the `None`-optional omission -//! invariant. Uses an unpacked original Game.dat if present (the `WOLFDAWN_TEST_DATA` fixture first, -//! the corpus `Data/BasicData/Game.dat` as a fallback). Skips gracefully if neither is available. - -use wolf_decompiler::{apply_game_dat, dump_game_dat}; -use wolf_formats::game_dat::GameDat; - -mod common; -use common::test_data; - -/// Locate an unpacked original Game.dat: the fixtures copy first, then the workspace corpus. -fn fixture() -> Option> { - let candidates = [ - test_data("chamber/Data/BasicData/Game.dat"), - Some( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../Data/BasicData/Game.dat"), - ), - ]; - candidates - .into_iter() - .flatten() - .find_map(|p| std::fs::read(&p).ok()) -} - -#[test] -fn dump_apply_write_is_byte_exact_noop() { - let Some(bytes) = fixture() else { - eprintln!("no Game.dat fixture, skipping"); - return; - }; - let mut gd = GameDat::read(&bytes).expect("parse Game.dat"); - let json = dump_game_dat(&gd); - // The dump is valid JSON. - let _: serde_json::Value = serde_json::from_str(&json).expect("dump is valid JSON"); - // Applying the dump back onto the file changes nothing... - let changed = apply_game_dat(&mut gd, &json).expect("apply dump"); - assert_eq!(changed, 0, "round-tripping the dump must change no fields"); - // ...and the write is byte-exact. - assert!( - gd.write() == bytes, - "dump -> apply -> write must reproduce the file byte-exact" - ); -} - -#[test] -fn edit_font_round_trips_and_leaves_other_fields_intact() { - let Some(bytes) = fixture() else { - return; - }; - let original = GameDat::read(&bytes).expect("parse base"); - - // Build an edit JSON from the dump, swapping only the Font. - let dump = dump_game_dat(&original); - let mut root: serde_json::Value = serde_json::from_str(&dump).unwrap(); - root["Font"] = serde_json::Value::String("TestFont".to_string()); - let edit = serde_json::to_string(&root).unwrap(); - - let mut gd = GameDat::read(&bytes).unwrap(); - let changed = apply_game_dat(&mut gd, &edit).expect("apply edit"); - assert_eq!(changed, 1, "only the Font should change"); - - let out = gd.write(); - let reread = GameDat::read(&out).expect("edited Game.dat must re-parse"); - - // Font reads back as the new value. - let reread_json: serde_json::Value = - serde_json::from_str(&dump_game_dat(&reread)).unwrap(); - assert_eq!(reread_json["Font"], "TestFont", "Font must read the new text"); - - // Every other field is byte-identical to the original struct. - assert_eq!(reread.title, original.title, "title intact"); - assert_eq!(reread.title_plus, original.title_plus, "title_plus intact"); - assert_eq!(reread.sub_fonts, original.sub_fonts, "sub_fonts intact"); - assert_eq!( - reread.default_pc_graphic, original.default_pc_graphic, - "default_pc_graphic intact" - ); - assert_eq!(reread.road_img, original.road_img, "road_img intact"); - assert_eq!(reread.gauge_img, original.gauge_img, "gauge_img intact"); - assert_eq!(reread.start_up_msg, original.start_up_msg, "start_up_msg intact"); - assert_eq!(reread.title_msg, original.title_msg, "title_msg intact"); - assert_eq!( - reread.unknown_string14, original.unknown_string14, - "unknown_string14 intact" - ); - assert_eq!(reread.decrypt_key, original.decrypt_key, "decrypt key intact"); - assert_eq!(reread.magic_string, original.magic_string, "magic string intact"); - assert_eq!( - reread.unknown_word_data, original.unknown_word_data, - "word data intact" - ); -} - -#[test] -fn edit_title_length_change_round_trips() { - let Some(bytes) = fixture() else { - return; - }; - let original = GameDat::read(&bytes).unwrap(); - let old_title = { - let d: serde_json::Value = serde_json::from_str(&dump_game_dat(&original)).unwrap(); - d["Title"].as_str().unwrap().to_string() - }; - - // A new title of a clearly different length, to exercise the housekeeping-offset shift. - let new_title = format!("{old_title} -- WolfDawn Full Edit Demo"); - assert_ne!(new_title.len(), old_title.len(), "fixture sanity: length differs"); - - let mut root: serde_json::Value = serde_json::from_str(&dump_game_dat(&original)).unwrap(); - root["Title"] = serde_json::Value::String(new_title.clone()); - let edit = serde_json::to_string(&root).unwrap(); - - let mut gd = GameDat::read(&bytes).unwrap(); - let changed = apply_game_dat(&mut gd, &edit).expect("apply title edit"); - assert_eq!(changed, 1, "only the Title should change"); - - let out = gd.write(); - // The file must re-parse cleanly despite the length change (offsets shifted correctly). - let reread = GameDat::read(&out).expect("re-parse after length-changing Title edit"); - let reread_title = { - let d: serde_json::Value = serde_json::from_str(&dump_game_dat(&reread)).unwrap(); - d["Title"].as_str().unwrap().to_string() - }; - assert_eq!(reread_title, new_title, "Title must read the new (longer) text"); - // Non-title fields still intact. - assert_eq!(reread.font, original.font, "font intact after title resize"); - assert_eq!( - reread.default_pc_graphic, original.default_pc_graphic, - "graphic intact after title resize" - ); -} - -#[test] -fn dump_omits_none_optionals_and_includes_present() { - let Some(bytes) = fixture() else { - return; - }; - let gd = GameDat::read(&bytes).unwrap(); - let json = dump_game_dat(&gd); - let root: serde_json::Value = serde_json::from_str(&json).unwrap(); - let obj = root.as_object().unwrap(); - - // Fixed scalars, the array, and the encoding marker are always present. - assert!(obj.contains_key("encoding"), "encoding marker present"); - assert!(obj.contains_key("Title"), "Title always present"); - assert!(obj.contains_key("Font"), "Font always present"); - assert!(obj.contains_key("DefaultPcGraphic"), "graphic always present"); - assert!(root["SubFonts"].is_array(), "SubFonts is an array"); - assert_eq!( - root["SubFonts"].as_array().unwrap().len(), - gd.sub_fonts.len(), - "SubFonts array length matches the struct" - ); - - // Each optional key appears in the JSON iff the struct cell is Some. - let checks: [(&str, bool); 6] = [ - ("TitlePlus", gd.title_plus.is_some()), - ("RoadImg", gd.road_img.is_some()), - ("GaugeImg", gd.gauge_img.is_some()), - ("StartUpMsg", gd.start_up_msg.is_some()), - ("TitleMsg", gd.title_msg.is_some()), - ("UnknownString14", gd.unknown_string14.is_some()), - ]; - for (key, present) in checks { - assert_eq!( - obj.contains_key(key), - present, - "`{key}` key presence must match the optional cell" - ); - } - - // The read-only encoding marker must match the file. - assert_eq!( - root["encoding"].as_str().unwrap(), - if gd.utf8 { "utf8" } else { "shiftjis" } - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/gamepro_decompile.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/gamepro_decompile.rs deleted file mode 100644 index 93c0547..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/gamepro_decompile.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Probe that reports and never asserts. Runs the decompiler and recompiler against the Wolf-Pro -//! game's 1300 v3.5 common events and maps. Reports (1) byte-exact recompile coverage, (2) any -//! command ids missing from the spec, and (3) how readable the output is (pretty vs @raw -//! fallback), driven by the per-command v3.5 trailing blob. -//! cargo test -p wolf-decompiler --test gamepro_decompile -- --nocapture - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use wolf_decompiler::{compile_commands, decompile_commands, spec}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn pro() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("GamePro_Data") -} - -#[test] -fn recompile_pro_common_events() { - let p = pro().join("BasicData").join("CommonEvent.dat"); - let Ok(bytes) = std::fs::read(&p) else { - eprintln!("missing {}", p.display()); - return; - }; - let ce = CommonEventsFile::read(&bytes).expect("parse Pro CommonEvent.dat"); - eprintln!( - "Pro CommonEvent.dat: v35={} events={}", - ce.v35, - ce.events.len() - ); - - let mut ok = 0usize; - let mut fail = 0usize; - let mut unknown_cids: BTreeMap = BTreeMap::new(); - let mut blob_sizes: BTreeMap = BTreeMap::new(); - let mut pretty_lines = 0usize; - let mut raw_lines = 0usize; - let mut failures: Vec<(u32, String)> = Vec::new(); - - for ev in &ce.events { - for c in &ev.commands { - if spec::command(c.cid).is_none() { - *unknown_cids.entry(c.cid).or_default() += 1; - } - let bl = c.v35_blob.as_ref().map(|b| b.len()).unwrap_or(usize::MAX); - if bl != usize::MAX { - *blob_sizes.entry(bl).or_default() += 1; - } - } - - let text = decompile_commands(&ev.commands); - for line in text.lines() { - let t = line.trim_start(); - if t.is_empty() { - continue; - } - if t.starts_with("@raw ") { - raw_lines += 1; - } else { - pretty_lines += 1; - } - } - - match compile_commands(&text) { - Ok(back) if back == ev.commands => ok += 1, - Ok(back) => { - fail += 1; - let idx = back.iter().zip(&ev.commands).position(|(a, b)| a != b); - let detail = match idx { - Some(i) => format!( - "cmd#{i} cid{}: orig {:?} vs back {:?}", - ev.commands[i].cid, ev.commands[i], back[i] - ), - None => format!("length {} vs {}", back.len(), ev.commands.len()), - }; - failures.push((ev.int_id, detail)); - } - Err(e) => { - fail += 1; - failures.push((ev.int_id, format!("compile error: {e}"))); - } - } - } - - eprintln!( - "recompile byte-exact: {ok}/{} (fail {fail})", - ce.events.len() - ); - eprintln!("readability: pretty lines={pretty_lines} @raw lines={raw_lines}"); - eprintln!("v35 blob size distribution (size: count): {blob_sizes:?}"); - if unknown_cids.is_empty() { - eprintln!("unknown command ids: NONE - every cid is in the spec"); - } else { - eprintln!("unknown command ids (cid: occurrences): {unknown_cids:?}"); - } - eprintln!("--- all {} failures ---", failures.len()); - for (id, detail) in &failures { - eprintln!(" event {id}: {detail}"); - } - - // Dump a couple of examples of each unknown command id, with arg shape, to identify them. - for &cid in &[242u32, 252, 260] { - let mut shown = 0; - for ev in &ce.events { - for c in &ev.commands { - if c.cid == cid && shown < 3 { - eprintln!( - " ex cid{cid}: ints={:?} strs={:?}", - c.int_args, - c.str_args - .iter() - .map(|s| String::from_utf8_lossy(s.as_bytes()) - .trim_end_matches('\0') - .to_string()) - .collect::>() - ); - shown += 1; - } - } - } - } - - assert_eq!( - ok, - ce.events.len(), - "all Pro common events must recompile byte-exact" - ); -} - -#[test] -fn scan_pro_map_commands() { - let dir = pro().join("MapData"); - let Ok(entries) = std::fs::read_dir(&dir) else { - return; - }; - let mut unknown: BTreeMap = BTreeMap::new(); - let mut cmds = 0usize; - let mut maps = 0usize; - for e in entries { - let path = e.unwrap().path(); - if path.extension().and_then(|s| s.to_str()) != Some("mps") { - continue; - } - let bytes = std::fs::read(&path).unwrap(); - let Ok(m) = Map::read(&bytes) else { continue }; - maps += 1; - for ev in &m.events { - for pg in &ev.pages { - for c in &pg.commands { - cmds += 1; - if spec::command(c.cid).is_none() { - *unknown.entry(c.cid).or_default() += 1; - } - } - } - } - } - eprintln!("Pro maps scanned: {maps}, commands: {cmds}"); - if unknown.is_empty() { - eprintln!("unknown map command ids: NONE"); - } else { - eprintln!("unknown map command ids: {unknown:?}"); - } -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/merge_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/merge_roundtrip.rs deleted file mode 100644 index e3f3623..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/merge_roundtrip.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Incremental translation-merge (translation memory) round-trip tests. No corpus is needed. -//! Every fixture is a tiny hand-built JSON, exercising the generic `source`/`text` walker across -//! the different document shapes (names, scenes, db-like). -//! -//! Gates: -//! (a) build_memory keeps only real translations (drops `text == source`) and reports a source -//! that diverges across two files as a conflict, keeping the first value. -//! (b) apply_memory carries a memory hit into an untranslated leaf, leaves a no-match leaf -//! untranslated (still_new), and never overwrites a leaf the new file already translated -//! (kept_existing). -//! (c) Structure preservation: an empty memory leaves every `text` unchanged and re-parses to -//! the same logical content, and non-`source`/`text` fields are preserved exactly. - -use std::collections::HashMap; - -use serde_json::Value; -use wolf_decompiler::{apply_memory, build_memory, dropped_sources, MergeStats}; - -/// Collect `source -> text` from a parsed value, generically (any object with both string fields). -fn pairs(v: &Value) -> HashMap { - fn walk(v: &Value, out: &mut HashMap) { - match v { - Value::Object(m) => { - if let (Some(Value::String(s)), Some(Value::String(t))) = - (m.get("source"), m.get("text")) - { - out.insert(s.clone(), t.clone()); - } - for c in m.values() { - walk(c, out); - } - } - Value::Array(a) => { - for c in a { - walk(c, out); - } - } - _ => {} - } - } - let mut out = HashMap::new(); - walk(v, &mut out); - out -} - -// --------------------------------------------------------------------------- -// (a) build_memory -// --------------------------------------------------------------------------- - -#[test] -fn build_memory_excludes_untranslated() { - // {names:[{source:"あ",text:"A"},{source:"い",text:"い"}]} yields {"あ":"A"} only. - let names = r#"{"kind":"names","names":[ - {"source":"あ","text":"A","occurrences":2,"note":"Monster"}, - {"source":"い","text":"い","occurrences":1,"note":"Item"} - ]}"#; - let (mem, conflicts) = build_memory(&[names]); - assert_eq!(mem.len(), 1); - assert_eq!(mem.get("あ").map(String::as_str), Some("A")); - assert!(!mem.contains_key("い"), "untranslated entry excluded"); - assert!(conflicts.is_empty()); -} - -#[test] -fn build_memory_divergent_source_is_conflict_first_kept() { - // Same source, two distinct translations across two files is a conflict. The first is kept. - let f1 = r#"{"scenes":[{"lines":[{"source":"あ","text":"A1"}]}]}"#; - let f2 = r#"{"scenes":[{"lines":[{"source":"あ","text":"A2"}]}]}"#; - let (mem, conflicts) = build_memory(&[f1, f2]); - assert_eq!(mem.get("あ").map(String::as_str), Some("A1"), "first input wins"); - assert_eq!(conflicts.len(), 1); - let (src, variants) = &conflicts[0]; - assert_eq!(src, "あ"); - assert_eq!(variants.first().map(String::as_str), Some("A1"), "kept value leads"); - assert!(variants.iter().any(|t| t == "A2")); - assert_eq!(variants.len(), 2); -} - -// --------------------------------------------------------------------------- -// (b) apply_memory -// --------------------------------------------------------------------------- - -#[test] -fn apply_memory_carries_and_keeps() { - // New scenes: あ matches memory (carried), う has no entry (still_new), and a pre-translated - // entry (text != source) is kept untouched (kept_existing). - let new = r#"{"scenes":[{"lines":[ - {"cmd":0,"str":0,"speaker":"NPC","source":"あ","text":"あ"}, - {"cmd":1,"str":0,"speaker":"NPC","source":"う","text":"う"}, - {"cmd":2,"str":0,"speaker":"NPC","source":"え","text":"AlreadyDone"} - ]}]}"#; - let mut mem = HashMap::new(); - mem.insert("あ".to_string(), "A".to_string()); - // A memory entry for え exists too, but the new file already translated it, so it must not override. - mem.insert("え".to_string(), "ShouldNotWin".to_string()); - - let (out, stats) = apply_memory(new, &mem).unwrap(); - assert_eq!( - stats, - MergeStats { - carried: 1, - still_new: 1, - kept_existing: 1 - } - ); - let v: Value = serde_json::from_str(&out).unwrap(); - let p = pairs(&v); - assert_eq!(p.get("あ").map(String::as_str), Some("A"), "carried"); - assert_eq!(p.get("う").map(String::as_str), Some("う"), "still untranslated"); - assert_eq!( - p.get("え").map(String::as_str), - Some("AlreadyDone"), - "existing translation kept, not overwritten by memory" - ); -} - -// --------------------------------------------------------------------------- -// (c) structure preservation -// --------------------------------------------------------------------------- - -#[test] -fn apply_empty_memory_is_structural_noop() { - // Applying an empty memory leaves every `text` unchanged and preserves all sibling fields - // (speaker, cmd, row, and so on). The re-serialized JSON parses to the same logical content. - let new = r#"{"kind":"map","scenes":[{"event":4,"lines":[ - {"cmd":3,"str":1,"row":2,"speaker":"勇者","speaker_src":"勇者","source":"あ","text":"あ"}, - {"cmd":4,"str":0,"row":5,"speaker":"魔王","speaker_src":"魔王","source":"い","text":"い"} - ]}]}"#; - let before: Value = serde_json::from_str(new).unwrap(); - let (out, stats) = apply_memory(new, &HashMap::new()).unwrap(); - assert_eq!(stats.carried, 0); - assert_eq!(stats.kept_existing, 0); - assert_eq!(stats.still_new, 2); - let after: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(before, after, "empty-memory apply must be a logical no-op"); -} - -#[test] -fn non_source_text_fields_survive_a_carry() { - // When a carry happens, only `text` changes. Every other field on the leaf is preserved. - let new = r#"{"groups":[{"type":0,"lines":[ - {"row":7,"field":2,"rowName":"Slime","fieldName":"Name","source":"あ","text":"あ"} - ]}]}"#; - let mut mem = HashMap::new(); - mem.insert("あ".to_string(), "A".to_string()); - let (out, _) = apply_memory(new, &mem).unwrap(); - let v: Value = serde_json::from_str(&out).unwrap(); - let leaf = &v["groups"][0]["lines"][0]; - assert_eq!(leaf["text"], Value::String("A".into()), "text carried"); - assert_eq!(leaf["row"], Value::from(7)); - assert_eq!(leaf["field"], Value::from(2)); - assert_eq!(leaf["rowName"], Value::String("Slime".into())); - assert_eq!(leaf["fieldName"], Value::String("Name".into())); - assert_eq!(leaf["source"], Value::String("あ".into()), "source unchanged"); -} - -// --------------------------------------------------------------------------- -// dropped reporting -// --------------------------------------------------------------------------- - -#[test] -fn dropped_sources_lists_removed_in_update() { - let old = r#"{"lines":[{"source":"あ","text":"A"},{"source":"い","text":"I"}]}"#; - let (mem, _) = build_memory(&[old]); - // The new extraction dropped い (removed in the update). - let new = r#"{"lines":[{"source":"あ","text":"あ"}]}"#; - let dropped = dropped_sources(&mem, &[new]); - assert_eq!(dropped, vec!["い".to_string()]); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/names_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/names_roundtrip.rs deleted file mode 100644 index 1a782db..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/names_roundtrip.rs +++ /dev/null @@ -1,407 +0,0 @@ -//! Project-level name glossary round-trip, against the real corpus at `/Data` -//! (resolved via `CARGO_MANIFEST_DIR/../../../Data`). Skips gracefully when the corpus is absent. -//! -//! Gates: -//! (a) Extract then no-op inject (every `text == source`) leaves every DB pair, CommonEvent, -//! and map byte-identical. -//! (b) Translating one name and injecting rewrites it in every place it appears: its stored -//! `DbData.name`, every name/display cell that held it, and every command by-name reference. -//! All other strings and every other file's bytes stay untouched, and every file still -//! re-parses. -//! (c) Extraction is deterministic (byte-identical across runs). - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use serde_json::Value; -use wolf_decompiler::text::decode_wstr; -use wolf_decompiler::{ - extract_db_name_cells, extract_db_strings, extract_names, inject_names, InjectOptions, -}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -/// The BasicData dir of the corpus, or `None` when the corpus is not present. -fn corpus_basic() -> Option { - let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/BasicData"); - base.join("DataBase.project").exists().then_some(base) -} - -fn map_data_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/MapData") -} - -/// A loaded database with the original on-disk bytes kept for byte-identity checks. -struct LoadedDb { - proj: Vec, - dat: Vec, - db: Database, - stem: &'static str, -} - -fn load_dbs(basic: &Path) -> Vec { - let mut out = Vec::new(); - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - let proj = basic.join(format!("{stem}.project")); - let dat = basic.join(format!("{stem}.dat")); - if let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) { - if let Ok(db) = Database::read(&p, &d) { - out.push(LoadedDb { - proj: p, - dat: d, - db, - stem, - }); - } - } - } - out -} - -fn load_ce(basic: &Path) -> Option<(Vec, CommonEventsFile)> { - let b = std::fs::read(basic.join("CommonEvent.dat")).ok()?; - let ce = CommonEventsFile::read(&b).ok()?; - Some((b, ce)) -} - -fn load_maps() -> Vec<(Vec, Map)> { - let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir(map_data_dir()) else { - return out; - }; - let mut paths: Vec = rd - .flatten() - .map(|e| e.path()) - .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("mps")) - .collect(); - paths.sort(); - for p in paths { - if let Ok(b) = std::fs::read(&p) { - if let Ok(m) = Map::read(&b) { - out.push((b, m)); - } - } - } - out -} - -fn glossary() -> HashMap { - wolf_decompiler::symbols::load_embedded_engine_glossary() -} - -/// Run extract over the loaded corpus. -fn extract(dbs: &[LoadedDb], ce: Option<&CommonEventsFile>, maps: &[(Vec, Map)]) -> String { - let db_vec: Vec = dbs.iter().map(|l| l.db.clone()).collect(); - let ce_refs: Vec<&CommonEventsFile> = ce.into_iter().collect(); - let map_vec: Vec = maps.iter().map(|(_, m)| m.clone()).collect(); - extract_names(&db_vec, &ce_refs, &map_vec, &glossary()) -} - -#[test] -fn extract_then_noop_inject_is_byte_identical() { - let Some(basic) = corpus_basic() else { - eprintln!("no corpus, skipping"); - return; - }; - let dbs = load_dbs(&basic); - let ce = load_ce(&basic); - let maps = load_maps(); - assert!(!dbs.is_empty(), "corpus DBs must load"); - - // Extract is the identity glossary (every text == source), so injecting it must be a no-op. - let names_json = extract(&dbs, ce.as_ref().map(|(_, c)| c), &maps); - - let mut db_vec: Vec = dbs.iter().map(|l| l.db.clone()).collect(); - let mut ce_clone = ce.as_ref().map(|(_, c)| c.clone()); - let mut ce_refs: Vec<&mut CommonEventsFile> = ce_clone.as_mut().into_iter().collect(); - let mut map_vec: Vec = maps.iter().map(|(_, m)| m.clone()).collect(); - - let st = inject_names( - &names_json, - &mut db_vec, - &mut ce_refs, - &mut map_vec, - &InjectOptions::default(), - &glossary(), - ) - .expect("inject"); - assert_eq!( - st.applied, 0, - "a no-op (identity) glossary must apply zero changes" - ); - - // Every file must re-serialize byte-identical to its original on-disk bytes. - for (l, db) in dbs.iter().zip(&db_vec) { - let (p, d) = db.write(); - assert!( - p == l.proj && d == l.dat, - "{}: no-op inject changed the DB bytes", - l.stem - ); - } - if let (Some((b, _)), Some(c)) = (&ce, &ce_clone) { - assert!( - &c.write() == b, - "no-op inject changed the CommonEvent bytes" - ); - } - for ((b, _), m) in maps.iter().zip(&map_vec) { - assert!(&m.write() == b, "no-op inject changed a map's bytes"); - } -} - -/// Count how many times `source` appears as: a stored row name, a string cell value (any field), -/// and a by-name command reference (cid250/252 str2, cid251 str1, cid112 any str). -struct Occ { - row_names: usize, - cell_values: usize, - refs: usize, -} - -fn count_occ( - source: &str, - dbs: &[Database], - ce: Option<&CommonEventsFile>, - maps: &[Map], -) -> Occ { - let mut o = Occ { - row_names: 0, - cell_values: 0, - refs: 0, - }; - for db in dbs { - let utf8 = db.utf8; - for t in &db.types { - for row in &t.data { - if decode_wstr(&row.name, utf8) == source { - o.row_names += 1; - } - for w in &row.string_values { - if decode_wstr(w, utf8) == source { - o.cell_values += 1; - } - } - } - } - } - let count_refs = |cmds: &[wolf_formats::command::RawCommand], utf8: bool, o: &mut Occ| { - for cmd in cmds { - let idx: Vec = match cmd.cid { - 250 | 252 => vec![2], - 251 => vec![1], - 112 => (0..cmd.str_args.len()).collect(), - _ => vec![], - }; - for si in idx { - if let Some(w) = cmd.str_args.get(si) { - if decode_wstr(w, utf8) == source { - o.refs += 1; - } - } - } - } - }; - if let Some(ce) = ce { - for ev in &ce.events { - count_refs(&ev.commands, ce.utf8, &mut o); - } - } - for m in maps { - for ev in &m.events { - for pg in &ev.pages { - count_refs(&pg.commands, m.utf8, &mut o); - } - } - } - o -} - -#[test] -fn translate_one_name_updates_all_mirrors_consistently() { - let Some(basic) = corpus_basic() else { - eprintln!("no corpus, skipping"); - return; - }; - let dbs = load_dbs(&basic); - let ce = load_ce(&basic); - let maps = load_maps(); - - let names_json = extract(&dbs, ce.as_ref().map(|(_, c)| c), &maps); - let root: Value = serde_json::from_str(&names_json).unwrap(); - let names = root["names"].as_array().unwrap(); - - let db_snap: Vec = dbs.iter().map(|l| l.db.clone()).collect(); - let ce_snap = ce.as_ref().map(|(_, c)| c.clone()); - let map_snap: Vec = maps.iter().map(|(_, m)| m.clone()).collect(); - - // Pick a target name that lives in both a stored row name and a by-name command reference, - // the cross-mirror case the glossary exists to keep consistent. In the sample corpus these - // are the CDB variable rows looked up by `cid250` data-name. - let target = names - .iter() - .map(|e| e["source"].as_str().unwrap().to_string()) - .find(|s| { - let o = count_occ(s, &db_snap, ce_snap.as_ref(), &map_snap); - o.row_names >= 1 && o.refs >= 1 - }) - .expect("a name occurring as both a row name and a by-name reference"); - let before = count_occ(&target, &db_snap, ce_snap.as_ref(), &map_snap); - - // A translation representable in both UTF-8 and Shift-JIS (ASCII) so the encoding guard never - // skips it, and not already present in the corpus. - let translated = "WOLFDAWN_NAME_X"; - assert!( - count_occ(translated, &db_snap, ce_snap.as_ref(), &map_snap).row_names - + count_occ(translated, &db_snap, ce_snap.as_ref(), &map_snap).cell_values - == 0, - "sentinel must not pre-exist" - ); - - // Build a one-entry glossary translating only the target. - let one = serde_json::json!({ - "kind": "names", - "names": [ { "source": target, "text": translated, "occurrences": 0, "note": "" } ] - }) - .to_string(); - - // Inject onto fresh clones. - let mut db_vec: Vec = dbs.iter().map(|l| l.db.clone()).collect(); - let mut ce_clone = ce.as_ref().map(|(_, c)| c.clone()); - let mut ce_refs: Vec<&mut CommonEventsFile> = ce_clone.as_mut().into_iter().collect(); - let mut map_vec: Vec = maps.iter().map(|(_, m)| m.clone()).collect(); - let st = inject_names( - &one, - &mut db_vec, - &mut ce_refs, - &mut map_vec, - &InjectOptions::default(), - &glossary(), - ) - .expect("inject"); - - // Every mirror that held the source now holds the translation. The source is gone. - let after_src = count_occ(&target, &db_vec, ce_clone.as_ref(), &map_vec); - let after_dst = count_occ(translated, &db_vec, ce_clone.as_ref(), &map_vec); - - assert_eq!( - after_src.row_names, 0, - "the source row name must be rewritten" - ); - assert_eq!( - after_src.refs, 0, - "every by-name reference to the source must be rewritten" - ); - assert_eq!( - after_src.cell_values, 0, - "every cell holding the source must be rewritten" - ); - assert_eq!( - after_dst.row_names, before.row_names, - "row-name mirror count preserved under translation" - ); - assert_eq!( - after_dst.refs, before.refs, - "by-name reference count preserved under translation" - ); - assert_eq!( - after_dst.cell_values, before.cell_values, - "cell-value mirror count preserved under translation" - ); - // The applied count is at least the number of mirrors that changed (DB cells, row names, and - // refs). The reference mirror is the headline cross-file guarantee. - assert!(before.refs >= 1 && before.row_names >= 1); - assert!( - st.applied >= before.row_names + before.refs, - "applied ({}) must cover every rewritten mirror (row names {} + refs {})", - st.applied, - before.row_names, - before.refs - ); - - // Every file still re-parses (never ship a structurally-corrupt file). - for db in &db_vec { - let (p, d) = db.write(); - Database::read(&p, &d).expect("injected DB re-parses"); - } - if let Some(c) = &ce_clone { - CommonEventsFile::read(&c.write()).expect("injected CommonEvent re-parses"); - } - for m in &map_vec { - Map::read(&m.write()).expect("injected map re-parses"); - } - - // Untouched files: a file with no occurrence of the target must be byte-identical to the base. - for (l, db) in dbs.iter().zip(&db_vec) { - let touched = count_occ(&target, std::slice::from_ref(&l.db), None, &[]).row_names - + count_occ(&target, std::slice::from_ref(&l.db), None, &[]).cell_values - > 0; - if !touched { - let (p, d) = db.write(); - assert!( - p == l.proj && d == l.dat, - "{}: a DB with no target occurrence must be byte-identical", - l.stem - ); - } - } -} - -#[test] -fn extraction_is_deterministic() { - let Some(basic) = corpus_basic() else { - eprintln!("no corpus, skipping"); - return; - }; - let dbs = load_dbs(&basic); - let ce = load_ce(&basic); - let maps = load_maps(); - - let a = extract(&dbs, ce.as_ref().map(|(_, c)| c), &maps); - let b = extract(&dbs, ce.as_ref().map(|(_, c)| c), &maps); - assert_eq!(a, b, "extraction must be byte-identical across runs"); - assert!( - a.contains("\"kind\": \"names\""), - "names glossary has the expected shape" - ); - assert!(a.matches("\"source\"").count() > 0, "corpus yields names"); -} - -/// The disjoint-ownership invariant: every DB string cell is extracted by at most one path, -/// either db-strings (per-file content) or the glossary (per-cell name). On the real corpus, no -/// `(type, row, field)` locator may appear in both `extract_db_strings` and the glossary's -/// `extract_db_name_cells`. -#[test] -fn db_string_cells_have_disjoint_ownership() { - let Some(basic) = corpus_basic() else { - eprintln!("no corpus, skipping"); - return; - }; - let dbs = load_dbs(&basic); - assert!(!dbs.is_empty(), "corpus DBs must load"); - let g = glossary(); - let mut checked_cells = 0usize; - for l in &dbs { - // db-strings (content) cell locators. - let content: HashSet<(usize, usize, usize)> = extract_db_strings(&l.db, &g) - .iter() - .flat_map(|grp| grp.lines.iter().map(|ln| (ln.type_id, ln.row, ln.field))) - .collect(); - // glossary (name) cell locators. - let names: HashSet<(usize, usize, usize)> = - extract_db_name_cells(&l.db, &g).into_iter().collect(); - checked_cells += content.len() + names.len(); - let overlap: Vec<_> = content.intersection(&names).collect(); - assert!( - overlap.is_empty(), - "{}: {} DB cell(s) extracted by BOTH db-strings and the glossary: {:?}", - l.stem, - overlap.len(), - overlap.into_iter().take(8).collect::>() - ); - } - assert!( - checked_cells > 0, - "corpus must yield some DB cells to make the disjointness check meaningful" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/no_generic_argn.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/no_generic_argn.rs deleted file mode 100644 index e47c1ad..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/no_generic_argn.rs +++ /dev/null @@ -1,56 +0,0 @@ -use wolf_decompiler::{decompile_common_events, decompile_map, SymbolTable}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; -#[test] -fn no_generic_argn() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); - let mut stack = vec![root]; - let mut hits = 0; - let mut files = 0; - let re = |t: &str| -> usize { - t.match_indices("arg") - .filter(|(i, _)| { - let b = t.as_bytes(); - let j = i + 3; - j < b.len() - && b[j].is_ascii_digit() - && t[..*i] - .chars() - .last() - .map_or(true, |c| !c.is_alphanumeric()) - && t[*i..] - .split_once('=') - .map_or(false, |(k, _)| k[3..].chars().all(|c| c.is_ascii_digit())) - }) - .count() - }; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else if p.file_name().and_then(|s| s.to_str()) == Some("CommonEvent.dat") { - if let Ok(ce) = CommonEventsFile::read(&std::fs::read(&p).unwrap_or_default()) { - files += 1; - let t = decompile_common_events(&ce, &SymbolTable::new()); - let h = re(&t); - if h > 0 { - hits += h; - eprintln!("{} argN in {:?}", h, p.file_name().unwrap()); - } - } - } else if p.extension().and_then(|s| s.to_str()) == Some("mps") { - if let Ok(m) = Map::read(&std::fs::read(&p).unwrap_or_default()) { - files += 1; - let t = decompile_map(&m, &SymbolTable::new()); - hits += re(&t); - } - } - } - } - eprintln!("scanned {files} files, total generic argN occurrences: {hits}"); - assert_eq!(hits, 0, "generic argN still present"); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/raw_census.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/raw_census.rs deleted file mode 100644 index 6356115..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/raw_census.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Census of which commands still fall back to `@raw` (the lossy-pretty set to drive to zero), -//! across both games' common events and maps. -//! cargo test -p wolf-decompiler --test raw_census -- --nocapture - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use wolf_decompiler::{decompile_commands, spec}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") -} - -fn count(cmds: &[wolf_formats::command::RawCommand], hist: &mut BTreeMap) { - let text = decompile_commands(cmds); - // Re-walk: a line is @raw for the command at that position. Simplest: re-render each - // command alone is wrong (loses block ctx), so scan the produced lines and attribute - // each @raw line to its cid (the cid is the first token after `@raw `). - for line in text.lines() { - let t = line.trim_start(); - if let Some(rest) = t.strip_prefix("@raw ") { - if let Some(cid) = rest - .split_whitespace() - .next() - .and_then(|s| s.parse::().ok()) - { - *hist.entry(cid).or_default() += 1; - } - } - } -} - -#[test] -fn census() { - let mut hist: BTreeMap = BTreeMap::new(); - let mut total_cmds = 0usize; - - for game in ["Data", "GamePro_Data"] { - let ce_path = root().join(game).join("BasicData").join("CommonEvent.dat"); - if let Ok(b) = std::fs::read(&ce_path) { - let ce = CommonEventsFile::read(&b).unwrap(); - for ev in &ce.events { - total_cmds += ev.commands.len(); - count(&ev.commands, &mut hist); - } - } - let map_dir = root().join(game).join("MapData"); - if let Ok(entries) = std::fs::read_dir(&map_dir) { - for e in entries { - let p = e.unwrap().path(); - if p.extension().and_then(|s| s.to_str()) != Some("mps") { - continue; - } - let Ok(m) = Map::read(&std::fs::read(&p).unwrap()) else { - continue; - }; - for ev in &m.events { - for pg in &ev.pages { - total_cmds += pg.commands.len(); - count(&pg.commands, &mut hist); - } - } - } - } - } - - let total_raw: usize = hist.values().sum(); - eprintln!("total commands scanned: {total_cmds}"); - eprintln!("total @raw lines: {total_raw}"); - eprintln!("--- @raw by cid (cid name: count) ---"); - let mut by_count: Vec<_> = hist.into_iter().collect(); - by_count.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); - for (cid, n) in by_count { - eprintln!(" {cid:5} {:24} {n}", spec::command_name(cid)); - } - - // Every command must have a lossless pretty form. No `@raw` fallbacks in the corpus. - assert_eq!( - total_raw, 0, - "every command must render without an @raw fallback" - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/recompile.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/recompile.rs deleted file mode 100644 index 4782625..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/recompile.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Recompiler core gate: `compile(decompile(cmds)) == cmds`. -//! -//! The synthetic test proves the structural reconstruction (marker commands, suppressed `Blank` -//! slots, indent bytes, condition and operator decoding) round-trips exactly. The corpus test -//! measures how many real common events fully round-trip. - -use wolf_decompiler::{compile_commands, decompile_commands}; -use wolf_formats::command::RawCommand; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::WStr; - -fn leaf(cid: u32, ints: Vec, indent: u8, strs: &[&str]) -> RawCommand { - RawCommand { - cid, - int_args: ints, - indent, - str_args: strs - .iter() - .map(|s| WStr(format!("{s}\0").into_bytes())) - .collect(), - term: 0, - move_route: None, - v35_blob: None, - } -} - -fn bare(cid: u32, ints: Vec, indent: u8) -> RawCommand { - leaf(cid, ints, indent, &[]) -} - -#[test] -fn structural_round_trip_exact() { - // Mirrors Wolf's on-disk shape: an if (VariableCondition) with one branch plus else, - // each block body ending in a trailing Blank, plus a nested loop. - let cmds = vec![ - leaf(101, vec![], 0, &["hello"]), // Message - bare(111, vec![0x01, 1_600_000, 1, 2], 0), // if CSelf[0] == 1 (1 group, OR) - bare(401, vec![1], 0), // ChoiceCase (then) - bare(121, vec![1_100_000, 1, 0, 0], 1), // SetVariable Self[0] = 1 - bare(170, vec![], 1), // loop { - leaf(101, vec![], 2, &["in loop"]), // Message - bare(0, vec![], 2), // Blank (loop body trailing) - bare(498, vec![], 1), // LoopEnd - bare(0, vec![], 1), // Blank (then-body trailing) - bare(420, vec![0], 0), // ElseCase (arg0 = 0) - leaf(106, vec![], 1, &["dbg"]), // DebugMessage - bare(0, vec![], 1), // Blank (else-body trailing) - bare(499, vec![], 0), // BranchEnd - bare(0, vec![], 0), // Blank (top-level trailing) - ]; - - let text = decompile_commands(&cmds); - let back = compile_commands(&text).expect("compile failed"); - - if back != cmds { - eprintln!("--- decompiled ---\n{text}"); - for (i, (a, b)) in cmds.iter().zip(back.iter()).enumerate() { - if a != b { - eprintln!("differ at {i}:\n orig: {a:?}\n back: {b:?}"); - } - } - assert_eq!(back.len(), cmds.len(), "length differs"); - panic!("structural round-trip mismatch"); - } -} - -#[test] -fn editing_produces_edited_bytes() { - // Author a small program, decompile it, edit the readable text the way a modder would, then - // recompile. The round-trip gate only proves identity. This proves the parser builds the byte - // stream from the text, so changed values and inserted commands take effect, not from any - // memory of the original. - let cmds = vec![ - leaf(101, vec![], 0, &["start"]), // Message "start" - bare(121, vec![1_100_000, 1, 0, 0], 0), // SetVariable Self[0] = 1 - bare(170, vec![], 0), // loop { - leaf(101, vec![], 1, &["inside"]), // Message "inside" - bare(0, vec![], 1), // (trailing Blank) - bare(498, vec![], 0), // } LoopEnd - bare(0, vec![], 0), // (top-level trailing Blank) - ]; - - let text = decompile_commands(&cmds); - - // A modder edits the readable text: change a value, add a top-level command, and add a - // command inside a control-flow block. Source indentation is irrelevant since the parser - // reconstructs indent from nesting, so the inserted lines need no exact spacing. - let edited = text - .replace("SetVariable Self[0] = 1", "SetVariable Self[0] = 42") - .replace("loop {", "Message \"added\"\nloop {") - .replace( - "Message \"inside\"", - "Message \"inside\"\nDebugMessage \"dbg\"", - ); - - let back = compile_commands(&edited).expect("edited script compiles"); - - // 1) changed value landed - assert!( - back.iter() - .any(|c| c.cid == 121 && c.int_args == vec![1_100_000, 42, 0, 0]), - "value edit not reflected" - ); - // 2) brand-new top-level command exists at indent 0 - assert!( - back.iter().any(|c| c.cid == 101 - && c.indent == 0 - && c.str_args - .first() - .map_or(false, |s| s.as_bytes() == b"added\0")), - "inserted top-level command missing" - ); - // 3) new command inserted inside the loop body got indent 1 (reconstructed from nesting) - assert!( - back.iter().any(|c| c.cid == 106 && c.indent == 1), - "inserted in-block command missing or mis-indented" - ); - - // And the edited program is itself a stable, fully round-tripping file. Decompiling and - // recompiling it is a no-op. If editing produced something un-recompilable, this fails. - let again = compile_commands(&decompile_commands(&back)).expect("re-compile edited program"); - assert_eq!(again, back, "edited program is not a round-trip fixpoint"); -} - -#[test] -fn command_added_at_block_end_takes_the_blank_slot() { - // A "Blank" is the single trailing empty command Wolf keeps at the end of every block body, - // hidden in the readable view. The Blank's spot is the end of a block. Adding a command there - // places it before the auto-regenerated trailing Blank. The modder never sees or manages the - // Blank. The compiler always emits exactly one per block. - let src = "loop {\n Message \"a\"\n Message \"b\"\n}\n"; - let back = compile_commands(src).expect("compile"); - - let shape: Vec<(u32, u8)> = back.iter().map(|c| (c.cid, c.indent)).collect(); - assert_eq!( - shape, - vec![ - (170, 0), // loop { - (101, 1), // Message "a" - (101, 1), // Message "b" <- added at the end of the block (the Blank's spot) - (0, 1), // Blank <- exactly one, regenerated AFTER the new command - (498, 0), // } LoopEnd - (0, 0), // top-level trailing Blank - ], - "command added at block end should sit before a single regenerated trailing Blank" - ); -} - -#[test] -fn corpus_coverage_report() { - let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../Data/BasicData/CommonEvent.dat"); - let Ok(bytes) = std::fs::read(&path) else { - eprintln!("corpus not found, skipping"); - return; - }; - let ce = CommonEventsFile::read(&bytes).unwrap(); - - let mut ok = 0usize; - let total = ce.events.len(); - let mut fail_reason: std::collections::HashMap = Default::default(); - - for ev in &ce.events { - let text = decompile_commands(&ev.commands); - match compile_commands(&text) { - Ok(back) if back == ev.commands => ok += 1, - Ok(back) => { - let key = back - .iter() - .zip(&ev.commands) - .position(|(a, b)| a != b) - .map(|i| format!("mismatch@cid{}", ev.commands[i].cid)) - .unwrap_or_else(|| format!("len {}!={}", back.len(), ev.commands.len())); - *fail_reason.entry(key).or_default() += 1; - } - Err(e) => { - let key = format!( - "err: {}", - e.to_string().chars().take(38).collect::() - ); - *fail_reason.entry(key).or_default() += 1; - } - } - } - - eprintln!("common-event command round-trip: {ok}/{total} byte-exact"); - if ok != total { - let mut reasons: Vec<_> = fail_reason.into_iter().collect(); - reasons.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); - for (k, n) in reasons.iter().take(12) { - eprintln!(" {n:5} {k}"); - } - } - assert_eq!(ok, total, "not all common events recompile byte-exact"); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/save_pro_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/save_pro_roundtrip.rs deleted file mode 100644 index 3733e1a..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/save_pro_roundtrip.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! GamePro Pro (marker-3) save codec tests. The fixture-backed cases gate on the real GamePro-Pro -//! save and the ground-truth decrypted inner. They skip gracefully when those files are absent. The -//! LZ4 and pure-synthetic round-trips always run. - -use std::collections::HashMap; - -use wolf_core::codec::lz4; -use wolf_decompiler::save::{read_title_at, update_save}; -use wolf_decompiler::save_pro::{self, decrypt_pro, encrypt_pro, is_pro_save, key16}; - -mod common; -use common::test_data; - -const PACHIMON_SAVE: &str = "pachimon/SaveData01.sav"; -const GROUND_TRUTH: &str = "pachimon/decrypted.bin"; -const CHAMBER_SAVE: &str = "chamber/SaveData01.sav"; - -fn load(rel: &str) -> Option> { - let p = test_data(rel)?; - Some(std::fs::read(&p).expect("read fixture")) -} - -/// Read the 16-bit key the encoder stamps into a produced save: `header[7] | header[4]<<8`. -fn stored_key16(save: &[u8]) -> u16 { - save[7] as u16 | ((save[4] as u16) << 8) -} - -/// (1) The hard gate: `decrypt_pro(real save)` is byte-exact with the ground-truth `decrypted.bin`. -#[test] -fn decrypt_matches_ground_truth() { - let (Some(raw), Some(truth)) = (load(PACHIMON_SAVE), load(GROUND_TRUTH)) else { - eprintln!("skip: pachimon save and/or ground-truth fixture not present"); - return; - }; - let inner = decrypt_pro(&raw).expect("decrypt the real Pro save"); - assert_eq!(inner.len(), truth.len(), "inner length matches ground truth"); - assert_eq!(inner, truth, "decrypt_pro(real save) == decrypted.bin (byte-exact)"); - assert_eq!(inner[0], 0x19, "inner starts with the 0x19 title marker"); -} - -/// (2) Detection: the real Pro save is detected as Pro, and a standard (chamber) save is not. -#[test] -fn detection_pro_vs_standard() { - if let Some(raw) = load(PACHIMON_SAVE) { - assert!(is_pro_save(&raw), "real Pro save must be detected as Pro"); - } else { - eprintln!("skip: pachimon save fixture not present"); - } - if let Some(chamber) = load(CHAMBER_SAVE) { - assert!(!is_pro_save(&chamber), "standard chamber save must not be Pro"); - } else { - eprintln!("skip: chamber save fixture not present"); - } -} - -/// (3) Round-trip on the real inner: `decrypt_pro(encrypt_pro(inner, header)) == inner`. -/// (4) key16 of the produced save validates against `header[7] | header[4]<<8`. -#[test] -fn roundtrip_real_inner_and_key16() { - let Some(raw) = load(PACHIMON_SAVE) else { - eprintln!("skip: pachimon save fixture not present"); - return; - }; - let inner = decrypt_pro(&raw).expect("decrypt"); - let enc = encrypt_pro(&inner, &raw[..20]).expect("encrypt"); - let back = decrypt_pro(&enc).expect("re-decrypt"); - assert_eq!(back, inner, "encrypt->decrypt is identity on the real inner"); - - // key16 the encoder stored must equal the fold of the inner with a4 = header[9]. - assert_eq!(stored_key16(&enc), key16(&inner, raw[9]), "produced key16 validates"); -} - -/// (5) Modified-inner round-trip: flip a byte in the inner, encrypt, then decrypt, which equals -/// the modified inner, and the stored key16 re-validates for the modified payload. -#[test] -fn modified_inner_roundtrip() { - let Some(raw) = load(PACHIMON_SAVE) else { - eprintln!("skip: pachimon save fixture not present"); - return; - }; - let mut inner = decrypt_pro(&raw).expect("decrypt"); - // Flip a byte well past the header so the title marker stays intact. - let i = inner.len() / 2; - inner[i] ^= 0xFF; - - let enc = encrypt_pro(&inner, &raw[..20]).expect("encrypt modified"); - let back = decrypt_pro(&enc).expect("re-decrypt modified"); - assert_eq!(back, inner, "modified inner round-trips"); - assert_eq!(stored_key16(&enc), key16(&inner, raw[9]), "key16 valid for modified inner"); -} - -/// (6) LZ4: compress then decompress is identity on several buffers, including the ~1MB real inner. -#[test] -fn lz4_compress_decompress_identity() { - let mut cases: Vec> = vec![ - Vec::new(), - b"a".to_vec(), - b"hello world hello world".to_vec(), - vec![0u8; 5000], - (0..4096u32).map(|i| (i % 251) as u8).collect(), - ]; - if let Some(truth) = load(GROUND_TRUTH) { - cases.push(truth); // the ~1MB real inner - } else { - eprintln!("note: ground-truth inner absent; testing smaller buffers only"); - } - for data in &cases { - let block = lz4::compress_block(data); - let back = lz4::decompress_block(&block, data.len()).expect("lz4 decompress"); - assert_eq!(&back, data, "lz4 round-trip failed for {}-byte buffer", data.len()); - } -} - -/// (7) End-to-end via `update_save` on the real Pro save: setting `--title "X"` updates it rather -/// than skipping, and re-decrypting yields an inner whose title reads "X" with the 0x19 marker -/// intact. -#[test] -fn update_save_pro_end_to_end() { - let Some(raw) = load(PACHIMON_SAVE) else { - eprintln!("skip: pachimon save fixture not present"); - return; - }; - let (out, stats) = update_save(&raw, Some("X"), &HashMap::new()) - .expect("Pro save is now supported by update_save"); - assert!(stats.title_changed, "title was changed"); - assert_eq!(stats.encoding, "utf8"); - - let inner = decrypt_pro(&out).expect("re-decrypt the updated Pro save"); - assert_eq!(inner[0], save_pro::INNER_MARKER, "0x19 marker intact in the updated inner"); - assert_eq!(read_title_at(&inner, 0, true).as_deref(), Some("X"), "title reads back as X"); - // The stored key16 still validates after the edit. - assert_eq!(stored_key16(&out), key16(&inner, raw[9])); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/save_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/save_roundtrip.rs deleted file mode 100644 index 3109b9d..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/save_roundtrip.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! `.sav` auto-update tests. These exercise the real outer codec and the `update_save` pipeline -//! against actual game saves where present (skipping gracefully when the fixtures are absent), plus -//! a synthetic codec round-trip that always runs. - -use std::collections::HashMap; - -use wolf_core::codec::wolfsave; -use wolf_decompiler::save::{read_title, update_save}; - -mod common; -use common::test_data; - -const CHAMBER_SAVE: &str = "chamber/SaveData01.sav"; -const PACHIMON_SAVE: &str = "pachimon/SaveData01.sav"; - -const START_OFFSET: usize = 0x14; -const VALID_MARKER: u8 = 0x19; - -fn load(rel: &str) -> Option> { - let p = test_data(rel)?; - Some(std::fs::read(&p).expect("read save fixture")) -} - -/// (a) The codec round-trips a real save byte-for-byte. If the stored checksum already matches the -/// body (the normal case), `encrypt(decrypt(raw)) == raw` exactly. If it does not, we still require -/// the structural invariant (`[0x14] == 0x19`) and that a second decrypt of the re-encrypted bytes -/// reproduces the same plaintext (the checksum byte is the only possible difference). -#[test] -fn codec_roundtrips_real_chamber_save() { - let Some(raw) = load(CHAMBER_SAVE) else { - eprintln!("skip: chamber save fixture not present"); - return; - }; - let plain = wolfsave::decrypt(&raw); - assert!(plain.len() > START_OFFSET); - assert_eq!(plain[START_OFFSET], VALID_MARKER, "chamber save must be a valid 0x19 save"); - - let calc = plain[START_OFFSET..] - .iter() - .fold(0u8, |a, &b| a.wrapping_add(b)); - let reenc = wolfsave::encrypt(&plain); - if calc == plain[2] { - assert_eq!(reenc, raw, "stored checksum matches body -> encrypt(decrypt(raw)) is byte-exact"); - } else { - // Checksum byte was stale on disk. Re-encrypt normalises it. Plaintext must still match. - assert_eq!(wolfsave::decrypt(&reenc), plain, "re-decrypt reproduces the same plaintext"); - } -} - -/// (b) `update_save` with an empty map and no new title re-encrypts to a buffer that decrypts back -/// to the original plaintext. -#[test] -fn update_save_noop_preserves_plaintext() { - let Some(raw) = load(CHAMBER_SAVE) else { - eprintln!("skip: chamber save fixture not present"); - return; - }; - let plain = wolfsave::decrypt(&raw); - let (out, stats) = update_save(&raw, None, &HashMap::new()).expect("supported save"); - assert!(!stats.title_changed); - assert_eq!(stats.strings_replaced, 0); - assert_eq!(wolfsave::decrypt(&out), plain, "no-op update preserves the plaintext"); -} - -/// (c) Setting a new title, then re-decrypting, reads the new value back at 0x15, and the 0x14 -/// marker is still intact. -#[test] -fn update_save_sets_new_title() { - let Some(raw) = load(CHAMBER_SAVE) else { - eprintln!("skip: chamber save fixture not present"); - return; - }; - let new = "Translated Title (Test)"; - let (out, stats) = update_save(&raw, Some(new), &HashMap::new()).expect("supported save"); - assert!(stats.title_changed); - let plain = wolfsave::decrypt(&out); - assert_eq!(plain[START_OFFSET], VALID_MARKER, "marker survives the title length shift"); - assert_eq!(read_title(&plain).as_deref(), Some(new)); -} - -/// (d) Synthetic codec unit test (always runs, no fixtures). Build a buffer with a correct -/// checksum, encrypt then decrypt, and confirm identity plus that the body actually changed. -#[test] -fn codec_roundtrips_synthetic() { - let mut plain: Vec = (0u8..=255).cycle().take(START_OFFSET + 301).collect(); - plain[0] = 0x11; - plain[3] = 0x22; - plain[9] = 0x33; - wolfsave::fix_checksum(&mut plain); - let enc = wolfsave::encrypt(&plain); - assert_eq!(wolfsave::decrypt(&enc), plain); - assert_ne!(&enc[START_OFFSET..], &plain[START_OFFSET..], "body is actually enciphered"); - assert_eq!(&enc[..START_OFFSET], &plain[..START_OFFSET], "head carried verbatim"); -} - -/// The GamePro Pro (marker-3) save does not present as a standard save after the outer decrypt -/// (`[0x14] != 0x19`), but is handled by the Pro path. `update_save` accepts it and a re-decrypt -/// of the result reads the new title back. The Pro codec itself is exercised in -/// `save_pro_roundtrip.rs`. -#[test] -fn pachimon_save_handled_by_pro_path() { - let Some(raw) = load(PACHIMON_SAVE) else { - eprintln!("skip: pachimon save fixture not present"); - return; - }; - // It is not a standard save: the standard decrypt does not yield the 0x19 marker at 0x14. - let plain = wolfsave::decrypt(&raw); - assert_ne!(plain[START_OFFSET], VALID_MARKER, "Pro save is not a standard 0x19 save"); - - // But update_save now succeeds via the Pro path and writes the requested title. - let (out, stats) = update_save(&raw, Some("X"), &HashMap::new()) - .expect("pro save must now be supported"); - assert!(stats.title_changed); - assert_eq!(stats.encoding, "utf8"); - let inner = wolf_decompiler::save_pro::decrypt_pro(&out).expect("re-decrypt produced inner"); - assert_eq!(inner[0], VALID_MARKER, "inner 0x19 marker intact"); - assert_eq!( - wolf_decompiler::save::read_title_at(&inner, 0, true).as_deref(), - Some("X") - ); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/strings_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/strings_roundtrip.rs deleted file mode 100644 index 276122e..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/strings_roundtrip.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Player-text extract/inject round-trip. A no-op inject is byte-identical to the base, and -//! editing one line's translation changes only that string while others stay byte-exact. Covers -//! both the command/dialogue stream (maps) and database content (item, skill, term text). -use wolf_decompiler::{ - db_strings_to_json, extract_db_strings, extract_map, inject_db_strings, inject_map, - scenes_to_json, InjectOptions, -}; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -#[test] -fn db_strings_noop_byte_identical_then_translate() { - let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../Data/BasicData"); - let (Ok(p), Ok(d)) = ( - std::fs::read(base.join("DataBase.project")), - std::fs::read(base.join("DataBase.dat")), - ) else { - eprintln!("no corpus DataBase, skipping"); - return; - }; - let db = Database::read(&p, &d).expect("parse db"); - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let groups = extract_db_strings(&db, &glossary); - assert!(!groups.is_empty(), "expected some player-facing DB strings"); - let json = db_strings_to_json("DataBase.project", &groups); - - // No-op inject must be byte-identical. - let mut db2 = Database::read(&p, &d).unwrap(); - let st = inject_db_strings(&json, &mut db2, &InjectOptions::default()).expect("inject"); - assert_eq!(st.applied, 0); - assert_eq!(st.drifted, 0); - assert!( - db2.write() == (p.clone(), d.clone()), - "no-op DB-strings inject must be byte-identical" - ); - - // Translate one cell and confirm it persists. The sentinel reads as real text (a space, no - // bare-identifier shape) so the re-extraction's player-text filter keeps it. - let g = &groups[0]; - let ln = &g.lines[0]; - let edit = format!( - r#"{{ "groups": [ {{ "type": {}, "lines": [ {{ "row": {}, "field": {}, "source": {}, "text": "WOLFDAWN DB TR" }} ] }} ] }}"#, - g.type_id, - ln.row, - ln.field, - serde_json::Value::String(ln.source.clone()), - ); - let mut db3 = Database::read(&p, &d).unwrap(); - let st = inject_db_strings( - &edit, - &mut db3, - &InjectOptions { - allow_code_drift: true, - ..Default::default() - }, - ) - .expect("inject"); - assert_eq!(st.applied, 1, "one DB cell should translate"); - let (p3, d3) = db3.write(); - let db4 = Database::read(&p3, &d3).unwrap(); - let groups4 = extract_db_strings(&db4, &glossary); - assert!( - groups4 - .iter() - .any(|g| g.lines.iter().any(|l| l.source == "WOLFDAWN DB TR")), - "translated DB cell must read the new text" - ); -} - -/// Find a corpus map that actually has extractable player text. -fn find_map_with_text() -> Option<(std::path::PathBuf, Vec, Map)> { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); - let mut stack = vec![root]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else if p.extension().and_then(|s| s.to_str()) == Some("mps") { - let bytes = std::fs::read(&p).unwrap_or_default(); - if let Ok(m) = Map::read(&bytes) { - if extract_map(&m).iter().any(|s| !s.lines.is_empty()) { - return Some((p, bytes, m)); - } - } - } - } - } - None -} - -#[test] -fn noop_inject_is_byte_identical() { - let Some((_p, bytes, m)) = find_map_with_text() else { - eprintln!("no corpus map with player text, skipping"); - return; - }; - let json = scenes_to_json("m.mps", "map", &extract_map(&m)); - let mut m2 = Map::read(&bytes).unwrap(); - let st = inject_map(&json, &mut m2, &InjectOptions::default()).expect("inject"); - assert_eq!(st.applied, 0, "no-op inject must apply nothing"); - assert_eq!(st.drifted, 0, "no-op inject must not drift"); - assert!(m2.write() == bytes, "no-op inject must be byte-identical"); -} - -#[test] -fn translate_one_line_changes_only_that_string() { - let Some((_p, bytes, m)) = find_map_with_text() else { - return; - }; - let scenes = extract_map(&m); - // Locate the first scene with a line and record its locator and original. - let sc = scenes.iter().find(|s| !s.lines.is_empty()).unwrap(); - let ln = &sc.lines[0]; - let (event, page, cmd, str_idx, source) = ( - sc.event, - sc.page.unwrap(), - ln.cmd, - ln.str_idx, - ln.source.clone(), - ); - - // Build a minimal inject doc translating just that line. The sentinel reads as real text (a - // space, no bare-identifier shape) so the re-extraction's player-text filter keeps it. - let edit = format!( - r#"{{ "scenes": [ {{ "event": {event}, "page": {page}, "lines": [ {{ "cmd": {cmd}, "str": {str_idx}, "source": {src}, "text": "WOLFDAWN TR" }} ] }} ] }}"#, - src = serde_json::Value::String(source.clone()), - ); - - let mut m2 = Map::read(&bytes).unwrap(); - let st = inject_map( - &edit, - &mut m2, - &InjectOptions { - allow_code_drift: true, - ..Default::default() - }, - ) - .expect("inject"); - assert_eq!(st.applied, 1, "exactly one line should apply"); - - // Re-extract and confirm the translated line now reads the new text and others are intact. - let scenes2 = extract_map(&m2); - let sc2 = scenes2 - .iter() - .find(|s| s.event == event && s.page == Some(page)) - .unwrap(); - let ln2 = sc2 - .lines - .iter() - .find(|l| l.cmd == cmd && l.str_idx == str_idx) - .unwrap(); - assert_eq!( - ln2.source, "WOLFDAWN TR", - "translated line must read the new text" - ); -} - -/// Safety guard: a translation that drops an inline control code present in the source is -/// rejected. It is counted in `code_mismatch` and the line is left untouched, so a translator -/// cannot silently break a `\cself`/`\s`/`\f` reference. `--allow-code-drift` overrides. This is -/// the core "never break the game" guarantee. -#[test] -fn control_code_drop_is_blocked_by_default() { - let Some((_p, bytes, m)) = find_map_with_text() else { - return; - }; - // Find a line whose source carries an inline `\code` (so dropping it is detectable). - let scenes = extract_map(&m); - let Some((sc, ln)) = scenes - .iter() - .flat_map(|s| s.lines.iter().map(move |l| (s, l))) - .find(|(_, l)| { - l.source.contains("\\cself[") || l.source.contains("\\s[") || l.source.contains("\\c[") - }) - else { - eprintln!("no corpus line with an inline code, skipping"); - return; - }; - let edit = format!( - r#"{{ "scenes": [ {{ "event": {}, "page": {}, "lines": [ {{ "cmd": {}, "str": {}, "source": {}, "text": "code dropped entirely" }} ] }} ] }}"#, - sc.event, - sc.page.unwrap(), - ln.cmd, - ln.str_idx, - serde_json::Value::String(ln.source.clone()), - ); - - // Default: blocked, counted, base untouched (byte-identical). - let mut m2 = Map::read(&bytes).unwrap(); - let st = inject_map(&edit, &mut m2, &InjectOptions::default()).expect("inject"); - assert_eq!(st.applied, 0, "code-dropping translation must not apply"); - assert_eq!(st.code_mismatch, 1, "the dropped code must be counted"); - assert!( - m2.write() == bytes, - "blocked line must leave the file byte-identical" - ); - - // With --allow-code-drift: applied. - let mut m3 = Map::read(&bytes).unwrap(); - let st = inject_map( - &edit, - &mut m3, - &InjectOptions { - allow_code_drift: true, - ..Default::default() - }, - ) - .expect("inject"); - assert_eq!(st.applied, 1, "allow_code_drift must apply the edit"); - assert_eq!(st.code_mismatch, 0); -} diff --git a/util/wolfdawn-master/crates/wolf-decompiler/tests/txt_events_roundtrip.rs b/util/wolfdawn-master/crates/wolf-decompiler/tests/txt_events_roundtrip.rs deleted file mode 100644 index 468e333..0000000 --- a/util/wolfdawn-master/crates/wolf-decompiler/tests/txt_events_roundtrip.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! External event-text `.txt` round-trip. A no-op inject reproduces the base byte-for-byte, an -//! edit changes only its line while the surrounding `/` command lines stay intact, and extraction -//! drops every command/comment/blank line while keeping dialogue and speaker lines. -//! -//! The real-data cases use the `WOLFDAWN_TEST_DATA` fixture (`g210/Evtext/hev_a000h.txt`) and skip -//! gracefully when it is absent. The synthetic cases always run. - -use wolf_decompiler::{extract_txt_events, inject_txt_events}; - -mod common; -use common::test_data; - -/// Parse the `source` strings out of an extracted document, in order. -fn sources(json: &str) -> Vec { - let v: serde_json::Value = serde_json::from_str(json).unwrap(); - v["lines"] - .as_array() - .unwrap() - .iter() - .map(|l| l["source"].as_str().unwrap().to_owned()) - .collect() -} - -/// A synthetic Shift-JIS event file with CRLF endings, a `//` comment, a `/` command, a speaker -/// line, dialogue, blank-line spacing, and no trailing newline. Mirrors the real format. -fn synthetic_sjis() -> Vec { - let text = "//おねだり_コメント\r\n\ - /evcg,a0,b0,c0\r\n\ - /胸+1\r\n\ - アイリス:\r\n\ - こんにちは、世界\r\n\ - \r\n\ - /b\r\n\ - 最後の行に改行なし"; - encoding_rs::SHIFT_JIS.encode(text).0.into_owned() -} - -#[test] -fn synthetic_noop_is_byte_exact() { - let bytes = synthetic_sjis(); - let json = extract_txt_events(&bytes); - let out = inject_txt_events(&json, &bytes).expect("inject"); - assert_eq!(out, bytes, "no-op inject must reproduce the base byte-for-byte"); -} - -#[test] -fn synthetic_extraction_excludes_commands_and_blanks() { - let bytes = synthetic_sjis(); - let json = extract_txt_events(&bytes); - let got = sources(&json); - assert_eq!( - got, - vec![ - "アイリス:".to_string(), - "こんにちは、世界".to_string(), - "最後の行に改行なし".to_string(), - ], - "only the non-command, non-blank lines are extracted" - ); - // Confirm no command/comment line leaked into the document. - for forbidden in ["/evcg", "/胸+1", "/b", "//おねだり"] { - assert!( - !json.contains(forbidden), - "command/comment {forbidden} must not be extracted" - ); - } -} - -#[test] -fn synthetic_edit_round_trip_keeps_command_intact() { - let bytes = synthetic_sjis(); - let json = extract_txt_events(&bytes); - // Edit the dialogue line, leaving its neighbour command `/evcg` and `/b` untouched. - let edited = json.replacen( - r#""source": "こんにちは、世界", "text": "こんにちは、世界""#, - r#""source": "こんにちは、世界", "text": "やあ、世界""#, - 1, - ); - assert_ne!(edited, json, "edit must have matched a line"); - let out = inject_txt_events(&edited, &bytes).expect("inject"); - // Re-extract and confirm the new text landed. - let re = extract_txt_events(&out); - assert!( - sources(&re).contains(&"やあ、世界".to_string()), - "edited text must be present after re-extraction" - ); - // The command lines next to it must be byte-intact. - let (decoded, _, _) = encoding_rs::SHIFT_JIS.decode(&out); - assert!(decoded.contains("/evcg,a0,b0,c0"), "the /evcg command must survive"); - assert!(decoded.contains("/b"), "the /b page break must survive"); - assert!(!decoded.contains("こんにちは、世界"), "old text should be gone"); -} - -#[test] -fn synthetic_unrepresentable_and_newline_are_skipped() { - // A translation with a char SJIS cannot encode, and one with an embedded newline, are both - // skipped, leaving the file byte-exact. - let text = "せりふいち\r\nせりふに\r\n"; - let bytes = encoding_rs::SHIFT_JIS.encode(text).0.into_owned(); - let json = extract_txt_events(&bytes); - // Line 0 -> an emoji (not SJIS), line 1 -> a two-line value. - let edited = json - .replacen( - r#""source": "せりふいち", "text": "せりふいち""#, - r#""source": "せりふいち", "text": "🎮""#, - 1, - ) - .replacen( - r#""source": "せりふに", "text": "せりふに""#, - r#""source": "せりふに", "text": "a\nb""#, - 1, - ); - let out = inject_txt_events(&edited, &bytes).expect("inject"); - assert_eq!(out, bytes, "both guarded edits are skipped, base preserved"); -} - -#[test] -fn real_file_noop_is_byte_exact_and_keeps_dialogue() { - let Some(path) = test_data("g210/Evtext/hev_a000h.txt") else { - eprintln!("no g210/Evtext fixture, skipping real-file txt test"); - return; - }; - let bytes = std::fs::read(&path).expect("read fixture"); - - // No-op inject is byte-for-byte identical. - let json = extract_txt_events(&bytes); - let out = inject_txt_events(&json, &bytes).expect("inject"); - assert_eq!(out, bytes, "real-file no-op inject must be byte-exact"); - - // Extraction excludes every `/`-prefixed and blank line, and keeps the speaker line. - let srcs = sources(&json); - assert!(!srcs.is_empty(), "the real file has dialogue to extract"); - assert!( - srcs.iter().all(|s| !s.is_empty() && !s.starts_with('/')), - "no command/comment/blank line should be extracted" - ); - assert!( - srcs.iter().any(|s| s.starts_with("アイリス")), - "the アイリス: speaker line should be extracted" - ); - - // Edit one dialogue line and confirm it lands while a command line stays intact. - let target = srcs.iter().find(|s| !s.starts_with("アイリス")).unwrap().clone(); - let needle = format!("\"source\": {0}, \"text\": {0}", serde_json::Value::String(target.clone())); - let replacement = format!( - "\"source\": {}, \"text\": {}", - serde_json::Value::String(target.clone()), - serde_json::Value::String("WOLFDAWN_TXT_TR".to_string()) - ); - let edited = json.replacen(&needle, &replacement, 1); - assert_ne!(edited, json, "edit must have matched a line"); - let out2 = inject_txt_events(&edited, &bytes).expect("inject edit"); - let re = extract_txt_events(&out2); - assert!( - sources(&re).contains(&"WOLFDAWN_TXT_TR".to_string()), - "edited text must be present after re-extraction" - ); - let (decoded, _, _) = encoding_rs::SHIFT_JIS.decode(&out2); - assert!(decoded.contains("/evcg"), "an /evcg command line must remain intact"); -} diff --git a/util/wolfdawn-master/crates/wolf-formats/Cargo.toml b/util/wolfdawn-master/crates/wolf-formats/Cargo.toml deleted file mode 100644 index 4ddf116..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "wolf-formats" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Byte-faithful typed read/write of Wolf RPG data files (maps, databases, common events). Round-trip contract lives here." - -[dependencies] -wolf-core = { path = "../wolf-core" } - -[dev-dependencies] -# For the end-to-end gate: unpack Data.wolf and parse/round-trip its inner files. -wolf-archive = { path = "../wolf-archive" } diff --git a/util/wolfdawn-master/crates/wolf-formats/src/command.rs b/util/wolfdawn-master/crates/wolf-formats/src/command.rs deleted file mode 100644 index 0375eb8..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/src/command.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Event command and move-route encoding, the core unit a decompiler operates on. -//! -//! Read order: `argCount = byte-1`, `cid` u32, `argCount` ints, `indent` byte, -//! `strCount` byte, `strCount` strings, `terminator` byte. Terminator `0x01` (or -//! cid==Move) is followed by a move-route block. Wolf Pro v3.5 appends a per-command -//! length-prefixed blob. - -use crate::Ctx; -use wolf_core::{Error, Reader, Result, WStr, Writer}; - -/// On-disk command id for `Move` (201). The only cid whose `0x00`-terminated form -/// still carries a route block. -pub const CID_MOVE: u32 = 201; - -/// A single event command, mirroring the on-disk bytes exactly. This is the round-trip -/// authority that the higher decompiler IR is derived from and lowered back to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RawCommand { - pub cid: u32, - pub int_args: Vec, - pub indent: u8, - pub str_args: Vec, - /// Terminator byte as read (`0x00` normal, `0x01` move). Stored for faithfulness. - pub term: u8, - pub move_route: Option, - /// Wolf Pro v3.5 trailing blob (length-prefixed by a single byte), if present. - pub v35_blob: Option>, -} - -impl RawCommand { - pub fn read(r: &mut Reader, ctx: &Ctx) -> Result { - let arg_count_byte = r.read_u8()?; - let n = arg_count_byte - .checked_sub(1) - .ok_or_else(|| Error::invalid("command int-arg count byte was 0 (expected >= 1)"))? - as usize; - - let cid = r.read_u32()?; - let mut int_args = Vec::with_capacity(n); - for _ in 0..n { - int_args.push(r.read_u32()?); - } - - let indent = r.read_u8()?; - let str_count = r.read_u8()? as usize; - let mut str_args = Vec::with_capacity(str_count); - for _ in 0..str_count { - str_args.push(r.read_string()?); - } - - let term = r.read_u8()?; - let move_route = match term { - 0x01 => Some(MoveRoute::read(r)?), - 0x00 => { - if cid == CID_MOVE { - Some(MoveRoute::read(r)?) - } else { - None - } - } - other => { - return Err(Error::invalid(format!( - "unexpected command terminator 0x{other:02x} (cid {cid})" - ))) - } - }; - - let v35_blob = if ctx.v35 { - let size = r.read_u8()? as usize; - Some(r.read_bytes(size)?) - } else { - None - }; - - Ok(RawCommand { - cid, - int_args, - indent, - str_args, - term, - move_route, - v35_blob, - }) - } - - pub fn write(&self, w: &mut Writer, ctx: &Ctx) { - w.write_u8((self.int_args.len() + 1) as u8); - w.write_u32(self.cid); - for &a in &self.int_args { - w.write_u32(a); - } - w.write_u8(self.indent); - w.write_u8(self.str_args.len() as u8); - for s in &self.str_args { - w.write_string(s); - } - w.write_u8(self.term); - if let Some(mr) = &self.move_route { - mr.write(w); - } - if ctx.v35 { - let blob = self.v35_blob.as_deref().unwrap_or(&[]); - w.write_u8(blob.len() as u8); - w.write_bytes(blob); - } - } -} - -/// The move-route block attached to a `Move` command: 5 opaque bytes, a flags byte, -/// and a length-prefixed list of [`RouteCommand`]s. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MoveRoute { - pub unknown: [u8; 5], - pub flags: u8, - pub commands: Vec, -} - -impl MoveRoute { - pub fn read(r: &mut Reader) -> Result { - let mut unknown = [0u8; 5]; - for b in &mut unknown { - *b = r.read_u8()?; - } - let flags = r.read_u8()?; - let count = r.read_u32()? as usize; - let mut commands = Vec::with_capacity(count); - for _ in 0..count { - commands.push(RouteCommand::read(r)?); - } - Ok(MoveRoute { - unknown, - flags, - commands, - }) - } - - pub fn write(&self, w: &mut Writer) { - w.write_bytes(&self.unknown); - w.write_u8(self.flags); - w.write_u32(self.commands.len() as u32); - for c in &self.commands { - c.write(w); - } - } -} - -/// A single move-route sub-command: `id` byte, `argCount` byte, ints, then a fixed -/// `{01,00}` terminator. Used both inside [`MoveRoute`] and directly in a page's route list. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RouteCommand { - pub id: u8, - pub args: Vec, - pub terminator: [u8; 2], -} - -impl RouteCommand { - pub fn read(r: &mut Reader) -> Result { - let id = r.read_u8()?; - let argc = r.read_u8()? as usize; - let mut args = Vec::with_capacity(argc); - for _ in 0..argc { - args.push(r.read_u32()?); - } - let terminator = [r.read_u8()?, r.read_u8()?]; - if terminator != [0x01, 0x00] { - return Err(Error::invalid(format!( - "route command terminator was {:02x?}, expected [01, 00]", - terminator - ))); - } - Ok(RouteCommand { - id, - args, - terminator, - }) - } - - pub fn write(&self, w: &mut Writer) { - w.write_u8(self.id); - w.write_u8(self.args.len() as u8); - for &a in &self.args { - w.write_u32(a); - } - w.write_bytes(&self.terminator); - } -} - -/// Read a length-prefixed (`u32` count) list of [`RouteCommand`]s. This is the form used -/// directly in a map page's route field (no 5-byte/flags header). -pub fn read_route_list(r: &mut Reader) -> Result> { - let count = r.read_u32()? as usize; - let mut out = Vec::with_capacity(count); - for _ in 0..count { - out.push(RouteCommand::read(r)?); - } - Ok(out) -} - -pub fn write_route_list(w: &mut Writer, list: &[RouteCommand]) { - w.write_u32(list.len() as u32); - for c in list { - c.write(w); - } -} diff --git a/util/wolfdawn-master/crates/wolf-formats/src/common_event.rs b/util/wolfdawn-master/crates/wolf-formats/src/common_event.rs deleted file mode 100644 index 59bffb6..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/src/common_event.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! CommonEvent file (`CommonEvent.dat`): a flat list of shared event programs. -//! Each common event holds a command list (same encoding as map pages) plus a large -//! tail of editor metadata kept opaque for now. -//! -//! Layout on disk: -//! `[crypt-indicator u8=0][9-byte magic][version u8][eventCount u32][events][terminator u8]`. -//! The leading indicator and the v3.5 LZ4 body are crypto-layer concerns. This reader -//! handles the plaintext case (indicator 0, version != 0x93/0xCC) and errors otherwise. - -use crate::command::RawCommand; -use crate::Ctx; -use wolf_core::codec::lz4; -use wolf_core::{Error, Reader, Result, WStr, Writer}; - -/// `{0x57,00,00,0x4F,0x4C,(00|'U'),0x46,0x43,00}`: "WOLFC" with a UTF-8 toggle at idx 5. -const MAGIC_LEN: usize = 9; -const UTF8_MARKER_INDEX: usize = 5; -const EVENT_INDICATOR: u8 = 0x8E; -const DATA_INDICATOR_8F: u8 = 0x8F; -const DATA_INDICATOR_91: u8 = 0x91; -const DATA_INDICATOR_92: u8 = 0x92; -const UNKNOWN8_COUNT: usize = 100; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommonEventsFile { - /// Leading crypt-indicator byte (0 = plaintext). - pub indicator: u8, - pub magic: [u8; MAGIC_LEN], - pub version: u8, - pub events: Vec, - pub terminator: u8, - pub utf8: bool, - /// True when version is `0x93`/`0xCC` (Wolf Pro v3.5). The body after the version byte - /// is LZ4-compressed and every command carries a trailing blob. - pub v35: bool, - /// For v3.5 files: the original framed LZ4 body `[decSize][encSize][block]`, kept so an - /// unmodified file re-emits byte-exact (LZ4 recompression isn't byte-stable). - pub packed: Option>, -} - -impl CommonEventsFile { - pub fn read(bytes: &[u8]) -> Result { - let mut r = Reader::new(bytes); - - let indicator = r.read_u8()?; - if indicator != 0 { - return Err(Error::invalid( - "CommonEvent.dat is encrypted (indicator != 0); decrypt with wolf-archive first", - )); - } - - let magic_vec = r.read_bytes(MAGIC_LEN)?; - if magic_vec[0] != 0x57 || magic_vec[6] != 0x46 || magic_vec[7] != 0x43 { - return Err(Error::invalid("not a CommonEvent.dat (magic mismatch)")); - } - let mut magic = [0u8; MAGIC_LEN]; - magic.copy_from_slice(&magic_vec); - let utf8 = magic[UTF8_MARKER_INDEX] == b'U'; - - let version = r.read_u8()?; - let v35 = version == 0x93 || version == 0xCC; - let ctx = Ctx { utf8, v35 }; - - // For v3.5 the body after the version byte is an LZ4 block. Decompress it and parse - // the events from the decompressed bytes. Plain files just continue inline. - let rem = r.remaining(); - let (packed, body): (Option>, Vec) = if v35 { - let framed = r.read_bytes(rem)?; - let body = lz4::unpack(&framed)?; - (Some(framed), body) - } else { - (None, r.read_bytes(rem)?) - }; - let mut r = Reader::new(&body); - - let event_count = r.read_u32()? as usize; - let mut events = Vec::with_capacity(event_count); - for _ in 0..event_count { - events.push(CommonEvent::read(&mut r, &ctx)?); - } - - let terminator = r.read_u8()?; - if terminator < 0x89 { - return Err(Error::invalid(format!( - "CommonEvent terminator 0x{terminator:02x} < 0x89" - ))); - } - if !r.is_eof() { - return Err(Error::invalid("CommonEvent.dat has trailing data")); - } - - Ok(CommonEventsFile { - indicator, - magic, - version, - events, - terminator, - utf8, - v35, - packed, - }) - } - - pub fn write(&self) -> Vec { - let ctx = Ctx { - utf8: self.utf8, - v35: self.v35, - }; - let mut w = Writer::new(); - w.write_u8(self.indicator); - w.write_bytes(&self.magic); - w.write_u8(self.version); - - // The compressible body: event count + events + terminator. - let mut body = Writer::new(); - body.write_u32(self.events.len() as u32); - for ev in &self.events { - ev.write(&mut body, &ctx); - } - body.write_u8(self.terminator); - let body = body.into_bytes(); - - if self.v35 { - // Re-emit the original packed bytes verbatim when the body is unchanged - // (byte-exact). Otherwise compress fresh (valid LZ4, content-exact). - match &self.packed { - Some(orig) if lz4::unpack(orig).map(|b| b == body).unwrap_or(false) => { - w.write_bytes(orig); - } - _ => w.write_bytes(&lz4::pack(&body)), - } - } else { - w.write_bytes(&body); - } - w.into_bytes() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommonEvent { - pub int_id: u32, - pub unknown1: u32, - pub unknown2: [u8; 7], - pub name: WStr, - pub commands: Vec, - pub unknown11: WStr, - pub description: WStr, - pub unknown3: Vec, - pub unknown4: Vec, - pub unknown5: Vec>, - pub unknown6: Vec>, - pub unknown7: [u8; 0x1D], - /// Exactly 100 strings (slot/argument names), no count prefix on disk. - pub unknown8: Vec, - pub unknown9: WStr, - /// Optional `0x92` tail: `(unknown10, unknown12)`. - pub unknown10: Option<(WStr, u32)>, -} - -impl CommonEvent { - fn read(r: &mut Reader, ctx: &Ctx) -> Result { - let ind = r.read_u8()?; - if ind != EVENT_INDICATOR { - return Err(Error::invalid(format!( - "CommonEvent header indicator 0x{ind:02x} != 0x8E" - ))); - } - - let int_id = r.read_u32()?; - let unknown1 = r.read_u32()?; - let unknown2 = read_fixed::<7>(r)?; - let name = r.read_string()?; - - let command_count = r.read_u32()? as usize; - let mut commands = Vec::with_capacity(command_count); - for _ in 0..command_count { - commands.push(RawCommand::read(r, ctx)?); - } - - let unknown11 = r.read_string()?; - let description = r.read_string()?; - - expect(r, DATA_INDICATOR_8F, "0x8F")?; - - let unknown3 = r.read_string_array()?; - - let unknown4 = r.read_byte_array()?; - - let n5 = r.read_u32()? as usize; - let mut unknown5 = Vec::with_capacity(n5); - for _ in 0..n5 { - unknown5.push(r.read_string_array()?); - } - - let n6 = r.read_u32()? as usize; - let mut unknown6 = Vec::with_capacity(n6); - for _ in 0..n6 { - unknown6.push(r.read_int_array()?); - } - - let unknown7 = read_fixed::<0x1D>(r)?; - - let mut unknown8 = Vec::with_capacity(UNKNOWN8_COUNT); - for _ in 0..UNKNOWN8_COUNT { - unknown8.push(r.read_string()?); - } - - expect(r, DATA_INDICATOR_91, "0x91")?; - let unknown9 = r.read_string()?; - - let next = r.read_u8()?; - let unknown10 = match next { - DATA_INDICATOR_91 => None, - DATA_INDICATOR_92 => { - let s = r.read_string()?; - let n = r.read_u32()?; - expect(r, DATA_INDICATOR_92, "0x92")?; - Some((s, n)) - } - other => { - return Err(Error::invalid(format!( - "CommonEvent tail indicator 0x{other:02x} not 0x91/0x92" - ))) - } - }; - - Ok(CommonEvent { - int_id, - unknown1, - unknown2, - name, - commands, - unknown11, - description, - unknown3, - unknown4, - unknown5, - unknown6, - unknown7, - unknown8, - unknown9, - unknown10, - }) - } - - fn write(&self, w: &mut Writer, ctx: &Ctx) { - w.write_u8(EVENT_INDICATOR); - w.write_u32(self.int_id); - w.write_u32(self.unknown1); - w.write_bytes(&self.unknown2); - w.write_string(&self.name); - w.write_u32(self.commands.len() as u32); - for c in &self.commands { - c.write(w, ctx); - } - w.write_string(&self.unknown11); - w.write_string(&self.description); - w.write_u8(DATA_INDICATOR_8F); - - w.write_string_array(&self.unknown3); - w.write_byte_array(&self.unknown4); - - w.write_u32(self.unknown5.len() as u32); - for strs in &self.unknown5 { - w.write_string_array(strs); - } - - w.write_u32(self.unknown6.len() as u32); - for ints in &self.unknown6 { - w.write_int_array(ints); - } - - w.write_bytes(&self.unknown7); - for s in &self.unknown8 { - w.write_string(s); - } - - w.write_u8(DATA_INDICATOR_91); - w.write_string(&self.unknown9); - - match &self.unknown10 { - Some((s, n)) => { - w.write_u8(DATA_INDICATOR_92); - w.write_string(s); - w.write_u32(*n); - w.write_u8(DATA_INDICATOR_92); - } - None => w.write_u8(DATA_INDICATOR_91), - } - } -} - -fn read_fixed(r: &mut Reader) -> Result<[u8; N]> { - let v = r.read_bytes(N)?; - let mut out = [0u8; N]; - out.copy_from_slice(&v); - Ok(out) -} - -fn expect(r: &mut Reader, byte: u8, label: &str) -> Result<()> { - let got = r.read_u8()?; - if got != byte { - return Err(Error::invalid(format!( - "CommonEvent expected indicator {label}, got 0x{got:02x}" - ))); - } - Ok(()) -} diff --git a/util/wolfdawn-master/crates/wolf-formats/src/database.rs b/util/wolfdawn-master/crates/wolf-formats/src/database.rs deleted file mode 100644 index a3e889f..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/src/database.rs +++ /dev/null @@ -1,447 +0,0 @@ -//! Database: the paired `.project` (schema: type/field/data names + field UI types) -//! and `.dat` (field index-info + actual int/string cell values) format. Used for the -//! User Database, System Database (`SysDatabase`) and Changeable Database (`CDatabase`). -//! -//! Handles the plaintext case (dat indicator 0, version != 0xC4, unencrypted project). -//! Pro/LZ4 variants error pending wolf-archive. - -use crate::Ctx; -use wolf_core::codec::lz4; -use wolf_core::{Error, Reader, Result, WStr, Writer}; - -const MAGIC_LEN: usize = 9; -const UTF8_MARKER_INDEX: usize = 5; -const TYPE_SEPARATOR: [u8; 4] = [0xFE, 0xFF, 0xFF, 0xFF]; -const STRING_INDICATOR: u32 = 0x0001_D4C0; -const STRING_START: u32 = 0x07D0; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Database { - pub dat_indicator: u8, - pub dat_magic: [u8; MAGIC_LEN], - pub dat_version: u8, - pub utf8: bool, - pub types: Vec, - /// True when `dat_version == 0xC4` (Wolf Pro v3.5). The dat type/data section is - /// LZ4-compressed. The `.project` schema is never compressed. - pub v35: bool, - /// For v3.5: the original framed LZ4 dat body `[decSize][encSize][block]`, kept so an - /// unmodified database re-emits byte-exact. - pub dat_packed: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DbType { - // Project side. - pub name: WStr, - pub fields: Vec, - pub data: Vec, - pub description: WStr, - pub field_type_list_size: u32, - /// Padding bytes after the per-field type bytes (`field_type_list_size - fields.len()`). - pub field_type_pad: Vec, - // Dat side. - pub dat_unknown1: u32, - pub dat_fields_size: u32, - /// Present only when `dat_unknown1 == STRING_INDICATOR`. - pub dat_unknown2: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DbField { - // project side - pub name: WStr, - pub type_byte: u8, - pub unknown1: WStr, - pub string_args: Vec, - pub args: Vec, - pub default_value: u32, - // dat side - pub index_info: u32, -} - -impl DbField { - /// True if this field stores a string value (its `index_info` is in the string range). - /// Determines whether a data row reads from `string_values` or `int_values`. - pub fn is_string(&self) -> bool { - self.index_info >= STRING_START - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DbData { - pub name: WStr, - pub int_values: Vec, - pub string_values: Vec, -} - -impl Database { - pub fn read(project_bytes: &[u8], dat_bytes: &[u8]) -> Result { - // --- dat header --- - let mut rd = Reader::new(dat_bytes); - let dat_indicator = rd.read_u8()?; - if dat_indicator != 0 { - return Err(Error::invalid( - "database .dat is encrypted (indicator != 0); decrypt with wolf-archive first", - )); - } - let magic_vec = rd.read_bytes(MAGIC_LEN)?; - if magic_vec[0] != 0x57 || magic_vec[6] != 0x46 || magic_vec[7] != 0x4D { - return Err(Error::invalid("not a database .dat (magic mismatch)")); - } - let mut dat_magic = [0u8; MAGIC_LEN]; - dat_magic.copy_from_slice(&magic_vec); - let utf8 = dat_magic[UTF8_MARKER_INDEX] == b'U'; - let dat_version = rd.read_u8()?; - let v35 = dat_version == 0xC4; - let _ = Ctx { utf8, v35 }; - - // For v3.5 the dat type/data section after the version byte is an LZ4 block. - // Decompress it and read the values from the result. The `.project` is plain. - let rem = rd.remaining(); - let (dat_packed, dat_body): (Option>, Vec) = if v35 { - let framed = rd.read_bytes(rem)?; - let body = lz4::unpack(&framed)?; - (Some(framed), body) - } else { - (None, rd.read_bytes(rem)?) - }; - let mut rd = Reader::new(&dat_body); - - // --- project (schema) --- - let mut rp = Reader::new(project_bytes); - let type_cnt = rp.read_u32()? as usize; - let mut types = Vec::with_capacity(type_cnt); - for _ in 0..type_cnt { - types.push(DbType::read_project(&mut rp)?); - } - if !rp.is_eof() { - return Err(Error::invalid("database .project has trailing data")); - } - - // --- dat body (values) --- - let dat_type_cnt = rd.read_u32()? as usize; - if dat_type_cnt != types.len() { - return Err(Error::invalid(format!( - "database type count mismatch: project {} vs dat {}", - types.len(), - dat_type_cnt - ))); - } - for t in &mut types { - t.read_dat(&mut rd)?; - } - let terminator = rd.read_u8()?; - if terminator != dat_version { - return Err(Error::invalid(format!( - "database .dat terminator 0x{terminator:02x} != version 0x{dat_version:02x}" - ))); - } - if !rd.is_eof() { - return Err(Error::invalid("database .dat has trailing data")); - } - - Ok(Database { - dat_indicator, - dat_magic, - dat_version, - utf8, - types, - v35, - dat_packed, - }) - } - - /// Returns `(project_bytes, dat_bytes)`. - pub fn write(&self) -> (Vec, Vec) { - let mut wp = Writer::new(); - wp.write_u32(self.types.len() as u32); - for t in &self.types { - t.write_project(&mut wp); - } - - let mut wd = Writer::new(); - wd.write_u8(self.dat_indicator); - wd.write_bytes(&self.dat_magic); - wd.write_u8(self.dat_version); - - // The compressible dat body: type count + per-type values + terminator. - let mut body = Writer::new(); - body.write_u32(self.types.len() as u32); - for t in &self.types { - t.write_dat(&mut body); - } - body.write_u8(self.dat_version); - let body = body.into_bytes(); - - if self.v35 { - // Re-emit original packed bytes verbatim when unchanged (byte-exact). Else - // compress fresh (valid LZ4, content-exact). - match &self.dat_packed { - Some(orig) if lz4::unpack(orig).map(|b| b == body).unwrap_or(false) => { - wd.write_bytes(orig); - } - _ => wd.write_bytes(&lz4::pack(&body)), - } - } else { - wd.write_bytes(&body); - } - - (wp.into_bytes(), wd.into_bytes()) - } -} - -/// True if a database `.dat` carries the `W..OL.FM` container magic (`indicator 0` + magic). -/// The legacy `SysDataBaseBasic.dat` (Wolf 2.x) does not. See [`BasicDatabase`]. -pub fn dat_has_db_magic(dat_bytes: &[u8]) -> bool { - dat_bytes.len() >= 9 - && dat_bytes[0] == 0 - && dat_bytes[1] == 0x57 - && dat_bytes[7] == 0x46 - && dat_bytes[8] == 0x4D -} - -/// The legacy "basic system database" (`SysDataBaseBasic`, Wolf 2.x). Both halves predate the -/// modern serialization. The `.dat` has no `W..OL.FM` container magic, and the `.project` uses -/// an older field-metadata layout. It is an editor template, never referenced by command -/// operands (the real `SysDatabase` is the system DB), so reversing its older schema buys no -/// modding value. Both halves are carried verbatim. That keeps the pair byte-exact through a -/// repack and never errors, consistent with the toolchain's "unknown bytes preserved" contract. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BasicDatabase { - pub project_raw: Vec, - pub dat_raw: Vec, -} - -impl BasicDatabase { - pub fn read(project_bytes: &[u8], dat_bytes: &[u8]) -> Result { - // A light sanity check so this isn't an accept-anything passthrough. The `.dat` must - // lack the container magic (else it is a normal `Database`), and the `.project` must - // start with a plausible type count. - if dat_has_db_magic(dat_bytes) { - return Err(Error::invalid( - "SysDataBaseBasic .dat has DB magic; read it as a normal Database", - )); - } - let type_cnt = Reader::new(project_bytes).read_u32()?; - if type_cnt > 10_000 { - return Err(Error::invalid( - "basic database .project type count implausible", - )); - } - Ok(BasicDatabase { - project_raw: project_bytes.to_vec(), - dat_raw: dat_bytes.to_vec(), - }) - } - - /// Returns `(project_bytes, dat_bytes)`, both carried verbatim. - pub fn write(&self) -> (Vec, Vec) { - (self.project_raw.clone(), self.dat_raw.clone()) - } -} - -impl DbType { - fn read_project(r: &mut Reader) -> Result { - let name = r.read_string()?; - - let field_cnt = r.read_u32()? as usize; - let mut fields = Vec::with_capacity(field_cnt); - for _ in 0..field_cnt { - fields.push(DbField { - name: r.read_string()?, - type_byte: 0, - unknown1: WStr::default(), - string_args: Vec::new(), - args: Vec::new(), - default_value: 0, - index_info: 0, - }); - } - - let data_cnt = r.read_u32()? as usize; - let mut data = Vec::with_capacity(data_cnt); - for _ in 0..data_cnt { - data.push(DbData { - name: r.read_string()?, - int_values: Vec::new(), - string_values: Vec::new(), - }); - } - - let description = r.read_string()?; - - let field_type_list_size = r.read_u32()?; - for f in &mut fields { - f.type_byte = r.read_u8()?; - } - let pad_len = (field_type_list_size as usize) - .checked_sub(fields.len()) - .ok_or_else(|| Error::invalid("field_type_list_size smaller than field count"))?; - let field_type_pad = r.read_bytes(pad_len)?; - - // Per-field metadata blocks. Each is preceded by a count that (for well-formed - // databases) equals the field count. - read_indexed(r, fields.len(), "unknown1", |r, i| { - fields[i].unknown1 = r.read_string()?; - Ok(()) - })?; - read_indexed(r, fields.len(), "stringArgs", |r, i| { - fields[i].string_args = r.read_string_array()?; - Ok(()) - })?; - read_indexed(r, fields.len(), "args", |r, i| { - fields[i].args = r.read_int_array()?; - Ok(()) - })?; - read_indexed(r, fields.len(), "defaultValue", |r, i| { - fields[i].default_value = r.read_u32()?; - Ok(()) - })?; - - Ok(DbType { - name, - fields, - data, - description, - field_type_list_size, - field_type_pad, - dat_unknown1: 0, - dat_fields_size: 0, - dat_unknown2: None, - }) - } - - fn write_project(&self, w: &mut Writer) { - w.write_string(&self.name); - w.write_u32(self.fields.len() as u32); - for f in &self.fields { - w.write_string(&f.name); - } - w.write_u32(self.data.len() as u32); - for d in &self.data { - w.write_string(&d.name); - } - w.write_string(&self.description); - - w.write_u32(self.field_type_list_size); - for f in &self.fields { - w.write_u8(f.type_byte); - } - w.write_bytes(&self.field_type_pad); - - w.write_u32(self.fields.len() as u32); - for f in &self.fields { - w.write_string(&f.unknown1); - } - w.write_u32(self.fields.len() as u32); - for f in &self.fields { - w.write_string_array(&f.string_args); - } - w.write_u32(self.fields.len() as u32); - for f in &self.fields { - w.write_int_array(&f.args); - } - w.write_u32(self.fields.len() as u32); - for f in &self.fields { - w.write_u32(f.default_value); - } - } - - fn read_dat(&mut self, r: &mut Reader) -> Result<()> { - r.verify(&TYPE_SEPARATOR, "database type separator")?; - self.dat_unknown1 = r.read_u32()?; - self.dat_fields_size = r.read_u32()?; - if self.dat_unknown1 == STRING_INDICATOR { - self.dat_unknown2 = Some(r.read_string()?); - } - - let fields_size = self.dat_fields_size as usize; - if fields_size > self.fields.len() { - return Err(Error::invalid(format!( - "dat fields_size {} exceeds project field count {}", - fields_size, - self.fields.len() - ))); - } - for f in self.fields.iter_mut().take(fields_size) { - f.index_info = r.read_u32()?; - } - - let data_size = r.read_u32()? as usize; - if self.data.len() > data_size { - self.data.truncate(data_size); - } - if data_size > self.data.len() { - return Err(Error::invalid(format!( - "dat data_size {} exceeds project data count {}", - data_size, - self.data.len() - ))); - } - - let int_cnt = self - .fields - .iter() - .take(fields_size) - .filter(|f| !f.is_string()) - .count(); - let str_cnt = fields_size - int_cnt; - - for datum in &mut self.data { - datum.int_values = Vec::with_capacity(int_cnt); - for _ in 0..int_cnt { - datum.int_values.push(r.read_u32()?); - } - datum.string_values = Vec::with_capacity(str_cnt); - for _ in 0..str_cnt { - datum.string_values.push(r.read_string()?); - } - } - Ok(()) - } - - fn write_dat(&self, w: &mut Writer) { - w.write_bytes(&TYPE_SEPARATOR); - w.write_u32(self.dat_unknown1); - w.write_u32(self.dat_fields_size); - if self.dat_unknown1 == STRING_INDICATOR { - // Always present in this branch. Default to an empty string if somehow missing. - let s = self.dat_unknown2.clone().unwrap_or_default(); - w.write_string(&s); - } - let fields_size = self.dat_fields_size as usize; - for f in self.fields.iter().take(fields_size) { - w.write_u32(f.index_info); - } - w.write_u32(self.data.len() as u32); - for datum in &self.data { - for &v in &datum.int_values { - w.write_u32(v); - } - for s in &datum.string_values { - w.write_string(s); - } - } - } -} - -/// Read a `count`-prefixed metadata block. Reads the stored count and applies it to -/// `fields[0..count]`. Guards against the count exceeding the field vector. -fn read_indexed( - r: &mut Reader, - field_count: usize, - label: &str, - mut apply: impl FnMut(&mut Reader, usize) -> Result<()>, -) -> Result<()> { - let n = r.read_u32()? as usize; - if n > field_count { - return Err(Error::invalid(format!( - "database project '{label}' count {n} exceeds field count {field_count}" - ))); - } - for i in 0..n { - apply(r, i)?; - } - Ok(()) -} diff --git a/util/wolfdawn-master/crates/wolf-formats/src/game_dat.rs b/util/wolfdawn-master/crates/wolf-formats/src/game_dat.rs deleted file mode 100644 index 01ffad3..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/src/game_dat.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Game.dat: the per-game settings/title record. Carries the window title, the -//! splash/title-screen messages, the editor font list and a handful of opaque size/offset -//! housekeeping fields the engine uses internally. -//! -//! Layout on disk (plaintext case): -//! `[crypt-indicator u8=0][9-byte magic][unknown1 byteArray][stringCount u32][strings...] -//! [fileSize u32][unknownSize u32][unknownWordSize u32][wordData][intOffset1 u32] -//! [intOffset2 u32][unknown2 tail]`. -//! -//! The leading indicator and any encrypted body are crypto-layer concerns. This reader handles -//! the plaintext case (indicator 0) and errors otherwise. -//! -//! Write quirk: the engine recomputes `fileSize` and shifts `intOffset1`/`intOffset2` by the -//! size delta on dump. To keep an unmodified file byte-exact, anchor the delta on the body size -//! as read. An unchanged file yields `sizeDiff == 0`, so the three fields re-emit verbatim. An -//! edited title shifts them by the exact byte delta. - -use crate::Ctx; -use wolf_core::{Error, Reader, Result, WStr, Writer}; - -/// `{0x57,00,00,0x4F,0x4C,00,0x46,0x4D,(00|'U')}`: "WOLFM" with a UTF-8 toggle at idx 8. -const MAGIC_LEN: usize = 9; -const UTF8_MARKER_INDEX: usize = 8; -/// The fixed `magicString` (string index 1) every Game.dat carries. -const MAGIC_STRING: &[u8] = b"0000-0000"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GameDat { - /// Leading crypt-indicator byte (0 = plaintext). - pub indicator: u8, - pub magic: [u8; MAGIC_LEN], - pub utf8: bool, - - /// Opaque header byte-array preceding the string table. - pub unknown1: Vec, - /// The on-disk string count. Gates which optional strings are present (see [`GameDat::read`]). - pub string_count: u32, - - // The string table. Player-facing strings are kept as byte-preserving `WStr`. - /// String 0: the window/game title. - pub title: WStr, - /// String 1: the fixed `0000-0000` marker, preserved verbatim. - pub magic_string: WStr, - /// String 2: the decrypt key, stored as a raw byte-array (not a length-prefixed string). - pub decrypt_key: Vec, - /// String 3: the main editor font. - pub font: WStr, - /// Strings 4 to 6: the three fallback sub-fonts. - pub sub_fonts: Vec, - /// String 7: the default protagonist graphic. - pub default_pc_graphic: WStr, - /// String 8 (present when `string_count >= 9`): the title-suffix or version string. - pub title_plus: Option, - /// Strings 9 to 12 (present when `string_count > 9`): road image, gauge image, the start-up - /// message and the title message. - pub road_img: Option, - pub gauge_img: Option, - pub start_up_msg: Option, - pub title_msg: Option, - /// String 14 (present when `string_count > 13`): an opaque trailing string. - pub unknown_string14: Option, - - // Size/offset housekeeping (recomputed on write, see module docs). - pub file_size: u32, - pub unknown_size: u32, - pub unknown_word_size: u32, - /// `unknown_word_size * 2` opaque bytes. - pub unknown_word_data: Vec, - pub int_offset1: u32, - pub int_offset2: u32, - /// Everything after `int_offset2` until EOF (opaque editor tail). - pub unknown2: Vec, - - /// The body size (everything after the indicator byte) as computed from the data at read - /// time. The write-time `sizeDiff` is `calc_body_size() - body_size_at_read`, so an - /// unmodified file re-serializes byte-exact (delta 0) while an edit shifts the offsets by the - /// exact byte delta. - body_size_at_read: u32, -} - -impl GameDat { - pub fn read(bytes: &[u8]) -> Result { - let mut r = Reader::new(bytes); - - let indicator = r.read_u8()?; - if indicator != 0 { - return Err(Error::invalid( - "Game.dat is encrypted (indicator != 0); decrypt with wolf-archive first", - )); - } - - let magic_vec = r.read_bytes(MAGIC_LEN)?; - if magic_vec[0] != 0x57 || magic_vec[6] != 0x46 || magic_vec[7] != 0x4D { - return Err(Error::invalid("not a Game.dat (magic mismatch)")); - } - let mut magic = [0u8; MAGIC_LEN]; - magic.copy_from_slice(&magic_vec); - let utf8 = magic[UTF8_MARKER_INDEX] == b'U'; - let _ = Ctx { utf8, v35: false }; - - let unknown1 = r.read_byte_array()?; - let string_count = r.read_u32()?; - - // String 0 - let title = r.read_string()?; - // String 1 (the fixed marker) - let magic_string = r.read_string()?; - if magic_string.as_bytes().strip_suffix(&[0u8]).unwrap_or(magic_string.as_bytes()) - != MAGIC_STRING - { - return Err(Error::invalid(format!( - "Game.dat invalid magic string: {:?}", - magic_string.to_lossy() - ))); - } - // String 2 (a byte-array, not a string) - let decrypt_key = r.read_byte_array()?; - // String 3 - let font = r.read_string()?; - // Strings 4-6 - let mut sub_fonts = Vec::with_capacity(3); - for _ in 0..3 { - sub_fonts.push(r.read_string()?); - } - // String 7 - let default_pc_graphic = r.read_string()?; - - // String 8 - let title_plus = if string_count >= 9 { - Some(r.read_string()?) - } else { - None - }; - - // Strings 9-12 - let (road_img, gauge_img, start_up_msg, title_msg) = if string_count > 9 { - ( - Some(r.read_string()?), - Some(r.read_string()?), - Some(r.read_string()?), - Some(r.read_string()?), - ) - } else { - (None, None, None, None) - }; - - // String 14 - let unknown_string14 = if string_count > 13 { - Some(r.read_string()?) - } else { - None - }; - - let file_size = r.read_u32()?; - let unknown_size = r.read_u32()?; - let unknown_word_size = r.read_u32()?; - let unknown_word_data = r.read_bytes(unknown_word_size as usize * 2)?; - let int_offset1 = r.read_u32()?; - let int_offset2 = r.read_u32()?; - let unknown2 = r.read_bytes(r.remaining())?; - - if !r.is_eof() { - return Err(Error::invalid("Game.dat has trailing data")); - } - - let mut gd = GameDat { - indicator, - magic, - utf8, - unknown1, - string_count, - title, - magic_string, - decrypt_key, - font, - sub_fonts, - default_pc_graphic, - title_plus, - road_img, - gauge_img, - start_up_msg, - title_msg, - unknown_string14, - file_size, - unknown_size, - unknown_word_size, - unknown_word_data, - int_offset1, - int_offset2, - unknown2, - body_size_at_read: 0, - }; - gd.body_size_at_read = gd.calc_body_size(); - Ok(gd) - } - - pub fn write(&self) -> Vec { - // The size delta vs. the data as read. It is 0 for an unmodified file (byte-exact), else - // the exact byte difference, applied to the three housekeeping fields. - let new_size = self.calc_body_size(); - let size_diff = new_size as i64 - self.body_size_at_read as i64; - let shift = |v: u32| -> u32 { (v as i64 + size_diff) as u32 }; - - let mut w = Writer::new(); - w.write_u8(self.indicator); - w.write_bytes(&self.magic); - - w.write_byte_array(&self.unknown1); - w.write_u32(self.string_count); - - w.write_string(&self.title); - w.write_string(&self.magic_string); - w.write_byte_array(&self.decrypt_key); - w.write_string(&self.font); - for f in &self.sub_fonts { - w.write_string(f); - } - w.write_string(&self.default_pc_graphic); - if let Some(s) = &self.title_plus { - w.write_string(s); - } - if let (Some(r), Some(g), Some(s), Some(t)) = - (&self.road_img, &self.gauge_img, &self.start_up_msg, &self.title_msg) - { - w.write_string(r); - w.write_string(g); - w.write_string(s); - w.write_string(t); - } - if let Some(s) = &self.unknown_string14 { - w.write_string(s); - } - - w.write_u32(shift(self.file_size)); - w.write_u32(self.unknown_size); - w.write_u32(self.unknown_word_size); - w.write_bytes(&self.unknown_word_data); - w.write_u32(shift(self.int_offset1)); - w.write_u32(shift(self.int_offset2)); - w.write_bytes(&self.unknown2); - - w.into_bytes() - } - - /// The body size everything after the indicator byte serializes to (magic + string table + - /// housekeeping fields + tail). Excludes the leading crypt indicator. Used to anchor the - /// write-time `sizeDiff`. - fn calc_body_size(&self) -> u32 { - let str_size = |s: &WStr| s.len() + 4; // u32 length prefix + bytes - let mut size = MAGIC_LEN; - size += self.unknown1.len() + 4; - size += 4; // string_count - size += str_size(&self.title); - size += str_size(&self.magic_string); - size += self.decrypt_key.len() + 4; - size += str_size(&self.font); - for f in &self.sub_fonts { - size += str_size(f); - } - size += str_size(&self.default_pc_graphic); - if let Some(s) = &self.title_plus { - size += str_size(s); - } - for s in [&self.road_img, &self.gauge_img, &self.start_up_msg, &self.title_msg] - .into_iter() - .flatten() - { - size += str_size(s); - } - if let Some(s) = &self.unknown_string14 { - size += str_size(s); - } - size += 4; // file_size - size += 4; // unknown_size - size += 4; // unknown_word_size - size += self.unknown_word_data.len(); - size += 4; // int_offset1 - size += 4; // int_offset2 - size += self.unknown2.len(); - size as u32 - } -} diff --git a/util/wolfdawn-master/crates/wolf-formats/src/lib.rs b/util/wolfdawn-master/crates/wolf-formats/src/lib.rs deleted file mode 100644 index 1742d73..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `wolf-formats`: byte-faithful, typed (de)serialization of Wolf RPG data files. -//! -//! Each format exposes `read(&[u8]) -> Result` and `T::write(&self) -> Vec`. -//! The contract is round-trip fidelity. For an unmodified plaintext file, -//! `write(read(bytes)) == bytes`. Anything not yet understood is preserved verbatim -//! as opaque bytes rather than dropped, so recompiled files always remain loadable. - -#![forbid(unsafe_code)] - -pub mod command; -pub mod common_event; -pub mod database; -pub mod game_dat; -pub mod map; - -pub use wolf_core::{Error, Reader, Result, WStr, Writer}; - -/// Context threaded through a load/dump so per-command behaviour matches the owning -/// file. Encoding and v3.5 mode are passed explicitly rather than via globals. -#[derive(Debug, Clone, Copy, Default)] -pub struct Ctx { - /// File strings are UTF-8 (magic toggle byte == `'U'`), else Shift-JIS. - pub utf8: bool, - /// Wolf Pro v3.5 file: every command carries a trailing length-prefixed blob. - pub v35: bool, -} diff --git a/util/wolfdawn-master/crates/wolf-formats/src/map.rs b/util/wolfdawn-master/crates/wolf-formats/src/map.rs deleted file mode 100644 index ff6a32e..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/src/map.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! Map file (`.mps`): `WOLFM` magic, tileset/size header, opaque tile grid, then a -//! list of events, each with pages that hold the event command program. -//! -//! The LZ4-compressed body of v3.5 maps (version >= 0x65) is not yet handled. The local -//! corpus is version 0x64 (plain). - -use crate::command::{read_route_list, write_route_list, RawCommand, RouteCommand}; -use crate::Ctx; -use wolf_core::codec::lz4; -use wolf_core::{Error, Reader, Result, WStr, Writer}; - -const MAGIC_LEN: usize = 20; -const WOLFM: &[u8] = b"WOLFM"; -const UTF8_MARKER_INDEX: usize = 16; - -const EVENT_INDICATOR: u8 = 0x6F; -const MAP_TERMINATOR: u8 = 0x66; -const PAGE_INDICATOR: u8 = 0x79; -const EVENT_TERMINATOR: u8 = 0x70; -const PAGE_TERMINATOR: u8 = 0x7A; - -const EVENT_MAGIC1: [u8; 4] = [0x39, 0x30, 0x00, 0x00]; -const EVENT_MAGIC2: [u8; 4] = [0x00, 0x00, 0x00, 0x00]; - -const LZ4_VERSION_GATE: u32 = 0x65; -const V35_VERSION_GATE: u32 = 0x67; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Map { - /// The full 20-byte magic, kept verbatim (the byte at index 16 is `'U'` for UTF-8). - pub magic: [u8; MAGIC_LEN], - pub version: u32, - pub unknown2: u8, - pub unknown3: WStr, - pub tileset_id: u32, - pub width: u32, - pub height: u32, - /// Present only for version >= 0x67. - pub unknown4: Option, - /// Layer count (3 by default, explicit since v3.5). Used to size the tile grid. - pub layer_count: u32, - /// Raw tile grid bytes (`width * height * layer_count * 4`), opaque for now. - pub tiles: Vec, - /// UTF-8 maps may store a `0xFFFFFFFF` marker meaning "no tile grid". - pub no_tiles_marker: bool, - pub events: Vec, - pub utf8: bool, - /// For v3.5 maps (version >= 0x65): the original framed LZ4 body - /// `[decSize][encSize][block]`, kept so an unmodified map re-emits byte-exact. - pub packed: Option>, -} - -impl Map { - pub fn read(bytes: &[u8]) -> Result { - let mut r = Reader::new(bytes); - - let magic_vec = r.read_bytes(MAGIC_LEN)?; - if &magic_vec[10..15] != WOLFM { - return Err(Error::invalid("not a WOLFM map (magic mismatch)")); - } - let mut magic = [0u8; MAGIC_LEN]; - magic.copy_from_slice(&magic_vec); - let utf8 = magic[UTF8_MARKER_INDEX] == b'U'; - - let version = r.read_u32()?; - let unknown2 = r.read_u8()?; - - let ctx = Ctx { - utf8, - v35: version >= V35_VERSION_GATE, - }; - - // v3.5 maps (version >= 0x65) LZ4-compress everything after [magic][version][unknown2]. - let rem = r.remaining(); - let (packed, body): (Option>, Vec) = if version >= LZ4_VERSION_GATE { - let framed = r.read_bytes(rem)?; - let decompressed = lz4::unpack(&framed)?; - (Some(framed), decompressed) - } else { - (None, r.read_bytes(rem)?) - }; - let mut r = Reader::new(&body); - - let unknown3 = r.read_string()?; - let tileset_id = r.read_u32()?; - let width = r.read_u32()?; - let height = r.read_u32()?; - let event_count = r.read_u32()? as usize; - - let mut unknown4 = None; - let mut layer_count = 3u32; - if version >= V35_VERSION_GATE { - unknown4 = Some(r.read_u32()?); - layer_count = r.read_u32()?; - } - - // UTF-8 maps may signal an absent tile grid with a 0xFFFFFFFF sentinel. - let mut no_tiles_marker = false; - let mut tiles = Vec::new(); - if utf8 && r.peek_u32()? == 0xFFFF_FFFF { - r.read_u32()?; - no_tiles_marker = true; - } - if !no_tiles_marker { - let tile_bytes = (width as usize) - .saturating_mul(height as usize) - .saturating_mul(layer_count as usize) - .saturating_mul(4); - tiles = r.read_bytes(tile_bytes)?; - } - - let mut events = Vec::with_capacity(event_count); - loop { - let indicator = r.read_u8()?; - if indicator == MAP_TERMINATOR { - break; - } - if indicator != EVENT_INDICATOR { - return Err(Error::invalid(format!( - "unexpected map event indicator 0x{indicator:02x} (expected 0x6F or 0x66)" - ))); - } - events.push(Event::read(&mut r, &ctx)?); - } - - if events.len() != event_count { - return Err(Error::invalid(format!( - "map declared {event_count} events but read {}", - events.len() - ))); - } - if !r.is_eof() { - return Err(Error::invalid("map has trailing data after terminator")); - } - - Ok(Map { - magic, - version, - unknown2, - unknown3, - tileset_id, - width, - height, - unknown4, - layer_count, - tiles, - no_tiles_marker, - events, - utf8, - packed, - }) - } - - pub fn write(&self) -> Vec { - let ctx = Ctx { - utf8: self.utf8, - v35: self.version >= V35_VERSION_GATE, - }; - let mut w = Writer::with_capacity(self.tiles.len() + 4096); - - // The fixed, never-compressed header. - w.write_bytes(&self.magic); - w.write_u32(self.version); - w.write_u8(self.unknown2); - - // The compressible body: everything from unknown3 to the map terminator. - let mut body = Writer::with_capacity(self.tiles.len() + 4096); - body.write_string(&self.unknown3); - body.write_u32(self.tileset_id); - body.write_u32(self.width); - body.write_u32(self.height); - body.write_u32(self.events.len() as u32); - - if self.version >= V35_VERSION_GATE { - body.write_u32(self.unknown4.unwrap_or(0)); - body.write_u32(self.layer_count); - } - - if self.no_tiles_marker { - body.write_u32(0xFFFF_FFFF); - } else { - body.write_bytes(&self.tiles); - } - - for event in &self.events { - body.write_u8(EVENT_INDICATOR); - event.write(&mut body, &ctx); - } - body.write_u8(MAP_TERMINATOR); - let body = body.into_bytes(); - - if self.version >= LZ4_VERSION_GATE { - // Re-emit original packed bytes verbatim when unchanged (byte-exact). Else - // compress fresh (valid LZ4, content-exact). - match &self.packed { - Some(orig) if lz4::unpack(orig).map(|b| b == body).unwrap_or(false) => { - w.write_bytes(orig); - } - _ => w.write_bytes(&lz4::pack(&body)), - } - } else { - w.write_bytes(&body); - } - - w.into_bytes() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Event { - pub id: u32, - pub name: WStr, - pub x: u32, - pub y: u32, - pub pages: Vec, -} - -impl Event { - fn read(r: &mut Reader, ctx: &Ctx) -> Result { - r.verify(&EVENT_MAGIC1, "event magic1")?; - let id = r.read_u32()?; - let name = r.read_string()?; - let x = r.read_u32()?; - let y = r.read_u32()?; - let page_count = r.read_u32()? as usize; - r.verify(&EVENT_MAGIC2, "event magic2")?; - - let mut pages = Vec::with_capacity(page_count); - loop { - let indicator = r.read_u8()?; - if indicator == EVENT_TERMINATOR { - break; - } - if indicator != PAGE_INDICATOR { - return Err(Error::invalid(format!( - "unexpected event page indicator 0x{indicator:02x} (expected 0x79 or 0x70)" - ))); - } - pages.push(Page::read(r, ctx)?); - } - - if pages.len() != page_count { - return Err(Error::invalid(format!( - "event {id} declared {page_count} pages but read {}", - pages.len() - ))); - } - - Ok(Event { - id, - name, - x, - y, - pages, - }) - } - - fn write(&self, w: &mut Writer, ctx: &Ctx) { - w.write_bytes(&EVENT_MAGIC1); - w.write_u32(self.id); - w.write_string(&self.name); - w.write_u32(self.x); - w.write_u32(self.y); - w.write_u32(self.pages.len() as u32); - w.write_bytes(&EVENT_MAGIC2); - for page in &self.pages { - w.write_u8(PAGE_INDICATOR); - page.write(w, ctx); - } - w.write_u8(EVENT_TERMINATOR); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Page { - pub unknown1: u32, - pub graphic_name: WStr, - pub graphic_direction: u8, - pub graphic_frame: u8, - pub graphic_opacity: u8, - pub graphic_render_mode: u8, - /// 37 opaque condition bytes (1 + 4 + 16 + 16). - pub conditions: Vec, - /// 4 opaque movement bytes. - pub movement: Vec, - pub flags: u8, - pub route_flags: u8, - pub route: Vec, - pub commands: Vec, - pub features: u32, - pub shadow_graphic_num: u8, - pub collision_width: u8, - pub collision_height: u8, - /// Present only when `features > 3`. - pub page_transfer: Option, -} - -impl Page { - fn read(r: &mut Reader, ctx: &Ctx) -> Result { - let unknown1 = r.read_u32()?; - let graphic_name = r.read_string()?; - let graphic_direction = r.read_u8()?; - let graphic_frame = r.read_u8()?; - let graphic_opacity = r.read_u8()?; - let graphic_render_mode = r.read_u8()?; - let conditions = r.read_bytes(37)?; - let movement = r.read_bytes(4)?; - let flags = r.read_u8()?; - let route_flags = r.read_u8()?; - let route = read_route_list(r)?; - - let command_count = r.read_u32()? as usize; - let mut commands = Vec::with_capacity(command_count); - for _ in 0..command_count { - commands.push(RawCommand::read(r, ctx)?); - } - - let features = r.read_u32()?; - let shadow_graphic_num = r.read_u8()?; - let collision_width = r.read_u8()?; - let collision_height = r.read_u8()?; - let page_transfer = if features > 3 { - Some(r.read_u8()?) - } else { - None - }; - - let terminator = r.read_u8()?; - if terminator != PAGE_TERMINATOR { - return Err(Error::invalid(format!( - "page terminator was 0x{terminator:02x}, expected 0x7A" - ))); - } - - Ok(Page { - unknown1, - graphic_name, - graphic_direction, - graphic_frame, - graphic_opacity, - graphic_render_mode, - conditions, - movement, - flags, - route_flags, - route, - commands, - features, - shadow_graphic_num, - collision_width, - collision_height, - page_transfer, - }) - } - - fn write(&self, w: &mut Writer, ctx: &Ctx) { - w.write_u32(self.unknown1); - w.write_string(&self.graphic_name); - w.write_u8(self.graphic_direction); - w.write_u8(self.graphic_frame); - w.write_u8(self.graphic_opacity); - w.write_u8(self.graphic_render_mode); - w.write_bytes(&self.conditions); - w.write_bytes(&self.movement); - w.write_u8(self.flags); - w.write_u8(self.route_flags); - write_route_list(w, &self.route); - - w.write_u32(self.commands.len() as u32); - for c in &self.commands { - c.write(w, ctx); - } - - w.write_u32(self.features); - w.write_u8(self.shadow_graphic_num); - w.write_u8(self.collision_width); - w.write_u8(self.collision_height); - if let Some(pt) = self.page_transfer { - w.write_u8(pt); - } - w.write_u8(PAGE_TERMINATOR); - } -} diff --git a/util/wolfdawn-master/crates/wolf-formats/tests/all_versions.rs b/util/wolfdawn-master/crates/wolf-formats/tests/all_versions.rs deleted file mode 100644 index 9334396..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/tests/all_versions.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Cross-version format coverage: round-trip every Wolf editor version's loose Data/ files -//! (CommonEvent, maps, databases) byte-exact. Reveals decoder version gaps. -//! cargo test -p wolf-formats --test all_versions -- --nocapture -use std::path::{Path, PathBuf}; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -fn versions_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("ALLWOLFVERSIONS") -} - -fn find(dir: &Path, name: &str) -> Option { - let mut stack = vec![dir.to_path_buf()]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else if p.file_name().and_then(|s| s.to_str()) == Some(name) { - return Some(p); - } - } - } - None -} - -fn find_all_mps(dir: &Path) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![dir.to_path_buf()]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else if p.extension().and_then(|s| s.to_str()) == Some("mps") { - out.push(p); - } - } - } - out -} - -#[test] -fn all_wolf_versions_round_trip() { - let root = versions_root(); - let Ok(dirs) = std::fs::read_dir(&root) else { - eprintln!("no ALLWOLFVERSIONS"); - return; - }; - let mut editors: Vec = dirs - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .collect(); - editors.sort(); - - let mut total_ok = 0; - let mut total_fail = 0; - for ed in &editors { - let name = ed.file_name().unwrap().to_string_lossy().into_owned(); - let mut ok = 0; - let mut fails: Vec = Vec::new(); - - // CommonEvent.dat - if let Some(p) = find(ed, "CommonEvent.dat") { - match std::fs::read(&p) - .map(|b| (CommonEventsFile::read(&b).map(|ce| ce.write() == b), b)) - { - Ok((Ok(true), _)) => ok += 1, - Ok((Ok(false), _)) => fails.push("CommonEvent:diff".into()), - Ok((Err(e), b)) => fails.push(format!( - "CommonEvent:ERR(v{:#x}):{}", - b.get(10).copied().unwrap_or(0), - short(&e.to_string()) - )), - _ => {} - } - } - // Maps - let mut map_ok = 0; - let mut map_fail = 0; - for mp in find_all_mps(ed) { - let Ok(b) = std::fs::read(&mp) else { continue }; - match Map::read(&b) { - Ok(m) if m.write() == b => map_ok += 1, - Ok(_) => map_fail += 1, - Err(_) => map_fail += 1, - } - } - if map_fail == 0 && map_ok > 0 { - ok += 1; - } else if map_fail > 0 { - fails.push(format!("maps:{map_ok}ok/{map_fail}fail")); - } - // Databases (name varies: SysDatabase vs SysDataBaseBasic) - for (proj, dat) in db_candidates(ed) { - let (Ok(pb), Ok(db)) = (std::fs::read(&proj), std::fs::read(&dat)) else { - continue; - }; - match Database::read(&pb, &db) { - Ok(d) => { - let (po, dd) = d.write(); - if po == pb && dd == db { - ok += 1; - } else { - fails.push(format!("{}:diff", stem(&dat))); - } - } - Err(e) => fails.push(format!("{}:ERR:{}", stem(&dat), short(&e.to_string()))), - } - } - - if fails.is_empty() { - total_ok += 1; - eprintln!(" OK {name} ({ok} file-groups)"); - } else { - total_fail += 1; - eprintln!(" FAIL {name} ok={ok} -> {}", fails.join(", ")); - } - } - let total = total_ok + total_fail; - eprintln!( - "\nversions fully round-tripping (excl. legacy v2 SysDataBaseBasic): {total_ok}/{total}" - ); - if total > 0 { - assert_eq!( - total_fail, 0, - "some Wolf version's core data did not round-trip byte-exact" - ); - } -} - -/// The standard databases. The legacy v2 `SysDataBaseBasic` is a different pre-v3 format -/// (no `OLUFM` magic) and is intentionally excluded. This is a documented known gap. -fn db_candidates(ed: &Path) -> Vec<(PathBuf, PathBuf)> { - let mut out = Vec::new(); - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - if let Some(proj) = find(ed, &format!("{stem}.project")) { - let dat = proj.with_extension("dat"); - if dat.exists() { - out.push((proj, dat)); - } - } - } - out -} - -fn stem(p: &Path) -> String { - p.file_stem().unwrap().to_string_lossy().into_owned() -} -fn short(s: &str) -> String { - s.chars().take(40).collect() -} diff --git a/util/wolfdawn-master/crates/wolf-formats/tests/archive_end_to_end.rs b/util/wolfdawn-master/crates/wolf-formats/tests/archive_end_to_end.rs deleted file mode 100644 index 2fc3eac..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/tests/archive_end_to_end.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! End-to-end gate: unpack `Data.wolf` (the real DXArchive/Wolf v3.14 container) and prove -//! the extracted inner files are *complete, valid* Wolf files by parsing them and -//! round-tripping them byte-exact through `wolf-formats`. This proves the archive decode -//! (key + Huffman + LZSS) is byte-perfect even where the loose `Data/` is a different build. -//! cargo test -p wolf-formats --test archive_end_to_end -- --nocapture - -use std::path::PathBuf; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -fn root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") -} - -#[test] -fn data_wolf_inner_files_parse_and_roundtrip() { - let Ok(bytes) = std::fs::read(root().join("Data.wolf")) else { - eprintln!("Data.wolf not present, skipping"); - return; - }; - let files = wolf_archive::extract_archive(&bytes, wolf_archive::DEFAULT_WOLF_PRO_KEY) - .expect("decode Data.wolf"); - let get = |name: &str| -> Vec { - files - .iter() - .find(|(p, _)| p.replace('\\', "/") == name) - .unwrap_or_else(|| panic!("{name} not found in archive")) - .1 - .clone() - }; - - // CommonEvent.dat: parse + byte-exact round-trip. - let ce_bytes = get("BasicData/CommonEvent.dat"); - let ce = CommonEventsFile::read(&ce_bytes).expect("parse extracted CommonEvent.dat"); - assert_eq!( - ce.write(), - ce_bytes, - "extracted CommonEvent.dat must round-trip byte-exact" - ); - eprintln!( - "CommonEvent.dat: {} events, {} bytes, round-trip OK", - ce.events.len(), - ce_bytes.len() - ); - - // Every map: parse + byte-exact round-trip. - let mut maps = 0; - for (path, data) in &files { - if !path.to_ascii_lowercase().ends_with(".mps") { - continue; - } - let m = Map::read(data).unwrap_or_else(|e| panic!("parse {path}: {e}")); - assert_eq!( - &m.write(), - data, - "extracted {path} must round-trip byte-exact" - ); - maps += 1; - } - eprintln!("maps: {maps} parsed + round-tripped byte-exact"); - assert!(maps > 0, "expected maps in the archive"); - - // The three databases: parse + byte-exact round-trip. - for (proj, dat) in [ - ("BasicData/DataBase.project", "BasicData/DataBase.dat"), - ("BasicData/CDataBase.project", "BasicData/CDataBase.dat"), - ("BasicData/SysDatabase.project", "BasicData/SysDatabase.dat"), - ] { - let (pb, db_bytes) = (get(proj), get(dat)); - let db = Database::read(&pb, &db_bytes).unwrap_or_else(|e| panic!("parse {dat}: {e}")); - let (po, dout) = db.write(); - assert_eq!(po, pb, "{proj} round-trip"); - assert_eq!(dout, db_bytes, "{dat} round-trip"); - } - eprintln!("3 databases parsed + round-tripped byte-exact"); -} diff --git a/util/wolfdawn-master/crates/wolf-formats/tests/basic_database.rs b/util/wolfdawn-master/crates/wolf-formats/tests/basic_database.rs deleted file mode 100644 index a5089fc..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/tests/basic_database.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Legacy `SysDataBaseBasic` (Wolf 2.x) round-trip gate: the `.project` schema is parsed and -//! re-emitted byte-exact, the magic-less `.dat` is carried opaque, so the pair reproduces -//! byte-for-byte. This file is preserved losslessly rather than dropped. -//! cargo test -p wolf-formats --test basic_database -- --nocapture - -use std::path::{Path, PathBuf}; - -use wolf_formats::database::{dat_has_db_magic, BasicDatabase}; - -fn root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("ALLWOLFVERSIONS") -} - -/// Collect every `SysDataBaseBasic.project` under `dir`. -fn collect(dir: &Path, out: &mut Vec) { - let Ok(rd) = std::fs::read_dir(dir) else { - return; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - collect(&p, out); - } else if p.file_name().and_then(|s| s.to_str()) == Some("SysDataBaseBasic.project") { - out.push(p); - } - } -} - -#[test] -fn basic_database_round_trips_byte_exact() { - let mut projects = Vec::new(); - collect(&root(), &mut projects); - projects.sort(); - projects.dedup(); - - if projects.is_empty() { - eprintln!("no SysDataBaseBasic found, skipping"); - return; - } - - let mut ok = 0usize; - for proj in &projects { - let dat = proj.with_extension("dat"); - let (Ok(pb), Ok(db_)) = (std::fs::read(proj), std::fs::read(&dat)) else { - continue; - }; - // The legacy .dat must indeed lack the container magic (else it is a normal DB). - assert!( - !dat_has_db_magic(&db_), - "{} unexpectedly has DB magic", - dat.display() - ); - - let parsed = BasicDatabase::read(&pb, &db_).expect("basic database parses"); - let (proj_out, dat_out) = parsed.write(); - assert_eq!(proj_out, pb, "{}: .project not byte-exact", proj.display()); - assert_eq!(dat_out, db_, "{}: .dat not byte-exact", dat.display()); - ok += 1; - } - eprintln!( - "SysDataBaseBasic byte-exact round-trips: {ok}/{}", - projects.len() - ); - assert!(ok > 0, "no basic databases were exercised"); -} diff --git a/util/wolfdawn-master/crates/wolf-formats/tests/gamepro_probe.rs b/util/wolfdawn-master/crates/wolf-formats/tests/gamepro_probe.rs deleted file mode 100644 index 1aa485d..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/tests/gamepro_probe.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Diagnostic probe (reports, never asserts): point our readers at a second, Wolf-Pro -//! game's files to discover format/version/encryption gaps. Run with: -//! cargo test -p wolf-formats --test gamepro_probe -- --nocapture - -use std::path::PathBuf; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -fn pro_data() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("GamePro_Data") -} - -fn entropy(b: &[u8]) -> f64 { - if b.is_empty() { - return 0.0; - } - let mut counts = [0usize; 256]; - for &x in b { - counts[x as usize] += 1; - } - let n = b.len() as f64; - -counts - .iter() - .filter(|&&c| c > 0) - .map(|&c| { - let p = c as f64 / n; - p * p.log2() - }) - .sum::() -} - -fn report_head(label: &str, bytes: &[u8]) { - let n = bytes.len().min(16); - let hex: String = bytes[..n].iter().map(|b| format!("{b:02x} ")).collect(); - let ent = entropy(&bytes[..bytes.len().min(4096)]); - eprintln!( - " {label:24} {} bytes head: {hex} entropy(4k)={ent:.2}", - bytes.len() - ); -} - -#[test] -fn probe_common_event() { - let p = pro_data().join("BasicData").join("CommonEvent.dat"); - let Ok(bytes) = std::fs::read(&p) else { - eprintln!("missing {}", p.display()); - return; - }; - report_head("CommonEvent.dat", &bytes); - let ce = CommonEventsFile::read(&bytes).expect("parse Pro CommonEvent.dat"); - let rt = ce.write(); - eprintln!( - " PARSED: {} events; round-trip {}", - ce.events.len(), - if rt == bytes { "BYTE-EXACT" } else { "DIFF" } - ); - assert_eq!(rt, bytes, "Pro CommonEvent.dat must round-trip byte-exact"); -} - -#[test] -fn probe_databases() { - let dir = pro_data().join("BasicData"); - for (proj, dat) in [ - ("DataBase.project", "DataBase.dat"), - ("CDataBase.project", "CDataBase.dat"), - ("SysDatabase.project", "SysDatabase.dat"), - ] { - let (pp, dp) = (dir.join(proj), dir.join(dat)); - let (Ok(pb), Ok(db_bytes)) = (std::fs::read(&pp), std::fs::read(&dp)) else { - continue; - }; - report_head(dat, &db_bytes); - let db = - Database::read(&pb, &db_bytes).unwrap_or_else(|e| panic!("parse {proj}/{dat}: {e}")); - let (po, dout) = db.write(); - eprintln!( - " {proj}/{dat} PARSED: {} types; proj {} dat {}", - db.types.len(), - if po == pb { "ok" } else { "DIFF" }, - if dout == db_bytes { - "BYTE-EXACT" - } else { - "DIFF" - } - ); - assert_eq!(po, pb, "{proj} must round-trip byte-exact"); - assert_eq!(dout, db_bytes, "{dat} must round-trip byte-exact"); - } -} - -#[test] -fn probe_maps() { - let dir = pro_data().join("MapData"); - let Ok(entries) = std::fs::read_dir(&dir) else { - eprintln!("missing {}", dir.display()); - return; - }; - let mut ok = 0; - let mut fail = 0; - let mut diff = 0; - let mut total = 0; - let mut first_err = String::new(); - let mut versions: std::collections::BTreeMap = Default::default(); - for e in entries { - let path = e.unwrap().path(); - if path.extension().and_then(|s| s.to_str()) != Some("mps") { - continue; - } - let bytes = std::fs::read(&path).unwrap(); - total += 1; - // map version lives at offset 20 (after 10-byte 0x00 pad + WOLFM magic + 'U'/byte). - if bytes.len() > 23 { - let v = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); - *versions.entry(v).or_default() += 1; - } - match Map::read(&bytes) { - Ok(m) => { - let rt = m.write(); - if rt == bytes { - ok += 1; - } else { - diff += 1; - } - } - Err(e) => { - fail += 1; - if first_err.is_empty() { - first_err = format!("{}: {e}", path.file_name().unwrap().to_string_lossy()); - } - } - } - } - eprintln!(" maps: total={total} byte-exact={ok} diff={diff} parse-fail={fail}"); - eprintln!(" map versions seen: {versions:?}"); - if !first_err.is_empty() { - eprintln!(" first parse error: {first_err}"); - } - assert_eq!(fail, 0, "all Pro maps must parse"); - assert_eq!(ok, total, "all Pro maps must round-trip byte-exact"); -} diff --git a/util/wolfdawn-master/crates/wolf-formats/tests/roundtrip.rs b/util/wolfdawn-master/crates/wolf-formats/tests/roundtrip.rs deleted file mode 100644 index 88bfda5..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/tests/roundtrip.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Byte-exact round-trip gate over the local plaintext corpus. -//! For every sample file: `write(read(bytes)) == bytes`. - -use std::path::PathBuf; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -/// `Wolf/Data`, three levels up from this crate (`Wolf/wolf-dawn/crates/wolf-formats`). -fn data_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("Data") -} - -fn assert_byte_exact(name: &str, original: &[u8], rewritten: &[u8]) { - if original == rewritten { - return; - } - let first_diff = original - .iter() - .zip(rewritten.iter()) - .position(|(a, b)| a != b); - panic!( - "round-trip mismatch for {name}: original {} bytes, rewritten {} bytes, first differing offset = {:?}", - original.len(), - rewritten.len(), - first_diff - ); -} - -#[test] -fn maps_round_trip_byte_exact() { - let dir = data_dir().join("MapData"); - let mut checked = 0; - - let entries = - std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())); - - for entry in entries { - let path = entry.unwrap().path(); - if path.extension().and_then(|s| s.to_str()) != Some("mps") { - continue; - } - let name = path.file_name().unwrap().to_string_lossy().into_owned(); - let original = std::fs::read(&path).unwrap(); - - let map = Map::read(&original).unwrap_or_else(|e| panic!("failed to parse {name}: {e}")); - let rewritten = map.write(); - - assert_byte_exact(&name, &original, &rewritten); - checked += 1; - } - - assert!(checked > 0, "no .mps files found in {}", dir.display()); - eprintln!("round-tripped {checked} map(s) byte-exact"); -} - -#[test] -fn common_events_round_trip_byte_exact() { - let path = data_dir().join("BasicData").join("CommonEvent.dat"); - let original = - std::fs::read(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); - - let ce = CommonEventsFile::read(&original) - .unwrap_or_else(|e| panic!("failed to parse CommonEvent.dat: {e}")); - let rewritten = ce.write(); - - assert_byte_exact("CommonEvent.dat", &original, &rewritten); - eprintln!( - "round-tripped CommonEvent.dat ({} events) byte-exact", - ce.events.len() - ); -} - -#[test] -fn databases_round_trip_byte_exact() { - let dir = data_dir().join("BasicData"); - // (project, dat) pairs present in the sample game. - let pairs = [ - ("DataBase.project", "DataBase.dat"), - ("CDataBase.project", "CDataBase.dat"), - ("SysDatabase.project", "SysDatabase.dat"), - ]; - - let mut checked = 0; - for (proj, dat) in pairs { - let proj_path = dir.join(proj); - let dat_path = dir.join(dat); - if !proj_path.exists() || !dat_path.exists() { - continue; - } - let proj_bytes = std::fs::read(&proj_path).unwrap(); - let dat_bytes = std::fs::read(&dat_path).unwrap(); - - let db = Database::read(&proj_bytes, &dat_bytes) - .unwrap_or_else(|e| panic!("failed to parse {proj}/{dat}: {e}")); - let (proj_out, dat_out) = db.write(); - - assert_byte_exact(proj, &proj_bytes, &proj_out); - assert_byte_exact(dat, &dat_bytes, &dat_out); - eprintln!( - "round-tripped {proj} + {dat} ({} types) byte-exact", - db.types.len() - ); - checked += 1; - } - assert!(checked > 0, "no database pairs found in {}", dir.display()); -} diff --git a/util/wolfdawn-master/crates/wolf-formats/tests/ver5_archive.rs b/util/wolfdawn-master/crates/wolf-formats/tests/ver5_archive.rs deleted file mode 100644 index 5ccf0e8..0000000 --- a/util/wolfdawn-master/crates/wolf-formats/tests/ver5_archive.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Verify the DXArchive VER5 decoder against the real Wolf 2.01 sample-game Data.wolf: -//! extracted inner files must parse + round-trip byte-exact through wolf-formats. -//! cargo test -p wolf-formats --test ver5_archive -- --nocapture -use std::path::{Path, PathBuf}; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::map::Map; - -fn find_wolf(dir: &Path) -> Option { - let mut stack = vec![dir.to_path_buf()]; - while let Some(d) = stack.pop() { - let Ok(rd) = std::fs::read_dir(&d) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - } else if p.extension().and_then(|s| s.to_str()) == Some("wolf") { - return Some(p); - } - } - } - None -} - -#[test] -fn ver5_ver6_archives_parse() { - // VER5 = Wolf 2.01, VER6 = Wolf 2.20. - for editor in ["WolfRPGEditor_201", "WolfRPGEditor_220"] { - let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("ALLWOLFVERSIONS") - .join(editor); - let Some(arc) = find_wolf(&base) else { - eprintln!("{editor}: no Data.wolf, skipping"); - continue; - }; - let data = std::fs::read(&arc).expect("read Data.wolf"); - let files = wolf_archive::extract_archive(&data, wolf_archive::DEFAULT_WOLF_PRO_KEY) - .unwrap_or_else(|e| panic!("{editor}: archive decode failed: {e}")); - eprintln!("{editor}: extracted {} files", files.len()); - assert!(files.len() > 100, "{editor}: expected a populated archive"); - - // CommonEvent.dat was LZ-compressed: parse + round-trip proves keycode-LZSS + key decode. - let ce = files - .iter() - .find(|(p, _)| p.replace('\\', "/").ends_with("BasicData/CommonEvent.dat")) - .unwrap_or_else(|| panic!("{editor}: CommonEvent.dat not in archive")); - let parsed = CommonEventsFile::read(&ce.1) - .unwrap_or_else(|e| panic!("{editor}: parse CommonEvent: {e}")); - assert_eq!(parsed.write(), ce.1, "{editor}: CommonEvent.dat round-trip"); - eprintln!( - " CommonEvent.dat: {} events, {} bytes, round-trip OK", - parsed.events.len(), - ce.1.len() - ); - - let mut maps = 0; - for (p, b) in &files { - if !p.to_ascii_lowercase().ends_with(".mps") { - continue; - } - let m = Map::read(b).unwrap_or_else(|e| panic!("{editor}: parse {p}: {e}")); - assert_eq!(&m.write(), b, "{editor}: map {p} round-trip"); - maps += 1; - } - eprintln!(" maps round-tripped byte-exact: {maps}"); - assert!(maps > 0, "{editor}: expected maps"); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/Cargo.toml b/util/wolfdawn-master/crates/wolf-gui/Cargo.toml deleted file mode 100644 index a0df4c7..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "wolf-gui" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "WolfDawn Studio - the desktop GUI front-end for the WolfDawn toolchain (egui/eframe)." - -[[bin]] -name = "wolf-gui" -path = "src/main.rs" - -[dependencies] -# Native, immediate-mode GUI. eframe re-exports the matching egui; rfd gives native -# OS file dialogs. All three are pure-safe Rust (the `#![forbid(unsafe_code)]` lint at -# the crate root is satisfied - the unsafe lives inside the dependencies, not here). -# `persistence` enables eframe's on-disk key/value storage (RON under the OS config dir) plus the -# `eframe::get_value`/`set_value` helpers + the `App::save` hook, which the Settings section uses to -# persist preferences across runs. It pulls in serde-backed storage; still pure-safe Rust. -eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "persistence"] } -egui = "0.29" -# `TableBuilder` (the translation grid) lives in the base crate; no image/svg/http loaders needed. -egui_extras = "0.29" -rfd = "0.15" - -# Global allocator. The default Windows allocator holds freed pages, so RAM stayed high after a big -# extract / decompile / DB load even once the data was dropped. mimalloc returns freed memory to the -# OS promptly (and allocates faster), which keeps the long-running GUI light. Its unsafe lives inside -# the dependency, so the crate root's `#![forbid(unsafe_code)]` still holds. -mimalloc = "0.1" - -# JSON is the lingua franca of the translation pipeline: the library emits the per-file extract -# shapes as JSON, and the GUI parses them into its editable row model (and back) with serde_json. -serde_json = "1" -# The persisted `Settings` payload is a plain serde-derive struct (stored via eframe storage). -serde = { version = "1", features = ["derive"] } - -# The WolfDawn libraries the GUI drives directly (no shelling out to the CLI). -wolf-core = { path = "../wolf-core" } -wolf-formats = { path = "../wolf-formats" } -wolf-decompiler = { path = "../wolf-decompiler" } -wolf-archive = { path = "../wolf-archive" } - -[dev-dependencies] -# The Saves section's round-trip test re-encodes baked strings to skip ones a Shift-JIS save can't -# represent, matching the library's own encoding guard. -encoding_rs = "0.8" diff --git a/util/wolfdawn-master/crates/wolf-gui/src/app.rs b/util/wolfdawn-master/crates/wolf-gui/src/app.rs deleted file mode 100644 index b4988fb..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/app.rs +++ /dev/null @@ -1,4533 +0,0 @@ -//! `WolfDawnApp`, the application shell: state, the top/side/bottom/central panel layout, and -//! the per-section dispatch. Phase 0 ships the Project section (which reads real `Game.dat`) and -//! stubs every later section so the full navigation shape is visible from day one. - -use std::path::{Path, PathBuf}; -use std::sync::mpsc::{Receiver, Sender}; -use std::time::{Duration, Instant}; - -use serde::{Deserialize, Serialize}; - -use crate::archive::{self, CryptSource, PackFormat, PackOptions}; -use crate::database::{self, DbModel}; -use crate::decompile; -use crate::gamedat::{self, Group, LoadedGameDat}; -use crate::log::Log; -use crate::project::{self, NoteLevel, Project}; -use crate::saves::{self, LoadedSave}; -use crate::task::JobManager; -use crate::translation::{ - self, Conflict, FileKind, InjectTarget, TranslationModel, -}; -use crate::verify::{self, CorpusOutcome, Verdict}; -use crate::widgets; -use wolf_decompiler::{InjectOptions, SaveFormat}; - -/// The top-level navigation sections. Each later section plugs its real UI in where the stub is. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Section { - Project, - Archive, - Decompile, - Database, - GameDat, - Translation, - Saves, - Verify, - Settings, -} - -impl Section { - /// Every section, in sidebar order. Tests iterate this to exercise the dispatch exhaustively. - pub const ALL: [Section; 9] = [ - Section::Project, - Section::Archive, - Section::Decompile, - Section::Database, - Section::GameDat, - Section::Translation, - Section::Saves, - Section::Verify, - Section::Settings, - ]; - - /// The text label. - pub fn name(self) -> &'static str { - match self { - Section::Project => "Project", - Section::Archive => "Archive", - Section::Decompile => "Decompile", - Section::Database => "Database", - Section::GameDat => "Game.dat", - Section::Translation => "Translation", - Section::Saves => "Saves", - Section::Verify => "Verify", - Section::Settings => "Settings", - } - } -} - -/// Per-section UI state. Phase 0 only needs the Project section's. The rest are stubbed so the -/// fields exist for later phases to fill in (each section owns its inputs/options here). -#[derive(Default)] -struct SectionState { - archive: ArchiveState, - database: DatabaseState, - decompile: DecompileState, - gamedat: GameDatState, - translation: TranslationState, - saves: SavesState, - verify: VerifyState, - // Settings live on the app (`WolfDawnApp::settings`), not here, since they persist across runs - // and other sections read their defaults. The rest of `SectionState` is per-session scratch. -} - -/// Persisted user preferences (the Settings section). Serde-derived so the whole struct round-trips -/// through eframe's on-disk storage (see [`WolfDawnApp::new`] / [`eframe::App::save`]). Defaults -/// match the sections' historical defaults so a fresh install behaves exactly as before. -#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] -#[serde(default)] -pub struct Settings { - /// Dark vs light theme (applied via `ctx.set_visuals` on startup + when toggled). - pub dark_mode: bool, - /// Default for the Translation section's `--en-punct` flag. - pub default_en_punct: bool, - /// Default for the Translation section's `--allow-code-drift` flag. - pub default_allow_code_drift: bool, - /// Default for the "back up originals" toggles (Saves / Database / Game.dat). - pub default_backup: bool, - /// Gate for the periodic auto-detect rescan while a project is open. - pub auto_detect: bool, -} - -impl Default for Settings { - fn default() -> Self { - Self { - // egui's own default visuals are dark, so default to dark to match the unchanged look. - dark_mode: true, - default_en_punct: false, - default_allow_code_drift: false, - // Every section historically defaulted its backup toggle ON. Preserve that. - default_backup: true, - // Live auto-detect on by default. The Settings toggle gates it. - auto_detect: true, - } - } -} - -/// Which Archive sub-panel is active. -#[derive(Clone, Copy, PartialEq, Eq)] -enum ArchiveMode { - Unpack, - UnpackAll, - Repack, -} - -/// Archive section scratch state: the Unpack inputs (archive + output folder) and the Repack inputs -/// (folder + output `.wolf` + the encrypt/version/like/format options that mirror the CLI `pack` -/// flags). -struct ArchiveState { - /// Which sub-panel is shown (Unpack / Repack). - mode: ArchiveMode, - - // --- Unpack --- - /// The `.wolf` archive to extract. - unpack_archive: Option, - /// Where to extract it (defaults to `/Data`). - unpack_out: Option, - - // --- Unpack all (a per-category game's whole set of .wolf files) --- - /// The folder holding the `.wolf` archives (a game's `Data` folder). - unpack_all_dir: Option, - /// Where to write the per-archive folders (defaults to the archive folder itself, in place). - unpack_all_out: Option, - - // --- Repack --- - /// The folder to pack. - repack_in: Option, - /// The output `.wolf`. - repack_out: Option, - /// Encrypt the output (`--encrypt`). - repack_encrypt: bool, - /// Where the crypt params come from when encrypting (default / typed version / like an archive). - repack_crypt: RepackCryptMode, - /// The typed cryptVersion text (parsed to a `u16`, accepts `0x14b` or decimal). - repack_version_text: String, - /// The existing `.wolf` to inherit crypto from (for the "like existing" mode). - repack_like: Option, - /// The container format (`auto` / `ver5` / `ver6`). - repack_format: PackFormat, -} - -/// Where the encrypt-repack crypto parameters come from (the small radio in the Repack panel). -#[derive(Clone, Copy, PartialEq, Eq)] -enum RepackCryptMode { - /// The CLI default cryptVersion. - Default, - /// A manually-typed cryptVersion. - Version, - /// Inherit cryptVersion + password from an existing `.wolf` (`--like`). - Like, -} - -impl Default for ArchiveState { - fn default() -> Self { - Self { - mode: ArchiveMode::Unpack, - unpack_archive: None, - unpack_out: None, - unpack_all_dir: None, - unpack_all_out: None, - repack_in: None, - repack_out: None, - repack_encrypt: false, - repack_crypt: RepackCryptMode::Default, - repack_version_text: format!("{:#x}", archive::DEFAULT_CRYPT_VERSION), - repack_like: None, - repack_format: PackFormat::Auto, - } - } -} - -/// The heavy result a background Database job hands back to the UI thread (mirrors [`GameDatResult`]): -/// the freshly-loaded (or reloaded-after-save) grid model, over the per-section side-channel. -enum DbResult { - /// A freshly loaded model replaces the current one. - Loaded(Box), -} - -/// The Database section's scratch state: the `.project` path, the loaded editable grid model, the -/// selected type + a row-name filter, the in-place-vs-output choice + a backup toggle, and the -/// side-channel a background Load/Save job hands the model back on. -struct DatabaseState { - /// The `.project` to load (its sibling `.dat` is paired by extension). - path: Option, - /// The loaded, editable grid model (None until Load runs / after Reload-clears). - loaded: Option, - /// Which type (table) is shown on the right. - selected_type: usize, - /// The row-name filter (matches the row name, case-insensitive). - filter: String, - /// Output target: in-place (`None`) or a chosen output `.project`. - out_project: Option, - /// Back up the originals before an in-place overwrite (default ON). - backup: bool, - /// Receiver for a running Load/Save job's heavy result (the loaded/reloaded model). - result_rx: Option>, -} - -impl Default for DatabaseState { - fn default() -> Self { - Self { - path: None, - loaded: None, - selected_type: 0, - filter: String::new(), - out_project: None, - backup: true, - result_rx: None, - } - } -} - -/// The heavy result a background Decompile job hands back to the UI thread (mirrors [`VerifyResult`] -/// / [`SaveResult`]): the freshly-decompiled WolfScript text for the chosen input, carried over the -/// per-section side-channel since the shared [`JobManager`] only carries progress/log/summary. -enum DecompileResult { - /// Decompiled WolfScript text for `input` (the path it came from, kept as the compile `--base`). - Text(PathBuf, String), -} - -/// The Decompile section's scratch state: the input `.mps`/`CommonEvent.dat`, the editable WolfScript -/// buffer + the base it was decompiled from (needed as the compile `--base` so unknown bytes are -/// preserved), the chosen compile output, and the side-channel a background job hands its text back on. -struct DecompileState { - /// The binary event file to decompile (a `.mps` map or `CommonEvent.dat`). - input: Option, - /// The editable WolfScript document (empty until Decompile / Open .wscript runs). - script: String, - /// The original file the current script was decompiled from, the compile `--base`. Set on a - /// successful Decompile. Required before Compile (Open .wscript leaves it as the picked input). - base: Option, - /// Where Compile writes the recompiled file (defaults to `.out`). - out: Option, - /// Receiver for a running Decompile job's heavy result (the WolfScript text). - result_rx: Option>, - /// The find-in-editor (Ctrl+F) bar's state. - find: FindState, -} - -impl Default for DecompileState { - fn default() -> Self { - Self { - input: None, - script: String::new(), - base: None, - out: None, - result_rx: None, - find: FindState::default(), - } - } -} - -/// The Decompile editor's find-in-text (Ctrl+F) state: whether the bar is open, the query text, the -/// case toggle, and the index of the currently-selected match (so Next/Prev step through). The match -/// list itself is recomputed each frame from the script + query (`find_matches` is linear and cheap), -/// so it never goes stale against an edited script. -#[derive(Default)] -struct FindState { - /// Whether the find bar is shown (toggled by Ctrl+F / the Find button / the close button). - open: bool, - /// The search query. - query: String, - /// Case-sensitive matching (off = case-insensitive, the default). - case_sensitive: bool, - /// Which match is "current" (0-based) for the counter + scroll-to. Clamped to the match count. - current: usize, - /// Set when the bar was just opened, so the query field grabs focus for one frame. - focus_query: bool, - /// A pending match (char range) to select + scroll the editor to on this frame's `TextEdit`. - pending_select: Option<(usize, usize)>, -} - -/// The heavy result a background Game.dat job hands back to the UI thread: the freshly-loaded (or -/// reloaded-after-save) editable model, over the per-section side-channel. -enum GameDatResult { - /// A freshly loaded model replaces the current one. - Loaded(Box), -} - -/// The Game.dat section's scratch state: the `Game.dat` path, the loaded editable form model, the -/// in-place backup toggle, and the side-channel a background Load/Save job hands the model back on. -struct GameDatState { - /// The `Game.dat` to load (defaults to the open project's `game_dat`). - path: Option, - /// The loaded, editable form model (None until Load runs / after Reload-clears). - loaded: Option, - /// Back up the original before an in-place overwrite (default ON). - backup: bool, - /// Receiver for a running Load/Save job's heavy result (the loaded/reloaded model). - result_rx: Option>, -} - -impl Default for GameDatState { - fn default() -> Self { - Self { - path: None, - loaded: None, - backup: true, - result_rx: None, - } - } -} - -/// The Verify section's scratch state: the single-file pick + its last verdict, and the corpus -/// folder + its last per-file results. A background job hands results back via `result_rx`. -struct VerifyState { - /// The single data file to round-trip. - file: Option, - /// The last single-file verdict (shown in the result panel until re-run/cleared). - file_verdict: Option<(PathBuf, Verdict)>, - /// The folder to verify as a corpus. - corpus_dir: Option, - /// The last corpus run's per-file rows + summary. - corpus: Option, - /// Receiver for a running job's heavy result (a single verdict or a whole corpus outcome). - result_rx: Option>, -} - -impl Default for VerifyState { - fn default() -> Self { - Self { - file: None, - file_verdict: None, - corpus_dir: None, - corpus: None, - result_rx: None, - } - } -} - -/// The heavy result a background Verify job hands back to the UI thread (mirrors [`TrResult`] / -/// [`SaveResult`]): a single file's verdict, or a whole corpus outcome. -enum VerifyResult { - /// A single file's round-trip verdict, with the file it was run on. - Single(PathBuf, Box), - /// A whole corpus run's outcome. - Corpus(Box), -} - -/// A heavy result a background translation job hands back to the UI thread. The shared -/// [`JobManager`] only carries progress/log/summary. This side-channel carries the actual model the -/// job built so the UI can swap it in once the job lands. -enum TrResult { - /// A freshly extracted (or merged-into) model replaces the current one. - Model(Box), - /// The name-conflict check's findings, to render in the conflicts panel. - Conflicts(Vec), -} - -/// The Translation section's scratch state: the source folder, the loaded editable model, the grid -/// controls (selection / filter / counters), the inject options + output choice, and the -/// side-channel a background job uses to hand a rebuilt model back to the UI. -struct TranslationState { - /// The data dir to extract from (defaults to the open project's `data_dir`). - source_dir: Option, - /// The loaded editable model (None until Extract / Load runs). - model: Option, - /// Which left-list entry is selected: `None` = the names glossary, `Some(i)` = files\[i\]. - selected: SelectedEntry, - /// The grid's text filter (matches source or translation, case-insensitive). - filter: String, - /// Show only rows that are still untranslated. - untranslated_only: bool, - /// `--en-punct`: normalize Japanese punctuation to ASCII on inject. - en_punct: bool, - /// `--allow-code-drift`: relax the inline-code preservation guard on inject. - allow_code_drift: bool, - /// Output target: in-place when `None`, else a chosen output dir. - out_dir: Option, - /// The last name-conflict check's findings (shown in a small panel until re-run/cleared). - conflicts: Vec, - /// Receiver for a running job's heavy result (the rebuilt model / conflicts). - result_rx: Option>, -} - -/// Which left-list entry the grid is showing. -#[derive(Clone, Copy, PartialEq, Eq)] -enum SelectedEntry { - Names, - File(usize), -} - -impl Default for TranslationState { - fn default() -> Self { - Self { - source_dir: None, - model: None, - selected: SelectedEntry::Names, - filter: String::new(), - untranslated_only: false, - en_punct: false, - allow_code_drift: false, - out_dir: None, - conflicts: Vec::new(), - result_rx: None, - } - } -} - -/// The heavy result a background Saves job hands back to the UI thread, mirroring [`TrResult`]: the -/// shared [`JobManager`] only carries progress/log/summary, so the freshly-inspected save (or the -/// re-inspect after a write) comes back over this per-section side-channel. -enum SaveResult { - /// A freshly inspected (or re-inspected) save replaces the loaded one. - Loaded(Box), -} - -/// Which source the batch title comes from. -#[derive(Clone, Copy, PartialEq, Eq)] -enum BatchTitleMode { - /// No title change in the batch (only refresh strings). - None, - /// A literally-typed title. - Typed, - /// The title read from a chosen (translated) `Game.dat`. - FromGameDat, -} - -/// The Saves section's scratch state: the single-file open inputs + the loaded/editable save, and -/// the batch-update inputs. A background job hands a (re-)inspected save back via `result_rx`. -struct SavesState { - /// The `.sav` the user picked to inspect (single-file flow). - sav_path: Option, - /// The loaded, editable save (None until Inspect runs / after a clear). - loaded: Option, - /// The string-table filter (matches original or edited, case-insensitive). - filter: String, - /// Back up the original before writing (default ON), for both single + batch flows. - backup: bool, - /// Receiver for a running single-file job's heavy result (the inspected save). - result_rx: Option>, - - // --- batch flow --- - /// The save folder to batch-update. - batch_dir: Option, - /// Where the batch title comes from. - batch_title_mode: BatchTitleMode, - /// The typed batch title (used when `batch_title_mode == Typed`). - batch_title: String, - /// The translated `Game.dat` to read a title from (used when `batch_title_mode == FromGameDat`). - batch_game_dat: Option, - /// Optional translation file/dir(s) to refresh baked strings from in the batch. - batch_translations: Option, -} - -impl Default for SavesState { - fn default() -> Self { - Self { - sav_path: None, - loaded: None, - filter: String::new(), - backup: true, - result_rx: None, - batch_dir: None, - batch_title_mode: BatchTitleMode::None, - batch_title: String::new(), - batch_game_dat: None, - batch_translations: None, - } - } -} - -/// The application. `eframe` calls [`eframe::App::update`] each frame. -pub struct WolfDawnApp { - /// The active navigation section. - section: Section, - /// The open game, if any. - project: Option, - /// The scrolling activity log (bottom panel). - log: Log, - /// Background-job manager (progress bar + worker threads). - jobs: JobManager, - /// Per-section UI state. - state: SectionState, - /// Persisted user preferences (theme + section defaults + auto-detect gate). - settings: Settings, - /// When the open project's file index was last re-walked, for throttling the live auto-detect - /// in [`render_frame`](Self::render_frame). `None` until the first tick (or while no project). - last_scan: Option, - /// The output dir of an in-flight Unpack job, if any. Set when an Unpack starts. Consumed when - /// it finishes to immediately rescan the index (and adopt the dir as `data_dir` when it sits - /// under the project root) so the freshly-extracted files show in the dropdowns at once. - pending_unpack_out: Option, - /// Whether a job was running on the previous frame, so the per-frame tick can detect the - /// running→idle transition (job-just-finished) without a completion callback in `JobManager`. - job_was_running: bool, -} - -impl Default for WolfDawnApp { - fn default() -> Self { - let mut log = Log::new(); - log.info("WolfDawn Studio ready. Open a game from the Project section to begin."); - Self { - section: Section::Project, - project: None, - log, - jobs: JobManager::new(), - state: SectionState::default(), - settings: Settings::default(), - last_scan: None, - pending_unpack_out: None, - job_was_running: false, - } - } -} - -impl WolfDawnApp { - /// The eframe storage key the persisted [`Settings`] live under. - const SETTINGS_KEY: &'static str = "settings"; - - /// Construct the app for an eframe creation context. Installs a Japanese fallback font so CJK - /// text renders instead of tofu, loads the persisted [`Settings`] - /// (theme + defaults) from eframe storage, applies the theme, and seeds the sections' option - /// flags from the saved defaults so a returning user gets their preferences on startup. - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - install_cjk_font(&cc.egui_ctx); - let mut app = Self::default(); - // Restore the saved settings (falls back to defaults the first run / if storage is empty). - if let Some(storage) = cc.storage { - if let Some(saved) = eframe::get_value::(storage, Self::SETTINGS_KEY) { - app.settings = saved; - } - } - app.apply_theme(&cc.egui_ctx); - app.seed_section_defaults(); - app - } - - /// Apply the current theme preference to the egui context (called on startup + on every toggle). - fn apply_theme(&self, ctx: &egui::Context) { - ctx.set_visuals(if self.settings.dark_mode { - egui::Visuals::dark() - } else { - egui::Visuals::light() - }); - } - - /// Seed the sections' option flags from the persisted [`Settings`] defaults. Called once on - /// startup (and after a "reset" could be added later); sections then own those flags for the - /// session, so toggling a section checkbox is never clobbered by the settings default. - fn seed_section_defaults(&mut self) { - self.state.translation.en_punct = self.settings.default_en_punct; - self.state.translation.allow_code_drift = self.settings.default_allow_code_drift; - self.state.saves.backup = self.settings.default_backup; - self.state.database.backup = self.settings.default_backup; - self.state.gamedat.backup = self.settings.default_backup; - } - - // --- the section dispatch ------------------------------------------------------------- - - /// Render the body of the currently-selected section into `ui`. Public so the headless smoke - /// test can drive each section without a window. - pub fn render_current_section(&mut self, ui: &mut egui::Ui) { - match self.section { - Section::Project => self.ui_project(ui), - Section::Archive => self.ui_archive(ui), - Section::Decompile => self.ui_decompile(ui), - Section::Database => self.ui_database(ui), - Section::GameDat => self.ui_gamedat(ui), - Section::Translation => self.ui_translation(ui), - Section::Saves => self.ui_saves(ui), - Section::Verify => self.ui_verify(ui), - Section::Settings => self.ui_settings(ui), - } - } - - /// Switch the active section. The sidebar sets it inline. This is the programmatic entry the - /// smoke tests use to walk every section. - #[allow(dead_code)] - pub fn set_section(&mut self, section: Section) { - self.section = section; - } - - /// Install a translation model into the Translation section (test-only entry so the headless - /// render smoke test can exercise the grid with a loaded model, not just the empty state). - #[cfg(test)] - pub fn set_translation_model(&mut self, model: TranslationModel) { - self.state.translation.selected = if model.files.is_empty() { - SelectedEntry::Names - } else { - SelectedEntry::File(0) - }; - self.state.translation.model = Some(model); - } - - /// Install a loaded save into the Saves section (test-only entry so the headless render smoke - /// test can exercise the save editor + string table with a real loaded save). - #[cfg(test)] - pub fn set_loaded_save(&mut self, loaded: LoadedSave) { - self.state.saves.loaded = Some(loaded); - } - - /// Install WolfScript + its base into the Decompile section (test-only entry so the headless - /// render smoke test can exercise the code editor + Compile controls with real content). - #[cfg(test)] - pub fn set_decompile_script(&mut self, input: PathBuf, script: String) { - self.state.decompile.base = Some(input.clone()); - self.state.decompile.input = Some(input); - self.state.decompile.script = script; - } - - /// Open the Decompile find bar with `query` (test-only entry so the headless render smoke test - /// can exercise the find bar + a query on a loaded script without panicking). - #[cfg(test)] - pub fn open_decompile_find(&mut self, query: &str) { - self.state.decompile.find.open = true; - self.state.decompile.find.query = query.to_string(); - // Trigger an initial jump-to-first-match, the way a fresh query would. - let matches = decompile::find_matches( - &self.state.decompile.script, - query, - self.state.decompile.find.case_sensitive, - ); - if let Some(m) = matches.first() { - self.state.decompile.find.pending_select = Some((m.start, m.end)); - } - } - - /// Install a loaded Game.dat model into the Game.dat section (test-only entry so the headless - /// render smoke test can exercise the field form with a real loaded file). - #[cfg(test)] - pub fn set_gamedat_loaded(&mut self, loaded: LoadedGameDat) { - self.state.gamedat.loaded = Some(loaded); - } - - /// Install a loaded database model into the Database section (test-only entry so the headless - /// render smoke test can exercise the grid with a real loaded DB). - #[cfg(test)] - pub fn set_database_loaded(&mut self, loaded: DbModel) { - self.state.database.loaded = Some(loaded); - } - - // --- Unload / clear (free a section's loaded data so the RAM returns to the OS) ------------- - // Dropping the model frees its memory; with the mimalloc allocator that memory is handed back to - // the OS, so unloading a file you are done with actually lowers the process footprint. Paths are - // kept so the file can be reloaded with one click. - - /// Drop the loaded database grid (its rows/cells). Discards unsaved edits. - fn unload_database(&mut self) { - self.state.database.loaded = None; - self.state.database.selected_type = 0; - self.state.database.filter.clear(); - } - - /// Drop the decompiled script (and its editor layout). Discards unsaved edits. - fn clear_decompile(&mut self) { - self.state.decompile.script = String::new(); - self.state.decompile.find = FindState::default(); - } - - /// Drop the loaded translation model (every file's rows + docs + the glossary). Discards unsaved - /// edits. The source folder is kept so Extract can rebuild it. - fn unload_translation(&mut self) { - self.state.translation.model = None; - self.state.translation.conflicts.clear(); - self.state.translation.selected = SelectedEntry::Names; - self.state.translation.filter.clear(); - } - - /// Drop the loaded Game.dat model. Discards unsaved edits. - fn unload_gamedat(&mut self) { - self.state.gamedat.loaded = None; - } - - /// Drop the loaded save. Discards unsaved edits. - fn unload_save(&mut self) { - self.state.saves.loaded = None; - } - - /// Install an open project (test-only entry so the headless render smoke test can exercise the - /// per-section dropdowns + the Settings section with a project loaded). - #[cfg(test)] - pub fn set_project(&mut self, project: Project) { - self.project = Some(project); - } - - // --- Project section ------------------------------------------------------------------ - - fn ui_project(&mut self, ui: &mut egui::Ui) { - ui.heading("Project"); - widgets::caption( - ui, - "Open a Wolf RPG game to read its Game.dat and locate Data.wolf / the unpacked Data folder.", - ); - ui.add_space(8.0); - - ui.horizontal(|ui| { - if ui - .button("Open Game…") - .on_hover_text("Pick the game folder, or a Data.wolf / Game.dat file.") - .clicked() - { - if let Some(picked) = widgets::pick_folder() { - self.open_from(picked); - } - } - if ui - .button("Open File…") - .on_hover_text("Pick a Data.wolf archive or a Game.dat directly.") - .clicked() - { - if let Some(picked) = widgets::pick_file(&[ - ("Wolf archive / Game data", &["wolf", "dat"]), - ("All files", &["*"]), - ]) { - self.open_from(picked); - } - } - if self.project.is_some() { - if ui - .button("Refresh") - .on_hover_text("Re-scan the game's folders now for newly-added maps / databases / saves.") - .clicked() - { - self.refresh_project_index(); - } - if ui.button("Close").on_hover_text("Close the open game.").clicked() { - self.project = None; - self.last_scan = None; - self.log.info("closed project"); - } - } - }); - - ui.add_space(12.0); - ui.separator(); - ui.add_space(8.0); - - match &self.project { - None => { - widgets::caption(ui, "No project open."); - } - Some(p) => project_details(ui, p), - } - } - - /// Open a project from a picked path (synchronous, Game.dat parsing is fast). Routes the - /// outcome notes into the log. - fn open_from(&mut self, picked: PathBuf) { - self.log.info(format!("opening {}…", picked.display())); - let outcome = project::open_project(&picked); - for (level, msg) in outcome.notes { - match level { - NoteLevel::Info => self.log.info(msg), - NoteLevel::Warn => self.log.warn(msg), - } - } - self.project = Some(outcome.project); - // A fresh open resets the auto-detect clock so the next tick rescans promptly. - self.last_scan = None; - } - - /// Manually re-scan the open project's index now (the Project section's Refresh button), - /// logging whether anything changed. - fn refresh_project_index(&mut self) { - let Some(project) = self.project.as_mut() else { - return; - }; - if project.rescan() { - self.log.info(format!( - "refreshed: {} code file(s), {} database(s), {} save(s)", - project.code_files.len(), - project.databases.len(), - project.saves.len(), - )); - } else { - self.log.info("refreshed: no changes"); - } - self.last_scan = Some(Instant::now()); - } - - // --- Archive section: unpack a .wolf / repack a folder --------------------------------- - - fn ui_archive(&mut self, ui: &mut egui::Ui) { - ui.heading("Archive"); - widgets::caption( - ui, - "Unpack Data.wolf to an editable folder, or repack a folder back into a Data.wolf.", - ); - widgets::caption( - ui, - "Most workflows run on the loose, unpacked Data folder; encryption/version only matter \ - if you are shipping an encrypted archive.", - ); - ui.add_space(6.0); - - // Seed the unpack archive + the repack folder from the open project the first time we show. - self.archive_default_paths(); - - // Sub-panel selector (Unpack / Unpack all / Repack). - ui.horizontal(|ui| { - ui.selectable_value(&mut self.state.archive.mode, ArchiveMode::Unpack, "Unpack") - .on_hover_text("Extract one Data.wolf to an editable folder."); - ui.selectable_value(&mut self.state.archive.mode, ArchiveMode::UnpackAll, "Unpack all") - .on_hover_text( - "Some games split their data across many .wolf files (BasicData.wolf, \ - MapData.wolf, Evtext.wolf, ...). This unpacks every archive in a folder at once.", - ); - ui.selectable_value(&mut self.state.archive.mode, ArchiveMode::Repack, "Repack") - .on_hover_text("Rebuild Data.wolf from a folder."); - }); - ui.add_space(6.0); - ui.separator(); - ui.add_space(6.0); - - match self.state.archive.mode { - ArchiveMode::Unpack => self.archive_unpack_pane(ui), - ArchiveMode::UnpackAll => self.archive_unpack_all_pane(ui), - ArchiveMode::Repack => self.archive_repack_pane(ui), - } - } - - /// Seed the unpack archive + the repack input folder from the open project (its `data_wolf` / - /// `root`), once, so a project user has sensible defaults. - fn archive_default_paths(&mut self) { - if self.state.archive.unpack_archive.is_none() { - if let Some(p) = self.project.as_ref().and_then(|p| p.data_wolf.clone()) { - self.state.archive.unpack_archive = Some(p); - } - } - if self.state.archive.repack_in.is_none() { - if let Some(d) = self.project.as_ref().and_then(|p| p.data_dir.clone()) { - self.state.archive.repack_in = Some(d); - } - } - // Unpack-all defaults to the folder holding the archives: the project data dir, else the - // folder the single Data.wolf sits in. - if self.state.archive.unpack_all_dir.is_none() { - let guess = self - .project - .as_ref() - .and_then(|p| p.data_dir.clone()) - .or_else(|| { - self.project - .as_ref() - .and_then(|p| p.data_wolf.as_ref()) - .and_then(|w| w.parent().map(std::path::Path::to_path_buf)) - }); - if let Some(d) = guess { - self.state.archive.unpack_all_dir = Some(d); - } - } - } - - /// The Unpack pane: pick a `.wolf` + an output folder (default `/Data`), then Unpack. - fn archive_unpack_pane(&mut self, ui: &mut egui::Ui) { - let mut archive = self.state.archive.unpack_archive.clone(); - let changed = widgets::path_field( - ui, - "Archive", - &mut archive, - "Unpack = extract Data.wolf to an editable folder. Pick the .wolf to extract.", - || widgets::pick_file(&[("Wolf archive", &["wolf"]), ("All files", &["*"])]), - ); - self.state.archive.unpack_archive = archive; - // Default the output folder to /Data when a fresh archive is picked (or first show). - if (changed || self.state.archive.unpack_out.is_none()) - && self.state.archive.unpack_archive.is_some() - { - if let Some(arc) = &self.state.archive.unpack_archive { - let default_out = arc.parent().map(|d| d.join("Data")); - if self.state.archive.unpack_out.is_none() || changed { - self.state.archive.unpack_out = default_out; - } - } - } - - let start = self.state.archive.unpack_out.clone(); - let mut out = self.state.archive.unpack_out.clone(); - widgets::path_field( - ui, - "Output folder", - &mut out, - "Where to extract the archive's files (its inner folder tree is preserved).", - move || widgets::pick_folder_in(start.as_deref()), - ); - self.state.archive.unpack_out = out; - - ui.add_space(8.0); - let running = self.jobs.is_running(); - let ready = self.state.archive.unpack_archive.is_some() - && self.state.archive.unpack_out.is_some(); - ui.add_enabled_ui(ready && !running, |ui| { - if ui - .button("Unpack ▶") - .on_hover_text("Extract every file from the archive into the output folder on a background thread.") - .clicked() - { - self.start_unpack_job(); - } - }); - if !ready { - widgets::caption(ui, "Pick a .wolf archive and an output folder to enable Unpack."); - } - } - - /// The Unpack-all pane: pick the folder of `.wolf` archives and an output root, then unpack - /// every archive at once. Each `Name.wolf` becomes a `Name/` folder under the output, the loose - /// per-category tree the game loads and the Translation section can read. - fn archive_unpack_all_pane(&mut self, ui: &mut egui::Ui) { - let start_dir = self.state.archive.unpack_all_dir.clone(); - let mut dir = self.state.archive.unpack_all_dir.clone(); - let changed = widgets::path_field( - ui, - "Archives folder", - &mut dir, - "The folder holding the game's .wolf archives (its Data folder). Every .wolf in it is unpacked.", - move || widgets::pick_folder_in(start_dir.as_deref()), - ); - self.state.archive.unpack_all_dir = dir; - // Default the output root to the archives folder itself (unpack in place, the layout the - // game loads), refreshed when a new folder is picked. - if (changed || self.state.archive.unpack_all_out.is_none()) - && self.state.archive.unpack_all_dir.is_some() - { - self.state.archive.unpack_all_out = self.state.archive.unpack_all_dir.clone(); - } - - let start_out = self.state.archive.unpack_all_out.clone(); - let mut out = self.state.archive.unpack_all_out.clone(); - widgets::path_field( - ui, - "Output root", - &mut out, - "Where the per-archive folders are written. Defaults to the archives folder, so the \ - loose files sit beside the .wolf and the game loads them.", - move || widgets::pick_folder_in(start_out.as_deref()), - ); - self.state.archive.unpack_all_out = out; - - ui.add_space(8.0); - let running = self.jobs.is_running(); - let ready = self.state.archive.unpack_all_dir.is_some() - && self.state.archive.unpack_all_out.is_some(); - ui.add_enabled_ui(ready && !running, |ui| { - if ui - .button("Unpack all ▶") - .on_hover_text("Unpack every .wolf in the folder, each into its own subfolder, on a background thread.") - .clicked() - { - self.start_unpack_all_job(); - } - }); - if !ready { - widgets::caption(ui, "Pick the folder of .wolf archives and an output root to enable Unpack all."); - } - } - - /// The Repack pane: pick a folder + output `.wolf`, set the encrypt/version/like/format options - /// (mirroring the CLI `pack` flags), then Repack. - fn archive_repack_pane(&mut self, ui: &mut egui::Ui) { - let start_in = self.state.archive.repack_in.clone(); - let mut input = self.state.archive.repack_in.clone(); - widgets::path_field( - ui, - "Input folder", - &mut input, - "Repack = rebuild Data.wolf from a folder. Pick the folder of loose game files to pack.", - move || widgets::pick_folder_in(start_in.as_deref()), - ); - self.state.archive.repack_in = input; - - let mut out = self.state.archive.repack_out.clone(); - widgets::path_field( - ui, - "Output .wolf", - &mut out, - "Where to write the rebuilt archive.", - || widgets::save_file("Data.wolf", &[("Wolf archive", &["wolf"])]), - ); - self.state.archive.repack_out = out; - - ui.add_space(8.0); - // Format dropdown (auto / ver5 / ver6) in a shared form row so its label lines up with - // the path rows above. - widgets::form_row( - ui, - "Format", - "auto = the modern Data.wolf container. ver5/ver6 are legacy DXArchive containers for \ - old Wolf 2.0x games - leave on auto unless you specifically need them.", - |ui| { - egui::ComboBox::from_id_salt("repack_format") - .selected_text(self.state.archive.repack_format.label()) - .show_ui(ui, |ui| { - for f in [PackFormat::Auto, PackFormat::Ver5, PackFormat::Ver6] { - ui.selectable_value(&mut self.state.archive.repack_format, f, f.label()); - } - }); - }, - ); - - // Encryption is only meaningful for the modern (auto) container. - let auto = self.state.archive.repack_format == PackFormat::Auto; - ui.add_enabled_ui(auto, |ui| { - widgets::labeled_checkbox( - ui, - &mut self.state.archive.repack_encrypt, - "Encrypt", - "Encrypt the output archive. Usually only needed when shipping an encrypted \ - Data.wolf; most workflows leave this off and run the game on a loose Data folder.", - ); - }); - - // The crypt-source radio + its inputs, shown only when encrypting an auto archive. - if auto && self.state.archive.repack_encrypt { - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.radio_value( - &mut self.state.archive.repack_crypt, - RepackCryptMode::Default, - "default version", - ) - .on_hover_text("Use the standard default cryptVersion."); - ui.radio_value( - &mut self.state.archive.repack_crypt, - RepackCryptMode::Version, - "match version", - ) - .on_hover_text("Type a specific cryptVersion (e.g. 0x14b)."); - ui.radio_value( - &mut self.state.archive.repack_crypt, - RepackCryptMode::Like, - "like existing .wolf", - ) - .on_hover_text("Inherit the cryptVersion + embedded password from an original archive (turnkey repack)."); - }); - match self.state.archive.repack_crypt { - RepackCryptMode::Default => {} - RepackCryptMode::Version => { - widgets::form_row(ui, "Version", "", |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.state.archive.repack_version_text) - .hint_text("0x14b") - .desired_width(120.0), - ) - .on_hover_text("A cryptVersion as hex (0x14b) or decimal."); - }); - if parse_crypt_version(&self.state.archive.repack_version_text).is_none() { - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - "Warning: not a valid version number", - ); - } - } - RepackCryptMode::Like => { - let mut like = self.state.archive.repack_like.clone(); - widgets::path_field( - ui, - "Original .wolf", - &mut like, - "An existing encrypted archive to copy the crypto from.", - || widgets::pick_file(&[("Wolf archive", &["wolf"]), ("All files", &["*"])]), - ); - self.state.archive.repack_like = like; - } - } - } - - ui.add_space(8.0); - let running = self.jobs.is_running(); - let ready = self.repack_ready(); - ui.add_enabled_ui(ready && !running, |ui| { - if ui - .button("Repack ▶") - .on_hover_text("Rebuild the archive from the folder on a background thread.") - .clicked() - { - self.start_repack_job(); - } - }); - if !ready { - widgets::caption( - ui, - "Pick an input folder and an output .wolf (and, for \"like\"/\"match\", a valid \ - source/version) to enable Repack.", - ); - } - } - - /// True when the Repack inputs are complete enough to run (folder + output, and a resolvable - /// crypt source when encrypting). - fn repack_ready(&self) -> bool { - let a = &self.state.archive; - if a.repack_in.is_none() || a.repack_out.is_none() { - return false; - } - if a.repack_format == PackFormat::Auto && a.repack_encrypt { - return match a.repack_crypt { - RepackCryptMode::Default => true, - RepackCryptMode::Version => parse_crypt_version(&a.repack_version_text).is_some(), - RepackCryptMode::Like => a.repack_like.is_some(), - }; - } - true - } - - /// Spawn the Unpack job: extract the archive into the output folder on a worker thread, reusing - /// the library's `extract_archive` via [`archive::unpack`]. Bad/odd filenames are sanitised and - /// reported, never written outside the output folder. - fn start_unpack_job(&mut self) { - let Some(arc) = self.state.archive.unpack_archive.clone() else { - return; - }; - let Some(out) = self.state.archive.unpack_out.clone() else { - return; - }; - // Remember the output dir so the running→idle transition can rescan it (and adopt it as the - // project's data_dir when it sits under the project root) the moment the job finishes. - self.pending_unpack_out = Some(out.clone()); - self.jobs.run(format!("Unpack {}", arc.display()), &mut self.log, move |rep| { - let outcome = archive::unpack(&arc, &out, |frac, msg| rep.progress(frac, msg))?; - for (orig, safe) in &outcome.sanitised { - if safe.is_empty() { - rep.warn(format!("skipped odd archive entry {orig:?} (no safe path)")); - } else { - rep.warn(format!("sanitised archive path {orig:?} → {safe:?}")); - } - } - rep.info(format!("{} file(s) → {}", outcome.written, outcome.out_dir.display())); - Ok(format!("{} file(s) → {}", outcome.written, outcome.out_dir.display())) - }); - } - - /// Spawn the Unpack-all job: unpack every `.wolf` in the chosen folder into per-archive - /// subfolders on a worker thread, reusing [`archive::unpack_all`]. Each archive's result is - /// logged, and the output root is rescanned when the job finishes (so new files show up). - fn start_unpack_all_job(&mut self) { - let Some(dir) = self.state.archive.unpack_all_dir.clone() else { - return; - }; - let Some(out) = self.state.archive.unpack_all_out.clone() else { - return; - }; - self.pending_unpack_out = Some(out.clone()); - self.jobs.run(format!("Unpack all in {}", dir.display()), &mut self.log, move |rep| { - let outcome = archive::unpack_all(&dir, &out, |frac, msg| rep.progress(frac, msg))?; - for (name, res) in &outcome.details { - match res { - Ok(n) => rep.info(format!("{name} → {n} file(s)")), - Err(e) => rep.warn(format!("{name} failed: {e}")), - } - } - let summary = if outcome.failed > 0 { - format!( - "{} archive(s), {} file(s) → {} ({} failed)", - outcome.ok, - outcome.files, - outcome.out_root.display(), - outcome.failed - ) - } else { - format!( - "{} archive(s), {} file(s) → {}", - outcome.ok, - outcome.files, - outcome.out_root.display() - ) - }; - Ok(summary) - }); - } - - /// Spawn the Repack job: gather the folder + pack it with the chosen options on a worker thread, - /// reusing the library `pack_*` paths via [`archive::repack`]. - fn start_repack_job(&mut self) { - let a = &self.state.archive; - let Some(input) = a.repack_in.clone() else { - return; - }; - let Some(out) = a.repack_out.clone() else { - return; - }; - // Assemble the PackOptions from the UI (resolve the crypt source). - let crypt = match a.repack_crypt { - RepackCryptMode::Default => CryptSource::Default, - RepackCryptMode::Version => match parse_crypt_version(&a.repack_version_text) { - Some(v) => CryptSource::Version(v), - None => { - self.log.error("repack: invalid cryptVersion"); - return; - } - }, - RepackCryptMode::Like => match a.repack_like.clone() { - Some(p) => CryptSource::Like(p), - None => { - self.log.error("repack: pick an original .wolf for \"like existing\""); - return; - } - }, - }; - let opts = PackOptions { - encrypt: a.repack_encrypt, - crypt, - format: a.repack_format, - }; - self.jobs.run(format!("Repack → {}", out.display()), &mut self.log, move |rep| { - let outcome = archive::repack(&input, &out, &opts, |frac, msg| rep.progress(frac, msg))?; - Ok(format!( - "{} file(s) → {} ({} bytes, {})", - outcome.files, - outcome.out.display(), - outcome.bytes, - outcome.mode - )) - }); - } - - // --- stubbed sections ----------------------------------------------------------------- - - fn ui_decompile(&mut self, ui: &mut egui::Ui) { - // Pump any background result (the freshly-decompiled WolfScript) into section state first, so - // the rest of this frame renders what the just-finished job produced. - self.poll_decompile_result(); - - ui.heading("Decompile"); - widgets::caption(ui, "Render maps and common events to readable, editable WolfScript - and recompile byte-exact."); - ui.add_space(6.0); - - // Ctrl+F toggles the find bar (only meaningful once a script is loaded). Consuming the - // shortcut here keeps it working from anywhere in the section. - let have_script = !self.state.decompile.script.is_empty(); - if have_script - && ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::F)) - { - let f = &mut self.state.decompile.find; - f.open = true; - f.focus_query = true; // grab the query field next frame so the user can type at once. - } - - // Default the input to the open project's data dir (no file pre-selected) the first time. - self.decompile_input_row(ui); - ui.add_space(6.0); - self.decompile_action_row(ui); - ui.add_space(6.0); - ui.separator(); - ui.add_space(6.0); - self.decompile_editor(ui); - } - - /// The input field + the Decompile button. - fn decompile_input_row(&mut self, ui: &mut egui::Ui) { - // A project dropdown of the scanned code files (MapData/*.mps + CommonEvent.dat), above the - // Browse field for files outside the project. - if let Some(code) = self.project.as_ref().map(|p| p.code_files.clone()) { - let mut input = self.state.decompile.input.clone(); - if project_combo( - ui, - "decompile_combo", - "Project file", - &code, - &mut input, - "Pick a map / CommonEvent from the open game (or use Browse below for a file outside it).", - ) { - self.state.decompile.input = input; - } - } - - // Seed the picker's start dir from the open project's data dir. Don't auto-pick a file, - // since there are many candidate .mps maps and the user chooses. - let start = self - .project - .as_ref() - .and_then(|p| p.data_dir.clone()); - let mut input = self.state.decompile.input.clone(); - widgets::path_field( - ui, - "Input", - &mut input, - "Decompile = turn a map/common-event into readable WolfScript you can edit. Pick a .mps \ - map or CommonEvent.dat.", - move || { - widgets::pick_file_in( - start.as_deref(), - &[("Wolf event file", &["mps", "dat"]), ("All files", &["*"])], - ) - }, - ); - self.state.decompile.input = input; - - // Warn early if the pick isn't a code-bearing file. Decompile still runs and gives a clear error. - if let Some(f) = &self.state.decompile.input { - if !decompile::is_supported(f) { - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - "Warning: not a decompilable file (decompile supports .mps maps and CommonEvent.dat).", - ); - } - } - } - - /// The action row: Decompile, plus Open/Save .wscript and the Compile controls. - fn decompile_action_row(&mut self, ui: &mut egui::Ui) { - let running = self.jobs.is_running(); - let have_input = self.state.decompile.input.is_some(); - ui.horizontal(|ui| { - ui.add_enabled_ui(have_input && !running, |ui| { - if ui - .button("Decompile ▶") - .on_hover_text("Decompile the input to editable WolfScript on a background thread.") - .clicked() - { - self.start_decompile_job(); - } - }); - ui.add_enabled_ui(!running, |ui| { - if ui - .button("Open .wscript…") - .on_hover_text("Load an existing WolfScript document to compile (its base is the Input above).") - .clicked() - { - self.decompile_open_script(); - } - if !self.state.decompile.script.is_empty() - && ui - .button("Save .wscript…") - .on_hover_text("Export the current WolfScript text to a .wscript file.") - .clicked() - { - self.decompile_save_script(); - } - if !self.state.decompile.script.is_empty() - && ui - .button("Clear") - .on_hover_text("Close the script and free its memory (a large file's editor layout is the heaviest thing the GUI holds). Discards unsaved edits.") - .clicked() - { - self.clear_decompile(); - } - }); - }); - - // The Compile row: output target + the Compile button (needs a script + a base). - let mut out = self.state.decompile.out.clone(); - let start = out.clone().or_else(|| self.state.decompile.input.clone()); - widgets::path_field( - ui, - "Compile to", - &mut out, - "Where Compile writes the recompiled file (defaults to .out).", - move || widgets::save_file( - &start - .as_ref() - .and_then(|p| p.file_name().and_then(|s| s.to_str())) - .map(|n| format!("{n}.out")) - .unwrap_or_else(|| "compiled.out".to_string()), - &[("Wolf event file", &["mps", "dat", "out"]), ("All files", &["*"])], - ), - ); - self.state.decompile.out = out; - - let have_script = !self.state.decompile.script.is_empty(); - let have_base = self.state.decompile.base.is_some() || self.state.decompile.input.is_some(); - ui.add_enabled_ui(have_script && have_base && !running, |ui| { - if ui - .button("Compile ▶") - .on_hover_text("Compile needs the original file as a base so unknown bytes are preserved. Recompiles the edited WolfScript onto the base.") - .clicked() - { - self.start_compile_job(); - } - }); - if !have_script { - widgets::caption(ui, "Decompile a file (or Open a .wscript) to enable Compile."); - } else if !have_base { - widgets::caption(ui, "Pick the original Input file (the compile base) to enable Compile."); - } - } - - /// The WolfScript editor: a large monospace code editor inside a scroll area, with a line count. - /// - /// Perf: egui re-lays-out the whole `TextEdit` buffer every frame, and word-wrapping is the - /// dominant cost. Wrapping forces per-glyph width measurement of every line against the viewport - /// width on each layout pass. Giving the editor an infinite desired width inside a **both-axis** - /// `ScrollArea` disables wrapping, so each line lays out at its natural width with no wrap-point - /// search (horizontal scrolling instead). For code this is also the correct behaviour (lines - /// shouldn't reflow). On a big CommonEvent (thousands of lines) this is much cheaper than a - /// vertical-only scroll that clamps the editor to the viewport width and wraps every line each - /// frame. No re-decompile or buffer re-allocation happens per frame. Only the TextEdit's own - /// galley layout runs. - fn decompile_editor(&mut self, ui: &mut egui::Ui) { - /// Scripts at/above this size get a "large file" notice (in-app edits still work). - const LARGE_BYTES: usize = 200 * 1024; - /// …or at/above this many lines. - const LARGE_LINES: usize = 5000; - - if self.state.decompile.script.is_empty() { - widgets::caption( - ui, - "No WolfScript loaded. Pick a .mps map or CommonEvent.dat and press Decompile, or \ - Open an existing .wscript.", - ); - return; - } - let lines = self.state.decompile.script.lines().count(); - let chars = self.state.decompile.script.len(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("{lines} line(s) · {chars} bytes")).strong()); - if let Some(base) = &self.state.decompile.base { - ui.separator(); - ui.label( - egui::RichText::new(format!("base: {}", base.display())).weak(), - ); - } - }); - - // For very large scripts, surface the "edit externally" escape hatch. In-app edits still - // work. Save .wscript, edit in your editor, then Open .wscript to bring it back. - if chars >= LARGE_BYTES || lines >= LARGE_LINES { - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - "Warning: large script — editing stays responsive (lines don't wrap; scroll horizontally). \ - For heavy edits you can Save .wscript, edit it externally, then Open .wscript.", - ); - } - // The find-in-editor (Ctrl+F) bar sits just above the editor. It also returns the pending - // match (a char range) to select + scroll to in this frame's TextEdit. - self.decompile_find_bar(ui); - - ui.add_space(4.0); - // A generous, responsive code editor for potentially large CommonEvents. Both-axis scroll - // plus an infinite desired width disable word-wrap (the big per-frame layout cost). See the - // method doc. The CJK fallback is installed on the Monospace family (see `install_cjk_font`), - // so `.code_editor()` (which uses Monospace) renders Japanese here instead of tofu. - let pending = self.state.decompile.find.pending_select.take(); - egui::ScrollArea::both() - .auto_shrink([false, false]) - .show(ui, |ui| { - let mut output = egui::TextEdit::multiline(&mut self.state.decompile.script) - .code_editor() - .desired_rows(28) - // Infinite width means no wrap-point search. Lines lay out at natural width and - // the horizontal ScrollArea handles overflow. - .desired_width(f32::INFINITY) - .show(ui); - - // If the find bar asked to jump to a match, set the editor's selection to that char - // range and scroll it into view. We set the selection on the stored state (so egui - // shows it next frame) AND scroll directly via the galley this frame (so the jump is - // immediate and reliable regardless of focus timing). egui's own cursor-scroll only - // fires when the editor is focused and the selection changed. - if let Some((start, end)) = pending { - use egui::text::{CCursor, CCursorRange}; - let range = CCursorRange::two(CCursor::new(start), CCursor::new(end)); - output.state.cursor.set_char_range(Some(range)); - output.state.clone().store(ui.ctx(), output.response.id); - // Scroll the inner ScrollArea to the match's rect (galley-local to screen coords). - let cursor_rect = output - .galley - .pos_from_ccursor(CCursor::new(start)) - .translate(output.galley_pos.to_vec2()); - ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center)); - // Focus the editor so the selection highlight is visible. - output.response.request_focus(); - } - }); - } - - /// The find-in-editor (Ctrl+F) bar: a query field, Prev/Next, a match counter, an optional - /// case toggle, and a close (×). Recomputes the match list each frame from the script + query, and - /// (on a navigation) stashes the current match's char range in `find.pending_select` for the - /// editor to select + scroll to. Renders nothing when the bar is closed. - fn decompile_find_bar(&mut self, ui: &mut egui::Ui) { - if !self.state.decompile.find.open { - return; - } - // Compute matches up front (immutable borrow of the script), then mutate find state. - let matches = decompile::find_matches( - &self.state.decompile.script, - &self.state.decompile.find.query, - self.state.decompile.find.case_sensitive, - ); - let count = matches.len(); - - let f = &mut self.state.decompile.find; - // Keep `current` in range as matches change. - if count == 0 { - f.current = 0; - } else if f.current >= count { - f.current = count - 1; - } - - // `go` is set when the user navigates this frame: +1 next, -1 prev, 0 = jump to current - // (e.g. just after typing). We resolve it to a concrete match index at the end. - let mut nav: Option = None; - - ui.horizontal(|ui| { - ui.label("Find"); - // Enter = next, Shift+Enter = prev, handled via the response below. - let resp = ui.add( - egui::TextEdit::singleline(&mut f.query) - .hint_text("find in script") - .desired_width(220.0), - ); - if f.focus_query { - resp.request_focus(); - f.focus_query = false; - } - // On a query change, jump to the first match from the top. - if resp.changed() { - f.current = 0; - nav = Some(0); - } - if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - let shift = ui.input(|i| i.modifiers.shift); - nav = Some(if shift { -1 } else { 1 }); - // Keep typing/searching: re-focus the query field after an Enter. - f.focus_query = true; - } - - ui.add_enabled_ui(count > 0, |ui| { - if ui.button("◀ Prev").on_hover_text("Previous match (Shift+Enter).").clicked() { - nav = Some(-1); - } - if ui.button("Next ▶").on_hover_text("Next match (Enter).").clicked() { - nav = Some(1); - } - }); - - // The match counter ("3 / 17", or "0 / 0" / "no matches"). - let counter = if f.query.is_empty() { - String::new() - } else if count == 0 { - "no matches".to_string() - } else { - format!("{} / {}", f.current + 1, count) - }; - if !counter.is_empty() { - ui.label(egui::RichText::new(counter).weak()); - } - - ui.checkbox(&mut f.case_sensitive, "Aa") - .on_hover_text("Case-sensitive matching (off = case-insensitive)."); - - if ui.button("×").on_hover_text("Close find (Esc).").clicked() { - f.open = false; - } - }); - - // Esc closes the bar. - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - f.open = false; - } - - // Resolve a navigation request to a concrete match and stash its range for the editor. - if let Some(delta) = nav { - if count > 0 { - let cur = f.current as isize; - let next = (cur + delta).rem_euclid(count as isize) as usize; - f.current = next; - let m = matches[next]; - f.pending_select = Some((m.start, m.end)); - } - } - } - - // --- Decompile background jobs + sync helpers ----------------------------------------- - - /// Drain the heavy-result side-channel: a finished Decompile job hands its WolfScript here. - fn poll_decompile_result(&mut self) { - let Some(rx) = &self.state.decompile.result_rx else { - return; - }; - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(DecompileResult::Text(input, text)) => { - self.state.decompile.script = text; - // The decompiled input is the compile base (so unknown bytes are preserved). - self.state.decompile.base = Some(input.clone()); - // Default the compile output to .out the first time (or when unset). - if self.state.decompile.out.is_none() { - let mut name = input.file_name().map(|s| s.to_os_string()).unwrap_or_default(); - name.push(".out"); - self.state.decompile.out = Some(input.with_file_name(name)); - } - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - if disconnected && !self.jobs.is_running() { - self.state.decompile.result_rx = None; - } - } - - /// Spawn the Decompile job: render the input to editable WolfScript off the worker thread, hand - /// the text back via the side-channel (mirrors the CLI's `decompile --mode edit`). - fn start_decompile_job(&mut self) { - let Some(input) = self.state.decompile.input.clone() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.decompile.result_rx = Some(rx); - self.jobs.run(format!("Decompile {}", input.display()), &mut self.log, move |rep| { - rep.progress(0.2, "decompiling…"); - let text = decompile::decompile_edit(&input)?; - let lines = text.lines().count(); - let _ = tx.send(DecompileResult::Text(input.clone(), text)); - rep.progress(1.0, "done"); - Ok(format!("decompiled {} ({lines} line(s) of WolfScript)", input.display())) - }); - } - - /// Spawn the Compile job: recompile the edited WolfScript onto the stored base (the original - /// input, so unknown bytes are preserved) and write to the chosen output (mirrors the CLI's - /// `compile`). The compiled bytes are re-parsed before the write is reported. - fn start_compile_job(&mut self) { - let text = self.state.decompile.script.clone(); - let Some(base) = self - .state - .decompile - .base - .clone() - .or_else(|| self.state.decompile.input.clone()) - else { - self.log.error("compile: no base file (Decompile a file or pick the Input first)"); - return; - }; - // Default the output to .out when none was chosen. - let out = self.state.decompile.out.clone().unwrap_or_else(|| { - let mut name = base.file_name().map(|s| s.to_os_string()).unwrap_or_default(); - name.push(".out"); - base.with_file_name(name) - }); - self.jobs.run(format!("Compile → {}", out.display()), &mut self.log, move |rep| { - rep.progress(0.3, "compiling…"); - let outcome = decompile::compile_to(&text, &base, &out)?; - rep.progress(1.0, "done"); - Ok(format!( - "wrote {} ({} bytes{})", - outcome.out.display(), - outcome.bytes, - if outcome.byte_identical { ", byte-identical to base" } else { "" } - )) - }); - } - - /// Save the current WolfScript buffer to a chosen `.wscript` file (synchronous text write). - fn decompile_save_script(&mut self) { - let default = self - .state - .decompile - .base - .as_ref() - .or(self.state.decompile.input.as_ref()) - .and_then(|p| p.file_stem().and_then(|s| s.to_str())) - .map(|s| format!("{s}.wscript")) - .unwrap_or_else(|| "script.wscript".to_string()); - let Some(path) = widgets::save_file(&default, &[("WolfScript", &["wscript", "txt"])]) else { - return; - }; - match std::fs::write(&path, &self.state.decompile.script) { - Ok(()) => self.log.info(format!("saved WolfScript → {}", path.display())), - Err(e) => self.log.error(format!("save .wscript failed: {e}")), - } - } - - /// Load an existing `.wscript` into the editor (synchronous). The compile base stays the Input - /// file the user picked above (compile needs the matching original). - fn decompile_open_script(&mut self) { - let Some(path) = widgets::pick_file(&[("WolfScript", &["wscript", "txt"]), ("All files", &["*"])]) - else { - return; - }; - match std::fs::read_to_string(&path) { - Ok(text) => { - let lines = text.lines().count(); - self.state.decompile.script = text; - self.log.info(format!( - "loaded WolfScript ({lines} line(s)) from {} — set the Input above to its original file before Compile", - path.display() - )); - } - Err(e) => self.log.error(format!("open .wscript failed: {e}")), - } - } - - fn ui_database(&mut self, ui: &mut egui::Ui) { - // Pump any background result (a freshly loaded / reloaded grid model) into section state first. - self.poll_database_result(); - - // Default the path to the open project's main DataBase.project the first time we're shown. - if self.state.database.path.is_none() { - if let Some(p) = self.database_default_project() { - self.state.database.path = Some(p); - } - } - - // Layout: the heading + path row sit in a fixed top region, the Save controls in a fixed - // bottom region, and the (independently scrolling) grid fills the space between them. Using - // nested panels keeps each region in its own rectangle so the grid never draws over the - // controls (the old single-`ui` flow let the grid's inner CentralPanel claim all remaining - // height and paint on top of the Save row). - egui::TopBottomPanel::top("db_top") - .resizable(false) - .show_inside(ui, |ui| { - ui.heading("Database"); - widgets::caption( - ui, - "Database = items, skills, enemies, etc. — edit values in the grid (no JSON needed).", - ); - ui.add_space(6.0); - self.database_path_row(ui); - ui.add_space(4.0); - }); - - if self.state.database.loaded.is_none() { - egui::CentralPanel::default().show_inside(ui, |ui| { - widgets::caption( - ui, - "No database loaded. Pick a .project (a game has several: DataBase / CDataBase / \ - SysDatabase) and press Load.", - ); - }); - return; - } - - // Save controls pinned to the bottom (drawn before the central grid so the grid gets the - // remaining height and the two regions never overlap). - egui::TopBottomPanel::bottom("db_save") - .resizable(false) - .show_inside(ui, |ui| { - ui.add_space(4.0); - self.database_save_row(ui); - }); - - // The grid fills the remaining space and scrolls independently below the controls. - egui::CentralPanel::default().show_inside(ui, |ui| { - self.database_grid(ui); - }); - } - - /// The open project's `DataBase.project`, if its data dir holds one (the sensible default). - fn database_default_project(&self) -> Option { - let data_dir = self.project.as_ref().and_then(|p| p.data_dir.clone())?; - // The DB pairs live under the data dir or its BasicData/ subfolder. - for cand in [data_dir.join("DataBase.project"), data_dir.join("BasicData").join("DataBase.project")] { - if cand.exists() { - return Some(cand); - } - } - None - } - - /// The `.project` path field + Load/Reload buttons + the kind/encoding badges. - fn database_path_row(&mut self, ui: &mut egui::Ui) { - let mut path_changed = false; - // A project dropdown of the scanned databases (BasicData/*.project), above the Browse field. - if let Some(dbs) = self.project.as_ref().map(|p| p.databases.clone()) { - let mut path = self.state.database.path.clone(); - if project_combo( - ui, - "database_combo", - "Project DB", - &dbs, - &mut path, - "Pick a database (.project) from the open game: DataBase / CDataBase / SysDatabase.", - ) { - self.state.database.path = path; - self.state.database.out_project = None; - path_changed = true; - } - } - - let start = self.state.database.path.clone(); - let mut path = self.state.database.path.clone(); - let changed = widgets::path_field( - ui, - "Database", - &mut path, - "A database .project (its sibling .dat is loaded automatically). A game has several: \ - DataBase (items/skills/enemies), CDataBase (system config), SysDatabase.", - move || { - let start_dir = start.as_ref().and_then(|p| p.parent()); - widgets::pick_file_in(start_dir, &[("Database project", &["project"]), ("All files", &["*"])]) - }, - ); - self.state.database.path = path; - // Picking a fresh .project clears the previous output choice (it pointed at the old DB). - if changed { - self.state.database.out_project = None; - path_changed = true; - } - - // Auto-load the moment the selection or path changes, so switching databases does not need a - // manual Load click. Discards unsaved edits to the previous DB (it was a different table). - if path_changed && self.state.database.path.is_some() && !self.jobs.is_running() { - self.start_database_load_job(); - } - - ui.add_space(4.0); - let running = self.jobs.is_running(); - let have = self.state.database.path.is_some(); - ui.horizontal(|ui| { - ui.add_enabled_ui(have && !running, |ui| { - if ui - .button("Load") - .on_hover_text("Read the .project + .dat pair and fill the grid with its current values.") - .clicked() - { - self.start_database_load_job(); - } - }); - if self.state.database.loaded.is_some() - && ui - .button("Reload") - .on_hover_text("Discard edits and reload the values from disk.") - .clicked() - { - self.start_database_load_job(); - } - if self.state.database.loaded.is_some() - && ui - .button("Unload") - .on_hover_text("Close the database and free its memory (discards unsaved edits).") - .clicked() - { - self.unload_database(); - } - // Export the whole database to a JSON file (full structure, all rows/fields/numbers) so it - // can be edited outside the GUI, and import an edited JSON back. Exports the ON-DISK state, - // so Save first if you want grid edits included. - ui.add_enabled_ui(have && !running, |ui| { - if ui - .button("Export JSON…") - .on_hover_text("Write the full database to a .json file you can edit in any editor (Save first to include grid edits).") - .clicked() - { - if let Some(out) = widgets::save_file("database.json", &[("JSON", &["json"])]) { - self.start_database_export_job(out); - } - } - if ui - .button("Import JSON…") - .on_hover_text("Apply an edited database .json back onto the .project + .dat (the originals are backed up first), then reload.") - .clicked() - { - if let Some(json) = widgets::pick_file(&[("JSON", &["json"]), ("All files", &["*"])]) { - self.start_database_import_job(json); - } - } - }); - // The kind + encoding badges (read-only), shown once a model is loaded. - if let Some(m) = &self.state.database.loaded { - ui.separator(); - ui.label( - egui::RichText::new(format!(" {} ", m.kind)) - .strong() - .background_color(egui::Color32::from_rgb(0x40, 0x80, 0xE0)) - .color(egui::Color32::BLACK), - ) - .on_hover_text("UDB = the main user database (items/skills/enemies); CDB = system config; SDB = engine system."); - ui.separator(); - ui.label(egui::RichText::new(format!(" {} ", m.encoding)).strong()) - .on_hover_text("How the strings are stored. A Shift-JIS database can only hold characters representable in Shift-JIS."); - } - }); - } - - /// The two-pane grid: the type (table) selector on the left, the selected type's rows as an - /// editable table on the right. - fn database_grid(&mut self, ui: &mut egui::Ui) { - // Left: a selectable list of types with their row counts. - egui::SidePanel::left("db_types") - .resizable(true) - .default_width(240.0) - .show_inside(ui, |ui| { - ui.label(egui::RichText::new("Types (tables)").strong()) - .on_hover_text("Each type is a table: Skill, Item, Enemy, … Pick one to edit its rows."); - ui.add_space(2.0); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - let db = &mut self.state.database; - let Some(model) = &db.loaded else { return }; - if db.selected_type >= model.types.len() { - db.selected_type = 0; - } - for (i, t) in model.types.iter().enumerate() { - let edited = t.changed_count(); - let mut label = format!("{} ({} rows)", t.name, t.rows.len()); - if edited > 0 { - // The edit badge stays on the row (it wraps with the rest of the text). - label.push_str(&format!(" ● {edited}")); - } - // A custom full-width row that allocates at the wrapped text height and paints - // a rounded, padded highlight, so a two-line type name is fully contained. - if selectable_row(ui, db.selected_type == i, &label).clicked() { - db.selected_type = i; - db.filter.clear(); - } - } - }); - }); - - // Right: the selected type's filter/counter bar + the editable rows table. - egui::CentralPanel::default().show_inside(ui, |ui| { - self.database_rows_pane(ui); - }); - } - - /// The right pane: a filter box over row names, a row counter, and the editable rows table - /// (first column = the row name, then one column per data field). - fn database_rows_pane(&mut self, ui: &mut egui::Ui) { - use egui_extras::{Column, TableBuilder}; - - let db = &mut self.state.database; - let Some(model) = &mut db.loaded else { return }; - let Some(t) = model.types.get_mut(db.selected_type) else { - widgets::caption(ui, "Select a type on the left."); - return; - }; - - // Filter/counter bar. - let total = t.rows.len(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&t.name).strong()); - ui.separator(); - ui.label("Filter:"); - ui.add( - egui::TextEdit::singleline(&mut db.filter) - .hint_text("search row names") - .desired_width(200.0), - ); - ui.separator(); - ui.label(egui::RichText::new(format!("{total} row(s)")).strong()); - }); - ui.add_space(4.0); - - if t.fields.is_empty() { - widgets::caption(ui, "This type has no editable data fields."); - return; - } - - // Which row indices pass the (row-name) filter. When no filter is active we skip building an - // index Vec entirely and address rows directly, so the common case has no per-frame O(rows) - // allocation or scan on top of the now-virtualized body. - let needle = db.filter.to_lowercase(); - let filtered: Option> = if needle.is_empty() { - None - } else { - Some( - t.rows - .iter() - .enumerate() - .filter(|(_, r)| r.name.to_lowercase().contains(&needle)) - .map(|(i, _)| i) - .collect(), - ) - }; - let count = filtered.as_ref().map_or(t.rows.len(), Vec::len); - if count == 0 { - widgets::caption(ui, "No rows match the current filter."); - return; - } - - let text_h = egui::TextStyle::Body.resolve(ui.style()).size; - let field_labels = t.fields.clone(); - // The egui_extras Table only scrolls vertically. A type with many fields is wider than the - // pane, so wrap the whole table in a horizontal ScrollArea: the outer area pans sideways - // while the Table's own vertical scroll keeps the rows virtualized. Bound the table height to - // the space left so the inner vertical scroll has a viewport to virtualize against. - let avail_h = ui.available_height(); - egui::ScrollArea::horizontal() - .auto_shrink([false, false]) - .show(ui, |ui| { - let mut builder = TableBuilder::new(ui) - .striped(true) - .resizable(true) - .max_scroll_height(avail_h) - .cell_layout(egui::Layout::left_to_right(egui::Align::TOP)) - .column(Column::auto().at_least(40.0)) // id - .column(Column::initial(160.0).at_least(90.0)); // name - for _ in &field_labels { - builder = builder.column(Column::initial(130.0).at_least(70.0)); - } - builder - .header(text_h + 6.0, |mut header| { - header.col(|ui| { - ui.strong("id"); - }); - header.col(|ui| { - ui.strong("name").on_hover_text( - "The row's display name. Tip: translating names is best done in the \ - Translation section's glossary so by-name lookups stay consistent.", - ); - }); - for label in &field_labels { - header.col(|ui| { - ui.strong(label.as_str()).on_hover_text(label.as_str()); - }); - } - }) - // Virtualized body: egui_extras only builds widgets for the rows actually on - // screen, so a type with thousands of rows no longer lays out thousands of - // TextEdits every frame. `r.index()` indexes into the filtered set. - .body(|body| { - body.rows(text_h * 2.0, count, |mut r| { - let ri = filtered.as_ref().map_or(r.index(), |v| v[r.index()]); - let row = &mut t.rows[ri]; - r.col(|ui| { - ui.add( - egui::Label::new( - egui::RichText::new(row.id.to_string()).weak().small(), - ) - .wrap(), - ); - }); - r.col(|ui| { - ui.add( - egui::TextEdit::singleline(&mut row.name) - .desired_width(f32::INFINITY), - ); - }); - for cell in row.cells.iter_mut() { - r.col(|ui| { - let hint = if cell.is_string { "" } else { "(number)" }; - ui.add( - egui::TextEdit::singleline(&mut cell.value) - .hint_text(hint) - .desired_width(f32::INFINITY), - ); - }); - } - }); - }); - }); - } - - /// The Options + Save row: output choice (in place vs an output `.project`), backup toggle, - /// change counter, and Save. - fn database_save_row(&mut self, ui: &mut egui::Ui) { - let running = self.jobs.is_running(); - - // Output choice: in place (default) vs a Browse'd output .project. - let mut out = self.state.database.out_project.clone(); - let mut in_place = out.is_none(); - ui.horizontal(|ui| { - if ui - .radio(in_place, "Save in place") - .on_hover_text("Overwrite the loaded .project + .dat (backing up the originals first if the box is checked).") - .clicked() - { - in_place = true; - out = None; - } - let pick = ui - .radio(!in_place, "Save to a new .project") - .on_hover_text("Write the result to a chosen .project (its sibling .dat is written next to it); the originals are left untouched.") - .clicked(); - if pick { - in_place = false; - if out.is_none() { - out = widgets::save_file("DataBase.project", &[("Database project", &["project"])]); - } - } - }); - if !in_place { - let mut o = out.clone(); - widgets::path_field( - ui, - "Output .project", - &mut o, - "Where to write the edited database (the sibling .dat is written alongside it).", - || widgets::save_file("DataBase.project", &[("Database project", &["project"])]), - ); - out = o; - } - self.state.database.out_project = out; - - ui.add_space(4.0); - widgets::labeled_checkbox( - ui, - &mut self.state.database.backup, - "Back up originals", - "Copy the .project + .dat to *.bak before overwriting them in place. Leave ON unless you \ - have your own backups (ignored when saving to a new file).", - ); - - let model = self.state.database.loaded.as_ref().expect("loaded checked by caller"); - let changed = model.changed_count(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("{changed} cell(s)/name(s) edited")).strong()); - }); - ui.add_space(4.0); - - let dirty = model.dirty(); - let in_place_now = self.state.database.out_project.is_none(); - ui.add_enabled_ui(dirty && !running, |ui| { - if ui - .button("Save ▶") - .on_hover_text(if in_place_now { - "Apply the edits and write the .project + .dat back in place, byte-exact for untouched data." - } else { - "Apply the edits and write the .project + .dat to the chosen output, byte-exact for untouched data." - }) - .clicked() - { - self.start_database_save_job(); - } - }); - if !dirty { - widgets::caption(ui, "Edit a cell or row name to enable Save."); - } - } - - // --- Database background jobs + sync helpers ------------------------------------------- - - /// Drain the heavy-result side-channel: a finished Load/Save job hands its (re)loaded model here. - fn poll_database_result(&mut self) { - let Some(rx) = &self.state.database.result_rx else { - return; - }; - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(DbResult::Loaded(m)) => { - let ntypes = m.types.len(); - self.state.database.loaded = Some(*m); - if self.state.database.selected_type >= ntypes { - self.state.database.selected_type = 0; - } - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - if disconnected && !self.jobs.is_running() { - self.state.database.result_rx = None; - } - } - - /// Spawn the Load job: read + render the `.project`+`.dat` pair into the grid model off the worker - /// thread, hand it back via the side-channel, and report type/row counts (mirrors `db-json`). - fn start_database_load_job(&mut self) { - let Some(path) = self.state.database.path.clone() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.database.result_rx = Some(rx); - self.jobs.run(format!("Load {}", path.display()), &mut self.log, move |rep| { - rep.progress(0.3, "reading database…"); - let model = database::load(&path)?; - let summary = format!( - "{} type(s), {} data row(s), {} encoding", - model.types.len(), - model.total_rows(), - model.encoding - ); - let _ = tx.send(DbResult::Loaded(Box::new(model))); - rep.progress(1.0, "done"); - Ok(summary) - }); - } - - /// Spawn the Save job: apply the grid edits onto a freshly-read base, re-parse the written pair to - /// verify it round-trips, and write both halves (in place when no output is chosen), then reload - /// from the written `.project` so the grid shows the persisted state. Mirrors the CLI's `db-apply`. - fn start_database_save_job(&mut self) { - // Move the loaded model onto the worker. It comes back (reloaded) via the side-channel. - let Some(model) = self.state.database.loaded.take() else { - return; - }; - let backup = self.state.database.backup; - let output = self.state.database.out_project.clone().unwrap_or_else(|| model.project.clone()); - let in_place = output == model.project; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.database.result_rx = Some(rx); - self.jobs.run(format!("Save database → {}", output.display()), &mut self.log, move |rep| { - rep.progress(0.3, "applying edits…"); - match database::save(&model, &output, backup && in_place) { - Ok(outcome) => { - for bak in &outcome.backups { - rep.info(format!("backed up original → {}", bak.display())); - } - // Reload from the written .project so the grid reflects the persisted state. - match database::load(&outcome.out_project) { - Ok(fresh) => { - let _ = tx.send(DbResult::Loaded(Box::new(fresh))); - } - Err(e) => { - rep.warn(format!("reload after save failed: {e}")); - let _ = tx.send(DbResult::Loaded(Box::new(model))); - } - } - rep.progress(1.0, "done"); - Ok(format!( - "{} cell(s)/name(s) changed → {} + {}{}", - outcome.changed, - outcome.out_project.display(), - outcome.out_dat.display(), - if outcome.byte_identical { " (byte-identical to base)" } else { "" } - )) - } - Err(e) => { - // Put the model back unchanged so the user's edits aren't lost. - let _ = tx.send(DbResult::Loaded(Box::new(model))); - rep.progress(1.0, "done"); - Err(e) - } - } - }); - } - - /// Spawn the DB export job: write the on-disk database to a `.json` file on a worker thread - /// (reads only the base path, so the live grid is untouched). - fn start_database_export_job(&mut self, out: PathBuf) { - let Some(base) = self.state.database.path.clone() else { - return; - }; - self.jobs.run(format!("Export DB → {}", out.display()), &mut self.log, move |rep| { - rep.progress(0.4, "reading database…"); - database::export_json(&base, &out)?; - rep.progress(1.0, "done"); - Ok(format!("exported → {}", out.display())) - }); - } - - /// Spawn the DB import job: apply an edited `.json` onto the loaded database in place (backing up - /// the originals first), then reload the grid from the written result. - fn start_database_import_job(&mut self, json: PathBuf) { - let Some(base) = self.state.database.path.clone() else { - return; - }; - let backup = self.state.database.backup; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.database.result_rx = Some(rx); - self.jobs.run(format!("Import DB ← {}", json.display()), &mut self.log, move |rep| { - rep.progress(0.3, "applying JSON…"); - let outcome = database::import_json(&base, &json, &base, backup)?; - for bak in &outcome.backups { - rep.info(format!("backed up original → {}", bak.display())); - } - // Reload the grid from the written .project so it reflects the imported state. - match database::load(&outcome.out_project) { - Ok(fresh) => { - let _ = tx.send(DbResult::Loaded(Box::new(fresh))); - } - Err(e) => rep.warn(format!("reload after import failed: {e}")), - } - rep.progress(1.0, "done"); - Ok(format!( - "{} cell(s)/name(s) changed{}", - outcome.changed, - if outcome.byte_identical { " (no change)" } else { "" } - )) - }); - } - - fn ui_gamedat(&mut self, ui: &mut egui::Ui) { - // Pump any background result (a freshly loaded / reloaded model) into section state first. - self.poll_gamedat_result(); - - ui.heading("Game.dat"); - widgets::caption(ui, "Edit every Game.dat field: title, fonts, splash/title messages, image paths."); - ui.add_space(6.0); - - // Default the path to the open project's Game.dat the first time we're shown. - if self.state.gamedat.path.is_none() { - if let Some(p) = self.project.as_ref().and_then(|p| p.game_dat.clone()) { - self.state.gamedat.path = Some(p); - } - } - - self.gamedat_path_row(ui); - ui.add_space(8.0); - ui.separator(); - ui.add_space(6.0); - - if self.state.gamedat.loaded.is_none() { - widgets::caption(ui, "No Game.dat loaded. Pick a Game.dat and press Load."); - return; - } - self.gamedat_form(ui); - } - - /// The Game.dat path field + Load button + the encoding badge. - fn gamedat_path_row(&mut self, ui: &mut egui::Ui) { - let start = self.state.gamedat.path.clone(); - let mut path = self.state.gamedat.path.clone(); - widgets::path_field( - ui, - "Game.dat", - &mut path, - "The game's Game.dat (holds the window title, fonts, splash/title messages and image paths).", - move || { - let start_dir = start.as_ref().and_then(|p| p.parent()); - widgets::pick_file_in(start_dir, &[("Game data", &["dat"]), ("All files", &["*"])]) - }, - ); - self.state.gamedat.path = path; - - ui.add_space(4.0); - let running = self.jobs.is_running(); - let have = self.state.gamedat.path.is_some(); - ui.horizontal(|ui| { - ui.add_enabled_ui(have && !running, |ui| { - if ui - .button("Load") - .on_hover_text("Read the Game.dat and fill the form with its current field values.") - .clicked() - { - self.start_gamedat_load_job(); - } - }); - if self.state.gamedat.loaded.is_some() - && ui - .button("Reload") - .on_hover_text("Discard edits and reload the field values from disk.") - .clicked() - { - self.start_gamedat_load_job(); - } - if self.state.gamedat.loaded.is_some() - && ui - .button("Unload") - .on_hover_text("Close Game.dat and free its memory (discards unsaved edits).") - .clicked() - { - self.unload_gamedat(); - } - // The encoding badge (read-only), shown once a model is loaded. - if let Some(g) = &self.state.gamedat.loaded { - ui.separator(); - ui.label( - egui::RichText::new(format!(" {} ", g.encoding)) - .strong() - .background_color(egui::Color32::from_rgb(0x40, 0x80, 0xE0)) - .color(egui::Color32::BLACK), - ) - .on_hover_text("How the strings are stored (utf8 or shiftjis). A Shift-JIS file can only hold characters representable in Shift-JIS."); - } - }); - } - - /// The grouped field form + the Save controls. - fn gamedat_form(&mut self, ui: &mut egui::Ui) { - let running = self.jobs.is_running(); - // Render the grouped fields inside a scroll area (a file can carry many). - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .max_height(360.0) - .show(ui, |ui| { - let g = self.state.gamedat.loaded.as_mut().expect("loaded checked by caller"); - Self::gamedat_group(ui, "Titles & messages", g, Group::Titles, false); - Self::gamedat_group(ui, "Fonts", g, Group::Fonts, true); - Self::gamedat_group(ui, "Graphics", g, Group::Graphics, false); - Self::gamedat_group(ui, "Other", g, Group::Other, false); - }); - - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - - // Backup toggle + change counter + Save. - widgets::labeled_checkbox( - ui, - &mut self.state.gamedat.backup, - "Back up original", - "Copy the file to a *.bak before overwriting it in place. Leave ON unless you have your \ - own backups (ignored when saving to a new file).", - ); - let g = self.state.gamedat.loaded.as_ref().expect("loaded"); - let changed = g.changed_count(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("{changed} field(s) edited")).strong()); - }); - ui.add_space(4.0); - let dirty = g.dirty(); - ui.horizontal(|ui| { - ui.add_enabled_ui(dirty && !running, |ui| { - if ui - .button("Save (in place) ▶") - .on_hover_text("Apply the edits and write the Game.dat back in place (backing up the original first if the box is checked).") - .clicked() - { - self.start_gamedat_save_job(None); - } - }); - ui.add_enabled_ui(dirty && !running, |ui| { - if ui - .button("Save as…") - .on_hover_text("Apply the edits and write the result to a chosen file (the original is left untouched).") - .clicked() - { - if let Some(out) = - widgets::save_file("Game.dat", &[("Game data", &["dat"]), ("All files", &["*"])]) - { - self.start_gamedat_save_job(Some(out)); - } - } - }); - }); - if !dirty { - widgets::caption(ui, "Edit a field to enable Save."); - } - } - - /// Render the fields of one group as a labelled form section, plus (when `with_subfonts`) the - /// SubFonts array under the same header. Skips the section entirely when it has no fields. - /// - /// Every row goes through the shared [`widgets::text_row`], so every input fills the row to the - /// same width and starts at the same x (the fixed [`widgets::LABEL_COL_W`] left column). The - /// inputs line up on both edges across all groups, and the whole Game.dat form matches the path - /// rows of the other sections. - fn gamedat_group( - ui: &mut egui::Ui, - header: &str, - g: &mut LoadedGameDat, - group: Group, - with_subfonts: bool, - ) { - let any_fields = g.fields.iter().any(|f| f.group == group); - let any_subfonts = with_subfonts && !g.sub_fonts.is_empty(); - if !any_fields && !any_subfonts { - return; - } - ui.add_space(4.0); - ui.label(egui::RichText::new(header).strong()); - ui.add_space(2.0); - for f in g.fields.iter_mut().filter(|f| f.group == group) { - // The Font field's label gets a tofu-busting tooltip (the non-coder rescue). - let label_tip = if f.key == "Font" { - "the game's font; if text shows as boxes/tofu on your system, try another installed \ - Japanese font here" - } else { - "" - }; - widgets::text_row(ui, f.label, &mut f.value, label_tip, "(empty)"); - } - if any_subfonts { - for (i, s) in g.sub_fonts.iter_mut().enumerate() { - widgets::text_row( - ui, - &format!("Sub-font {}", i + 1), - &mut s.value, - "A fallback font the engine uses when the main font lacks a glyph.", - "(empty)", - ); - } - } - } - - // --- Game.dat background jobs + sync helpers ------------------------------------------- - - /// Drain the heavy-result side-channel: a finished Load/Save job hands its (re)loaded model here. - fn poll_gamedat_result(&mut self) { - let Some(rx) = &self.state.gamedat.result_rx else { - return; - }; - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(GameDatResult::Loaded(g)) => self.state.gamedat.loaded = Some(*g), - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - if disconnected && !self.jobs.is_running() { - self.state.gamedat.result_rx = None; - } - } - - /// Spawn the Load job: read + parse the Game.dat into the form model off the worker thread, hand - /// it back via the side-channel, and report the field/encoding counts (mirrors `gamedat-json`). - fn start_gamedat_load_job(&mut self) { - let Some(path) = self.state.gamedat.path.clone() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.gamedat.result_rx = Some(rx); - self.jobs.run(format!("Load {}", path.display()), &mut self.log, move |rep| { - rep.progress(0.3, "reading Game.dat…"); - let g = gamedat::load(&path)?; - let summary = format!( - "{} field(s) + {} sub-font(s), {} encoding", - g.fields.len(), - g.sub_fonts.len(), - g.encoding - ); - let _ = tx.send(GameDatResult::Loaded(Box::new(g))); - rep.progress(1.0, "done"); - Ok(summary) - }); - } - - /// Spawn the Save job: apply the form edits onto a fresh base, re-parse to verify the round-trip, - /// and write to `out` (in place when `None`), then reload from the written bytes so the form shows - /// the persisted state. Mirrors the CLI's `gamedat-apply` guard. - fn start_gamedat_save_job(&mut self, out: Option) { - // Move the loaded model onto the worker. It comes back (reloaded) via the side-channel. - let Some(loaded) = self.state.gamedat.loaded.take() else { - return; - }; - let backup = self.state.gamedat.backup; - let output = out.unwrap_or_else(|| loaded.path.clone()); - let in_place = output == loaded.path; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.gamedat.result_rx = Some(rx); - self.jobs.run(format!("Save Game.dat → {}", output.display()), &mut self.log, move |rep| { - rep.progress(0.3, "applying edits…"); - let result = gamedat::save(&loaded, &output, backup && in_place); - match result { - Ok(outcome) => { - if let Some(bak) = &outcome.backup { - rep.info(format!("backed up original → {}", bak.display())); - } - // Reload from the written file so the form reflects the persisted state. - match gamedat::load(&outcome.out) { - Ok(fresh) => { - let _ = tx.send(GameDatResult::Loaded(Box::new(fresh))); - } - Err(e) => { - rep.warn(format!("reload after save failed: {e}")); - let _ = tx.send(GameDatResult::Loaded(Box::new(loaded))); - } - } - rep.progress(1.0, "done"); - Ok(format!( - "{} field(s) changed → {}{}", - outcome.changed, - outcome.out.display(), - if outcome.byte_identical { " (byte-identical to base)" } else { "" } - )) - } - Err(e) => { - // Put the model back unchanged so the user's edits aren't lost. - let _ = tx.send(GameDatResult::Loaded(Box::new(loaded))); - rep.progress(1.0, "done"); - Err(e) - } - } - }); - } - - fn ui_translation(&mut self, ui: &mut egui::Ui) { - // Pump any background result (a rebuilt model / conflict list) into the section state first, - // so the rest of this frame renders what the just-finished job produced. - self.poll_translation_result(); - - ui.heading("Translation"); - widgets::caption(ui, "Extract player-facing text, edit translations, inject them back, and carry them across game updates."); - ui.add_space(6.0); - - // Default the source folder to the open project's data dir the first time we're shown. - if self.state.translation.source_dir.is_none() { - if let Some(d) = self.project.as_ref().and_then(|p| p.data_dir.clone()) { - self.state.translation.source_dir = Some(d); - } - } - - self.translation_source_row(ui); - ui.add_space(6.0); - self.translation_action_row(ui); - ui.add_space(8.0); - ui.separator(); - - if self.state.translation.model.is_none() { - ui.add_space(8.0); - widgets::caption( - ui, - "No translation loaded. Pick a game's unpacked Data folder and press Extract, \ - or Load a saved translation project.", - ); - return; - } - - if let Some(model) = &self.state.translation.model { - widgets::caption( - ui, - &format!( - "{} file(s) · {} / {} lines translated overall", - model.files.len(), - model.translated_rows(), - model.total_rows(), - ), - ); - } - ui.add_space(4.0); - self.translation_options_row(ui); - ui.add_space(6.0); - ui.separator(); - ui.add_space(6.0); - self.translation_grid(ui); - self.translation_conflicts_panel(ui); - } - - /// The source-folder field + Extract button. - fn translation_source_row(&mut self, ui: &mut egui::Ui) { - let mut dir = self.state.translation.source_dir.clone(); - let start = dir.clone(); - widgets::path_field( - ui, - "Source folder", - &mut dir, - "The game's unpacked Data folder (holds BasicData/ and MapData/). Extract reads every \ - text file under it.", - move || widgets::pick_folder_in(start.as_deref()), - ); - self.state.translation.source_dir = dir; - - ui.add_space(6.0); - let running = self.jobs.is_running(); - let have_dir = self.state.translation.source_dir.is_some(); - ui.horizontal(|ui| { - ui.add_enabled_ui(have_dir && !running, |ui| { - if ui - .button("Extract") - .on_hover_text("Read every map, CommonEvent, database and Game.dat under the folder into an editable grid.") - .clicked() - { - self.start_extract_job(); - } - }); - ui.add_enabled_ui(!running, |ui| { - if ui - .button("Load project…") - .on_hover_text("Restore a translation you saved earlier (a .wdtr.json file).") - .clicked() - { - self.translation_load_project(); - } - if self.state.translation.model.is_some() - && ui - .button("Save project…") - .on_hover_text("Save the whole translation (all edits) to a single JSON so it persists across sessions.") - .clicked() - { - self.translation_save_project(); - } - }); - }); - } - - /// The Merge / Check-names action row (only useful once a model is loaded). - fn translation_action_row(&mut self, ui: &mut egui::Ui) { - let running = self.jobs.is_running(); - let have_model = self.state.translation.model.is_some(); - ui.horizontal(|ui| { - ui.add_enabled_ui(have_model && !running, |ui| { - if ui - .button("Merge older translation…") - .on_hover_text("Carry translations from a previous version's exported JSON(s) into this model, matched by source text.") - .clicked() - { - self.start_merge_job(); - } - if ui - .button("Check names") - .on_hover_text("Check that each glossary name is translated to ONE value everywhere (inconsistent names break by-name lookups).") - .clicked() - { - self.start_check_job(); - } - }); - if have_model - && ui - .button("Unload") - .on_hover_text("Close the translation and free its memory (discards unsaved edits; Save or Inject first to keep them).") - .clicked() - { - self.unload_translation(); - } - }); - } - - /// The inject options (en-punct / allow-code-drift), the output choice, and the Inject button. - fn translation_options_row(&mut self, ui: &mut egui::Ui) { - let tr = &mut self.state.translation; - ui.horizontal(|ui| { - widgets::labeled_checkbox( - ui, - &mut tr.en_punct, - "--en-punct", - "Convert Japanese punctuation 「」~。 to ASCII for English (applied to your translations on inject).", - ); - widgets::labeled_checkbox( - ui, - &mut tr.allow_code_drift, - "--allow-code-drift", - "Allow dropping/altering \\codes — risky. Off keeps the strict control-code guard that protects byte-exactness.", - ); - }); - - // Output choice: in place (default, with a note) vs a Browse'd output dir. - let mut out = tr.out_dir.clone(); - let mut in_place = out.is_none(); - ui.horizontal(|ui| { - if ui - .radio(in_place, "Write in place") - .on_hover_text("Overwrite the files in the source folder (make a backup of your game first).") - .clicked() - { - in_place = true; - out = None; - } - let pick_out = ui - .radio(!in_place, "Write to output folder") - .on_hover_text("Write the translated files into a separate folder, mirroring the source layout.") - .clicked(); - if pick_out { - in_place = false; - if out.is_none() { - out = widgets::pick_folder(); - } - } - }); - if !in_place { - let start = out.clone(); - widgets::path_field( - ui, - "Output folder", - &mut out, - "Where to write the translated files (mirrors the source folder's layout).", - move || widgets::pick_folder_in(start.as_deref()), - ); - } - self.state.translation.out_dir = out; - - ui.add_space(6.0); - let running = self.jobs.is_running(); - let in_place_now = self.state.translation.out_dir.is_none(); - ui.add_enabled_ui(!running, |ui| { - let btn = ui.button("Inject ▶").on_hover_text(if in_place_now { - "Apply your translations back into the game files IN PLACE (overwrites the source folder)." - } else { - "Apply your translations into the chosen output folder, byte-exact." - }); - if btn.clicked() { - self.start_inject_job(); - } - }); - } - - /// The two-pane grid: the file/names list on the left, the selected entry's rows on the right. - fn translation_grid(&mut self, ui: &mut egui::Ui) { - // Left: a selectable list of files + the names glossary. - egui::SidePanel::left("tr_files") - .resizable(true) - .default_width(220.0) - .show_inside(ui, |ui| { - ui.label(egui::RichText::new("Files").strong()); - ui.add_space(2.0); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - let tr = &mut self.state.translation; - let Some(model) = &tr.model else { return }; - // The names glossary entry first. - let names_label = format!( - "Names ({}/{})", - model.names.iter().filter(|r| r.is_translated()).count(), - model.names.len() - ); - if ui - .selectable_label(tr.selected == SelectedEntry::Names, names_label) - .clicked() - { - tr.selected = SelectedEntry::Names; - } - ui.separator(); - for (i, f) in model.files.iter().enumerate() { - let label = format!( - "{} {} ({}/{})", - kind_icon(f.kind), - f.display_name(), - f.translated_count(), - f.rows.len() - ); - if ui - .selectable_label(tr.selected == SelectedEntry::File(i), label) - .clicked() - { - tr.selected = SelectedEntry::File(i); - } - } - }); - }); - - // Right: the filter/toggle/counter bar + the rows table for the selected entry. - egui::CentralPanel::default().show_inside(ui, |ui| { - self.translation_rows_pane(ui); - }); - } - - /// The right pane: filter box, untranslated toggle, live counter, and the editable rows table. - fn translation_rows_pane(&mut self, ui: &mut egui::Ui) { - use egui_extras::{Column, TableBuilder}; - - let tr = &mut self.state.translation; - let Some(model) = &mut tr.model else { return }; - - // Resolve the selected entry's rows. - let rows = match tr.selected { - SelectedEntry::Names => &mut model.names, - SelectedEntry::File(i) => match model.files.get_mut(i) { - Some(f) => &mut f.rows, - None => { - tr.selected = SelectedEntry::Names; - &mut model.names - } - }, - }; - - // Filter/toggle/counter bar. - let (translated, total) = (rows.iter().filter(|r| r.is_translated()).count(), rows.len()); - ui.horizontal(|ui| { - ui.label("Filter:"); - ui.add( - egui::TextEdit::singleline(&mut tr.filter) - .hint_text("search source or translation") - .desired_width(220.0), - ); - ui.checkbox(&mut tr.untranslated_only, "untranslated only") - .on_hover_text("Show only rows you have not translated yet."); - ui.separator(); - ui.label( - egui::RichText::new(format!("{translated} / {total} translated")).strong(), - ); - }); - ui.add_space(4.0); - - // Which row indices pass the filter/toggle. With no text filter and the toggle off we address - // rows directly (no per-frame index Vec), so an unfiltered file of thousands of lines costs - // nothing beyond the now-virtualized body. - let needle = tr.filter.to_lowercase(); - let active = !needle.is_empty() || tr.untranslated_only; - let filtered: Option> = if !active { - None - } else { - Some( - rows.iter() - .enumerate() - .filter(|(_, r)| { - if tr.untranslated_only && r.is_translated() { - return false; - } - needle.is_empty() - || r.source.to_lowercase().contains(&needle) - || r.translation.to_lowercase().contains(&needle) - }) - .map(|(i, _)| i) - .collect(), - ) - }; - let count = filtered.as_ref().map_or(rows.len(), Vec::len); - if count == 0 { - widgets::caption(ui, "No rows match the current filter."); - return; - } - - let text_h = egui::TextStyle::Body.resolve(ui.style()).size; - TableBuilder::new(ui) - .striped(true) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::TOP)) - .column(Column::remainder().at_least(180.0)) // Source - .column(Column::remainder().at_least(180.0)) // Translation - .column(Column::auto().at_least(90.0)) // Note/Loc - .header(text_h + 6.0, |mut header| { - header.col(|ui| { - ui.strong("Source"); - }); - header.col(|ui| { - ui.strong("Translation"); - }); - header.col(|ui| { - ui.strong("Note / Loc"); - }); - }) - // Virtualized: only on-screen rows are built, so a file with thousands of lines (the - // unpacked .txt scenes) does not lay out every TextEdit every frame. - .body(|body| { - body.rows(text_h * 3.2, count, |mut r| { - let idx = filtered.as_ref().map_or(r.index(), |v| v[r.index()]); - let row = &mut rows[idx]; - r.col(|ui| { - ui.add( - egui::Label::new(egui::RichText::new(&row.source).monospace()) - .wrap(), - ); - }); - r.col(|ui| { - ui.add( - egui::TextEdit::multiline(&mut row.translation) - .desired_rows(2) - .desired_width(f32::INFINITY), - ); - }); - r.col(|ui| { - ui.vertical(|ui| { - ui.add( - egui::Label::new( - egui::RichText::new(&row.location).weak().small(), - ) - .wrap(), - ); - if !row.note.is_empty() { - ui.add( - egui::Label::new( - egui::RichText::new(&row.note).weak().small(), - ) - .wrap(), - ); - } - }); - }); - }); - }); - } - - /// The small name-conflict panel, shown below the grid when the last check found any. - fn translation_conflicts_panel(&mut self, ui: &mut egui::Ui) { - let tr = &mut self.state.translation; - if tr.conflicts.is_empty() { - return; - } - ui.add_space(6.0); - ui.separator(); - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - format!("Warning: {} name(s) translated inconsistently:", tr.conflicts.len()), - ); - egui::ScrollArea::vertical() - .max_height(120.0) - .auto_shrink([false, true]) - .show(ui, |ui| { - for c in &tr.conflicts { - ui.label(format!( - "{:?} → {}", - c.source, - c.variants - .iter() - .map(|v| format!("{:?}", v.text)) - .collect::>() - .join(" · ") - )); - } - }); - if ui.button("Clear conflicts").clicked() { - tr.conflicts.clear(); - } - } - - // --- Translation background jobs + sync helpers --------------------------------------- - - /// Drain the heavy-result side-channel: a finished job hands its rebuilt model / conflicts here. - fn poll_translation_result(&mut self) { - let Some(rx) = &self.state.translation.result_rx else { - return; - }; - // Take whatever is available. The channel closes when the worker drops its sender. - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(TrResult::Model(m)) => { - let nfiles = m.files.len(); - self.state.translation.model = Some(*m); - // Keep the user's current selection across inject/merge/check (the model comes - // back the same shape). Only fix it up if it now points past the end (a fresh - // extract with fewer files, or no files at all). - let stale = match self.state.translation.selected { - SelectedEntry::File(i) => i >= nfiles, - SelectedEntry::Names => false, - }; - if stale { - self.state.translation.selected = - if nfiles > 0 { SelectedEntry::File(0) } else { SelectedEntry::Names }; - } - } - Ok(TrResult::Conflicts(c)) => self.state.translation.conflicts = c, - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - if disconnected && !self.jobs.is_running() { - self.state.translation.result_rx = None; - } - } - - /// Spawn the Extract job: build the editable model off the worker thread, hand it back via the - /// side-channel, and report counts through the job log/progress (the scaffold's pattern). - fn start_extract_job(&mut self) { - let Some(dir) = self.state.translation.source_dir.clone() else { - return; - }; - // A fresh extract invalidates any prior conflict findings. - self.state.translation.conflicts.clear(); - let (tx, rx) = std::sync::mpsc::channel(); - self.state.translation.result_rx = Some(rx); - self.jobs.run(format!("Extract {}", dir.display()), &mut self.log, move |rep| { - rep.progress(0.0, "scanning folder…"); - let outcome = translation::extract_model(&dir, |frac, msg| rep.progress(frac, msg))?; - for w in &outcome.warnings { - rep.warn(w.clone()); - } - let strings: usize = outcome.model.files.iter().map(|f| f.rows.len()).sum(); - let names = outcome.model.names.len(); - let nfiles = outcome.model.files.len(); - send_model(&tx, outcome.model); - rep.progress(1.0, "done"); - Ok(format!("{strings} strings across {nfiles} file(s), {names} names")) - }); - } - - /// Spawn the Inject job: serialize the edited model and apply it via the library inject paths. - fn start_inject_job(&mut self) { - // The model lives in section state and carries the edits, so re-extracting would drop them. - // Take the model out, run inject, and put it back via the side-channel so the (unchanged) - // model returns to the UI. - let Some(model) = self.state.translation.model.take() else { - return; - }; - let opts = InjectOptions { - allow_code_drift: self.state.translation.allow_code_drift, - normalize_punct: self.state.translation.en_punct, - }; - let target = match self.state.translation.out_dir.clone() { - Some(od) => InjectTarget::OutDir(od), - None => InjectTarget::InPlace, - }; - let where_label = match &target { - InjectTarget::InPlace => "in place".to_string(), - InjectTarget::OutDir(p) => format!("→ {}", p.display()), - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.translation.result_rx = Some(rx); - self.jobs.run(format!("Inject ({where_label})"), &mut self.log, move |rep| { - let mut model = model; - rep.progress(0.0, "injecting…"); - let result = translation::inject_model( - &mut model, - &opts, - &target, - |frac, msg| rep.progress(frac, msg), - ); - // Whatever happens, hand the model back so the UI keeps the user's edits. - let report = match result { - Ok(reports) => { - let (mut a, mut s, mut d) = (0, 0, 0); - for r in &reports { - rep.info(format!( - " {} — {} applied, {} skipped, {} drifted", - r.file, r.applied, r.skipped, r.drifted - )); - a += r.applied; - s += r.skipped; - d += r.drifted; - } - Ok(format!("{a} applied, {s} skipped, {d} drifted across {} file(s)", reports.len())) - } - Err(e) => Err(e), - }; - send_model(&tx, model); - rep.progress(1.0, "done"); - report - }); - } - - /// Spawn the Merge job: pick old translation file(s)/dir, carry their translations into the model. - fn start_merge_job(&mut self) { - // Pick the old translations on the UI thread (file dialogs must not run on a worker). - let mut old: Vec = Vec::new(); - if let Some(dir) = widgets::pick_folder() { - old.push(dir); - } else if let Some(files) = - rfd::FileDialog::new().add_filter("Translation JSON", &["json"]).pick_files() - { - old.extend(files); - } - if old.is_empty() { - return; - } - let Some(model) = self.state.translation.model.take() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.translation.result_rx = Some(rx); - self.jobs.run("Merge older translation".to_string(), &mut self.log, move |rep| { - let mut model = model; - rep.progress(0.1, "building translation memory…"); - let result = translation::merge_into_model(&mut model, &old); - let summary = match &result { - Ok(m) => Ok(format!("carried {} translation(s); {} still new", m.carried, m.still_new)), - Err(e) => Err(e.clone()), - }; - send_model(&tx, model); - rep.progress(1.0, "done"); - summary - }); - } - - /// Spawn the Check-names job: run the conflict check over the model, surface findings in the panel. - fn start_check_job(&mut self) { - let Some(model) = self.state.translation.model.take() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.translation.result_rx = Some(rx); - self.jobs.run("Check names".to_string(), &mut self.log, move |rep| { - let mut model = model; - rep.progress(0.2, "scanning for conflicts…"); - let conflicts = translation::check_conflicts(&mut model); - let n = conflicts.len(); - // Hand both the (unchanged) model and the conflict list back. - let _ = tx.send(TrResult::Model(Box::new(model))); - let _ = tx.send(TrResult::Conflicts(conflicts)); - rep.progress(1.0, "done"); - if n == 0 { - Ok("no name-translation conflicts".to_string()) - } else { - Ok(format!("{n} name(s) translated inconsistently — see the panel")) - } - }); - } - - /// Save the whole model to a single project JSON (synchronous, serialization is fast). - fn translation_save_project(&mut self) { - let Some(model) = self.state.translation.model.as_mut() else { - return; - }; - let Some(path) = widgets::save_file( - "translation.wdtr.json", - &[("WolfDawn translation", &["json"])], - ) else { - return; - }; - match translation::save_to_path(model, &path) { - Ok(()) => self.log.info(format!("saved translation project → {}", path.display())), - Err(e) => self.log.error(format!("save failed: {e}")), - } - } - - /// Load a model from a project JSON (synchronous). - fn translation_load_project(&mut self) { - let Some(path) = widgets::pick_file(&[("WolfDawn translation", &["json"])]) else { - return; - }; - match translation::load_from_path(&path) { - Ok(model) => { - let total = model.total_rows(); - self.state.translation.selected = - if model.files.is_empty() { SelectedEntry::Names } else { SelectedEntry::File(0) }; - if self.state.translation.source_dir.is_none() { - self.state.translation.source_dir = Some(model.data_dir.clone()); - } - self.state.translation.model = Some(model); - self.state.translation.conflicts.clear(); - self.log.info(format!("loaded translation project ({total} rows) from {}", path.display())); - } - Err(e) => self.log.error(format!("load failed: {e}")), - } - } - - fn ui_saves(&mut self, ui: &mut egui::Ui) { - // Pump any background result (a freshly inspected / re-inspected save) into the section state - // first, so the rest of this frame renders what the just-finished job produced. - self.poll_saves_result(); - - ui.heading("Saves"); - widgets::caption(ui, "Refresh a baked save's title and player-facing strings to match the translated build."); - widgets::caption( - ui, - "A save bakes in the game's title; a translated build rejects a save whose title doesn't \ - match its own Game.dat (\"trying to load from another game\"). Rewriting the title is \ - exactly what lets an old / Japanese save load in the translated build.", - ); - ui.add_space(6.0); - - // Default the open path / batch dir to the project's Save dir the first time we're shown. - self.saves_default_paths(); - - // A shared backup toggle (used by both the single-file and batch writes). - widgets::labeled_checkbox( - ui, - &mut self.state.saves.backup, - "Back up original", - "Copy each save to a *.bak (single file) or a save_backup_/ folder (batch) \ - before overwriting it. Leave this ON unless you have your own backups.", - ); - ui.add_space(6.0); - - egui::CollapsingHeader::new("Single save") - .default_open(true) - .show(ui, |ui| self.saves_single_pane(ui)); - - ui.add_space(8.0); - - egui::CollapsingHeader::new("Batch update a save folder") - .default_open(false) - .show(ui, |ui| self.saves_batch_pane(ui)); - } - - /// Seed the batch folder from the open project's scanned `Save/` dir, once (only when empty, so a - /// folder the user picked is never clobbered). - fn saves_default_paths(&mut self) { - if self.state.saves.batch_dir.is_none() { - if let Some(dir) = self.project.as_ref().and_then(|p| p.save_dir.clone()) { - self.state.saves.batch_dir = Some(dir); - } - } - } - - /// The single-save pane: open a `.sav`, Inspect it, edit the title + baked strings, and save. - fn saves_single_pane(&mut self, ui: &mut egui::Ui) { - // A project dropdown of the scanned saves (Save/*.sav), above the Browse field. - if let Some(saves) = self.project.as_ref().map(|p| p.saves.clone()) { - let mut path = self.state.saves.sav_path.clone(); - if project_combo( - ui, - "saves_combo", - "Project save", - &saves, - &mut path, - "Pick a .sav from the open game's Save/ folder (or Browse below for one elsewhere).", - ) { - self.state.saves.sav_path = path; - } - } - - let start = self.state.saves.batch_dir.clone(); - let mut path = self.state.saves.sav_path.clone(); - widgets::path_field( - ui, - "Save file", - &mut path, - "A single .sav to inspect and edit.", - move || { - widgets::pick_file_in(start.as_deref(), &[("Wolf save", &["sav"]), ("All files", &["*"])]) - }, - ); - self.state.saves.sav_path = path; - - ui.add_space(4.0); - let running = self.jobs.is_running(); - let have = self.state.saves.sav_path.is_some(); - ui.horizontal(|ui| { - ui.add_enabled_ui(have && !running, |ui| { - if ui - .button("Inspect") - .on_hover_text("Read the save on a background thread: detect its format, decode the title, and list its baked strings.") - .clicked() - { - self.start_inspect_job(); - } - }); - if self.state.saves.loaded.is_some() - && ui - .button("Close save") - .on_hover_text("Clear the loaded save and free its memory (discards unsaved edits).") - .clicked() - { - self.unload_save(); - } - }); - if !have { - widgets::caption(ui, "Pick a .sav (or open a game with a Save/ folder) and press Inspect."); - } - - ui.add_space(6.0); - if self.state.saves.loaded.is_some() { - ui.separator(); - ui.add_space(4.0); - self.saves_editor(ui); - } - } - - /// The loaded-save editor: a format/encoding badge, the editable title, the baked-string table, - /// and the Save-changes button. For an unsupported save, editing is disabled with a clear note. - fn saves_editor(&mut self, ui: &mut egui::Ui) { - let running = self.jobs.is_running(); - let loaded = self.state.saves.loaded.as_mut().expect("loaded checked by caller"); - - // Format badge + encoding. - ui.horizontal(|ui| { - let (label, color) = match loaded.format { - SaveFormat::Standard => ("Standard", egui::Color32::from_rgb(0x40, 0xA0, 0x60)), - SaveFormat::GameProPro => ("GamePro Pro", egui::Color32::from_rgb(0x40, 0x80, 0xE0)), - SaveFormat::Unsupported => ("Unsupported", egui::Color32::from_rgb(0xE0, 0x60, 0x60)), - }; - ui.label(egui::RichText::new(format!(" {label} ")).strong().background_color(color).color(egui::Color32::BLACK)) - .on_hover_text("The detected save codec. Standard and GamePro Pro can be edited; an unsupported save is read-only."); - ui.separator(); - ui.label(format!("Encoding: {}", loaded.encoding)) - .on_hover_text("How the baked strings are stored (utf8 or sjis). A Shift-JIS save can only hold characters representable in Shift-JIS."); - ui.separator(); - ui.label(egui::RichText::new(loaded.path.display().to_string()).weak()); - }); - ui.add_space(6.0); - - if loaded.format == SaveFormat::Unsupported { - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - "Warning: This save's format isn't supported yet, so it can't be edited. Only standard and \ - GamePro Pro saves can be rewritten.", - ); - return; - } - - // Editable title (in a shared form row so it aligns with the section's path rows). - widgets::text_row( - ui, - "Title", - &mut loaded.title, - "The baked game identity. Set this to the translated build's Game.dat title so the \ - save loads there. This is the field that fixes \"trying to load from another game\".", - "", - ); - if loaded.title_changed() { - ui.label( - egui::RichText::new(format!("was: {}", loaded.original_title)).weak().small(), - ); - } - ui.add_space(6.0); - - // Baked-string filter + counter. - let changed = loaded.changed_count(); - let total = loaded.strings.len(); - ui.horizontal(|ui| { - ui.label("Filter:"); - ui.add( - egui::TextEdit::singleline(&mut self.state.saves.filter) - .hint_text("search baked strings") - .desired_width(220.0), - ); - ui.separator(); - ui.label(egui::RichText::new(format!("{changed} edited · {total} strings")).strong()); - }); - ui.add_space(4.0); - - self.saves_string_table(ui); - - ui.add_space(8.0); - let loaded = self.state.saves.loaded.as_ref().expect("loaded"); - let dirty = loaded.title_changed() || loaded.changed_count() > 0; - ui.add_enabled_ui(dirty && !running, |ui| { - if ui - .button("Save changes ▶") - .on_hover_text("Write the edited title + changed baked strings back into the save (backing up the original first if the box is checked).") - .clicked() - { - self.start_save_changes_job(); - } - }); - if !dirty { - widgets::caption(ui, "Edit the title or a baked string to enable Save changes."); - } - } - - /// The baked-string table: a `TableBuilder` with one editable `TextEdit` per string and the - /// original shown dim alongside. Honours the filter box. - fn saves_string_table(&mut self, ui: &mut egui::Ui) { - use egui_extras::{Column, TableBuilder}; - - let loaded = self.state.saves.loaded.as_mut().expect("loaded"); - let needle = self.state.saves.filter.to_lowercase(); - let visible: Vec = loaded - .strings - .iter() - .enumerate() - .filter(|(_, r)| { - needle.is_empty() - || r.original.to_lowercase().contains(&needle) - || r.edited.to_lowercase().contains(&needle) - }) - .map(|(i, _)| i) - .collect(); - - if loaded.strings.is_empty() { - widgets::caption(ui, "This save has no baked player-facing strings to edit."); - return; - } - if visible.is_empty() { - widgets::caption(ui, "No baked strings match the current filter."); - return; - } - - let text_h = egui::TextStyle::Body.resolve(ui.style()).size; - TableBuilder::new(ui) - .striped(true) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::TOP)) - .column(Column::remainder().at_least(180.0)) // Original (dim) - .column(Column::remainder().at_least(180.0)) // Edited - .header(text_h + 6.0, |mut header| { - header.col(|ui| { - ui.strong("Original"); - }); - header.col(|ui| { - ui.strong("Edited"); - }); - }) - .body(|mut body| { - for &i in &visible { - let row = &mut loaded.strings[i]; - body.row(text_h * 2.0, |mut r| { - r.col(|ui| { - ui.add( - egui::Label::new( - egui::RichText::new(&row.original).monospace().weak(), - ) - .wrap(), - ); - }); - r.col(|ui| { - ui.add( - egui::TextEdit::singleline(&mut row.edited) - .desired_width(f32::INFINITY), - ); - }); - }); - } - }); - } - - /// The batch pane: pick a save folder, choose a new title (typed or from a translated Game.dat) - /// and/or a translations file/dir, then run update_save over every *.sav (mirrors the CLI). - fn saves_batch_pane(&mut self, ui: &mut egui::Ui) { - widgets::caption( - ui, - "Apply a new title and/or refreshed baked strings to every .sav in a folder at once - \ - the same as the command-line save-update.", - ); - ui.add_space(4.0); - - let mut dir = self.state.saves.batch_dir.clone(); - let start = dir.clone(); - widgets::path_field( - ui, - "Save folder", - &mut dir, - "The folder holding the .sav files (e.g. the game's Save/ folder).", - move || widgets::pick_folder_in(start.as_deref()), - ); - self.state.saves.batch_dir = dir; - ui.add_space(6.0); - - // Title source: None / Typed / from Game.dat. - let sv = &mut self.state.saves; - ui.horizontal(|ui| { - ui.label("New title:").on_hover_text( - "What to set every save's baked title to. Pick \"From Game.dat\" to copy the \ - translated build's title, so the saves load there.", - ); - ui.radio_value(&mut sv.batch_title_mode, BatchTitleMode::None, "leave as-is") - .on_hover_text("Don't change the title; only refresh baked strings (needs a translations file below)."); - ui.radio_value(&mut sv.batch_title_mode, BatchTitleMode::Typed, "type one"); - ui.radio_value(&mut sv.batch_title_mode, BatchTitleMode::FromGameDat, "from Game.dat") - .on_hover_text("Read the title from a translated Game.dat (the same value the game checks)."); - }); - - match self.state.saves.batch_title_mode { - BatchTitleMode::Typed => { - widgets::text_row( - ui, - "Title text", - &mut self.state.saves.batch_title, - "", - "the translated game title", - ); - } - BatchTitleMode::FromGameDat => { - let mut gd = self.state.saves.batch_game_dat.clone(); - widgets::path_field( - ui, - "Game.dat", - &mut gd, - "A translated Game.dat; its Title is read and applied to every save.", - || widgets::pick_file(&[("Game data", &["dat"]), ("All files", &["*"])]), - ); - self.state.saves.batch_game_dat = gd; - } - BatchTitleMode::None => {} - } - ui.add_space(6.0); - - // Optional translations source for the baked-string refresh. - let mut tr = self.state.saves.batch_translations.clone(); - widgets::path_field( - ui, - "Translations", - &mut tr, - "Optional: a translation .json file or a folder of them. Their source→target text is \ - used to refresh the saves' baked strings (the same map the game itself uses).", - || { - // Prefer a folder, fall back to a single JSON file. - widgets::pick_folder().or_else(|| widgets::pick_file(&[("Translation JSON", &["json"])])) - }, - ); - self.state.saves.batch_translations = tr; - - ui.add_space(8.0); - let running = self.jobs.is_running(); - let have_dir = self.state.saves.batch_dir.is_some(); - // Something to do? a title source that yields a title, or a translations path. - let has_title = match self.state.saves.batch_title_mode { - BatchTitleMode::None => false, - BatchTitleMode::Typed => !self.state.saves.batch_title.trim().is_empty(), - BatchTitleMode::FromGameDat => self.state.saves.batch_game_dat.is_some(), - }; - let has_tr = self.state.saves.batch_translations.is_some(); - let ready = have_dir && (has_title || has_tr); - ui.add_enabled_ui(ready && !running, |ui| { - if ui - .button("Batch update ▶") - .on_hover_text("Update every .sav in the folder. Unsupported saves are skipped, not mangled.") - .clicked() - { - self.start_batch_job(); - } - }); - if !ready { - widgets::caption( - ui, - "Pick a save folder and choose a new title and/or a translations file to enable Batch update.", - ); - } - } - - // --- Saves background jobs + sync helpers --------------------------------------------- - - /// Drain the heavy-result side-channel: a finished single-file job hands its (re-)inspected save - /// here (mirrors `poll_translation_result`). - fn poll_saves_result(&mut self) { - let Some(rx) = &self.state.saves.result_rx else { - return; - }; - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(SaveResult::Loaded(s)) => { - self.state.saves.loaded = Some(*s); - self.state.saves.filter.clear(); - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - if disconnected && !self.jobs.is_running() { - self.state.saves.result_rx = None; - } - } - - /// Spawn the Inspect job: read + inspect the save off the worker thread, hand the editable model - /// back via the side-channel, and report the format/encoding/counts through the job log. - fn start_inspect_job(&mut self) { - let Some(path) = self.state.saves.sav_path.clone() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.saves.result_rx = Some(rx); - self.jobs.run(format!("Inspect {}", path.display()), &mut self.log, move |rep| { - rep.progress(0.2, "reading save…"); - let loaded = saves::inspect(&path)?; - let summary = format!( - "{} save, {} encoding, title {:?}, {} baked string(s)", - loaded.format.label(), - loaded.encoding, - loaded.original_title, - loaded.strings.len() - ); - if !loaded.editable() { - rep.warn("this save's format isn't supported; it opened read-only"); - } - let _ = tx.send(SaveResult::Loaded(Box::new(loaded))); - rep.progress(1.0, "done"); - Ok(summary) - }); - } - - /// Spawn the Save-changes job: write the edited save (backing up first), then re-inspect the - /// written bytes and hand the fresh model back so the UI shows the persisted state. - fn start_save_changes_job(&mut self) { - // Move the loaded save onto the worker. It comes back (re-inspected) via the side-channel. - let Some(loaded) = self.state.saves.loaded.take() else { - return; - }; - let backup = self.state.saves.backup; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.saves.result_rx = Some(rx); - self.jobs.run("Save changes".to_string(), &mut self.log, move |rep| { - rep.progress(0.2, "writing save…"); - let path = loaded.path.clone(); - let result = saves::save_changes(&loaded, backup); - // Whatever happens, hand a model back so the editor isn't left empty: the re-inspected - // file on success, or the (unchanged) in-memory model on failure. - match result { - Ok(stats) => { - rep.info(format!( - "title changed: {} · strings replaced: {} · encoding: {}", - stats.title_changed, stats.strings_replaced, stats.encoding - )); - if backup { - rep.info(format!("backed up original → {}.bak", path.display())); - } - match saves::inspect(&path) { - Ok(fresh) => { - let _ = tx.send(SaveResult::Loaded(Box::new(fresh))); - } - Err(e) => { - rep.warn(format!("re-inspect after write failed: {e}")); - let _ = tx.send(SaveResult::Loaded(Box::new(loaded))); - } - } - rep.progress(1.0, "done"); - Ok(format!( - "saved {} (title {}, {} string(s) replaced)", - path.display(), - if stats.title_changed { "updated" } else { "unchanged" }, - stats.strings_replaced - )) - } - Err(e) => { - // Put the model back unchanged so the user's edits aren't lost. - let _ = tx.send(SaveResult::Loaded(Box::new(loaded))); - rep.progress(1.0, "done"); - Err(e) - } - } - }); - } - - /// Spawn the Batch job: resolve the title (typed or from Game.dat) + the string map (from the - /// translations path) on the UI thread, then run update_save over every *.sav on the worker. - fn start_batch_job(&mut self) { - let sv = &self.state.saves; - let Some(dir) = sv.batch_dir.clone() else { - return; - }; - // Resolve the new title up front (Game.dat read is cheap and surfaces errors immediately). - let new_title: Option = match sv.batch_title_mode { - BatchTitleMode::None => None, - BatchTitleMode::Typed => { - let t = sv.batch_title.trim(); - (!t.is_empty()).then(|| t.to_string()) - } - BatchTitleMode::FromGameDat => match &sv.batch_game_dat { - Some(gd) => match saves::title_from_game_dat(gd) { - Ok(t) => { - self.log.info(format!("batch: title from {} = {t:?}", gd.display())); - Some(t) - } - Err(e) => { - self.log.error(format!("batch: {e}")); - return; - } - }, - None => None, - }, - }; - // Build the baked-string map from the translations path, if given. - let map = match &sv.batch_translations { - Some(p) => match saves::build_string_map(std::slice::from_ref(p)) { - Ok((m, conflicts)) => { - if conflicts > 0 { - self.log.warn(format!( - "batch: {conflicts} source(s) had divergent translations across files; kept the first" - )); - } - m - } - Err(e) => { - self.log.error(format!("batch: {e}")); - return; - } - }, - None => std::collections::HashMap::new(), - }; - let backup = sv.backup; - - self.jobs.run(format!("Batch update {}", dir.display()), &mut self.log, move |rep| { - rep.progress(0.0, "scanning saves…"); - let outcome = saves::batch_update( - &dir, - new_title.as_deref(), - &map, - backup, - |frac, msg| rep.progress(frac, msg), - )?; - if let Some(bdir) = &outcome.backup_dir { - rep.info(format!("backed up originals → {}", bdir.display())); - } - for entry in &outcome.entries { - match entry { - saves::BatchEntry::Updated(name, stats) => rep.info(format!( - " {name}: title={} strings={} enc={}", - u8::from(stats.title_changed), - stats.strings_replaced, - stats.encoding - )), - saves::BatchEntry::Skipped(name) => { - rep.warn(format!(" {name}: SKIPPED (unsupported save format)")) - } - saves::BatchEntry::Failed(name, msg) => { - rep.error(format!(" {name}: {msg}")) - } - } - } - rep.progress(1.0, "done"); - Ok(format!( - "{} updated, {} skipped, {} failed", - outcome.updated(), - outcome.skipped(), - outcome.failed() - )) - }); - } - - fn ui_verify(&mut self, ui: &mut egui::Ui) { - // Pump any background result (a single verdict / a corpus outcome) into section state first. - self.poll_verify_result(); - - ui.heading("Verify"); - widgets::caption( - ui, - "Verify confirms a file decodes and re-encodes without loss (byte-exact where possible) \ - — your safety net before shipping.", - ); - ui.add_space(6.0); - - // Default the corpus folder to the open project's data dir the first time we're shown. - if self.state.verify.corpus_dir.is_none() { - if let Some(d) = self.project.as_ref().and_then(|p| p.data_dir.clone()) { - self.state.verify.corpus_dir = Some(d); - } - } - - egui::CollapsingHeader::new("Single file") - .default_open(true) - .show(ui, |ui| self.verify_single_pane(ui)); - - ui.add_space(8.0); - - egui::CollapsingHeader::new("Corpus (a whole folder)") - .default_open(true) - .show(ui, |ui| self.verify_corpus_pane(ui)); - } - - /// The single-file pane: pick a data file, Verify it, show a PASS/FAIL/ERR result panel. - fn verify_single_pane(&mut self, ui: &mut egui::Ui) { - let mut file = self.state.verify.file.clone(); - widgets::path_field( - ui, - "Data file", - &mut file, - "Any single data file (.mps map, CommonEvent.dat, Game.dat, or a database .project).", - || { - widgets::pick_file(&[ - ("Wolf data", &["mps", "dat", "project"]), - ("All files", &["*"]), - ]) - }, - ); - self.state.verify.file = file; - - // Warn early if the pick isn't a verifiable file type. Verify still runs and gives an ERR verdict. - if let Some(f) = &self.state.verify.file { - if !verify::is_supported(f) { - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - "Warning: this is not a verifiable file type (.mps, CommonEvent.dat, Game.dat, or a database .project).", - ); - } - } - - ui.add_space(4.0); - let running = self.jobs.is_running(); - let have = self.state.verify.file.is_some(); - ui.add_enabled_ui(have && !running, |ui| { - if ui - .button("Verify ▶") - .on_hover_text("Round-trip this file on a background thread and report PASS / FAIL.") - .clicked() - { - self.start_verify_file_job(); - } - }); - if !have { - widgets::caption(ui, "Pick a data file to verify."); - } - - // The result panel for the last single-file verdict. - if let Some((path, verdict)) = &self.state.verify.file_verdict { - ui.add_space(6.0); - let (color, tag) = verdict_color(verdict); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!(" {tag} ")) - .strong() - .background_color(color) - .color(egui::Color32::BLACK), - ); - ui.label(egui::RichText::new(path.display().to_string()).weak()); - }); - ui.label(verdict.detail()); - } - } - - /// The corpus pane: pick a folder, Verify all, show a per-file table + the byte-exact summary. - fn verify_corpus_pane(&mut self, ui: &mut egui::Ui) { - let start = self.state.verify.corpus_dir.clone(); - let mut dir = self.state.verify.corpus_dir.clone(); - widgets::path_field( - ui, - "Folder", - &mut dir, - "A folder of game data; every supported file under it is round-tripped.", - move || widgets::pick_folder_in(start.as_deref()), - ); - self.state.verify.corpus_dir = dir; - - ui.add_space(4.0); - let running = self.jobs.is_running(); - let have = self.state.verify.corpus_dir.is_some(); - ui.add_enabled_ui(have && !running, |ui| { - if ui - .button("Verify all ▶") - .on_hover_text("Walk the folder and round-trip every supported file on a background thread.") - .clicked() - { - self.start_verify_corpus_job(); - } - }); - if !have { - widgets::caption(ui, "Pick a folder to verify its files."); - } - - if let Some(outcome) = &self.state.verify.corpus { - ui.add_space(6.0); - let (pass, fail, err, total) = - (outcome.passed(), outcome.failed(), outcome.errored(), outcome.total()); - let summary = egui::RichText::new(format!("{pass}/{total} byte-exact")) - .strong() - .color(if fail == 0 && err == 0 { - egui::Color32::from_rgb(0x40, 0xA0, 0x60) - } else { - egui::Color32::from_rgb(0xE0, 0xB0, 0x40) - }); - ui.horizontal(|ui| { - ui.label(summary); - if fail > 0 || err > 0 { - ui.label( - egui::RichText::new(format!("· {fail} differ · {err} error")).weak(), - ); - } - }); - ui.add_space(4.0); - self.verify_corpus_table(ui); - } - } - - /// The per-file corpus result table: file | result. - fn verify_corpus_table(&mut self, ui: &mut egui::Ui) { - use egui_extras::{Column, TableBuilder}; - - let Some(outcome) = &self.state.verify.corpus else { - return; - }; - let text_h = egui::TextStyle::Body.resolve(ui.style()).size; - TableBuilder::new(ui) - .striped(true) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::TOP)) - .column(Column::remainder().at_least(200.0)) // File - .column(Column::auto().at_least(220.0)) // Result - .header(text_h + 6.0, |mut header| { - header.col(|ui| { - ui.strong("File"); - }); - header.col(|ui| { - ui.strong("Result"); - }); - }) - .body(|mut body| { - for row in &outcome.rows { - body.row(text_h * 1.8, |mut r| { - r.col(|ui| { - ui.add( - egui::Label::new(egui::RichText::new(&row.rel).monospace()).wrap(), - ); - }); - r.col(|ui| { - let (color, tag) = verdict_color(&row.verdict); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!(" {tag} ")) - .strong() - .background_color(color) - .color(egui::Color32::BLACK), - ); - if !row.verdict.is_pass() { - ui.add( - egui::Label::new( - egui::RichText::new(row.verdict.detail()).weak().small(), - ) - .wrap(), - ); - } - }); - }); - }); - } - }); - } - - // --- Verify background jobs + sync helpers --------------------------------------------- - - /// Drain the heavy-result side-channel: a finished verify job hands its verdict / corpus here - /// (mirrors `poll_translation_result` / `poll_saves_result`). - fn poll_verify_result(&mut self) { - let Some(rx) = &self.state.verify.result_rx else { - return; - }; - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(VerifyResult::Single(path, v)) => { - self.state.verify.file_verdict = Some((path, *v)); - } - Ok(VerifyResult::Corpus(c)) => self.state.verify.corpus = Some(*c), - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - if disconnected && !self.jobs.is_running() { - self.state.verify.result_rx = None; - } - } - - /// Spawn the single-file Verify job: round-trip the file on a worker thread, hand the verdict - /// back via the side-channel, and log a PASS/FAIL/ERR line. - fn start_verify_file_job(&mut self) { - let Some(path) = self.state.verify.file.clone() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.verify.result_rx = Some(rx); - self.jobs.run(format!("Verify {}", path.display()), &mut self.log, move |rep| { - rep.progress(0.2, "round-tripping…"); - let verdict = verify::verify_file(&path); - let summary = format!("{} — {}", verdict.tag(), verdict.detail()); - match &verdict { - Verdict::Pass => rep.info(summary.clone()), - Verdict::Fail { .. } => rep.warn(summary.clone()), - Verdict::Error(_) => rep.error(summary.clone()), - } - let _ = tx.send(VerifyResult::Single(path.clone(), Box::new(verdict))); - rep.progress(1.0, "done"); - Ok(summary) - }); - } - - /// Spawn the Corpus Verify job: walk + round-trip every supported file on a worker thread, hand - /// the outcome back via the side-channel, and log the summary. - fn start_verify_corpus_job(&mut self) { - let Some(dir) = self.state.verify.corpus_dir.clone() else { - return; - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.state.verify.result_rx = Some(rx); - self.jobs.run(format!("Verify corpus {}", dir.display()), &mut self.log, move |rep| { - let outcome = verify::verify_corpus(&dir, |frac, msg| rep.progress(frac, msg))?; - let (pass, fail, err, total) = - (outcome.passed(), outcome.failed(), outcome.errored(), outcome.total()); - // Surface every non-pass file in the log too. - for row in &outcome.rows { - if !row.verdict.is_pass() { - rep.warn(format!(" {} — {}: {}", row.verdict.tag(), row.rel, row.verdict.detail())); - } - } - let _ = tx.send(VerifyResult::Corpus(Box::new(outcome))); - Ok(format!("{pass}/{total} byte-exact ({fail} differ, {err} error)")) - }); - } - - fn ui_settings(&mut self, ui: &mut egui::Ui) { - ui.heading("Settings"); - widgets::caption(ui, "Theme and tool preferences. These persist across runs."); - ui.add_space(8.0); - - // --- Theme --- - ui.label(egui::RichText::new("Theme").strong()); - ui.add_space(2.0); - ui.horizontal(|ui| { - // A Dark/Light selector. Applying the visuals immediately on a change keeps it live. - let mut dark = self.settings.dark_mode; - let mut changed = ui - .selectable_value(&mut dark, true, "Dark") - .on_hover_text("Dark theme (the default).") - .changed(); - changed |= ui - .selectable_value(&mut dark, false, "Light") - .on_hover_text("Light theme.") - .changed(); - if changed { - self.settings.dark_mode = dark; - self.apply_theme(ui.ctx()); - } - }); - ui.add_space(8.0); - ui.separator(); - ui.add_space(8.0); - - // --- Translation defaults --- - ui.label(egui::RichText::new("Translation defaults").strong()); - ui.add_space(2.0); - widgets::caption( - ui, - "Used to initialise the Translation section's inject options; changing one here doesn't \ - override what you've already set this session.", - ); - widgets::labeled_checkbox( - ui, - &mut self.settings.default_en_punct, - "Default --en-punct on", - "Start the Translation section with \"convert Japanese punctuation to ASCII\" enabled.", - ); - widgets::labeled_checkbox( - ui, - &mut self.settings.default_allow_code_drift, - "Default --allow-code-drift on", - "Start the Translation section with the strict control-code guard relaxed (risky - leave \ - OFF unless you know you need it).", - ); - ui.add_space(8.0); - ui.separator(); - ui.add_space(8.0); - - // --- Saves / Database defaults --- - ui.label(egui::RichText::new("Saves & Database defaults").strong()); - ui.add_space(2.0); - widgets::labeled_checkbox( - ui, - &mut self.settings.default_backup, - "Back up originals by default", - "Start the Saves / Database / Game.dat sections with \"back up originals before \ - overwriting\" enabled. Leave ON unless you keep your own backups.", - ); - ui.add_space(8.0); - ui.separator(); - ui.add_space(8.0); - - // --- Auto-detect --- - ui.label(egui::RichText::new("Files").strong()); - ui.add_space(2.0); - widgets::labeled_checkbox( - ui, - &mut self.settings.auto_detect, - "Auto-detect new files", - "While a game is open, periodically re-scan its folders so newly-added maps / databases \ - / saves (e.g. right after an Unpack) appear in the per-section dropdowns automatically.", - ); - if !self.settings.auto_detect { - widgets::caption( - ui, - "Auto-detect is off — use the Refresh button on the Project section to re-scan.", - ); - } - - ui.add_space(10.0); - widgets::caption(ui, "Settings are saved automatically when you close the app."); - } - - // --- chrome --------------------------------------------------------------------------- - - /// Top panel: app title + a one-line summary of the open project. - fn top_bar(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { - ui.horizontal(|ui| { - ui.heading("WolfDawn Studio"); - ui.separator(); - match &self.project { - None => { - ui.label(egui::RichText::new("No project").weak()); - } - Some(p) => { - let title = if p.title.is_empty() { "(untitled)" } else { &p.title }; - ui.label(egui::RichText::new(title).strong()); - ui.separator(); - ui.label(project::encoding_label(p.utf8)); - if !p.version.is_empty() { - ui.separator(); - ui.label(&p.version); - } - if !p.font.is_empty() { - ui.separator(); - ui.label(format!("font: {}", p.font)); - } - } - } - }); - }); - } - - /// Left panel: one selectable button per section. - fn side_bar(&mut self, ctx: &egui::Context) { - egui::SidePanel::left("nav") - .resizable(false) - .exact_width(150.0) - .show(ctx, |ui| { - ui.add_space(6.0); - ui.spacing_mut().item_spacing.y = 2.0; - for section in Section::ALL { - let selected = self.section == section; - if nav_button(ui, selected, section.name()).clicked() { - self.section = section; - } - } - }); - } - - /// Bottom panel: the log, plus the progress bar while a job runs. - fn bottom_bar(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::bottom("status") - .resizable(true) - .default_height(150.0) - .show(ctx, |ui| { - if self.jobs.is_running() { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(self.jobs.label()).strong()); - if !self.jobs.status().is_empty() { - ui.label(egui::RichText::new(self.jobs.status()).weak()); - } - }); - ui.add( - egui::ProgressBar::new(self.jobs.progress()) - .show_percentage() - .animate(true), - ); - ui.separator(); - } - ui.label(egui::RichText::new("Log").weak()); - self.log.show(ui); - }); - } -} - -impl WolfDawnApp { - /// Lay out and render one frame's worth of UI into `ctx`: pump the job channel, draw the four - /// panels, and request a repaint while a job runs. This is the whole per-frame body, factored - /// out of [`eframe::App::update`] so the headless tests can drive a full frame without an - /// `eframe::Frame` (which has no public constructor). - pub fn render_frame(&mut self, ctx: &egui::Context) { - // Pump the background job channel first so progress/log are current for this frame. - let running = self.jobs.poll(&mut self.log); - - // A job just finished this frame (running→idle): if it was an Unpack, immediately rescan the - // project index so the extracted files appear in the dropdowns now, not on the next throttle. - if self.job_was_running && !running { - self.on_job_finished(); - } - self.job_was_running = running; - - // Live auto-detect: while a project is open (and auto-detect is enabled), periodically - // re-walk its small index dirs so files added externally (or by a just-finished Unpack) - // surface in the per-section dropdowns without a manual refresh. - self.tick_auto_detect(); - - self.top_bar(ctx); - self.side_bar(ctx); - self.bottom_bar(ctx); - egui::CentralPanel::default().show(ctx, |ui| { - self.render_current_section(ui); - }); - - // While a job runs, keep animating even without user input so the bar moves and messages - // arrive promptly (otherwise egui only repaints on interaction). - if running { - ctx.request_repaint(); - } else if self.project.is_some() && self.settings.auto_detect { - // Keep the auto-detect tick running without user input while a project is open: ask for - // a repaint ~1s out so the throttled rescan keeps firing even on an idle window. - ctx.request_repaint_after(Duration::from_secs(1)); - } - } - - /// The throttled auto-detect tick: when a project is open and auto-detect is on, re-walk its - /// index at most every ~1.5s. Log a one-line summary only when the file set actually changed. - fn tick_auto_detect(&mut self) { - /// How long between background rescans of the open project's index dirs. - const RESCAN_EVERY: Duration = Duration::from_millis(1500); - - if self.project.is_none() || !self.settings.auto_detect { - // Reset the clock so re-enabling / re-opening rescans promptly rather than waiting. - self.last_scan = None; - return; - } - let due = self.last_scan.map_or(true, |t| t.elapsed() >= RESCAN_EVERY); - if !due { - return; - } - self.last_scan = Some(Instant::now()); - if let Some(project) = self.project.as_mut() { - if project.rescan() { - self.log.info(format!( - "detected file changes ({} code file(s), {} database(s), {} save(s))", - project.code_files.len(), - project.databases.len(), - project.saves.len(), - )); - } - } - } - - /// Handle a job finishing (the running→idle transition). When the finished job was an Unpack and - /// a project is open: adopt the output dir as `data_dir` if it sits under the project root (so - /// Translation/Verify default to the just-extracted folder), then rescan the index immediately - /// so the freshly-extracted code/databases/saves populate the dropdowns at once. - fn on_job_finished(&mut self) { - let Some(out) = self.pending_unpack_out.take() else { - return; - }; - let Some(project) = self.project.as_mut() else { - return; - }; - // Adopt the unpack output as the data dir when it lives under (or is) the project root. - if out.starts_with(&project.root) && project.data_dir.as_deref() != Some(out.as_path()) { - project.data_dir = Some(out.clone()); - self.log.info(format!("data dir set to unpack output: {}", out.display())); - } - if project.rescan() { - self.log.info(format!( - "detected file changes ({} code file(s), {} database(s), {} save(s)) after unpack", - project.code_files.len(), - project.databases.len(), - project.saves.len(), - )); - } - // Force the next throttled tick to run promptly too (the index just moved). - self.last_scan = Some(Instant::now()); - } -} - -impl eframe::App for WolfDawnApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.render_frame(ctx); - } - - /// Persist the user [`Settings`] to eframe's on-disk storage. eframe calls this periodically and - /// on exit (the `persistence` feature provides the storage backend under the OS config dir). - fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, Self::SETTINGS_KEY, &self.settings); - } -} - -/// Render the open project's details into the Project body. -fn project_details(ui: &mut egui::Ui, p: &Project) { - egui::Grid::new("project_details") - .num_columns(2) - .spacing([16.0, 6.0]) - .striped(true) - .show(ui, |ui| { - row(ui, "Title", if p.title.is_empty() { "—".into() } else { p.title.clone() }); - row(ui, "Version", if p.version.is_empty() { "—".into() } else { p.version.clone() }); - row(ui, "Encoding", project::encoding_label(p.utf8).to_string()); - row(ui, "Font", if p.font.is_empty() { "—".into() } else { p.font.clone() }); - row(ui, "Game root", p.root.display().to_string()); - row(ui, "Data.wolf", opt_path(&p.data_wolf)); - row(ui, "Data dir", opt_path(&p.data_dir)); - row(ui, "Game.dat", opt_path(&p.game_dat)); - row( - ui, - "Indexed", - format!( - "{} code file(s) · {} database(s) · {} save(s)", - p.code_files.len(), - p.databases.len(), - p.saves.len(), - ), - ); - }); - - if !p.game_dat_ok { - ui.add_space(8.0); - ui.colored_label( - egui::Color32::from_rgb(0xE0, 0xB0, 0x40), - "Warning: Game.dat was not found or could not be read - the project is open with what was located. See the log below.", - ); - } -} - -fn row(ui: &mut egui::Ui, key: &str, value: String) { - ui.label(egui::RichText::new(key).strong()); - ui.label(value); - ui.end_row(); -} - -fn opt_path(p: &Option) -> String { - p.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "—".into()) -} - -/// Hand a (possibly large) model back to the UI thread over the translation side-channel. Boxed so -/// the `TrResult` enum stays small. A send failure (UI hung up / app closing) is a no-op. -fn send_model(tx: &Sender, model: TranslationModel) { - let _ = tx.send(TrResult::Model(Box::new(model))); -} - -/// A project-file dropdown: a `ComboBox` over `options` (the scanned index for this section). The -/// shown text is each path's basename (or its tail relative to a common ancestor when basenames -/// collide, so two `Map001.mps` from different folders stay distinguishable). The *full* path is -/// stored into `target` on selection. Returns `true` when the selection changed this frame. -/// -/// Paths are stored as `Option` everywhere in the sections (matching `widgets::path_field`), -/// so the dropdown reuses that type rather than a raw `String`. Renders nothing (and returns false) -/// when there are no options, so a section with an empty index falls back to its Browse button. -fn project_combo( - ui: &mut egui::Ui, - id: &str, - label: &str, - options: &[PathBuf], - target: &mut Option, - tooltip: &str, -) -> bool { - if options.is_empty() { - return false; - } - let mut changed = false; - // Use the shared form row so the combo's label sits in the same fixed left column as the path - // rows, and fill the width up to the same right edge those rows' inputs reach (leaving the - // BTN_COL reserve), so the dropdown lines up with the Browse fields under it. - widgets::form_row(ui, label, tooltip, |ui| { - // The selected text: the current target's display name, or a prompt when none is chosen. - let selected_text = target - .as_ref() - .map(|p| combo_label(p, options)) - .unwrap_or_else(|| "— pick from project —".to_string()); - let combo_w = (ui.available_width() - widgets::BTN_COL_W).max(120.0); - egui::ComboBox::from_id_salt(id) - .selected_text(selected_text) - .width(combo_w) - .show_ui(ui, |ui| { - for opt in options { - let is_sel = target.as_deref() == Some(opt.as_path()); - if ui - .selectable_label(is_sel, combo_label(opt, options)) - .on_hover_text(opt.display().to_string()) - .clicked() - && !is_sel - { - *target = Some(opt.clone()); - changed = true; - } - } - }) - .response - .on_hover_text(tooltip); - }); - changed -} - -/// The label shown for one project-combo entry: the file's basename, disambiguated with its parent -/// dir's name when another option shares the same basename (e.g. `MapData/Map001.mps`). -fn combo_label(path: &Path, options: &[PathBuf]) -> String { - let base = path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); - let collides = options - .iter() - .filter(|o| o.as_path() != path) - .any(|o| o.file_name() == path.file_name()); - if collides { - if let Some(parent) = path.parent().and_then(|p| p.file_name()) { - return format!("{}/{}", parent.to_string_lossy(), base); - } - } - base -} - -/// A full-width sidebar nav row. Text-only (no icon column): emoji pictographs render as tofu on -/// systems lacking an emoji font, so the sidebar shows just the section name, left-aligned at a -/// fixed x so every row's text starts at the same column. Mimics `SelectableLabel` visuals. -fn nav_button(ui: &mut egui::Ui, selected: bool, name: &str) -> egui::Response { - let height = 26.0; - let (rect, resp) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), height), egui::Sense::click()); - let visuals = ui.style().interact_selectable(&resp, selected); - if selected { - ui.painter() - .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill); - } else if resp.hovered() { - ui.painter().rect_filled(rect, 4.0, visuals.weak_bg_fill); - } - let color = visuals.text_color(); - let font = egui::FontId::proportional(15.0); - let mid_y = rect.center().y; - ui.painter().text( - egui::pos2(rect.left() + 10.0, mid_y), - egui::Align2::LEFT_CENTER, - name, - font, - color, - ); - resp -} - -/// A full-width, multi-line-aware selectable list row (used by the Database type list). Unlike -/// `ui.selectable_label`, this allocates the row at the *wrapped* text height and paints its own -/// rounded, padded highlight, so a long type name that wraps to two lines (e.g. -/// `アニメーション / Animation (34 rows)`) is fully contained by the highlight instead of being -/// clipped by a harsh single-line fill. Mirrors `nav_button`'s painting approach: selected uses -/// `selection.bg_fill`, hovered uses a subtle `weak_bg_fill`, with readable selected-text colour. -/// Returns the row's `Response` (so the caller handles `.clicked()`). -fn selectable_row(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response { - /// Inner padding around the label inside the highlight. - const PAD_X: f32 = 8.0; - const PAD_Y: f32 = 4.0; - /// Rounded-corner radius of the highlight (matches `nav_button`). - const ROUNDING: f32 = 4.0; - - let full_w = ui.available_width(); - // Lay out the label wrapped to the row width (minus the horizontal padding) so we know how tall - // the row must be to contain every wrapped line. - let wrap_w = (full_w - 2.0 * PAD_X).max(1.0); - let galley = ui.painter().layout( - text.to_owned(), - egui::TextStyle::Body.resolve(ui.style()), - // PLACEHOLDER so the glyph colour is taken from the `fallback_color` passed to - // `painter.galley` below, the interaction visuals' (selected-aware) text colour. - egui::Color32::PLACEHOLDER, - wrap_w, - ); - let row_h = galley.size().y + 2.0 * PAD_Y; - - let (rect, resp) = - ui.allocate_exact_size(egui::vec2(full_w, row_h), egui::Sense::click()); - let visuals = ui.style().interact_selectable(&resp, selected); - if selected { - ui.painter() - .rect_filled(rect, ROUNDING, ui.visuals().selection.bg_fill); - } else if resp.hovered() { - ui.painter().rect_filled(rect, ROUNDING, visuals.weak_bg_fill); - } - // Draw the (already-wrapped) label with padding inside the highlight, using the interaction - // visuals' text colour so the selected row stays readable. - ui.painter().galley( - egui::pos2(rect.left() + PAD_X, rect.top() + PAD_Y), - galley, - visuals.text_color(), - ); - resp -} - -/// Parse a cryptVersion from the Repack "match version" field: accepts `0x14b` (hex) or a decimal -/// number, mirroring `cmd_pack`'s `--version` parsing. `None` for anything that doesn't parse. -fn parse_crypt_version(text: &str) -> Option { - let t = text.trim(); - if t.is_empty() { - return None; - } - let stripped = t.trim_start_matches("0x").trim_start_matches("0X"); - u16::from_str_radix(stripped, 16).ok().or_else(|| t.parse().ok()) -} - -/// The badge colour + tag word for a verify [`Verdict`] (green PASS / amber FAIL / red ERR). -fn verdict_color(verdict: &Verdict) -> (egui::Color32, &'static str) { - match verdict { - Verdict::Pass => (egui::Color32::from_rgb(0x40, 0xA0, 0x60), "PASS"), - Verdict::Fail { .. } => (egui::Color32::from_rgb(0xE0, 0xB0, 0x40), "FAIL"), - Verdict::Error(_) => (egui::Color32::from_rgb(0xE0, 0x60, 0x60), "ERR"), - } -} - -/// Append a Japanese system font as a fallback so CJK glyphs render. egui ships Latin-only fonts, so -/// without this every Japanese string in the tool shows as tofu (□). The JP font goes to the END of -/// both families, so Latin keeps the crisp default and only glyphs the default lacks (kana/kanji) -/// fall back to it. Tries a few common Windows fonts. If none is found it's a no-op (Latin only). -fn install_cjk_font(ctx: &egui::Context) { - const CANDIDATES: &[&str] = &[ - r"C:\Windows\Fonts\YuGothR.ttc", - r"C:\Windows\Fonts\meiryo.ttc", - r"C:\Windows\Fonts\msgothic.ttc", - r"C:\Windows\Fonts\YuGothM.ttc", - r"C:\Windows\Fonts\msmincho.ttc", - ]; - let Some(bytes) = CANDIDATES.iter().find_map(|p| std::fs::read(p).ok()) else { - return; - }; - let mut fonts = egui::FontDefinitions::default(); - fonts - .font_data - .insert("jp".to_owned(), egui::FontData::from_owned(bytes)); - for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { - fonts.families.entry(family).or_default().push("jp".to_owned()); - } - ctx.set_fonts(fonts); -} - -/// A short, always-renderable text tag for each translatable file kind in the left list (shown in -/// brackets before the file name). Replaces the old emoji icons so the list never tofus. -fn kind_icon(kind: FileKind) -> &'static str { - match kind { - FileKind::Map => "[Map]", - FileKind::CommonEvent => "[Common]", - FileKind::Database => "[DB]", - FileKind::GameDat => "[Game]", - FileKind::TxtEvent => "[Text]", - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/archive.rs b/util/wolfdawn-master/crates/wolf-gui/src/archive.rs deleted file mode 100644 index 92dabaf..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/archive.rs +++ /dev/null @@ -1,463 +0,0 @@ -//! The Archive section's headless logic: unpack a `.wolf` to a folder and repack a folder back -//! into a `.wolf`, calling the **same** `wolf_archive` library functions the CLI's `cmd_unpack` / -//! `cmd_pack` use (no shelling out). The egui rendering lives in `app.rs`. This module owns the -//! file-level work so the whole flow can be exercised headlessly in tests. -//! -//! * [`unpack`] mirrors `cmd_unpack`: read the archive, call [`wolf_archive::extract_archive`] -//! (which handles any supported crypt regime), then write each file into the -//! output dir under a *sanitised* relative path (the same `..`/`.`-stripping the CLI does, so a -//! hostile or malformed archive path can't escape the output folder). -//! * [`repack`] mirrors `cmd_pack`: gather `(relative-path, bytes)` for every file under the input -//! folder, then call the matching `wolf_archive::pack_*` path for the chosen [`PackOptions`] -//! (plaintext / encrypted / new-crypt / `--like` an existing archive / the legacy ver5|ver6 -//! containers). -//! -//! Repack fidelity: an unpack/repack/unpack round-trips the *file set and contents* byte-for-byte, -//! but a fresh `.wolf` is not guaranteed byte-identical to an editor-produced original (see the -//! `wolf_archive::pack` notes, the header/compression differs slightly). The library's own -//! `extract_archive` reads our output back losslessly, which is what the verify/round-trip tests -//! assert. - -use std::path::{Path, PathBuf}; - -/// The CLI's default cryptVersion when `--encrypt` is given without an explicit `--version` -/// (Wolf v3.00). Kept in sync with `cmd_pack`'s `version` default. -pub const DEFAULT_CRYPT_VERSION: u16 = 0x12C; - -/// The default new-crypt password used when encrypting a new-crypt version (`0x14B`/`0x15E`) -/// without a `--like` source or an explicit password. Mirrors `cmd_pack`'s `default_pwd`. The -/// archive embeds whatever password it was built with, so it stays self-describing. -const DEFAULT_NEWCRYPT_PWD: [u8; 15] = *b"WolfDawnRepack!"; - -/// The container format the user picked, mirroring the CLI `pack --format` flag. `Auto` is the -/// modern VER8 container (plaintext, or encrypted per [`PackOptions::encrypt`]); `Ver5`/`Ver6` are -/// the legacy DXArchive containers for Wolf 2.0x. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub enum PackFormat { - #[default] - Auto, - Ver5, - Ver6, -} - -impl PackFormat { - /// The dropdown label. - pub fn label(self) -> &'static str { - match self { - PackFormat::Auto => "auto", - PackFormat::Ver5 => "ver5", - PackFormat::Ver6 => "ver6", - } - } -} - -/// Where the encryption parameters for an encrypted repack come from. Mutually exclusive (a small -/// radio in the UI): the CLI default cryptVersion, a manually-typed version, or inherited from an -/// existing `.wolf` (`--like`). Only consulted when [`PackOptions::encrypt`] is set and the format -/// is [`PackFormat::Auto`]. -#[derive(Clone, PartialEq, Eq, Debug)] -pub enum CryptSource { - /// The CLI default cryptVersion ([`DEFAULT_CRYPT_VERSION`]). - Default, - /// A manually-entered cryptVersion (e.g. `0x14b`). - Version(u16), - /// Inherit cryptVersion + embedded password from an existing archive (turnkey repack). - Like(PathBuf), -} - -impl Default for CryptSource { - fn default() -> Self { - CryptSource::Default - } -} - -/// The repack options, assembled from the UI controls. The GUI equivalent of `cmd_pack`'s flags. -#[derive(Clone, Debug)] -pub struct PackOptions { - /// Encrypt the output (the `--encrypt` flag). Ignored for the legacy ver5/ver6 formats, which - /// have their own (un-keyed) container. - pub encrypt: bool, - /// Where the crypt params come from when `encrypt` is set on an `Auto`-format archive. - pub crypt: CryptSource, - /// The container format. - pub format: PackFormat, -} - -impl Default for PackOptions { - fn default() -> Self { - Self { - encrypt: false, - crypt: CryptSource::Default, - format: PackFormat::Auto, - } - } -} - -/// The outcome of an [`unpack`]: how many files were written, and the (already-logged) names of any -/// files whose archive path had to be sanitised (rare, a malformed/hostile path). -pub struct UnpackOutcome { - /// Number of files written to the output dir. - pub written: usize, - /// `(original archive path, sanitised path)` for files whose name needed `..`/`.` stripped. - pub sanitised: Vec<(String, String)>, - /// The output directory files were written under. - pub out_dir: PathBuf, -} - -/// Sanitise an archive's inner path into a safe relative path: drop empty/`.`/`..` segments and the -/// drive/leading-separator, exactly like `cmd_unpack`, so a file can never escape the output dir. -fn safe_relative(path: &str) -> PathBuf { - path.split(['\\', '/']) - .filter(|s| !s.is_empty() && *s != "." && *s != "..") - .collect() -} - -/// Extract every file from a `.wolf` archive into `out_dir`, preserving the inner directory tree. -/// Calls the library's [`wolf_archive::extract_archive`] (the same path `cmd_unpack` uses), which -/// handles every crypt regime and is resilient to odd archives. `report` drives a progress bar. Pass -/// a no-op in tests. Returns the file count + any sanitised-path warnings. -/// -/// The empty key string (`b""`) lets the library pick the right default WolfPro key, matching the -/// CLI. Files are written under a sanitised relative path so a malformed archive entry cannot write -/// outside `out_dir`. -pub fn unpack( - archive: &Path, - out_dir: &Path, - mut report: impl FnMut(f32, String), -) -> Result { - report(0.05, "reading archive…".to_string()); - let data = std::fs::read(archive) - .map_err(|e| format!("cannot read {}: {e}", archive.display()))?; - - report(0.3, "decoding archive…".to_string()); - let files = wolf_archive::extract_archive(&data, b"") - .map_err(|e| format!("not a readable Wolf archive ({e})"))?; - let total = files.len(); - if total == 0 { - return Err("the archive holds no files".to_string()); - } - - std::fs::create_dir_all(out_dir) - .map_err(|e| format!("cannot create {}: {e}", out_dir.display()))?; - - let mut sanitised = Vec::new(); - let mut written = 0usize; - for (i, (path, bytes)) in files.iter().enumerate() { - // Progress over the back 0.3..1.0 of the bar (the table decode took the first 0.3). - if total > 0 { - let frac = 0.3 + 0.7 * (i as f32) / (total as f32); - report(frac, format!("writing {path}")); - } - let safe = safe_relative(path); - if safe.as_os_str().is_empty() { - // A path that sanitised away to nothing (all `.`/`..`); skip it rather than write to the - // dir root. Record it so the caller can warn. - sanitised.push((path.clone(), String::new())); - continue; - } - // If sanitising changed the path, note it (so the UI can warn about the odd entry). - let safe_str = safe.to_string_lossy().replace('\\', "/"); - let orig_norm = path.replace('\\', "/"); - if safe_str != orig_norm { - sanitised.push((path.clone(), safe_str.clone())); - } - let target = out_dir.join(&safe); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; - } - std::fs::write(&target, bytes) - .map_err(|e| format!("cannot write {}: {e}", target.display()))?; - written += 1; - } - report(1.0, "done".to_string()); - Ok(UnpackOutcome { - written, - sanitised, - out_dir: out_dir.to_path_buf(), - }) -} - -/// The outcome of an [`unpack_all`]: how many archives unpacked, how many failed, the total file -/// count, the output root, and a per-archive `(name, written-or-error)` list for the log. -pub struct UnpackAllOutcome { - pub ok: usize, - pub failed: usize, - pub files: usize, - pub out_root: PathBuf, - pub details: Vec<(String, Result)>, -} - -/// Unpack every `.wolf` archive directly inside `dir` into `//`. A per-category -/// game splits its data across many archives (BasicData.wolf, MapData.wolf, Evtext.wolf, ...); -/// this takes the whole set apart in one pass. Each `Name.wolf` lands in `/Name/`, which -/// mirrors how the engine maps a category archive to a `Name/` folder, so the result is a loose -/// data tree the game can load and the per-file extractors can read. One bad archive is recorded -/// and skipped rather than aborting the run. Mirrors the CLI `unpack-all`. -pub fn unpack_all( - dir: &Path, - out_root: &Path, - mut report: impl FnMut(f32, String), -) -> Result { - let mut archives: Vec = std::fs::read_dir(dir) - .map_err(|e| format!("cannot read {}: {e}", dir.display()))? - .flatten() - .map(|e| e.path()) - .filter(|p| { - p.is_file() - && p.extension() - .and_then(|x| x.to_str()) - .map(|x| x.eq_ignore_ascii_case("wolf")) - .unwrap_or(false) - }) - .collect(); - archives.sort(); - if archives.is_empty() { - return Err(format!("no .wolf archives found in {}", dir.display())); - } - - let total = archives.len(); - let mut out = UnpackAllOutcome { - ok: 0, - failed: 0, - files: 0, - out_root: out_root.to_path_buf(), - details: Vec::with_capacity(total), - }; - for (i, arc) in archives.iter().enumerate() { - let stem = arc - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("archive") - .to_string(); - let name = arc - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(&stem) - .to_string(); - report(i as f32 / total as f32, format!("unpacking {name}…")); - let dest = out_root.join(&stem); - // Swallow the inner per-file progress; the outer bar steps once per archive. - match unpack(arc, &dest, |_f, _m| {}) { - Ok(o) => { - out.ok += 1; - out.files += o.written; - out.details.push((name, Ok(o.written))); - } - Err(e) => { - out.failed += 1; - out.details.push((name, Err(e))); - } - } - } - report(1.0, "done".to_string()); - Ok(out) -} - -/// The outcome of a [`repack`]: the output path, the file count packed, and the output size. -pub struct RepackOutcome { - pub out: PathBuf, - pub files: usize, - pub bytes: usize, - /// A short human description of the crypto mode used (for the log). - pub mode: String, -} - -/// Recursively gather `(relative-path, bytes)` for every file under `dir`, the same as `cmd_pack`'s -/// `gather_files` (slashes normalised so the archive's inner paths are platform-independent). -fn gather_files(base: &Path, dir: &Path, out: &mut Vec<(String, Vec)>) -> Result<(), String> { - let rd = std::fs::read_dir(dir).map_err(|e| format!("cannot read {}: {e}", dir.display()))?; - for entry in rd { - let p = entry.map_err(|e| e.to_string())?.path(); - if p.is_dir() { - gather_files(base, &p, out)?; - } else { - let rel = p - .strip_prefix(base) - .unwrap_or(&p) - .to_string_lossy() - .replace('\\', "/"); - let bytes = std::fs::read(&p).map_err(|e| format!("cannot read {}: {e}", p.display()))?; - out.push((rel, bytes)); - } - } - Ok(()) -} - -/// Pack every file under `input_dir` into a `.wolf` at `out`, using the crypto/format the -/// [`PackOptions`] describe. Dispatches to the same `wolf_archive::pack_*` functions `cmd_pack` does. -/// `report` drives a progress bar. Pass a no-op in tests. -/// -/// For an encrypted `Auto` archive the cryptVersion (and, for `--like`, the embedded password) is -/// resolved up front. New-crypt versions (`0x14B`/`0x15E`) use the embedded default password when no -/// `--like` source is given. The output is written to `out` and its size reported. -pub fn repack( - input_dir: &Path, - out: &Path, - opts: &PackOptions, - mut report: impl FnMut(f32, String), -) -> Result { - report(0.05, "gathering files…".to_string()); - let mut files: Vec<(String, Vec)> = Vec::new(); - gather_files(input_dir, input_dir, &mut files)?; - if files.is_empty() { - return Err(format!("no files found under {}", input_dir.display())); - } - files.sort(); - report(0.4, format!("packing {} file(s)…", files.len())); - - // Resolve the crypto mode, mirroring cmd_pack's dispatch. - let (bytes, mode) = match opts.format { - PackFormat::Ver5 => ( - wolf_archive::pack_ver5(&files)?, - "ver5 (Wolf 2.0x)".to_string(), - ), - PackFormat::Ver6 => ( - wolf_archive::pack_ver6(&files)?, - "ver6 (Wolf 2.0x)".to_string(), - ), - PackFormat::Auto if !opts.encrypt => { - (wolf_archive::pack_plaintext(&files)?, "unencrypted".to_string()) - } - PackFormat::Auto => { - // Resolve cryptVersion (+ embedded password for --like). - let (version, pwd, src_note) = match &opts.crypt { - CryptSource::Default => (DEFAULT_CRYPT_VERSION, None, ""), - CryptSource::Version(v) => (*v, None, ""), - CryptSource::Like(orig) => { - let params = std::fs::read(orig) - .ok() - .and_then(|d| wolf_archive::archive_crypt_params(&d)); - match params { - Some((cv, p)) => (cv, Some(p), " (--like)"), - None => { - return Err(format!( - "could not read crypt params from {}", - orig.display() - )) - } - } - } - }; - let packed = if matches!(version, 0x14B | 0x15E) { - wolf_archive::pack_newcrypt(&files, version, &pwd.unwrap_or(DEFAULT_NEWCRYPT_PWD))? - } else { - wolf_archive::pack_encrypted(&files, version)? - }; - (packed, format!("encrypted cryptVersion={version:#x}{src_note}")) - } - }; - - if let Some(parent) = out.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; - } - } - std::fs::write(out, &bytes).map_err(|e| format!("cannot write {}: {e}", out.display()))?; - report(1.0, "done".to_string()); - Ok(RepackOutcome { - out: out.to_path_buf(), - files: files.len(), - bytes: bytes.len(), - mode, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = Path::new(&base).join(rel); - p.exists().then_some(p) - } - - fn tmp_dir(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "wolfdawn_archive_{tag}_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = std::fs::create_dir_all(&p); - p - } - - /// Unpack the Chamber fixture's Data.wolf (if present) and confirm the data files appear. Writes - /// only to a temp dir, never touches the game folder. Skips gracefully when the archive is absent. - #[test] - fn unpack_chamber_archive() { - let Some(archive) = test_data("chamber/Data.wolf") else { - eprintln!("skip unpack_chamber_archive: archive fixture not present"); - return; - }; - let out = tmp_dir("unpack"); - let outcome = unpack(&archive, &out, |_, _| {}).expect("unpack"); - assert!(outcome.written > 0, "some files should have been written"); - assert!( - out.join("BasicData/CommonEvent.dat").exists(), - "the unpacked tree should contain BasicData/CommonEvent.dat" - ); - let _ = std::fs::remove_dir_all(&out); - } - - /// Pack a small temp folder to a `.wolf`, unpack it again, and confirm the files round-trip with - /// identical content. Uses copies of a couple of fixture data files (never modifies the fixtures root). - #[test] - fn repack_round_trip() { - // Source a couple of small, real data files from the fixtures root (read-only). - let present: Vec = [ - "chamber/Data/BasicData/Game.dat", - "chamber/Data/BasicData/CommonEvent.dat", - "chamber/Data/MapData/TitleMap.mps", - ] - .iter() - .filter_map(|r| test_data(r)) - .collect(); - if present.is_empty() { - eprintln!("skip repack_round_trip: no data fixtures present"); - return; - } - - // Build a small input folder of COPIES (with a nested subdir to exercise the tree). - let src = tmp_dir("repack_src"); - let _ = std::fs::create_dir_all(src.join("BasicData")); - let mut expect: Vec<(String, Vec)> = Vec::new(); - for (i, p) in present.iter().enumerate() { - let name = p.file_name().unwrap().to_string_lossy().to_string(); - let rel = format!("BasicData/{name}"); - let dst = src.join("BasicData").join(&name); - std::fs::copy(p, &dst).expect("copy"); - expect.push((rel, std::fs::read(p).expect("read source"))); - // Only take up to two files to keep the archive small. - if i >= 1 { - break; - } - } - - let out_wolf = tmp_dir("repack_out").join("Data.wolf"); - let outcome = repack(&src, &out_wolf, &PackOptions::default(), |_, _| {}).expect("repack"); - assert!(outcome.files >= 1, "at least one file should be packed"); - assert!(outcome.bytes > 0, "the archive should be non-empty"); - - // Unpack the freshly-built archive and confirm every input file comes back with the same bytes. - let back = tmp_dir("repack_back"); - let un = unpack(&out_wolf, &back, |_, _| {}).expect("unpack repacked"); - assert!(un.written >= 1, "files should extract from the repacked archive"); - for (rel, bytes) in &expect { - let got = back.join(rel); - assert!(got.exists(), "{rel} should round-trip through repack"); - assert_eq!(&std::fs::read(&got).expect("read back"), bytes, "{rel} content must match"); - } - - let _ = std::fs::remove_dir_all(&src); - let _ = std::fs::remove_dir_all(out_wolf.parent().unwrap()); - let _ = std::fs::remove_dir_all(&back); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/database.rs b/util/wolfdawn-master/crates/wolf-gui/src/database.rs deleted file mode 100644 index c8eb2e8..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/database.rs +++ /dev/null @@ -1,678 +0,0 @@ -//! The Database section's data model and the headless logic behind it. -//! -//! A spreadsheet-like editor over a Wolf database (`*.project` + sibling `*.dat`) so a non-coder can -//! change values like item prices, skill power, or enemy HP without touching JSON. This module owns -//! everything *except* the egui rendering (which lives in `app.rs`), so the whole load/edit/save -//! flow can be exercised headlessly in tests. -//! -//! It mirrors the CLI's `db-json` (`database_to_json`) + `db-apply` (`apply_database_edit`) pair, -//! calling the **same** `wolf_decompiler` library functions directly (no shelling out), so the GUI -//! and CLI agree on the JSON shape and the byte-exact apply: -//! -//! * [`load`] reads the `.project`+`.dat` pair with [`Database::read`], renders it to JSON with -//! [`database_to_json`] (exactly what `cmd_db_json` does, via the same per-stem `kind` label), -//! parses it into a [`DbModel`] of [`DbType`]s -> [`DbRow`]s -> [`Cell`]s, and **keeps the parsed -//! `doc`** so a save can rewrite just the edited leaves. Each editable cell carries a JSON -//! Pointer (RFC 6901) to its leaf in `doc`, exactly like `translation.rs`, so an edit touches -//! one leaf and every other byte stays identical. -//! * [`save`] writes each row's edited value into the leaf its pointer addresses, serializes the -//! patched `doc`, and applies it with [`apply_database_edit`] onto a freshly-read base (the -//! `db-apply` path), re-parses the [`Database::write`] output to verify it round-trips, and -//! writes the `.project`+`.dat` pair together byte-exact, optionally backing up the originals -//! first. Untouched data re-serializes verbatim, so a no-change save reproduces both halves -//! byte-for-byte. -//! -//! The DB `.project` is the canonical handle. The sibling `.dat` is paired by extension, mirroring -//! the CLI's `cmd_db_apply` (`base.with_extension("dat")`). - -use std::path::{Path, PathBuf}; - -use serde_json::Value; -use wolf_decompiler::{apply_database_edit, database_to_json}; -use wolf_formats::database::Database; - -/// One editable cell of a data row: which field column it belongs to, the editable value (as text - -/// ints are edited as their decimal string and validated on apply), whether it is a string or int -/// field (so the grid can hint and the apply path can re-type it), and the JSON Pointer to this -/// cell's leaf inside the owning [`DbModel::doc`]. The pointer is what makes a save lossless: an edit -/// rewrites exactly that one leaf, leaving every other byte of the document untouched. -#[derive(Clone)] -pub struct Cell { - /// The owning field's display key (the same key the JSON `values` object uses). - pub field: String, - /// The editable value, as text (string cells verbatim, int cells as their decimal form). - pub value: String, - /// The value as loaded (read-only, for change detection). - pub original: String, - /// `true` for a string field, `false` for an int field. - pub is_string: bool, - /// RFC 6901 JSON Pointer to this cell's value leaf inside [`DbModel::doc`]. - ptr: String, -} - -impl Cell { - /// True when the user changed this cell from its loaded value. - pub fn is_changed(&self) -> bool { - self.value != self.original - } -} - -/// One data row of a type: its editable display name (+ pointer) plus one [`Cell`] per data field. -#[derive(Clone)] -pub struct DbRow { - /// The row's stable id (its index in the type's data), shown for orientation. - pub id: usize, - /// The editable row name (the first grid column). - pub name: String, - /// The name as loaded (read-only, for change detection). - pub name_original: String, - /// JSON Pointer to this row's `name` leaf inside [`DbModel::doc`]. - name_ptr: String, - /// One cell per data field, in column order. - pub cells: Vec, -} - -impl DbRow { - /// How many of this row's editable leaves changed (name counts as one). - pub fn changed_count(&self) -> usize { - let n = usize::from(self.name != self.name_original); - n + self.cells.iter().filter(|c| c.is_changed()).count() - } -} - -/// One database type (タイプ), a table like "Skill", "Item", "Enemy": its display name, the data -/// field column names (in order), and its data rows. -pub struct DbType { - /// The type's display name (e.g. `技能 / Skill`). - pub name: String, - /// The data field column names, in order (the grid header, after the row-name column). - pub fields: Vec, - /// The data rows. - pub rows: Vec, -} - -impl DbType { - /// How many editable leaves the user changed across this type. - pub fn changed_count(&self) -> usize { - self.rows.iter().map(DbRow::changed_count).sum() - } -} - -/// The loaded, editable view of a database: where it came from (the `.project`, with the `.dat` -/// paired by extension), the read-only kind/encoding badges, and the types. -/// -/// The full parsed JSON document is NOT kept resident. It is the heaviest part of a large database -/// (every value and a repeated key per row, plus map overhead), so save/export rebuild it on demand -/// from the base file and apply the edits then. The editable grid keeps only what a user can change -/// (cell values + row names) plus each leaf's JSON pointer. -pub struct DbModel { - /// The on-disk `.project` this was loaded from (the [`save`] in-place target, `.dat` is sibling). - pub project: PathBuf, - /// The DB kind label (`UDB` / `CDB` / `SDB`), read-only. - pub kind: String, - /// The text encoding badge (`UTF-8` / `Shift-JIS`), read-only. - pub encoding: String, - /// The types (tables), in order. - pub types: Vec, -} - -impl DbModel { - /// Total data rows across every type. - pub fn total_rows(&self) -> usize { - self.types.iter().map(|t| t.rows.len()).sum() - } - - /// How many editable leaves the user changed across the whole model. - pub fn changed_count(&self) -> usize { - self.types.iter().map(DbType::changed_count).sum() - } - - /// True when anything was edited (so the Save button can gate on it). - pub fn dirty(&self) -> bool { - self.changed_count() > 0 - } -} - -/// The per-stem `kind` label, matching the CLI's `db_json_one` exactly. -fn kind_for(proj: &Path) -> &'static str { - match proj.file_stem().and_then(|s| s.to_str()) { - Some("DataBase") => "UDB", - Some("CDataBase") => "CDB", - Some("SysDatabase") => "SDB", - _ => "DB", - } -} - -/// Escape one path token for an RFC 6901 JSON Pointer (`~` -> `~0`, `/` -> `~1`). Field keys can -/// contain `/` (e.g. `発動回数/武器の影響`) so this must run on every dynamic segment. -fn esc(token: &str) -> String { - token.replace('~', "~0").replace('/', "~1") -} - -/// Read and parse the `.project`+`.dat` pair into the editable grid model, reusing [`Database::read`] -/// + [`database_to_json`] (the CLI's `db-json` path) with the same per-stem `kind` label. The -/// returned model keeps the parsed JSON so a later [`save`] rewrites only the edited leaves. -pub fn load(project: &Path) -> Result { - let dat = project.with_extension("dat"); - let pb = std::fs::read(project) - .map_err(|e| format!("cannot read {}: {e}", project.display()))?; - let db_ = std::fs::read(&dat).map_err(|e| format!("cannot read {}: {e}", dat.display()))?; - let db = Database::read(&pb, &db_).map_err(|e| format!("database parse failed: {e}"))?; - let kind = kind_for(project); - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = database_to_json(&db, kind, &glossary); - let doc: Value = serde_json::from_str(&json).map_err(|e| format!("invalid DB JSON: {e}"))?; - - let kind_s = doc - .get("kind") - .and_then(Value::as_str) - .unwrap_or(kind) - .to_string(); - let encoding = doc - .get("encoding") - .and_then(Value::as_str) - .unwrap_or("UTF-8") - .to_string(); - - let types = types_from_doc(&doc); - // `doc` is dropped here: the grid carries everything editable, and save/export rebuild the doc - // from the base file when needed, so it is not held for the session. - Ok(DbModel { - project: project.to_path_buf(), - kind: kind_s, - encoding, - types, - }) -} - -/// Build the editable types/rows/cells (each carrying its JSON pointer) from the parsed document. -fn types_from_doc(doc: &Value) -> Vec { - let mut out = Vec::new(); - let Some(types) = doc.get("types").and_then(Value::as_array) else { - return out; - }; - for (idx, t) in types.iter().enumerate() { - let ti = t.get("id").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(idx); - let type_name = display_name(t); - - // The data field columns are the keys of the FIRST row's `values` object, in order (only the - // `fields_size` data fields appear there, schema-only fields are absent, matching the export). - // We derive the column order + each cell's string/int kind from the `fields` schema, keyed by - // the same `values` key (unique field name, else `#`), so a missing first row still works. - let columns = value_columns(t); - let field_labels: Vec = columns.iter().map(|c| c.label.clone()).collect(); - - let mut rows = Vec::new(); - if let Some(rs) = t.get("rows").and_then(Value::as_array) { - for r in rs { - let ri = r.get("id").and_then(Value::as_u64).unwrap_or(0) as usize; - let name = r.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let mut cells = Vec::with_capacity(columns.len()); - for col in &columns { - let v = r.get("values").and_then(|vv| vv.get(&col.key)); - let value = match v { - Some(Value::String(s)) => s.clone(), - Some(Value::Number(n)) => n.to_string(), - Some(other) => other.to_string(), - None => String::new(), - }; - cells.push(Cell { - field: col.label.clone(), - original: value.clone(), - value, - is_string: col.is_string, - ptr: format!("/types/{ti}/rows/{ri}/values/{}", esc(&col.key)), - }); - } - rows.push(DbRow { - id: ri, - name_original: name.clone(), - name, - name_ptr: format!("/types/{ti}/rows/{ri}/name"), - cells, - }); - } - } - - out.push(DbType { - name: type_name, - fields: field_labels, - rows, - }); - } - out -} - -/// A data column derived from a type's schema: the `values` key, a friendly label, and the kind. -struct Column { - /// The key as it appears in each row's `values` object. - key: String, - /// A friendly column label (`name_en` when present, else the JP `name`). - label: String, - /// `true` for a string field. - is_string: bool, -} - -/// Derive the ordered data columns from a type's `fields` schema. A field carries row data unless it -/// is tagged `schemaOnly: true`. Its `values` key is its unique field name, else `#`, the same -/// rule `value_slots`/`database_to_json` use, so the keys always match the row objects. -fn value_columns(t: &Value) -> Vec { - let Some(fields) = t.get("fields").and_then(Value::as_array) else { - return Vec::new(); - }; - // Count name occurrences among data-bearing fields so duplicates fall back to `#`. - let data_fields: Vec<&Value> = fields - .iter() - .filter(|f| !f.get("schemaOnly").and_then(Value::as_bool).unwrap_or(false)) - .collect(); - let mut name_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); - for f in &data_fields { - let n = f.get("name").and_then(Value::as_str).unwrap_or(""); - if !n.is_empty() { - *name_counts.entry(n).or_default() += 1; - } - } - let mut out = Vec::with_capacity(data_fields.len()); - for f in &data_fields { - let fi = f.get("id").and_then(Value::as_u64).unwrap_or(0) as usize; - let jp = f.get("name").and_then(Value::as_str).unwrap_or(""); - let en = f.get("name_en").and_then(Value::as_str); - let key = if jp.is_empty() || name_counts.get(jp).copied().unwrap_or(0) > 1 { - format!("#{fi}") - } else { - jp.to_string() - }; - let label = match en { - Some(e) if !e.is_empty() && e != jp => format!("{jp} / {e}"), - _ if jp.is_empty() => format!("#{fi}"), - _ => jp.to_string(), - }; - let is_string = f.get("kind").and_then(Value::as_str) == Some("string"); - out.push(Column { key, label, is_string }); - } - out -} - -/// A friendly display name for a type/row: `name_en` appended when present (`JP / EN`), else JP. -fn display_name(v: &Value) -> String { - let jp = v.get("name").and_then(Value::as_str).unwrap_or(""); - match v.get("name_en").and_then(Value::as_str) { - Some(en) if !en.is_empty() && en != jp => format!("{jp} / {en}"), - _ => jp.to_string(), - } -} - -// ---------------------------------------------------------------------------- -// Save / export: rebuild the doc from the base, patch in the edits, apply + write -// ---------------------------------------------------------------------------- - -/// Push each row's edited name + cell values into `doc` (a freshly rebuilt `database_to_json` -/// document), via each leaf's JSON pointer, so the document is current before it is fed to -/// `apply_database_edit`. Int cells write a JSON number (so the apply path's `as_i64` accepts them). -/// String cells write a JSON string. An int cell that does not parse is a hard error here with a -/// clear field reference. -fn apply_edits_to_doc(model: &DbModel, doc: &mut Value) -> Result<(), String> { - for t in &model.types { - for r in &t.rows { - if r.name != r.name_original { - if let Some(slot) = doc.pointer_mut(&r.name_ptr) { - *slot = Value::String(r.name.clone()); - } - } - for c in &r.cells { - if !c.is_changed() { - continue; - } - let Some(slot) = doc.pointer_mut(&c.ptr) else { - continue; - }; - if c.is_string { - *slot = Value::String(c.value.clone()); - } else { - let n: i64 = c.value.trim().parse().map_err(|_| { - format!( - "field {:?} in type {:?} row {} ({:?}): {:?} is not a whole number", - c.field, t.name, r.id, r.name, c.value - ) - })?; - *slot = Value::Number(n.into()); - } - } - } - } - Ok(()) -} - -/// Rebuild the full `database_to_json` document from the base `.project`+`.dat` and patch in the -/// grid's edits. The doc is not kept resident, so this re-reads the base (deterministic, same -/// structure as load) and applies the edits via their pointers. Returns the edited doc plus the base -/// bytes, so [`save`] can apply onto the same bytes without a second read. -fn edited_doc(model: &DbModel) -> Result<(Value, Vec, Vec), String> { - let base_proj = &model.project; - let base_dat = base_proj.with_extension("dat"); - let pb = std::fs::read(base_proj) - .map_err(|e| format!("cannot read base {}: {e}", base_proj.display()))?; - let db_ = std::fs::read(&base_dat) - .map_err(|e| format!("cannot read base {}: {e}", base_dat.display()))?; - let db = Database::read(&pb, &db_).map_err(|e| format!("base parse failed: {e}"))?; - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = database_to_json(&db, kind_for(base_proj), &glossary); - let mut doc: Value = serde_json::from_str(&json).map_err(|e| format!("invalid DB JSON: {e}"))?; - apply_edits_to_doc(model, &mut doc)?; - Ok((doc, pb, db_)) -} - -/// Apply an edits JSON onto a fresh base, verify the result re-parses, then write the `.project`+ -/// `.dat` pair (backing up existing halves first when asked). The shared tail of [`save`] and -/// [`import_json`]. `base_pb`/`base_dat_bytes`, when supplied, are the already-read base bytes (save -/// reuses them); pass `None` to read the base here (import). -fn apply_and_write( - base_project: &Path, - edited_json: &str, - out_project: &Path, - backup: bool, - base_bytes: Option<(Vec, Vec)>, -) -> Result { - let base_dat = base_project.with_extension("dat"); - let out_dat = out_project.with_extension("dat"); - let (pb, db_) = match base_bytes { - Some(b) => b, - None => ( - std::fs::read(base_project) - .map_err(|e| format!("cannot read base {}: {e}", base_project.display()))?, - std::fs::read(&base_dat) - .map_err(|e| format!("cannot read base {}: {e}", base_dat.display()))?, - ), - }; - let mut db = Database::read(&pb, &db_).map_err(|e| format!("base parse failed: {e}"))?; - let changed = - apply_database_edit(edited_json, &mut db).map_err(|e| format!("apply failed: {e}"))?; - - let (out_pb, out_db) = db.write(); - // Re-parse the output to verify it still round-trips before committing it. Never ship a - // structurally-corrupt pair. - Database::read(&out_pb, &out_db) - .map_err(|e| format!("internal error: edited database no longer parses ({e})"))?; - let byte_identical = out_pb == pb && out_db == db_; - - let mut backups = Vec::new(); - if backup { - for half in [out_project, out_dat.as_path()] { - if half.exists() { - let bak = backup_path(half); - std::fs::copy(half, &bak) - .map_err(|e| format!("backup failed ({}): {e}", bak.display()))?; - backups.push(bak); - } - } - } - if let Some(parent) = out_project.parent() { - if !parent.as_os_str().is_empty() { - let _ = std::fs::create_dir_all(parent); - } - } - if let (Err(e), _) | (_, Err(e)) = ( - std::fs::write(out_project, &out_pb), - std::fs::write(&out_dat, &out_db), - ) { - return Err(format!("write failed: {e}")); - } - Ok(SaveOutcome { - changed, - byte_identical, - out_project: out_project.to_path_buf(), - out_dat, - backups, - }) -} - -/// Export the on-disk database as a pretty-printed `database_to_json` document for editing outside -/// the GUI (re-import with [`import_json`]). Reads only the base path, so it runs on a worker without -/// borrowing the live grid. Grid edits are NOT included here, so Save first to bake them in. -pub fn export_json(base_project: &Path, out: &Path) -> Result<(), String> { - let base_dat = base_project.with_extension("dat"); - let pb = std::fs::read(base_project) - .map_err(|e| format!("cannot read {}: {e}", base_project.display()))?; - let db_ = std::fs::read(&base_dat) - .map_err(|e| format!("cannot read {}: {e}", base_dat.display()))?; - let db = Database::read(&pb, &db_).map_err(|e| format!("database parse failed: {e}"))?; - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = database_to_json(&db, kind_for(base_project), &glossary); - let doc: Value = serde_json::from_str(&json).map_err(|e| format!("invalid DB JSON: {e}"))?; - let pretty = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?; - std::fs::write(out, pretty.as_bytes()) - .map_err(|e| format!("cannot write {}: {e}", out.display()))?; - Ok(()) -} - -/// Import an edited `database_to_json` document (full or patch-style), apply it onto `base_project`'s -/// pair, and write to `out_project`. The same verify-then-write guard as [`save`]. -pub fn import_json( - base_project: &Path, - json_path: &Path, - out_project: &Path, - backup: bool, -) -> Result { - let json = std::fs::read_to_string(json_path) - .map_err(|e| format!("cannot read {}: {e}", json_path.display()))?; - apply_and_write(base_project, &json, out_project, backup, None) -} - -/// Where a backup goes for an in-place save of one half. -fn backup_path(file: &Path) -> PathBuf { - let mut name = file.file_name().map(|s| s.to_os_string()).unwrap_or_default(); - name.push(".bak"); - file.with_file_name(name) -} - -/// The outcome of a [`save`] run, for the log. -pub struct SaveOutcome { - /// How many cells/names `apply_database_edit` actually changed. - pub changed: usize, - /// True when both output halves are byte-identical to the base (a no-op save, or edits that net - /// to the original values). - pub byte_identical: bool, - /// Where the `.project` was written. - pub out_project: PathBuf, - /// Where the `.dat` was written. - pub out_dat: PathBuf, - /// The backup paths made (`.project.bak`, `.dat.bak`), if any. - pub backups: Vec, -} - -/// Apply the grid's edits onto the base database and write the `.project`+`.dat` pair to -/// `out_project` (+ its sibling `.dat`), mirroring the CLI's `db-apply`: patch the parsed doc with -/// the edited leaves, [`apply_database_edit`] it onto a freshly-read base, **re-parse the -/// [`Database::write`] output to verify it round-trips** before writing, and report the change count -/// + byte-identity. When `backup` is set and an output half already exists, the original is copied to -/// `.bak` first. -/// -/// Byte-exactness: `apply_database_edit` only rewrites cells whose value changed (it skips unchanged -/// strings to dodge the empty-string / trailing-NUL re-encode ambiguity), so untouched data -/// re-serializes verbatim and a no-change save reproduces both halves byte-for-byte. -pub fn save(model: &DbModel, out_project: &Path, backup: bool) -> Result { - let (doc, pb, db_) = edited_doc(model)?; - let edited_json = serde_json::to_string(&doc).map_err(|e| e.to_string())?; - apply_and_write(&model.project, &edited_json, out_project, backup, Some((pb, db_))) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A unique temp dir for scratch output (never a game folder or the fixtures root). - fn tmp_dir(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "wolfdawn_db_{tag}_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = std::fs::create_dir_all(&p); - p - } - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// The shared DB fixture (`.project` plus its `.dat` sibling); tests skip when it is absent. - fn fixture() -> Option { - test_data("chamber/Data/BasicData/DataBase.project") - .filter(|p| p.with_extension("dat").exists()) - } - - /// Copy the `.project`+`.dat` pair into `dir`, returning the copied `.project` path. - fn copy_pair(src_proj: &Path, dir: &Path) -> PathBuf { - let stem = src_proj.file_name().unwrap(); - let dst_proj = dir.join(stem); - let dst_dat = dst_proj.with_extension("dat"); - std::fs::copy(src_proj, &dst_proj).expect("copy .project"); - std::fs::copy(src_proj.with_extension("dat"), &dst_dat).expect("copy .dat"); - dst_proj - } - - /// Load a real DB, edit one cell's value, save to a temp output, reload from the output: the - /// edited cell shows the new value and an UNEDITED type/row is unchanged. Never touches the - /// fixtures root (reads the fixture, copies + writes only into a temp dir). - #[test] - fn load_edit_cell_save_reload_round_trip() { - let Some(src) = fixture() else { - eprintln!("skip load_edit_cell_save_reload_round_trip: no DB fixture present"); - return; - }; - let work = tmp_dir("rt"); - let base = copy_pair(&src, &work); - - let mut m = load(&base).expect("load"); - assert!(!m.types.is_empty(), "DB should have at least one type"); - assert!(m.total_rows() >= 1, "DB should have at least one data row"); - - // Find a type with at least one row that has at least one cell, and edit the first cell. - let (ti, ri, ci, new_value, is_string) = { - let mut found = None; - 'outer: for (ti, t) in m.types.iter().enumerate() { - for (ri, r) in t.rows.iter().enumerate() { - if !r.cells.is_empty() { - found = Some((ti, ri)); - break 'outer; - } - } - } - let (ti, ri) = found.expect("a type/row with at least one cell"); - let cell = &m.types[ti].rows[ri].cells[0]; - // For a string cell, append a marker. For an int cell, bump it by 1 (stays a valid int). - let new = if cell.is_string { - format!("{}_EDIT", cell.value) - } else { - let cur: i64 = cell.value.trim().parse().unwrap_or(0); - (cur + 1).to_string() - }; - (ti, ri, 0usize, new, cell.is_string) - }; - // Record an UNEDITED reference cell elsewhere (a different row, or a different type). - let reference = { - let mut chosen = None; - 'r: for (oti, t) in m.types.iter().enumerate() { - for (ori, r) in t.rows.iter().enumerate() { - if (oti, ori) != (ti, ri) && !r.cells.is_empty() { - chosen = Some((oti, ori, r.cells[0].value.clone())); - break 'r; - } - } - } - chosen - }; - - m.types[ti].rows[ri].cells[ci].value = new_value.clone(); - assert_eq!(m.changed_count(), 1, "exactly one cell changed"); - - let out = work.join("edited.project"); - let outcome = save(&mut m, &out, false).expect("save"); - assert_eq!(outcome.changed, 1, "apply should report one cell changed"); - assert!(!outcome.byte_identical, "an edited save differs from base"); - assert!(out.with_extension("dat").exists(), "the .dat half was written"); - - // Reload from the OUTPUT pair: the edited cell shows the new value. - let re = load(&out).expect("reload"); - let got = &re.types[ti].rows[ri].cells[ci]; - assert_eq!(got.value, new_value, "edited cell shows the new value"); - assert_eq!(got.is_string, is_string, "cell kind preserved"); - - // The unedited reference cell is unchanged. - if let Some((oti, ori, orig)) = reference { - assert_eq!( - re.types[oti].rows[ori].cells[0].value, orig, - "an unedited cell must be unchanged after the round-trip" - ); - } - - let _ = std::fs::remove_dir_all(&work); - } - - /// A no-change save is byte-exact: load -> save with no edits -> both output halves are - /// byte-identical to the inputs (lossless-pointer + db-apply's byte-exact pairing). - #[test] - fn no_change_save_is_byte_exact() { - let Some(src) = fixture() else { - eprintln!("skip no_change_save_is_byte_exact: no DB fixture present"); - return; - }; - let work = tmp_dir("noop"); - let base = copy_pair(&src, &work); - let in_proj = std::fs::read(&base).unwrap(); - let in_dat = std::fs::read(base.with_extension("dat")).unwrap(); - - let mut m = load(&base).expect("load"); - assert_eq!(m.changed_count(), 0, "nothing edited yet"); - - let out = work.join("out.project"); - let outcome = save(&mut m, &out, false).expect("save"); - assert_eq!(outcome.changed, 0, "no cells changed"); - assert!(outcome.byte_identical, "a no-change save must be byte-identical to base"); - assert_eq!(std::fs::read(&out).unwrap(), in_proj, ".project is byte-identical"); - assert_eq!( - std::fs::read(out.with_extension("dat")).unwrap(), - in_dat, - ".dat is byte-identical" - ); - - let _ = std::fs::remove_dir_all(&work); - } - - /// In-place save with backups: both `.project.bak` and `.dat.bak` hold the original bytes. - #[test] - fn in_place_save_backs_up_both_halves() { - let Some(src) = fixture() else { - eprintln!("skip in_place_save_backs_up_both_halves: no DB fixture present"); - return; - }; - let work = tmp_dir("bak"); - let base = copy_pair(&src, &work); - let orig_proj = std::fs::read(&base).unwrap(); - let orig_dat = std::fs::read(base.with_extension("dat")).unwrap(); - - let mut m = load(&base).expect("load"); - // Edit a row name so a save actually rewrites bytes. - let row = m - .types - .iter_mut() - .find(|t| !t.rows.is_empty()) - .map(|t| &mut t.rows[0]) - .expect("a row"); - row.name = format!("{}_X", row.name); - - let outcome = save(&mut m, &base, true).expect("save in place"); - assert_eq!(outcome.backups.len(), 2, "both halves backed up"); - assert_eq!(std::fs::read(&outcome.backups[0]).unwrap(), orig_proj); - assert_eq!(std::fs::read(&outcome.backups[1]).unwrap(), orig_dat); - - let _ = std::fs::remove_dir_all(&work); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/decompile.rs b/util/wolfdawn-master/crates/wolf-gui/src/decompile.rs deleted file mode 100644 index 9927b19..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/decompile.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! The Decompile section's headless logic: turn a binary event file (a `.mps` map or -//! `CommonEvent.dat`) into readable, editable WolfScript, then recompile the edited text back into a -//! runnable file using the original as a *base* so every opaque byte is preserved. -//! -//! This mirrors the CLI's `decompile --mode edit` (`decompile_edit_path`) and `compile` -//! (`cmd_compile`) exactly, calling the same `wolf_decompiler` library functions directly (no -//! shelling out), so the GUI and CLI produce identical output: -//! -//! * [`decompile_edit`] reads the input, parses it with the matching `wolf_formats` reader, builds -//! a symbol-aware table (CommonEvent + the three databases under the game's BasicData), and -//! renders the **edit** document (`decompile_*_edit_annotated`), the recompilable, bilingual -//! form `compile` consumes. -//! * [`compile_to`] re-reads the *base* file (the original input), applies the edited WolfScript -//! onto it (`compile_*_edit`), and writes the result. The base supplies all opaque metadata. -//! Only the command bodies are swapped in, so untouched code re-serializes byte-identical. -//! -//! Only code-bearing files (`.mps`, `CommonEvent.dat`) are supported. Anything else is rejected with -//! a clear message, matching the CLI. - -use std::path::{Path, PathBuf}; - -use wolf_decompiler::{ - compile_common_events_edit, compile_map_edit, decompile_common_events_edit_annotated, - decompile_map_edit_annotated, SymbolTable, -}; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::map::Map; - -/// Which kind of code-bearing file we're working with. Decides the reader/decompiler/compiler -/// pair, mirroring the CLI's `classify` (only the two code-bearing variants are relevant here). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum CodeKind { - Map, - CommonEvent, -} - -/// Classify a path as a map / common-event, or `None` for anything decompile does not understand -/// (a database, Game.dat, an unrelated file). Mirrors the relevant arms of the CLI's `classify`. -fn classify(path: &Path) -> Option { - let ext = path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - let name = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - match ext.as_str() { - "mps" => Some(CodeKind::Map), - "dat" if name == "commonevent.dat" => Some(CodeKind::CommonEvent), - _ => None, - } -} - -/// True when `path` is a file decompile understands (a `.mps` map or `CommonEvent.dat`), so the UI -/// can warn early on an unsupported pick. The action still runs and gives a clear error. -pub fn is_supported(path: &Path) -> bool { - classify(path).is_some() -} - -/// Load the three standard databases under `basic` into `symbols`, exactly like the CLI's -/// `add_databases`. Skips any that are missing or fail to parse (symbols are best-effort labels). -fn add_databases(symbols: &mut SymbolTable, basic: &Path) { - for (kind, stem) in [("UDB", "DataBase"), ("CDB", "CDataBase"), ("SDB", "SysDatabase")] { - let proj = basic.join(format!("{stem}.project")); - let dat = basic.join(format!("{stem}.dat")); - if let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) { - if let Ok(db) = Database::read(&p, &d) { - symbols.add_database(kind, &db); - } - } - } -} - -/// Build the symbol table for a map, locating the game's BasicData folder the same way the CLI's -/// `load_symbols` does (`.../Data/MapData/X.mps` → `.../Data/BasicData`) and loading CommonEvent + -/// the databases + the embedded glossaries. Best-effort: a missing BasicData just yields fewer labels. -fn map_symbols(map_path: &Path) -> SymbolTable { - let mut symbols = SymbolTable::new(); - let basic = map_path.parent().and_then(|p| p.parent()).map(|data| data.join("BasicData")); - let Some(basic) = basic else { - return symbols; - }; - if let Ok(bytes) = std::fs::read(basic.join("CommonEvent.dat")) { - if let Ok(ce) = CommonEventsFile::read(&bytes) { - symbols.add_common_events(&ce); - } - } - add_databases(&mut symbols, &basic); - symbols.set_glossary(wolf_decompiler::symbols::load_embedded_glossary()); - symbols.set_engine_text(wolf_decompiler::symbols::load_embedded_engine_glossary()); - symbols -} - -/// Build the symbol table for a CommonEvent.dat: its own events, plus the sibling databases in the -/// same folder + the embedded glossaries (mirrors the CLI's `decompile_edit_path` CommonEvent arm). -fn common_event_symbols(ce: &CommonEventsFile, ce_dir: Option<&Path>) -> SymbolTable { - let mut symbols = SymbolTable::new(); - symbols.add_common_events(ce); - if let Some(dir) = ce_dir { - add_databases(&mut symbols, dir); - } - symbols.set_glossary(wolf_decompiler::symbols::load_embedded_glossary()); - symbols.set_engine_text(wolf_decompiler::symbols::load_embedded_engine_glossary()); - symbols -} - -/// Decompile `input` to the **edit** WolfScript document (the recompilable, identity-delimited form -/// `compile` consumes), exactly like the CLI's `decompile --mode edit`. Symbol-aware: each operand -/// carries its index and a bilingual label, so the one file is both readable and recompilable. -/// Returns the WolfScript text. Errors for an unsupported file or a parse failure. -pub fn decompile_edit(input: &Path) -> Result { - let bytes = std::fs::read(input).map_err(|e| format!("cannot read {}: {e}", input.display()))?; - match classify(input) { - Some(CodeKind::Map) => { - let map = Map::read(&bytes).map_err(|e| e.to_string())?; - Ok(decompile_map_edit_annotated(&map, &map_symbols(input))) - } - Some(CodeKind::CommonEvent) => { - let ce = CommonEventsFile::read(&bytes).map_err(|e| e.to_string())?; - let symbols = common_event_symbols(&ce, input.parent()); - Ok(decompile_common_events_edit_annotated(&ce, &symbols)) - } - None => Err(format!( - "{} is not a code-bearing file (decompile supports .mps maps and CommonEvent.dat)", - input.display() - )), - } -} - -/// The outcome of a [`compile_to`] run, for the log: the bytes written, whether the result is -/// byte-identical to the base (i.e. the code was untouched), and the output path. -pub struct CompileOutcome { - pub out: PathBuf, - pub bytes: usize, - pub byte_identical: bool, -} - -/// Compile the edited WolfScript `text` back into a runnable file, using `base` (the original input -/// the text was decompiled from) as the base, and write the result to `output`. Mirrors the CLI's -/// `cmd_compile`: the base supplies all opaque metadata, only the command bodies are swapped in, so -/// with the code untouched the output is byte-identical to the base. -/// -/// As a safety net (and what the GUI's round-trip test asserts), the freshly compiled bytes are -/// re-parsed with the matching reader before the write is reported, so a structurally-corrupt result -/// never lands on disk silently. -pub fn compile_to(text: &str, base: &Path, output: &Path) -> Result { - let base_bytes = - std::fs::read(base).map_err(|e| format!("cannot read base {}: {e}", base.display()))?; - let out_bytes = match classify(base) { - Some(CodeKind::CommonEvent) => { - let mut ce = CommonEventsFile::read(&base_bytes) - .map_err(|e| format!("base parse failed: {e}"))?; - compile_common_events_edit(text, &mut ce).map_err(|e| format!("compile failed: {e}"))?; - let out = ce.write(); - // Re-parse before reporting: never ship a structurally-corrupt file. - CommonEventsFile::read(&out) - .map_err(|e| format!("internal error: compiled CommonEvent no longer parses ({e})"))?; - out - } - Some(CodeKind::Map) => { - let mut map = Map::read(&base_bytes).map_err(|e| format!("base parse failed: {e}"))?; - compile_map_edit(text, &mut map).map_err(|e| format!("compile failed: {e}"))?; - let out = map.write(); - Map::read(&out) - .map_err(|e| format!("internal error: compiled map no longer parses ({e})"))?; - out - } - None => { - return Err(format!( - "base {} must be a .mps map or CommonEvent.dat", - base.display() - )) - } - }; - - let byte_identical = out_bytes == base_bytes; - std::fs::write(output, &out_bytes) - .map_err(|e| format!("cannot write {}: {e}", output.display()))?; - Ok(CompileOutcome { - out: output.to_path_buf(), - bytes: out_bytes.len(), - byte_identical, - }) -} - -/// One match of a find query in the editor text, as a half-open range of *character* indices -/// `[start, end)` (not byte offsets). Character indices are what egui's `CCursor` wants, so the find -/// bar can set the editor selection directly from these. Kept here (not in `app.rs`) so the -/// match-finding logic is unit-testable without a UI. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct FindMatch { - /// Inclusive start, as a character index into the script. - pub start: usize, - /// Exclusive end, as a character index into the script. - pub end: usize, -} - -/// Find every (non-overlapping) occurrence of `query` in `haystack`, returning each as a character -/// range [`FindMatch`]. Empty when `query` is empty or has no match. When `case_sensitive` is false -/// the comparison is ASCII-case-insensitive (lowercasing both sides). CJK text is unaffected by -/// case folding, so the default insensitive mode is safe for the Japanese content this tool edits. -/// -/// Matching is done on chars (not bytes), and the returned ranges are char indices, so they map -/// straight onto egui's `CCursor`/selection model. Overlapping matches are skipped (search resumes -/// after each hit), matching a typical "find next" stepping behaviour. -pub fn find_matches(haystack: &str, query: &str, case_sensitive: bool) -> Vec { - if query.is_empty() { - return Vec::new(); - } - // Work on char vectors so the reported indices are char positions (what egui's CCursor uses), - // not byte offsets. Fold per char (1:1) so case-insensitive matching never shifts those indices. - let hay: Vec = haystack.chars().map(|c| fold_char(c, case_sensitive)).collect(); - let need: Vec = query.chars().map(|c| fold_char(c, case_sensitive)).collect(); - - let mut out = Vec::new(); - if need.len() > hay.len() { - return out; - } - let mut i = 0usize; - while i + need.len() <= hay.len() { - if hay[i..i + need.len()] == need[..] { - out.push(FindMatch { start: i, end: i + need.len() }); - i += need.len(); // non-overlapping: resume after the match. - } else { - i += 1; - } - } - out -} - -/// Case-fold one char without changing the char count (so character indices stay aligned with the -/// original string). For Latin + CJK content this is exactly ASCII lowercasing. CJK is returned -/// unchanged. Using a 1:1 fold keeps the [`find_matches`] ranges valid as `CCursor` char indices. -fn fold_char(c: char, case_sensitive: bool) -> char { - if case_sensitive { - c - } else { - c.to_ascii_lowercase() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `find_matches` returns the correct char ranges for ASCII, is case-insensitive by default, - /// honours a case-sensitive flag, handles non-overlapping repeats, and is char- (not byte-) - /// indexed across multi-byte (CJK) text. - #[test] - fn find_matches_ranges_are_correct() { - // Basic ASCII, case-insensitive (default). - let hay = "abc ABC abc"; - let m = find_matches(hay, "abc", false); - assert_eq!(m.len(), 3, "three case-insensitive hits"); - assert_eq!(m[0], FindMatch { start: 0, end: 3 }); - assert_eq!(m[1], FindMatch { start: 4, end: 7 }); - assert_eq!(m[2], FindMatch { start: 8, end: 11 }); - - // Case-sensitive: only the two lowercase runs. - let m = find_matches(hay, "abc", true); - assert_eq!(m.len(), 2, "two case-sensitive hits"); - assert_eq!(m[0], FindMatch { start: 0, end: 3 }); - assert_eq!(m[1], FindMatch { start: 8, end: 11 }); - - // Empty query → no matches. - assert!(find_matches(hay, "", false).is_empty()); - // No match. - assert!(find_matches(hay, "zzz", false).is_empty()); - - // Non-overlapping: "aa" in "aaaa" → 2 matches (0..2, 2..4), not 3. - let m = find_matches("aaaa", "aa", false); - assert_eq!(m, vec![FindMatch { start: 0, end: 2 }, FindMatch { start: 2, end: 4 }]); - - // Char-indexed across CJK: prefix is 2 multi-byte chars, so the match starts at char 2. - let hay = "あい行 X 行"; - let m = find_matches(hay, "行", false); - assert_eq!(m.len(), 2, "two occurrences of 行"); - assert_eq!(m[0], FindMatch { start: 2, end: 3 }, "char index, not byte offset"); - // The second 行 is at char index 6 ("あ い 行 ␠ X ␠ 行"). - assert_eq!(m[1], FindMatch { start: 6, end: 7 }); - } - - - /// A unique temp dir for a test's scratch output (never under a game folder or the fixtures root). - fn tmp_dir(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "wolfdawn_decompile_{tag}_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = std::fs::create_dir_all(&p); - p - } - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// The first present code-bearing fixture from the fixtures root (read-only). A map is - /// preferred (it exercises the BasicData symbol lookup), then CommonEvent.dat. - fn fixture() -> Option { - [ - "chamber/Data/MapData/TitleMap.mps", - "chamber/Data/BasicData/CommonEvent.dat", - ] - .into_iter() - .find_map(test_data) - } - - /// Decompile a real file to WolfScript (non-empty), compile it back onto the original as base to - /// a temp output, and assert the result re-decompiles to the same text (semantic round-trip). For - /// an untouched document compile is byte-exact, so we also assert byte-identity to the base. Never - /// modifies the fixtures root. Reads the fixture, writes only into a temp dir. - #[test] - fn decompile_compile_round_trip() { - let Some(input) = fixture() else { - eprintln!("skip decompile_compile_round_trip: no code fixture present"); - return; - }; - assert!(is_supported(&input), "{input:?} should be supported"); - - let text = decompile_edit(&input).expect("decompile"); - assert!(!text.is_empty(), "decompiled WolfScript should be non-empty"); - - let work = tmp_dir("rt"); - // Keep the base's file name so the compiled output re-classifies (and re-decompiles) the - // same way (a `.mps` stays a map, `CommonEvent.dat` stays a common-event). - let out = work.join(input.file_name().expect("file name")); - let outcome = compile_to(&text, &input, &out).expect("compile"); - assert!(out.exists(), "compiled output should be written"); - assert!(outcome.bytes > 0, "output should be non-empty"); - - // Untouched code is byte-identical to base (the strong guarantee compile gives). - let base_bytes = std::fs::read(&input).expect("read base"); - let out_bytes = std::fs::read(&out).expect("read out"); - assert_eq!(out_bytes, base_bytes, "untouched compile must be byte-identical to base"); - assert!(outcome.byte_identical, "outcome should report byte-identity"); - - // Semantic check: re-decompile the compiled output and compare to a decompile of the *base - // placed in the same temp dir*. Both share the same (BasicData-less) symbol context, so the - // bilingual operand labels match. Comparing against the original `text` (decompiled at the - // real location, where the databases resolve those labels) would differ only in those labels, - // and the compiler strips labels, so the bytes already proved equal above. - let base_here = work.join("base").join(input.file_name().unwrap()); - std::fs::create_dir_all(base_here.parent().unwrap()).unwrap(); - std::fs::copy(&input, &base_here).expect("copy base"); - let baseline = decompile_edit(&base_here).expect("decompile base copy"); - let re = decompile_edit(&out).expect("re-decompile"); - assert_eq!(re, baseline, "re-decompiled WolfScript should match the base's own decompile"); - - let _ = std::fs::remove_dir_all(&work); - } - - /// An unsupported file (e.g. a plain .txt) is rejected by both entry points, not panicked on. - #[test] - fn unsupported_file_is_rejected() { - let work = tmp_dir("unsup"); - let f = work.join("notes.txt"); - std::fs::write(&f, b"hello").unwrap(); - assert!(!is_supported(&f)); - assert!(decompile_edit(&f).is_err(), "txt should not decompile"); - assert!( - compile_to("", &f, &work.join("out")).is_err(), - "txt base should not compile" - ); - let _ = std::fs::remove_dir_all(&work); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/gamedat.rs b/util/wolfdawn-master/crates/wolf-gui/src/gamedat.rs deleted file mode 100644 index 0239f90..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/gamedat.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! The Game.dat section's data model and headless logic. -//! -//! A friendly form over *every* editable `Game.dat` string field, so a non-coder can change the -//! window title or the editor font without touching JSON. This mirrors the CLI's `gamedat-json` -//! (`dump_game_dat`) + `gamedat-apply` (`apply_game_dat`) pair, calling the same `wolf_decompiler` -//! library functions directly (no shelling out), so the GUI and CLI agree on the field set and the -//! byte-exact apply. -//! -//! The module owns everything except the egui rendering (which lives in `app.rs`), so the whole -//! workflow can be exercised headlessly: -//! -//! * [`load`] reads a `Game.dat` with [`GameDat::read`], dumps every editable field to JSON with -//! [`dump_game_dat`], and parses it into typed [`Field`]s. **Only the fields actually present** -//! for this file's `string_count` (no phantom keys), plus the read-only `encoding` badge. -//! * [`save`] builds a JSON object of *only the changed fields*, applies it with [`apply_game_dat`] -//! onto a freshly-read base, re-parses the [`GameDat::write`] output to verify it round-trips -//! (exactly as `cmd_gamedat_apply` does), and writes it, optionally backing up the original -//! first. Untouched fields re-serialize byte-exact (guaranteed by `apply_game_dat` + the -//! `GameDat::write` housekeeping anchor), so a no-change save reproduces the file byte-for-byte. - -use std::path::{Path, PathBuf}; - -use serde_json::{Map, Value}; -use wolf_decompiler::{apply_game_dat, dump_game_dat}; -use wolf_formats::game_dat::GameDat; - -/// Which group a field belongs to, so the form can render sensible section headers. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Group { - /// Window title + splash/title-screen messages. - Titles, - /// The editor font + fallback sub-fonts. - Fonts, - /// Image / graphic path fields. - Graphics, - /// The opaque trailing string. - Other, -} - -/// One editable scalar field of a loaded Game.dat: its JSON key, an editable value (seeded from the -/// original), the read-only original (for "changed?" detection), and which form group it belongs to. -pub struct Field { - /// The JSON key (`"Title"`, `"Font"`, …), the same key `apply_game_dat` recognizes. - pub key: &'static str, - /// A friendly label for the form. - pub label: &'static str, - /// The editable value (starts equal to `original`). - pub value: String, - /// The value as loaded (read-only reference, for change detection). - pub original: String, - /// Which form section this field is shown under. - pub group: Group, -} - -impl Field { - /// True when the user changed this field's value. - pub fn is_changed(&self) -> bool { - self.value != self.original - } -} - -/// The loaded, editable view of a Game.dat: where it came from, the read-only encoding badge, the -/// present scalar fields (in form order), and the present `SubFonts` (0..=3, as the file carries). -pub struct LoadedGameDat { - /// The on-disk `Game.dat` this was loaded from (the [`save`] in-place target). - pub path: PathBuf, - /// The encoding badge (`"utf8"` / `"shiftjis"`), read-only. - pub encoding: String, - /// The present scalar fields, in display order. - pub fields: Vec, - /// The present sub-font values (editable), in order. Empty when the file carries none. - pub sub_fonts: Vec, -} - -/// One editable sub-font slot (a `SubFonts` array entry). -pub struct SubFont { - pub value: String, - pub original: String, -} - -impl SubFont { - pub fn is_changed(&self) -> bool { - self.value != self.original - } -} - -/// The form-order + group + label for each scalar key. Only keys actually present in the dumped JSON -/// become [`Field`]s, so an absent optional never shows (and is never materialized on save). -const SCALAR_FORM: &[(&str, &str, Group)] = &[ - ("Title", "Title", Group::Titles), - ("TitlePlus", "Title plus", Group::Titles), - ("StartUpMsg", "Start-up message", Group::Titles), - ("TitleMsg", "Title message", Group::Titles), - ("Font", "Font", Group::Fonts), - ("DefaultPcGraphic", "Default PC graphic", Group::Graphics), - ("RoadImg", "Road image", Group::Graphics), - ("GaugeImg", "Gauge image", Group::Graphics), - ("UnknownString14", "Unknown string 14", Group::Other), -]; - -impl LoadedGameDat { - /// How many fields (scalars + sub-fonts) the user has changed. - pub fn changed_count(&self) -> usize { - self.fields.iter().filter(|f| f.is_changed()).count() - + self.sub_fonts.iter().filter(|s| s.is_changed()).count() - } - - /// True when anything was edited (so the Save button can gate on it). - pub fn dirty(&self) -> bool { - self.changed_count() > 0 - } - - /// Build the minimal `apply_game_dat` JSON: an object holding only the *changed* fields (each - /// already proven present at load time, so `apply_game_dat` only edits existing cells). Including - /// just the changes keeps unchanged cells untouched, which is what makes a no-change save - /// byte-exact. `SubFonts` must be passed whole (the array is replaced atomically) whenever any - /// entry changed, so the unchanged entries carry their current value through. - fn edits_json(&self) -> String { - let mut obj = Map::new(); - for f in &self.fields { - if f.is_changed() { - obj.insert(f.key.to_string(), Value::String(f.value.clone())); - } - } - if self.sub_fonts.iter().any(SubFont::is_changed) { - let arr: Vec = - self.sub_fonts.iter().map(|s| Value::String(s.value.clone())).collect(); - obj.insert("SubFonts".to_string(), Value::Array(arr)); - } - Value::Object(obj).to_string() - } -} - -/// Read and parse a `Game.dat` into the editable form model, reusing [`GameDat::read`] + -/// [`dump_game_dat`] (the CLI's `gamedat-json` path). Only the fields the file actually carries -/// appear (the dump omits absent optionals). The `encoding` badge is read-only. -pub fn load(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; - let gd = GameDat::read(&bytes).map_err(|e| format!("Game.dat parse failed: {e}"))?; - let json = dump_game_dat(&gd); - let root: Value = serde_json::from_str(&json).map_err(|e| format!("invalid dump JSON: {e}"))?; - let obj = root.as_object().ok_or("Game.dat dump must be a JSON object")?; - - let encoding = obj - .get("encoding") - .and_then(Value::as_str) - .unwrap_or("utf8") - .to_string(); - - // Present scalars, in form order (skip any the dump omitted for this file's string_count). - let mut fields = Vec::new(); - for (key, label, group) in SCALAR_FORM { - if let Some(v) = obj.get(*key).and_then(Value::as_str) { - fields.push(Field { - key, - label, - value: v.to_string(), - original: v.to_string(), - group: *group, - }); - } - } - - // SubFonts: a present array (0..=3 entries). Each becomes an editable slot. - let sub_fonts = obj - .get("SubFonts") - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(Value::as_str) - .map(|s| SubFont { value: s.to_string(), original: s.to_string() }) - .collect() - }) - .unwrap_or_default(); - - Ok(LoadedGameDat { - path: path.to_path_buf(), - encoding, - fields, - sub_fonts, - }) -} - -/// Where a backup goes for an in-place save. -fn backup_path(file: &Path) -> PathBuf { - let mut name = file.file_name().map(|s| s.to_os_string()).unwrap_or_default(); - name.push(".bak"); - file.with_file_name(name) -} - -/// The outcome of a [`save`] run, for the log. -pub struct SaveOutcome { - /// How many fields `apply_game_dat` actually changed. - pub changed: usize, - /// True when the output is byte-identical to the base (an unchanged save, or edits that net to - /// the original values). - pub byte_identical: bool, - /// Where the result was written. - pub out: PathBuf, - /// The backup path, if one was made. - pub backup: Option, -} - -/// Apply the form's edits onto the base `Game.dat` and write the result to `output`, mirroring the -/// CLI's `gamedat-apply`: build a minimal JSON of the changed fields, [`apply_game_dat`] it onto a -/// freshly-read base, **re-parse the [`GameDat::write`] output to verify it round-trips** before -/// writing, and report the change count + byte-identity. When `backup` is set and the output already -/// exists, the original is copied to `.bak` first. -/// -/// Byte-exactness: `apply_game_dat` only rewrites cells whose value changed and `GameDat::write` -/// anchors its size/offset housekeeping on the read-time body size, so untouched fields re-serialize -/// verbatim and a no-change save reproduces the base byte-for-byte. -pub fn save(loaded: &LoadedGameDat, output: &Path, backup: bool) -> Result { - // Always read the base fresh from disk (the load-time bytes), so the apply is onto a pristine - // base and the byte-identity check is honest. - let base_bytes = std::fs::read(&loaded.path) - .map_err(|e| format!("cannot read base {}: {e}", loaded.path.display()))?; - let mut gd = GameDat::read(&base_bytes).map_err(|e| format!("base parse failed: {e}"))?; - - let edits = loaded.edits_json(); - let changed = apply_game_dat(&mut gd, &edits).map_err(|e| format!("apply failed: {e}"))?; - - let out_bytes = gd.write(); - // Re-parse the output to verify it still round-trips before committing it to disk (the guard - // `cmd_gamedat_apply` runs). - GameDat::read(&out_bytes) - .map_err(|e| format!("internal error: edited Game.dat no longer parses ({e})"))?; - let byte_identical = out_bytes == base_bytes; - - // Back up the original (only when overwriting an existing target). - let backup_made = if backup && output.exists() { - let bak = backup_path(output); - std::fs::copy(output, &bak) - .map_err(|e| format!("backup failed ({}): {e}", bak.display()))?; - Some(bak) - } else { - None - }; - - if let Some(parent) = output.parent() { - if !parent.as_os_str().is_empty() { - let _ = std::fs::create_dir_all(parent); - } - } - std::fs::write(output, &out_bytes) - .map_err(|e| format!("write failed ({}): {e}", output.display()))?; - - Ok(SaveOutcome { - changed, - byte_identical, - out: output.to_path_buf(), - backup: backup_made, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A unique temp dir for scratch output (never a game folder or the fixtures root). - fn tmp_dir(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "wolfdawn_gamedat_{tag}_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = std::fs::create_dir_all(&p); - p - } - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// The first present Game.dat fixture from the fixtures root (read-only). - fn fixture() -> Option { - ["chamber/Data/BasicData/Game.dat"] - .into_iter() - .find_map(test_data) - } - - /// Helper: find a field by key in a loaded model. - fn field<'a>(g: &'a LoadedGameDat, key: &str) -> Option<&'a Field> { - g.fields.iter().find(|f| f.key == key) - } - - /// Load a real Game.dat, change the Font field through this module's code, save to a temp output, - /// then reload. Font is the new value and Title etc. unchanged. Then a no-change save reproduces - /// the file byte-for-byte. Never touches the fixtures root (reads the fixture, writes only temp). - #[test] - fn load_edit_font_save_reload_round_trip() { - let Some(input) = fixture() else { - eprintln!("skip load_edit_font_save_reload_round_trip: no Game.dat fixture present"); - return; - }; - // Work on a COPY so the base path is in a temp dir (we never write into the fixtures root). - let work = tmp_dir("rt"); - let copy = work.join("Game.dat"); - std::fs::copy(&input, ©).expect("copy fixture"); - - let mut g = load(©).expect("load"); - assert!(field(&g, "Title").is_some(), "Title should always be present"); - assert!(field(&g, "Font").is_some(), "Font should always be present"); - let original_title = field(&g, "Title").unwrap().value.clone(); - let original_font = field(&g, "Font").unwrap().value.clone(); - - // Change the Font field. - let new_font = format!("{original_font}_X"); - g.fields.iter_mut().find(|f| f.key == "Font").unwrap().value = new_font.clone(); - assert_eq!(g.changed_count(), 1, "exactly one field changed"); - - let out = work.join("edited.dat"); - let outcome = save(&g, &out, false).expect("save"); - assert_eq!(outcome.changed, 1, "apply should report one field changed"); - assert!(!outcome.byte_identical, "an edited save differs from base"); - - // Reload the edited file: Font is the new value, Title unchanged. - let re = load(&out).expect("reload"); - assert_eq!(field(&re, "Font").unwrap().value, new_font, "Font should be the new value"); - assert_eq!( - field(&re, "Title").unwrap().value, - original_title, - "Title must be unchanged" - ); - assert_eq!(re.encoding, g.encoding, "encoding preserved"); - - // A no-change save reproduces the (original) file byte-for-byte. - let pristine = load(©).expect("reload pristine"); - let noop_out = work.join("noop.dat"); - let noop = save(&pristine, &noop_out, false).expect("no-op save"); - assert_eq!(noop.changed, 0, "no fields changed"); - assert!(noop.byte_identical, "a no-change save must be byte-identical to base"); - assert_eq!( - std::fs::read(&noop_out).unwrap(), - std::fs::read(©).unwrap(), - "no-op output equals the original bytes" - ); - - let _ = std::fs::remove_dir_all(&work); - } - - /// In-place save with a backup writes a `.bak` holding the original bytes, then overwrites. - #[test] - fn in_place_save_backs_up_original() { - let Some(input) = fixture() else { - eprintln!("skip in_place_save_backs_up_original: no Game.dat fixture present"); - return; - }; - let work = tmp_dir("bak"); - let copy = work.join("Game.dat"); - std::fs::copy(&input, ©).expect("copy fixture"); - let original_bytes = std::fs::read(©).unwrap(); - - let mut g = load(©).expect("load"); - let title = g.fields.iter_mut().find(|f| f.key == "Title").unwrap(); - title.value = format!("{} [GUI]", title.value); - - // Save in place (output == loaded.path) with backup ON. - let outcome = save(&g, ©, true).expect("save in place"); - assert!(outcome.backup.is_some(), "a backup should have been made"); - let bak = outcome.backup.unwrap(); - assert_eq!(std::fs::read(&bak).unwrap(), original_bytes, "backup holds the original bytes"); - // The in-place file is now the edited one (reloads to the new title). - let re = load(©).expect("reload"); - assert!(field(&re, "Title").unwrap().value.ends_with("[GUI]"), "title was rewritten"); - - let _ = std::fs::remove_dir_all(&work); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/log.rs b/util/wolfdawn-master/crates/wolf-gui/src/log.rs deleted file mode 100644 index 0b96b00..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/log.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! A bounded, colour-coded activity log shown in the bottom panel. -//! -//! It is a simple ring buffer of `(level, message)`: helpers `info`/`warn`/`error` append a line, -//! the oldest lines are dropped past a cap so a long session never grows unbounded, and -//! [`Log::show`] renders it bottom-anchored with auto-scroll. Background jobs feed it through the -//! [`Reporter`](crate::task::Reporter). The UI thread feeds it directly. - -// `len`/`is_empty` are part of the log's public surface (and exercised by tests) but not called by -// the Phase 0 bin itself. Allow them rather than gating on `cfg(test)`. -#![allow(dead_code)] - -use egui::Color32; - -/// Severity of a log line, controlling its colour. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Level { - Info, - Warn, - Error, -} - -impl Level { - fn color(self) -> Color32 { - match self { - Level::Info => Color32::from_rgb(0xC8, 0xC8, 0xC8), - Level::Warn => Color32::from_rgb(0xE0, 0xB0, 0x40), - Level::Error => Color32::from_rgb(0xE0, 0x60, 0x60), - } - } -} - -/// A scrolling activity log with a fixed line cap. -pub struct Log { - lines: Vec<(Level, String)>, - cap: usize, -} - -impl Default for Log { - fn default() -> Self { - Self { - lines: Vec::new(), - cap: 500, - } - } -} - -impl Log { - pub fn new() -> Self { - Self::default() - } - - /// Append a line at `level`, dropping the oldest if the cap is exceeded. - pub fn push(&mut self, level: Level, msg: impl Into) { - self.lines.push((level, msg.into())); - if self.lines.len() > self.cap { - let overflow = self.lines.len() - self.cap; - self.lines.drain(0..overflow); - } - } - - pub fn info(&mut self, msg: impl Into) { - self.push(Level::Info, msg); - } - - pub fn warn(&mut self, msg: impl Into) { - self.push(Level::Warn, msg); - } - - pub fn error(&mut self, msg: impl Into) { - self.push(Level::Error, msg); - } - - /// Number of buffered lines (used by tests). - pub fn len(&self) -> usize { - self.lines.len() - } - - pub fn is_empty(&self) -> bool { - self.lines.is_empty() - } - - /// Render the log into the current panel, scrolled to the newest line. - pub fn show(&self, ui: &mut egui::Ui) { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .stick_to_bottom(true) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 1.0; - for (level, msg) in &self.lines { - ui.colored_label(level.color(), egui::RichText::new(msg).monospace()); - } - }); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/main.rs b/util/wolfdawn-master/crates/wolf-gui/src/main.rs deleted file mode 100644 index b4d01bf..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/main.rs +++ /dev/null @@ -1,759 +0,0 @@ -//! WolfDawn Studio. The desktop GUI front-end for the WolfDawn toolchain. -//! -//! Phase 0 ships the app shell (top/side/bottom/central panel layout), the Project section (which -//! reads a real `Game.dat`), and the shared infrastructure every later section plugs into: -//! background jobs with a progress bar, a colour-coded activity log, native file pickers, and the -//! reusable labelled controls. The CLI is unchanged. This is purely additive. -//! -//! The workspace forbids `unsafe`. eframe/egui/rfd carry their own `unsafe` internally. None is -//! written here. - -#![forbid(unsafe_code)] -// On Windows, suppress the console window for a release GUI build. Debug builds keep the console -// so `eprintln!`/panics are visible while developing. -#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] - -// Use mimalloc so freed memory (after a large extract / decompile / DB load) is returned to the OS -// instead of being held by the default allocator. No unsafe here: the attribute on a static is safe, -// the GlobalAlloc impl lives in the dependency. -#[global_allocator] -static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; - -mod app; -mod archive; -mod database; -mod decompile; -mod gamedat; -mod log; -mod project; -mod saves; -mod task; -mod translation; -mod verify; -mod widgets; - -use app::WolfDawnApp; - -fn main() -> eframe::Result<()> { - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("WolfDawn Studio") - .with_inner_size([1000.0, 680.0]) - .with_min_inner_size([720.0, 480.0]), - ..Default::default() - }; - eframe::run_native( - "WolfDawn Studio", - native_options, - Box::new(|cc| Ok(Box::new(WolfDawnApp::new(cc)))), - ) -} - -#[cfg(test)] -mod tests { - use super::app::{Section, WolfDawnApp}; - use super::project; - use std::path::{Path, PathBuf}; - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file/dir is missing, so the data-dependent gates skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// The first present fixture among `rels`, resolved under `WOLFDAWN_TEST_DATA`. - fn first_present(rels: &[&str]) -> Option { - rels.iter().find_map(|r| test_data(r)) - } - - /// Drive `f` under a fresh headless egui context (no window/GPU), the way the test gates - /// exercise the UI. `ctx.run` calls the closure to build a frame. `FnMut` since egui may call - /// it more than once. - fn run_headless(f: impl FnMut(&egui::Context)) { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), f); - } - - /// Every `Section` must render without panicking under a headless context. - #[test] - fn every_section_renders() { - for section in Section::ALL { - let mut app = WolfDawnApp::default(); - app.set_section(section); - run_headless(|ctx| { - egui::CentralPanel::default().show(ctx, |ui| { - app.render_current_section(ui); - }); - }); - } - } - - /// A full frame (all four panels, the same body `eframe::App::update` runs) must render for - /// every section without panicking. - #[test] - fn full_frame_runs_for_every_section() { - for section in Section::ALL { - let mut app = WolfDawnApp::default(); - app.set_section(section); - // Two frames: the second exercises any state seeded on the first (e.g. the archive - // section defaulting its selection from the open project). - for _ in 0..2 { - run_headless(|ctx| app.render_frame(ctx)); - } - } - } - - /// The Translation section must render with a *loaded* model too (the grid, not just the empty - /// state). Builds the model through the section's own extract code, installs it, and drives a - /// full headless frame. Skips gracefully when the data fixture is absent. - #[test] - fn translation_section_renders_with_model() { - use super::translation; - let Some(dir) = test_data("chamber/Data") else { - eprintln!("skip translation_section_renders_with_model: fixture not present"); - return; - }; - if !dir.join("BasicData").join("CommonEvent.dat").exists() { - eprintln!("skip translation_section_renders_with_model: fixture not present"); - return; - } - let outcome = translation::extract_model(&dir, |_, _| {}).expect("extract"); - let mut app = WolfDawnApp::default(); - app.set_section(Section::Translation); - app.set_translation_model(outcome.model); - // Two frames: the first lays out the grid, the second re-renders after any state settles. - for _ in 0..2 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - } - - /// The Saves section must render with a *loaded* save too (the editor + baked-string table, not - /// just the empty state). Inspects a COPY of a real save through the section's own code path, - /// installs it, and drives a full headless frame. Skips gracefully when no fixture is present. - #[test] - fn saves_section_renders_with_loaded_save() { - use super::saves; - let Some(fixture) = - first_present(&["chamber/SaveData01.sav", "pachimon/SaveData01.sav"]) - else { - eprintln!("skip saves_section_renders_with_loaded_save: no save fixture present"); - return; - }; - // Inspect a COPY so the game folder is never touched. - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_saves_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let copy = tmp.join("SaveData01.sav"); - std::fs::copy(fixture, ©).expect("copy fixture"); - let loaded = saves::inspect(©).expect("inspect"); - - let mut app = WolfDawnApp::default(); - app.set_section(Section::Saves); - app.set_loaded_save(loaded); - for _ in 0..2 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - let _ = std::fs::remove_dir_all(&tmp); - } - - /// The Saves section's headless code path: inspect a real save (on a COPY), change the title + - /// one baked string, save it through the section logic, re-inspect from the written bytes, and - /// assert the title + string changed and the format is preserved. Never touches game folders. - #[test] - fn saves_inspect_edit_save_reinspect() { - use super::saves; - let Some(fixture) = - first_present(&["chamber/SaveData01.sav", "pachimon/SaveData01.sav"]) - else { - eprintln!("skip saves_inspect_edit_save_reinspect: no save fixture present"); - return; - }; - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_rt_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let copy = tmp.join("SaveData01.sav"); - std::fs::copy(fixture, ©).expect("copy fixture"); - - let mut loaded = saves::inspect(©).expect("inspect"); - assert!(loaded.editable(), "real save should be editable"); - let fmt = loaded.format; - let new_title = format!("{} [UI]", loaded.original_title); - loaded.title = new_title.clone(); - // Edit the first string that re-encodes in this save's encoding. - let utf8 = loaded.encoding == "utf8"; - let mut marker = None; - for r in loaded.strings.iter_mut() { - if r.original.is_empty() { - continue; - } - let cand = format!("{} X", r.original); - let ok = utf8 || !encoding_rs::SHIFT_JIS.encode(&cand).2; - if ok { - r.edited = cand.clone(); - marker = Some(cand); - break; - } - } - - let stats = saves::save_changes(&loaded, true).expect("save"); - assert!(stats.title_changed); - let re = saves::inspect(©).expect("re-inspect"); - assert_eq!(re.format, fmt, "format preserved"); - assert_eq!(re.title, new_title, "title changed"); - if let Some(m) = marker { - assert!(re.strings.iter().any(|r| r.original == m), "string changed"); - } - let _ = std::fs::remove_dir_all(&tmp); - } - - /// The Decompile section must render with a *loaded* WolfScript document too (the code editor + - /// Compile controls, not just the empty state). Decompiles a real file through the section's own - /// code path, installs it, and drives a full headless frame. Skips when no code fixture. - #[test] - fn decompile_section_renders_with_script() { - use super::decompile; - let Some(input) = first_present(&[ - "chamber/Data/MapData/TitleMap.mps", - "chamber/Data/BasicData/CommonEvent.dat", - ]) else { - eprintln!("skip decompile_section_renders_with_script: no code fixture present"); - return; - }; - let script = decompile::decompile_edit(&input).expect("decompile"); - let mut app = WolfDawnApp::default(); - app.set_section(Section::Decompile); - app.set_decompile_script(input.clone(), script); - for _ in 0..2 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - } - - /// The Decompile find bar (Ctrl+F) must render + run a query on a loaded script without - /// panicking: open the bar with a query that occurs in the decompiled WolfScript and drive a few - /// full headless frames (the find bar sets a selection + scrolls the editor). Skips when no - /// code fixture is present. This guards the find-bar UI path (selection store + scroll-to). - #[test] - fn decompile_find_bar_renders_with_query() { - use super::decompile; - let Some(input) = first_present(&[ - "chamber/Data/MapData/TitleMap.mps", - "chamber/Data/BasicData/CommonEvent.dat", - ]) else { - eprintln!("skip decompile_find_bar_renders_with_query: no code fixture present"); - return; - }; - let script = decompile::decompile_edit(&input).expect("decompile"); - // Pick a query that is guaranteed to occur (the first word of the script, else a common - // token). An empty match list is fine too. The bar must still render without panicking. - let query = script - .split_whitespace() - .next() - .map(|s| s.to_string()) - .unwrap_or_else(|| "Event".to_string()); - - let mut app = WolfDawnApp::default(); - app.set_section(Section::Decompile); - app.set_decompile_script(input.to_path_buf(), script); - app.open_decompile_find(&query); - // Several frames: open + jump-to-match, then re-render after the selection/scroll settles. - for _ in 0..3 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - } - - /// The Decompile section's headless code path: decompile a real map/common-event to WolfScript, - /// assert non-empty, compile it back onto the original base to a temp output, and assert the - /// re-decompiled text matches (and the untouched compile is byte-identical to the base). Never - /// modifies the fixtures root (reads the fixture, writes only into a temp dir). - #[test] - fn decompile_compile_round_trip_via_section() { - use super::decompile; - let Some(input) = first_present(&[ - "chamber/Data/MapData/TitleMap.mps", - "chamber/Data/BasicData/CommonEvent.dat", - ]) else { - eprintln!("skip decompile_compile_round_trip_via_section: no code fixture present"); - return; - }; - let input = input.as_path(); - let text = decompile::decompile_edit(input).expect("decompile"); - assert!(!text.is_empty(), "WolfScript should be non-empty"); - - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_dec_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - // Keep the base's file name so the compiled output re-classifies the same way. - let out = tmp.join(input.file_name().expect("file name")); - let outcome = decompile::compile_to(&text, input, &out).expect("compile"); - assert!(out.exists() && outcome.bytes > 0, "compiled output should be written"); - assert!(outcome.byte_identical, "untouched compile is byte-identical to base"); - // Compare the re-decompile against a decompile of the base placed in the same temp dir, so - // both share the same (BasicData-less) symbol context (the operand labels match). The - // byte-identity above is the strong guarantee, the labels are stripped by the compiler. - let base_here = tmp.join("base").join(input.file_name().unwrap()); - std::fs::create_dir_all(base_here.parent().unwrap()).unwrap(); - std::fs::copy(input, &base_here).expect("copy base"); - let baseline = decompile::decompile_edit(&base_here).expect("decompile base copy"); - let re = decompile::decompile_edit(&out).expect("re-decompile"); - assert_eq!(re, baseline, "re-decompiled WolfScript should match the base's own decompile"); - let _ = std::fs::remove_dir_all(&tmp); - } - - /// The Game.dat section must render with a *loaded* model too (the field form, not just the empty - /// state). Loads a real Game.dat (on a COPY) through the section's own code path, installs it, and - /// drives a full headless frame. Skips gracefully when the Game.dat fixture is absent. - #[test] - fn gamedat_section_renders_with_loaded() { - use super::gamedat; - let Some(fixture) = first_present(&["chamber/Data/BasicData/Game.dat"]) else { - eprintln!("skip gamedat_section_renders_with_loaded: no Game.dat fixture present"); - return; - }; - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_gd_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let copy = tmp.join("Game.dat"); - std::fs::copy(fixture, ©).expect("copy fixture"); - let loaded = gamedat::load(©).expect("load"); - - let mut app = WolfDawnApp::default(); - app.set_section(Section::GameDat); - app.set_gamedat_loaded(loaded); - for _ in 0..2 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - let _ = std::fs::remove_dir_all(&tmp); - } - - /// The Game.dat section's headless code path: load a real Game.dat (on a COPY), change Font, save - /// to a temp output, then reload. Font is new and Title unchanged. A no-change save is byte-exact. - #[test] - fn gamedat_edit_font_round_trip_via_section() { - use super::gamedat; - let Some(fixture) = first_present(&["chamber/Data/BasicData/Game.dat"]) else { - eprintln!("skip gamedat_edit_font_round_trip_via_section: no Game.dat fixture present"); - return; - }; - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_gdrt_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let copy = tmp.join("Game.dat"); - std::fs::copy(fixture, ©).expect("copy fixture"); - - let mut g = gamedat::load(©).expect("load"); - let title = g.fields.iter().find(|f| f.key == "Title").map(|f| f.value.clone()); - let font = g.fields.iter().find(|f| f.key == "Font").expect("Font present").value.clone(); - let new_font = format!("{font}_X"); - g.fields.iter_mut().find(|f| f.key == "Font").unwrap().value = new_font.clone(); - - let out = tmp.join("edited.dat"); - let outcome = gamedat::save(&g, &out, false).expect("save"); - assert_eq!(outcome.changed, 1, "one field changed"); - let re = gamedat::load(&out).expect("reload"); - assert_eq!( - re.fields.iter().find(|f| f.key == "Font").unwrap().value, - new_font, - "Font is the new value" - ); - assert_eq!( - re.fields.iter().find(|f| f.key == "Title").map(|f| f.value.clone()), - title, - "Title unchanged" - ); - - // No-change save reproduces the original byte-for-byte. - let pristine = gamedat::load(©).expect("reload pristine"); - let noop = tmp.join("noop.dat"); - let n = gamedat::save(&pristine, &noop, false).expect("no-op save"); - assert!(n.byte_identical, "no-change save is byte-identical"); - assert_eq!(std::fs::read(&noop).unwrap(), std::fs::read(©).unwrap()); - let _ = std::fs::remove_dir_all(&tmp); - } - - /// The Database section must render with a *loaded* model too (the type selector + editable grid, - /// not just the empty state). Loads a real DB (on a COPY of the .project+.dat pair) through the - /// section's own code path, installs it, and drives a full headless frame. Skips when absent. - #[test] - fn database_section_renders_with_loaded() { - use super::database; - let Some(src) = test_data("chamber/Data/BasicData/DataBase.project") - .filter(|p| p.with_extension("dat").exists()) - else { - eprintln!("skip database_section_renders_with_loaded: no database fixture present"); - return; - }; - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_db_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let proj = tmp.join("DataBase.project"); - std::fs::copy(&src, &proj).expect("copy .project"); - std::fs::copy(src.with_extension("dat"), proj.with_extension("dat")).expect("copy .dat"); - let loaded = database::load(&proj).expect("load"); - - let mut app = WolfDawnApp::default(); - app.set_section(Section::Database); - app.set_database_loaded(loaded); - for _ in 0..2 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - let _ = std::fs::remove_dir_all(&tmp); - } - - /// Unpack the Chamber fixture's Data.wolf (if present) through the Archive section's own code path - /// into a temp dir, and assert the data files appear. Never touches the game folder. Skips when - /// the archive is absent. - #[test] - fn archive_unpack_data_wolf() { - use super::archive; - let Some(arc) = test_data("chamber/Data.wolf") else { - eprintln!("skip archive_unpack_data_wolf: archive fixture not present"); - return; - }; - let out = std::env::temp_dir().join(format!("wolfdawn_gui_unpack_{}", std::process::id())); - let _ = std::fs::create_dir_all(&out); - let outcome = archive::unpack(&arc, &out, |_, _| {}).expect("unpack"); - assert!(outcome.written > 0, "files should be written"); - assert!( - out.join("BasicData/CommonEvent.dat").exists(), - "BasicData/CommonEvent.dat should be unpacked" - ); - let _ = std::fs::remove_dir_all(&out); - } - - /// Repack round-trip through the Archive section: pack a small temp folder (copies of a couple - /// fixture data files) to a temp .wolf, unpack it, and confirm the files + content come back. - /// Never modifies the fixtures root (read-only copies + temp dirs). - #[test] - fn archive_repack_round_trip() { - use super::archive; - let present: Vec = [ - "chamber/Data/BasicData/Game.dat", - "chamber/Data/BasicData/CommonEvent.dat", - ] - .iter() - .filter_map(|r| test_data(r)) - .collect(); - if present.is_empty() { - eprintln!("skip archive_repack_round_trip: no data fixtures present"); - return; - } - let base = std::env::temp_dir().join(format!("wolfdawn_gui_repack_{}", std::process::id())); - let src = base.join("src"); - let _ = std::fs::create_dir_all(src.join("BasicData")); - let mut expect: Vec<(String, Vec)> = Vec::new(); - for p in &present { - let name = p.file_name().unwrap().to_string_lossy().to_string(); - std::fs::copy(p, src.join("BasicData").join(&name)).expect("copy"); - expect.push((format!("BasicData/{name}"), std::fs::read(p).expect("read"))); - } - - let out_wolf = base.join("Data.wolf"); - let outcome = - archive::repack(&src, &out_wolf, &archive::PackOptions::default(), |_, _| {}).expect("repack"); - assert!(outcome.files >= 1 && outcome.bytes > 0, "should pack a non-empty archive"); - - let back = base.join("back"); - archive::unpack(&out_wolf, &back, |_, _| {}).expect("unpack repacked"); - for (rel, bytes) in &expect { - let got = back.join(rel); - assert!(got.exists(), "{rel} should round-trip"); - assert_eq!(&std::fs::read(&got).expect("read back"), bytes, "{rel} content matches"); - } - let _ = std::fs::remove_dir_all(&base); - } - - /// The Verify section's single-file path: a known-good plaintext data file from the fixtures must - /// verify PASS. Read-only. Skips when absent. - #[test] - fn verify_single_known_good() { - use super::verify; - let Some(path) = first_present(&[ - "chamber/Data/MapData/TitleMap.mps", - "chamber/Data/BasicData/Game.dat", - "chamber/Data/BasicData/CommonEvent.dat", - ]) else { - eprintln!("skip verify_single_known_good: no data fixture present"); - return; - }; - let verdict = verify::verify_file(&path); - assert!( - verdict.is_pass(), - "{} should PASS, got {}: {}", - path.display(), - verdict.tag(), - verdict.detail() - ); - } - - /// Opening a known game dir populates the project's Game.dat facts. Skips gracefully when the - /// fixture is absent (CI without the data corpus). - #[test] - fn opens_known_game_dir() { - let Some(dir) = test_data("chamber/Data") else { - eprintln!("skip opens_known_game_dir: fixture not present"); - return; - }; - if !dir.join("BasicData").join("Game.dat").exists() { - eprintln!("skip opens_known_game_dir: fixture {dir:?} not present"); - return; - } - let outcome = project::open_project(&dir); - let p = outcome.project; - assert!(p.game_dat_ok, "Game.dat should have parsed for the fixture"); - assert!(p.game_dat.is_some(), "Game.dat path should be recorded"); - assert!(!p.title.is_empty(), "a title should have been decoded"); - // The fixture's BasicData is the data dir. It must have been located. - assert!(p.data_dir.is_some(), "the data dir should have been located"); - } - - /// Opening a path with no Game.dat is non-fatal: the project still opens, flagged not-ok. - #[test] - fn open_missing_game_dat_is_non_fatal() { - let tmp = std::env::temp_dir().join(format!("wolfdawn_gui_test_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let outcome = project::open_project(&tmp); - assert!(!outcome.project.game_dat_ok); - assert!(outcome.notes.iter().any(|(_, m)| m.contains("Game.dat"))); - let _ = std::fs::remove_dir_all(&tmp); - } - - /// A unique temp game dir for the index tests, with the minimal layout the scan looks at: - /// `BasicData/CommonEvent.dat`, `BasicData/DataBase.project`, `MapData/X.mps`, and a - /// `Save/SaveData01.sav`. The files are fabricated (rescan only *lists* by name/extension, it - /// never parses), so this never touches real game folders or the fixtures root. - fn make_temp_game(tag: &str) -> std::path::PathBuf { - let root = std::env::temp_dir() - .join(format!("wolfdawn_gui_idx_{tag}_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - let data = root.join("Data"); - std::fs::create_dir_all(data.join("BasicData")).expect("mk BasicData"); - std::fs::create_dir_all(data.join("MapData")).expect("mk MapData"); - std::fs::create_dir_all(root.join("Save")).expect("mk Save"); - std::fs::write(data.join("BasicData").join("CommonEvent.dat"), b"x").expect("CommonEvent"); - std::fs::write(data.join("BasicData").join("DataBase.project"), b"x").expect("project"); - std::fs::write(data.join("BasicData").join("DataBase.dat"), b"x").expect("project .dat"); - std::fs::write(data.join("MapData").join("MapA.mps"), b"x").expect("MapA"); - std::fs::write(root.join("Save").join("SaveData01.sav"), b"x").expect("save"); - root - } - - /// Opening a fabricated game dir populates the scanned index (code files / databases / saves), - /// and a later `rescan()` after a NEW .mps is added returns `true` and lists the new file. - #[test] - fn project_index_and_rescan_detects_new_file() { - let root = make_temp_game("rescan"); - - let mut p = project::open_project(&root).project; - // CommonEvent.dat + MapA.mps = 2 code files, one .project, one save. - assert!( - p.code_files.iter().any(|f| f.ends_with("MapData/MapA.mps")), - "MapA.mps should be indexed; got {:?}", - p.code_files - ); - assert!( - p.code_files.iter().any(|f| f.ends_with("BasicData/CommonEvent.dat")), - "CommonEvent.dat should be indexed" - ); - assert_eq!(p.databases.len(), 1, "one database .project indexed"); - assert_eq!(p.saves.len(), 1, "one save indexed"); - assert!(p.save_dir.is_some(), "the Save/ dir should be recorded"); - - // A no-op rescan reports no change. - assert!(!p.rescan(), "rescan with no fs change returns false"); - - // Add a second map, then rescan returns true and the new map appears. - let new_map = root.join("Data").join("MapData").join("MapB.mps"); - std::fs::write(&new_map, b"x").expect("write MapB"); - assert!(p.rescan(), "rescan after adding a .mps returns true"); - assert!( - p.code_files.iter().any(|f| f.ends_with("MapData/MapB.mps")), - "the newly-added MapB.mps should now be indexed; got {:?}", - p.code_files - ); - - let _ = std::fs::remove_dir_all(&root); - } - - /// The persisted `Settings` payload round-trips through serde unchanged (covers the eframe - /// storage value), for both a non-default and the default configuration. - #[test] - fn settings_serde_round_trip() { - use super::app::Settings; - let custom = Settings { - dark_mode: false, - default_en_punct: true, - default_allow_code_drift: true, - default_backup: false, - auto_detect: false, - }; - let json = serde_json::to_string(&custom).expect("serialize"); - let back: Settings = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(custom, back, "custom settings round-trip equal"); - - let def = Settings::default(); - let json = serde_json::to_string(&def).expect("serialize default"); - let back: Settings = serde_json::from_str(&json).expect("deserialize default"); - assert_eq!(def, back, "default settings round-trip equal"); - } - - /// The Settings section must render in a full headless frame with a project loaded (so the - /// per-section dropdowns also render), without panicking. Uses a fabricated temp game dir. - #[test] - fn settings_section_renders_with_project() { - let root = make_temp_game("render"); - let project = project::open_project(&root).project; - - let mut app = WolfDawnApp::default(); - app.set_project(project); - // Render the Settings section, then walk a couple sections that show dropdowns, each over - // two frames so any first-frame state settles. - for section in [Section::Settings, Section::Decompile, Section::Database, Section::Saves] { - app.set_section(section); - for _ in 0..2 { - let ctx = egui::Context::default(); - let _ = ctx.run(Default::default(), |ctx| app.render_frame(ctx)); - } - } - let _ = std::fs::remove_dir_all(&root); - } - - // ------------------------------------------------------------------------------------------ - // No-emoji guard: the GUI must render its glyphs on ANY system, including ones without an OS - // emoji font. egui's bundled fonts (and the optional CJK fallback) cover Latin, the Geometric - // Shapes / Arrows blocks, and CJK, but NOT the Unicode emoji / pictograph blocks, which show - // as tofu (□) without an emoji font. These two tests keep the UI free of those pictographs. - // ------------------------------------------------------------------------------------------ - - /// The safe non-ASCII glyphs the UI uses in string/char literals: ASCII/Latin-1 - /// punctuation, Geometric Shapes (U+25A0..U+25FF), and Arrows (U+2190..U+21FF). Anything else - /// non-ASCII in a literal must be CJK-range (Japanese sample/help text). A stray emoji is caught. - const SAFE_GLYPHS: &[char] = &[ - '×', // U+00D7 multiplication sign, the clear/close button - '·', // U+00B7 middle dot, separators - '—', // U+2014 em dash, captions/log separators - '…', // U+2026 horizontal ellipsis, "Browse…", "Open…" - '→', // U+2192 rightwards arrow, "src → dst" log lines - '←', // U+2190 leftwards arrow, "Import DB ← file" log lines - '▶', // U+25B6 black right-pointing triangle, action buttons / log start marker - '◀', // U+25C0 black left-pointing triangle, find "Prev" - '●', // U+25CF black circle, the Database "edited" marker - ]; - - /// Every `.rs` file under `src/`, as (path, contents). - fn gui_source_files() -> Vec<(std::path::PathBuf, String)> { - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut out = Vec::new(); - for entry in std::fs::read_dir(&src).expect("read src dir") { - let path = entry.expect("dir entry").path(); - if path.extension().and_then(|e| e.to_str()) == Some("rs") { - let text = std::fs::read_to_string(&path).expect("read source"); - out.push((path, text)); - } - } - assert!(!out.is_empty(), "found no .rs files under {}", src.display()); - out - } - - /// True for a codepoint in the Unicode emoji / pictograph blocks we must never ship: everything - /// at/above U+1F000 (Mahjong/Domino/Playing-cards through Symbols & Pictographs Extended-A). - fn is_emoji_pictograph(c: char) -> bool { - c as u32 >= 0x1F000 - } - - /// A codepoint that the always-loaded CJK fallback font covers (so it renders, not tofu): the - /// main CJK/kana blocks plus CJK Symbols & Punctuation and the Fullwidth/Halfwidth Forms used in - /// Japanese help text (e.g. 「」、。~). Conservative but ample for the tool's sample strings. - fn is_cjk(c: char) -> bool { - let u = c as u32; - (0x3000..=0x30FF).contains(&u) // CJK Symbols/Punctuation + Hiragana + Katakana - || (0x3400..=0x4DBF).contains(&u) // CJK Ext A - || (0x4E00..=0x9FFF).contains(&u) // CJK Unified Ideographs - || (0xFF00..=0xFFEF).contains(&u) // Halfwidth/Fullwidth Forms - } - - /// HARD GATE: no emoji-pictograph codepoint (>= U+1F000) may appear ANYWHERE in the GUI source. - /// Not in strings, not even in comments (there should be none). This makes it impossible to ship - /// a pictograph that could tofu on a user's machine. - #[test] - fn no_emoji_pictographs_in_source() { - let mut offenders: Vec = Vec::new(); - for (path, text) in gui_source_files() { - for (lineno, line) in text.lines().enumerate() { - for c in line.chars() { - if is_emoji_pictograph(c) { - offenders.push(format!( - "{}:{}: U+{:04X} {:?}", - path.file_name().unwrap().to_string_lossy(), - lineno + 1, - c as u32, - c - )); - } - } - } - } - assert!( - offenders.is_empty(), - "found emoji pictograph(s) (>=U+1F000) in GUI source — these tofu on systems without an \ - emoji font; replace with text or a Geometric/Arrow glyph:\n{}", - offenders.join("\n") - ); - } - - /// Strip Rust line comments (`//` and doc `///`) from `src`, returning code + string/char - /// literals only. The GUI source uses line comments exclusively (no `/* */` blocks), and no - /// string literal contains a `//` sequence, so truncating each line at its first `//` is exact - /// here. That is more robust than a hand lexer (no lifetime / raw-string / char-literal edge - /// cases). This keeps benign non-ASCII that lives ONLY in comments (e.g. the tofu glyph □, the - /// ␠ space marker, a ▼ doc example) from being mistaken for a UI glyph. - fn strip_comments(src: &str) -> String { - let mut out = String::with_capacity(src.len()); - for line in src.lines() { - let code = match line.find("//") { - Some(idx) => &line[..idx], - None => line, - }; - out.push_str(code); - out.push('\n'); - } - out - } - - /// ALLOW-LIST GATE: every non-ASCII glyph that survives comment-stripping (i.e. lives in code or - /// in a string/char literal) must be either CJK-range (Japanese sample/help text the CJK font - /// covers) or on the [`SAFE_GLYPHS`] list (Latin-1 punctuation + Geometric/Arrow glyphs the - /// bundled fonts cover). This catches a future stray dingbat (e.g. ✔ ✖ ⚠ ★) even though it sits - /// below U+1F000 and so escapes the hard pictograph gate above. - #[test] - fn non_ascii_ui_glyphs_are_safe_or_cjk() { - let mut offenders: Vec = Vec::new(); - for (path, text) in gui_source_files() { - let code = strip_comments(&text); - for c in code.chars() { - if (c as u32) <= 0x7F || c.is_whitespace() { - continue; - } - if SAFE_GLYPHS.contains(&c) || is_cjk(c) { - continue; - } - offenders.push(format!( - "{}: U+{:04X} {:?}", - path.file_name().unwrap().to_string_lossy(), - c as u32, - c - )); - } - } - offenders.sort(); - offenders.dedup(); - assert!( - offenders.is_empty(), - "found non-ASCII glyph(s) in GUI code/literals that are neither CJK nor on the safe \ - allow-list — they may tofu on systems lacking the right font; use text or a \ - Geometric/Arrow glyph (and extend SAFE_GLYPHS if you added an intentional one):\n{}", - offenders.join("\n") - ); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/project.rs b/util/wolfdawn-master/crates/wolf-gui/src/project.rs deleted file mode 100644 index 4bb530c..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/project.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! The open game **project**: locating the game's files on disk and reading `Game.dat`. -//! -//! A project is opened from either a folder or a `Data.wolf` / `Game.dat` file. From whatever the -//! user picks we resolve the *game root* (the dir holding `Data.wolf` and/or an unpacked `Data/`), -//! then locate the three things every later section needs: the `Data.wolf` archive, the unpacked -//! `Data/` directory, and `Game.dat`. `Game.dat` is parsed with [`GameDat::read`] to surface the -//! title / encoding / font / version. A missing or unparseable `Game.dat` is non-fatal. The -//! project still opens with whatever was found, and the caller logs a warning. - -use std::path::{Path, PathBuf}; - -use wolf_decompiler::extract_game_dat; -use wolf_decompiler::text::decode_wstr; -use wolf_formats::game_dat::GameDat; - -/// An opened game, with the on-disk locations later sections operate on plus the human-readable -/// facts read out of `Game.dat`. -#[derive(Default, Clone)] -pub struct Project { - /// The game root (the directory that holds `Data.wolf` and/or `Data/`). - pub root: PathBuf, - /// The packed `Data.wolf` archive, if present. - pub data_wolf: Option, - /// The unpacked `Data/` directory, if present. - pub data_dir: Option, - /// The `Game.dat` we parsed (root, `Data/`, or `Data/BasicData/`), if found. - pub game_dat: Option, - - // --- a cheap scanned index of the small relevant dirs (kept fresh by `rescan`) ------------- - /// Decompilable code files: `MapData/*.mps` + `BasicData/CommonEvent.dat` (sorted). - pub code_files: Vec, - /// Editable databases: `BasicData/*.project` (sorted). - pub databases: Vec, - /// `.sav` files found under the game's `Save/` dir (sorted). - pub saves: Vec, - /// The `Save/` dir the saves were found under (the batch-update default), if any. - pub save_dir: Option, - - // --- facts read from Game.dat (empty / defaults when it was absent or unparseable) --- - /// Window/game title (decoded for display). - pub title: String, - /// A short version/encoding descriptor (e.g. `UTF-8` vs `Shift-JIS`, plus title-suffix). - pub version: String, - /// True when `Game.dat` declared UTF-8 (vs Shift-JIS). - pub utf8: bool, - /// The editor's main font (decoded for display). - pub font: String, - /// True if `Game.dat` was found and parsed. - pub game_dat_ok: bool, -} - -impl Project { - /// Re-walk the small relevant subdirs (`MapData/`, `BasicData/`, and `Save/`) and refresh the - /// scanned file index ([`code_files`](Self::code_files) / [`databases`](Self::databases) / - /// [`saves`](Self::saves) + [`save_dir`](Self::save_dir)). Returns `true` if any of those sets - /// changed versus the previous scan, so the caller can log / repaint only on a real change. - /// - /// Cheap: it only `read_dir`s those specific folders (never the huge asset trees - /// like Picture/ or Sound/), and only stats by extension/name. The lists are sorted so the - /// dropdowns built from them have a stable order. - pub fn rescan(&mut self) -> bool { - let prev_code = self.code_files.clone(); - let prev_db = self.databases.clone(); - let prev_saves = self.saves.clone(); - let prev_save_dir = self.save_dir.clone(); - - // The data dir is where MapData/ and BasicData/ live (it may be the root itself when the - // project was opened on a folder that *is* a Data dir). Fall back to the root. - let data_base = self.data_dir.clone().unwrap_or_else(|| self.root.clone()); - - // Code: MapData/*.mps + BasicData/CommonEvent.dat. - let mut code_files = list_with_ext(&data_base.join("MapData"), "mps"); - let common = data_base.join("BasicData").join("CommonEvent.dat"); - if common.is_file() { - code_files.push(common); - } - code_files.sort(); - self.code_files = code_files; - - // Databases: BasicData/*.project (paired .dat is loaded by the section). - let mut databases = list_with_ext(&data_base.join("BasicData"), "project"); - databases.sort(); - self.databases = databases; - - // Saves: *.sav under a Save/ dir at the game root (and/or next to Game.exe == the root). - let (saves, save_dir) = scan_saves(&self.root); - self.saves = saves; - self.save_dir = save_dir; - - self.code_files != prev_code - || self.databases != prev_db - || self.saves != prev_saves - || self.save_dir != prev_save_dir - } -} - -/// List the files directly in `dir` whose extension matches `ext` (case-insensitive). Empty when -/// `dir` is missing or unreadable. The scan is best-effort and never fails a project open. -fn list_with_ext(dir: &Path, ext: &str) -> Vec { - let mut out = Vec::new(); - let Ok(entries) = std::fs::read_dir(dir) else { - return out; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path - .extension() - .and_then(|s| s.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case(ext)) - { - out.push(path); - } - } - out -} - -/// Locate the game's `Save/` dir (at the root) and list the `*.sav` files in it. Returns the sorted -/// save list plus the dir they came from (the batch-update default). Empty / `None` when there is no -/// `Save/` dir yet. -fn scan_saves(root: &Path) -> (Vec, Option) { - let save_dir = root.join("Save"); - if !save_dir.is_dir() { - return (Vec::new(), None); - } - let mut saves = list_with_ext(&save_dir, "sav"); - saves.sort(); - (saves, Some(save_dir)) -} - -/// The result of opening a project: the project itself plus any human-readable notes to log -/// (warnings for things that were missing or failed to parse, info for what was found). -pub struct OpenOutcome { - pub project: Project, - pub notes: Vec<(NoteLevel, String)>, -} - -/// Severity for an [`OpenOutcome`] note, mapped onto the log by the caller. -#[derive(Clone, Copy)] -pub enum NoteLevel { - Info, - Warn, -} - -/// Resolve the game root from whatever the user picked (a folder, a `Data.wolf`, or a `Game.dat`), -/// then locate `Data.wolf` / `Data/` / `Game.dat`, parse `Game.dat`, and build the [`Project`]. -/// -/// Never errors: a missing/garbled `Game.dat` produces a project with `game_dat_ok == false` and a -/// warning note. (The only "failure" worth refusing is a path that does not exist at all.) -pub fn open_project(picked: &Path) -> OpenOutcome { - let mut notes: Vec<(NoteLevel, String)> = Vec::new(); - - // Resolve the game root. If the user picked a file, the root is (heuristically) its parent, or - // its grandparent when the file sits inside a `Data/` (or `Data/BasicData/`) subtree. - let root = resolve_root(picked); - - // Locate Data.wolf (root or Data/) and the unpacked Data/ dir. - let data_wolf = ["Data.wolf"] - .iter() - .map(|n| root.join(n)) - .find(|p| p.is_file()); - let data_dir = { - let d = root.join("Data"); - if d.is_dir() { - Some(d) - } else { - // A folder that *is* a Data dir (holds BasicData) counts too. - root.join("BasicData").is_dir().then(|| root.clone()) - } - }; - - // Locate Game.dat. Try, in order: an explicitly-picked Game.dat, then the common spots. - let game_dat = locate_game_dat(picked, &root, data_dir.as_deref()); - - let mut project = Project { - root: root.clone(), - data_wolf: data_wolf.clone(), - data_dir: data_dir.clone(), - game_dat: game_dat.clone(), - ..Default::default() - }; - // Build the initial scanned index (code files / databases / saves) from the resolved dirs. - project.rescan(); - - // Log what we found, so the user sees the layout we resolved. - notes.push((NoteLevel::Info, format!("game root: {}", root.display()))); - match &data_wolf { - Some(p) => notes.push((NoteLevel::Info, format!("found Data.wolf: {}", p.display()))), - None => notes.push((NoteLevel::Info, "no Data.wolf (will work from unpacked Data/)".into())), - } - match &data_dir { - Some(p) => notes.push((NoteLevel::Info, format!("found unpacked data dir: {}", p.display()))), - None => notes.push((NoteLevel::Info, "no unpacked Data/ dir yet (unpack Data.wolf to create one)".into())), - } - // Surface the scanned index counts so the user sees what the per-section dropdowns will offer. - notes.push(( - NoteLevel::Info, - format!( - "indexed {} code file(s), {} database(s), {} save(s)", - project.code_files.len(), - project.databases.len(), - project.saves.len(), - ), - )); - - // Parse Game.dat if we found one. Failure is non-fatal. - match &game_dat { - Some(path) => match read_game_facts(path) { - Ok(facts) => { - project.title = facts.title; - project.version = facts.version; - project.utf8 = facts.utf8; - project.font = facts.font; - project.game_dat_ok = true; - notes.push(( - NoteLevel::Info, - format!("parsed Game.dat: title={:?}, encoding={}", project.title, encoding_label(project.utf8)), - )); - } - Err(e) => { - notes.push(( - NoteLevel::Warn, - format!("Game.dat found but could not be read ({e}); opening project without it"), - )); - } - }, - None => notes.push(( - NoteLevel::Warn, - "no Game.dat found (looked in the root, Data/, and Data/BasicData/)".into(), - )), - } - - OpenOutcome { project, notes } -} - -/// Human label for the encoding flag. -pub fn encoding_label(utf8: bool) -> &'static str { - if utf8 { - "UTF-8" - } else { - "Shift-JIS" - } -} - -/// Resolve the game root from a picked path. -fn resolve_root(picked: &Path) -> PathBuf { - if picked.is_dir() { - // If they picked a `Data` dir, the game root is its parent. Otherwise it's the dir itself. - if picked.file_name().and_then(|s| s.to_str()) == Some("Data") { - if let Some(parent) = picked.parent() { - return parent.to_path_buf(); - } - } - // If they picked a BasicData dir, climb to the game root (BasicData -> Data -> root). - if picked.file_name().and_then(|s| s.to_str()) == Some("BasicData") { - if let Some(root) = picked.parent().and_then(|p| p.parent()) { - return root.to_path_buf(); - } - } - return picked.to_path_buf(); - } - // A file: walk up out of a known `Data`/`BasicData` subtree to the game root. - let parent = picked.parent().unwrap_or(Path::new(".")); - let parent_name = parent.file_name().and_then(|s| s.to_str()); - match parent_name { - Some("BasicData") => parent - .parent() - .and_then(|p| p.parent()) - .unwrap_or(parent) - .to_path_buf(), - Some("Data") => parent.parent().unwrap_or(parent).to_path_buf(), - _ => parent.to_path_buf(), - } -} - -/// Locate `Game.dat`, trying (in order): an explicitly-picked `Game.dat`, the game root, -/// `Data/`, and `Data/BasicData/`. -fn locate_game_dat(picked: &Path, root: &Path, data_dir: Option<&Path>) -> Option { - if picked.is_file() && picked.file_name().and_then(|s| s.to_str()) == Some("Game.dat") { - return Some(picked.to_path_buf()); - } - let mut candidates = vec![ - root.join("Game.dat"), - root.join("Data").join("Game.dat"), - root.join("Data").join("BasicData").join("Game.dat"), - ]; - if let Some(d) = data_dir { - candidates.push(d.join("Game.dat")); - candidates.push(d.join("BasicData").join("Game.dat")); - } - candidates.into_iter().find(|p| p.is_file()) -} - -/// The subset of `Game.dat` facts we display. -struct GameFacts { - title: String, - version: String, - utf8: bool, - font: String, -} - -/// Read and decode the display facts from a `Game.dat` file. -fn read_game_facts(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let gd = GameDat::read(&bytes).map_err(|e| e.to_string())?; - let utf8 = gd.utf8; - let title = decode_wstr(&gd.title, utf8); - let font = decode_wstr(&gd.font, utf8); - - // "version" is a soft concept in Game.dat: surface the encoding plus the title-suffix string - // (string 8, e.g. a "Ver1.02" the author bakes in), which is the closest user-visible version. - let suffix = gd - .title_plus - .as_ref() - .map(|w| decode_wstr(w, utf8)) - .filter(|s| !s.trim().is_empty()); - let version = match suffix { - Some(s) => format!("{} · {}", encoding_label(utf8), s.trim()), - None => encoding_label(utf8).to_string(), - }; - - // Sanity-cross-check against extract_game_dat: it always emits Title first, decoded in-encoding. - // If our direct decode somehow disagrees (it shouldn't), prefer the extractor's value so the UI - // matches the translation tooling exactly. Cheap and keeps the two paths from ever drifting. - let title = title_from_extract(&gd).unwrap_or(title); - - Ok(GameFacts { - title, - version, - utf8, - font, - }) -} - -/// Pull the Title `source` out of `extract_game_dat`'s JSON (the same value the translation -/// pipeline sees), so the GUI's title display can never disagree with the tooling. -fn title_from_extract(gd: &GameDat) -> Option { - let json = extract_game_dat(gd); - // Fixed machine shape: `{"key": "Title", "source": "...", "text": "..."}`. Scan for it without - // pulling in a JSON dep (mirrors wolf-cli's own minimal scan in save-update). - let marker = json.find("\"key\": \"Title\"")?; - let after = &json[marker..]; - let src_at = after.find("\"source\":")?; - let after = &after[src_at + "\"source\":".len()..]; - let open = after.find('"')? + 1; - let rest = &after[open..]; - let mut out = String::new(); - let mut chars = rest.chars(); - while let Some(c) = chars.next() { - match c { - '"' => return Some(out), - '\\' => match chars.next()? { - 'n' => out.push('\n'), - 'r' => out.push('\r'), - 't' => out.push('\t'), - '"' => out.push('"'), - '\\' => out.push('\\'), - '/' => out.push('/'), - 'u' => { - let hex: String = chars.by_ref().take(4).collect(); - let code = u32::from_str_radix(&hex, 16).ok()?; - out.push(char::from_u32(code)?); - } - other => out.push(other), - }, - _ => out.push(c), - } - } - None -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/saves.rs b/util/wolfdawn-master/crates/wolf-gui/src/saves.rs deleted file mode 100644 index 4df6faa..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/saves.rs +++ /dev/null @@ -1,501 +0,0 @@ -//! The Saves section's data model and the headless logic behind it. -//! -//! A `.sav` bakes in the game *title* and player-facing *strings* (item / skill / mode names shown -//! on the save-load screen). After a fan translation those go stale: the translated build rejects a -//! save whose baked title does not match its own `Game.dat` title ("trying to load from another -//! game"), and the baked strings still read in the original language. This section refreshes both, -//! exactly as the CLI's `save-update` does, over either a single `.sav` or a whole save folder. -//! -//! This module owns everything *except* the egui rendering (which lives in `app.rs`), so the whole -//! workflow can be exercised headlessly in tests: -//! -//! * [`inspect`] reads a `.sav` and calls the library's [`inspect_save`] to surface the format, -//! encoding, baked title, and the editable baked-string list, the same records `update_save` -//! can rewrite. -//! * [`save_changes`] turns the user's edits (a possibly-changed title + the rows the user -//! actually changed) into a `new_title` + `source->target` map, optionally backs up the -//! original, then calls the library's [`update_save`] and writes the result. -//! * [`batch_update`] mirrors the CLI's `cmd_save_update`: a new title (typed or read from a -//! translated `Game.dat`) and/or a translation file/dir (turned into a `source->target` map via -//! [`build_memory`], like the CLI), applied with `update_save` over every `*.sav` in a folder. -//! -//! Every codec is handled by `wolf_decompiler` (standard and GamePro Pro). An unsupported buffer is -//! reported and skipped, never written. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use wolf_decompiler::{ - build_memory, extract_game_dat, inspect_save, update_save, SaveFormat, SaveInfo, - SaveUpdateStats, -}; -use wolf_formats::game_dat::GameDat; - -/// One editable baked string: the read-only `original` (used as the `update_save` map key) and the -/// editable `edited` value (starts equal to `original`). -#[derive(Clone)] -pub struct StringRow { - pub original: String, - pub edited: String, -} - -impl StringRow { - /// True when the user actually changed this string (so only real edits go into the map). - pub fn is_changed(&self) -> bool { - self.edited != self.original - } -} - -/// The loaded-and-editable view of a single inspected `.sav`: where it came from, its detected -/// format/encoding, the editable title (seeded from the baked title), and the editable string rows. -pub struct LoadedSave { - /// The on-disk `.sav` this was inspected from (the [`save_changes`] write target). - pub path: PathBuf, - pub format: SaveFormat, - pub encoding: &'static str, - /// The baked title as read (read-only reference, for "changed?" detection and the dim original). - pub original_title: String, - /// The editable title field (starts equal to `original_title`). - pub title: String, - /// The editable baked strings (deduplicated, in first-seen order). - pub strings: Vec, -} - -impl LoadedSave { - /// True when the save's format is one we can edit/write. - pub fn editable(&self) -> bool { - self.format != SaveFormat::Unsupported - } - - /// True when the title field differs from the baked title. - pub fn title_changed(&self) -> bool { - self.title != self.original_title - } - - /// How many string rows the user has changed. - pub fn changed_count(&self) -> usize { - self.strings.iter().filter(|r| r.is_changed()).count() - } - - /// Build the `new_title` + `source->target` map that [`save_changes`] hands to `update_save`, - /// from only what the user actually changed. - fn build_edits(&self) -> (Option, HashMap) { - let new_title = self.title_changed().then(|| self.title.clone()); - let mut map = HashMap::new(); - for r in &self.strings { - if r.is_changed() && !r.original.is_empty() { - // Last write wins if the same original appears twice with different edits. The rows - // are deduplicated on load so that should not happen in practice. - map.insert(r.original.clone(), r.edited.clone()); - } - } - (new_title, map) - } -} - -/// Read and inspect a `.sav`, building the editable [`LoadedSave`]. The baked-string list is -/// deduplicated (keeping first-seen order) so the same item/skill name shows once. Editing it -/// rewrites *every* occurrence on save (that is how `update_save`'s map works). An `Unsupported` -/// save still loads (so the UI can show the not-supported note), just with editing disabled. -pub fn inspect(path: &Path) -> Result { - let raw = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; - let info: SaveInfo = inspect_save(&raw)?; - - // Deduplicate the strings, preserving first-seen order, so each unique baked string is one row. - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut strings = Vec::new(); - for s in &info.strings { - if seen.insert(s.as_str()) { - strings.push(StringRow { - original: s.clone(), - edited: s.clone(), - }); - } - } - - Ok(LoadedSave { - path: path.to_path_buf(), - format: info.format, - encoding: info.encoding, - original_title: info.title.clone(), - title: info.title, - strings, - }) -} - -/// Where a backup goes for a single-file save change. -fn backup_path(save: &Path) -> PathBuf { - let mut name = save.file_name().map(|s| s.to_os_string()).unwrap_or_default(); - name.push(".bak"); - save.with_file_name(name) -} - -/// Apply the user's edits (title + changed strings) to the loaded save and write it back, optionally -/// backing up the original to `*.bak` first. Returns the [`SaveUpdateStats`] from `update_save`. -/// Refuses to write an unsupported save. Mirrors the single-file path of the CLI's `cmd_save_update`. -pub fn save_changes(loaded: &LoadedSave, backup: bool) -> Result { - if !loaded.editable() { - return Err("this save's format is not supported; nothing was written".to_string()); - } - let (new_title, map) = loaded.build_edits(); - if new_title.is_none() && map.is_empty() { - return Err("no changes to save (edit the title or a baked string first)".to_string()); - } - - let raw = std::fs::read(&loaded.path) - .map_err(|e| format!("cannot read {}: {e}", loaded.path.display()))?; - let (out, stats) = update_save(&raw, new_title.as_deref(), &map)?; - - if backup { - let bak = backup_path(&loaded.path); - std::fs::copy(&loaded.path, &bak) - .map_err(|e| format!("backup failed ({}): {e}", bak.display()))?; - } - std::fs::write(&loaded.path, &out) - .map_err(|e| format!("write failed ({}): {e}", loaded.path.display()))?; - Ok(stats) -} - -/// Read the baked title out of a (translated) `Game.dat`, the same way the CLI's `--game` does: -/// parse it and pull the Title `source` from `extract_game_dat`'s fixed JSON shape. -pub fn title_from_game_dat(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; - let gd = GameDat::read(&bytes).map_err(|e| format!("Game.dat parse failed: {e}"))?; - let json = extract_game_dat(&gd); - let value: serde_json::Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - value - .get("lines") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .find(|l| l.get("key").and_then(serde_json::Value::as_str) == Some("Title")) - .and_then(|l| l.get("source").and_then(serde_json::Value::as_str)) - .map(str::to_owned) - .ok_or_else(|| format!("{} has no Title entry", path.display())) -} - -/// Collect every `*.json` under each path (recursively for dirs, the file itself if it is `.json`). -fn collect_json_files(paths: &[PathBuf]) -> Vec { - fn walk(path: &Path, out: &mut Vec) { - if path.is_dir() { - if let Ok(rd) = std::fs::read_dir(path) { - for e in rd.flatten() { - walk(&e.path(), out); - } - } - } else if path.extension().and_then(|s| s.to_str()) == Some("json") { - out.push(path.to_path_buf()); - } - } - let mut out = Vec::new(); - for p in paths { - walk(p, &mut out); - } - out.sort(); - out.dedup(); - out -} - -/// Build the `source->target` baked-string map from translation JSON file(s)/dir(s), reusing the -/// library's [`build_memory`] exactly as the CLI's `build_save_string_map` does. Returns the map and -/// the count of sources that had divergent translations across files (the first value is kept). -pub fn build_string_map(paths: &[PathBuf]) -> Result<(HashMap, usize), String> { - let files = collect_json_files(paths); - if files.is_empty() { - return Err("no *.json found under the chosen translation path(s)".to_string()); - } - let mut texts: Vec = Vec::with_capacity(files.len()); - for p in &files { - texts.push(std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?); - } - let refs: Vec<&str> = texts.iter().map(String::as_str).collect(); - let (memory, conflicts) = build_memory(&refs); - Ok((memory, conflicts.len())) -} - -/// One save's outcome in a [`batch_update`] run, for the log. -pub enum BatchEntry { - /// Updated: `(file name, stats)`. - Updated(String, SaveUpdateStats), - /// Skipped because its format is unsupported: `file name`. - Skipped(String), - /// An I/O error on this file: `(file name, message)`. - Failed(String, String), -} - -/// The summary of a whole batch run. -pub struct BatchOutcome { - pub entries: Vec, - pub backup_dir: Option, -} - -impl BatchOutcome { - pub fn updated(&self) -> usize { - self.entries.iter().filter(|e| matches!(e, BatchEntry::Updated(..))).count() - } - pub fn skipped(&self) -> usize { - self.entries.iter().filter(|e| matches!(e, BatchEntry::Skipped(..))).count() - } - pub fn failed(&self) -> usize { - self.entries.iter().filter(|e| matches!(e, BatchEntry::Failed(..))).count() - } -} - -/// List every `*.sav` directly under `dir`, sorted (mirrors the CLI's discovery). -fn list_saves(dir: &Path) -> Vec { - let mut v: Vec = std::fs::read_dir(dir) - .into_iter() - .flatten() - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("sav")) - .collect(); - v.sort(); - v -} - -/// A short timestamp (`YYYYmmdd_HHMMSS`) for a backup dir name, like the CLI's `backup_stamp`. -pub fn backup_stamp() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let days = secs / 86_400; - let tod = secs % 86_400; - let (hh, mm, ss) = (tod / 3600, (tod % 3600) / 60, tod % 60); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = if m <= 2 { y + 1 } else { y }; - format!("{year:04}{m:02}{d:02}_{hh:02}{mm:02}{ss:02}") -} - -/// Apply a `new_title` and/or a `source->target` string `map` over every `*.sav` in `dir`, in place, -/// mirroring the CLI's `cmd_save_update`. When `backup` is set, the originals are copied into a -/// `save_backup_/` under `dir` first. Each file's outcome (updated / skipped-unsupported / -/// failed) is recorded for the log. `report` drives a progress bar. Pass a no-op in tests. -/// -/// Errors only when there is nothing to do (no title and an empty map) or no `*.sav` is found. -pub fn batch_update( - dir: &Path, - new_title: Option<&str>, - map: &HashMap, - backup: bool, - mut report: impl FnMut(f32, String), -) -> Result { - if new_title.is_none() && map.is_empty() { - return Err("nothing to do (give a new title and/or a translations file)".to_string()); - } - let saves = list_saves(dir); - if saves.is_empty() { - return Err(format!("no *.sav files found under {}", dir.display())); - } - - // Back up the originals into a stamped dir before overwriting (mirrors the CLI). - let backup_dir = if backup { - let root = dir.join(format!("save_backup_{}", backup_stamp())); - std::fs::create_dir_all(&root) - .map_err(|e| format!("cannot create backup dir {}: {e}", root.display()))?; - for src in &saves { - let name = src.file_name().unwrap_or_default(); - std::fs::copy(src, root.join(name)) - .map_err(|e| format!("backup failed for {}: {e}", src.display()))?; - } - Some(root) - } else { - None - }; - - let total = saves.len(); - let mut entries = Vec::with_capacity(total); - for (i, src) in saves.iter().enumerate() { - let name = src - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("?") - .to_string(); - report((i as f32) / (total as f32), format!("update {name}")); - let raw = match std::fs::read(src) { - Ok(b) => b, - Err(e) => { - entries.push(BatchEntry::Failed(name, format!("read failed: {e}"))); - continue; - } - }; - match update_save(&raw, new_title, map) { - Ok((out, stats)) => match std::fs::write(src, &out) { - Ok(()) => entries.push(BatchEntry::Updated(name, stats)), - Err(e) => entries.push(BatchEntry::Failed(name, format!("write failed: {e}"))), - }, - // Err means the buffer is neither a standard nor a detectable Pro save: skip, never write. - Err(_) => entries.push(BatchEntry::Skipped(name)), - } - } - report(1.0, "done".to_string()); - Ok(BatchOutcome { entries, backup_dir }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// The two real save fixtures. Tests skip gracefully when neither is present. - fn fixtures() -> Vec { - ["chamber/SaveData01.sav", "pachimon/SaveData01.sav"] - .into_iter() - .filter_map(test_data) - .collect() - } - - fn tmp_dir(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "wolfdawn_saves_{tag}_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = std::fs::create_dir_all(&p); - p - } - - /// Inspect a real save (on a COPY), change the title + one baked string through this module's - /// own code path, write it, re-inspect from the written bytes, and assert the title + string - /// changed and the format is preserved. The game folders are never touched. - #[test] - fn inspect_edit_save_reinspect_round_trip() { - let fixtures = fixtures(); - if fixtures.is_empty() { - eprintln!("skip inspect_edit_save_reinspect_round_trip: no save fixture present"); - return; - } - for fixture in fixtures { - let work = tmp_dir("rt"); - let copy = work.join("SaveData01.sav"); - std::fs::copy(&fixture, ©).expect("copy fixture"); - - let mut loaded = inspect(©).expect("inspect"); - assert!(loaded.editable(), "{fixture:?}: real save should be editable"); - assert!(!loaded.original_title.is_empty(), "title should be non-empty"); - let original_format = loaded.format; - - // Change the title. - let new_title = format!("{} [GUI]", loaded.original_title); - loaded.title = new_title.clone(); - - // Change the first baked string that can re-encode in this save's encoding. - let utf8 = loaded.encoding == "utf8"; - let mut edited_marker: Option = None; - for r in &mut loaded.strings { - if r.original.is_empty() { - continue; - } - let candidate = format!("{} X", r.original); - // Shift-JIS saves: only edit a string whose new value is representable. - let representable = utf8 || { - let (_, _, had_err) = encoding_rs::SHIFT_JIS.encode(&candidate); - !had_err - }; - if representable { - r.edited = candidate.clone(); - edited_marker = Some(candidate); - break; - } - } - - let stats = save_changes(&loaded, true).expect("save changes"); - assert!(stats.title_changed); - assert_eq!(stats.encoding, loaded.encoding); - // The backup must exist and still hold the original bytes. - let bak = copy.with_file_name("SaveData01.sav.bak"); - assert!(bak.exists(), "backup should have been written"); - assert_eq!( - std::fs::read(&bak).unwrap(), - std::fs::read(&fixture).unwrap(), - "backup should equal the original fixture" - ); - - // Re-inspect from the written file: title + string changed, format preserved. - let re = inspect(©).expect("re-inspect"); - assert_eq!(re.format, original_format, "format must be preserved"); - assert_eq!(re.title, new_title, "new title should re-inspect"); - if let Some(marker) = edited_marker { - assert!( - re.strings.iter().any(|r| r.original == marker), - "the edited baked string should re-inspect" - ); - } - - let _ = std::fs::remove_dir_all(&work); - } - } - - /// Batch update over a folder of save copies: applies a new title and reports per-file outcomes, - /// with the originals backed up to a stamped dir first. - #[test] - fn batch_update_folder_titles() { - let fixtures = fixtures(); - if fixtures.is_empty() { - eprintln!("skip batch_update_folder_titles: no save fixture present"); - return; - } - let fixture = &fixtures[0]; - let work = tmp_dir("batch"); - // Two copies so the batch has more than one file to walk. - for n in ["SaveData01.sav", "SaveData02.sav"] { - std::fs::copy(fixture, work.join(n)).expect("copy"); - } - - let new_title = "Batched Title"; - let outcome = - batch_update(&work, Some(new_title), &HashMap::new(), true, |_, _| {}).expect("batch"); - assert!(outcome.updated() >= 1, "at least one save should update"); - assert_eq!(outcome.failed(), 0, "no I/O failures expected"); - assert!(outcome.backup_dir.is_some(), "a backup dir should have been made"); - let backup_dir = outcome.backup_dir.unwrap(); - assert!(backup_dir.join("SaveData01.sav").exists(), "original backed up"); - - // Every updated file re-inspects to the new title. - for n in ["SaveData01.sav", "SaveData02.sav"] { - let re = inspect(&work.join(n)).expect("re-inspect"); - if re.editable() { - assert_eq!(re.title, new_title); - } - } - - let _ = std::fs::remove_dir_all(&work); - } - - /// `save_changes` refuses a no-op (nothing edited) and never writes. - #[test] - fn save_changes_rejects_no_op() { - let fixtures = fixtures(); - if fixtures.is_empty() { - eprintln!("skip save_changes_rejects_no_op: no save fixture present"); - return; - } - let work = tmp_dir("noop"); - let copy = work.join("SaveData01.sav"); - std::fs::copy(&fixtures[0], ©).expect("copy"); - let loaded = inspect(©).expect("inspect"); - let err = save_changes(&loaded, true).unwrap_err(); - assert!(err.contains("no changes"), "got: {err}"); - let _ = std::fs::remove_dir_all(&work); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/task.rs b/util/wolfdawn-master/crates/wolf-gui/src/task.rs deleted file mode 100644 index f35532e..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/task.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Background jobs. The shared pattern every long-running section uses. -//! -//! Long operations (unpack, extract, inject, save-update...) must never run on the UI thread -//! or the window freezes. The pattern here: -//! -//! 1. A section calls [`JobManager::run`] with a label and a closure that does the work. The -//! closure receives a [`Reporter`] it pushes progress + log messages through. -//! 2. The closure runs on a fresh `std::thread`. Each [`Reporter::progress`] / `Reporter::log` -//! call sends a [`JobMsg`] down an `mpsc` channel. The final `Result` is sent as -//! [`JobMsg::Done`]. -//! 3. Every frame, [`JobManager::poll`] drains the channel: progress updates the bar, log -//! messages append to the [`Log`], and `Done` clears the active job. While a job is live the -//! app keeps calling `ctx.request_repaint()` so the UI animates even with no user input. -//! -//! Sections copy this shape verbatim: build the inputs on the UI thread (cheap), then hand the -//! heavy work to `run(...)` so the result and progress flow back through the channel. - -// `Reporter::warn`/`error` round out the reporting API for the heavier jobs that later sections -// add. The Phase 0 demo job only needs `info`/`progress`, so allow the unused-yet variants here. -#![allow(dead_code)] - -use std::sync::mpsc::{Receiver, Sender}; - -use crate::log::{Level, Log}; - -/// A message from a running job back to the UI thread. -pub enum JobMsg { - /// Fractional progress in `0.0..=1.0` plus a short status line. - Progress(f32, String), - /// A line to append to the log at the given level. - Log(Level, String), - /// The job finished. `Ok` carries a human summary. `Err` carries the failure message. - Done(Result), -} - -/// Handed to a job closure so it can report progress and log lines back to the UI. Cloneable so a -/// job can pass it into sub-steps. Sends are best-effort: if the UI side has hung up (app closing) -/// the calls become no-ops rather than panicking. -#[derive(Clone)] -pub struct Reporter { - tx: Sender, -} - -impl Reporter { - /// Report fractional progress (`0.0..=1.0`) and a status line shown next to the bar. - pub fn progress(&self, fraction: f32, status: impl Into) { - let _ = self.tx.send(JobMsg::Progress(fraction.clamp(0.0, 1.0), status.into())); - } - - /// Append an info line to the log. - pub fn info(&self, msg: impl Into) { - let _ = self.tx.send(JobMsg::Log(Level::Info, msg.into())); - } - - /// Append a warning line to the log. - pub fn warn(&self, msg: impl Into) { - let _ = self.tx.send(JobMsg::Log(Level::Warn, msg.into())); - } - - /// Append an error line to the log. - pub fn error(&self, msg: impl Into) { - let _ = self.tx.send(JobMsg::Log(Level::Error, msg.into())); - } -} - -/// The single in-flight background job (if any) plus its latest progress, owned by the app. -/// -/// At most one job runs at a time. The section UIs disable their action buttons via -/// [`JobManager::is_running`] while one is live, so the worker never contends with itself. -#[derive(Default)] -pub struct JobManager { - /// Receiver for the active job's messages. `None` when idle. - rx: Option>, - /// Label of the active job (shown by the progress bar). - label: String, - /// Latest fractional progress in `0.0..=1.0`. - progress: f32, - /// Latest status line from the job. - status: String, -} - -impl JobManager { - pub fn new() -> Self { - Self::default() - } - - /// True while a background job is in flight. - pub fn is_running(&self) -> bool { - self.rx.is_some() - } - - pub fn label(&self) -> &str { - &self.label - } - - pub fn progress(&self) -> f32 { - self.progress - } - - pub fn status(&self) -> &str { - &self.status - } - - /// Spawn `work` on a worker thread, routing its progress/log/result back to this manager. - /// - /// `work` returns `Ok(summary)` on success or `Err(message)` on failure. Both are surfaced in - /// the log when the job completes. If a job is already running the call is ignored (the caller - /// should gate its button on [`is_running`](Self::is_running)). - pub fn run(&mut self, label: impl Into, log: &mut Log, work: F) - where - F: FnOnce(&Reporter) -> Result + Send + 'static, - { - if self.is_running() { - log.warn("a job is already running; please wait for it to finish"); - return; - } - let label = label.into(); - let (tx, rx) = std::sync::mpsc::channel(); - self.rx = Some(rx); - self.label = label.clone(); - self.progress = 0.0; - self.status = "starting…".to_string(); - log.info(format!("▶ {label}")); - - // The worker owns its Reporter (and thus the Sender). When the thread ends the Sender drops - // and the channel closes, which is how `poll` detects completion even if `Done` is missed. - std::thread::spawn(move || { - let reporter = Reporter { tx }; - let result = work(&reporter); - let _ = reporter.tx.send(JobMsg::Done(result)); - }); - } - - /// Drain all pending messages from the active job into the log + progress fields. Returns - /// `true` while a job is still running (the caller then requests a repaint so the bar animates). - pub fn poll(&mut self, log: &mut Log) -> bool { - let Some(rx) = &self.rx else { - return false; - }; - // Drain everything available this frame so the bar/log stay current under a chatty job. - let mut finished = false; - loop { - match rx.try_recv() { - Ok(JobMsg::Progress(frac, status)) => { - self.progress = frac; - self.status = status; - } - Ok(JobMsg::Log(level, msg)) => log.push(level, msg), - Ok(JobMsg::Done(result)) => { - match result { - Ok(summary) => log.info(format!("done — {} — {summary}", self.label)), - Err(err) => log.error(format!("failed — {} — {err}", self.label)), - } - finished = true; - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - // Worker thread ended without an explicit Done (panic / early drop). Treat the - // channel close as completion so the UI never gets stuck "running" forever. - finished = true; - break; - } - } - } - if finished { - self.rx = None; - self.progress = 0.0; - self.status.clear(); - self.label.clear(); - return false; - } - true - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/translation.rs b/util/wolfdawn-master/crates/wolf-gui/src/translation.rs deleted file mode 100644 index 56d3626..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/translation.rs +++ /dev/null @@ -1,1248 +0,0 @@ -//! The Translation section's data model and the headless logic behind it. -//! -//! A translator opens a game's unpacked `Data/`, gets an editable source<->translation grid, edits -//! the translations, then injects them back into byte-exact game files. This module owns everything -//! *except* the egui rendering (which lives in `app.rs`), so the whole workflow can be exercised in -//! tests without a window: -//! -//! * [`extract_model`] walks a data dir, calls the **same** `wolf_decompiler` extract functions the -//! CLI uses, and parses the per-file JSON they emit into a [`TranslationModel`] of editable -//! [`Row`]s. Each file keeps its *original parsed JSON* (`doc`) so injection is byte-for-byte -//! lossless: a row edit only rewrites a single `text` leaf inside that `Value`, never rebuilds -//! the shape (exactly how the library's own merge does it). -//! * [`inject_model`] serializes each file's (edited) JSON back and calls the matching -//! `inject_*` / `inject_names` library path with [`InjectOptions`] from the UI checkboxes. -//! * [`merge_into_model`] / [`check_conflicts`] / save+load round out the workflow. -//! -//! The DB `.project` is the canonical handle for a database. Injection writes the `.project`+`.dat` -//! pair together, mirroring the CLI's `strings_inject_db` / `names-inject`. - -use std::path::{Path, PathBuf}; - -use serde_json::Value; -use wolf_decompiler::{ - apply_memory, build_memory, check_name_conflicts, db_strings_to_json, extract_common_events, - extract_db_strings, extract_game_dat, extract_map, extract_names, extract_txt_events, - inject_common_events, inject_db_strings, inject_game_dat, inject_map, inject_names, - inject_txt_events, scenes_to_json, InjectOptions, -}; - -// Re-export the library's conflict type so the section UI can name it without reaching past this -// module (it owns the translation surface). -pub use wolf_decompiler::Conflict; -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::Database; -use wolf_formats::game_dat::GameDat; -use wolf_formats::map::Map; - -/// What kind of file a [`FileEntry`] came from. Decides which extract/inject library path applies -/// and (for the DB) that the `.project`+`.dat` pair must be written together. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum FileKind { - /// A `.mps` map (`scenes` JSON). - Map, - /// `CommonEvent.dat` (`scenes` JSON). - CommonEvent, - /// A database `.project` (+ sibling `.dat`); `db` JSON. - Database, - /// `Game.dat` (`gamedat` JSON). - GameDat, - /// An external event-text `.txt` scene file (`txt` JSON). Some GamePro games keep their - /// dialogue in Shift-JIS `.txt` files (the unpacked `Evtext*.wolf`), one scene per file. - TxtEvent, -} - -impl FileKind { - /// A short label for the file list. - pub fn tag(self) -> &'static str { - match self { - FileKind::Map => "map", - FileKind::CommonEvent => "common", - FileKind::Database => "db", - FileKind::GameDat => "gamedat", - FileKind::TxtEvent => "txt", - } - } -} - -/// One editable translation row: the read-only `source`, the editable `translation`, plus a -/// human-readable `location`/`note` and the JSON-pointer (`ptr`) of this row's `text` leaf inside -/// its owning [`FileEntry::doc`]. The pointer is what makes injection lossless: an edit rewrites -/// exactly that one leaf, leaving every other byte of the document untouched. -#[derive(Clone)] -pub struct Row { - pub source: String, - pub translation: String, - /// Where this string lives (e.g. `ev42/p0 · ▼Speaker`, `Type 3 row 7 · Name`). - pub location: String, - /// A small dim note (occurrence count for names, field hint for DB, etc.). - pub note: String, - /// JSON Pointer (RFC 6901) to the `text` value inside the owning document. - ptr: String, -} - -impl Row { - /// True when the translator has actually changed the text from the source. - pub fn is_translated(&self) -> bool { - self.translation != self.source && !self.translation.is_empty() - } -} - -/// One translatable file's rows plus the parsed JSON document they index into. `path` is the -/// on-disk file the inject writes back to (the `.project` for a database). -pub struct FileEntry { - pub path: PathBuf, - pub kind: FileKind, - pub rows: Vec, - /// The original extract JSON, parsed. Rows mutate `text` leaves here. Inject serializes it back. - doc: Value, -} - -impl FileEntry { - /// A short display name for the file list (the file name, or a DB-friendly stem). - pub fn display_name(&self) -> String { - self.path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("?") - .to_string() - } - - /// Translated-row count, for the per-file and overall counters. - pub fn translated_count(&self) -> usize { - self.rows.iter().filter(|r| r.is_translated()).count() - } -} - -/// The whole editable project: every translatable file plus the project-level name glossary. -#[derive(Default)] -pub struct TranslationModel { - /// The data dir this was extracted from (so inject/merge default sensibly). - pub data_dir: PathBuf, - pub files: Vec, - /// The glossary rows (the "Names" entry), kept beside its own parsed `names.json` document. - pub names: Vec, - names_doc: Value, -} - -impl TranslationModel { - /// Total source lines across files + names. - pub fn total_rows(&self) -> usize { - self.files.iter().map(|f| f.rows.len()).sum::() + self.names.len() - } - - /// Total translated lines across files + names. - pub fn translated_rows(&self) -> usize { - self.files.iter().map(FileEntry::translated_count).sum::() - + self.names.iter().filter(|r| r.is_translated()).count() - } - - /// Push the editable `translation` values back into the parsed documents, so the documents are - /// current before serialization (inject/save). Called by [`inject_model`] and `save_to_path`. - fn sync_docs(&mut self) { - for f in &mut self.files { - apply_rows_to_doc(&mut f.doc, &f.rows); - } - apply_rows_to_doc(&mut self.names_doc, &self.names); - } -} - -/// Write each row's `translation` into the `text` leaf its pointer addresses. An untranslated row -/// (empty `translation`) writes its `source` back, so the doc's text equals the source (the inject -/// then treats it as a no-op) and clearing a translation correctly resets the cell to its source. -fn apply_rows_to_doc(doc: &mut Value, rows: &[Row]) { - for r in rows { - if let Some(slot) = doc.pointer_mut(&r.ptr) { - let text = if r.is_translated() { &r.translation } else { &r.source }; - *slot = Value::String(text.clone()); - } - } -} - -// ---------------------------------------------------------------------------- -// Extraction: walk the data dir, call the library extract fns, parse to rows -// ---------------------------------------------------------------------------- - -/// Resolve which of `dir` / `dir/BasicData` holds `CommonEvent.dat` + the DB pairs (mirrors the -/// CLI's `basic_data_dir`). -fn basic_data_dir(dir: &Path) -> PathBuf { - if dir.join("CommonEvent.dat").exists() || dir.join("DataBase.project").exists() { - dir.to_path_buf() - } else if dir.join("BasicData").join("CommonEvent.dat").exists() - || dir.join("BasicData").join("DataBase.project").exists() - { - dir.join("BasicData") - } else { - dir.to_path_buf() - } -} - -/// Every translatable file under a data dir, in a stable order: maps, then `CommonEvent.dat`, the -/// three DB pairs, and `Game.dat` (mirrors how the CLI's `discover_data_dir` + `cmd_*` locate them). -fn discover_files(dir: &Path) -> (Vec, Vec<(FileKind, PathBuf)>) { - let basic = basic_data_dir(dir); - - // Maps live in MapData (or directly under dir for a flattened layout). - let mut maps: Vec = Vec::new(); - for sub in [dir.to_path_buf(), dir.join("MapData")] { - let Ok(rd) = std::fs::read_dir(&sub) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.extension().and_then(|s| s.to_str()) == Some("mps") { - maps.push(p); - } - } - } - maps.sort(); - maps.dedup(); - - let mut others: Vec<(FileKind, PathBuf)> = Vec::new(); - let ce = basic.join("CommonEvent.dat"); - if ce.exists() { - others.push((FileKind::CommonEvent, ce)); - } - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - let proj = basic.join(format!("{stem}.project")); - if proj.exists() && proj.with_extension("dat").exists() { - others.push((FileKind::Database, proj)); - } - } - let game_dat = basic.join("Game.dat"); - if game_dat.exists() { - others.push((FileKind::GameDat, game_dat)); - } - (maps, others) -} - -/// Every external event-text `.txt` file anywhere under `dir`, sorted by path. These are the -/// unpacked `Evtext*.wolf` scene files. The walk is bounded by a depth limit so a deep tree cannot -/// run away, and it only collects `.txt` (the sole `.txt` use in a Wolf game), so it skips the -/// image/audio archives cheaply. -fn discover_txt_files(dir: &Path) -> Vec { - fn walk(dir: &Path, depth: usize, out: &mut Vec) { - if depth > 8 { - return; - } - let Ok(rd) = std::fs::read_dir(dir) else { - return; - }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - walk(&p, depth + 1, out); - } else if p.extension().and_then(|s| s.to_str()).map(|x| x.eq_ignore_ascii_case("txt")) == Some(true) { - out.push(p); - } - } - } - let mut out = Vec::new(); - walk(dir, 0, &mut out); - out.sort(); - out -} - -/// Extract the whole data dir into a [`TranslationModel`]. `report` receives progress callbacks -/// (`fraction`, `message`) so a background job can drive the progress bar. Pass a no-op closure in -/// tests. Errors only on a truly empty dir. An unreadable single file is logged-and-skipped via the -/// returned `warnings` list. -pub struct ExtractOutcome { - pub model: TranslationModel, - pub warnings: Vec, -} - -pub fn extract_model( - dir: &Path, - mut report: impl FnMut(f32, String), -) -> Result { - let (maps, others) = discover_files(dir); - // External event-text .txt files (unpacked Evtext*). Found anywhere under the dir, so a game - // that keeps its dialogue in .txt (some GamePro titles) gets that text too, not just the - // command stream. Most games have none, so this adds nothing for them. - let txts = discover_txt_files(dir); - let total = maps.len() + others.len() + txts.len() + 1; // +1 for the names glossary pass - if total <= 1 { - return Err(format!( - "no translatable files found under {} (looked for *.mps, CommonEvent.dat, the DB pairs, Game.dat, event .txt)", - dir.display() - )); - } - let mut done = 0usize; - let mut warnings = Vec::new(); - let mut files: Vec = Vec::new(); - - let step = |report: &mut dyn FnMut(f32, String), done: &mut usize, msg: String| { - *done += 1; - report(*done as f32 / total as f32, msg); - }; - - // Maps. - for p in &maps { - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string(); - step(&mut report, &mut done, format!("map {name}")); - match extract_map_file(p) { - Ok(entry) => files.push(entry), - Err(e) => warnings.push(format!("skip {name}: {e}")), - } - } - // CommonEvent / DB / Game.dat. - for (kind, p) in &others { - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string(); - step(&mut report, &mut done, format!("{} {name}", kind.tag())); - let r = match kind { - FileKind::CommonEvent => extract_common_file(p), - FileKind::Database => extract_db_file(p), - FileKind::GameDat => extract_gamedat_file(p), - FileKind::Map => unreachable!("maps handled above"), - FileKind::TxtEvent => unreachable!("txt handled in its own loop"), - }; - match r { - Ok(entry) => files.push(entry), - Err(e) => warnings.push(format!("skip {name}: {e}")), - } - } - // External event-text .txt scene files, one entry each (so the per-file grid stays responsive - // even when a game ships thousands of them). - for p in &txts { - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string(); - step(&mut report, &mut done, format!("txt {name}")); - match extract_txt_file(p) { - Ok(entry) => files.push(entry), - Err(e) => warnings.push(format!("skip {name}: {e}")), - } - } - - // Names glossary (whole-dir). - step(&mut report, &mut done, "names glossary".to_string()); - let (names, names_doc) = match extract_names_doc(dir) { - Ok(v) => v, - Err(e) => { - warnings.push(format!("names glossary: {e}")); - (Vec::new(), Value::Null) - } - }; - - Ok(ExtractOutcome { - model: TranslationModel { - data_dir: dir.to_path_buf(), - files, - names, - names_doc, - }, - warnings, - }) -} - -fn extract_map_file(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let map = Map::read(&bytes).map_err(|e| e.to_string())?; - let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let json = scenes_to_json(fname, "map", &extract_map(&map)); - let doc: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - let rows = rows_from_scenes(&doc); - Ok(FileEntry { path: path.to_path_buf(), kind: FileKind::Map, rows, doc }) -} - -fn extract_common_file(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let ce = CommonEventsFile::read(&bytes).map_err(|e| e.to_string())?; - let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let json = scenes_to_json(fname, "common", &extract_common_events(&ce)); - let doc: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - let rows = rows_from_scenes(&doc); - Ok(FileEntry { path: path.to_path_buf(), kind: FileKind::CommonEvent, rows, doc }) -} - -fn extract_db_file(proj: &Path) -> Result { - let dat = proj.with_extension("dat"); - let (p, d) = ( - std::fs::read(proj).map_err(|e| e.to_string())?, - std::fs::read(&dat).map_err(|e| e.to_string())?, - ); - let db = Database::read(&p, &d).map_err(|e| e.to_string())?; - let fname = proj.file_name().and_then(|s| s.to_str()).unwrap_or(""); - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = db_strings_to_json(fname, &extract_db_strings(&db, &glossary)); - let doc: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - let rows = rows_from_db(&doc); - Ok(FileEntry { path: proj.to_path_buf(), kind: FileKind::Database, rows, doc }) -} - -fn extract_gamedat_file(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let gd = GameDat::read(&bytes).map_err(|e| e.to_string())?; - let json = extract_game_dat(&gd); - let doc: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - let rows = rows_from_gamedat(&doc); - Ok(FileEntry { path: path.to_path_buf(), kind: FileKind::GameDat, rows, doc }) -} - -fn extract_txt_file(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let json = extract_txt_events(&bytes); - let doc: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - let rows = rows_from_txt(&doc); - Ok(FileEntry { path: path.to_path_buf(), kind: FileKind::TxtEvent, rows, doc }) -} - -/// Extract the project-level name glossary, returning its rows + parsed `names.json`. -fn extract_names_doc(dir: &Path) -> Result<(Vec, Value), String> { - let basic = basic_data_dir(dir); - let mut dbs = Vec::new(); - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - let proj = basic.join(format!("{stem}.project")); - let dat = basic.join(format!("{stem}.dat")); - if let (Ok(p), Ok(d)) = (std::fs::read(&proj), std::fs::read(&dat)) { - if let Ok(db) = Database::read(&p, &d) { - dbs.push(db); - } - } - } - if dbs.is_empty() { - return Err("no databases found for the name glossary".into()); - } - let ce = std::fs::read(basic.join("CommonEvent.dat")) - .ok() - .and_then(|b| CommonEventsFile::read(&b).ok()); - let mut maps = Vec::new(); - for sub in [dir.to_path_buf(), dir.join("MapData")] { - let Ok(rd) = std::fs::read_dir(&sub) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.extension().and_then(|s| s.to_str()) == Some("mps") { - if let Ok(b) = std::fs::read(&p) { - if let Ok(m) = Map::read(&b) { - maps.push(m); - } - } - } - } - } - let ce_refs: Vec<&CommonEventsFile> = ce.iter().collect(); - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let json = extract_names(&dbs, &ce_refs, &maps, &glossary); - let doc: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; - let rows = rows_from_names(&doc); - Ok((rows, doc)) -} - -// --- JSON document -> editable rows (each row carries the pointer to its `text`) -------------- - -fn rows_from_scenes(doc: &Value) -> Vec { - let mut rows = Vec::new(); - let Some(scenes) = doc.get("scenes").and_then(Value::as_array) else { - return rows; - }; - for (si, sc) in scenes.iter().enumerate() { - let event = sc.get("event").and_then(Value::as_i64).unwrap_or(0); - let page = sc.get("page").and_then(Value::as_i64); - let name = sc.get("name").and_then(Value::as_str).unwrap_or(""); - let loc_prefix = match page { - Some(p) => format!("ev{event}/p{p}"), - None => format!("ev{event}"), - }; - let Some(lines) = sc.get("lines").and_then(Value::as_array) else { - continue; - }; - for (li, ln) in lines.iter().enumerate() { - let source = ln.get("source").and_then(Value::as_str).unwrap_or("").to_string(); - let t = ln.get("text").and_then(Value::as_str).unwrap_or(&source).to_string(); - // Untranslated rows hold an empty translation (no copy of the source), which both saves - // memory on big files and gives translators a clean target box. is_translated/apply treat - // empty as "same as source". - let text = if t == source { String::new() } else { t }; - let speaker = ln.get("speaker").and_then(Value::as_str).unwrap_or(""); - let mut note = String::new(); - if !speaker.is_empty() { - note = speaker.to_string(); - } else if !name.is_empty() { - note = name.to_string(); - } - rows.push(Row { - source, - translation: text, - location: loc_prefix.clone(), - note, - ptr: format!("/scenes/{si}/lines/{li}/text"), - }); - } - } - rows -} - -fn rows_from_db(doc: &Value) -> Vec { - let mut rows = Vec::new(); - let Some(groups) = doc.get("groups").and_then(Value::as_array) else { - return rows; - }; - for (gi, g) in groups.iter().enumerate() { - let type_name = g.get("typeName").and_then(Value::as_str).unwrap_or(""); - let Some(lines) = g.get("lines").and_then(Value::as_array) else { - continue; - }; - for (li, ln) in lines.iter().enumerate() { - let source = ln.get("source").and_then(Value::as_str).unwrap_or("").to_string(); - let t = ln.get("text").and_then(Value::as_str).unwrap_or(&source).to_string(); - // Untranslated rows hold an empty translation (no copy of the source), which both saves - // memory on big files and gives translators a clean target box. is_translated/apply treat - // empty as "same as source". - let text = if t == source { String::new() } else { t }; - let row_name = ln.get("rowName").and_then(Value::as_str).unwrap_or(""); - let field = ln.get("fieldName").and_then(Value::as_str).unwrap_or(""); - rows.push(Row { - source, - translation: text, - location: format!("{type_name} · {row_name}"), - note: field.to_string(), - ptr: format!("/groups/{gi}/lines/{li}/text"), - }); - } - } - rows -} - -fn rows_from_gamedat(doc: &Value) -> Vec { - let mut rows = Vec::new(); - let Some(lines) = doc.get("lines").and_then(Value::as_array) else { - return rows; - }; - for (li, ln) in lines.iter().enumerate() { - let source = ln.get("source").and_then(Value::as_str).unwrap_or("").to_string(); - let t = ln.get("text").and_then(Value::as_str).unwrap_or(&source).to_string(); - let text = if t == source { String::new() } else { t }; - let key = ln.get("key").and_then(Value::as_str).unwrap_or(""); - rows.push(Row { - source, - translation: text, - location: key.to_string(), - note: String::new(), - ptr: format!("/lines/{li}/text"), - }); - } - rows -} - -/// Rows for an event-text `.txt` document (`{ lines: [ {i, source, text} ] }`). Each translatable -/// line becomes a row pointing at its `text` leaf. The `i` (the physical line index in the file) -/// is shown as the location so a translator can find it in the original. -fn rows_from_txt(doc: &Value) -> Vec { - let mut rows = Vec::new(); - let Some(lines) = doc.get("lines").and_then(Value::as_array) else { - return rows; - }; - for (li, ln) in lines.iter().enumerate() { - let source = ln.get("source").and_then(Value::as_str).unwrap_or("").to_string(); - let t = ln.get("text").and_then(Value::as_str).unwrap_or(&source).to_string(); - let text = if t == source { String::new() } else { t }; - let i = ln.get("i").and_then(Value::as_i64).unwrap_or(li as i64); - rows.push(Row { - source, - translation: text, - location: format!("line {i}"), - note: String::new(), - ptr: format!("/lines/{li}/text"), - }); - } - rows -} - -fn rows_from_names(doc: &Value) -> Vec { - let mut rows = Vec::new(); - let Some(names) = doc.get("names").and_then(Value::as_array) else { - return rows; - }; - for (i, e) in names.iter().enumerate() { - let source = e.get("source").and_then(Value::as_str).unwrap_or("").to_string(); - let t = e.get("text").and_then(Value::as_str).unwrap_or(&source).to_string(); - let text = if t == source { String::new() } else { t }; - let occ = e.get("occurrences").and_then(Value::as_i64).unwrap_or(0); - let glossary_note = e.get("note").and_then(Value::as_str).unwrap_or(""); - let note = if glossary_note.is_empty() { - format!("×{occ}") - } else { - format!("×{occ} · {glossary_note}") - }; - rows.push(Row { - source, - translation: text, - location: "name".to_string(), - note, - ptr: format!("/names/{i}/text"), - }); - } - rows -} - -// ---------------------------------------------------------------------------- -// Injection: serialize the edited docs back, call the library inject paths -// ---------------------------------------------------------------------------- - -/// Where an inject writes. `InPlace` overwrites the source files. `OutDir` mirrors the data dir's -/// relative layout under a chosen output directory (like the CLI's `-o`). -#[derive(Clone)] -pub enum InjectTarget { - InPlace, - OutDir(PathBuf), -} - -/// Per-file inject result line for the log. -pub struct InjectReport { - pub file: String, - pub applied: usize, - pub skipped: usize, - pub drifted: usize, -} - -/// Inject the whole (edited) model back into byte-exact game files. Calls the same `inject_*` / -/// `inject_names` library paths the CLI uses, with `opts` from the UI checkboxes. `report` drives -/// progress. The returned per-file [`InjectReport`]s are logged by the caller. -pub fn inject_model( - model: &mut TranslationModel, - opts: &InjectOptions, - target: &InjectTarget, - mut report: impl FnMut(f32, String), -) -> Result, String> { - model.sync_docs(); - let total = model.files.len() + 1; // +1 names - let mut done = 0usize; - let mut out = Vec::new(); - - let out_path = |orig: &Path| -> PathBuf { - match target { - InjectTarget::InPlace => orig.to_path_buf(), - InjectTarget::OutDir(od) => od.join(rel(&model.data_dir, orig)), - } - }; - - for f in &model.files { - done += 1; - let name = f.display_name(); - report(done as f32 / total as f32, format!("inject {name}")); - let json = serde_json::to_string(&f.doc).map_err(|e| e.to_string())?; - let dest = out_path(&f.path); - let rep = inject_one_file(f.kind, &f.path, &dest, &json, opts) - .map_err(|e| format!("{name}: {e}"))?; - out.push(rep); - } - - // Names glossary: applied consistently across the WHOLE dir (DBs + CE + maps), like names-inject. - done += 1; - report(done as f32 / total as f32, "inject names".to_string()); - let names_json = serde_json::to_string(&model.names_doc).map_err(|e| e.to_string())?; - if let Some(rep) = inject_names_all(&model.data_dir, &names_json, opts, target)? { - out.push(rep); - } - - Ok(out) -} - -/// Apply one file's translation JSON to its base, writing byte-exact output. Databases write the -/// `.project`+`.dat` pair (mirrors the CLI's `strings_inject_db`). Every written file is re-parsed -/// to never ship a structurally-corrupt file. -fn inject_one_file( - kind: FileKind, - base: &Path, - dest: &Path, - json: &str, - opts: &InjectOptions, -) -> Result { - let name = base.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string(); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - // External event-text .txt: `inject_txt_events` rebuilds the file byte-exact from the base, so - // there is no structured stats struct. Count the intended edits (lines whose text differs from - // source) for the report, then return early. - if kind == FileKind::TxtEvent { - let base_bytes = std::fs::read(base).map_err(|e| e.to_string())?; - let out = inject_txt_events(json, &base_bytes)?; - std::fs::write(dest, &out).map_err(|e| e.to_string())?; - let applied = serde_json::from_str::(json) - .ok() - .and_then(|v| { - v.get("lines").and_then(|l| l.as_array()).map(|ls| { - ls.iter() - .filter(|l| { - l.get("text").and_then(Value::as_str) - != l.get("source").and_then(Value::as_str) - }) - .count() - }) - }) - .unwrap_or(0); - return Ok(InjectReport { file: name, applied, skipped: 0, drifted: 0 }); - } - let stats = match kind { - FileKind::CommonEvent => { - let base_bytes = std::fs::read(base).map_err(|e| e.to_string())?; - let mut ce = CommonEventsFile::read(&base_bytes).map_err(|e| e.to_string())?; - let st = inject_common_events(json, &mut ce, opts)?; - let out = ce.write(); - CommonEventsFile::read(&out).map_err(|e| format!("re-parse failed: {e}"))?; - std::fs::write(dest, &out).map_err(|e| e.to_string())?; - st - } - FileKind::Map => { - let base_bytes = std::fs::read(base).map_err(|e| e.to_string())?; - let mut m = Map::read(&base_bytes).map_err(|e| e.to_string())?; - let st = inject_map(json, &mut m, opts)?; - let out = m.write(); - Map::read(&out).map_err(|e| format!("re-parse failed: {e}"))?; - std::fs::write(dest, &out).map_err(|e| e.to_string())?; - st - } - FileKind::GameDat => { - let base_bytes = std::fs::read(base).map_err(|e| e.to_string())?; - let mut gd = GameDat::read(&base_bytes).map_err(|e| e.to_string())?; - let st = inject_game_dat(json, &mut gd, opts)?; - let out = gd.write(); - GameDat::read(&out).map_err(|e| format!("re-parse failed: {e}"))?; - std::fs::write(dest, &out).map_err(|e| e.to_string())?; - st - } - FileKind::Database => { - // The `.project`+`.dat` pair, written together byte-exact. - let proj = base.to_path_buf(); - let base_dat = proj.with_extension("dat"); - let out_dat = dest.with_extension("dat"); - let (p, d) = ( - std::fs::read(&proj).map_err(|e| e.to_string())?, - std::fs::read(&base_dat).map_err(|e| e.to_string())?, - ); - let mut db = Database::read(&p, &d).map_err(|e| e.to_string())?; - let st = inject_db_strings(json, &mut db, opts)?; - let (op, od) = db.write(); - Database::read(&op, &od).map_err(|e| format!("re-parse failed: {e}"))?; - std::fs::write(dest, &op).map_err(|e| e.to_string())?; - std::fs::write(&out_dat, &od).map_err(|e| e.to_string())?; - st - } - FileKind::TxtEvent => unreachable!("txt handled before the match"), - }; - Ok(InjectReport { - file: name, - applied: stats.applied, - skipped: stats.untranslated + stats.code_mismatch + stats.unrepresentable, - drifted: stats.drifted, - }) -} - -/// Apply the name glossary across the whole data dir (DB pairs + CommonEvent + maps), consistently, -/// exactly as the CLI's `names-inject` does. Returns `None` if the glossary is empty/absent. -fn inject_names_all( - dir: &Path, - names_json: &str, - opts: &InjectOptions, - target: &InjectTarget, -) -> Result, String> { - // Parse everything up front so a partial write can't desync the name mirrors. - let basic = basic_data_dir(dir); - - // The names pass must LAYER on top of the per-file string injects: when writing to an output - // dir, the string pass has already written its edits to `/`. So we read each base file - // from its output destination if that exists (string-inject touched it), else from the original. - // This stops the names pass from clobbering a string-edited file with a fresh original copy. - // In place, the destination IS the original, so it reads the just-string-injected file directly. - let remap = |orig: &Path| -> PathBuf { - match target { - InjectTarget::InPlace => orig.to_path_buf(), - InjectTarget::OutDir(od) => od.join(rel(dir, orig)), - } - }; - let base_of = |orig: &Path| -> PathBuf { - let dest = remap(orig); - if dest.exists() { - dest - } else { - orig.to_path_buf() - } - }; - - let mut db_paths: Vec<(PathBuf, PathBuf)> = Vec::new(); - for stem in ["DataBase", "CDataBase", "SysDatabase"] { - let proj = basic.join(format!("{stem}.project")); - let dat = basic.join(format!("{stem}.dat")); - if proj.exists() && dat.exists() { - db_paths.push((proj, dat)); - } - } - if db_paths.is_empty() { - return Ok(None); - } - let mut db_loaded: Vec<(PathBuf, PathBuf, Database)> = Vec::new(); - for (proj, dat) in &db_paths { - let (p, d) = ( - std::fs::read(base_of(proj)).map_err(|e| e.to_string())?, - std::fs::read(base_of(dat)).map_err(|e| e.to_string())?, - ); - let db = Database::read(&p, &d).map_err(|e| e.to_string())?; - db_loaded.push((proj.clone(), dat.clone(), db)); - } - let ce_path = basic.join("CommonEvent.dat"); - let mut ce_loaded: Option<(PathBuf, CommonEventsFile)> = None; - if ce_path.exists() { - let b = std::fs::read(base_of(&ce_path)).map_err(|e| e.to_string())?; - let ce = CommonEventsFile::read(&b).map_err(|e| e.to_string())?; - ce_loaded = Some((ce_path.clone(), ce)); - } - let mut map_paths: Vec = Vec::new(); - for sub in [dir.to_path_buf(), dir.join("MapData")] { - let Ok(rd) = std::fs::read_dir(&sub) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if p.extension().and_then(|s| s.to_str()) == Some("mps") { - map_paths.push(p); - } - } - } - map_paths.sort(); - map_paths.dedup(); - let mut map_loaded: Vec<(PathBuf, Map)> = Vec::new(); - for p in &map_paths { - let b = std::fs::read(base_of(p)).map_err(|e| e.to_string())?; - let m = Map::read(&b).map_err(|e| e.to_string())?; - map_loaded.push((p.clone(), m)); - } - - let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary(); - let mut dbs: Vec = db_loaded.iter().map(|(_, _, db)| db.clone()).collect(); - let mut ce_vec: Vec<&mut CommonEventsFile> = - ce_loaded.as_mut().map(|(_, c)| c).into_iter().collect(); - let mut maps: Vec = map_loaded.iter().map(|(_, m)| m.clone()).collect(); - let stats = inject_names(names_json, &mut dbs, &mut ce_vec, &mut maps, opts, &glossary)?; - // Move the mutated copies back. - for ((_, _, db), new) in db_loaded.iter_mut().zip(dbs) { - *db = new; - } - for ((_, m), new) in map_loaded.iter_mut().zip(maps) { - *m = new; - } - - let ensure_parent = |p: &Path| { - if let Some(parent) = p.parent() { - let _ = std::fs::create_dir_all(parent); - } - }; - - // Write everything (re-parsing each) only after the whole apply succeeded. - for (proj, dat, db) in &db_loaded { - let (op, od) = db.write(); - Database::read(&op, &od).map_err(|e| format!("names: DB re-parse failed: {e}"))?; - let (out_proj, out_dat) = (remap(proj), remap(dat)); - ensure_parent(&out_proj); - std::fs::write(&out_proj, &op).map_err(|e| e.to_string())?; - std::fs::write(&out_dat, &od).map_err(|e| e.to_string())?; - } - if let Some((p, c)) = &ce_loaded { - let out = c.write(); - CommonEventsFile::read(&out).map_err(|e| format!("names: CE re-parse failed: {e}"))?; - let dest = remap(p); - ensure_parent(&dest); - std::fs::write(&dest, &out).map_err(|e| e.to_string())?; - } - for (p, m) in &map_loaded { - let out = m.write(); - Map::read(&out).map_err(|e| format!("names: map re-parse failed: {e}"))?; - let dest = remap(p); - ensure_parent(&dest); - std::fs::write(&dest, &out).map_err(|e| e.to_string())?; - } - - Ok(Some(InjectReport { - file: "Names (glossary)".to_string(), - applied: stats.applied, - skipped: stats.untranslated + stats.code_mismatch + stats.unrepresentable, - drifted: stats.drifted, - })) -} - -// ---------------------------------------------------------------------------- -// Merge (translation memory), name-conflict check, save/load -// ---------------------------------------------------------------------------- - -/// Outcome of a merge: how many rows got a translation carried in, and how many are still untouched. -pub struct MergeOutcome { - pub carried: usize, - pub still_new: usize, -} - -/// Carry existing translations from old translation JSON file(s)/dir into the current model, matched -/// by exact source string. Reuses the library's `build_memory` + `apply_memory`: a memory is built -/// from every old `*.json`, then applied to each file's parsed document. The carried `text` values -/// are read back into the rows. Untouched (already-translated) rows are kept. -pub fn merge_into_model(model: &mut TranslationModel, old_paths: &[PathBuf]) -> Result { - // Build one global memory from every old JSON (files + every *.json under old dirs). - let mut old_files: Vec = Vec::new(); - for p in old_paths { - collect_json_files(p, &mut old_files); - } - old_files.sort(); - old_files.dedup(); - if old_files.is_empty() { - return Err("no *.json found under the chosen old translation path(s)".into()); - } - let mut texts: Vec = Vec::with_capacity(old_files.len()); - for p in &old_files { - texts.push(std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?); - } - let refs: Vec<&str> = texts.iter().map(String::as_str).collect(); - let (memory, _conflicts) = build_memory(&refs); - - // Sync current edits into the docs first so we don't clobber existing in-session translations. - model.sync_docs(); - - let mut carried = 0usize; - let mut still_new = 0usize; - for f in &mut model.files { - let json = serde_json::to_string(&f.doc).map_err(|e| e.to_string())?; - let (merged, stats) = apply_memory(&json, &memory)?; - f.doc = serde_json::from_str(&merged).map_err(|e| e.to_string())?; - read_docs_into_rows(&f.doc, &mut f.rows); - carried += stats.carried; - still_new += stats.still_new; - } - if !model.names_doc.is_null() { - let json = serde_json::to_string(&model.names_doc).map_err(|e| e.to_string())?; - let (merged, stats) = apply_memory(&json, &memory)?; - model.names_doc = serde_json::from_str(&merged).map_err(|e| e.to_string())?; - read_docs_into_rows(&model.names_doc, &mut model.names); - carried += stats.carried; - still_new += stats.still_new; - } - Ok(MergeOutcome { carried, still_new }) -} - -/// Read the `text` leaf each row points at back into the row's `translation` (the inverse of -/// [`apply_rows_to_doc`]). Used after a merge rewrote the documents. -fn read_docs_into_rows(doc: &Value, rows: &mut [Row]) { - for r in rows { - if let Some(Value::String(s)) = doc.pointer(&r.ptr) { - // Keep the empty-when-untranslated invariant so the memory + UX stay consistent. - r.translation = if *s == r.source { String::new() } else { s.clone() }; - } - } -} - -/// Run the name-conflict check over the current model's name glossary + every file's current JSON -/// (so a name translated inconsistently between the glossary and a DB/scene is caught). Returns the -/// conflicts the library found. -pub fn check_conflicts(model: &mut TranslationModel) -> Vec { - model.sync_docs(); - let mut docs: Vec<(String, String)> = Vec::new(); - docs.push(("Names".to_string(), model.names_doc.to_string())); - for f in &model.files { - docs.push((f.display_name(), f.doc.to_string())); - } - let refs: Vec<(String, &str)> = docs.iter().map(|(n, j)| (n.clone(), j.as_str())).collect(); - check_name_conflicts(&refs) -} - -/// Serialize the whole model to a single self-describing JSON for the project file. Stores each -/// file's path/kind + its (synced) document, plus the names document, enough to fully restore. -pub fn save_to_path(model: &mut TranslationModel, path: &Path) -> Result<(), String> { - model.sync_docs(); - let files: Vec = model - .files - .iter() - .map(|f| { - serde_json::json!({ - "path": f.path.to_string_lossy(), - "kind": f.kind.tag(), - "doc": f.doc, - }) - }) - .collect(); - let root = serde_json::json!({ - "wolfdawn_translation": 1, - "data_dir": model.data_dir.to_string_lossy(), - "files": files, - "names": model.names_doc, - }); - let text = serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?; - std::fs::write(path, text).map_err(|e| e.to_string()) -} - -/// Restore a model previously written by [`save_to_path`]. -pub fn load_from_path(path: &Path) -> Result { - let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - let root: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; - if root.get("wolfdawn_translation").is_none() { - return Err("not a WolfDawn translation project file".into()); - } - let data_dir = PathBuf::from(root.get("data_dir").and_then(Value::as_str).unwrap_or("")); - let mut files = Vec::new(); - if let Some(arr) = root.get("files").and_then(Value::as_array) { - for f in arr { - let path = PathBuf::from(f.get("path").and_then(Value::as_str).unwrap_or("")); - let kind = match f.get("kind").and_then(Value::as_str).unwrap_or("") { - "map" => FileKind::Map, - "common" => FileKind::CommonEvent, - "db" => FileKind::Database, - "gamedat" => FileKind::GameDat, - "txt" => FileKind::TxtEvent, - other => return Err(format!("unknown file kind {other:?} in project file")), - }; - let doc = f.get("doc").cloned().unwrap_or(Value::Null); - let rows = match kind { - FileKind::Map | FileKind::CommonEvent => rows_from_scenes(&doc), - FileKind::Database => rows_from_db(&doc), - FileKind::GameDat => rows_from_gamedat(&doc), - FileKind::TxtEvent => rows_from_txt(&doc), - }; - files.push(FileEntry { path, kind, rows, doc }); - } - } - let names_doc = root.get("names").cloned().unwrap_or(Value::Null); - let names = rows_from_names(&names_doc); - Ok(TranslationModel { data_dir, files, names, names_doc }) -} - -// --- small helpers (mirror the CLI's) --------------------------------------------------------- - -/// Relative path of `path` under `base`, as a `PathBuf` for re-joining under an output dir. -fn rel(base: &Path, path: &Path) -> PathBuf { - path.strip_prefix(base).unwrap_or(path).to_path_buf() -} - -/// Collect every `*.json` under `path` (recursively for a dir, the file itself if it is a `.json`). -fn collect_json_files(path: &Path, out: &mut Vec) { - if path.is_dir() { - let Ok(rd) = std::fs::read_dir(path) else { - return; - }; - for e in rd.flatten() { - collect_json_files(&e.path(), out); - } - } else if path.extension().and_then(|s| s.to_str()) == Some("json") { - out.push(path.to_path_buf()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file/dir is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = std::path::Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// The shared Data-dir fixture. Tests skip gracefully when it is absent (CI without the corpus). - fn fixture_dir() -> Option { - test_data("chamber/Data") - .filter(|d| d.join("BasicData").join("CommonEvent.dat").exists()) - } - - fn tmp_out(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "wolfdawn_tr_{tag}_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = std::fs::create_dir_all(&p); - p - } - - /// A self-contained data dir with one event-text `.txt` (no external fixture needed) round-trips - /// through the model: extract picks it up as a TxtEvent entry with the dialogue (not the `/` - /// command lines), and an edit injects back into the file byte-aware while the commands survive. - #[test] - fn txt_event_round_trip_through_model() { - let dir = tmp_out("txt"); - let evt = dir.join("Evtext"); - std::fs::create_dir_all(&evt).unwrap(); - let txt_path = evt.join("scene.txt"); - // UTF-8 so the test needs no encoding crate; the extractor detects UTF-8 vs Shift-JIS. - let original = "/evcg,a0\r\nアイリス:\r\n元のセリフ\r\n/b\r\n"; - std::fs::write(&txt_path, original.as_bytes()).unwrap(); - - let mut outcome = extract_model(&dir, |_, _| {}).expect("extract"); - let entry = outcome - .model - .files - .iter_mut() - .find(|f| f.kind == FileKind::TxtEvent) - .expect("a TxtEvent entry from the .txt"); - assert!( - entry.rows.iter().any(|r| r.source == "元のセリフ"), - "the dialogue line should be extracted" - ); - assert!( - entry.rows.iter().all(|r| !r.source.starts_with('/')), - "no /command or //comment line should be extracted" - ); - - // Edit the dialogue line, inject in place. - let row = entry.rows.iter_mut().find(|r| r.source == "元のセリフ").unwrap(); - row.translation = "新しいセリフ".to_string(); - let reports = inject_model( - &mut outcome.model, - &InjectOptions::default(), - &InjectTarget::InPlace, - |_, _| {}, - ) - .expect("inject"); - assert!(reports.iter().any(|r| r.applied >= 1), "the txt edit should apply"); - - // The file now holds the new text, the old text is gone, and the commands are intact. - let after = std::fs::read_to_string(&txt_path).unwrap(); - assert!(after.contains("新しいセリフ"), "edited text present"); - assert!(!after.contains("元のセリフ"), "old text gone"); - assert!(after.contains("/evcg,a0") && after.contains("/b"), "command lines intact"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Extract -> edit one row -> inject to a temp out dir -> re-extract -> the edit is present. - /// Proves extract->edit->inject works through the GUI's own code paths. - #[test] - fn round_trip_extract_edit_inject() { - let Some(dir) = fixture_dir() else { - eprintln!("skip round_trip_extract_edit_inject: fixture not present"); - return; - }; - let mut outcome = extract_model(&dir, |_, _| {}).expect("extract"); - let model = &mut outcome.model; - assert!(!model.files.is_empty(), "should have extracted at least one file"); - assert!(model.total_rows() > 0, "model should have non-empty rows"); - - // Find the first file with at least one row and edit its first row. - let (fi, edited_source, edited_text) = { - let f = model - .files - .iter_mut() - .find(|f| !f.rows.is_empty()) - .expect("a file with rows"); - let row = &mut f.rows[0]; - let src = row.source.clone(); - let new = format!("{src} [EDITED-BY-TEST]"); - row.translation = new.clone(); - (f.path.clone(), src, new) - }; - - let out = tmp_out("rt"); - let reports = inject_model( - model, - &InjectOptions::default(), - &InjectTarget::OutDir(out.clone()), - |_, _| {}, - ) - .expect("inject"); - assert!(reports.iter().any(|r| r.applied >= 1), "at least one translation applied"); - - // Re-extract from the output dir and confirm the edit survived the byte-exact round-trip. - let re = extract_model(&out, |_, _| {}).expect("re-extract"); - let same_file = re - .model - .files - .iter() - .find(|f| f.path.file_name() == fi.file_name()) - .expect("the edited file re-extracted"); - let found = same_file - .rows - .iter() - .any(|r| r.source == edited_text || r.translation == edited_text); - assert!(found, "the injected edit {edited_text:?} should re-extract (source was {edited_source:?})"); - - let _ = std::fs::remove_dir_all(&out); - } - - /// en-punct: a translation containing 「」, injected with normalize_punct=true, re-extracts as - /// ASCII quotes. - #[test] - fn en_punct_normalizes_brackets() { - let Some(dir) = fixture_dir() else { - eprintln!("skip en_punct_normalizes_brackets: fixture not present"); - return; - }; - let mut outcome = extract_model(&dir, |_, _| {}).expect("extract"); - let model = &mut outcome.model; - - // Pick a CLEAN source row (no inline `\code`, `@window` prefix, or `/` tag, and no - // newlines) so replacing it wholesale with a bracketed translation can never trip the - // control-code guard, and the assertion is unambiguous. Set it to a fixed `「Hello」`. - let marker = "「Hello」"; - let edited_loc = { - let mut chosen: Option<(usize, usize)> = None; - 'outer: for (fi, f) in model.files.iter().enumerate() { - for (ri, r) in f.rows.iter().enumerate() { - if !r.source.contains(['\\', '@', '<', '>', '\n']) && !r.source.is_empty() { - chosen = Some((fi, ri)); - break 'outer; - } - } - } - let (fi, ri) = chosen.expect("a clean source row"); - model.files[fi].rows[ri].translation = marker.to_string(); - (fi, ri) - }; - - let out = tmp_out("punct"); - let opts = InjectOptions { - normalize_punct: true, - ..Default::default() - }; - let reports = - inject_model(model, &opts, &InjectTarget::OutDir(out.clone()), |_, _| {}).expect("inject"); - assert!(reports.iter().any(|r| r.applied >= 1), "the en-punct edit should apply"); - - // Re-extract from the output dir: the brackets must have become ASCII double quotes. - let re = extract_model(&out, |_, _| {}).expect("re-extract"); - let edited_file = &model.files[edited_loc.0]; - let same = re - .model - .files - .iter() - .find(|f| f.path.file_name() == edited_file.path.file_name()) - .expect("edited file re-extracted"); - let found = same.rows.iter().any(|r| r.source == "\"Hello\""); - assert!( - found, - "en-punct should have produced ASCII quotes (\"Hello\"); re-extracted sources did not contain it" - ); - - let _ = std::fs::remove_dir_all(&out); - } - - /// Save then load restores the model's rows and edits. - #[test] - fn save_load_round_trip() { - let Some(dir) = fixture_dir() else { - eprintln!("skip save_load_round_trip: fixture not present"); - return; - }; - let mut outcome = extract_model(&dir, |_, _| {}).expect("extract"); - let model = &mut outcome.model; - let total = model.total_rows(); - // Edit a row so we can assert the translation survives save/load. - let edited = { - let f = model.files.iter_mut().find(|f| !f.rows.is_empty()).expect("rows"); - f.rows[0].translation = format!("{} [SAVED]", f.rows[0].source); - f.rows[0].translation.clone() - }; - - let out = tmp_out("save"); - let proj_file = out.join("project.wdtr.json"); - save_to_path(model, &proj_file).expect("save"); - let restored = load_from_path(&proj_file).expect("load"); - assert_eq!(restored.total_rows(), total, "row count should survive save/load"); - let found = restored - .files - .iter() - .flat_map(|f| f.rows.iter()) - .any(|r| r.translation == edited); - assert!(found, "the edited translation should survive save/load"); - - let _ = std::fs::remove_dir_all(&out); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/verify.rs b/util/wolfdawn-master/crates/wolf-gui/src/verify.rs deleted file mode 100644 index 5fe02da..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/verify.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! The Verify section's headless logic: round-trip a data file (or a whole corpus) to prove the -//! library reads and re-writes it without loss. The same check the CLI's `verify-roundtrip` runs. -//! -//! A round-trip is `write(read(bytes)) == bytes`: parse the file with the matching `wolf_formats` -//! reader, re-serialise it, and compare against the original bytes. A match is PASS (byte-exact). A -//! mismatch is FAIL with the first-differing offset for diagnosis. An unreadable file is an error. -//! This module mirrors `cmd.rs`'s `classify` / `roundtrip_path` / `verify_corpus` exactly, so the -//! GUI and CLI agree on what "verified" means. - -use std::path::{Path, PathBuf}; - -use wolf_formats::common_event::CommonEventsFile; -use wolf_formats::database::{BasicDatabase, Database}; -use wolf_formats::game_dat::GameDat; -use wolf_formats::map::Map; - -/// Which kind of file we're verifying. Decides the reader/writer pair, mirroring the CLI's -/// `FileKind` + `classify`. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum FileKind { - Map, - CommonEvent, - GameDat, - /// A database, represented by its `.project` (the `.dat` sibling is paired in). - Database, - /// Legacy `SysDatabaseBasic` (Wolf 2.x): schema-only `.project` + opaque `.dat`. - BasicDatabase, - Unsupported, -} - -/// Classify a path by extension/name, exactly like the CLI's `classify`. -fn classify(path: &Path) -> FileKind { - let ext = path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - let name = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - match ext.as_str() { - "mps" => FileKind::Map, - "project" if name == "sysdatabasebasic.project" => FileKind::BasicDatabase, - "dat" if name == "sysdatabasebasic.dat" => FileKind::BasicDatabase, - "project" => FileKind::Database, - "dat" if name == "commonevent.dat" => FileKind::CommonEvent, - "dat" if name == "game.dat" => FileKind::GameDat, - // A .dat with a sibling .project is the value half of a database. - "dat" if path.with_extension("project").exists() => FileKind::Database, - _ => FileKind::Unsupported, - } -} - -/// True when the file is one verify understands (so the corpus walk knows what to include and the -/// single-file flow can warn early on an unsupported pick). -pub fn is_supported(path: &Path) -> bool { - !matches!(classify(path), FileKind::Unsupported) -} - -/// The verdict of one file's round-trip. -#[derive(Clone, PartialEq, Eq, Debug)] -pub enum Verdict { - /// `write(read(bytes)) == bytes`, byte-exact. - Pass, - /// Parsed and re-serialised, but the output differs. Carries the first-differing byte offset - /// and the two lengths, for a clear diagnostic. - Fail { - /// Index of the first differing byte (or the shorter length when one is a prefix of the other). - offset: usize, - original_len: usize, - rewritten_len: usize, - }, - /// The file could not be read/parsed (unreadable, unsupported, or a parse error). Carries the - /// message. - Error(String), -} - -impl Verdict { - /// A short status word for the UI/log (`PASS` / `FAIL` / `ERR`). - pub fn tag(&self) -> &'static str { - match self { - Verdict::Pass => "PASS", - Verdict::Fail { .. } => "FAIL", - Verdict::Error(_) => "ERR", - } - } - - /// A one-line human description of the verdict (the diagnostic). - pub fn detail(&self) -> String { - match self { - Verdict::Pass => "byte-exact round-trip".to_string(), - Verdict::Fail { - offset, - original_len, - rewritten_len, - } => format!( - "differs at byte {offset} (original {original_len} bytes, rewritten {rewritten_len} bytes)" - ), - Verdict::Error(e) => e.clone(), - } - } - - pub fn is_pass(&self) -> bool { - matches!(self, Verdict::Pass) - } -} - -/// The index of the first byte that differs between `a` and `b` (or the shorter length when one is -/// a strict prefix of the other). Used to build the [`Verdict::Fail`] diagnostic. -fn first_diff(a: &[u8], b: &[u8]) -> usize { - a.iter().zip(b.iter()).position(|(x, y)| x != y).unwrap_or_else(|| a.len().min(b.len())) -} - -/// Compare an original buffer to a re-serialised one, producing [`Verdict::Pass`] or a -/// [`Verdict::Fail`] with the first-differing offset. -fn compare(original: &[u8], rewritten: Vec) -> Verdict { - if rewritten == original { - Verdict::Pass - } else { - Verdict::Fail { - offset: first_diff(original, &rewritten), - original_len: original.len(), - rewritten_len: rewritten.len(), - } - } -} - -/// Round-trip a single file, returning the [`Verdict`]. Reads the file, parses it with the matching -/// `wolf_formats` reader, re-serialises, and compares. Same logic as the CLI's `roundtrip_path`, -/// but returning a structured verdict (with the diff offset) instead of a bare bool. -/// -/// For a database the `.project` is the canonical handle and its sibling `.dat` is paired in. Both -/// halves must re-serialise byte-exact to PASS. -pub fn verify_file(path: &Path) -> Verdict { - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) => return Verdict::Error(format!("cannot read {}: {e}", path.display())), - }; - match classify(path) { - FileKind::Map => match Map::read(&bytes) { - Ok(m) => compare(&bytes, m.write()), - Err(e) => Verdict::Error(e.to_string()), - }, - FileKind::CommonEvent => match CommonEventsFile::read(&bytes) { - Ok(ce) => compare(&bytes, ce.write()), - Err(e) => Verdict::Error(e.to_string()), - }, - FileKind::GameDat => match GameDat::read(&bytes) { - Ok(gd) => compare(&bytes, gd.write()), - Err(e) => Verdict::Error(e.to_string()), - }, - FileKind::Database => { - // `path` may be the .project or its .dat. Verify the pair via the .project. - let proj_path = path.with_extension("project"); - let dat_path = path.with_extension("dat"); - let (proj_bytes, dat_bytes) = - match (std::fs::read(&proj_path), std::fs::read(&dat_path)) { - (Ok(p), Ok(d)) => (p, d), - _ => { - return Verdict::Error(format!( - "cannot read DB pair {} + {}", - proj_path.display(), - dat_path.display() - )) - } - }; - match Database::read(&proj_bytes, &dat_bytes) { - Ok(db) => { - let (proj_out, dat_out) = db.write(); - // Report the .project diff first, then the .dat. - let proj_v = compare(&proj_bytes, proj_out); - if !proj_v.is_pass() { - return proj_v; - } - compare(&dat_bytes, dat_out) - } - Err(e) => Verdict::Error(e.to_string()), - } - } - FileKind::BasicDatabase => { - let proj_path = path.with_extension("project"); - let dat_path = path.with_extension("dat"); - let (proj_bytes, dat_bytes) = - match (std::fs::read(&proj_path), std::fs::read(&dat_path)) { - (Ok(p), Ok(d)) => (p, d), - _ => { - return Verdict::Error(format!( - "cannot read DB pair {} + {}", - proj_path.display(), - dat_path.display() - )) - } - }; - match BasicDatabase::read(&proj_bytes, &dat_bytes) { - Ok(db) => { - let (proj_out, dat_out) = db.write(); - let proj_v = compare(&proj_bytes, proj_out); - if !proj_v.is_pass() { - return proj_v; - } - compare(&dat_bytes, dat_out) - } - Err(e) => Verdict::Error(e.to_string()), - } - } - FileKind::Unsupported => { - Verdict::Error("not a verifiable file type (.mps, CommonEvent.dat, Game.dat, .project)".to_string()) - } - } -} - -/// One file's result in a corpus run: its path relative to the corpus root + its verdict. -pub struct CorpusRow { - pub rel: String, - pub verdict: Verdict, -} - -/// A whole corpus verify run: the per-file rows + the pass/total summary. -pub struct CorpusOutcome { - pub rows: Vec, -} - -impl CorpusOutcome { - /// How many rows passed (byte-exact). - pub fn passed(&self) -> usize { - self.rows.iter().filter(|r| r.verdict.is_pass()).count() - } - /// How many rows failed (parsed but differed). - pub fn failed(&self) -> usize { - self.rows.iter().filter(|r| matches!(r.verdict, Verdict::Fail { .. })).count() - } - /// How many rows errored (could not parse). - pub fn errored(&self) -> usize { - self.rows.iter().filter(|r| matches!(r.verdict, Verdict::Error(_))).count() - } - /// Total rows checked. - pub fn total(&self) -> usize { - self.rows.len() - } -} - -/// Recursively collect every file under `dir` (depth-first), like the CLI's `collect`. -fn collect(dir: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - collect(&p, out); - } else { - out.push(p); - } - } -} - -/// Path of `path` relative to `base` (for the per-file table), like the CLI's `rel`. -fn rel(base: &Path, path: &Path) -> String { - path.strip_prefix(base).unwrap_or(path).display().to_string() -} - -/// Walk every supported file under `dir`, round-trip each, and return the per-file rows + summary. -/// The `verify-roundtrip --corpus` path. The lone `.dat` half of a database is skipped (it is -/// verified through its `.project`, matching the CLI). `report` drives a progress bar. Pass a no-op -/// in tests. -pub fn verify_corpus(dir: &Path, mut report: impl FnMut(f32, String)) -> Result { - if !dir.is_dir() { - return Err(format!("{} is not a folder", dir.display())); - } - report(0.0, "scanning folder…".to_string()); - let mut files: Vec = Vec::new(); - collect(dir, &mut files); - files.sort(); - - // Keep only the files verify understands. Drop the lone .dat half of a DB pair (verified via the - // .project) so each pair is checked once. - let to_check: Vec = files - .into_iter() - .filter(|p| match classify(p) { - FileKind::Unsupported => false, - FileKind::Database | FileKind::BasicDatabase - if p.extension().and_then(|s| s.to_str()) == Some("dat") => - { - false - } - _ => true, - }) - .collect(); - - if to_check.is_empty() { - return Err(format!("no verifiable files found under {}", dir.display())); - } - - let total = to_check.len(); - let mut rows = Vec::with_capacity(total); - for (i, path) in to_check.iter().enumerate() { - let r = rel(dir, path); - report((i as f32) / (total as f32), format!("verify {r}")); - let verdict = verify_file(path); - rows.push(CorpusRow { rel: r, verdict }); - } - report(1.0, "done".to_string()); - Ok(CorpusOutcome { rows }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when - /// the var is unset or the file/dir is missing, so the data-dependent tests skip gracefully. - fn test_data(rel: &str) -> Option { - let base = std::env::var_os("WOLFDAWN_TEST_DATA")?; - let p = Path::new(&base).join(rel); - p.exists().then_some(p) - } - - /// A known-good plaintext data file from the fixtures root should verify PASS through the - /// section's own code path. Read-only. Skips when absent. - #[test] - fn verify_single_known_good_passes() { - let Some(path) = [ - "chamber/Data/MapData/TitleMap.mps", - "chamber/Data/BasicData/Game.dat", - "chamber/Data/BasicData/CommonEvent.dat", - "chamber/Data/BasicData/DataBase.project", - ] - .iter() - .find_map(|r| test_data(r)) else { - eprintln!("skip verify_single_known_good_passes: no data fixture present"); - return; - }; - let verdict = verify_file(&path); - assert!( - verdict.is_pass(), - "{} should round-trip byte-exact, got {:?}: {}", - path.display(), - verdict.tag(), - verdict.detail() - ); - } - - /// A corpus walk over the fixtures Data dir produces rows and a sensible summary. Assert it - /// runs and at least one file passes. Do not require zero failures (the corpus may include - /// edge files), matching the CLI's tolerant report. - #[test] - fn verify_corpus_runs() { - let Some(dir) = test_data("chamber/Data").filter(|p| p.is_dir()) else { - eprintln!("skip verify_corpus_runs: data fixture not present"); - return; - }; - let outcome = verify_corpus(&dir, |_, _| {}).expect("corpus verify"); - assert!(outcome.total() > 0, "corpus should have verifiable files"); - assert!(outcome.passed() > 0, "at least one file should pass"); - assert_eq!( - outcome.passed() + outcome.failed() + outcome.errored(), - outcome.total(), - "every row has exactly one verdict" - ); - } - - /// An unsupported file type reports an error verdict, not a panic. - #[test] - fn unsupported_file_errors() { - let tmp = std::env::temp_dir().join(format!("wolfdawn_verify_unsup_{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); - let f = tmp.join("notes.txt"); - std::fs::write(&f, b"hello").unwrap(); - let v = verify_file(&f); - assert!(matches!(v, Verdict::Error(_)), "txt should be an error verdict"); - assert!(!is_supported(&f)); - let _ = std::fs::remove_dir_all(&tmp); - } -} diff --git a/util/wolfdawn-master/crates/wolf-gui/src/widgets.rs b/util/wolfdawn-master/crates/wolf-gui/src/widgets.rs deleted file mode 100644 index b83a2d2..0000000 --- a/util/wolfdawn-master/crates/wolf-gui/src/widgets.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! Reusable UI pieces: native file dialogs (thin `rfd` wrappers) and the small labelled -//! controls the sections lean on heavily (a checkbox-with-tooltip and a path-field-plus-Browse). -//! -//! An option flag like `--en-punct` becomes one `labeled_checkbox(...)` call, and a path argument -//! becomes one `path_field(...)` call, with the tooltip and Browse button handled here. Every -//! non-obvious control gets a plain-language tooltip. - -// Shared widget/dialog toolkit. Some helpers are not wired into a section yet, so allow dead code -// here rather than annotating each one. -#![allow(dead_code)] - -use std::path::PathBuf; - -// ---------------------------------------------------------------------------- -// File dialogs (rfd) -// ---------------------------------------------------------------------------- - -/// Pick an existing folder. `None` if the user cancels. -pub fn pick_folder() -> Option { - rfd::FileDialog::new().pick_folder() -} - -/// Pick an existing folder, starting the dialog at `start` when it exists. -pub fn pick_folder_in(start: Option<&std::path::Path>) -> Option { - let mut dlg = rfd::FileDialog::new(); - if let Some(dir) = start.filter(|p| p.is_dir()) { - dlg = dlg.set_directory(dir); - } - dlg.pick_folder() -} - -/// Pick an existing file. `filters` is a list of `(label, &[extensions])`, e.g. -/// `&[("Wolf archive", &["wolf"]), ("Game data", &["dat"])]`. `None` if cancelled. -pub fn pick_file(filters: &[(&str, &[&str])]) -> Option { - let mut dlg = rfd::FileDialog::new(); - for (label, exts) in filters { - dlg = dlg.add_filter(*label, exts); - } - dlg.pick_file() -} - -/// Pick an existing file, starting the dialog in `start` when it is a directory. `filters` is a list -/// of `(label, &[extensions])`. `None` if cancelled. -pub fn pick_file_in(start: Option<&std::path::Path>, filters: &[(&str, &[&str])]) -> Option { - let mut dlg = rfd::FileDialog::new(); - if let Some(dir) = start.filter(|p| p.is_dir()) { - dlg = dlg.set_directory(dir); - } - for (label, exts) in filters { - dlg = dlg.add_filter(*label, exts); - } - dlg.pick_file() -} - -/// Choose a destination path for a save, pre-filled with `default_name`. `None` if cancelled. -pub fn save_file(default_name: &str, filters: &[(&str, &[&str])]) -> Option { - let mut dlg = rfd::FileDialog::new().set_file_name(default_name); - for (label, exts) in filters { - dlg = dlg.add_filter(*label, exts); - } - dlg.save_file() -} - -// ---------------------------------------------------------------------------- -// Reusable controls -// ---------------------------------------------------------------------------- - -/// A checkbox with a label and a hover tooltip. Returns `true` if the value changed this frame. -/// Sections use this for optional flags (`--en-punct`, `--allow-code-drift`, encrypt toggles…). -pub fn labeled_checkbox(ui: &mut egui::Ui, value: &mut bool, label: &str, tooltip: &str) -> bool { - ui.checkbox(value, label).on_hover_text(tooltip).changed() -} - -// ---------------------------------------------------------------------------- -// Aligned form rows (shared layout for every section) -// ---------------------------------------------------------------------------- -// -// These helpers give one consistent layout: a fixed-width left LABEL column, an input that FILLS -// the remaining width (so every input in a section shares the same left AND right edge), and a -// fixed right-hand BUTTON area for Browse/× so those align too. The input fills available width -// rather than taking a fixed pixel width, which keeps rows uniform and responsive to window size. - -/// The fixed width (px) of a form row's left label column. Wide enough for the longest labels used -/// across the sections ("Start-up message", "Default PC graphic", "Output folder", …) so every -/// input starts at the same x regardless of its label. -pub const LABEL_COL_W: f32 = 128.0; - -/// The reserved width (px) of a path row's right-hand button area. Room for "Browse… ×" so the -/// Browse buttons (and the optional clear ×) line up in a fixed column across a section. -pub const BTN_COL_W: f32 = 128.0; - -/// Emit `label` into a fixed-width [`LABEL_COL_W`] left cell (left-aligned, vertically centred to -/// match the adjacent control), then run `content` for the rest of the row inside one -/// `ui.horizontal`. The label's `Response` carries `tooltip`. Returns whatever `content` returns. -/// -/// The other form helpers (and the sections' bespoke rows) build on this, so every labelled row -/// across the app shares the same left column and baseline. -pub fn form_row( - ui: &mut egui::Ui, - label: &str, - tooltip: &str, - content: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - ui.horizontal(|ui| { - // Reserve an EXACT fixed-width cell for the label. `allocate_ui_with_layout` advances the - // cursor by the label's width, not the desired width, so short labels leave each row's input - // starting at a different x. `allocate_exact_size` reserves the full column unconditionally, - // so every input begins at a constant x and is uniform width. The label is painted (clipped - // to the cell) like `nav_button`. - let h = ui.spacing().interact_size.y; - let (rect, resp) = ui.allocate_exact_size(egui::vec2(LABEL_COL_W, h), egui::Sense::hover()); - let font = egui::TextStyle::Body.resolve(ui.style()); - let color = ui.visuals().widgets.noninteractive.fg_stroke.color; - ui.painter().with_clip_rect(rect).text( - egui::pos2(rect.left(), rect.center().y), - egui::Align2::LEFT_CENTER, - label, - font, - color, - ); - if !tooltip.is_empty() { - resp.on_hover_text(tooltip); - } - content(ui) - }) - .inner -} - -/// A labelled path row: a read-only [`TextEdit`] that FILLS the row up to a common right edge -/// (`available_width - BTN_COL_W`), followed by a "Browse…" button (and, when `clearable`, a ×) in -/// the reserved right-hand area. Because the input fills to a fixed offset from the right, every -/// path row in a section is identical width and every Browse lines up. When the button is pressed -/// `browse` produces a new path. A returned `Some` replaces `value`. Returns `true` when `value` -/// changed this frame. Shows a placeholder when empty. -pub fn path_row( - ui: &mut egui::Ui, - label: &str, - value: &mut Option, - tooltip: &str, - clearable: bool, - browse: impl FnOnce() -> Option, -) -> bool { - let mut changed = false; - form_row(ui, label, tooltip, |ui| { - // The input fills everything except the reserved right-hand button area, so it ends at a - // common x and the Browse button(s) line up across the section. - let input_w = (ui.available_width() - BTN_COL_W).max(80.0); - // Editable: the text is derived from `value` each frame and written straight back when the - // user types, so a path can be typed directly (for example naming an output folder - // "extracted") without the Browse dialog. Browse still fills it for those who prefer it. - let mut text = value - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - let resp = ui.add( - egui::TextEdit::singleline(&mut text) - .hint_text("type a path or use Browse…") - .desired_width(input_w), - ); - if resp.changed() { - let t = text.trim(); - *value = if t.is_empty() { None } else { Some(PathBuf::from(t)) }; - changed = true; - } - if ui.button("Browse…").on_hover_text(tooltip).clicked() { - if let Some(picked) = browse() { - *value = Some(picked); - changed = true; - } - } - if clearable && value.is_some() && ui.button("×").on_hover_text("Clear").clicked() { - *value = None; - changed = true; - } - }); - changed -} - -/// A labelled text-input row: a `&mut String` editor that FILLS the whole remaining row width (no -/// trailing button), so every text row in a section is uniform full width. `hint` is the -/// placeholder shown when empty. Returns the input's `Response` so callers can attach a tooltip or -/// inspect `.changed()`. -pub fn text_row( - ui: &mut egui::Ui, - label: &str, - value: &mut String, - label_tooltip: &str, - hint: &str, -) -> egui::Response { - form_row(ui, label, label_tooltip, |ui| { - // `desired_width` (not `add_sized`) reliably makes the TextEdit fill the column. `add_sized` - // lets TextEdit's intrinsic content sizing win, which left filled rows narrower than empty ones. - let avail = ui.available_width().max(80.0); - ui.add(egui::TextEdit::singleline(value).hint_text(hint).desired_width(avail)) - }) -} - -/// A read-only path field followed by a "Browse…" button. Thin wrapper over [`path_row`] (clearable) -/// that sections call as their entry point. The layout is the shared aligned one. -pub fn path_field( - ui: &mut egui::Ui, - label: &str, - value: &mut Option, - tooltip: &str, - browse: impl FnOnce() -> Option, -) -> bool { - path_row(ui, label, value, tooltip, true, browse) -} - -/// A small, dimmed caption line, used for the "what this section will do" blurbs. -pub fn caption(ui: &mut egui::Ui, text: &str) { - ui.label(egui::RichText::new(text).weak()); -} - -/// The standard "coming soon" body for a not-yet-built section: a heading, a one-line description -/// of what the section will do, and the phase it lands in. Keeps the full nav shape visible now. -pub fn coming_soon(ui: &mut egui::Ui, phase: u8, description: &str) { - ui.add_space(8.0); - ui.heading("Coming soon"); - ui.add_space(4.0); - caption(ui, description); - ui.add_space(8.0); - ui.label( - egui::RichText::new(format!("Planned for Phase {phase}.")) - .italics() - .weak(), - ); -} diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index 4f0dfe2..a65d396 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -1,13 +1,15 @@ """WolfDawn CLI bootstrap and thin subprocess wrappers. -WolfDawn is the vendored Rust toolchain under ``util/wolfdawn-master`` that -unpacks, extracts, injects, and repacks WOLF RPG Editor game data. It ships as -source, so the ``wolf`` binary is built on demand with ``cargo build --release`` -the first time it is needed (mirrors the on-demand tool bootstrap in -``util/ace``). Everything the DazedMTLTool Wolf workflow needs goes through the -helpers here so command syntax and exit-code handling live in one place. +WolfDawn is the Rust toolchain (https://gitgud.io/zero64801/wolfdawn) that +unpacks, extracts, injects, and repacks WOLF RPG Editor game data. DazedMTLTool +ships prebuilt ``wolf`` binaries offline under ``util/wolfdawn/bin//`` +so end users never need a Rust toolchain. When no offline binary is bundled for +the running platform, the tool pulls a prebuilt one from the WolfDawn release +page and caches it into that same folder. Everything the Wolf workflow needs +goes through the helpers here so command syntax and exit-code handling live in +one place. -Exit codes emitted by ``wolf`` (see crates/wolf-cli/src/main.rs): +Exit codes emitted by ``wolf`` (see WolfDawn crates/wolf-cli/src/main.rs): 0 success 2 round-trip mismatch / inject guard / name-consistency failure 4 read / parse / write / crypto failure @@ -16,9 +18,14 @@ Exit codes emitted by ``wolf`` (see crates/wolf-cli/src/main.rs): from __future__ import annotations -import shutil +import io +import json +import re import subprocess import sys +import urllib.error +import urllib.request +import zipfile from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional, Sequence, Union @@ -26,9 +33,10 @@ from typing import Iterable, Optional, Sequence, Union __all__ = [ "WolfDawnError", "WolfResult", - "wolfdawn_source_dir", "wolf_binary_path", + "bundled_binary_path", "ensure_wolf_binary", + "download_wolf_binary", "unpack_all", "strings_extract", "names_extract", @@ -39,20 +47,35 @@ __all__ = [ "names_check", ] -# ``util/wolfdawn`` -> repo root is two parents up; the vendored source sits at -# ``util/wolfdawn-master``. -_PACKAGE_DIR = Path(__file__).resolve().parent -_UTIL_DIR = _PACKAGE_DIR.parent -_SOURCE_DIR = _UTIL_DIR / "wolfdawn-master" # Committed, prebuilt binaries live here (per-platform) so end users don't need a -# Rust toolchain. A build is only attempted when no bundled binary is present. +# Rust toolchain. When one is missing we download it from the WolfDawn release. +_PACKAGE_DIR = Path(__file__).resolve().parent _BUNDLED_DIR = _PACKAGE_DIR / "bin" +# WolfDawn upstream project on gitgud.io (a GitLab instance). The numeric project +# id is used for the uploads URL because the human-readable project path sits +# behind a Cloudflare challenge, while ``/-/project//uploads/...`` does not. +_GITGUD_HOST = "https://gitgud.io" +_WOLFDAWN_PROJECT_ID = 48753 +_RELEASES_API = f"{_GITGUD_HOST}/api/v4/projects/{_WOLFDAWN_PROJECT_ID}/releases" +# Substring that identifies each platform's prebuilt zip in a release. Only the +# assets the upstream maintainer publishes can be pulled; unpublished platforms +# fall back to the committed offline binary. +_RELEASE_ASSET_MATCH = { + "windows": "win", + "linux": "linux", + "macos": "mac", +} +_DOWNLOAD_UA = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + "DazedMTLTool WolfDawn-fetch" +) + PathLike = Union[str, Path] class WolfDawnError(RuntimeError): - """Raised when the WolfDawn binary can't be built/located or a command fails hard.""" + """Raised when the WolfDawn binary can't be located/downloaded or a command fails hard.""" @dataclass @@ -77,11 +100,6 @@ class WolfResult: return self -def wolfdawn_source_dir() -> Path: - """Absolute path to the vendored WolfDawn Rust workspace.""" - return _SOURCE_DIR - - def _exe_name() -> str: return "wolf.exe" if sys.platform.startswith("win") else "wolf" @@ -94,101 +112,120 @@ def _platform_dir() -> str: return "linux" -def bundled_binary_path() -> Path: - """Committed prebuilt binary path for the current platform (may not exist).""" - return _BUNDLED_DIR / _platform_dir() / _exe_name() - - -def _built_binary_path() -> Path: - """Path of a locally cargo-built binary under the source tree's target/.""" - return _SOURCE_DIR / "target" / "release" / _exe_name() +def bundled_binary_path(platform: Optional[str] = None) -> Path: + """Committed/cached prebuilt binary path for a platform (may not exist).""" + platform = platform or _platform_dir() + exe = "wolf.exe" if platform == "windows" else "wolf" + return _BUNDLED_DIR / platform / exe def wolf_binary_path() -> Path: - """Preferred ``wolf`` binary path: bundled if present, else the built path.""" - bundled = bundled_binary_path() - if bundled.is_file(): - return bundled - return _built_binary_path() + """Preferred ``wolf`` binary path for the current platform (may not exist).""" + return bundled_binary_path() -def _persist_binary(src: Path, log_fn=None) -> Path: - """Copy a freshly built binary into the committed per-platform bundle dir.""" - dest = bundled_binary_path() +def _find_release_upload_url(platform: str) -> Optional[str]: + """Look up the newest release and return the download URL of ``platform``'s zip. + + WolfDawn attaches prebuilt zips to a release as GitLab *uploads* referenced + from the release description markdown, e.g. + ``[WolfDawn-win64.zip](/uploads//WolfDawn-win64.zip)``. Returns the + numeric-project uploads URL (which bypasses the Cloudflare challenge) or + ``None`` when the platform isn't published. + """ + match = _RELEASE_ASSET_MATCH.get(platform) + if not match: + return None + req = urllib.request.Request(_RELEASES_API, headers={"User-Agent": _DOWNLOAD_UA}) + with urllib.request.urlopen(req, timeout=30) as resp: + releases = json.loads(resp.read().decode("utf-8")) + link_re = re.compile(r"\[([^\]]+)\]\((/uploads/[0-9a-f]+/[^)]+)\)") + for release in releases: + description = release.get("description") or "" + for filename, upload_path in link_re.findall(description): + name = filename.lower() + if match in name and name.endswith(".zip"): + return f"{_GITGUD_HOST}/-/project/{_WOLFDAWN_PROJECT_ID}{upload_path}" + return None + + +def _extract_wolf_from_zip(zip_bytes: bytes, dest: Path, log_fn=None) -> Path: + """Extract the ``wolf``/``wolf.exe`` member from a release zip into ``dest``.""" + want = dest.name.lower() + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + member = None + for info in zf.infolist(): + base = info.filename.rsplit("/", 1)[-1].lower() + if base == want: + member = info + break + if member is None: + names = ", ".join(i.filename for i in zf.infolist()) + raise WolfDawnError( + f"WolfDawn release zip did not contain '{dest.name}' (found: {names})." + ) + payload = zf.read(member) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(payload) try: - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dest) dest.chmod(0o755) - _log(f"Saved WolfDawn binary to {dest}", log_fn) - return dest - except Exception as exc: # pragma: no cover - best effort persistence - _log(f"Warning: could not persist WolfDawn binary to {dest}: {exc}", log_fn) - return src + except OSError: # pragma: no cover - non-POSIX filesystems + pass + _log(f"Saved WolfDawn binary to {dest}", log_fn) + return dest + + +def download_wolf_binary(platform: Optional[str] = None, log_fn=print) -> Path: + """Pull the prebuilt ``wolf`` binary from the WolfDawn release and cache it. + + Downloads the platform's release zip, extracts the ``wolf`` executable into + ``util/wolfdawn/bin//``, and returns its path. Raises + :class:`WolfDawnError` when the platform has no published binary or the + download fails. + """ + platform = platform or _platform_dir() + dest = bundled_binary_path(platform) + _log(f"Looking up WolfDawn '{platform}' binary on {_GITGUD_HOST} ...", log_fn) + try: + url = _find_release_upload_url(platform) + except (urllib.error.URLError, TimeoutError, ValueError) as exc: + raise WolfDawnError( + f"Could not reach the WolfDawn release page: {exc}. " + f"Place a prebuilt binary at {dest} to work fully offline." + ) from exc + if not url: + raise WolfDawnError( + f"No prebuilt WolfDawn binary is published for '{platform}'. " + f"Place one at {dest}, or build it from source " + "(https://gitgud.io/zero64801/wolfdawn)." + ) + _log(f"Downloading WolfDawn binary from {url} ...", log_fn) + try: + req = urllib.request.Request(url, headers={"User-Agent": _DOWNLOAD_UA}) + with urllib.request.urlopen(req, timeout=120) as resp: + zip_bytes = resp.read() + except (urllib.error.URLError, TimeoutError) as exc: + raise WolfDawnError(f"Failed to download WolfDawn binary: {exc}") from exc + return _extract_wolf_from_zip(zip_bytes, dest, log_fn) def ensure_wolf_binary(force: bool = False, log_fn=print) -> Path: - """Return the ``wolf`` binary path, building it with cargo only if needed. + """Return the ``wolf`` binary path, downloading it only if needed. Resolution order: - 1. Committed, prebuilt binary under ``util/wolfdawn/bin//`` — used - directly so end users never need a Rust toolchain. - 2. A locally cargo-built binary under the source ``target/release``. - 3. Build from source with ``cargo build --release`` and persist the result - into the committed bundle dir for reuse. + 1. Committed/cached prebuilt binary under ``util/wolfdawn/bin//`` + — used directly so the tool works fully offline. + 2. Otherwise (or when ``force`` is set), pull the prebuilt binary from the + WolfDawn release page and cache it into that same folder. - Raises :class:`WolfDawnError` with actionable guidance when the source tree - is missing, ``cargo`` is not on PATH, or the build fails. + Raises :class:`WolfDawnError` with actionable guidance when no offline binary + is present and the download can't be completed. """ if not force: bundled = bundled_binary_path() if bundled.is_file(): return bundled - built = _built_binary_path() - if built.is_file(): - return _persist_binary(built, log_fn) - - if not _SOURCE_DIR.is_dir(): - raise WolfDawnError( - f"WolfDawn source not found at {_SOURCE_DIR}. The vendored " - "'wolfdawn-master' tree is required to build the 'wolf' CLI." - ) - - cargo = shutil.which("cargo") - if not cargo: - raise WolfDawnError( - "WolfDawn needs to be compiled but 'cargo' (the Rust toolchain) was " - "not found on PATH. Install Rust from https://rustup.rs/ and try " - "again, or place a prebuilt binary at " - f"{bundled_binary_path()}." - ) - - _log(f"Building WolfDawn 'wolf' binary (cargo build --release) in {_SOURCE_DIR} ...", log_fn) - try: - proc = subprocess.run( - [cargo, "build", "--release", "--bin", "wolf"], - cwd=str(_SOURCE_DIR), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - except Exception as exc: # pragma: no cover - subprocess spawn failure - raise WolfDawnError(f"Failed to run cargo build for WolfDawn: {exc}") from exc - - if proc.returncode != 0: - tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-20:] - raise WolfDawnError( - "cargo build --release failed for WolfDawn:\n" + "\n".join(tail) - ) - - built = _built_binary_path() - if not built.is_file(): - raise WolfDawnError( - f"cargo build reported success but {built} is missing. " - "Check the WolfDawn workspace layout." - ) - _log("WolfDawn 'wolf' binary ready", log_fn) - return _persist_binary(built, log_fn) + return download_wolf_binary(log_fn=log_fn) def _log(msg: str, log_fn) -> None: diff --git a/util/wolfdawn/bin/windows/wolf.exe b/util/wolfdawn/bin/windows/wolf.exe new file mode 100644 index 0000000..25f4704 Binary files /dev/null and b/util/wolfdawn/bin/windows/wolf.exe differ