diff --git a/.github/workflows/build-psu-packer.yml b/.github/workflows/build-psu-packer.yml
index b02025b..53a1cfa 100644
--- a/.github/workflows/build-psu-packer.yml
+++ b/.github/workflows/build-psu-packer.yml
@@ -1,4 +1,4 @@
-name: Build PSU Packer
+name: Build PSU Packer GUI
on:
push:
@@ -17,13 +17,13 @@ jobs:
os:
- name: ubuntu
version: ubuntu-latest
- executable: psu-packer
+ executable: psu-packer-gui
- name: macos
version: macos-latest
- executable: psu-packer
+ executable: psu-packer-gui
- name: windows
version: windows-latest
- executable: psu-packer.exe
+ executable: psu-packer-gui.exe
runs-on: ${{matrix.os.version}}
steps:
@@ -31,9 +31,26 @@ jobs:
- run: rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
- name: Build
- run: cargo build --package psu-packer --verbose --release
- - name: Upload artifacts
+ run: cargo build --package psu-packer-gui --verbose --release
+ - name: Ensure executable permissions
+ if: matrix.os.name != 'windows'
+ run: chmod +x target/release/${{matrix.os.executable}}
+ - name: Archive Windows binary
+ if: matrix.os.name == 'windows'
+ shell: pwsh
+ run: |
+ $archivePath = "target/release/${{matrix.os.executable}}.zip"
+ if (Test-Path $archivePath) { Remove-Item $archivePath }
+ Compress-Archive -Path "target/release/${{matrix.os.executable}}" -DestinationPath $archivePath
+ - name: Upload artifacts (macOS/Linux)
+ if: matrix.os.name != 'windows'
uses: actions/upload-artifact@v4
with:
- name: psu-packer-${{matrix.os.name}}
- path: target/release/${{matrix.os.executable}}
\ No newline at end of file
+ name: psu-packer-gui-${{matrix.os.name}}
+ path: target/release/${{matrix.os.executable}}
+ - name: Upload artifacts (Windows)
+ if: matrix.os.name == 'windows'
+ uses: actions/upload-artifact@v4
+ with:
+ name: psu-packer-gui-${{matrix.os.name}}
+ path: target/release/${{matrix.os.executable}}.zip
diff --git a/.github/workflows/build-suitcase.yml b/.github/workflows/build-suitcase.yml
index ee55cf0..1567025 100644
--- a/.github/workflows/build-suitcase.yml
+++ b/.github/workflows/build-suitcase.yml
@@ -5,6 +5,7 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
+ workflow_dispatch:
env:
CARGO_TERM_COLOR: always
@@ -38,4 +39,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: suitcase-${{matrix.os.name}}
- path: target/release/${{matrix.os.executable}}
\ No newline at end of file
+ path: target/release/${{matrix.os.executable}}
diff --git a/Cargo.lock b/Cargo.lock
index 58dbc4d..4f6382c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -287,6 +287,24 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "ashpd"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.8.5",
+ "serde",
+ "serde_repr",
+ "url",
+ "zbus 4.4.0",
+]
+
[[package]]
name = "ashpd"
version = "0.11.0"
@@ -539,9 +557,9 @@ dependencies = [
[[package]]
name = "avif-serialize"
-version = "0.8.2"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62"
+checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
dependencies = [
"arrayvec",
]
@@ -552,6 +570,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -1083,6 +1107,7 @@ dependencies = [
"objc2-foundation 0.2.2",
"parking_lot",
"percent-encoding",
+ "pollster 0.4.0",
"profiling",
"raw-window-handle",
"ron",
@@ -1092,6 +1117,7 @@ dependencies = [
"wasm-bindgen-futures",
"web-sys",
"web-time",
+ "wgpu",
"winapi",
"windows-sys 0.59.0",
"winit",
@@ -1174,6 +1200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624659a2e972a46f4d5f646557906c55f1cd5a0836eddbe610fdf1afba1b4226"
dependencies = [
"ahash",
+ "chrono",
"egui",
"enum-map",
"image",
@@ -1217,6 +1244,15 @@ dependencies = [
"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.0"
@@ -1365,6 +1401,26 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "fax"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
+dependencies = [
+ "fax_derive",
+]
+
+[[package]]
+name = "fax_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1913,9 +1969,9 @@ dependencies = [
[[package]]
name = "image"
-version = "0.25.6"
+version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
+checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
@@ -1923,8 +1979,9 @@ dependencies = [
"exr",
"gif",
"image-webp",
+ "moxcms",
"num-traits",
- "png",
+ "png 0.18.0",
"qoi",
"ravif",
"rayon",
@@ -2015,6 +2072,12 @@ dependencies = [
"either",
]
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
[[package]]
name = "jni"
version = "0.21.1"
@@ -2047,12 +2110,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "jpeg-decoder"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
-
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -2321,6 +2378,16 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "moxcms"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "naga"
version = "24.0.0"
@@ -2527,6 +2594,17 @@ dependencies = [
"malloc_buf",
]
+[[package]]
+name = "objc-foundation"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
+dependencies = [
+ "block",
+ "objc",
+ "objc_id",
+]
+
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2796,6 +2874,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
+]
+
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -2992,6 +3079,19 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "png"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
+dependencies = [
+ "bitflags 2.9.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
[[package]]
name = "polling"
version = "3.7.4"
@@ -3007,6 +3107,12 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "pollster"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
+
[[package]]
name = "pollster"
version = "0.4.0"
@@ -3076,8 +3182,10 @@ dependencies = [
name = "ps2-filetypes"
version = "0.1.0"
dependencies = [
+ "base64 0.22.1",
"byteorder",
"chrono",
+ "encoding_rs",
"image",
"indexmap",
"toml 0.9.5",
@@ -3090,7 +3198,7 @@ dependencies = [
"eframe",
"image",
"ps2-filetypes",
- "rfd",
+ "rfd 0.15.3",
]
[[package]]
@@ -3100,7 +3208,7 @@ dependencies = [
"byteorder",
"chrono",
"eframe",
- "rfd",
+ "rfd 0.15.3",
]
[[package]]
@@ -3112,9 +3220,36 @@ dependencies = [
"colored",
"ps2-filetypes",
"serde",
+ "tempfile",
"toml 0.9.5",
]
+[[package]]
+name = "psu-packer-gui"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "eframe",
+ "egui_extras",
+ "image",
+ "ps2-filetypes",
+ "psu-packer",
+ "rfd 0.14.1",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "winresource",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "qoi"
version = "0.4.1"
@@ -3260,9 +3395,9 @@ dependencies = [
[[package]]
name = "ravif"
-version = "0.11.11"
+version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6"
+checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b"
dependencies = [
"avif-serialize",
"imgref",
@@ -3352,13 +3487,36 @@ dependencies = [
"usvg",
]
+[[package]]
+name = "rfd"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251"
+dependencies = [
+ "ashpd 0.8.1",
+ "block",
+ "dispatch",
+ "js-sys",
+ "log",
+ "objc",
+ "objc-foundation",
+ "objc_id",
+ "pollster 0.3.0",
+ "raw-window-handle",
+ "urlencoding",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "rfd"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d"
dependencies = [
- "ashpd",
+ "ashpd 0.11.0",
"block2 0.6.0",
"dispatch2",
"js-sys",
@@ -3367,7 +3525,7 @@ dependencies = [
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
- "pollster",
+ "pollster 0.4.0",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
@@ -3391,7 +3549,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
- "base64",
+ "base64 0.21.7",
"bitflags 2.9.0",
"serde",
"serde_derive",
@@ -3447,6 +3605,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3501,6 +3665,18 @@ dependencies = [
"syn",
]
+[[package]]
+name = "serde_json"
+version = "1.0.143"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -3727,8 +3903,9 @@ dependencies = [
"notify",
"ps2-filetypes",
"relative-path",
- "rfd",
+ "rfd 0.15.3",
"serde",
+ "tempfile",
"toml 0.9.5",
"wavefront_obj",
"winresource",
@@ -3849,13 +4026,16 @@ dependencies = [
[[package]]
name = "tiff"
-version = "0.9.1"
+version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
+checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
dependencies = [
+ "fax",
"flate2",
- "jpeg-decoder",
+ "half",
+ "quick-error",
"weezl",
+ "zune-jpeg",
]
[[package]]
@@ -3869,7 +4049,7 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
- "png",
+ "png 0.17.16",
"tiny-skia-path",
]
@@ -4084,7 +4264,7 @@ version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b0a51b72ab80ca511d126b77feeeb4fb1e972764653e61feac30adc161a756"
dependencies = [
- "base64",
+ "base64 0.21.7",
"log",
"pico-args",
"usvg-parser",
@@ -4407,9 +4587,9 @@ dependencies = [
[[package]]
name = "weezl"
-version = "0.1.8"
+version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
+checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
[[package]]
name = "wgpu"
@@ -4423,6 +4603,7 @@ dependencies = [
"document-features",
"js-sys",
"log",
+ "naga",
"parking_lot",
"profiling",
"raw-window-handle",
@@ -4471,6 +4652,7 @@ dependencies = [
"arrayvec",
"ash",
"bitflags 2.9.0",
+ "block",
"bytemuck",
"cfg_aliases",
"core-graphics-types",
@@ -4683,6 +4865,15 @@ dependencies = [
"windows-targets 0.42.2",
]
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -5419,9 +5610,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
-version = "0.4.13"
+version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768"
+checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
@@ -5436,6 +5627,7 @@ dependencies = [
"enumflags2",
"serde",
"static_assertions",
+ "url",
"zvariant_derive 4.2.0",
]
diff --git a/Cargo.toml b/Cargo.toml
index cd73287..ee2e206 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,8 +9,9 @@ members = [
"crates/xtask-build-app",
"crates/memcard",
"crates/psu-packer",
+ "crates/psu-packer-gui",
]
-default-members = ["crates/psu-packer"]
+default-members = ["crates/psu-packer-gui"]
[workspace.package]
edition = "2021"
diff --git a/Readme.md b/Readme.md
index a400f74..2e85753 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,17 +1,161 @@
-# PS2 Rust
+# PS2Suitcase
+*Original Project by [tech](https://github.com/ps2store/ps2suitcase) and his team <3*
+PS2Suitcase is a Rust toolchain for building, inspecting, and packaging PlayStation 2 save archives. The project focuses on a reliable PSU packer and modern graphical front ends so creators can prepare saves without juggling legacy utilities.
-Monorepo of my Rust projects for PS2 homebrew.
+## Project goals
-## Crates
+- Deliver a cross-platform desktop experience for organising PS2 save projects, editing metadata, and producing PSU archives.
+- Provide a scriptable command-line packer that integrates cleanly with build pipelines.
+- Maintain reusable PS2 file-format libraries that power both the CLI and GUI applications.
-### ps2-filetypes
+## Maintained components
-A collection of PS2 file type parsers.
+- **`suitcase` (PS2Suitcase GUI):** Full-featured desktop app with project workspaces, live validation, icon/sys editors, and PSU export workflows.
+- **`psu-packer` (CLI):** Standalone packer that reads `psu.toml`, regenerates `icon.sys` when requested, and writes deterministic `.psu` archives.
+- **`psu-packer-gui`:** Lightweight GUI wrapper around the packer for quick folder selection and metadata edits when the full PS2Suitcase interface is unnecessary.
+- **Libraries:** `ps2-filetypes` (parsers and writers for PSU, ICON, and TITLE files), `memcard` and `ps2-mcm` (memory-card utilities under active refactor), and shared UI macros.
+- **Packaging tooling:** `xtask-build-app` bundles the PS2Suitcase GUI into a macOS `.app` structure with the correct resources.
-### ps2-mcm
+## Feature highlights
-Memory Card Manager
+- Multi-tab editors for `psu.toml`, `icon.sys`, `title.cfg`, and icon textures, with previews powered by `ps2-filetypes`.
+- Folder tree with hot-reloading via filesystem watchers and validation messages that point directly to problematic assets.
+- Wizards for generating ICON files and automatically applying metadata presets.
+- Optional regeneration of `icon.sys` from structured TOML, ensuring consistent headers inside each archive.
+- Ready-to-use templates located in `assets/templates/` for both configuration and metadata files.
+
+## Build and run
+
+### Prerequisites
+
+- The latest stable Rust toolchain (install via [`rustup`](https://rustup.rs/)).
+- On Linux, install system dependencies required by `wgpu` (Vulkan/GL drivers). On macOS, ensure the Xcode command-line tools are present.
+
+### Command-line packer
+
+```bash
+# Run directly against a project directory containing psu.toml
+cargo run -p psu-packer -- path/to/save-project
+
+# Optional: choose the output path
+cargo run -p psu-packer -- path/to/save-project -o output/ExampleSave.psu
+```
+
+Produce a release binary with:
+
+```bash
+cargo build -p psu-packer --release
+```
+
+The resulting executable lives in `target/release/psu-packer` (or `.exe` on Windows).
+
+### Graphical interfaces
+
+#### PS2Suitcase (full editor)
+
+```bash
+# Default build enables both the wgpu and glow renderers for portability
+cargo run -p suitcase
+
+# Build an optimised binary
+cargo build -p suitcase --release
+```
+
+The release binary can be distributed from `target/release/suitcase`/`suitcase.exe`.
+
+#### PSU Packer GUI (focused packer)
+
+```bash
+cargo run -p psu-packer-gui
+```
+
+This windowed utility mirrors the CLI packer with a simplified layout for quick packaging jobs.
+
+## Platform support
+
+- **Windows 10/11:** Native builds with either renderer; the build script embeds the application icon when compiling on Windows.
+- **macOS (Intel & Apple Silicon):** Supported via Metal-backed `wgpu`. Use the packaging instructions below to create a signed `.app` bundle for distribution.
+- **Linux (Wayland/X11):** Supported through `wgpu` (Vulkan/GL) or the `glow` fallback. Ensure GPU drivers expose the required APIs.
+- **Command-line tools:** Build and run anywhere Rust targets (`psu-packer` does not depend on a windowing backend).
+
+## Packaging PS2Suitcase for release
+
+1. Build a release binary:
+ ```bash
+ cargo build -p suitcase --release
+ ```
+2. (Windows/Linux) Ship the `target/release/suitcase(.exe)` binary together with the `assets` directory if you need to provide templates or icons alongside the executable.
+3. (macOS) After building, create an application bundle:
+ ```bash
+ cargo run -p xtask-build-app
+ ```
+ This writes `build/PSU Builder.app/` with the executable, icon, and `Info.plist`. Codesign and notarise as required for distribution.
+4. Include sample templates from `assets/templates/` in your distribution package so users can scaffold new projects quickly.
+
+## Configuration (`psu.toml`)
+
+A valid project folder contains a `psu.toml` file that looks like:
+
+```toml
+[config]
+name = "Example Save"
+timestamp = "2024-10-10 10:30:00" # Optional (local time)
+include = ["BOOT.ELF", "TITLE.DB"] # Files to package
+exclude = ["debug.log"] # Optional inverse selector
+
+[icon_sys]
+flags = "PS2 Save File" # Accepts numeric values or preset names
+title = "Example Save"
+linebreak_pos = 16 # Optional, defaults to safe value
+background_transparency = 0 # Optional
+
+# Each list must contain the number of entries expected by the PS2 firmware
+background_colors = [
+ { r = 0, g = 32, b = 96, a = 0 },
+ { r = 0, g = 48, b = 128, a = 0 },
+ { r = 0, g = 64, b = 160, a = 0 },
+ { r = 0, g = 16, b = 48, a = 0 },
+]
+light_directions = [
+ { x = 0.0, y = 0.0, z = 1.0, w = 0.0 },
+ { x = -0.5, y = -0.5, z = 0.5, w = 0.0 },
+ { x = 0.5, y = -0.5, z = 0.5, w = 0.0 },
+]
+light_colors = [
+ { r = 1.0, g = 1.0, b = 1.0, a = 1.0 },
+ { r = 0.5, g = 0.5, b = 0.6, a = 1.0 },
+ { r = 0.3, g = 0.3, b = 0.4, a = 1.0 },
+]
+ambient_color = { r = 0.2, g = 0.2, b = 0.2, a = 1.0 }
+```
+
+Key expectations:
+
+- `name` sets the memory-card folder name and defaults the CLI output file name.
+- Use either `include` *or* `exclude` to control file selection; leaving both empty packages every file.
+- `timestamp` is optional. When omitted, the packer writes the current time.
+- The `[icon_sys]` table is optional. When provided (or toggled on in the GUIs) the packer regenerates `icon.sys` from the supplied values and adds it to the archive automatically.
+- Array lengths are validated; the packer reports descriptive errors if counts do not match firmware expectations.
+- Templates live in `assets/templates/psu.toml` and `assets/templates/title.cfg`.
+
+## Contributing
+
+1. Fork and clone the repository.
+2. Install the Rust toolchain and add `rustfmt` and `clippy` components (`rustup component add rustfmt clippy`).
+3. Make your changes, then run:
+ ```bash
+ cargo fmt
+ cargo clippy --all-targets --all-features
+ cargo test --all-targets
+ ```
+4. Submit a pull request with a clear description of the change and relevant screenshots if you modified GUI behaviour.
+
+Contributions covering new PS2 formats, validation improvements, or UI polish are especially welcome.
+
+## License
+
+PS2Suitcase is distributed under the MIT License. See [`LICENSE.txt`](LICENSE.txt) for the full text.
## Credits
-Icon & UI Design by [@Berion](https://www.psx-place.com/members/berion.1431/)
\ No newline at end of file
+Icon and UI design by [@Berion](https://www.psx-place.com/members/berion.1431/) and the PS2 homebrew community for test assets and documentation.
diff --git a/assets/templates/psu.toml b/assets/templates/psu.toml
new file mode 100644
index 0000000..69da22b
--- /dev/null
+++ b/assets/templates/psu.toml
@@ -0,0 +1,4 @@
+[config]
+name = "Example Save"
+timestamp = "2024-01-01 00:00:00"
+include = ["BOOT.ELF"]
diff --git a/assets/templates/title.cfg b/assets/templates/title.cfg
new file mode 100644
index 0000000..7f532ec
--- /dev/null
+++ b/assets/templates/title.cfg
@@ -0,0 +1,7 @@
+title=Example Game
+Description=Starter description for your save
+boot=cdrom0:\SLUS_123.45
+Release=2024
+Developer=Example Studio
+source=OPL
+Version=1.00
diff --git a/crates/ps2-filetypes/Cargo.toml b/crates/ps2-filetypes/Cargo.toml
index 0849caf..39b0186 100644
--- a/crates/ps2-filetypes/Cargo.toml
+++ b/crates/ps2-filetypes/Cargo.toml
@@ -8,4 +8,8 @@ byteorder = "1.5.0"
chrono = "0.4.40"
image = "0.25.6"
indexmap = "2.10.0"
+encoding_rs = "0.8"
toml = "0.9.2"
+
+[dev-dependencies]
+base64 = "0.22"
diff --git a/crates/ps2-filetypes/src/common/color.rs b/crates/ps2-filetypes/src/common/color.rs
index 2b5db79..99a741a 100644
--- a/crates/ps2-filetypes/src/common/color.rs
+++ b/crates/ps2-filetypes/src/common/color.rs
@@ -19,10 +19,10 @@ impl Color {
pub fn to_bytes(&self) -> Vec {
vec![
- (self.r as u32).to_le_bytes(),
- (self.g as u32).to_le_bytes(),
- (self.b as u32).to_le_bytes(),
- (self.a as u32).to_le_bytes(),
+ u32::to_le_bytes(self.r as u32),
+ u32::to_le_bytes(self.g as u32),
+ u32::to_le_bytes(self.b as u32),
+ u32::to_le_bytes(self.a as u32),
]
.into_flattened()
}
diff --git a/crates/ps2-filetypes/src/common/sjis.rs b/crates/ps2-filetypes/src/common/sjis.rs
index d4c6300..db8893a 100644
--- a/crates/ps2-filetypes/src/common/sjis.rs
+++ b/crates/ps2-filetypes/src/common/sjis.rs
@@ -1,65 +1,82 @@
-pub fn encode_sjis(input: &str) -> Vec {
- input
- .as_bytes()
- .iter()
- .flat_map(|b| match *b {
- b' ' => [0x80, 0x3F],
- b':' => [0x81, 0x46],
- b'/' => [0x81, 0x5E],
- b'(' => [0x81, 0x69],
- b')' => [0x81, 0x6A],
- b'[' => [0x81, 0x6D],
- b']' => [0x81, 0x6E],
- b'{' => [0x81, 0x6F],
- b'}' => [0x81, 0x70],
- 48..=90 => [0x82, *b + 31],
- 97..=122 => [0x82, *b + 32],
- _ => [0x00, 0x00],
- })
- .collect::>()
+use encoding_rs::SHIFT_JIS;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SjisEncodeError {
+ UnmappableCharacter,
+}
+
+pub fn encode_sjis(input: &str) -> Result, SjisEncodeError> {
+ let (encoded, _, had_errors) = SHIFT_JIS.encode(input);
+ if had_errors {
+ return Err(SjisEncodeError::UnmappableCharacter);
+ }
+
+ Ok(encoded.into_owned())
}
pub fn decode_sjis(input: &[u8]) -> String {
- let mut str_out = vec![0u8; input.len()];
-
- for (i, pair) in input.chunks_exact(2).enumerate() {
- str_out[i] = match pair[0] {
- 0x00 => {
- if pair[1] == 0x00 {
- b'\0'
- } else {
- b'?'
- }
- }
- 0x0D => {
- if pair[1] == 0x0A {
- // TODO: Technically this should be \r\n, but for now it works
- b'\n'
- } else {
- b'?'
- }
- }
- 0x81 => match pair[1] {
- 0x40 => b' ',
- 0x46 => b':',
- 0x5E => b'/',
- 0x69 => b'(',
- 0x6A => b')',
- 0x6D => b'[',
- 0x6E => b']',
- 0x6F => b'{',
- 0x70 => b'}',
- _ => b'?',
- },
- 0x82 => match pair[1] {
- 0x4f..=0x7A => pair[1] - 31,
- 0x81..=0x99 => pair[1] - 32,
- 0x3F => b' ',
- _ => b'?',
- },
- _ => b'?',
- };
+ let (decoded, _, _) = SHIFT_JIS.decode(input);
+ decoded.trim_end_matches('\0').to_string()
+}
+
+pub fn is_roundtrip_sjis(value: &str) -> bool {
+ let (encoded, _, encode_errors) = SHIFT_JIS.encode(value);
+ if encode_errors {
+ return false;
}
- String::from_utf8_lossy(&str_out).to_string()
+ let (decoded, _, decode_errors) = SHIFT_JIS.decode(&encoded);
+ !decode_errors && decoded == value
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn assert_decodes_to(input: [u8; 2], expected: &str) {
+ assert_eq!(decode_sjis(&input), expected);
+ }
+
+ #[test]
+ fn decode_crlf_preserves_both_characters() {
+ assert_decodes_to([0x0D, 0x0A], "\r\n");
+ }
+
+ #[test]
+ fn decode_cr_only() {
+ assert_decodes_to([0x0D, 0x00], "\r");
+ }
+
+ #[test]
+ fn decode_lf_only() {
+ assert_decodes_to([0x0A, 0x00], "\n");
+ }
+
+ #[test]
+ fn encode_decode_roundtrip_ascii_punctuation() {
+ let input = "SAVE!&LOAD";
+ let encoded = encode_sjis(input).expect("encode ASCII punctuation");
+ let decoded = decode_sjis(&encoded);
+
+ assert_eq!(decoded, input);
+ }
+
+ #[test]
+ fn encode_decode_roundtrip_multibyte_japanese() {
+ let input = "セーブテスト";
+ let encoded = encode_sjis(input).expect("encode Japanese text");
+ let decoded = decode_sjis(&encoded);
+
+ assert_eq!(decoded, input);
+ }
+
+ #[test]
+ fn reports_unmappable_characters() {
+ assert!(matches!(
+ encode_sjis("𝄞"),
+ Err(SjisEncodeError::UnmappableCharacter)
+ ));
+ assert!(!is_roundtrip_sjis("𝄞"));
+ assert!(is_roundtrip_sjis("テスト"));
+ }
}
diff --git a/crates/ps2-filetypes/src/lib.rs b/crates/ps2-filetypes/src/lib.rs
index b662d6f..9058f45 100644
--- a/crates/ps2-filetypes/src/lib.rs
+++ b/crates/ps2-filetypes/src/lib.rs
@@ -2,6 +2,7 @@ mod util;
mod writer;
mod parser;
mod common;
+pub mod templates;
pub use util::*;
pub use parser::*;
diff --git a/crates/ps2-filetypes/src/parser/icon_sys.rs b/crates/ps2-filetypes/src/parser/icon_sys.rs
index 1d0ae1b..24479a9 100644
--- a/crates/ps2-filetypes/src/parser/icon_sys.rs
+++ b/crates/ps2-filetypes/src/parser/icon_sys.rs
@@ -102,7 +102,12 @@ impl IconSys {
bytes.extend_from_slice(&self.ambient_color.to_bytes());
- let title_bytes = encode_sjis(&self.title);
+ let title_bytes = encode_sjis(&self.title).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "Title contains characters that cannot be encoded as Shift-JIS",
+ )
+ })?;
let title_len = title_bytes.len();
if title_len > 68 {
return Err(std::io::Error::new(
@@ -210,10 +215,24 @@ fn parse_sjis_string(c: &[u8]) -> String {
}
fn parse_color(c: &mut Cursor>) -> Result {
- let r = c.read_u32::()? as u8;
- let g = c.read_u32::()? as u8;
- let b = c.read_u32::()? as u8;
- let a = c.read_u32::()? as u8;
+ fn read_channel(cursor: &mut Cursor>) -> Result {
+ let mut buf = [0u8; 4];
+ cursor.read_exact(&mut buf)?;
+ let raw_int = u32::from_le_bytes(buf);
+
+ if raw_int <= 255 {
+ Ok(raw_int as u8)
+ } else {
+ let raw = f32::from_le_bytes(buf);
+ let normalized = if raw.is_nan() { 0.0 } else { raw.clamp(0.0, 1.0) };
+ Ok((normalized * 255.0).round() as u8)
+ }
+ }
+
+ let r = read_channel(c)?;
+ let g = read_channel(c)?;
+ let b = read_channel(c)?;
+ let a = read_channel(c)?;
Ok(Color { r, g, b, a })
}
diff --git a/crates/ps2-filetypes/src/parser/title_cfg.rs b/crates/ps2-filetypes/src/parser/title_cfg.rs
index b6d40e6..74efe92 100644
--- a/crates/ps2-filetypes/src/parser/title_cfg.rs
+++ b/crates/ps2-filetypes/src/parser/title_cfg.rs
@@ -43,17 +43,20 @@ impl TitleCfg {
}
pub fn has_mandatory_fields(&self) -> bool {
- for (_, key) in MANDATORY_KEYS.iter().enumerate() {
- if !self.index_map.contains_key(key.to_owned()) {
- return false;
- }
- }
- true
+ self.missing_mandatory_fields().is_empty()
+ }
+
+ pub fn missing_mandatory_fields(&self) -> Vec<&'static str> {
+ MANDATORY_KEYS
+ .iter()
+ .copied()
+ .filter(|key| !self.index_map.contains_key(*key))
+ .collect()
}
pub fn add_missing_fields(&mut self) -> &Self {
for (_, key) in MANDATORY_KEYS.iter().enumerate() {
- if !self.index_map.contains_key(key.to_owned()) {
+ if !self.index_map.contains_key(*key) {
self.index_map.insert(key.to_string(), "".to_string());
}
}
@@ -76,9 +79,57 @@ fn string_to_index_map(contents: String) -> IndexMap {
let lines = contents.lines();
for line in lines {
- let pair = line.split('=').collect::>();
- index_map.insert(pair[0].to_string(), pair[1].to_string());
+ if let Some((key, value)) = line.split_once('=') {
+ index_map.insert(key.to_string(), value.to_string());
+ }
}
index_map
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn string_to_index_map_skips_lines_without_delimiter() {
+ let contents = "title=Example Game\ninvalid_line\ndeveloper=Example Dev";
+
+ let map = string_to_index_map(contents.to_string());
+
+ assert_eq!(map.get("title"), Some(&"Example Game".to_string()));
+ assert_eq!(map.get("developer"), Some(&"Example Dev".to_string()));
+ assert!(!map.contains_key("invalid_line"));
+ }
+
+ #[test]
+ fn title_cfg_handles_malformed_lines_gracefully() {
+ let contents = "title=Another Game\njust_text\nboot=cdrom0:\\SLUS_123.45";
+
+ let cfg = TitleCfg::new(contents.to_string());
+
+ assert_eq!(
+ cfg.index_map.get("title"),
+ Some(&"Another Game".to_string())
+ );
+ assert_eq!(
+ cfg.index_map.get("boot"),
+ Some(&"cdrom0:\\SLUS_123.45".to_string())
+ );
+ assert!(!cfg.index_map.contains_key("just_text"));
+ }
+
+ #[test]
+ fn reports_missing_mandatory_fields() {
+ let contents = "title=Example\nDeveloper=Someone";
+
+ let cfg = TitleCfg::new(contents.to_string());
+ let mut missing = cfg.missing_mandatory_fields();
+ missing.sort();
+
+ assert!(missing.contains(&"Description"));
+ assert!(missing.contains(&"Release"));
+ assert!(missing.contains(&"source"));
+ assert!(!cfg.has_mandatory_fields());
+ }
+}
diff --git a/crates/ps2-filetypes/src/templates.rs b/crates/ps2-filetypes/src/templates.rs
new file mode 100644
index 0000000..5120bcf
--- /dev/null
+++ b/crates/ps2-filetypes/src/templates.rs
@@ -0,0 +1,7 @@
+//! Shared configuration templates bundled with the workspace.
+
+/// Template `title.cfg` file with the mandatory keys pre-populated.
+pub const TITLE_CFG_TEMPLATE: &str = include_str!("../../../assets/templates/title.cfg");
+
+/// Template `psu.toml` file with minimal project metadata.
+pub const PSU_TOML_TEMPLATE: &str = include_str!("../../../assets/templates/psu.toml");
diff --git a/crates/ps2-filetypes/tests/fixtures/icon_sys_float.b64 b/crates/ps2-filetypes/tests/fixtures/icon_sys_float.b64
new file mode 100644
index 0000000..834c4cc
--- /dev/null
+++ b/crates/ps2-filetypes/tests/fixtures/icon_sys_float.b64
@@ -0,0 +1,17 @@
+UFMyRAEABQAAAAAARDMiEQAAgD8AAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAA
+AIA/AACAP4GAAD+BgAA/gYAAP4GAAD8AAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAA
+AAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACA
+PwAAgD8AAIA+AACAPgAAgD4AAIA/SEVMTE8gV09STEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUSVRMRS5JQ04AAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ09QWS5JQ04AAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERFTC5JQ04AAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
diff --git a/crates/ps2-filetypes/tests/fixtures/icon_sys_int.b64 b/crates/ps2-filetypes/tests/fixtures/icon_sys_int.b64
new file mode 100644
index 0000000..ca616ce
--- /dev/null
+++ b/crates/ps2-filetypes/tests/fixtures/icon_sys_int.b64
@@ -0,0 +1,17 @@
+UFMyRAEABQAAAAAARDMiEf8AAAAAAAAAAAAAAP8AAAAAAAAA/wAAAAAAAAD/AAAAAAAAAAAAAAD/
+AAAA/wAAAIAAAACAAAAAgAAAAIAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAA
+AAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACA
+PwAAgD8AAIA+AACAPgAAgD4AAIA/SEVMTE8gV09STEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUSVRMRS5JQ04AAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ09QWS5JQ04AAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERFTC5JQ04AAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
diff --git a/crates/ps2-filetypes/tests/icon_sys.rs b/crates/ps2-filetypes/tests/icon_sys.rs
new file mode 100644
index 0000000..5f577ce
--- /dev/null
+++ b/crates/ps2-filetypes/tests/icon_sys.rs
@@ -0,0 +1,82 @@
+use base64::{engine::general_purpose::STANDARD, Engine};
+use ps2_filetypes::{color::Color, IconSys};
+
+fn decode_fixture(path: &str) -> Vec {
+ let encoded = match path {
+ "fixtures/icon_sys_float.b64" => include_str!("fixtures/icon_sys_float.b64"),
+ "fixtures/icon_sys_int.b64" => include_str!("fixtures/icon_sys_int.b64"),
+ _ => panic!("unknown fixture path: {}", path),
+ };
+ let encoded: String = encoded.split_whitespace().collect();
+ STANDARD.decode(encoded).expect("decode icon.sys fixture")
+}
+
+fn expected_colors() -> [Color; 4] {
+ [
+ Color::new(255, 0, 0, 255),
+ Color::new(0, 255, 0, 255),
+ Color::new(0, 0, 255, 255),
+ Color::new(128, 128, 128, 128),
+ ]
+}
+
+#[test]
+fn parses_float_encoded_icon_sys_background_colors_and_serializes_to_integers() {
+ let float_bytes = decode_fixture("fixtures/icon_sys_float.b64");
+ let icon_sys = IconSys::new(float_bytes);
+
+ for (parsed, expected) in icon_sys
+ .background_colors
+ .iter()
+ .zip(expected_colors().iter())
+ {
+ let parsed: [u8; 4] = (*parsed).into();
+ let expected: [u8; 4] = (*expected).into();
+ assert_eq!(parsed, expected);
+ }
+
+ let encoded_bytes = icon_sys
+ .to_bytes()
+ .expect("serialize icon.sys back to bytes");
+
+ let int_bytes = decode_fixture("fixtures/icon_sys_int.b64");
+ assert_eq!(encoded_bytes, int_bytes);
+}
+
+#[test]
+fn parses_and_roundtrips_integer_encoded_icon_sys_background_colors() {
+ let bytes = decode_fixture("fixtures/icon_sys_int.b64");
+ let icon_sys = IconSys::new(bytes.clone());
+
+ for (parsed, expected) in icon_sys
+ .background_colors
+ .iter()
+ .zip(expected_colors().iter())
+ {
+ let parsed: [u8; 4] = (*parsed).into();
+ let expected: [u8; 4] = (*expected).into();
+ assert_eq!(parsed, expected);
+ }
+
+ let encoded_bytes = icon_sys
+ .to_bytes()
+ .expect("serialize icon.sys back to bytes");
+
+ assert_eq!(encoded_bytes, bytes);
+}
+
+#[test]
+fn icon_sys_roundtrips_shift_jis_title() {
+ let bytes = decode_fixture("fixtures/icon_sys_int.b64");
+ let mut icon_sys = IconSys::new(bytes);
+ icon_sys.title = "SAVE!&テスト".to_string();
+ icon_sys.linebreak_pos = "SAVE!&".chars().count() as u16;
+
+ let serialized = icon_sys
+ .to_bytes()
+ .expect("serialize icon.sys with Shift-JIS title");
+ let reparsed = IconSys::new(serialized);
+
+ assert_eq!(reparsed.title, "SAVE!&テスト");
+ assert_eq!(reparsed.linebreak_pos, icon_sys.linebreak_pos);
+}
diff --git a/crates/ps2-filetypes/title_cfg.toml b/crates/ps2-filetypes/title_cfg.toml
index 737b22f..e11ed40 100644
--- a/crates/ps2-filetypes/title_cfg.toml
+++ b/crates/ps2-filetypes/title_cfg.toml
@@ -36,9 +36,12 @@ hint = "Homebrew"
[Description]
tooltip = "A short description of the app. Must be less than 255 characters or it will be truncated in OPL."
+char_limit = 255
+multiline = true
[Notes]
tooltip = "A free text field. Not used in most cases."
+multiline = true
[source]
tooltip = "The url of the project. It can be a git repository, forum thread, website of the app, etc."
diff --git a/crates/psu-packer-gui/Cargo.toml b/crates/psu-packer-gui/Cargo.toml
new file mode 100644
index 0000000..793f08c
--- /dev/null
+++ b/crates/psu-packer-gui/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "psu-packer-gui"
+edition.workspace = true
+license.workspace = true
+version.workspace = true
+
+[features]
+default = []
+# Re-enable the psu.toml editor UI with `--features psu-toml-editor`.
+psu-toml-editor = []
+
+[dependencies]
+psu-packer = { path = "../psu-packer" }
+ps2-filetypes = { path = "../ps2-filetypes" }
+eframe = { version = "0.31.1", features = ["default", "wgpu"] }
+rfd = "0.14"
+chrono = "0.4.42"
+egui_extras = { version = "0.31.1", features = ["chrono"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tempfile = "3"
+image = { version = "0.25.6", features = ["ico"] }
+
+[build-dependencies]
+winresource = "0.1"
diff --git a/crates/psu-packer-gui/assets/fonts/Orbitron-Regular.ttf b/crates/psu-packer-gui/assets/fonts/Orbitron-Regular.ttf
new file mode 100644
index 0000000..7d0d5e0
Binary files /dev/null and b/crates/psu-packer-gui/assets/fonts/Orbitron-Regular.ttf differ
diff --git a/crates/psu-packer-gui/build.rs b/crates/psu-packer-gui/build.rs
new file mode 100644
index 0000000..ee97948
--- /dev/null
+++ b/crates/psu-packer-gui/build.rs
@@ -0,0 +1,13 @@
+use {
+ std::{env, io},
+ winresource::WindowsResource,
+};
+
+fn main() -> io::Result<()> {
+ if env::var_os("CARGO_CFG_WINDOWS").is_some() {
+ WindowsResource::new()
+ .set_icon("../suitcase/assets/icon.ico")
+ .compile()?;
+ }
+ Ok(())
+}
diff --git a/crates/psu-packer-gui/src/lib.rs b/crates/psu-packer-gui/src/lib.rs
new file mode 100644
index 0000000..bb76da7
--- /dev/null
+++ b/crates/psu-packer-gui/src/lib.rs
@@ -0,0 +1,3433 @@
+use std::{
+ collections::{HashMap, HashSet},
+ fs, io,
+ path::{Path, PathBuf},
+ sync::{Arc, Mutex},
+ thread,
+};
+
+use crate::ui::theme;
+use chrono::NaiveDateTime;
+use eframe::egui::{self, Widget};
+use ps2_filetypes::{sjis, templates, IconSys, PSUEntryKind, TitleCfg, PSU};
+use psu_packer::{ColorConfig, ColorFConfig, IconSysConfig, VectorConfig};
+use tempfile::{tempdir, TempDir};
+
+pub(crate) mod sas_timestamps;
+pub mod ui;
+
+use sas_timestamps::TimestampRules;
+
+pub use ui::{dialogs, file_picker, pack_controls};
+
+pub(crate) const TIMESTAMP_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
+pub(crate) const ICON_SYS_FLAG_OPTIONS: &[(u16, &str)] =
+ &[(0, "Save Data"), (1, "System Software"), (4, "Settings")];
+pub(crate) const ICON_SYS_TITLE_CHAR_LIMIT: usize = 16;
+const ICON_SYS_UNSUPPORTED_CHAR_PLACEHOLDER: char = '\u{FFFD}';
+const TIMESTAMP_RULES_FILE: &str = "timestamp_rules.json";
+pub(crate) const REQUIRED_PROJECT_FILES: &[&str] =
+ &["icon.icn", "icon.sys", "psu.toml", "title.cfg"];
+const CENTERED_COLUMN_MAX_WIDTH: f32 = 1180.0;
+const PACK_CONTROLS_TWO_COLUMN_MIN_WIDTH: f32 = 940.0;
+const TITLE_CFG_GRID_SPACING: [f32; 2] = [28.0, 12.0];
+const TITLE_CFG_SECTION_GAP: f32 = 20.0;
+const TITLE_CFG_SECTION_HEADING_GAP: f32 = 6.0;
+const TITLE_CFG_MULTILINE_ROWS: usize = 6;
+const TITLE_CFG_SECTIONS: &[(&str, &[&str])] = &[
+ (
+ "Application identity",
+ &["title", "Title", "Version", "Release", "Developer", "Genre"],
+ ),
+ (
+ "Boot configuration",
+ &["boot", "CfgVersion", "$ConfigSource", "source"],
+ ),
+ ("Description", &["Description", "Notes"]),
+ (
+ "Presentation",
+ &[
+ "Parental",
+ "ParentalText",
+ "Vmode",
+ "VmodeText",
+ "Aspect",
+ "AspectText",
+ "Scan",
+ "ScanText",
+ ],
+ ),
+ (
+ "Players and devices",
+ &["Players", "PlayersText", "Device", "DeviceText"],
+ ),
+ ("Ratings", &["Rating", "RatingText"]),
+];
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum MissingFileReason {
+ AlwaysRequired,
+ ExplicitlyIncluded,
+ TimestampAutomation,
+}
+
+impl MissingFileReason {
+ fn detail(&self) -> Option<&'static str> {
+ match self {
+ MissingFileReason::AlwaysRequired => None,
+ MissingFileReason::ExplicitlyIncluded => Some("listed in Include files"),
+ MissingFileReason::TimestampAutomation => Some("needed for SAS timestamp automation"),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct MissingRequiredFile {
+ pub(crate) name: String,
+ pub(crate) reason: MissingFileReason,
+}
+
+impl MissingRequiredFile {
+ fn always(name: &str) -> Self {
+ Self {
+ name: name.to_string(),
+ reason: MissingFileReason::AlwaysRequired,
+ }
+ }
+
+ fn included(name: &str) -> Self {
+ Self {
+ name: name.to_string(),
+ reason: MissingFileReason::ExplicitlyIncluded,
+ }
+ }
+
+ fn timestamp_rules() -> Self {
+ Self {
+ name: TIMESTAMP_RULES_FILE.to_string(),
+ reason: MissingFileReason::TimestampAutomation,
+ }
+ }
+}
+
+fn split_icon_sys_title(title: &str, break_index: usize) -> (String, String) {
+ let sanitized_chars: Vec = title
+ .chars()
+ .map(|c| {
+ if c.is_control() {
+ ICON_SYS_UNSUPPORTED_CHAR_PLACEHOLDER
+ } else {
+ c
+ }
+ })
+ .collect();
+
+ let mut remaining_bytes = break_index;
+ let mut break_in_chars = 0usize;
+ if remaining_bytes > 0 {
+ for ch in title.chars() {
+ let mut utf8 = [0u8; 4];
+ let encoded_len = sjis::encode_sjis(ch.encode_utf8(&mut utf8))
+ .map(|bytes| bytes.len())
+ .unwrap_or(1)
+ .max(1);
+
+ if remaining_bytes < encoded_len {
+ break;
+ }
+ remaining_bytes -= encoded_len;
+ break_in_chars += 1;
+ if remaining_bytes == 0 {
+ break;
+ }
+ }
+ }
+
+ let break_index = break_in_chars.min(sanitized_chars.len());
+ let line1_count = break_index.min(ICON_SYS_TITLE_CHAR_LIMIT);
+ let skip_count = line1_count;
+
+ let line1: String = sanitized_chars.iter().take(line1_count).copied().collect();
+ let line2: String = sanitized_chars
+ .iter()
+ .skip(skip_count)
+ .take(ICON_SYS_TITLE_CHAR_LIMIT)
+ .copied()
+ .collect();
+
+ (line1, line2)
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum SasPrefix {
+ None,
+ App,
+ Apps,
+ Ps1,
+ Emu,
+ Gme,
+ Dst,
+ Dbg,
+ Raa,
+ Rte,
+ Sys,
+ Zzy,
+ Zzz,
+}
+
+pub(crate) const SAS_PREFIXES: [SasPrefix; 12] = [
+ SasPrefix::App,
+ SasPrefix::Apps,
+ SasPrefix::Ps1,
+ SasPrefix::Emu,
+ SasPrefix::Gme,
+ SasPrefix::Dst,
+ SasPrefix::Dbg,
+ SasPrefix::Raa,
+ SasPrefix::Rte,
+ SasPrefix::Sys,
+ SasPrefix::Zzy,
+ SasPrefix::Zzz,
+];
+
+impl Default for SasPrefix {
+ fn default() -> Self {
+ SasPrefix::App
+ }
+}
+
+impl SasPrefix {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ SasPrefix::None => "",
+ SasPrefix::App => "APP_",
+ SasPrefix::Apps => "APPS",
+ SasPrefix::Ps1 => "PS1_",
+ SasPrefix::Emu => "EMU_",
+ SasPrefix::Gme => "GME_",
+ SasPrefix::Dst => "DST_",
+ SasPrefix::Dbg => "DBG_",
+ SasPrefix::Raa => "RAA_",
+ SasPrefix::Rte => "RTE_",
+ SasPrefix::Sys => "SYS_",
+ SasPrefix::Zzy => "ZZY_",
+ SasPrefix::Zzz => "ZZZ_",
+ }
+ }
+
+ pub const fn label(self) -> &'static str {
+ match self {
+ SasPrefix::None => "(none)",
+ _ => self.as_str(),
+ }
+ }
+
+ pub(crate) fn iter_all() -> impl Iterator- {
+ std::iter::once(SasPrefix::None).chain(SAS_PREFIXES.iter().copied())
+ }
+
+ pub(crate) fn split_from_name(name: &str) -> (SasPrefix, &str) {
+ for prefix in SAS_PREFIXES {
+ let value = prefix.as_str();
+ if name.starts_with(value) {
+ let remainder = &name[value.len()..];
+ return (prefix, remainder);
+ }
+ }
+ (SasPrefix::None, name)
+ }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum IconFlagSelection {
+ Preset(usize),
+ Custom,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum TimestampStrategy {
+ None,
+ InheritSource,
+ SasRules,
+ Manual,
+}
+
+impl Default for TimestampStrategy {
+ fn default() -> Self {
+ TimestampStrategy::None
+ }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum EditorTab {
+ PsuSettings,
+ #[cfg(feature = "psu-toml-editor")]
+ /// Enable the psu.toml editor again with `--features psu-toml-editor`.
+ PsuToml,
+ TitleCfg,
+ IconSys,
+ TimestampAuto,
+}
+
+#[derive(Default)]
+struct TextFileEditor {
+ content: String,
+ modified: bool,
+ load_error: Option,
+}
+
+impl TextFileEditor {
+ fn set_content(&mut self, content: String) {
+ self.content = content;
+ self.modified = false;
+ self.load_error = None;
+ }
+
+ fn set_error_message(&mut self, message: String) {
+ self.content.clear();
+ self.modified = false;
+ self.load_error = Some(message);
+ }
+
+ fn clear(&mut self) {
+ self.content.clear();
+ self.modified = false;
+ self.load_error = None;
+ }
+}
+
+struct PackJob {
+ progress: Arc>,
+ handle: Option>,
+}
+
+enum PackProgress {
+ InProgress,
+ Finished(PackOutcome),
+}
+
+struct PackPreparation {
+ folder: PathBuf,
+ config: psu_packer::Config,
+ missing_required_files: Vec,
+}
+
+enum PackOutcome {
+ Success {
+ output_path: PathBuf,
+ },
+ Error {
+ folder: PathBuf,
+ output_path: PathBuf,
+ error: psu_packer::Error,
+ },
+}
+
+enum PendingPackAction {
+ Pack {
+ folder: PathBuf,
+ output_path: PathBuf,
+ config: psu_packer::Config,
+ missing_required_files: Vec,
+ },
+}
+
+impl PendingPackAction {
+ fn missing_files(&self) -> &[MissingRequiredFile] {
+ match self {
+ PendingPackAction::Pack {
+ missing_required_files,
+ ..
+ } => missing_required_files,
+ }
+ }
+}
+
+#[derive(Clone, Default)]
+pub(crate) struct TimestampRulesUiState;
+
+impl TimestampRulesUiState {
+ pub(crate) fn from_rules(_: &TimestampRules) -> Self {
+ Self
+ }
+
+ pub(crate) fn ensure_matches(&mut self, _: &TimestampRules) {}
+
+ fn swap(&mut self, _: usize, _: usize) {}
+}
+
+pub struct PackerApp {
+ pub(crate) folder: Option,
+ pub(crate) output: String,
+ pub(crate) status: String,
+ pub(crate) error_message: Option,
+ pub(crate) selected_prefix: SasPrefix,
+ pub(crate) folder_base_name: String,
+ pub(crate) psu_file_base_name: String,
+ pub(crate) timestamp: Option,
+ pub(crate) timestamp_strategy: TimestampStrategy,
+ pub(crate) timestamp_from_rules: bool,
+ pub(crate) source_timestamp: Option,
+ pub(crate) manual_timestamp: Option,
+ pub(crate) timestamp_rules: TimestampRules,
+ pub(crate) timestamp_rules_loaded_from_file: bool,
+ pub(crate) timestamp_rules_modified: bool,
+ pub(crate) timestamp_rules_error: Option,
+ pub(crate) timestamp_rules_ui: TimestampRulesUiState,
+ pub(crate) include_files: Vec,
+ pub(crate) exclude_files: Vec,
+ pub(crate) include_manual_entry: String,
+ pub(crate) exclude_manual_entry: String,
+ pub(crate) selected_include: Option,
+ pub(crate) selected_exclude: Option,
+ pub(crate) missing_required_project_files: Vec,
+ pending_pack_action: Option,
+ pub(crate) loaded_psu_path: Option,
+ pub(crate) loaded_psu_files: Vec,
+ pub(crate) show_exit_confirm: bool,
+ pub(crate) exit_confirmed: bool,
+ pub(crate) source_present_last_frame: bool,
+ pub(crate) icon_sys_enabled: bool,
+ pub(crate) icon_sys_title_line1: String,
+ pub(crate) icon_sys_title_line2: String,
+ pub(crate) icon_sys_flag_selection: IconFlagSelection,
+ pub(crate) icon_sys_custom_flag: u16,
+ pub(crate) icon_sys_background_transparency: u32,
+ pub(crate) icon_sys_background_colors: [ColorConfig; 4],
+ pub(crate) icon_sys_light_directions: [VectorConfig; 3],
+ pub(crate) icon_sys_light_colors: [ColorFConfig; 3],
+ pub(crate) icon_sys_ambient_color: ColorFConfig,
+ pub(crate) icon_sys_selected_preset: Option,
+ pub(crate) icon_sys_use_existing: bool,
+ pub(crate) icon_sys_existing: Option,
+ zoom_factor: f32,
+ pack_job: Option,
+ temp_workspace: Option,
+ editor_tab: EditorTab,
+ psu_toml_editor: TextFileEditor,
+ title_cfg_editor: TextFileEditor,
+ psu_toml_sync_blocked: bool,
+ theme: theme::Palette,
+ #[cfg(test)]
+ test_pack_job_started: bool,
+}
+
+struct ErrorMessage {
+ message: String,
+ failed_files: Vec,
+}
+
+impl From for ErrorMessage {
+ fn from(message: String) -> Self {
+ Self {
+ message,
+ failed_files: Vec::new(),
+ }
+ }
+}
+
+impl From<&str> for ErrorMessage {
+ fn from(message: &str) -> Self {
+ Self {
+ message: message.to_owned(),
+ failed_files: Vec::new(),
+ }
+ }
+}
+
+impl
From<(S, Vec)> for ErrorMessage
+where
+ S: Into,
+{
+ fn from((message, failed_files): (S, Vec)) -> Self {
+ Self {
+ message: message.into(),
+ failed_files,
+ }
+ }
+}
+
+impl Default for PackerApp {
+ fn default() -> Self {
+ let timestamp_rules = TimestampRules::default();
+ let timestamp_rules_ui = TimestampRulesUiState::from_rules(×tamp_rules);
+ Self {
+ folder: None,
+ output: String::new(),
+ status: String::new(),
+ error_message: None,
+ selected_prefix: SasPrefix::default(),
+ folder_base_name: String::new(),
+ psu_file_base_name: String::new(),
+ timestamp: None,
+ timestamp_strategy: TimestampStrategy::default(),
+ timestamp_from_rules: false,
+ source_timestamp: None,
+ manual_timestamp: None,
+ timestamp_rules,
+ timestamp_rules_loaded_from_file: false,
+ timestamp_rules_modified: false,
+ timestamp_rules_error: None,
+ timestamp_rules_ui,
+ include_files: Vec::new(),
+ exclude_files: Vec::new(),
+ include_manual_entry: String::new(),
+ exclude_manual_entry: String::new(),
+ selected_include: None,
+ selected_exclude: None,
+ missing_required_project_files: Vec::new(),
+ pending_pack_action: None,
+ loaded_psu_path: None,
+ loaded_psu_files: Vec::new(),
+ show_exit_confirm: false,
+ exit_confirmed: false,
+ source_present_last_frame: false,
+ icon_sys_enabled: false,
+ icon_sys_title_line1: String::new(),
+ icon_sys_title_line2: String::new(),
+ icon_sys_flag_selection: IconFlagSelection::Preset(0),
+ icon_sys_custom_flag: ICON_SYS_FLAG_OPTIONS[0].0,
+ icon_sys_background_transparency: IconSysConfig::default_background_transparency(),
+ icon_sys_background_colors: IconSysConfig::default_background_colors(),
+ icon_sys_light_directions: IconSysConfig::default_light_directions(),
+ icon_sys_light_colors: IconSysConfig::default_light_colors(),
+ icon_sys_ambient_color: IconSysConfig::default_ambient_color(),
+ icon_sys_selected_preset: None,
+ icon_sys_use_existing: false,
+ icon_sys_existing: None,
+ zoom_factor: 1.0,
+ pack_job: None,
+ temp_workspace: None,
+ editor_tab: EditorTab::PsuSettings,
+ psu_toml_editor: TextFileEditor::default(),
+ title_cfg_editor: TextFileEditor::default(),
+ psu_toml_sync_blocked: false,
+ theme: theme::Palette::default(),
+ #[cfg(test)]
+ test_pack_job_started: false,
+ }
+ }
+}
+
+impl PackerApp {
+ pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
+ let mut app = Self::default();
+ app.zoom_factor = cc.egui_ctx.pixels_per_point();
+ theme::install(&cc.egui_ctx, &app.theme);
+ app
+ }
+
+ fn timestamp_rules_path_from(folder: &Path) -> PathBuf {
+ folder.join(TIMESTAMP_RULES_FILE)
+ }
+
+ pub(crate) fn timestamp_rules_path(&self) -> Option {
+ self.folder
+ .as_ref()
+ .map(|folder| Self::timestamp_rules_path_from(folder))
+ }
+
+ fn missing_required_project_files_for(&self, folder: &Path) -> Vec {
+ let mut missing = REQUIRED_PROJECT_FILES
+ .iter()
+ .filter_map(|name| {
+ let candidate = folder.join(name);
+ if candidate.is_file() {
+ None
+ } else {
+ Some(MissingRequiredFile::always(name))
+ }
+ })
+ .collect::>();
+
+ if self.include_requires_file("BOOT.ELF") {
+ let candidate = folder.join("BOOT.ELF");
+ if !candidate.is_file() {
+ missing.push(MissingRequiredFile::included("BOOT.ELF"));
+ }
+ }
+
+ if self.uses_timestamp_rules_file() {
+ let candidate = folder.join(TIMESTAMP_RULES_FILE);
+ if !candidate.is_file() {
+ missing.push(MissingRequiredFile::timestamp_rules());
+ }
+ }
+
+ missing
+ }
+
+ fn include_requires_file(&self, file_name: &str) -> bool {
+ self.include_files
+ .iter()
+ .any(|entry| entry.eq_ignore_ascii_case(file_name))
+ }
+
+ fn uses_timestamp_rules_file(&self) -> bool {
+ matches!(self.timestamp_strategy, TimestampStrategy::SasRules)
+ && (self.timestamp_rules_loaded_from_file || self.timestamp_rules_modified)
+ }
+
+ pub(crate) fn refresh_missing_required_project_files(&mut self) {
+ if let Some(folder) = self.folder.clone() {
+ self.missing_required_project_files = self.missing_required_project_files_for(&folder);
+ } else {
+ self.missing_required_project_files.clear();
+ }
+ }
+
+ pub(crate) fn pending_pack_missing_files(&self) -> Option<&[MissingRequiredFile]> {
+ self.pending_pack_action
+ .as_ref()
+ .map(|action| action.missing_files())
+ }
+
+ pub(crate) fn confirm_pending_pack_action(&mut self) {
+ if let Some(action) = self.pending_pack_action.take() {
+ match action {
+ PendingPackAction::Pack {
+ folder,
+ output_path,
+ config,
+ ..
+ } => {
+ self.begin_pack_job(folder, output_path, config);
+ }
+ }
+ }
+ }
+
+ pub(crate) fn cancel_pending_pack_action(&mut self) {
+ self.pending_pack_action = None;
+ }
+
+ fn editor_tab_button(
+ &mut self,
+ ui: &mut egui::Ui,
+ tab: EditorTab,
+ label: &str,
+ alert: bool,
+ font: &egui::FontId,
+ ) {
+ let widget = EditorTabWidget::new(
+ label,
+ font.clone(),
+ &self.theme,
+ self.editor_tab == tab,
+ alert,
+ );
+ let response = ui.add(widget);
+ if response.clicked() {
+ self.editor_tab = tab;
+ }
+ }
+
+ pub(crate) fn load_timestamp_rules_from_folder(&mut self, folder: &Path) {
+ let path = Self::timestamp_rules_path_from(folder);
+ match fs::read_to_string(&path) {
+ Ok(content) => match serde_json::from_str::(&content) {
+ Ok(mut rules) => {
+ rules.sanitize();
+ self.timestamp_rules = rules;
+ self.timestamp_rules_error = None;
+ self.timestamp_rules_loaded_from_file = true;
+ }
+ Err(err) => {
+ self.timestamp_rules = TimestampRules::default();
+ self.timestamp_rules_error =
+ Some(format!("Failed to parse {}: {err}", path.display()));
+ self.timestamp_rules_loaded_from_file = true;
+ }
+ },
+ Err(err) => {
+ if err.kind() == io::ErrorKind::NotFound {
+ self.timestamp_rules = TimestampRules::default();
+ self.timestamp_rules_error = None;
+ self.timestamp_rules_loaded_from_file = false;
+ } else {
+ self.timestamp_rules = TimestampRules::default();
+ self.timestamp_rules_error =
+ Some(format!("Failed to read {}: {err}", path.display()));
+ self.timestamp_rules_loaded_from_file = true;
+ }
+ }
+ }
+
+ self.timestamp_rules_ui = TimestampRulesUiState::from_rules(&self.timestamp_rules);
+ self.timestamp_rules_modified = false;
+ }
+
+ pub(crate) fn save_timestamp_rules(&mut self) -> Result {
+ let Some(folder) = self.folder.as_ref() else {
+ return Err("Select a folder before saving timestamp rules.".to_string());
+ };
+
+ self.timestamp_rules.sanitize();
+ let serialized = serde_json::to_string_pretty(&self.timestamp_rules)
+ .map_err(|err| format!("Failed to serialize timestamp rules: {err}"))?;
+
+ let path = Self::timestamp_rules_path_from(folder);
+ fs::write(&path, serialized)
+ .map_err(|err| format!("Failed to write {}: {err}", path.display()))?;
+
+ self.timestamp_rules_ui = TimestampRulesUiState::from_rules(&self.timestamp_rules);
+ self.timestamp_rules_modified = false;
+ self.timestamp_rules_error = None;
+ self.timestamp_rules_loaded_from_file = true;
+ Ok(path)
+ }
+
+ pub(crate) fn set_timestamp_strategy(&mut self, strategy: TimestampStrategy) {
+ if self.timestamp_strategy == strategy {
+ return;
+ }
+
+ self.timestamp_strategy = strategy;
+
+ if matches!(self.timestamp_strategy, TimestampStrategy::Manual)
+ && self.manual_timestamp.is_none()
+ {
+ if let Some(source) = self.source_timestamp {
+ self.manual_timestamp = Some(source);
+ } else if let Some(planned) = self.planned_timestamp_for_current_source() {
+ self.manual_timestamp = Some(planned);
+ }
+ }
+
+ self.refresh_timestamp_from_strategy();
+ }
+
+ pub(crate) fn refresh_timestamp_from_strategy(&mut self) {
+ let new_timestamp = match self.timestamp_strategy {
+ TimestampStrategy::None => None,
+ TimestampStrategy::InheritSource => self.source_timestamp,
+ TimestampStrategy::SasRules => self.planned_timestamp_for_current_source(),
+ TimestampStrategy::Manual => self.manual_timestamp,
+ };
+
+ let changed = self.timestamp != new_timestamp;
+ self.timestamp = new_timestamp;
+ self.timestamp_from_rules = matches!(self.timestamp_strategy, TimestampStrategy::SasRules)
+ && self.timestamp.is_some();
+
+ if changed {
+ self.refresh_psu_toml_editor();
+ }
+ }
+
+ pub(crate) fn sync_timestamp_after_source_update(&mut self) {
+ let planned = self.planned_timestamp_for_current_source();
+
+ if matches!(self.timestamp_strategy, TimestampStrategy::None) {
+ if self.source_timestamp.is_some() {
+ self.timestamp_strategy = TimestampStrategy::InheritSource;
+ } else if planned.is_some() {
+ self.timestamp_strategy = TimestampStrategy::SasRules;
+ }
+ }
+
+ if matches!(self.timestamp_strategy, TimestampStrategy::Manual)
+ && self.manual_timestamp.is_none()
+ {
+ if let Some(source) = self.source_timestamp {
+ self.manual_timestamp = Some(source);
+ } else if let Some(planned) = planned {
+ self.manual_timestamp = Some(planned);
+ }
+ }
+
+ self.refresh_timestamp_from_strategy();
+ }
+
+ pub(crate) fn mark_timestamp_rules_modified(&mut self) {
+ self.timestamp_rules_modified = true;
+ self.recompute_timestamp_from_rules();
+ }
+
+ fn recompute_timestamp_from_rules(&mut self) {
+ if !matches!(self.timestamp_strategy, TimestampStrategy::SasRules) {
+ return;
+ }
+
+ self.refresh_timestamp_from_strategy();
+ }
+
+ pub(crate) fn apply_planned_timestamp(&mut self) {
+ self.set_timestamp_strategy(TimestampStrategy::SasRules);
+ }
+
+ pub(crate) fn planned_timestamp_for_current_source(&self) -> Option {
+ if let Some(folder) = self.folder.as_ref() {
+ return sas_timestamps::planned_timestamp_for_folder(
+ folder.as_path(),
+ &self.timestamp_rules,
+ );
+ }
+
+ let name = self.folder_name();
+ if name.trim().is_empty() {
+ return None;
+ }
+
+ sas_timestamps::planned_timestamp_for_name(&name, &self.timestamp_rules)
+ }
+
+ pub(crate) fn move_timestamp_category_up(&mut self, index: usize) {
+ if index == 0 || index >= self.timestamp_rules.categories.len() {
+ return;
+ }
+ self.timestamp_rules.categories.swap(index - 1, index);
+ self.timestamp_rules_ui.swap(index - 1, index);
+ self.mark_timestamp_rules_modified();
+ }
+
+ pub(crate) fn move_timestamp_category_down(&mut self, index: usize) {
+ let len = self.timestamp_rules.categories.len();
+ if index + 1 >= len {
+ return;
+ }
+ self.timestamp_rules.categories.swap(index, index + 1);
+ self.timestamp_rules_ui.swap(index, index + 1);
+ self.mark_timestamp_rules_modified();
+ }
+
+ pub(crate) fn set_timestamp_aliases(&mut self, index: usize, aliases: Vec) {
+ if let Some(category) = self.timestamp_rules.categories.get_mut(index) {
+ let allowed = sas_timestamps::canonical_aliases_for_category(&category.key);
+ let selected: HashSet<&str> = aliases.iter().map(|alias| alias.as_str()).collect();
+ let sanitized: Vec = allowed
+ .iter()
+ .filter(|alias| selected.contains(**alias))
+ .map(|alias| (*alias).to_string())
+ .collect();
+
+ if category.aliases != sanitized {
+ category.aliases = sanitized;
+ self.mark_timestamp_rules_modified();
+ }
+ }
+ }
+
+ pub(crate) fn reset_timestamp_rules_to_default(&mut self) {
+ self.timestamp_rules = TimestampRules::default();
+ self.timestamp_rules_error = None;
+ self.timestamp_rules_ui = TimestampRulesUiState::from_rules(&self.timestamp_rules);
+ self.timestamp_rules_loaded_from_file = false;
+ self.mark_timestamp_rules_modified();
+ }
+
+ pub(crate) fn set_error_message(&mut self, message: M)
+ where
+ M: Into,
+ {
+ let message = message.into();
+ let mut text = message.message;
+ if !message.failed_files.is_empty() {
+ if !text.is_empty() {
+ text.push(' ');
+ }
+ text.push_str("Failed files: ");
+ text.push_str(&message.failed_files.join(", "));
+ }
+ self.error_message = Some(text);
+ self.status.clear();
+ }
+
+ pub(crate) fn format_missing_required_files_message(missing: &[MissingRequiredFile]) -> String {
+ let formatted = missing
+ .iter()
+ .map(|entry| match entry.reason.detail() {
+ Some(detail) => format!("• {} ({detail})", entry.name),
+ None => format!("• {}", entry.name),
+ })
+ .collect::>()
+ .join("\n");
+ format!(
+ "The selected folder is missing files needed to pack the PSU:\n{}",
+ formatted
+ )
+ }
+
+ pub(crate) fn clear_error_message(&mut self) {
+ self.error_message = None;
+ }
+
+ pub(crate) fn reset_icon_sys_fields(&mut self) {
+ self.icon_sys_enabled = false;
+ self.icon_sys_use_existing = false;
+ self.icon_sys_existing = None;
+ self.icon_sys_title_line1.clear();
+ self.icon_sys_title_line2.clear();
+ self.icon_sys_flag_selection = IconFlagSelection::Preset(0);
+ self.icon_sys_custom_flag = ICON_SYS_FLAG_OPTIONS[0].0;
+ self.icon_sys_background_transparency = IconSysConfig::default_background_transparency();
+ self.icon_sys_background_colors = IconSysConfig::default_background_colors();
+ self.icon_sys_light_directions = IconSysConfig::default_light_directions();
+ self.icon_sys_light_colors = IconSysConfig::default_light_colors();
+ self.icon_sys_ambient_color = IconSysConfig::default_ambient_color();
+ self.icon_sys_selected_preset = None;
+ }
+
+ pub(crate) fn apply_icon_sys_config(
+ &mut self,
+ icon_cfg: psu_packer::IconSysConfig,
+ icon_sys_fallback: Option<&IconSys>,
+ ) {
+ let flag_value = icon_cfg.flags.value();
+ self.icon_sys_enabled = true;
+ self.icon_sys_use_existing = false;
+ self.icon_sys_custom_flag = flag_value;
+ if let Some(index) = ICON_SYS_FLAG_OPTIONS
+ .iter()
+ .position(|(value, _)| *value == flag_value)
+ {
+ self.icon_sys_flag_selection = IconFlagSelection::Preset(index);
+ } else {
+ self.icon_sys_flag_selection = IconFlagSelection::Custom;
+ }
+
+ let break_index = icon_cfg.linebreak_position() as usize;
+ let (line1, line2) = split_icon_sys_title(&icon_cfg.title, break_index);
+ self.icon_sys_title_line1 = line1;
+ self.icon_sys_title_line2 = line2;
+
+ self.icon_sys_background_transparency =
+ icon_cfg.background_transparency.unwrap_or_else(|| {
+ icon_sys_fallback
+ .map(|icon_sys| icon_sys.background_transparency)
+ .unwrap_or_else(IconSysConfig::default_background_transparency)
+ });
+
+ self.icon_sys_background_colors = if icon_cfg.background_colors.is_some() {
+ icon_cfg.background_colors_array()
+ } else if let Some(icon_sys) = icon_sys_fallback {
+ let mut colors = IconSysConfig::default_background_colors();
+ for (target, color) in colors.iter_mut().zip(icon_sys.background_colors.iter()) {
+ *target = ColorConfig {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ a: color.a,
+ };
+ }
+ colors
+ } else {
+ IconSysConfig::default_background_colors()
+ };
+
+ self.icon_sys_light_directions = if icon_cfg.light_directions.is_some() {
+ icon_cfg.light_directions_array()
+ } else if let Some(icon_sys) = icon_sys_fallback {
+ let mut directions = IconSysConfig::default_light_directions();
+ for (target, direction) in directions.iter_mut().zip(icon_sys.light_directions.iter()) {
+ *target = VectorConfig {
+ x: direction.x,
+ y: direction.y,
+ z: direction.z,
+ w: direction.w,
+ };
+ }
+ directions
+ } else {
+ IconSysConfig::default_light_directions()
+ };
+
+ self.icon_sys_light_colors = if icon_cfg.light_colors.is_some() {
+ icon_cfg.light_colors_array()
+ } else if let Some(icon_sys) = icon_sys_fallback {
+ let mut colors = IconSysConfig::default_light_colors();
+ for (target, color) in colors.iter_mut().zip(icon_sys.light_colors.iter()) {
+ *target = ColorFConfig {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ a: color.a,
+ };
+ }
+ colors
+ } else {
+ IconSysConfig::default_light_colors()
+ };
+
+ self.icon_sys_ambient_color = if let Some(color) = icon_cfg.ambient_color {
+ color
+ } else if let Some(icon_sys) = icon_sys_fallback {
+ ColorFConfig {
+ r: icon_sys.ambient_color.r,
+ g: icon_sys.ambient_color.g,
+ b: icon_sys.ambient_color.b,
+ a: icon_sys.ambient_color.a,
+ }
+ } else {
+ IconSysConfig::default_ambient_color()
+ };
+ self.icon_sys_selected_preset = icon_cfg.preset.clone();
+ }
+
+ pub(crate) fn apply_icon_sys_file(&mut self, icon_sys: &IconSys) {
+ let flag_value = icon_sys.flags;
+ self.icon_sys_enabled = true;
+ self.icon_sys_use_existing = true;
+ self.icon_sys_existing = Some(icon_sys.clone());
+ self.icon_sys_custom_flag = flag_value;
+ if let Some(index) = ICON_SYS_FLAG_OPTIONS
+ .iter()
+ .position(|(value, _)| *value == flag_value)
+ {
+ self.icon_sys_flag_selection = IconFlagSelection::Preset(index);
+ } else {
+ self.icon_sys_flag_selection = IconFlagSelection::Custom;
+ }
+
+ let break_index = icon_sys.linebreak_pos as usize;
+ let (line1, line2) = split_icon_sys_title(&icon_sys.title, break_index);
+ self.icon_sys_title_line1 = line1;
+ self.icon_sys_title_line2 = line2;
+
+ self.icon_sys_background_transparency = icon_sys.background_transparency;
+ for (target, color) in self
+ .icon_sys_background_colors
+ .iter_mut()
+ .zip(icon_sys.background_colors.iter())
+ {
+ *target = ColorConfig {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ a: color.a,
+ };
+ }
+
+ for (target, direction) in self
+ .icon_sys_light_directions
+ .iter_mut()
+ .zip(icon_sys.light_directions.iter())
+ {
+ *target = VectorConfig {
+ x: direction.x,
+ y: direction.y,
+ z: direction.z,
+ w: direction.w,
+ };
+ }
+
+ for (target, color) in self
+ .icon_sys_light_colors
+ .iter_mut()
+ .zip(icon_sys.light_colors.iter())
+ {
+ *target = ColorFConfig {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ a: color.a,
+ };
+ }
+
+ self.icon_sys_ambient_color = ColorFConfig {
+ r: icon_sys.ambient_color.r,
+ g: icon_sys.ambient_color.g,
+ b: icon_sys.ambient_color.b,
+ a: icon_sys.ambient_color.a,
+ };
+ self.icon_sys_selected_preset = None;
+ }
+
+ pub(crate) fn clear_icon_sys_preset(&mut self) {
+ self.icon_sys_selected_preset = None;
+ }
+
+ pub(crate) fn reset_metadata_fields(&mut self) {
+ self.selected_prefix = SasPrefix::default();
+ self.folder_base_name.clear();
+ self.psu_file_base_name.clear();
+ self.timestamp = None;
+ self.timestamp_strategy = TimestampStrategy::None;
+ self.timestamp_from_rules = false;
+ self.source_timestamp = None;
+ self.manual_timestamp = None;
+ self.include_files.clear();
+ self.exclude_files.clear();
+ self.include_manual_entry.clear();
+ self.exclude_manual_entry.clear();
+ self.selected_include = None;
+ self.selected_exclude = None;
+ self.reset_icon_sys_fields();
+ }
+
+ pub(crate) fn folder_name(&self) -> String {
+ let mut name = String::from(self.selected_prefix.as_str());
+ name.push_str(&self.folder_base_name);
+ name
+ }
+
+ fn effective_psu_file_base_name(&self) -> Option {
+ let trimmed_file = self.psu_file_base_name.trim();
+ if !trimmed_file.is_empty() {
+ return Some(trimmed_file.to_string());
+ }
+
+ let trimmed_folder = self.folder_base_name.trim();
+ if trimmed_folder.is_empty() {
+ None
+ } else {
+ Some(trimmed_folder.to_string())
+ }
+ }
+
+ fn existing_output_directory(&self) -> Option {
+ let trimmed_output = self.output.trim();
+ if trimmed_output.is_empty() {
+ return None;
+ }
+
+ let path = Path::new(trimmed_output);
+ path.parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .map(|parent| parent.to_path_buf())
+ }
+
+ fn loaded_psu_directory(&self) -> Option {
+ self.loaded_psu_path
+ .as_ref()
+ .and_then(|path| path.parent())
+ .map(|parent| parent.to_path_buf())
+ }
+
+ fn default_output_directory(&self, fallback_dir: Option<&Path>) -> Option {
+ if let Some(existing) = self.existing_output_directory() {
+ return Some(existing);
+ }
+
+ if let Some(dir) = fallback_dir {
+ return Some(dir.to_path_buf());
+ }
+
+ if let Some(folder) = self.folder.as_ref() {
+ return Some(folder.clone());
+ }
+
+ self.loaded_psu_directory()
+ }
+
+ pub(crate) fn default_output_path(&self) -> Option {
+ self.default_output_path_with(None)
+ }
+
+ pub(crate) fn default_output_path_with(&self, fallback_dir: Option<&Path>) -> Option {
+ let file_name = self.default_output_file_name()?;
+ let directory = self.default_output_directory(fallback_dir);
+ Some(match directory {
+ Some(dir) => dir.join(file_name),
+ None => PathBuf::from(file_name),
+ })
+ }
+
+ pub(crate) fn default_output_file_name(&self) -> Option {
+ let base_name = self.effective_psu_file_base_name()?;
+ let mut stem = String::from(self.selected_prefix.as_str());
+ stem.push_str(&base_name);
+ if stem.is_empty() {
+ None
+ } else {
+ Some(format!("{stem}.psu"))
+ }
+ }
+
+ fn update_output_if_matches_default(&mut self, previous_default_output: Option) {
+ let should_update = if self.output.trim().is_empty() {
+ true
+ } else if let Some(previous_default) = previous_default_output {
+ Path::new(&self.output)
+ .file_name()
+ .and_then(|name| name.to_str())
+ .map(|name| name == previous_default)
+ .unwrap_or(false)
+ } else {
+ false
+ };
+
+ if should_update {
+ match self.default_output_path() {
+ Some(path) => {
+ self.output = path.display().to_string();
+ }
+ None => self.output.clear(),
+ }
+ }
+ }
+
+ pub(crate) fn metadata_inputs_changed(&mut self, previous_default_output: Option) {
+ if self.psu_file_base_name.trim().is_empty() {
+ let trimmed_folder = self.folder_base_name.trim();
+ if !trimmed_folder.is_empty() {
+ self.psu_file_base_name = trimmed_folder.to_string();
+ }
+ }
+
+ self.update_output_if_matches_default(previous_default_output);
+ self.ensure_timestamp_strategy_default();
+ if matches!(self.timestamp_strategy, TimestampStrategy::SasRules) {
+ self.refresh_timestamp_from_strategy();
+ }
+ self.refresh_psu_toml_editor();
+ }
+
+ fn ensure_timestamp_strategy_default(&mut self) {
+ if !matches!(self.timestamp_strategy, TimestampStrategy::None) {
+ return;
+ }
+
+ let recommended = if self.source_timestamp.is_some() {
+ Some(TimestampStrategy::InheritSource)
+ } else if self.planned_timestamp_for_current_source().is_some() {
+ Some(TimestampStrategy::SasRules)
+ } else {
+ Some(TimestampStrategy::Manual)
+ };
+
+ if let Some(strategy) = recommended {
+ self.set_timestamp_strategy(strategy);
+ }
+ }
+
+ pub(crate) fn set_folder_name_from_full(&mut self, name: &str) {
+ let (prefix, remainder) = SasPrefix::split_from_name(name);
+ self.selected_prefix = prefix;
+ self.folder_base_name = remainder.to_string();
+ }
+
+ pub(crate) fn set_psu_file_base_from_full(&mut self, file_stem: &str) {
+ let (prefix, remainder) = SasPrefix::split_from_name(file_stem);
+ if prefix == SasPrefix::None || prefix == self.selected_prefix {
+ self.psu_file_base_name = remainder.to_string();
+ } else {
+ self.psu_file_base_name = file_stem.to_string();
+ }
+ }
+
+ pub(crate) fn icon_flag_label(&self) -> String {
+ match self.icon_sys_flag_selection {
+ IconFlagSelection::Preset(index) => ICON_SYS_FLAG_OPTIONS
+ .get(index)
+ .map(|(_, label)| (*label).to_string())
+ .unwrap_or_else(|| format!("Preset {index}")),
+ IconFlagSelection::Custom => {
+ format!("Custom (0x{:04X})", self.icon_sys_custom_flag)
+ }
+ }
+ }
+
+ pub(crate) fn selected_icon_flag_value(&self) -> Result {
+ match self.icon_sys_flag_selection {
+ IconFlagSelection::Preset(index) => ICON_SYS_FLAG_OPTIONS
+ .get(index)
+ .map(|(value, _)| *value)
+ .ok_or_else(|| "Invalid icon.sys flag selection".to_string()),
+ IconFlagSelection::Custom => Ok(self.icon_sys_custom_flag),
+ }
+ }
+
+ pub(crate) fn missing_include_files(&self, folder: &Path) -> Vec {
+ if self.include_files.is_empty() {
+ return Vec::new();
+ }
+
+ self.include_files
+ .iter()
+ .filter_map(|file| {
+ let candidate = folder.join(file);
+ if candidate.is_file() {
+ None
+ } else {
+ Some(file.clone())
+ }
+ })
+ .collect()
+ }
+
+ pub(crate) fn handle_pack_request(&mut self) {
+ if self.is_pack_running() {
+ return;
+ }
+
+ let Some(preparation) = self.prepare_pack_inputs() else {
+ return;
+ };
+
+ let output_path = PathBuf::from(&self.output);
+ let PackPreparation {
+ folder,
+ config,
+ missing_required_files,
+ } = preparation;
+
+ if missing_required_files.is_empty() {
+ self.begin_pack_job(folder, output_path, config);
+ } else {
+ self.pending_pack_action = Some(PendingPackAction::Pack {
+ folder,
+ output_path,
+ config,
+ missing_required_files,
+ });
+ }
+ }
+
+ pub(crate) fn handle_update_psu_request(&mut self) {
+ if self.is_pack_running() {
+ return;
+ }
+
+ if self.loaded_psu_path.is_none() && self.output.trim().is_empty() {
+ if !self.ensure_output_destination_selected() {
+ return;
+ }
+ }
+
+ let destination = match self.determine_update_destination() {
+ Ok(path) => path,
+ Err(message) => {
+ self.set_error_message(message);
+ return;
+ }
+ };
+
+ if !destination.exists() {
+ self.set_error_message(format!(
+ "Cannot update because {} does not exist.",
+ destination.display()
+ ));
+ return;
+ }
+
+ let mut temp_workspace_to_hold: Option = None;
+ let preparation_result = if self.folder.is_some() {
+ self.prepare_pack_inputs()
+ } else if self.loaded_psu_path.is_some() {
+ let (workspace, export_root) = match self.prepare_loaded_psu_workspace() {
+ Ok(result) => result,
+ Err(message) => {
+ self.set_error_message(message);
+ return;
+ }
+ };
+ let preparation = self.prepare_pack_inputs_for_folder(export_root, None, true);
+ if preparation.is_some() {
+ temp_workspace_to_hold = Some(workspace);
+ }
+ preparation
+ } else {
+ self.prepare_pack_inputs()
+ };
+
+ let Some(preparation) = preparation_result else {
+ return;
+ };
+
+ if !preparation.missing_required_files.is_empty() {
+ self.pending_pack_action = None;
+ self.temp_workspace = None;
+ return;
+ }
+
+ let PackPreparation { folder, config, .. } = preparation;
+
+ self.temp_workspace = temp_workspace_to_hold;
+ self.begin_pack_job(folder, destination, config);
+ }
+
+ pub(crate) fn handle_save_as_folder_with_contents(&mut self) {
+ if self.is_pack_running() {
+ return;
+ }
+
+ if self.loaded_psu_path.is_none() && self.output.trim().is_empty() {
+ if !self.ensure_output_destination_selected() {
+ return;
+ }
+ }
+
+ let source_path = match self.determine_export_source_path() {
+ Ok(path) => path,
+ Err(message) => {
+ self.set_error_message(message);
+ return;
+ }
+ };
+
+ let Some(destination_parent) = rfd::FileDialog::new().pick_folder() else {
+ return;
+ };
+
+ match self.export_psu_to_folder(&source_path, &destination_parent) {
+ Ok(export_root) => {
+ self.clear_error_message();
+ self.status = format!(
+ "Exported PSU contents from {} to {}",
+ source_path.display(),
+ export_root.display()
+ );
+ }
+ Err(message) => {
+ self.set_error_message(message);
+ }
+ }
+ }
+
+ fn prepare_pack_inputs(&mut self) -> Option {
+ let Some(folder) = self.folder.clone() else {
+ self.set_error_message("Please select a folder");
+ return None;
+ };
+
+ self.prepare_pack_inputs_for_folder(folder, None, false)
+ }
+
+ fn prepare_pack_inputs_for_folder(
+ &mut self,
+ folder: PathBuf,
+ config_override: Option,
+ allow_missing_psu_toml: bool,
+ ) -> Option {
+ if self.folder_base_name.trim().is_empty() {
+ self.set_error_message("Please provide a folder name");
+ return None;
+ }
+
+ if self.psu_file_base_name.trim().is_empty() {
+ let trimmed_folder = self.folder_base_name.trim();
+ if trimmed_folder.is_empty() {
+ self.set_error_message("Please provide a PSU filename");
+ return None;
+ }
+ self.psu_file_base_name = trimmed_folder.to_string();
+ }
+
+ if !self.ensure_output_destination_selected() {
+ return None;
+ }
+
+ let mut missing = self.missing_required_project_files_for(&folder);
+ if allow_missing_psu_toml {
+ missing.retain(|entry| !entry.name.eq_ignore_ascii_case("psu.toml"));
+ }
+ self.missing_required_project_files = missing.clone();
+ if !missing.is_empty() {
+ let message = Self::format_missing_required_files_message(&missing);
+ let failed_files = missing.iter().map(|entry| entry.name.clone()).collect();
+ self.set_error_message((message, failed_files));
+ }
+
+ let config = match config_override {
+ Some(config) => config,
+ None => match self.build_config() {
+ Ok(config) => config,
+ Err(err) => {
+ self.set_error_message(err);
+ self.pending_pack_action = None;
+ return None;
+ }
+ },
+ };
+
+ Some(PackPreparation {
+ folder,
+ config,
+ missing_required_files: missing,
+ })
+ }
+
+ fn determine_update_destination(&self) -> Result {
+ if let Some(path) = &self.loaded_psu_path {
+ return Ok(path.clone());
+ }
+
+ let trimmed = self.output.trim();
+ if trimmed.is_empty() {
+ Err("Load a PSU file or set the output path before updating.".to_string())
+ } else {
+ Ok(PathBuf::from(trimmed))
+ }
+ }
+
+ fn determine_export_source_path(&self) -> Result {
+ if let Some(path) = &self.loaded_psu_path {
+ return Ok(path.clone());
+ }
+
+ let trimmed = self.output.trim();
+ if trimmed.is_empty() {
+ Err("Load a PSU file or select a packed PSU before exporting its contents.".to_string())
+ } else {
+ Ok(PathBuf::from(trimmed))
+ }
+ }
+
+ fn export_psu_to_folder(
+ &self,
+ source_path: &Path,
+ destination_parent: &Path,
+ ) -> Result {
+ if !source_path.is_file() {
+ return Err(format!(
+ "Cannot export because {} does not exist.",
+ source_path.display()
+ ));
+ }
+
+ let data = fs::read(source_path)
+ .map_err(|err| format!("Failed to read {}: {err}", source_path.display()))?;
+
+ let parsed = std::panic::catch_unwind(|| PSU::new(data))
+ .map_err(|_| format!("Failed to parse PSU file {}", source_path.display()))?;
+
+ let entries = parsed.entries();
+ let root_name = entries
+ .iter()
+ .find(|entry| {
+ matches!(entry.kind, PSUEntryKind::Directory)
+ && entry.name != "."
+ && entry.name != ".."
+ })
+ .map(|entry| entry.name.clone())
+ .ok_or_else(|| format!("{} does not contain PSU metadata", source_path.display()))?;
+
+ if root_name.trim().is_empty() {
+ return Err(format!(
+ "{} does not contain a valid root directory entry.",
+ source_path.display()
+ ));
+ }
+
+ let export_root = destination_parent.join(&root_name);
+ fs::create_dir_all(&export_root)
+ .map_err(|err| format!("Failed to create {}: {err}", export_root.display()))?;
+
+ for entry in entries {
+ match entry.kind {
+ PSUEntryKind::Directory => {
+ if entry.name == "." || entry.name == ".." {
+ continue;
+ }
+
+ let target = if entry.name == root_name {
+ export_root.clone()
+ } else {
+ export_root.join(&entry.name)
+ };
+
+ fs::create_dir_all(&target)
+ .map_err(|err| format!("Failed to create {}: {err}", target.display()))?;
+ }
+ PSUEntryKind::File => {
+ let Some(contents) = entry.contents else {
+ return Err(format!(
+ "{} is missing file data in the PSU archive.",
+ entry.name
+ ));
+ };
+
+ let target = export_root.join(&entry.name);
+ if let Some(parent) = target.parent() {
+ fs::create_dir_all(parent).map_err(|err| {
+ format!("Failed to create {}: {err}", parent.display())
+ })?;
+ }
+
+ fs::write(&target, contents)
+ .map_err(|err| format!("Failed to write {}: {err}", target.display()))?;
+ }
+ }
+ }
+
+ Ok(export_root)
+ }
+
+ fn prepare_loaded_psu_workspace(&self) -> Result<(TempDir, PathBuf), String> {
+ let source_path = self
+ .loaded_psu_path
+ .as_ref()
+ .ok_or_else(|| "No PSU file is currently loaded.".to_string())?;
+ let temp_dir =
+ tempdir().map_err(|err| format!("Failed to create temporary workspace: {err}"))?;
+ let export_root = self
+ .export_psu_to_folder(source_path, temp_dir.path())
+ .map_err(|err| format!("Failed to export loaded PSU: {err}"))?;
+ Ok((temp_dir, export_root))
+ }
+
+ pub(crate) fn reload_project_files(&mut self) {
+ if let Some(folder) = self.folder.clone() {
+ load_text_file_into_editor(folder.as_path(), "psu.toml", &mut self.psu_toml_editor);
+ load_text_file_into_editor(folder.as_path(), "title.cfg", &mut self.title_cfg_editor);
+ self.psu_toml_sync_blocked = false;
+ self.refresh_missing_required_project_files();
+ } else {
+ self.clear_text_editors();
+ self.missing_required_project_files.clear();
+ }
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ pub(crate) fn apply_psu_toml_edits(&mut self) -> bool {
+ let temp_dir = match tempdir() {
+ Ok(dir) => dir,
+ Err(err) => {
+ self.set_error_message(format!(
+ "Failed to prepare temporary psu.toml for parsing: {err}"
+ ));
+ return false;
+ }
+ };
+
+ let config_path = temp_dir.path().join("psu.toml");
+ if let Err(err) = fs::write(&config_path, self.psu_toml_editor.content.as_bytes()) {
+ self.set_error_message(format!("Failed to write temporary psu.toml: {err}"));
+ return false;
+ }
+
+ let config = match psu_packer::load_config(temp_dir.path()) {
+ Ok(config) => config,
+ Err(err) => {
+ self.set_error_message(format!("Failed to parse psu.toml: {err}"));
+ return false;
+ }
+ };
+
+ let previous_default_output = self.default_output_file_name();
+
+ let psu_packer::Config {
+ name,
+ timestamp,
+ include,
+ exclude,
+ icon_sys,
+ } = config;
+
+ self.set_folder_name_from_full(&name);
+ self.psu_file_base_name = self.folder_base_name.clone();
+ self.source_timestamp = timestamp;
+ self.manual_timestamp = timestamp;
+ self.timestamp = timestamp;
+ self.timestamp_strategy = if timestamp.is_some() {
+ TimestampStrategy::Manual
+ } else {
+ TimestampStrategy::None
+ };
+ self.timestamp_from_rules = false;
+ self.metadata_inputs_changed(previous_default_output);
+
+ self.include_files = include.unwrap_or_default();
+ self.exclude_files = exclude.unwrap_or_default();
+ self.selected_include = None;
+ self.selected_exclude = None;
+
+ let existing_icon_sys = self.icon_sys_existing.clone();
+
+ match icon_sys {
+ Some(icon_cfg) => {
+ self.apply_icon_sys_config(icon_cfg, existing_icon_sys.as_ref());
+ }
+ None => {
+ if let Some(existing_icon_sys) = existing_icon_sys.as_ref() {
+ self.apply_icon_sys_file(existing_icon_sys);
+ } else {
+ self.reset_icon_sys_fields();
+ }
+ }
+ }
+
+ self.psu_toml_sync_blocked = false;
+ self.clear_error_message();
+ self.status = "Applied psu.toml edits in memory.".to_string();
+ true
+ }
+
+ pub(crate) fn apply_title_cfg_edits(&mut self) -> bool {
+ let cfg = TitleCfg::new(self.title_cfg_editor.content.clone());
+ if !cfg.has_mandatory_fields() {
+ self.set_error_message(
+ "title.cfg is missing mandatory fields. Please include the required keys.",
+ );
+ return false;
+ }
+
+ self.clear_error_message();
+ self.status = "Validated title.cfg contents.".to_string();
+ true
+ }
+
+ fn clear_text_editors(&mut self) {
+ #[cfg(feature = "psu-toml-editor")]
+ {
+ self.psu_toml_editor.clear();
+ self.psu_toml_sync_blocked = false;
+ }
+ self.title_cfg_editor.clear();
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ pub(crate) fn create_psu_toml_from_template(&mut self) {
+ self.create_file_from_template(
+ "psu.toml",
+ templates::PSU_TOML_TEMPLATE,
+ EditorTab::PsuToml,
+ );
+ }
+
+ pub(crate) fn create_title_cfg_from_template(&mut self) {
+ self.create_file_from_template(
+ "title.cfg",
+ templates::TITLE_CFG_TEMPLATE,
+ EditorTab::TitleCfg,
+ );
+ }
+
+ fn create_file_from_template(&mut self, file_name: &str, template: &str, tab: EditorTab) {
+ if let Some(folder) = self.folder.clone() {
+ let path = folder.join(file_name);
+ if path.exists() {
+ self.set_error_message(format!(
+ "{} already exists in the selected folder.",
+ path.display()
+ ));
+ return;
+ }
+
+ if let Err(err) = fs::write(&path, template) {
+ self.set_error_message(format!("Failed to create {}: {}", path.display(), err));
+ return;
+ }
+
+ self.status = format!("Created {} from template.", path.display());
+ self.clear_error_message();
+ self.reload_project_files();
+ } else {
+ if let Some(editor) = self.editor_for_text_tab(tab) {
+ editor.set_content(template.to_string());
+ editor.modified = true;
+ self.clear_error_message();
+ self.status = format!(
+ "Loaded default {file_name} template in the editor. Select a folder to save it."
+ );
+ } else {
+ self.set_error_message(format!(
+ "Select a folder before creating {file_name} from the template."
+ ));
+ return;
+ }
+ }
+
+ match tab {
+ EditorTab::PsuSettings => self.open_psu_settings_tab(),
+ #[cfg(feature = "psu-toml-editor")]
+ EditorTab::PsuToml => self.open_psu_toml_tab(),
+ EditorTab::TitleCfg => self.open_title_cfg_tab(),
+ EditorTab::IconSys => self.open_icon_sys_tab(),
+ EditorTab::TimestampAuto => self.open_timestamp_auto_tab(),
+ }
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ fn editor_for_text_tab(&mut self, tab: EditorTab) -> Option<&mut TextFileEditor> {
+ match tab {
+ EditorTab::PsuToml => Some(&mut self.psu_toml_editor),
+ EditorTab::TitleCfg => Some(&mut self.title_cfg_editor),
+ _ => None,
+ }
+ }
+
+ #[cfg(not(feature = "psu-toml-editor"))]
+ fn editor_for_text_tab(&mut self, tab: EditorTab) -> Option<&mut TextFileEditor> {
+ match tab {
+ EditorTab::TitleCfg => Some(&mut self.title_cfg_editor),
+ _ => None,
+ }
+ }
+
+ pub(crate) fn open_psu_settings_tab(&mut self) {
+ self.editor_tab = EditorTab::PsuSettings;
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ pub(crate) fn open_psu_toml_tab(&mut self) {
+ self.editor_tab = EditorTab::PsuToml;
+ }
+
+ pub(crate) fn open_title_cfg_tab(&mut self) {
+ self.editor_tab = EditorTab::TitleCfg;
+ }
+
+ pub(crate) fn open_icon_sys_tab(&mut self) {
+ self.editor_tab = EditorTab::IconSys;
+ }
+
+ pub(crate) fn open_timestamp_auto_tab(&mut self) {
+ self.editor_tab = EditorTab::TimestampAuto;
+ }
+
+ fn has_source(&self) -> bool {
+ self.folder.is_some() || self.loaded_psu_path.is_some() || !self.loaded_psu_files.is_empty()
+ }
+
+ fn showing_loaded_psu(&self) -> bool {
+ self.folder.is_none()
+ && (self.loaded_psu_path.is_some() || !self.loaded_psu_files.is_empty())
+ }
+
+ pub(crate) fn is_pack_running(&self) -> bool {
+ self.pack_job.is_some()
+ }
+
+ #[cfg(not(test))]
+ fn begin_pack_job(
+ &mut self,
+ folder: PathBuf,
+ output_path: PathBuf,
+ config: psu_packer::Config,
+ ) {
+ self.pending_pack_action = None;
+ self.start_pack_job(folder, output_path, config);
+ }
+
+ #[cfg(test)]
+ fn begin_pack_job(
+ &mut self,
+ folder: PathBuf,
+ output_path: PathBuf,
+ config: psu_packer::Config,
+ ) {
+ self.pending_pack_action = None;
+ self.test_pack_job_started = true;
+ self.start_pack_job(folder, output_path, config);
+ }
+
+ pub(crate) fn start_pack_job(
+ &mut self,
+ folder: PathBuf,
+ output_path: PathBuf,
+ config: psu_packer::Config,
+ ) {
+ if self.pack_job.is_some() {
+ return;
+ }
+
+ let progress = Arc::new(Mutex::new(PackProgress::InProgress));
+ let thread_progress = Arc::clone(&progress);
+
+ let handle = thread::spawn(move || {
+ let result =
+ psu_packer::pack_with_config(folder.as_path(), output_path.as_path(), config);
+
+ let outcome = match result {
+ Ok(_) => PackOutcome::Success {
+ output_path: output_path.clone(),
+ },
+ Err(error) => PackOutcome::Error {
+ folder: folder.clone(),
+ output_path: output_path.clone(),
+ error,
+ },
+ };
+
+ let mut guard = thread_progress
+ .lock()
+ .unwrap_or_else(|poison| poison.into_inner());
+ *guard = PackProgress::Finished(outcome);
+ });
+
+ self.status = "Packing…".to_string();
+ self.clear_error_message();
+ self.pack_job = Some(PackJob {
+ progress,
+ handle: Some(handle),
+ });
+ }
+
+ fn pack_progress_value(&self) -> Option {
+ let job = self.pack_job.as_ref()?;
+ let guard = job.progress.lock().ok()?;
+ Some(match &*guard {
+ PackProgress::InProgress => 0.0,
+ PackProgress::Finished(_) => 1.0,
+ })
+ }
+
+ fn poll_pack_job(&mut self) {
+ let Some(mut job) = self.pack_job.take() else {
+ return;
+ };
+
+ let outcome = match job.progress.lock() {
+ Ok(mut guard) => {
+ if let PackProgress::Finished(_) = &*guard {
+ if let PackProgress::Finished(outcome) =
+ std::mem::replace(&mut *guard, PackProgress::InProgress)
+ {
+ Some(outcome)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+ Err(poison) => {
+ let mut guard = poison.into_inner();
+ if let PackProgress::Finished(_) = &*guard {
+ if let PackProgress::Finished(outcome) =
+ std::mem::replace(&mut *guard, PackProgress::InProgress)
+ {
+ Some(outcome)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+ };
+
+ if let Some(outcome) = outcome {
+ if let Some(handle) = job.handle.take() {
+ let _ = handle.join();
+ }
+
+ self.temp_workspace = None;
+
+ match outcome {
+ PackOutcome::Success { output_path } => {
+ self.status = format!("Packed to {}", output_path.display());
+ self.clear_error_message();
+ }
+ PackOutcome::Error {
+ folder,
+ output_path,
+ error,
+ } => {
+ let message = self.format_pack_error(&folder, &output_path, error);
+ self.set_error_message(message);
+ }
+ }
+ } else {
+ self.pack_job = Some(job);
+ }
+ }
+}
+
+fn load_text_file_into_editor(folder: &Path, file_name: &str, editor: &mut TextFileEditor) {
+ let path = folder.join(file_name);
+ match fs::read_to_string(&path) {
+ Ok(content) => {
+ editor.set_content(content);
+ }
+ Err(err) => {
+ if err.kind() == io::ErrorKind::NotFound {
+ editor
+ .set_error_message(format!("{} not found in the selected folder.", file_name));
+ } else {
+ editor.set_error_message(format!("Failed to read {}: {err}", file_name));
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod packer_app_tests {
+ use super::*;
+ use psu_packer::Config as PsuConfig;
+ use std::{path::Path, thread, time::Duration};
+ use tempfile::tempdir;
+
+ fn wait_for_pack_completion(app: &mut PackerApp) {
+ while app.pack_job.is_some() {
+ thread::sleep(Duration::from_millis(10));
+ app.poll_pack_job();
+ }
+ }
+
+ fn write_required_files(folder: &Path) {
+ for file in REQUIRED_PROJECT_FILES {
+ let path = folder.join(file);
+ fs::write(&path, b"data").expect("write required file");
+ }
+ }
+
+ #[test]
+ fn metadata_inputs_fill_missing_psu_filename() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+
+ let mut app = PackerApp::default();
+ app.folder = Some(project_dir.clone());
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name.clear();
+
+ let previous_default = app.default_output_file_name();
+ app.metadata_inputs_changed(previous_default);
+
+ assert_eq!(app.psu_file_base_name, "SAVE");
+ assert!(app.output.ends_with("APP_SAVE.psu"));
+ }
+
+ #[test]
+ fn prepare_pack_inputs_sets_default_output_path() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+ write_required_files(&project_dir);
+
+ let mut app = PackerApp::default();
+ app.folder = Some(project_dir.clone());
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name.clear();
+ app.selected_prefix = SasPrefix::App;
+ app.output.clear();
+
+ let result = app.prepare_pack_inputs();
+ assert!(result.is_some(), "inputs should prepare successfully");
+ assert!(app.output.ends_with("APP_SAVE.psu"));
+ }
+
+ #[test]
+ fn declining_pack_confirmation_keeps_warning_visible() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+
+ let mut app = PackerApp::default();
+ app.folder = Some(project_dir);
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.selected_prefix = SasPrefix::App;
+ app.output = workspace.path().join("output.psu").display().to_string();
+
+ app.handle_pack_request();
+
+ assert!(
+ app.pending_pack_action.is_some(),
+ "confirmation should be pending"
+ );
+ assert!(
+ !app.missing_required_project_files.is_empty(),
+ "missing files should be tracked"
+ );
+
+ let missing_before = app.missing_required_project_files.clone();
+ app.cancel_pending_pack_action();
+
+ assert!(
+ app.pending_pack_action.is_none(),
+ "pending confirmation cleared"
+ );
+ assert_eq!(
+ app.missing_required_project_files, missing_before,
+ "warning about missing files remains visible"
+ );
+ }
+
+ #[test]
+ fn accepting_pack_confirmation_triggers_pack_job() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+
+ let mut app = PackerApp::default();
+ app.folder = Some(project_dir);
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.selected_prefix = SasPrefix::App;
+ app.output = workspace.path().join("output.psu").display().to_string();
+
+ app.handle_pack_request();
+ assert!(
+ app.pending_pack_action.is_some(),
+ "confirmation should be pending"
+ );
+ assert!(!app.test_pack_job_started);
+
+ app.confirm_pending_pack_action();
+
+ assert!(app.pending_pack_action.is_none(), "confirmation accepted");
+ assert!(
+ app.test_pack_job_started,
+ "pack job should start after acceptance"
+ );
+ assert!(app.pack_job.is_some(), "pack job handle should be created");
+
+ wait_for_pack_completion(&mut app);
+ }
+
+ #[test]
+ fn update_psu_overwrites_existing_file() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+ write_required_files(&project_dir);
+
+ let existing_output = workspace.path().join("existing.psu");
+ fs::write(&existing_output, b"old").expect("create placeholder output");
+
+ let mut app = PackerApp::default();
+ app.folder = Some(project_dir);
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.selected_prefix = SasPrefix::App;
+ app.output = existing_output.display().to_string();
+ app.loaded_psu_path = Some(existing_output.clone());
+
+ app.handle_update_psu_request();
+
+ assert!(app.pack_job.is_some(), "pack job should start");
+ wait_for_pack_completion(&mut app);
+
+ assert!(app.error_message.is_none(), "no error after update");
+ assert!(app.status.contains(&existing_output.display().to_string()));
+ let metadata = fs::metadata(&existing_output).expect("output metadata");
+ assert!(metadata.len() > 0, "packed PSU should not be empty");
+ }
+
+ #[test]
+ fn update_psu_reports_missing_destination() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+ write_required_files(&project_dir);
+
+ let missing_output = workspace.path().join("missing.psu");
+
+ let mut app = PackerApp::default();
+ app.folder = Some(project_dir);
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.selected_prefix = SasPrefix::App;
+ app.output = missing_output.display().to_string();
+ app.loaded_psu_path = Some(missing_output.clone());
+
+ app.handle_update_psu_request();
+
+ assert!(app.pack_job.is_none(), "pack job should not start");
+ let message = app.error_message.expect("error message expected");
+ assert!(message.contains("does not exist"));
+ }
+
+ #[test]
+ fn update_loaded_psu_without_project_folder_uses_temporary_workspace() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+ write_required_files(&project_dir);
+
+ let existing_output = workspace.path().join("existing.psu");
+ let config = PsuConfig {
+ name: "APP_SAVE".to_string(),
+ timestamp: None,
+ include: None,
+ exclude: None,
+ icon_sys: None,
+ };
+ psu_packer::pack_with_config(&project_dir, &existing_output, config)
+ .expect("pack source PSU");
+
+ let mut app = PackerApp::default();
+ app.folder = None;
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.selected_prefix = SasPrefix::App;
+ app.output = existing_output.display().to_string();
+ app.loaded_psu_path = Some(existing_output.clone());
+
+ app.handle_update_psu_request();
+
+ assert!(app.pack_job.is_some(), "pack job should start");
+ assert_ne!(
+ app.error_message.as_deref(),
+ Some("Please select a folder"),
+ "loaded PSU update should not emit folder selection error"
+ );
+ assert!(
+ app.folder.is_none(),
+ "temporary workspace should not persist as project folder"
+ );
+
+ wait_for_pack_completion(&mut app);
+
+ assert!(
+ app.error_message.is_none(),
+ "no error after updating loaded PSU"
+ );
+ assert!(
+ app.temp_workspace.is_none(),
+ "temporary workspace should be cleaned up"
+ );
+ }
+
+ #[test]
+ fn export_psu_contents_to_folder() {
+ let workspace = tempdir().expect("temp workspace");
+ let project_dir = workspace.path().join("project");
+ fs::create_dir_all(&project_dir).expect("create project folder");
+ write_required_files(&project_dir);
+ fs::write(project_dir.join("EXTRA.BIN"), b"payload").expect("write extra file");
+
+ let psu_path = workspace.path().join("source.psu");
+ let config = PsuConfig {
+ name: "APP_SAVE".to_string(),
+ timestamp: None,
+ include: None,
+ exclude: None,
+ icon_sys: None,
+ };
+ psu_packer::pack_with_config(&project_dir, &psu_path, config).expect("pack source PSU");
+
+ let export_parent = workspace.path().join("export");
+ fs::create_dir_all(&export_parent).expect("create export parent");
+
+ let app = PackerApp::default();
+ let exported_root = app
+ .export_psu_to_folder(&psu_path, &export_parent)
+ .expect("export succeeds");
+
+ assert_eq!(exported_root, export_parent.join("APP_SAVE"));
+ assert!(
+ !exported_root.join("psu.toml").exists(),
+ "psu.toml should not be embedded in exported PSUs"
+ );
+ assert!(exported_root.join("title.cfg").exists());
+ assert!(exported_root.join("icon.icn").exists());
+ assert!(exported_root.join("EXTRA.BIN").exists());
+ }
+
+ #[test]
+ fn export_psu_fails_for_missing_source() {
+ let workspace = tempdir().expect("temp workspace");
+ let destination = workspace.path();
+ let app = PackerApp::default();
+
+ let result = app.export_psu_to_folder(Path::new("/nonexistent.psu"), destination);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().contains("does not exist"));
+ }
+}
+
+fn save_editor_to_disk(
+ folder: Option<&Path>,
+ file_name: &str,
+ editor: &mut TextFileEditor,
+) -> Result {
+ let folder =
+ folder.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "No folder selected"))?;
+ let path = folder.join(file_name);
+ fs::write(&path, editor.content.as_bytes())?;
+ editor.modified = false;
+ editor.load_error = None;
+ Ok(path)
+}
+
+#[derive(Default)]
+struct TextEditorActions {
+ save_clicked: bool,
+ apply_clicked: bool,
+}
+
+fn editor_action_buttons(
+ ui: &mut egui::Ui,
+ file_name: &str,
+ editing_enabled: bool,
+ save_enabled: bool,
+ editor: &mut TextFileEditor,
+) -> TextEditorActions {
+ let mut actions = TextEditorActions::default();
+
+ if save_enabled {
+ ui.horizontal(|ui| {
+ let button_label = format!("Save {file_name}");
+ if ui
+ .add_enabled(editor.modified, egui::Button::new(button_label))
+ .clicked()
+ {
+ actions.save_clicked = true;
+ }
+
+ if editor.modified {
+ if ui
+ .add_enabled(
+ editor.modified,
+ egui::Button::new(format!("Apply {file_name}")),
+ )
+ .clicked()
+ {
+ actions.apply_clicked = true;
+ }
+ ui.colored_label(egui::Color32::YELLOW, "Unsaved changes");
+ }
+ });
+ } else if editing_enabled {
+ if editor.modified {
+ ui.horizontal(|ui| {
+ if ui
+ .add_enabled(
+ editor.modified,
+ egui::Button::new(format!("Apply {file_name}")),
+ )
+ .clicked()
+ {
+ actions.apply_clicked = true;
+ }
+ ui.colored_label(egui::Color32::YELLOW, "Unsaved changes");
+ });
+ }
+ ui.label(
+ egui::RichText::new(format!(
+ "Edits to {file_name} are kept in memory. Select a folder when you're ready to save them to disk."
+ ))
+ .italics(),
+ );
+ } else {
+ ui.label(format!(
+ "Select a folder or open a PSU to edit {file_name}."
+ ));
+ }
+
+ actions
+}
+
+#[cfg(feature = "psu-toml-editor")]
+fn text_editor_ui(
+ ui: &mut egui::Ui,
+ file_name: &str,
+ editing_enabled: bool,
+ save_enabled: bool,
+ editor: &mut TextFileEditor,
+) -> TextEditorActions {
+ if let Some(message) = &editor.load_error {
+ ui.colored_label(egui::Color32::YELLOW, message);
+ ui.add_space(8.0);
+ }
+
+ let show_editor = editing_enabled || !editor.content.is_empty();
+
+ if show_editor {
+ let response = egui::ScrollArea::vertical()
+ .id_source(format!("{file_name}_editor_scroll"))
+ .show(ui, |ui| {
+ ui.add_enabled(
+ editing_enabled,
+ egui::TextEdit::multiline(&mut editor.content)
+ .desired_rows(20)
+ .code_editor(),
+ )
+ })
+ .inner;
+
+ if editing_enabled && response.changed() {
+ editor.modified = true;
+ }
+ }
+
+ ui.add_space(8.0);
+ editor_action_buttons(ui, file_name, editing_enabled, save_enabled, editor)
+}
+
+fn title_cfg_form_ui(
+ ui: &mut egui::Ui,
+ editing_enabled: bool,
+ save_enabled: bool,
+ editor: &mut TextFileEditor,
+) -> TextEditorActions {
+ if let Some(message) = &editor.load_error {
+ ui.colored_label(egui::Color32::YELLOW, message);
+ ui.add_space(8.0);
+ }
+
+ let show_form = editing_enabled || !editor.content.is_empty();
+
+ if show_form {
+ let previous_content = editor.content.clone();
+ let mut cfg = TitleCfg::new(editor.content.clone());
+ let helper = cfg.helper.clone();
+
+ let mut keys: Vec = cfg.index_map.keys().cloned().collect();
+ let mut seen_keys: HashSet = keys.iter().cloned().collect();
+ for key in helper.keys() {
+ if seen_keys.insert(key.clone()) {
+ keys.push(key.clone());
+ }
+ }
+
+ let missing_fields = cfg.missing_mandatory_fields();
+ let missing_field_set: HashSet<&str> = missing_fields.iter().copied().collect();
+
+ let mut section_lookup: HashMap<&'static str, usize> = HashMap::new();
+ for (index, (_, field_keys)) in TITLE_CFG_SECTIONS.iter().enumerate() {
+ for key in *field_keys {
+ section_lookup.insert(*key, index);
+ }
+ }
+
+ let mut section_fields: Vec> = vec![Vec::new(); TITLE_CFG_SECTIONS.len()];
+ let mut additional_fields: Vec = Vec::new();
+ for key in &keys {
+ if let Some(&index) = section_lookup.get(key.as_str()) {
+ section_fields[index].push(key.clone());
+ } else {
+ additional_fields.push(key.clone());
+ }
+ }
+
+ let mut index_map_changed = false;
+
+ egui::ScrollArea::vertical()
+ .id_source("title_cfg_form_scroll")
+ .show(ui, |ui| {
+ ui::centered_column(ui, CENTERED_COLUMN_MAX_WIDTH, |ui| {
+ if !missing_fields.is_empty() {
+ let message =
+ format!("Missing mandatory fields: {}", missing_fields.join(", "));
+ ui.colored_label(egui::Color32::YELLOW, message);
+ ui.add_space(8.0);
+ }
+
+ let mut render_fields =
+ |ui: &mut egui::Ui, grid_id: String, section_keys: &[String]| {
+ egui::Grid::new(grid_id)
+ .num_columns(2)
+ .spacing(TITLE_CFG_GRID_SPACING)
+ .striped(true)
+ .show(ui, |ui| {
+ for key in section_keys {
+ let mut tooltip: Option = None;
+ let mut hint: Option = None;
+ let mut values: Option> = None;
+ let mut char_limit: Option = None;
+ let mut multiline = false;
+
+ if let Some(table) =
+ helper.get(key).and_then(|value| value.as_table())
+ {
+ tooltip = table
+ .get("tooltip")
+ .and_then(|value| value.as_str())
+ .map(|s| s.to_owned());
+ hint = table
+ .get("hint")
+ .and_then(|value| value.as_str())
+ .map(|s| s.to_owned());
+ if let Some(array) = table
+ .get("values")
+ .and_then(|value| value.as_array())
+ {
+ let options: Vec = array
+ .iter()
+ .filter_map(|value| {
+ value.as_str().map(|s| s.to_owned())
+ })
+ .collect();
+ if !options.is_empty() {
+ values = Some(options);
+ }
+ }
+ char_limit = table
+ .get("char_limit")
+ .and_then(|value| value.as_integer())
+ .and_then(|value| {
+ (value >= 0).then(|| value as usize)
+ });
+ multiline = table
+ .get("multiline")
+ .and_then(|value| value.as_bool())
+ .unwrap_or(false);
+ }
+
+ let mut label_text = egui::RichText::new(key.as_str());
+ if missing_field_set.contains(key.as_str()) {
+ label_text = label_text.color(egui::Color32::YELLOW);
+ }
+ let label = ui.label(label_text);
+ if let Some(tooltip) = &tooltip {
+ label.on_hover_text(tooltip);
+ }
+
+ let existing_value =
+ cfg.index_map.get(key).cloned().unwrap_or_default();
+ let mut new_value = existing_value.clone();
+ let mut field_changed = false;
+
+ if let Some(options) = values.as_ref() {
+ let display_text = if new_value.is_empty() {
+ hint.clone()
+ .unwrap_or_else(|| "(not set)".to_string())
+ } else {
+ new_value.clone()
+ };
+ if editing_enabled {
+ let response = egui::ComboBox::from_id_source(
+ format!("title_cfg_option_{key}"),
+ )
+ .selected_text(display_text.clone())
+ .show_ui(ui, |ui| {
+ ui.selectable_value(
+ &mut new_value,
+ String::new(),
+ "(not set)",
+ );
+ for option in options {
+ ui.selectable_value(
+ &mut new_value,
+ option.clone(),
+ option,
+ );
+ }
+ });
+ if let Some(tooltip) = &tooltip {
+ response.response.on_hover_text(tooltip);
+ }
+ if new_value != existing_value {
+ field_changed = true;
+ }
+ } else {
+ let response = ui.label(display_text);
+ if let Some(tooltip) = &tooltip {
+ response.on_hover_text(tooltip);
+ }
+ }
+ } else {
+ let mut text_edit = if multiline {
+ egui::TextEdit::multiline(&mut new_value)
+ .desired_rows(TITLE_CFG_MULTILINE_ROWS)
+ .desired_width(f32::INFINITY)
+ } else {
+ egui::TextEdit::singleline(&mut new_value)
+ };
+ if let Some(hint) = &hint {
+ text_edit = text_edit.hint_text(hint.clone());
+ }
+ if let Some(limit) = char_limit {
+ text_edit = text_edit.char_limit(limit);
+ }
+ let response =
+ ui.add_enabled(editing_enabled, text_edit);
+ let changed = editing_enabled
+ && response.changed()
+ && new_value != existing_value;
+ if let Some(tooltip) = &tooltip {
+ response.on_hover_text(tooltip);
+ }
+ if changed {
+ field_changed = true;
+ }
+ }
+
+ if editing_enabled && field_changed {
+ cfg.index_map.insert(key.clone(), new_value);
+ index_map_changed = true;
+ }
+
+ ui.end_row();
+ }
+ });
+ };
+
+ let mut rendered_section = false;
+ for (index, (title, _)) in TITLE_CFG_SECTIONS.iter().enumerate() {
+ let section_keys = §ion_fields[index];
+ if section_keys.is_empty() {
+ continue;
+ }
+ if rendered_section {
+ ui.add_space(TITLE_CFG_SECTION_GAP);
+ }
+ rendered_section = true;
+ ui.heading(theme::display_heading_text(ui, *title));
+ ui.add_space(TITLE_CFG_SECTION_HEADING_GAP);
+ render_fields(ui, format!("title_cfg_form_grid_{title}"), section_keys);
+ }
+
+ if !additional_fields.is_empty() {
+ if rendered_section {
+ ui.add_space(TITLE_CFG_SECTION_GAP);
+ }
+ ui.heading(theme::display_heading_text(ui, "Additional fields"));
+ ui.add_space(TITLE_CFG_SECTION_HEADING_GAP);
+ render_fields(
+ ui,
+ "title_cfg_form_grid_additional".to_string(),
+ &additional_fields,
+ );
+ }
+ });
+ });
+
+ if index_map_changed {
+ cfg.sync_index_map_to_contents();
+ if cfg.contents != previous_content {
+ editor.content = cfg.contents.clone();
+ editor.modified = true;
+ }
+ }
+ }
+
+ ui.add_space(8.0);
+ editor_action_buttons(ui, "title.cfg", editing_enabled, save_enabled, editor)
+}
+
+impl eframe::App for PackerApp {
+ fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ self.poll_pack_job();
+
+ if ctx.input(|i| i.viewport().close_requested()) && !self.exit_confirmed {
+ self.exit_confirmed = false;
+ self.show_exit_confirm = true;
+ ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
+ }
+
+ self.zoom_factor = self.zoom_factor.clamp(0.5, 2.0);
+ ctx.set_pixels_per_point(self.zoom_factor);
+
+ let source_present = self.has_source();
+ if !source_present && self.source_present_last_frame {
+ self.reset_metadata_fields();
+ }
+ self.source_present_last_frame = source_present;
+
+ if let Some(progress) = self.pack_progress_value() {
+ ctx.request_repaint();
+ egui::Window::new("packing_progress")
+ .title_bar(false)
+ .collapsible(false)
+ .resizable(false)
+ .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
+ .frame(egui::Frame::popup(&ctx.style()))
+ .show(ctx, |ui| {
+ ui.vertical_centered(|ui| {
+ ui.label(
+ egui::RichText::new("Packing PSU…")
+ .font(theme::display_font(26.0))
+ .color(self.theme.neon_accent),
+ );
+ ui.add_space(8.0);
+ ui.add(
+ egui::ProgressBar::new(progress)
+ .desired_width(200.0)
+ .animate(true),
+ );
+ });
+ });
+ }
+
+ egui::TopBottomPanel::top("top_panel")
+ .frame(egui::Frame::none().fill(self.theme.background))
+ .show(ctx, |ui| {
+ let rect = ui.max_rect();
+ theme::draw_vertical_gradient(
+ ui.painter(),
+ rect,
+ self.theme.header_top,
+ self.theme.header_bottom,
+ );
+ let separator_rect =
+ egui::Rect::from_min_max(egui::pos2(rect.min.x, rect.max.y - 2.0), rect.max);
+ theme::draw_separator(ui.painter(), separator_rect, self.theme.separator);
+ egui::menu::bar(ui, |ui| {
+ ui::file_picker::file_menu(self, ui);
+ ui.menu_button("View", |ui| {
+ if ui.button("Zoom In").clicked() {
+ self.zoom_factor = (self.zoom_factor + 0.1).min(2.0);
+ ui.close_menu();
+ }
+ if ui.button("Zoom Out").clicked() {
+ self.zoom_factor = (self.zoom_factor - 0.1).max(0.5);
+ ui.close_menu();
+ }
+ if ui.button("Reset Zoom").clicked() {
+ self.zoom_factor = 1.0;
+ ui.close_menu();
+ }
+ });
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.add_space(12.0);
+ let zoom_text = format!("Zoom: {:.0}%", self.zoom_factor * 100.0);
+ ui.label(egui::RichText::new(zoom_text).color(self.theme.neon_accent));
+ });
+ });
+ });
+
+ egui::CentralPanel::default()
+ .frame(egui::Frame::none().fill(self.theme.background))
+ .show(ctx, |ui| {
+ let rect = ui.max_rect();
+ theme::draw_vertical_gradient(
+ ui.painter(),
+ rect,
+ self.theme.footer_top,
+ self.theme.footer_bottom,
+ );
+ let top_separator =
+ egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, rect.min.y + 2.0));
+ theme::draw_separator(ui.painter(), top_separator, self.theme.separator);
+ let bottom_separator =
+ egui::Rect::from_min_max(egui::pos2(rect.min.x, rect.max.y - 2.0), rect.max);
+ theme::draw_separator(ui.painter(), bottom_separator, self.theme.separator);
+ ui.add_space(8.0);
+
+ let tab_font = theme::display_font(18.0);
+ let tab_bar = ui.horizontal_wrapped(|ui| {
+ let spacing = ui.spacing_mut();
+ spacing.item_spacing.x = 12.0;
+ spacing.item_spacing.y = 8.0;
+
+ self.editor_tab_button(
+ ui,
+ EditorTab::PsuSettings,
+ "PSU Settings",
+ false,
+ &tab_font,
+ );
+
+ #[cfg(feature = "psu-toml-editor")]
+ {
+ let psu_toml_label = if self.psu_toml_editor.modified {
+ "psu.toml*"
+ } else {
+ "psu.toml"
+ };
+ self.editor_tab_button(
+ ui,
+ EditorTab::PsuToml,
+ psu_toml_label,
+ self.psu_toml_editor.modified,
+ &tab_font,
+ );
+ }
+
+ let title_cfg_label = if self.title_cfg_editor.modified {
+ "title.cfg*"
+ } else {
+ "title.cfg"
+ };
+ self.editor_tab_button(
+ ui,
+ EditorTab::TitleCfg,
+ title_cfg_label,
+ self.title_cfg_editor.modified,
+ &tab_font,
+ );
+
+ self.editor_tab_button(ui, EditorTab::IconSys, "icon.sys", false, &tab_font);
+
+ let timestamp_label = if self.timestamp_rules_modified {
+ "Timestamp rules*"
+ } else {
+ "Timestamp rules"
+ };
+ self.editor_tab_button(
+ ui,
+ EditorTab::TimestampAuto,
+ timestamp_label,
+ self.timestamp_rules_modified,
+ &tab_font,
+ );
+ });
+
+ let tab_rect = tab_bar.response.rect;
+ let tab_separator = egui::Rect::from_min_max(
+ egui::pos2(rect.min.x, tab_rect.max.y + 4.0),
+ egui::pos2(rect.max.x, tab_rect.max.y + 6.0),
+ );
+ theme::draw_separator(ui.painter(), tab_separator, self.theme.separator);
+ ui.add_space(10.0);
+
+ match self.editor_tab {
+ EditorTab::PsuSettings => {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ ui::centered_column(ui, CENTERED_COLUMN_MAX_WIDTH, |ui| {
+ ui::file_picker::folder_section(self, ui);
+
+ let showing_psu = self.showing_loaded_psu();
+ if showing_psu {
+ ui.add_space(8.0);
+ ui::file_picker::loaded_psu_section(self, ui);
+ }
+
+ ui.add_space(8.0);
+
+ let two_column_layout =
+ ui.available_width() >= PACK_CONTROLS_TWO_COLUMN_MIN_WIDTH;
+ if two_column_layout {
+ ui.columns(2, |columns| {
+ columns[0].vertical(|ui| {
+ ui::pack_controls::metadata_section(self, ui);
+ ui.add_space(8.0);
+ ui::pack_controls::output_section(self, ui);
+ });
+
+ columns[1].vertical(|ui| {
+ if !showing_psu {
+ ui::pack_controls::file_filters_section(self, ui);
+ ui.add_space(8.0);
+ }
+ ui::pack_controls::packaging_section(self, ui);
+ });
+ });
+ } else {
+ ui::pack_controls::metadata_section(self, ui);
+
+ if !showing_psu {
+ ui.add_space(8.0);
+ ui::pack_controls::file_filters_section(self, ui);
+ }
+
+ ui.add_space(8.0);
+ ui::pack_controls::output_section(self, ui);
+
+ ui.add_space(8.0);
+ ui::pack_controls::packaging_section(self, ui);
+ }
+ });
+ });
+ }
+ #[cfg(feature = "psu-toml-editor")]
+ EditorTab::PsuToml => {
+ let editing_enabled = true; // Allow editing even without a source selection.
+ let save_enabled = self.folder.is_some();
+ let actions = text_editor_ui(
+ ui,
+ "psu.toml",
+ editing_enabled,
+ save_enabled,
+ &mut self.psu_toml_editor,
+ );
+ if actions.save_clicked {
+ match save_editor_to_disk(
+ self.folder.as_deref(),
+ "psu.toml",
+ &mut self.psu_toml_editor,
+ ) {
+ Ok(path) => {
+ self.status = format!("Saved {}", path.display());
+ self.clear_error_message();
+ }
+ Err(err) => {
+ self.set_error_message(format!(
+ "Failed to save psu.toml: {err}"
+ ));
+ }
+ }
+ }
+ if actions.apply_clicked {
+ self.apply_psu_toml_edits();
+ }
+ }
+ EditorTab::TitleCfg => {
+ let editing_enabled = true; // Allow editing even without a source selection.
+ let save_enabled = self.folder.is_some();
+ let actions = title_cfg_form_ui(
+ ui,
+ editing_enabled,
+ save_enabled,
+ &mut self.title_cfg_editor,
+ );
+ if actions.save_clicked {
+ match save_editor_to_disk(
+ self.folder.as_deref(),
+ "title.cfg",
+ &mut self.title_cfg_editor,
+ ) {
+ Ok(path) => {
+ self.status = format!("Saved {}", path.display());
+ self.clear_error_message();
+ }
+ Err(err) => {
+ self.set_error_message(format!(
+ "Failed to save title.cfg: {err}"
+ ));
+ }
+ }
+ }
+ if actions.apply_clicked {
+ self.apply_title_cfg_edits();
+ }
+ }
+ EditorTab::IconSys => {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ ui::centered_column(ui, CENTERED_COLUMN_MAX_WIDTH, |ui| {
+ ui::icon_sys::icon_sys_editor(self, ui);
+ });
+ });
+ }
+ EditorTab::TimestampAuto => {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ ui::centered_column(ui, CENTERED_COLUMN_MAX_WIDTH, |ui| {
+ ui::timestamps::timestamp_rules_editor(self, ui);
+ });
+ });
+ }
+ }
+ });
+
+ ui::dialogs::pack_confirmation(self, ctx);
+ ui::dialogs::exit_confirmation(self, ctx);
+ }
+}
+
+struct EditorTabWidget<'a> {
+ label: &'a str,
+ font: egui::FontId,
+ theme: &'a theme::Palette,
+ is_selected: bool,
+ alert: bool,
+}
+
+impl<'a> EditorTabWidget<'a> {
+ fn new(
+ label: &'a str,
+ font: egui::FontId,
+ theme: &'a theme::Palette,
+ is_selected: bool,
+ alert: bool,
+ ) -> Self {
+ Self {
+ label,
+ font,
+ theme,
+ is_selected,
+ alert,
+ }
+ }
+}
+
+impl<'a> Widget for EditorTabWidget<'a> {
+ fn ui(self, ui: &mut egui::Ui) -> egui::Response {
+ let base_padding = egui::vec2(12.0, 6.0);
+ let hover_extra = egui::vec2(2.0, 2.0);
+ let selected_extra = egui::vec2(4.0, 4.0);
+ let max_padding = base_padding + selected_extra;
+ let rounding = egui::CornerRadius::same(10);
+
+ let mut text_color = self.theme.text_primary;
+ if self.is_selected {
+ text_color = egui::Color32::WHITE;
+ } else if self.alert {
+ text_color = self.theme.neon_accent;
+ }
+
+ let galley = ui.fonts(|fonts| {
+ fonts.layout_no_wrap(self.label.to_owned(), self.font.clone(), text_color)
+ });
+ let desired_size = galley.size() + max_padding * 2.0;
+
+ let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
+
+ if ui.is_rect_visible(rect) {
+ let mut padding = base_padding;
+ if response.hovered() {
+ padding += hover_extra;
+ }
+ if self.is_selected {
+ padding += selected_extra;
+ }
+
+ let fill = if self.is_selected {
+ self.theme.neon_accent.gamma_multiply(0.45)
+ } else if response.hovered() {
+ self.theme.soft_accent.gamma_multiply(0.38)
+ } else if self.alert {
+ self.theme.neon_accent.gamma_multiply(0.24)
+ } else {
+ self.theme.soft_accent.gamma_multiply(0.24)
+ };
+
+ let mut stroke_color = self.theme.soft_accent.gamma_multiply(0.7);
+ if self.is_selected {
+ stroke_color = self.theme.neon_accent;
+ } else if self.alert || response.hovered() {
+ stroke_color = self.theme.neon_accent.gamma_multiply(0.8);
+ }
+
+ ui.painter().rect_filled(rect, rounding, fill);
+ ui.painter().rect_stroke(
+ rect,
+ rounding,
+ egui::Stroke::new(1.0, stroke_color),
+ egui::StrokeKind::Outside,
+ );
+
+ let text_pos = rect.left_top() + padding;
+ ui.painter().galley(text_pos, galley, text_color);
+ }
+
+ response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
+ let enabled = response.enabled();
+ response.widget_info(|| {
+ egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, self.label)
+ });
+ response
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ps2_filetypes::sjis;
+ use psu_packer::IconSysFlags;
+ use std::fs;
+ use tempfile::tempdir;
+
+ #[cfg(feature = "psu-toml-editor")]
+ #[test]
+ fn manual_edits_persist_without_folder_selection() {
+ let mut app = PackerApp::default();
+ app.open_psu_toml_tab();
+
+ app.psu_toml_editor
+ .set_content("custom configuration".to_string());
+ app.psu_toml_editor.modified = true;
+
+ let ctx = egui::Context::default();
+
+ let _ = ctx.run(Default::default(), |ctx| {
+ egui::CentralPanel::default().show(ctx, |ui| {
+ let actions = text_editor_ui(
+ ui,
+ "psu.toml",
+ true,
+ app.folder.is_some(),
+ &mut app.psu_toml_editor,
+ );
+ assert!(!actions.save_clicked);
+ assert!(!actions.apply_clicked);
+ });
+ });
+
+ assert_eq!(app.psu_toml_editor.content, "custom configuration");
+ assert!(app.psu_toml_editor.modified);
+
+ app.open_title_cfg_tab();
+ app.title_cfg_editor
+ .set_content("title settings".to_string());
+ app.title_cfg_editor.modified = true;
+
+ let _ = ctx.run(Default::default(), |ctx| {
+ egui::CentralPanel::default().show(ctx, |ui| {
+ let actions =
+ title_cfg_form_ui(ui, true, app.folder.is_some(), &mut app.title_cfg_editor);
+ assert!(!actions.save_clicked);
+ assert!(!actions.apply_clicked);
+ });
+ });
+
+ assert_eq!(app.psu_toml_editor.content, "custom configuration");
+ assert!(app.psu_toml_editor.modified);
+
+ app.open_psu_toml_tab();
+
+ let _ = ctx.run(Default::default(), |ctx| {
+ egui::CentralPanel::default().show(ctx, |ui| {
+ let actions = text_editor_ui(
+ ui,
+ "psu.toml",
+ true,
+ app.folder.is_some(),
+ &mut app.psu_toml_editor,
+ );
+ assert!(!actions.save_clicked);
+ assert!(!actions.apply_clicked);
+ });
+ });
+
+ assert_eq!(app.psu_toml_editor.content, "custom configuration");
+ assert!(app.psu_toml_editor.modified);
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ #[test]
+ fn apply_psu_toml_updates_state_without_disk() {
+ let mut app = PackerApp::default();
+ let timestamp = "2023-05-17 08:30:00";
+ app.psu_toml_editor.content = format!(
+ r#"[config]
+name = "APP_Custom Save"
+timestamp = "{timestamp}"
+include = ["BOOT.ELF", "DATA.BIN"]
+exclude = ["IGNORE.DAT"]
+
+[icon_sys]
+flags = 1
+title = "HELLOWORLD"
+linebreak_pos = 5
+"#
+ );
+ app.psu_toml_editor.modified = true;
+
+ assert!(app.apply_psu_toml_edits());
+
+ assert_eq!(app.selected_prefix, SasPrefix::App);
+ assert_eq!(app.folder_base_name, "Custom Save");
+ assert_eq!(app.psu_file_base_name, "Custom Save");
+ assert_eq!(app.include_files, vec!["BOOT.ELF", "DATA.BIN"]);
+ assert_eq!(app.exclude_files, vec!["IGNORE.DAT"]);
+ let expected_timestamp =
+ NaiveDateTime::parse_from_str(timestamp, TIMESTAMP_FORMAT).unwrap();
+ assert_eq!(app.timestamp, Some(expected_timestamp));
+ assert_eq!(app.timestamp_strategy, TimestampStrategy::Manual);
+ assert!(app.icon_sys_enabled);
+ assert!(matches!(
+ app.icon_sys_flag_selection,
+ IconFlagSelection::Preset(1)
+ ));
+ assert_eq!(app.icon_sys_custom_flag, 1);
+ assert_eq!(app.icon_sys_title_line1, "HELLO");
+ assert_eq!(app.icon_sys_title_line2, "WORLD");
+ assert!(!app.psu_toml_sync_blocked);
+ assert!(app.psu_toml_editor.modified);
+ }
+
+ #[test]
+ fn apply_icon_sys_file_preserves_multibyte_characters() {
+ let mut app = PackerApp::default();
+ let title = "メモリーカード";
+
+ let icon_sys = IconSys {
+ flags: 4,
+ linebreak_pos: sjis::encode_sjis("メモリー").unwrap().len() as u16,
+ background_transparency: IconSysConfig::default_background_transparency(),
+ background_colors: IconSysConfig::default_background_colors().map(Into::into),
+ light_directions: IconSysConfig::default_light_directions().map(Into::into),
+ light_colors: IconSysConfig::default_light_colors().map(Into::into),
+ ambient_color: IconSysConfig::default_ambient_color().into(),
+ title: title.to_string(),
+ icon_file: "icon.icn".to_string(),
+ icon_copy_file: "icon.icn".to_string(),
+ icon_delete_file: "icon.icn".to_string(),
+ };
+
+ app.apply_icon_sys_file(&icon_sys);
+
+ assert_eq!(app.icon_sys_title_line1, "メモリー");
+ assert_eq!(app.icon_sys_title_line2, "カード");
+ }
+
+ #[test]
+ fn apply_icon_sys_config_preserves_multibyte_characters() {
+ let mut app = PackerApp::default();
+ let title = "メモリーカード";
+
+ let icon_cfg = IconSysConfig {
+ flags: IconSysFlags::new(1),
+ title: title.to_string(),
+ linebreak_pos: Some(sjis::encode_sjis("メモリー").unwrap().len() as u16),
+ preset: None,
+ background_transparency: None,
+ background_colors: None,
+ light_directions: None,
+ light_colors: None,
+ ambient_color: None,
+ };
+
+ app.apply_icon_sys_config(icon_cfg, None);
+
+ assert_eq!(app.icon_sys_title_line1, "メモリー");
+ assert_eq!(app.icon_sys_title_line2, "カード");
+ }
+
+ #[test]
+ fn load_project_files_reads_uppercase_icon_sys() {
+ use ps2_filetypes::{color::Color, ColorF, Vector};
+
+ let temp_dir = tempdir().expect("temporary directory");
+ let folder = temp_dir.path();
+
+ let config = psu_packer::Config {
+ name: "APP_Test Save".to_string(),
+ timestamp: None,
+ include: None,
+ exclude: None,
+ icon_sys: None,
+ };
+ let config_toml = config.to_toml_string().expect("serialize minimal psu.toml");
+ fs::write(folder.join("psu.toml"), config_toml).expect("write psu.toml");
+ fs::write(folder.join("title.cfg"), "title=Test Save\n").expect("write title.cfg");
+
+ let icon_sys = IconSys {
+ flags: 1,
+ linebreak_pos: 5,
+ background_transparency: 0,
+ background_colors: [Color::WHITE; 4],
+ light_directions: [
+ Vector {
+ x: 0.0,
+ y: 0.0,
+ z: 1.0,
+ w: 0.0,
+ },
+ Vector {
+ x: 0.0,
+ y: 1.0,
+ z: 0.0,
+ w: 0.0,
+ },
+ Vector {
+ x: 1.0,
+ y: 0.0,
+ z: 0.0,
+ w: 0.0,
+ },
+ ],
+ light_colors: [
+ ColorF {
+ r: 1.0,
+ g: 1.0,
+ b: 1.0,
+ a: 1.0,
+ },
+ ColorF {
+ r: 0.5,
+ g: 0.5,
+ b: 0.5,
+ a: 1.0,
+ },
+ ColorF {
+ r: 0.25,
+ g: 0.25,
+ b: 0.25,
+ a: 1.0,
+ },
+ ],
+ ambient_color: ColorF {
+ r: 0.1,
+ g: 0.2,
+ b: 0.3,
+ a: 1.0,
+ },
+ title: "HELLOWORLD".to_string(),
+ icon_file: "icon.icn".to_string(),
+ icon_copy_file: "icon.icn".to_string(),
+ icon_delete_file: "icon.icn".to_string(),
+ };
+ let icon_bytes = icon_sys.to_bytes().expect("serialize icon.sys");
+ fs::write(folder.join("ICON.SYS"), icon_bytes).expect("write ICON.SYS");
+
+ let mut app = PackerApp::default();
+ crate::ui::file_picker::load_project_files(&mut app, folder);
+
+ assert!(app.icon_sys_existing.is_some());
+ assert!(app.icon_sys_use_existing);
+ assert_eq!(app.icon_sys_title_line1, "HELLO");
+ assert_eq!(app.icon_sys_title_line2, "WORLD");
+ }
+
+ #[test]
+ fn split_icon_sys_title_replaces_control_characters() {
+ let (line1, line2) = split_icon_sys_title("A\u{0001}B\rC", 3);
+
+ assert_eq!(
+ line1,
+ format!("A{}B", ICON_SYS_UNSUPPORTED_CHAR_PLACEHOLDER)
+ );
+ assert_eq!(line2, format!("{}C", ICON_SYS_UNSUPPORTED_CHAR_PLACEHOLDER));
+ }
+
+ #[test]
+ fn split_icon_sys_title_uses_byte_based_breaks_for_multibyte_titles() {
+ let title = "メモリーカード";
+ let break_bytes = sjis::encode_sjis("メモリー").unwrap().len();
+
+ let (line1, line2) = split_icon_sys_title(title, break_bytes);
+
+ assert_eq!(line1, "メモリー");
+ assert_eq!(line2, "カード");
+ }
+
+ #[test]
+ fn split_icon_sys_title_preserves_second_line_for_partial_multibyte_breaks() {
+ let title = "メモリーカード";
+ let break_bytes = sjis::encode_sjis("メモ").unwrap().len() + 1;
+
+ let (line1, line2) = split_icon_sys_title(title, break_bytes);
+
+ assert_eq!(line1, "メモ");
+ assert_eq!(line2, "リーカード");
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ #[test]
+ fn apply_invalid_psu_toml_reports_error() {
+ let mut app = PackerApp::default();
+ app.psu_toml_editor.content = "[config".to_string();
+ app.psu_toml_editor.modified = true;
+
+ assert!(!app.apply_psu_toml_edits());
+ assert!(app
+ .error_message
+ .as_ref()
+ .is_some_and(|message| message.contains("Failed to")));
+ }
+
+ #[test]
+ fn apply_title_cfg_validates_contents() {
+ let mut app = PackerApp::default();
+ app.title_cfg_editor.content = templates::TITLE_CFG_TEMPLATE.to_string();
+ app.title_cfg_editor.modified = true;
+
+ assert!(app.apply_title_cfg_edits());
+ assert_eq!(app.status, "Validated title.cfg contents.");
+ assert!(app.error_message.is_none());
+ }
+
+ #[test]
+ fn apply_title_cfg_reports_missing_fields() {
+ let mut app = PackerApp::default();
+ app.title_cfg_editor.content = "title=Example".to_string();
+ app.title_cfg_editor.modified = true;
+
+ assert!(!app.apply_title_cfg_edits());
+ assert!(app
+ .error_message
+ .as_ref()
+ .is_some_and(|message| message.contains("missing mandatory")));
+ }
+
+ #[test]
+ fn load_warning_flags_missing_required_files() {
+ let temp_dir = tempdir().expect("temporary directory");
+ for file in REQUIRED_PROJECT_FILES {
+ let path = temp_dir.path().join(file);
+ fs::write(&path, b"placeholder").expect("create required file");
+ }
+
+ let mut app = PackerApp::default();
+ app.folder = Some(temp_dir.path().to_path_buf());
+
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+
+ for file in REQUIRED_PROJECT_FILES {
+ let path = temp_dir.path().join(file);
+ fs::remove_file(&path).expect("remove required file");
+ app.refresh_missing_required_project_files();
+ assert_eq!(
+ app.missing_required_project_files,
+ vec![MissingRequiredFile::always(file)]
+ );
+ fs::write(&path, b"placeholder").expect("restore required file");
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+ }
+
+ // Optional files should only be required when their features are enabled.
+ app.include_files.push("BOOT.ELF".to_string());
+ app.refresh_missing_required_project_files();
+ assert_eq!(
+ app.missing_required_project_files,
+ vec![MissingRequiredFile::included("BOOT.ELF")]
+ );
+
+ let boot_path = temp_dir.path().join("BOOT.ELF");
+ fs::write(&boot_path, b"boot").expect("create BOOT.ELF");
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+
+ let timestamp_path = temp_dir.path().join(TIMESTAMP_RULES_FILE);
+ if timestamp_path.exists() {
+ fs::remove_file(×tamp_path).expect("remove timestamp rules");
+ }
+
+ app.timestamp_strategy = TimestampStrategy::SasRules;
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+
+ app.mark_timestamp_rules_modified();
+ app.refresh_missing_required_project_files();
+ assert_eq!(
+ app.missing_required_project_files,
+ vec![MissingRequiredFile::timestamp_rules()]
+ );
+
+ app.timestamp_rules_modified = false;
+ app.timestamp_rules_loaded_from_file = false;
+
+ fs::write(×tamp_path, b"{}").expect("create timestamp rules");
+ app.load_timestamp_rules_from_folder(temp_dir.path());
+ fs::remove_file(×tamp_path).expect("remove timestamp rules");
+ app.refresh_missing_required_project_files();
+ assert_eq!(
+ app.missing_required_project_files,
+ vec![MissingRequiredFile::timestamp_rules()]
+ );
+
+ fs::write(×tamp_path, b"{}").expect("restore timestamp rules");
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+ }
+
+ #[test]
+ fn pack_request_blocks_missing_required_files() {
+ let temp_dir = tempdir().expect("temporary directory");
+ for file in REQUIRED_PROJECT_FILES {
+ let path = temp_dir.path().join(file);
+ fs::write(&path, b"placeholder").expect("create required file");
+ }
+
+ let mut app = PackerApp::default();
+ app.folder = Some(temp_dir.path().to_path_buf());
+ app.folder_base_name = "Sample".to_string();
+ app.psu_file_base_name = "Sample".to_string();
+ app.output = temp_dir.path().join("Sample.psu").display().to_string();
+
+ for file in REQUIRED_PROJECT_FILES {
+ let path = temp_dir.path().join(file);
+ fs::remove_file(&path).expect("remove required file");
+ app.handle_pack_request();
+ let error = app
+ .error_message
+ .as_ref()
+ .expect("missing files should block packing");
+ assert!(error.contains(file));
+ assert_eq!(
+ app.missing_required_project_files,
+ vec![MissingRequiredFile::always(file)]
+ );
+ fs::write(&path, b"placeholder").expect("restore required file");
+ app.clear_error_message();
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+ }
+
+ // BOOT.ELF becomes required when referenced in the include list.
+ let boot_path = temp_dir.path().join("BOOT.ELF");
+ if boot_path.exists() {
+ fs::remove_file(&boot_path).expect("remove BOOT.ELF");
+ }
+ app.include_files.push("BOOT.ELF".to_string());
+ app.handle_pack_request();
+ let error = app
+ .error_message
+ .as_ref()
+ .expect("missing BOOT.ELF should block packing");
+ assert!(error.contains("BOOT.ELF"));
+ assert_eq!(
+ app.missing_required_project_files,
+ vec![MissingRequiredFile::included("BOOT.ELF")]
+ );
+ fs::write(&boot_path, b"boot").expect("restore BOOT.ELF");
+ app.clear_error_message();
+ app.refresh_missing_required_project_files();
+ assert!(app.missing_required_project_files.is_empty());
+
+ // Timestamp automation requires timestamp_rules.json when enabled.
+ let timestamp_path = temp_dir.path().join(TIMESTAMP_RULES_FILE);
+ if timestamp_path.exists() {
+ fs::remove_file(×tamp_path).expect("remove timestamp rules");
+ }
+ app.timestamp_strategy = TimestampStrategy::SasRules;
+ let result = app.prepare_pack_inputs();
+ assert!(
+ result.is_some(),
+ "timestamp automation should use built-in rules"
+ );
+ assert!(app.error_message.is_none());
+ assert!(app.missing_required_project_files.is_empty());
+ }
+}
diff --git a/crates/psu-packer-gui/src/main.rs b/crates/psu-packer-gui/src/main.rs
new file mode 100644
index 0000000..2d642f3
--- /dev/null
+++ b/crates/psu-packer-gui/src/main.rs
@@ -0,0 +1,131 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use eframe::{egui, egui::IconData, NativeOptions, Renderer};
+use psu_packer_gui::PackerApp;
+use std::any::Any;
+use std::fmt;
+use std::panic::{self, AssertUnwindSafe};
+
+fn main() -> eframe::Result<()> {
+ let wgpu_result = run_app_with_renderer(Renderer::Wgpu);
+
+ match wgpu_result {
+ Ok(result) => Ok(result),
+ Err(wgpu_error) => {
+ report_renderer_error("WGPU", &wgpu_error);
+
+ let glow_result = run_app_with_renderer(Renderer::Glow);
+ match glow_result {
+ Ok(result) => Ok(result),
+ Err(glow_error) => {
+ report_renderer_error("Glow", &glow_error);
+ Err(wgpu_error)
+ }
+ }
+ }
+ }
+}
+
+fn create_native_options(renderer: Renderer) -> NativeOptions {
+ let mut options = shared_native_options();
+ options.renderer = renderer;
+ options
+}
+
+fn shared_native_options() -> NativeOptions {
+ let mut options = NativeOptions {
+ viewport: egui::ViewportBuilder::default()
+ .with_inner_size([1024.0, 768.0])
+ .with_min_inner_size([640.0, 480.0])
+ .with_icon(load_app_icon())
+ .with_resizable(true),
+ ..Default::default()
+ };
+
+ options.centered = true;
+ options
+}
+
+fn load_app_icon() -> IconData {
+ let icon = include_bytes!("../../suitcase/assets/psupackergui.ico");
+ match image::load_from_memory(icon) {
+ Ok(image) => {
+ let image = image.into_rgba8();
+ let (width, height) = image.dimensions();
+
+ IconData {
+ rgba: image.into_raw(),
+ width,
+ height,
+ }
+ }
+ Err(error) => {
+ eprintln!("Failed to load icon: {error}");
+ IconData {
+ rgba: vec![0; 4],
+ width: 1,
+ height: 1,
+ }
+ }
+ }
+}
+
+fn run_app(options: NativeOptions) -> eframe::Result<()> {
+ eframe::run_native(
+ "PSU Packer",
+ options,
+ Box::new(|cc| Ok(Box::new(PackerApp::new(cc)))),
+ )
+}
+
+fn run_app_with_renderer(renderer: Renderer) -> eframe::Result<()> {
+ let options = create_native_options(renderer);
+ match panic::catch_unwind(AssertUnwindSafe(|| run_app(options))) {
+ Ok(result) => result,
+ Err(payload) => Err(panic_payload_to_error(payload)),
+ }
+}
+
+fn panic_payload_to_error(payload: Box) -> eframe::Error {
+ let message = panic_message(payload);
+ eframe::Error::AppCreation(Box::new(PanicAppError(message)))
+}
+
+fn panic_message(payload: Box) -> String {
+ let payload_ref = &*payload;
+ if let Some(message) = payload_ref.downcast_ref::<&str>() {
+ (*message).to_owned()
+ } else if let Some(message) = payload_ref.downcast_ref::() {
+ message.clone()
+ } else {
+ "Unknown panic".to_owned()
+ }
+}
+
+#[derive(Debug)]
+struct PanicAppError(String);
+
+impl fmt::Display for PanicAppError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl std::error::Error for PanicAppError {}
+
+fn report_renderer_error(renderer: &str, error: &eframe::Error) {
+ eprintln!("Failed to initialize {renderer} renderer: {error}");
+
+ #[cfg(target_os = "windows")]
+ {
+ use rfd::MessageDialog;
+
+ MessageDialog::new()
+ .set_title("PSU Packer")
+ .set_description(&format!(
+ "Failed to initialize {renderer} renderer:\n{error}\n\nAttempting fallback..."
+ ))
+ .set_buttons(rfd::MessageButtons::Ok)
+ .show();
+ }
+}
diff --git a/crates/psu-packer-gui/src/sas_timestamps.rs b/crates/psu-packer-gui/src/sas_timestamps.rs
new file mode 100644
index 0000000..c07c83a
--- /dev/null
+++ b/crates/psu-packer-gui/src/sas_timestamps.rs
@@ -0,0 +1,477 @@
+use std::{collections::HashSet, path::Path};
+
+use chrono::{
+ DateTime, Duration, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone,
+ Timelike, Utc,
+};
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy)]
+pub(crate) struct CanonicalCategoryAliases {
+ pub(crate) key: &'static str,
+ pub(crate) aliases: &'static [&'static str],
+}
+
+const CANONICAL_CATEGORY_ALIASES: &[CanonicalCategoryAliases] = &[
+ CanonicalCategoryAliases {
+ key: "APP_",
+ aliases: &["OSDXMB", "XEBPLUS"],
+ },
+ CanonicalCategoryAliases {
+ key: "APPS",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "PS1_",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "EMU_",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "GME_",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "DST_",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "DBG_",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "RAA_",
+ aliases: &["RESTART", "POWEROFF"],
+ },
+ CanonicalCategoryAliases {
+ key: "RTE_",
+ aliases: &["NEUTRINO"],
+ },
+ CanonicalCategoryAliases {
+ key: "DEFAULT",
+ aliases: &[],
+ },
+ CanonicalCategoryAliases {
+ key: "SYS_",
+ aliases: &["BOOT"],
+ },
+ CanonicalCategoryAliases {
+ key: "ZZY_",
+ aliases: &["EXPLOITS"],
+ },
+ CanonicalCategoryAliases {
+ key: "ZZZ_",
+ aliases: &["BM", "MATRIXTEAM", "OPL"],
+ },
+];
+
+pub(crate) fn canonical_category_aliases() -> &'static [CanonicalCategoryAliases] {
+ CANONICAL_CATEGORY_ALIASES
+}
+
+pub(crate) fn canonical_aliases_for_category(key: &str) -> &'static [&'static str] {
+ for group in CANONICAL_CATEGORY_ALIASES {
+ if group.key == key {
+ return group.aliases;
+ }
+ }
+ &[]
+}
+
+fn is_supported_alias(key: &str, alias: &str) -> bool {
+ canonical_aliases_for_category(key)
+ .iter()
+ .any(|candidate| *candidate == alias)
+}
+
+const CHARSET: &str = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_-.";
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub(crate) struct TimestampRules {
+ #[serde(default = "TimestampRules::default_seconds_between_items")]
+ pub(crate) seconds_between_items: u32,
+ #[serde(default = "TimestampRules::default_slots_per_category")]
+ pub(crate) slots_per_category: u32,
+ #[serde(default = "TimestampRules::default_categories")]
+ pub(crate) categories: Vec,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub(crate) struct CategoryRule {
+ pub(crate) key: String,
+ #[serde(default)]
+ pub(crate) aliases: Vec,
+}
+
+impl CategoryRule {
+ fn new(key: &'static str) -> Self {
+ Self {
+ key: key.to_string(),
+ aliases: Vec::new(),
+ }
+ }
+
+ fn with_aliases(mut self, aliases: &'static [&'static str]) -> Self {
+ self.aliases = aliases.iter().map(|alias| alias.to_string()).collect();
+ self
+ }
+}
+
+impl TimestampRules {
+ const fn default_seconds_between_items() -> u32 {
+ 2
+ }
+
+ const fn default_slots_per_category() -> u32 {
+ 86_400
+ }
+
+ fn default_categories() -> Vec {
+ canonical_category_aliases()
+ .iter()
+ .map(|group| {
+ let mut category = CategoryRule::new(group.key);
+ if !group.aliases.is_empty() {
+ category = category.with_aliases(group.aliases);
+ }
+ category
+ })
+ .collect()
+ }
+
+ pub(crate) fn sanitize(&mut self) {
+ if self.seconds_between_items == 0 {
+ self.seconds_between_items = Self::default_seconds_between_items();
+ }
+ let adjusted = u64::from(self.seconds_between_items.max(2));
+ let next_even = ((adjusted + 1) / 2) * 2;
+ let max_even = {
+ let max_value = u64::from(u32::MAX);
+ max_value - (max_value % 2)
+ };
+ self.seconds_between_items = next_even.min(max_even) as u32;
+ if self.slots_per_category == 0 {
+ self.slots_per_category = Self::default_slots_per_category();
+ }
+
+ if self.categories.is_empty() {
+ *self = Self::default();
+ return;
+ }
+
+ let mut sanitized = Vec::with_capacity(self.categories.len());
+ let mut seen_keys: HashSet = HashSet::new();
+
+ for category in self.categories.drain(..) {
+ let key = category.key.trim().to_ascii_uppercase();
+ if key.is_empty() {
+ continue;
+ }
+ if !seen_keys.insert(key.clone()) {
+ continue;
+ }
+
+ let mut aliases: Vec = category
+ .aliases
+ .into_iter()
+ .filter_map(|alias| sanitize_alias(alias, &key))
+ .collect();
+
+ let mut seen_aliases = HashSet::new();
+ aliases.retain(|alias| seen_aliases.insert(alias.clone()));
+
+ sanitized.push(CategoryRule { key, aliases });
+ }
+
+ if !sanitized.iter().any(|category| category.key == "DEFAULT") {
+ sanitized.push(CategoryRule {
+ key: "DEFAULT".to_string(),
+ aliases: Vec::new(),
+ });
+ }
+
+ self.categories = sanitized;
+ }
+
+ pub(crate) fn seconds_between_items_i64(&self) -> i64 {
+ i64::from(self.seconds_between_items)
+ }
+
+ pub(crate) fn slots_per_category_i64(&self) -> i64 {
+ i64::from(self.slots_per_category)
+ }
+}
+
+impl Default for TimestampRules {
+ fn default() -> Self {
+ Self {
+ seconds_between_items: Self::default_seconds_between_items(),
+ slots_per_category: Self::default_slots_per_category(),
+ categories: Self::default_categories(),
+ }
+ }
+}
+
+fn sanitize_alias(alias: String, key: &str) -> Option {
+ let mut value = alias.trim().to_ascii_uppercase();
+ if value.is_empty() {
+ return None;
+ }
+
+ if key != "APPS" && key != "DEFAULT" && value.starts_with(key) {
+ value = value[key.len()..].to_string();
+ }
+
+ if value.is_empty() {
+ return None;
+ }
+
+ if !is_supported_alias(key, &value) {
+ return None;
+ }
+
+ Some(value)
+}
+
+pub(crate) fn planned_timestamp_for_folder(
+ path: &Path,
+ rules: &TimestampRules,
+) -> Option {
+ let name = path.file_name()?.to_str()?;
+ planned_timestamp_for_name(name, rules)
+}
+
+pub(crate) fn planned_timestamp_for_name(
+ name: &str,
+ rules: &TimestampRules,
+) -> Option {
+ let trimmed = name.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+
+ let offset_seconds = deterministic_offset_seconds(trimmed, rules)?;
+ let base = base_datetime_local_to_utc()?;
+ let planned_utc = base - Duration::seconds(offset_seconds);
+ let snapped = snap_even_second(planned_utc);
+ let local = snapped.with_timezone(&Local);
+ Some(local.naive_local())
+}
+
+fn deterministic_offset_seconds(name: &str, rules: &TimestampRules) -> Option {
+ let effective = normalize_name_for_rules(name, rules)?;
+ let category_index = category_priority_index(&effective, rules)?;
+ let slot = slot_index_within_category(&effective, rules);
+ let category_block_seconds = rules.seconds_between_items_i64() * rules.slots_per_category_i64();
+ let category_offset = category_index as i64 * category_block_seconds;
+ let name_offset = slot * rules.seconds_between_items_i64();
+ Some(category_offset + name_offset)
+}
+
+fn normalize_name_for_rules(name: &str, rules: &TimestampRules) -> Option {
+ let trimmed = name.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+
+ let upper = trimmed.to_ascii_uppercase();
+
+ for category in &rules.categories {
+ if category.aliases.iter().any(|alias| *alias == upper) {
+ return Some(match category.key.as_str() {
+ "APPS" => String::from("APPS"),
+ "DEFAULT" => upper,
+ key => format!("{key}{upper}"),
+ });
+ }
+ }
+
+ Some(upper)
+}
+
+fn category_priority_index(effective: &str, rules: &TimestampRules) -> Option {
+ find_category(effective, rules).map(|(index, _)| index)
+}
+
+fn find_category<'a>(
+ effective: &str,
+ rules: &'a TimestampRules,
+) -> Option<(usize, &'a CategoryRule)> {
+ let mut fallback: Option<(usize, &'a CategoryRule)> = None;
+
+ for (index, category) in rules.categories.iter().enumerate() {
+ match category.key.as_str() {
+ "DEFAULT" => fallback = Some((index, category)),
+ "APPS" => {
+ if effective == "APPS" {
+ return Some((index, category));
+ }
+ }
+ key => {
+ if effective.starts_with(key) {
+ return Some((index, category));
+ }
+ }
+ }
+ }
+
+ fallback
+}
+
+fn slot_index_within_category(effective: &str, rules: &TimestampRules) -> i64 {
+ let payload = payload_for_effective(effective, rules);
+
+ let mut total = 0.0f64;
+ let mut scale = 1.0f64;
+
+ for ch in payload.chars().take(128) {
+ scale *= CHARSET.len() as f64;
+ let index = match CHARSET.find(ch.to_ascii_uppercase()) {
+ Some(idx) => idx + 1,
+ None => CHARSET.len(),
+ } as f64;
+ total += index / scale;
+ }
+
+ let slots_per_category = rules.slots_per_category_i64();
+ let mut slot = (total * slots_per_category as f64).floor() as i64;
+ if slot >= slots_per_category {
+ slot = slots_per_category - 1;
+ }
+ slot
+}
+
+fn payload_for_effective(effective: &str, rules: &TimestampRules) -> String {
+ if let Some((_, category)) = find_category(effective, rules) {
+ match category.key.as_str() {
+ "APPS" => "APPS".to_string(),
+ "DEFAULT" => effective.replace('-', ""),
+ key => effective
+ .strip_prefix(key)
+ .unwrap_or(effective)
+ .replace('-', ""),
+ }
+ } else {
+ effective.replace('-', "")
+ }
+}
+
+fn base_datetime_local_to_utc() -> Option> {
+ let date = NaiveDate::from_ymd_opt(2098, 12, 31)?;
+ let time = NaiveTime::from_hms_opt(23, 59, 59)?;
+ let naive = NaiveDateTime::new(date, time);
+
+ let local = match Local.from_local_datetime(&naive) {
+ LocalResult::Single(dt) => dt,
+ LocalResult::Ambiguous(dt, alt) => dt.min(alt),
+ LocalResult::None => return None,
+ };
+
+ Some(local.with_timezone(&Utc))
+}
+
+fn snap_even_second(dt: DateTime) -> DateTime {
+ let mut snapped = dt.with_nanosecond(0).unwrap_or(dt);
+ if snapped.second() % 2 == 1 {
+ snapped += Duration::seconds(1);
+ }
+ snapped
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::PathBuf;
+
+ #[test]
+ fn produces_even_seconds() {
+ let path = PathBuf::from("APP_SAMPLE");
+ let rules = TimestampRules::default();
+ let timestamp = planned_timestamp_for_folder(&path, &rules).expect("timestamp");
+ assert_eq!(timestamp.second() % 2, 0);
+ assert_eq!(timestamp.nanosecond(), 0);
+ }
+
+ #[test]
+ fn handles_aliases() {
+ let mut rules = TimestampRules::default();
+ rules.sanitize();
+ let path = PathBuf::from("boot");
+ let ts_boot = planned_timestamp_for_folder(&path, &rules).expect("timestamp");
+ let sys_path = PathBuf::from("SYS_BOOT");
+ let ts_sys = planned_timestamp_for_folder(&sys_path, &rules).expect("timestamp");
+ assert_eq!(ts_boot, ts_sys);
+ }
+
+ #[test]
+ fn canonical_aliases_match_prefixed_names() {
+ let mut rules = TimestampRules::default();
+ rules.sanitize();
+
+ let alias_path = PathBuf::from("osdxmb");
+ let prefixed_path = PathBuf::from("APP_OSDXMB");
+
+ let alias_timestamp =
+ planned_timestamp_for_folder(&alias_path, &rules).expect("alias timestamp");
+ let prefixed_timestamp =
+ planned_timestamp_for_folder(&prefixed_path, &rules).expect("prefixed timestamp");
+
+ assert_eq!(alias_timestamp, prefixed_timestamp);
+ }
+
+ #[test]
+ fn unsupported_aliases_are_removed() {
+ let mut rules = TimestampRules::default();
+ if let Some(category) = rules
+ .categories
+ .iter_mut()
+ .find(|category| category.key == "RAA_")
+ {
+ category.aliases = vec!["INVALID".to_string()];
+ }
+
+ rules.sanitize();
+
+ let aliases = rules
+ .categories
+ .iter()
+ .find(|category| category.key == "RAA_")
+ .expect("category");
+
+ assert!(aliases.aliases.is_empty());
+ }
+
+ #[test]
+ fn odd_spacing_produces_distinct_consecutive_timestamps() {
+ let mut rules = TimestampRules {
+ seconds_between_items: 3,
+ slots_per_category: 32,
+ categories: vec![CategoryRule {
+ key: "DEFAULT".to_string(),
+ aliases: Vec::new(),
+ }],
+ };
+ rules.sanitize();
+ assert!(rules.seconds_between_items >= 2);
+ assert_eq!(rules.seconds_between_items % 2, 0);
+
+ let first_name = "A";
+ let second_name = "B";
+ let first_effective =
+ normalize_name_for_rules(first_name, &rules).expect("first effective name");
+ let second_effective =
+ normalize_name_for_rules(second_name, &rules).expect("second effective name");
+ let first_slot = slot_index_within_category(&first_effective, &rules);
+ let second_slot = slot_index_within_category(&second_effective, &rules);
+ assert_eq!(second_slot, first_slot + 1, "expected consecutive slots");
+
+ let first_timestamp =
+ planned_timestamp_for_name(first_name, &rules).expect("first timestamp");
+ let second_timestamp =
+ planned_timestamp_for_name(second_name, &rules).expect("second timestamp");
+
+ assert_ne!(first_timestamp, second_timestamp);
+ }
+}
diff --git a/crates/psu-packer-gui/src/ui/dialogs.rs b/crates/psu-packer-gui/src/ui/dialogs.rs
new file mode 100644
index 0000000..8e3f238
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/dialogs.rs
@@ -0,0 +1,51 @@
+use eframe::egui;
+
+use crate::PackerApp;
+
+pub(crate) fn pack_confirmation(app: &mut PackerApp, ctx: &egui::Context) {
+ if let Some(missing) = app.pending_pack_missing_files() {
+ let message = PackerApp::format_missing_required_files_message(missing);
+ egui::Window::new("Confirm Packing")
+ .collapsible(false)
+ .resizable(false)
+ .show(ctx, |ui| {
+ ui.label(&message);
+ ui.add_space(12.0);
+ ui.label("Pack anyway?");
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ if ui.button("Proceed").clicked() {
+ app.confirm_pending_pack_action();
+ }
+ if ui.button("Go Back").clicked() {
+ app.cancel_pending_pack_action();
+ }
+ });
+ });
+ }
+}
+
+pub(crate) fn exit_confirmation(app: &mut PackerApp, ctx: &egui::Context) {
+ if app.show_exit_confirm {
+ egui::Window::new("Confirm Exit")
+ .collapsible(false)
+ .resizable(false)
+ .show(ctx, |ui| {
+ ui.label("Are you sure you want to exit?");
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ let yes_clicked = ui.button("Yes").clicked();
+ let no_clicked = ui.button("No").clicked();
+
+ if yes_clicked {
+ app.exit_confirmed = true;
+ app.show_exit_confirm = false;
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+ } else if no_clicked {
+ app.exit_confirmed = false;
+ app.show_exit_confirm = false;
+ }
+ });
+ });
+ }
+}
diff --git a/crates/psu-packer-gui/src/ui/file_picker.rs b/crates/psu-packer-gui/src/ui/file_picker.rs
new file mode 100644
index 0000000..9930e85
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/file_picker.rs
@@ -0,0 +1,499 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+use eframe::egui;
+use ps2_filetypes::{IconSys, PSUEntryKind, PSU};
+
+use crate::{ui::theme, PackerApp, SasPrefix, TimestampStrategy};
+
+pub(crate) fn file_menu(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.menu_button("File", |ui| {
+ file_menu_contents(app, ui, None);
+ });
+}
+
+fn file_menu_contents(
+ app: &mut PackerApp,
+ ui: &mut egui::Ui,
+ mut recorder: Option<&mut dyn FileMenuRecorder>,
+) {
+ let pack_in_progress = app.is_pack_running();
+
+ let pack_psu_response = ui
+ .add_enabled(!pack_in_progress, egui::Button::new("Pack PSU"))
+ .on_hover_text("Create the PSU archive using the settings above.");
+ if let Some(recorder) = recorder.as_mut() {
+ recorder.record(FileMenuItem::PackPsu, pack_psu_response.enabled());
+ }
+ if pack_psu_response.clicked() {
+ app.handle_pack_request();
+ ui.close_menu();
+ }
+
+ if ui.button("Save PSU As...").clicked() {
+ app.browse_output_destination();
+ ui.close_menu();
+ }
+
+ if ui.button("Open PSU...").clicked() {
+ app.handle_open_psu();
+ ui.close_menu();
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ {
+ let edit_psu_response = ui.button("Edit psu.toml");
+ if let Some(recorder) = recorder.as_mut() {
+ recorder.record(FileMenuItem::EditPsuToml, edit_psu_response.enabled());
+ }
+ if edit_psu_response.clicked() {
+ app.open_psu_toml_tab();
+ ui.close_menu();
+ }
+ }
+
+ let edit_title_response = ui.button("Edit title.cfg");
+ if let Some(recorder) = recorder.as_mut() {
+ recorder.record(FileMenuItem::EditTitleCfg, edit_title_response.enabled());
+ }
+ if edit_title_response.clicked() {
+ app.open_title_cfg_tab();
+ ui.close_menu();
+ }
+
+ let edit_icon_response = ui.button("Edit icon.sys");
+ if let Some(recorder) = recorder.as_mut() {
+ recorder.record(FileMenuItem::EditIconSys, edit_icon_response.enabled());
+ }
+ if edit_icon_response.clicked() {
+ app.open_icon_sys_tab();
+ ui.close_menu();
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ {
+ let create_psu_response = ui.button("Create psu.toml from template");
+ if let Some(recorder) = recorder.as_mut() {
+ recorder.record(FileMenuItem::CreatePsuToml, create_psu_response.enabled());
+ }
+ if create_psu_response.clicked() {
+ app.create_psu_toml_from_template();
+ ui.close_menu();
+ }
+ }
+
+ let create_title_response = ui.button("Create title.cfg from template");
+ if let Some(recorder) = recorder.as_mut() {
+ recorder.record(
+ FileMenuItem::CreateTitleCfg,
+ create_title_response.enabled(),
+ );
+ }
+ if create_title_response.clicked() {
+ app.create_title_cfg_from_template();
+ ui.close_menu();
+ }
+
+ ui.separator();
+
+ if ui.button("Exit").clicked() {
+ app.show_exit_confirm = true;
+ app.exit_confirmed = false;
+ ui.close_menu();
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(crate) enum FileMenuItem {
+ PackPsu,
+ #[cfg_attr(not(feature = "psu-toml-editor"), allow(dead_code))]
+ EditPsuToml,
+ EditTitleCfg,
+ EditIconSys,
+ #[cfg_attr(not(feature = "psu-toml-editor"), allow(dead_code))]
+ CreatePsuToml,
+ CreateTitleCfg,
+}
+
+trait FileMenuRecorder {
+ fn record(&mut self, item: FileMenuItem, enabled: bool);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::PackerApp;
+ use eframe::egui;
+ use std::collections::HashMap;
+
+ #[test]
+ fn file_menu_buttons_enabled_without_folder() {
+ let mut app = PackerApp::default();
+ assert!(app.folder.is_none());
+
+ let ctx = egui::Context::default();
+ let mut recorder = RecordingMenuRecorder::default();
+
+ ctx.begin_frame(egui::RawInput::default());
+ egui::CentralPanel::default().show(&ctx, |ui| {
+ file_menu_contents(&mut app, ui, Some(&mut recorder));
+ });
+ let _ = ctx.end_frame();
+
+ assert!(recorder.is_enabled(FileMenuItem::PackPsu));
+ #[cfg(feature = "psu-toml-editor")]
+ assert!(recorder.is_enabled(FileMenuItem::EditPsuToml));
+ #[cfg(not(feature = "psu-toml-editor"))]
+ assert!(!recorder.has_entry(FileMenuItem::EditPsuToml));
+ assert!(recorder.is_enabled(FileMenuItem::EditTitleCfg));
+ assert!(recorder.is_enabled(FileMenuItem::EditIconSys));
+ #[cfg(feature = "psu-toml-editor")]
+ assert!(recorder.is_enabled(FileMenuItem::CreatePsuToml));
+ #[cfg(not(feature = "psu-toml-editor"))]
+ assert!(!recorder.has_entry(FileMenuItem::CreatePsuToml));
+ assert!(recorder.is_enabled(FileMenuItem::CreateTitleCfg));
+ }
+
+ #[derive(Default)]
+ struct RecordingMenuRecorder {
+ entries: HashMap,
+ }
+
+ impl RecordingMenuRecorder {
+ fn is_enabled(&self, item: FileMenuItem) -> bool {
+ *self.entries.get(&item).unwrap_or(&false)
+ }
+
+ fn has_entry(&self, item: FileMenuItem) -> bool {
+ self.entries.contains_key(&item)
+ }
+ }
+
+ impl FileMenuRecorder for RecordingMenuRecorder {
+ fn record(&mut self, item: FileMenuItem, enabled: bool) {
+ self.entries.insert(item, enabled);
+ }
+ }
+}
+
+pub(crate) fn folder_section(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Folder"));
+ ui.small("Select the PSU project folder containing psu.toml.");
+ ui.horizontal(|ui| {
+ let spacing = ui.spacing().item_spacing.x;
+ ui.spacing_mut().item_spacing.x = spacing.max(8.0);
+
+ if ui
+ .button("Select folder")
+ .on_hover_text("Pick the source directory to load configuration values.")
+ .clicked()
+ {
+ if let Some(folder) = rfd::FileDialog::new().pick_folder() {
+ load_project_files(app, &folder);
+ if app.icon_sys_enabled {
+ app.open_icon_sys_tab();
+ } else {
+ app.open_psu_settings_tab();
+ }
+ }
+ }
+
+ if ui
+ .button("Load PSU...")
+ .on_hover_text(
+ "Open an existing PSU archive to populate the editor from its metadata.",
+ )
+ .clicked()
+ {
+ app.handle_open_psu();
+ }
+ });
+
+ if let Some(folder) = &app.folder {
+ ui.label(format!("Folder: {}", folder.display()));
+ if !app.missing_required_project_files.is_empty() {
+ let warning = PackerApp::format_missing_required_files_message(
+ &app.missing_required_project_files,
+ );
+ ui.colored_label(egui::Color32::YELLOW, warning);
+ }
+ }
+ });
+}
+
+pub(crate) fn loaded_psu_section(app: &PackerApp, ui: &mut egui::Ui) {
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Loaded PSU"));
+ ui.small("Review the files discovered in the opened PSU archive.");
+ if let Some(path) = &app.loaded_psu_path {
+ ui.label(format!("File: {}", path.display()));
+ }
+ egui::ScrollArea::vertical()
+ .max_height(150.0)
+ .show(ui, |ui| {
+ if app.loaded_psu_files.is_empty() {
+ ui.label("The archive does not contain any files.");
+ } else {
+ for file in &app.loaded_psu_files {
+ ui.label(file);
+ }
+ }
+ });
+ });
+}
+
+pub(crate) fn load_project_files(app: &mut PackerApp, folder: &Path) {
+ app.load_timestamp_rules_from_folder(folder);
+ match psu_packer::load_config(folder) {
+ Ok(config) => {
+ let psu_packer::Config {
+ name,
+ timestamp,
+ include,
+ exclude,
+ icon_sys,
+ } = config;
+
+ app.set_folder_name_from_full(&name);
+ app.psu_file_base_name = app.folder_base_name.clone();
+ if let Some(default_path) = app.default_output_path_with(Some(folder)) {
+ app.output = default_path.display().to_string();
+ } else {
+ app.output.clear();
+ }
+ app.source_timestamp = timestamp;
+ app.include_files = include.unwrap_or_default();
+ app.exclude_files = exclude.unwrap_or_default();
+ app.selected_include = None;
+ app.selected_exclude = None;
+ app.clear_error_message();
+ app.status.clear();
+
+ let mut parsed_icon_sys = None;
+ if let Some(icon_sys_path) = find_icon_sys_path(folder) {
+ match fs::read(&icon_sys_path) {
+ Ok(bytes) => match std::panic::catch_unwind(|| IconSys::new(bytes)) {
+ Ok(icon_sys) => parsed_icon_sys = Some(icon_sys),
+ Err(_) => {
+ app.set_error_message(format!(
+ "Failed to parse {} as an icon.sys file.",
+ icon_sys_path.display()
+ ));
+ }
+ },
+ Err(err) => {
+ app.set_error_message(format!(
+ "Failed to read {}: {}",
+ icon_sys_path.display(),
+ err
+ ));
+ }
+ }
+ }
+
+ if let Some(icon_cfg) = icon_sys {
+ app.apply_icon_sys_config(icon_cfg, parsed_icon_sys.as_ref());
+ } else if let Some(existing_icon_sys) = parsed_icon_sys.as_ref() {
+ app.apply_icon_sys_file(existing_icon_sys);
+ } else {
+ app.reset_icon_sys_fields();
+ }
+
+ app.icon_sys_existing = parsed_icon_sys;
+ }
+ Err(err) => {
+ let message = format_load_error(folder, err);
+ app.set_error_message(message);
+ app.output.clear();
+ app.selected_prefix = SasPrefix::default();
+ app.folder_base_name.clear();
+ app.psu_file_base_name.clear();
+ app.timestamp = None;
+ app.timestamp_strategy = TimestampStrategy::None;
+ app.timestamp_from_rules = false;
+ app.source_timestamp = None;
+ app.manual_timestamp = None;
+ app.include_files.clear();
+ app.exclude_files.clear();
+ app.selected_include = None;
+ app.selected_exclude = None;
+ app.reset_icon_sys_fields();
+ }
+ }
+ app.loaded_psu_path = None;
+ app.loaded_psu_files.clear();
+ app.folder = Some(folder.to_path_buf());
+ app.sync_timestamp_after_source_update();
+ app.reload_project_files();
+}
+
+fn find_icon_sys_path(folder: &Path) -> Option {
+ let entries = fs::read_dir(folder).ok()?;
+ entries
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .find(|path| {
+ path.is_file()
+ && path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .map(|name| name.eq_ignore_ascii_case("icon.sys"))
+ .unwrap_or(false)
+ })
+}
+
+impl PackerApp {
+ pub(crate) fn handle_open_psu(&mut self) {
+ let Some(path) = rfd::FileDialog::new()
+ .add_filter("PSU", &["psu"])
+ .pick_file()
+ else {
+ return;
+ };
+
+ let data = match std::fs::read(&path) {
+ Ok(bytes) => bytes,
+ Err(err) => {
+ self.set_error_message(format!("Failed to read {}: {err}", path.display()));
+ return;
+ }
+ };
+
+ let parsed = match std::panic::catch_unwind(|| PSU::new(data)) {
+ Ok(psu) => psu,
+ Err(_) => {
+ self.set_error_message(format!("Failed to parse PSU file {}", path.display()));
+ return;
+ }
+ };
+
+ let entries = parsed.entries();
+ let mut root_name: Option = None;
+ let mut root_timestamp = None;
+ let mut files = Vec::new();
+ let mut psu_toml_bytes: Option> = None;
+ let mut title_cfg_bytes: Option> = None;
+ let mut icon_sys_bytes: Option> = None;
+
+ for entry in &entries {
+ match entry.kind {
+ PSUEntryKind::Directory => {
+ if entry.name != "." && entry.name != ".." && root_name.is_none() {
+ root_name = Some(entry.name.clone());
+ root_timestamp = Some(entry.created);
+ }
+ }
+ PSUEntryKind::File => {
+ let name_matches = entry.name.as_str();
+ if psu_toml_bytes.is_none() && name_matches.eq_ignore_ascii_case("psu.toml") {
+ if let Some(bytes) = entry.contents.clone() {
+ psu_toml_bytes = Some(bytes);
+ }
+ }
+ if title_cfg_bytes.is_none() && name_matches.eq_ignore_ascii_case("title.cfg") {
+ if let Some(bytes) = entry.contents.clone() {
+ title_cfg_bytes = Some(bytes);
+ }
+ }
+ if icon_sys_bytes.is_none() && name_matches.eq_ignore_ascii_case("icon.sys") {
+ if let Some(bytes) = entry.contents.clone() {
+ icon_sys_bytes = Some(bytes);
+ }
+ }
+ files.push(entry.name.clone());
+ }
+ }
+ }
+
+ let Some(name) = root_name else {
+ self.set_error_message(format!("{} does not contain PSU metadata", path.display()));
+ return;
+ };
+
+ self.set_folder_name_from_full(&name);
+ if let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) {
+ self.set_psu_file_base_from_full(stem);
+ } else {
+ self.psu_file_base_name = self.folder_base_name.clone();
+ }
+ self.source_timestamp = root_timestamp;
+ self.loaded_psu_files = files;
+ self.loaded_psu_path = Some(path.clone());
+ self.clear_error_message();
+ self.status = format!("Loaded PSU from {}", path.display());
+ self.folder = None;
+ self.missing_required_project_files.clear();
+ self.sync_timestamp_after_source_update();
+ self.include_files.clear();
+ self.exclude_files.clear();
+ self.selected_include = None;
+ self.selected_exclude = None;
+ let decode_text = |bytes: Vec| match String::from_utf8(bytes) {
+ Ok(content) => content,
+ Err(err) => {
+ let bytes = err.into_bytes();
+ String::from_utf8_lossy(&bytes).into_owned()
+ }
+ };
+
+ if let Some(bytes) = psu_toml_bytes {
+ self.psu_toml_editor.set_content(decode_text(bytes));
+ } else {
+ self.psu_toml_editor
+ .set_error_message("psu.toml not found in the opened archive.".to_string());
+ }
+
+ if let Some(bytes) = title_cfg_bytes {
+ self.title_cfg_editor.set_content(decode_text(bytes));
+ } else {
+ self.title_cfg_editor
+ .set_error_message("title.cfg not found in the opened archive.".to_string());
+ }
+
+ self.psu_toml_sync_blocked = false;
+
+ if let Some(bytes) = icon_sys_bytes {
+ match std::panic::catch_unwind(|| IconSys::new(bytes)) {
+ Ok(parsed_icon_sys) => {
+ self.apply_icon_sys_file(&parsed_icon_sys);
+ }
+ Err(_) => {
+ self.reset_icon_sys_fields();
+ self.set_error_message(format!(
+ "Failed to parse icon.sys from {}.",
+ path.display()
+ ));
+ }
+ }
+ } else {
+ self.reset_icon_sys_fields();
+ }
+ self.open_psu_settings_tab();
+
+ if self.output.trim().is_empty() {
+ self.output = path.display().to_string();
+ }
+ }
+}
+
+fn format_load_error(folder: &Path, err: psu_packer::Error) -> String {
+ match err {
+ psu_packer::Error::NameError => "Configuration contains an invalid PSU name.".to_string(),
+ psu_packer::Error::ConfigError(message) => {
+ format!("The psu.toml file is invalid: {message}")
+ }
+ psu_packer::Error::IOError(io_err) => {
+ let config_path = folder.join("psu.toml");
+ match io_err.kind() {
+ std::io::ErrorKind::NotFound => format!(
+ "Could not find {}. Create a psu.toml file in the selected folder.",
+ config_path.display()
+ ),
+ _ => format!("Failed to read {}: {}", config_path.display(), io_err),
+ }
+ }
+ }
+}
diff --git a/crates/psu-packer-gui/src/ui/icon_sys.rs b/crates/psu-packer-gui/src/ui/icon_sys.rs
new file mode 100644
index 0000000..af9f4cd
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/icon_sys.rs
@@ -0,0 +1,661 @@
+use eframe::egui::{self, Color32, RichText};
+
+use crate::{
+ ui::theme, IconFlagSelection, PackerApp, ICON_SYS_FLAG_OPTIONS, ICON_SYS_TITLE_CHAR_LIMIT,
+};
+use ps2_filetypes::sjis;
+use psu_packer::{ColorConfig, ColorFConfig, IconSysConfig, VectorConfig};
+
+const TITLE_CHAR_LIMIT: usize = ICON_SYS_TITLE_CHAR_LIMIT;
+const TITLE_INPUT_WIDTH: f32 = (ICON_SYS_TITLE_CHAR_LIMIT as f32) * 9.0;
+
+#[derive(Clone, Copy)]
+struct IconSysPreset {
+ id: &'static str,
+ label: &'static str,
+ background_transparency: u32,
+ background_colors: [ColorConfig; 4],
+ light_directions: [VectorConfig; 3],
+ light_colors: [ColorFConfig; 3],
+ ambient_color: ColorFConfig,
+}
+
+const ICON_SYS_PRESETS: &[IconSysPreset] = &[
+ IconSysPreset {
+ id: "default",
+ label: "Standard (PS2)",
+ background_transparency: IconSysConfig::default_background_transparency(),
+ background_colors: IconSysConfig::default_background_colors(),
+ light_directions: IconSysConfig::default_light_directions(),
+ light_colors: IconSysConfig::default_light_colors(),
+ ambient_color: IconSysConfig::default_ambient_color(),
+ },
+ IconSysPreset {
+ id: "cool_blue",
+ label: "Cool Blue",
+ background_transparency: 0,
+ background_colors: [
+ ColorConfig {
+ r: 0,
+ g: 32,
+ b: 96,
+ a: 0,
+ },
+ ColorConfig {
+ r: 0,
+ g: 48,
+ b: 128,
+ a: 0,
+ },
+ ColorConfig {
+ r: 0,
+ g: 64,
+ b: 160,
+ a: 0,
+ },
+ ColorConfig {
+ r: 0,
+ g: 16,
+ b: 48,
+ a: 0,
+ },
+ ],
+ light_directions: [
+ VectorConfig {
+ x: 0.0,
+ y: 0.0,
+ z: 1.0,
+ w: 0.0,
+ },
+ VectorConfig {
+ x: -0.5,
+ y: -0.5,
+ z: 0.5,
+ w: 0.0,
+ },
+ VectorConfig {
+ x: 0.5,
+ y: -0.5,
+ z: 0.5,
+ w: 0.0,
+ },
+ ],
+ light_colors: [
+ ColorFConfig {
+ r: 1.0,
+ g: 1.0,
+ b: 1.0,
+ a: 1.0,
+ },
+ ColorFConfig {
+ r: 0.5,
+ g: 0.5,
+ b: 0.6,
+ a: 1.0,
+ },
+ ColorFConfig {
+ r: 0.3,
+ g: 0.3,
+ b: 0.4,
+ a: 1.0,
+ },
+ ],
+ ambient_color: ColorFConfig {
+ r: 0.2,
+ g: 0.2,
+ b: 0.2,
+ a: 1.0,
+ },
+ },
+ IconSysPreset {
+ id: "warm_sunset",
+ label: "Warm Sunset",
+ background_transparency: 0,
+ background_colors: [
+ ColorConfig {
+ r: 128,
+ g: 48,
+ b: 16,
+ a: 0,
+ },
+ ColorConfig {
+ r: 176,
+ g: 72,
+ b: 32,
+ a: 0,
+ },
+ ColorConfig {
+ r: 208,
+ g: 112,
+ b: 48,
+ a: 0,
+ },
+ ColorConfig {
+ r: 96,
+ g: 32,
+ b: 16,
+ a: 0,
+ },
+ ],
+ light_directions: [
+ VectorConfig {
+ x: -0.2,
+ y: -0.4,
+ z: 0.8,
+ w: 0.0,
+ },
+ VectorConfig {
+ x: 0.0,
+ y: -0.6,
+ z: 0.6,
+ w: 0.0,
+ },
+ VectorConfig {
+ x: 0.3,
+ y: -0.5,
+ z: 0.7,
+ w: 0.0,
+ },
+ ],
+ light_colors: [
+ ColorFConfig {
+ r: 1.0,
+ g: 0.9,
+ b: 0.75,
+ a: 1.0,
+ },
+ ColorFConfig {
+ r: 0.9,
+ g: 0.6,
+ b: 0.3,
+ a: 1.0,
+ },
+ ColorFConfig {
+ r: 0.6,
+ g: 0.3,
+ b: 0.2,
+ a: 1.0,
+ },
+ ],
+ ambient_color: ColorFConfig {
+ r: 0.25,
+ g: 0.18,
+ b: 0.12,
+ a: 1.0,
+ },
+ },
+];
+
+pub(crate) fn icon_sys_editor(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.heading(theme::display_heading_text(ui, "icon.sys metadata"));
+ ui.small("Configure the save icon title, flags, and lighting.");
+ ui.add_space(8.0);
+
+ let mut config_changed = false;
+
+ let checkbox = ui.checkbox(&mut app.icon_sys_enabled, "Enable icon.sys metadata");
+ let checkbox_changed = checkbox.changed();
+ checkbox
+ .on_hover_text("Use an existing icon.sys file or generate a new one when packing the PSU.");
+
+ if checkbox_changed {
+ config_changed = true;
+ }
+
+ if !app.icon_sys_enabled {
+ app.icon_sys_use_existing = false;
+ } else if app.icon_sys_existing.is_none() {
+ app.icon_sys_use_existing = false;
+ }
+
+ if app.icon_sys_enabled {
+ if let Some(existing_icon) = app.icon_sys_existing.clone() {
+ let previous = app.icon_sys_use_existing;
+ ui.horizontal(|ui| {
+ ui.label("Mode:");
+ let use_existing = ui.selectable_value(
+ &mut app.icon_sys_use_existing,
+ true,
+ "Use existing icon.sys",
+ );
+ if use_existing.changed() {
+ config_changed = true;
+ }
+ let generate_new = ui.selectable_value(
+ &mut app.icon_sys_use_existing,
+ false,
+ "Generate new icon.sys",
+ );
+ if generate_new.changed() {
+ config_changed = true;
+ }
+ });
+
+ if app.icon_sys_use_existing && !previous {
+ app.apply_icon_sys_file(&existing_icon);
+ config_changed = true;
+ }
+
+ if app.icon_sys_use_existing {
+ ui.small(concat!(
+ "The existing icon.sys file will be packed without modification. ",
+ "Switch to \"Generate new icon.sys\" to edit metadata.",
+ ));
+ }
+ }
+ }
+
+ ui.add_space(8.0);
+
+ let enabled = app.icon_sys_enabled && !app.icon_sys_use_existing;
+ let inner_response = ui.add_enabled_ui(enabled, |ui| {
+ let mut inner_changed = false;
+ inner_changed |= title_section(app, ui);
+ ui.add_space(12.0);
+ inner_changed |= flag_section(app, ui);
+ ui.add_space(12.0);
+ inner_changed |= presets_section(app, ui);
+ ui.add_space(12.0);
+ inner_changed |= background_section(app, ui);
+ ui.add_space(12.0);
+ inner_changed |= lighting_section(app, ui);
+ inner_changed
+ });
+
+ if inner_response.inner {
+ config_changed = true;
+ }
+
+ if config_changed {
+ app.refresh_psu_toml_editor();
+ }
+}
+
+fn title_section(app: &mut PackerApp, ui: &mut egui::Ui) -> bool {
+ let mut changed = false;
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Title"));
+ ui.small("Each line supports up to 16 characters that must round-trip through Shift-JIS");
+
+ egui::Grid::new("icon_sys_title_grid")
+ .num_columns(2)
+ .spacing(egui::vec2(8.0, 4.0))
+ .show(ui, |ui| {
+ ui.label("Line 1");
+ if title_input(
+ ui,
+ egui::Id::new("icon_sys_title_line1"),
+ &mut app.icon_sys_title_line1,
+ ) {
+ changed = true;
+ }
+ ui.end_row();
+
+ ui.label("Line 2");
+ if title_input(
+ ui,
+ egui::Id::new("icon_sys_title_line2"),
+ &mut app.icon_sys_title_line2,
+ ) {
+ changed = true;
+ }
+ ui.end_row();
+
+ ui.label("Preview");
+ ui.vertical(|ui| {
+ ui.monospace(format!(
+ "{: {
+ let break_pos = bytes.len();
+ ui.small(format!("Shift-JIS byte length: {break_pos}"));
+ ui.small(format!("Line break position: {break_pos}"));
+ }
+ Err(_) => {
+ let warning = RichText::new(
+ "Shift-JIS byte length: invalid (non-encodable characters)",
+ )
+ .color(Color32::RED);
+ ui.small(warning);
+ ui.small(
+ RichText::new("Line break position: -- (invalid Shift-JIS)")
+ .color(Color32::RED),
+ );
+ }
+ }
+ });
+ ui.end_row();
+ });
+ });
+ changed
+}
+
+fn title_input(ui: &mut egui::Ui, id: egui::Id, value: &mut String) -> bool {
+ let mut edit = egui::TextEdit::singleline(value)
+ .char_limit(TITLE_CHAR_LIMIT)
+ .desired_width(TITLE_INPUT_WIDTH);
+ edit = edit.id_source(id);
+
+ let response = ui.add(edit);
+ let mut changed = false;
+ if response.changed() {
+ let mut sanitized = String::new();
+ let mut accepted_chars = 0usize;
+ for ch in value.chars() {
+ if ch.is_control() {
+ continue;
+ }
+
+ if accepted_chars >= TITLE_CHAR_LIMIT {
+ break;
+ }
+
+ sanitized.push(ch);
+ if sjis::is_roundtrip_sjis(&sanitized) {
+ accepted_chars += 1;
+ } else {
+ sanitized.pop();
+ }
+ }
+ if *value != sanitized {
+ *value = sanitized;
+ }
+ changed = true;
+ }
+
+ let char_count = value.chars().count();
+ ui.small(format!(
+ "{char_count} / {TITLE_CHAR_LIMIT} characters (Shift-JIS compatible)"
+ ));
+ changed
+}
+
+fn flag_section(app: &mut PackerApp, ui: &mut egui::Ui) -> bool {
+ let mut changed = false;
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Flags"));
+ egui::Grid::new("icon_sys_flag_grid")
+ .num_columns(2)
+ .spacing(egui::vec2(8.0, 4.0))
+ .show(ui, |ui| {
+ ui.label("Icon type");
+ ui.horizontal(|ui| {
+ egui::ComboBox::from_id_source("icon_sys_flag_combo")
+ .selected_text(app.icon_flag_label())
+ .show_ui(ui, |ui| {
+ for (idx, (_, label)) in ICON_SYS_FLAG_OPTIONS.iter().enumerate() {
+ let response = ui.selectable_value(
+ &mut app.icon_sys_flag_selection,
+ IconFlagSelection::Preset(idx),
+ *label,
+ );
+ if response.changed() {
+ changed = true;
+ }
+ }
+ let response = ui.selectable_value(
+ &mut app.icon_sys_flag_selection,
+ IconFlagSelection::Custom,
+ "Custom…",
+ );
+ if response.changed() {
+ changed = true;
+ }
+ });
+
+ if matches!(app.icon_sys_flag_selection, IconFlagSelection::Custom) {
+ let response = ui.add(
+ egui::DragValue::new(&mut app.icon_sys_custom_flag)
+ .clamp_range(0.0..=u16::MAX as f64)
+ .speed(1),
+ );
+ if response.changed() {
+ changed = true;
+ }
+ response.on_hover_text("Enter the raw flag value (0-65535).");
+ ui.label(format!("0x{:04X}", app.icon_sys_custom_flag));
+ }
+ });
+ ui.end_row();
+ });
+ });
+ changed
+}
+
+fn presets_section(app: &mut PackerApp, ui: &mut egui::Ui) -> bool {
+ let mut changed = false;
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Presets"));
+ ui.small("Choose a preset to populate the colors and lights automatically.");
+
+ let selected_label = match app.icon_sys_selected_preset.as_deref() {
+ Some(id) => find_preset(id)
+ .map(|preset| preset.label.to_string())
+ .unwrap_or_else(|| format!("Custom ({id})")),
+ None => "Manual".to_string(),
+ };
+
+ egui::ComboBox::from_id_source("icon_sys_preset_combo")
+ .selected_text(selected_label)
+ .show_ui(ui, |ui| {
+ if ui
+ .selectable_label(app.icon_sys_selected_preset.is_none(), "Manual")
+ .clicked()
+ {
+ app.clear_icon_sys_preset();
+ changed = true;
+ }
+ for preset in ICON_SYS_PRESETS {
+ let selected = app
+ .icon_sys_selected_preset
+ .as_deref()
+ .map(|id| id == preset.id)
+ .unwrap_or(false);
+ if ui.selectable_label(selected, preset.label).clicked() {
+ apply_preset(app, preset);
+ changed = true;
+ }
+ }
+ });
+
+ ui.add_space(6.0);
+ preset_preview(app, ui);
+ });
+ changed
+}
+
+fn preset_preview(app: &PackerApp, ui: &mut egui::Ui) {
+ ui.vertical(|ui| {
+ ui.label("Background gradient");
+ ui.horizontal(|ui| {
+ for color in app.icon_sys_background_colors {
+ draw_color_swatch(ui, color32_from_color_config(color));
+ }
+ });
+
+ ui.label("Light colors");
+ ui.horizontal(|ui| {
+ for color in app.icon_sys_light_colors {
+ draw_color_swatch(ui, color32_from_color_f_config(color));
+ }
+ });
+
+ ui.label("Ambient");
+ draw_color_swatch(ui, color32_from_color_f_config(app.icon_sys_ambient_color));
+ });
+}
+
+fn draw_color_swatch(ui: &mut egui::Ui, color: Color32) {
+ let (rect, _) = ui.allocate_exact_size(egui::vec2(20.0, 14.0), egui::Sense::hover());
+ ui.painter().rect_filled(rect, 3.0, color);
+}
+
+fn background_section(app: &mut PackerApp, ui: &mut egui::Ui) -> bool {
+ let mut changed = false;
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Background"));
+ ui.small("Adjust the gradient colors and alpha layer.");
+
+ if ui
+ .add(
+ egui::DragValue::new(&mut app.icon_sys_background_transparency)
+ .clamp_range(0.0..=255.0)
+ .speed(1)
+ .suffix(" α"),
+ )
+ .changed()
+ {
+ app.clear_icon_sys_preset();
+ changed = true;
+ }
+
+ let mut background_changed = false;
+ egui::Grid::new("icon_sys_background_grid")
+ .num_columns(2)
+ .spacing(egui::vec2(8.0, 4.0))
+ .show(ui, |ui| {
+ for (index, color) in app.icon_sys_background_colors.iter_mut().enumerate() {
+ ui.label(format!("Color {}", index + 1));
+ let mut display = color32_from_color_config(*color);
+ if ui.color_edit_button_srgba(&mut display).changed() {
+ *color = color_config_from_color32(display);
+ background_changed = true;
+ }
+ ui.end_row();
+ }
+ });
+ if background_changed {
+ app.clear_icon_sys_preset();
+ changed = true;
+ }
+ });
+ changed
+}
+
+fn lighting_section(app: &mut PackerApp, ui: &mut egui::Ui) -> bool {
+ let mut changed = false;
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Lighting"));
+ ui.small("Tweak light directions, colors, and the ambient glow.");
+
+ let mut lighting_changed = false;
+
+ for (index, (color, direction)) in app
+ .icon_sys_light_colors
+ .iter_mut()
+ .zip(app.icon_sys_light_directions.iter_mut())
+ .enumerate()
+ {
+ let mut light_dirty = false;
+ ui.collapsing(format!("Light {}", index + 1), |ui| {
+ ui.label("Color");
+ let mut rgba = color_f_config_to_array(*color);
+ if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
+ *color = array_to_color_f_config(rgba);
+ light_dirty = true;
+ }
+
+ ui.add_space(4.0);
+ ui.label("Direction");
+ for (label, component) in [
+ ("x", &mut direction.x),
+ ("y", &mut direction.y),
+ ("z", &mut direction.z),
+ ("w", &mut direction.w),
+ ] {
+ ui.horizontal(|ui| {
+ ui.label(label);
+ if ui
+ .add(
+ egui::DragValue::new(component)
+ .clamp_range(-1.0..=1.0)
+ .speed(0.01),
+ )
+ .changed()
+ {
+ light_dirty = true;
+ }
+ });
+ }
+ });
+ if light_dirty {
+ lighting_changed = true;
+ }
+ ui.add_space(4.0);
+ }
+
+ ui.label("Ambient color");
+ let mut ambient = color_f_config_to_array(app.icon_sys_ambient_color);
+ if ui
+ .color_edit_button_rgba_unmultiplied(&mut ambient)
+ .changed()
+ {
+ app.icon_sys_ambient_color = array_to_color_f_config(ambient);
+ lighting_changed = true;
+ }
+
+ if lighting_changed {
+ app.clear_icon_sys_preset();
+ changed = true;
+ }
+ });
+ changed
+}
+
+fn apply_preset(app: &mut PackerApp, preset: &IconSysPreset) {
+ app.icon_sys_background_transparency = preset.background_transparency;
+ app.icon_sys_background_colors = preset.background_colors;
+ app.icon_sys_light_directions = preset.light_directions;
+ app.icon_sys_light_colors = preset.light_colors;
+ app.icon_sys_ambient_color = preset.ambient_color;
+ app.icon_sys_selected_preset = Some(preset.id.to_string());
+}
+
+fn find_preset(id: &str) -> Option<&'static IconSysPreset> {
+ ICON_SYS_PRESETS.iter().find(|preset| preset.id == id)
+}
+
+fn color32_from_color_config(color: ColorConfig) -> Color32 {
+ Color32::from_rgba_unmultiplied(color.r, color.g, color.b, color.a)
+}
+
+fn color32_from_color_f_config(color: ColorFConfig) -> Color32 {
+ let clamp = |value: f32| -> u8 { (value.clamp(0.0, 1.0) * 255.0).round() as u8 };
+ Color32::from_rgba_unmultiplied(
+ clamp(color.r),
+ clamp(color.g),
+ clamp(color.b),
+ clamp(color.a),
+ )
+}
+
+fn color_config_from_color32(color: Color32) -> ColorConfig {
+ ColorConfig {
+ r: color.r(),
+ g: color.g(),
+ b: color.b(),
+ a: color.a(),
+ }
+}
+
+fn color_f_config_to_array(color: ColorFConfig) -> [f32; 4] {
+ [color.r, color.g, color.b, color.a]
+}
+
+fn array_to_color_f_config(color: [f32; 4]) -> ColorFConfig {
+ ColorFConfig {
+ r: color[0],
+ g: color[1],
+ b: color[2],
+ a: color[3],
+ }
+}
diff --git a/crates/psu-packer-gui/src/ui/mod.rs b/crates/psu-packer-gui/src/ui/mod.rs
new file mode 100644
index 0000000..98ddb0f
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/mod.rs
@@ -0,0 +1,42 @@
+use eframe::egui;
+
+pub mod dialogs;
+pub mod file_picker;
+pub mod icon_sys;
+pub mod pack_controls;
+pub mod theme;
+pub mod timestamps;
+
+pub(crate) fn centered_column(
+ ui: &mut egui::Ui,
+ max_width: f32,
+ add_contents: impl FnOnce(&mut egui::Ui) -> R,
+) -> R {
+ let available = ui.available_width();
+ let width = available.min(max_width);
+ let margin = ((available - width) * 0.5).max(0.0);
+
+ let mut result = None;
+ ui.horizontal(|ui| {
+ if margin > 0.0 {
+ ui.add_space(margin);
+ }
+
+ result = Some(
+ ui.scope(|ui| {
+ ui.set_width(width);
+ ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
+ add_contents(ui)
+ })
+ .inner
+ })
+ .inner,
+ );
+
+ if margin > 0.0 {
+ ui.add_space(margin);
+ }
+ });
+
+ result.expect("centered_column should always produce a result")
+}
diff --git a/crates/psu-packer-gui/src/ui/pack_controls.rs b/crates/psu-packer-gui/src/ui/pack_controls.rs
new file mode 100644
index 0000000..71adcea
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/pack_controls.rs
@@ -0,0 +1,806 @@
+use std::path::Path;
+
+use eframe::egui;
+
+use crate::{ui::theme, PackerApp, SasPrefix, ICON_SYS_TITLE_CHAR_LIMIT};
+use ps2_filetypes::sjis;
+
+pub(crate) fn metadata_section(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.set_width(ui.available_width());
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Metadata"));
+ ui.small("Edit PSU metadata before or after selecting a folder.");
+ let previous_default_output = app.default_output_file_name();
+ let mut metadata_changed = false;
+
+ egui::Grid::new("metadata_grid")
+ .num_columns(2)
+ .spacing(egui::vec2(12.0, 6.0))
+ .show(ui, |ui| {
+ ui.label("SAS prefix");
+ let prefix_changed = egui::ComboBox::from_id_source("metadata_prefix_combo")
+ .selected_text(app.selected_prefix.label())
+ .show_ui(ui, |ui| {
+ let mut changed = false;
+ for prefix in SasPrefix::iter_all() {
+ let response = ui.selectable_value(
+ &mut app.selected_prefix,
+ prefix,
+ prefix.label(),
+ );
+ if response.changed() {
+ changed = true;
+ }
+ }
+ changed
+ })
+ .inner
+ .unwrap_or(false);
+ if prefix_changed {
+ metadata_changed = true;
+ }
+ ui.end_row();
+
+ ui.label("Folder name");
+ if ui.text_edit_singleline(&mut app.folder_base_name).changed() {
+ metadata_changed = true;
+ }
+ ui.end_row();
+
+ ui.label("PSU filename");
+ if ui
+ .text_edit_singleline(&mut app.psu_file_base_name)
+ .changed()
+ {
+ metadata_changed = true;
+ }
+ ui.end_row();
+
+ ui.label("Timestamp");
+ crate::ui::timestamps::metadata_timestamp_section(app, ui);
+ ui.end_row();
+
+ ui.label("icon.sys");
+ let mut label = "Configure icon.sys metadata in the dedicated tab.".to_string();
+ if app.icon_sys_enabled {
+ if app.icon_sys_use_existing {
+ label.push_str(" Existing icon.sys file will be reused.");
+ } else {
+ label.push_str(" A new icon.sys will be generated.");
+ }
+ }
+ ui.small(label);
+ ui.end_row();
+ });
+
+ if metadata_changed {
+ app.metadata_inputs_changed(previous_default_output);
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ if app.folder.is_some() && app.psu_toml_sync_blocked {
+ ui.add_space(6.0);
+ ui.colored_label(
+ egui::Color32::YELLOW,
+ "psu.toml has manual edits; automatic metadata syncing is paused.",
+ );
+ }
+ });
+}
+
+pub(crate) fn file_filters_section(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.set_width(ui.available_width());
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "File filters"));
+ ui.small("Manage which files to include or exclude before creating the archive.");
+ let folder_selected = app.folder.is_some();
+ if !folder_selected {
+ ui.small("No folder selected. Enter file names manually or choose a folder to browse.");
+ }
+ ui.columns(2, |columns| {
+ let include_actions = file_list_ui(
+ &mut columns[0],
+ ListKind::Include.label(),
+ &mut app.include_files,
+ &mut app.selected_include,
+ &mut app.include_manual_entry,
+ folder_selected,
+ );
+ if include_actions.browse_add && app.handle_add_file(ListKind::Include) {
+ app.refresh_psu_toml_editor();
+ }
+ if let Some(entry) = include_actions.manual_add {
+ if app.handle_add_file_from_entry(ListKind::Include, &entry) {
+ app.refresh_psu_toml_editor();
+ }
+ }
+ if include_actions.remove && app.handle_remove_file(ListKind::Include) {
+ app.refresh_psu_toml_editor();
+ }
+
+ let exclude_actions = file_list_ui(
+ &mut columns[1],
+ ListKind::Exclude.label(),
+ &mut app.exclude_files,
+ &mut app.selected_exclude,
+ &mut app.exclude_manual_entry,
+ folder_selected,
+ );
+ if exclude_actions.browse_add && app.handle_add_file(ListKind::Exclude) {
+ app.refresh_psu_toml_editor();
+ }
+ if let Some(entry) = exclude_actions.manual_add {
+ if app.handle_add_file_from_entry(ListKind::Exclude, &entry) {
+ app.refresh_psu_toml_editor();
+ }
+ }
+ if exclude_actions.remove && app.handle_remove_file(ListKind::Exclude) {
+ app.refresh_psu_toml_editor();
+ }
+ });
+ });
+}
+
+pub(crate) fn output_section(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.set_width(ui.available_width());
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Output"));
+ ui.small("Choose where the packed PSU file will be saved.");
+ egui::Grid::new("output_grid")
+ .num_columns(2)
+ .spacing(egui::vec2(12.0, 6.0))
+ .show(ui, |ui| {
+ ui.label("Packed PSU path");
+ let trimmed_output = app.output.trim();
+ if trimmed_output.is_empty() {
+ ui.weak("No destination selected");
+ } else {
+ ui.label(egui::RichText::new(trimmed_output).monospace());
+ }
+ ui.end_row();
+
+ ui.label("");
+ if ui
+ .button("Choose destination")
+ .on_hover_text("Pick where the PSU file will be created or updated.")
+ .clicked()
+ {
+ app.browse_output_destination();
+ }
+ ui.end_row();
+ });
+ });
+}
+
+pub(crate) fn packaging_section(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.set_width(ui.available_width());
+ ui.group(|ui| {
+ ui.heading(theme::display_heading_text(ui, "Packaging"));
+ ui.small("Validate the configuration and generate the PSU archive.");
+ let pack_in_progress = app.is_pack_running();
+ if !app.missing_required_project_files.is_empty() {
+ let warning = PackerApp::format_missing_required_files_message(
+ &app.missing_required_project_files,
+ );
+ ui.colored_label(egui::Color32::YELLOW, warning);
+ }
+ ui.horizontal_wrapped(|ui| {
+ let pack_button = ui
+ .add_enabled(!pack_in_progress, egui::Button::new("Pack PSU"))
+ .on_hover_text("Create the PSU archive using the settings above.");
+
+ if pack_button.clicked() {
+ app.handle_pack_request();
+ }
+
+ let update_button = ui
+ .add_enabled(!pack_in_progress, egui::Button::new("Update PSU"))
+ .on_hover_text("Repack the current project into the existing PSU file.");
+ if update_button.clicked() {
+ app.handle_update_psu_request();
+ }
+
+ let export_button = ui
+ .add_enabled(
+ !pack_in_progress,
+ egui::Button::new("Save as Folder with contents"),
+ )
+ .on_hover_text("Export the contents of the current PSU archive to a folder.");
+ if export_button.clicked() {
+ app.handle_save_as_folder_with_contents();
+ }
+ });
+
+ if pack_in_progress {
+ ui.label("Packing in progress…");
+ }
+
+ if let Some(error) = &app.error_message {
+ ui.colored_label(egui::Color32::RED, error);
+ }
+ if !app.status.is_empty() {
+ ui.label(&app.status);
+ }
+ });
+}
+
+#[derive(Copy, Clone)]
+pub(crate) enum ListKind {
+ Include,
+ Exclude,
+}
+
+impl ListKind {
+ fn label(self) -> &'static str {
+ match self {
+ ListKind::Include => "Include files",
+ ListKind::Exclude => "Exclude files",
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::NaiveDate;
+ use ps2_filetypes::sjis;
+ use std::path::PathBuf;
+
+ #[test]
+ fn config_from_state_appends_psu_toml_once() {
+ let mut app = PackerApp::default();
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.selected_prefix = SasPrefix::App;
+
+ let config = app.config_from_state().expect("configuration should build");
+ assert_eq!(config.exclude, Some(vec!["psu.toml".to_string()]));
+ assert!(
+ app.exclude_files.is_empty(),
+ "building the configuration should not modify the exclude list"
+ );
+
+ app.exclude_files = vec!["DATA.BIN".to_string()];
+ let config_with_manual_entry = app
+ .config_from_state()
+ .expect("configuration should include manual exclude");
+ assert_eq!(
+ config_with_manual_entry.exclude,
+ Some(vec!["DATA.BIN".to_string(), "psu.toml".to_string()])
+ );
+
+ app.exclude_files = vec!["psu.toml".to_string()];
+ let config_with_duplicate = app
+ .config_from_state()
+ .expect("configuration should handle duplicate entries");
+ assert_eq!(
+ config_with_duplicate.exclude,
+ Some(vec!["psu.toml".to_string()])
+ );
+ }
+
+ #[test]
+ fn build_config_uses_loaded_psu_edits() {
+ let mut app = PackerApp::default();
+ app.loaded_psu_path = Some(PathBuf::from("input.psu"));
+ app.selected_prefix = SasPrefix::Emu;
+ app.folder_base_name = "SAVE".to_string();
+ let timestamp = NaiveDate::from_ymd_opt(2023, 11, 14)
+ .and_then(|date| date.and_hms_opt(12, 34, 56))
+ .expect("valid timestamp");
+ app.timestamp = Some(timestamp);
+ app.include_files.push("FILE.BIN".to_string());
+ app.exclude_files.push("SKIP.DAT".to_string());
+
+ let config = app.build_config().expect("config builds successfully");
+ assert_eq!(config.name, "EMU_SAVE");
+ assert_eq!(config.timestamp, Some(timestamp));
+ assert_eq!(config.include, Some(vec!["FILE.BIN".to_string()]));
+ assert_eq!(
+ config.exclude,
+ Some(vec!["SKIP.DAT".to_string(), "psu.toml".to_string()])
+ );
+ }
+
+ #[test]
+ fn manual_filter_entries_allowed_without_folder() {
+ let mut app = PackerApp::default();
+ app.selected_prefix = SasPrefix::App;
+ app.folder_base_name = "SAVE".to_string();
+
+ assert!(app.handle_add_file_from_entry(ListKind::Include, "BOOT.ELF"));
+ assert!(app.handle_add_file_from_entry(ListKind::Exclude, "THUMBS.DB"));
+
+ let config = app.build_config().expect("config builds successfully");
+ assert_eq!(config.include, Some(vec!["BOOT.ELF".to_string()]));
+ assert_eq!(
+ config.exclude,
+ Some(vec!["THUMBS.DB".to_string(), "psu.toml".to_string()])
+ );
+ }
+
+ #[test]
+ fn manual_filter_entries_trim_and_reject_duplicates() {
+ let mut app = PackerApp::default();
+
+ assert!(app.handle_add_file_from_entry(ListKind::Include, " DATA.BIN "));
+ assert_eq!(app.include_files, vec!["DATA.BIN"]);
+
+ assert!(!app.handle_add_file_from_entry(ListKind::Include, "DATA.BIN"));
+ assert_eq!(app.include_files, vec!["DATA.BIN"]);
+ assert!(app.error_message.is_some());
+ }
+
+ #[test]
+ fn config_from_state_uses_shift_jis_byte_linebreaks() {
+ let mut app = PackerApp::default();
+ app.selected_prefix = SasPrefix::App;
+ app.folder_base_name = "SAVE".to_string();
+ app.psu_file_base_name = "SAVE".to_string();
+ app.icon_sys_enabled = true;
+ app.icon_sys_use_existing = false;
+ app.icon_sys_title_line1 = "メモ".to_string();
+ app.icon_sys_title_line2 = "リーカード".to_string();
+
+ let config = app.config_from_state().expect("configuration should build");
+ let icon_sys = config.icon_sys.expect("icon_sys configuration present");
+ let expected_break = sjis::encode_sjis(&app.icon_sys_title_line1).unwrap().len() as u16;
+
+ assert_eq!(icon_sys.linebreak_pos, Some(expected_break));
+ }
+}
+
+struct FileListActions {
+ browse_add: bool,
+ remove: bool,
+ manual_add: Option,
+}
+
+fn file_list_ui(
+ ui: &mut egui::Ui,
+ label: &str,
+ files: &mut Vec,
+ selected: &mut Option,
+ manual_entry: &mut String,
+ allow_browse: bool,
+) -> FileListActions {
+ let mut browse_clicked = false;
+ let mut remove_clicked = false;
+ let mut manual_added: Option = None;
+ let has_selection = selected.is_some();
+
+ ui.horizontal(|ui| {
+ ui.label(label);
+ ui.add_space(ui.spacing().item_spacing.x);
+
+ let browse_button = egui::Button::new("📁").small();
+ let browse_response = ui
+ .add_enabled(allow_browse, browse_button)
+ .on_hover_text("Browse for files in the selected folder.");
+ if browse_response.clicked() {
+ browse_clicked = true;
+ }
+
+ if ui
+ .add_enabled(has_selection, egui::Button::new("➖").small())
+ .on_hover_text("Remove the selected file from this list.")
+ .clicked()
+ {
+ remove_clicked = true;
+ }
+ });
+
+ ui.horizontal(|ui| {
+ let response =
+ ui.add(egui::TextEdit::singleline(manual_entry).hint_text("Add file by name"));
+ let add_manual = ui
+ .add(egui::Button::new("Add").small())
+ .on_hover_text("Add the typed entry to this list.")
+ .clicked();
+ let enter_pressed = ui.input(|input| input.key_pressed(egui::Key::Enter));
+
+ if add_manual || (response.lost_focus() && enter_pressed) {
+ let value = manual_entry.trim();
+ if !value.is_empty() {
+ manual_added = Some(value.to_string());
+ manual_entry.clear();
+ }
+ }
+ });
+
+ egui::ScrollArea::vertical()
+ .max_height(150.0)
+ .show(ui, |ui| {
+ for (idx, file) in files.iter().enumerate() {
+ ui.horizontal(|ui| {
+ let is_selected = Some(idx) == *selected;
+ if ui.selectable_label(is_selected, file).clicked() {
+ *selected = Some(idx);
+ }
+
+ ui.add_space(ui.spacing().item_spacing.x);
+
+ if ui
+ .small_button("✖")
+ .on_hover_text("Remove this file from the list.")
+ .clicked()
+ {
+ *selected = Some(idx);
+ remove_clicked = true;
+ }
+ });
+ }
+ });
+
+ FileListActions {
+ browse_add: browse_clicked,
+ remove: remove_clicked,
+ manual_add: manual_added,
+ }
+}
+
+impl PackerApp {
+ pub(crate) fn browse_output_destination(&mut self) -> bool {
+ let mut dialog = rfd::FileDialog::new().add_filter("PSU", &["psu"]);
+
+ let trimmed_output = self.output.trim();
+ if trimmed_output.is_empty() {
+ if let Some(default_dir) = self.default_output_directory(None) {
+ dialog = dialog.set_directory(default_dir);
+ }
+ if let Some(default_name) = self.default_output_file_name() {
+ dialog = dialog.set_file_name(&default_name);
+ }
+ } else {
+ let current_path = Path::new(trimmed_output);
+ if let Some(parent) = current_path.parent() {
+ if !parent.as_os_str().is_empty() {
+ dialog = dialog.set_directory(parent);
+ } else if let Some(default_dir) = self.default_output_directory(None) {
+ dialog = dialog.set_directory(default_dir);
+ }
+ } else if let Some(default_dir) = self.default_output_directory(None) {
+ dialog = dialog.set_directory(default_dir);
+ }
+
+ if let Some(existing_name) = current_path.file_name().and_then(|name| name.to_str()) {
+ dialog = dialog.set_file_name(existing_name);
+ } else if let Some(default_name) = self.default_output_file_name() {
+ dialog = dialog.set_file_name(&default_name);
+ }
+ }
+
+ if let Some(mut file) = dialog.save_file() {
+ let has_psu_extension = file
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .map(|ext| ext.eq_ignore_ascii_case("psu"))
+ .unwrap_or(false);
+
+ if !has_psu_extension {
+ file.set_extension("psu");
+ }
+
+ self.output = file.display().to_string();
+ true
+ } else {
+ false
+ }
+ }
+
+ pub(crate) fn ensure_output_destination_selected(&mut self) -> bool {
+ if self.output.trim().is_empty() {
+ if let Some(path) = self.default_output_path() {
+ self.output = path.display().to_string();
+ }
+ }
+
+ if self.output.trim().is_empty() {
+ return self.browse_output_destination();
+ }
+
+ true
+ }
+
+ pub(crate) fn build_config(&self) -> Result {
+ self.validate_icon_sys_settings()?;
+ self.config_from_state()
+ }
+
+ pub(crate) fn format_pack_error(
+ &self,
+ folder: &Path,
+ output_path: &Path,
+ err: psu_packer::Error,
+ ) -> String {
+ match err {
+ psu_packer::Error::NameError => {
+ "PSU name can only contain letters, numbers, spaces, underscores, and hyphens."
+ .to_string()
+ }
+ psu_packer::Error::ConfigError(message) => {
+ format!("Configuration error: {message}")
+ }
+ psu_packer::Error::IOError(io_err) => {
+ let missing_files = self.missing_include_files(folder);
+ if !missing_files.is_empty() {
+ let formatted = missing_files
+ .into_iter()
+ .map(|name| format!("• {name}"))
+ .collect::>()
+ .join("\n");
+ return format!(
+ "The following files referenced in the configuration are missing from {}:\n{}",
+ folder.display(),
+ formatted
+ );
+ }
+
+ match io_err.kind() {
+ std::io::ErrorKind::NotFound => {
+ if let Some(parent) = output_path.parent() {
+ if !parent.exists() {
+ return format!(
+ "Cannot write the PSU file because the destination folder {} does not exist.",
+ parent.display()
+ );
+ }
+ }
+ format!("A required file or folder could not be found: {io_err}")
+ }
+ std::io::ErrorKind::PermissionDenied => {
+ format!("Permission denied while accessing the file system: {io_err}")
+ }
+ _ => format!("File system error: {io_err}"),
+ }
+ }
+ }
+ }
+
+ pub(crate) fn handle_add_file(&mut self, kind: ListKind) -> bool {
+ let Some(folder) = self.folder.clone() else {
+ return false;
+ };
+
+ let list_label = kind.label();
+
+ let Some(paths) = rfd::FileDialog::new().set_directory(&folder).pick_files() else {
+ return false;
+ };
+
+ if paths.is_empty() {
+ return false;
+ }
+
+ let mut invalid_entries = Vec::new();
+ let mut added_any = false;
+
+ for path in paths {
+ let Ok(relative) = path.strip_prefix(&folder) else {
+ invalid_entries.push(format!(
+ "{} (must be in the selected folder)",
+ path.display()
+ ));
+ continue;
+ };
+
+ if relative.components().count() != 1 {
+ invalid_entries.push(format!(
+ "{} (must be in the selected folder)",
+ path.display()
+ ));
+ continue;
+ }
+
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+ invalid_entries.push(format!("{} (invalid file name)", path.display()));
+ continue;
+ };
+
+ match self.add_file_entry(kind, name) {
+ Ok(_) => {
+ added_any = true;
+ }
+ Err(err) => {
+ invalid_entries.push(err);
+ }
+ }
+ }
+
+ if invalid_entries.is_empty() {
+ if added_any {
+ self.clear_error_message();
+ self.status.clear();
+ }
+ } else {
+ let message = format!("Some files could not be added to the {list_label} list");
+ self.set_error_message((message, invalid_entries));
+ }
+
+ added_any
+ }
+
+ pub(crate) fn handle_add_file_from_entry(&mut self, kind: ListKind, entry: &str) -> bool {
+ let list_label = kind.label();
+ match self.add_file_entry(kind, entry) {
+ Ok(_) => {
+ self.clear_error_message();
+ self.status.clear();
+ true
+ }
+ Err(err) => {
+ let message = format!("Could not add the entry to the {list_label} list");
+ self.set_error_message((message, vec![err]));
+ false
+ }
+ }
+ }
+
+ pub(crate) fn handle_remove_file(&mut self, kind: ListKind) -> bool {
+ let (files, selected) = self.list_mut(kind);
+ let mut removed = false;
+ if let Some(idx) = selected.take() {
+ files.remove(idx);
+ removed = true;
+ if files.is_empty() {
+ *selected = None;
+ } else if idx >= files.len() {
+ *selected = Some(files.len() - 1);
+ } else {
+ *selected = Some(idx);
+ }
+ }
+ removed
+ }
+
+ fn list_mut(&mut self, kind: ListKind) -> (&mut Vec, &mut Option) {
+ match kind {
+ ListKind::Include => (&mut self.include_files, &mut self.selected_include),
+ ListKind::Exclude => (&mut self.exclude_files, &mut self.selected_exclude),
+ }
+ }
+
+ fn add_file_entry(&mut self, kind: ListKind, entry: &str) -> Result {
+ let trimmed = entry.trim();
+ if trimmed.is_empty() {
+ return Err("File name cannot be empty".to_string());
+ }
+
+ let (files, selected) = self.list_mut(kind);
+ if files.iter().any(|existing| existing == trimmed) {
+ return Err(format!("{trimmed} (already listed)"));
+ }
+
+ files.push(trimmed.to_string());
+ let index = files.len() - 1;
+ *selected = Some(index);
+ Ok(index)
+ }
+
+ fn validate_icon_sys_settings(&self) -> Result<(), String> {
+ if self.icon_sys_enabled && !self.icon_sys_use_existing {
+ let line1 = &self.icon_sys_title_line1;
+ let line2 = &self.icon_sys_title_line2;
+
+ if line1.chars().count() > ICON_SYS_TITLE_CHAR_LIMIT {
+ return Err(format!(
+ "Icon.sys line 1 cannot exceed {ICON_SYS_TITLE_CHAR_LIMIT} characters"
+ ));
+ }
+ if line2.chars().count() > ICON_SYS_TITLE_CHAR_LIMIT {
+ return Err(format!(
+ "Icon.sys line 2 cannot exceed {ICON_SYS_TITLE_CHAR_LIMIT} characters"
+ ));
+ }
+ let title_is_valid = |value: &str| {
+ !value.chars().any(|c| c.is_control()) && sjis::is_roundtrip_sjis(value)
+ };
+ if !title_is_valid(line1) || !title_is_valid(line2) {
+ return Err(
+ "Icon.sys titles must contain characters representable in Shift-JIS"
+ .to_string(),
+ );
+ }
+
+ let has_content = line1.chars().any(|c| !c.is_whitespace())
+ || line2.chars().any(|c| !c.is_whitespace());
+ if !has_content {
+ return Err(
+ "Provide at least one non-space character for the icon.sys title".to_string(),
+ );
+ }
+
+ self.selected_icon_flag_value()?;
+ }
+
+ Ok(())
+ }
+
+ fn config_from_state(&self) -> Result {
+ let include = if self.include_files.is_empty() {
+ None
+ } else {
+ Some(self.include_files.clone())
+ };
+
+ let mut exclude = self.exclude_files.clone();
+ if !exclude.iter().any(|entry| entry == "psu.toml") {
+ exclude.push("psu.toml".to_string());
+ }
+ let exclude = Some(exclude);
+
+ let icon_sys = if self.icon_sys_enabled && !self.icon_sys_use_existing {
+ let encoded_line1 = sjis::encode_sjis(&self.icon_sys_title_line1).map_err(|_| {
+ "Icon.sys titles must contain characters representable in Shift-JIS".to_string()
+ })?;
+ let linebreak_pos = encoded_line1.len() as u16;
+ let combined_title =
+ format!("{}{}", self.icon_sys_title_line1, self.icon_sys_title_line2);
+ let flag_value = self.selected_icon_flag_value()?;
+
+ Some(psu_packer::IconSysConfig {
+ flags: psu_packer::IconSysFlags::new(flag_value),
+ title: combined_title,
+ linebreak_pos: Some(linebreak_pos),
+ preset: self.icon_sys_selected_preset.clone(),
+ background_transparency: Some(self.icon_sys_background_transparency),
+ background_colors: Some(self.icon_sys_background_colors.to_vec()),
+ light_directions: Some(self.icon_sys_light_directions.to_vec()),
+ light_colors: Some(self.icon_sys_light_colors.to_vec()),
+ ambient_color: Some(self.icon_sys_ambient_color),
+ })
+ } else {
+ None
+ };
+
+ if self.folder_base_name.trim().is_empty() {
+ return Err("PSU name cannot be empty".to_string());
+ }
+
+ let name = self.folder_name();
+
+ Ok(psu_packer::Config {
+ name,
+ timestamp: self.timestamp,
+ include,
+ exclude,
+ icon_sys,
+ })
+ }
+
+ #[cfg(feature = "psu-toml-editor")]
+ pub(crate) fn refresh_psu_toml_editor(&mut self) {
+ if self.folder.is_none() {
+ self.psu_toml_sync_blocked = false;
+ return;
+ }
+
+ if self.psu_toml_editor.modified {
+ self.psu_toml_sync_blocked = true;
+ return;
+ }
+
+ let config = match self.config_from_state() {
+ Ok(config) => config,
+ Err(_) => {
+ self.psu_toml_sync_blocked = true;
+ return;
+ }
+ };
+
+ match config.to_toml_string() {
+ Ok(serialized) => {
+ self.psu_toml_editor.set_content(serialized);
+ self.psu_toml_sync_blocked = false;
+ }
+ Err(_) => {
+ self.psu_toml_sync_blocked = true;
+ }
+ }
+ }
+
+ #[cfg(not(feature = "psu-toml-editor"))]
+ pub(crate) fn refresh_psu_toml_editor(&mut self) {
+ self.psu_toml_sync_blocked = false;
+ }
+}
diff --git a/crates/psu-packer-gui/src/ui/theme.rs b/crates/psu-packer-gui/src/ui/theme.rs
new file mode 100644
index 0000000..4111a95
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/theme.rs
@@ -0,0 +1,171 @@
+use eframe::egui::{
+ self, Color32, FontData, FontDefinitions, FontFamily, FontId, Margin, RichText, Style,
+ TextStyle, Vec2,
+};
+
+pub const DISPLAY_FONT_NAME: &str = "ps2_display";
+
+#[derive(Clone)]
+pub struct Palette {
+ pub background: Color32,
+ pub panel: Color32,
+ pub input_background: Color32,
+ pub header_top: Color32,
+ pub header_bottom: Color32,
+ pub footer_top: Color32,
+ pub footer_bottom: Color32,
+ pub neon_accent: Color32,
+ pub soft_accent: Color32,
+ pub separator: Color32,
+ pub text_primary: Color32,
+}
+
+impl Default for Palette {
+ fn default() -> Self {
+ Self {
+ background: Color32::from_rgb(6, 8, 20),
+ panel: Color32::from_rgb(18, 38, 52),
+ input_background: Color32::from_rgb(32, 58, 78),
+ header_top: Color32::from_rgb(12, 16, 40),
+ header_bottom: Color32::from_rgb(60, 40, 120),
+ footer_top: Color32::from_rgb(16, 30, 52),
+ footer_bottom: Color32::from_rgb(52, 52, 112),
+ neon_accent: Color32::from_rgb(150, 92, 255),
+ soft_accent: Color32::from_rgb(124, 148, 220),
+ separator: Color32::from_rgb(88, 68, 168),
+ text_primary: Color32::from_rgb(214, 220, 240),
+ }
+ }
+}
+
+pub fn install(ctx: &egui::Context, palette: &Palette) {
+ install_fonts(ctx);
+ apply_visuals(ctx, palette);
+ ctx.style_mut(|style| {
+ apply_text_styles(style);
+ apply_spacing(style);
+ });
+}
+
+pub fn display_font(size: f32) -> FontId {
+ FontId::new(size, FontFamily::Name(DISPLAY_FONT_NAME.into()))
+}
+
+fn install_fonts(ctx: &egui::Context) {
+ let mut fonts = FontDefinitions::default();
+ fonts.font_data.insert(
+ DISPLAY_FONT_NAME.to_owned(),
+ FontData::from_static(include_bytes!("../../assets/fonts/Orbitron-Regular.ttf")).into(),
+ );
+
+ fonts
+ .families
+ .entry(FontFamily::Name(DISPLAY_FONT_NAME.into()))
+ .or_default()
+ .insert(0, DISPLAY_FONT_NAME.to_owned());
+
+ ctx.set_fonts(fonts);
+}
+
+pub fn display_heading_text(ui: &egui::Ui, text: impl Into) -> RichText {
+ let size = ui.style().text_styles[&TextStyle::Heading].size;
+ RichText::new(text).font(display_font(size))
+}
+
+fn apply_visuals(ctx: &egui::Context, palette: &Palette) {
+ let mut visuals = egui::Visuals::dark();
+ visuals.override_text_color = Some(palette.text_primary);
+ visuals.widgets.noninteractive.bg_fill = palette.input_background.gamma_multiply(0.9);
+ visuals.widgets.noninteractive.fg_stroke.color = palette.text_primary;
+ visuals.widgets.inactive.bg_fill = palette.input_background;
+ visuals.widgets.inactive.fg_stroke.color = palette.text_primary;
+ visuals.widgets.hovered.bg_fill = palette.soft_accent.gamma_multiply(0.22);
+ visuals.widgets.active.bg_fill = palette.soft_accent.gamma_multiply(0.32);
+ visuals.widgets.open.bg_fill = palette.input_background.gamma_multiply(0.95);
+ visuals.extreme_bg_color = palette.background;
+ visuals.faint_bg_color = palette.background;
+ visuals.panel_fill = palette.background;
+
+ ctx.set_visuals(visuals);
+}
+
+fn apply_spacing(style: &mut Style) {
+ style.spacing.item_spacing = Vec2::new(12.0, 8.0);
+ style.spacing.button_padding = Vec2::new(14.0, 8.0);
+ style.spacing.window_margin = Margin::same(14);
+ style.spacing.menu_margin = Margin::same(10);
+ style.spacing.indent = 20.0;
+}
+
+fn apply_text_styles(style: &mut Style) {
+ style
+ .text_styles
+ .insert(TextStyle::Heading, FontId::proportional(28.0));
+ style
+ .text_styles
+ .insert(TextStyle::Body, FontId::proportional(18.0));
+ style
+ .text_styles
+ .insert(TextStyle::Button, FontId::proportional(18.0));
+ style
+ .text_styles
+ .insert(TextStyle::Small, FontId::proportional(15.0));
+ style
+ .text_styles
+ .insert(TextStyle::Monospace, FontId::monospace(16.0));
+}
+
+pub fn draw_vertical_gradient(
+ painter: &egui::Painter,
+ rect: egui::Rect,
+ top: Color32,
+ bottom: Color32,
+) {
+ if rect.width() <= 0.0 || rect.height() <= 0.0 {
+ return;
+ }
+
+ let mut mesh = egui::epaint::Mesh::default();
+
+ let top_left = rect.left_top();
+ let top_right = rect.right_top();
+ let bottom_left = rect.left_bottom();
+ let bottom_right = rect.right_bottom();
+
+ let base_index = mesh.vertices.len() as u32;
+ mesh.vertices.push(egui::epaint::Vertex {
+ pos: top_left,
+ uv: egui::pos2(0.0, 0.0),
+ color: top,
+ });
+ mesh.vertices.push(egui::epaint::Vertex {
+ pos: top_right,
+ uv: egui::pos2(1.0, 0.0),
+ color: top,
+ });
+ mesh.vertices.push(egui::epaint::Vertex {
+ pos: bottom_left,
+ uv: egui::pos2(0.0, 1.0),
+ color: bottom,
+ });
+ mesh.vertices.push(egui::epaint::Vertex {
+ pos: bottom_right,
+ uv: egui::pos2(1.0, 1.0),
+ color: bottom,
+ });
+
+ mesh.indices.extend_from_slice(&[
+ base_index,
+ base_index + 1,
+ base_index + 2,
+ base_index + 2,
+ base_index + 1,
+ base_index + 3,
+ ]);
+
+ painter.add(egui::Shape::mesh(mesh));
+}
+
+pub fn draw_separator(painter: &egui::Painter, rect: egui::Rect, color: Color32) {
+ painter.rect_filled(rect, 0.0, color);
+}
diff --git a/crates/psu-packer-gui/src/ui/timestamps.rs b/crates/psu-packer-gui/src/ui/timestamps.rs
new file mode 100644
index 0000000..6ee776a
--- /dev/null
+++ b/crates/psu-packer-gui/src/ui/timestamps.rs
@@ -0,0 +1,611 @@
+use std::collections::HashSet;
+
+use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
+use eframe::egui;
+use egui_extras::DatePickerButton;
+
+use crate::{sas_timestamps, ui::theme, PackerApp, TimestampStrategy, TIMESTAMP_FORMAT};
+
+pub(crate) fn metadata_timestamp_section(app: &mut PackerApp, ui: &mut egui::Ui) {
+ ui.vertical(|ui| {
+ let default_timestamp = default_timestamp();
+ let source_timestamp = app.source_timestamp;
+ let planned_timestamp = app.planned_timestamp_for_current_source();
+ let recommended_strategy = recommended_timestamp_strategy(source_timestamp, planned_timestamp);
+
+ ui.small(
+ "Deterministic timestamps ensure repeated packs produce identical archives for verification.",
+ );
+ ui.add_space(6.0);
+
+ let mut strategy = app.timestamp_strategy;
+ let recommended_badge = |ui: &mut egui::Ui| {
+ let badge_text = egui::RichText::new("Recommended")
+ .color(egui::Color32::WHITE)
+ .background_color(egui::Color32::from_rgb(38, 166, 65))
+ .strong();
+ ui.add(egui::Label::new(badge_text))
+ .on_hover_text("Best choice based on the available metadata");
+ };
+
+ ui.group(|ui| {
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ let response = ui.radio_value(
+ &mut strategy,
+ TimestampStrategy::None,
+ "No timestamp",
+ );
+ if response.changed()
+ && app.timestamp_strategy != TimestampStrategy::None
+ && strategy == TimestampStrategy::None
+ {
+ app.set_timestamp_strategy(strategy);
+ }
+ });
+ ui.label("• Use when verifying contents does not require metadata timestamps.");
+ ui.label("• Relies on: no metadata—timestamp field will be omitted.");
+ });
+ });
+
+ ui.add_space(6.0);
+
+ ui.group(|ui| {
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ let response = ui.radio_value(
+ &mut strategy,
+ TimestampStrategy::InheritSource,
+ "Use source timestamp",
+ );
+ if recommended_strategy == Some(TimestampStrategy::InheritSource) {
+ recommended_badge(ui);
+ }
+ if response.changed()
+ && app.timestamp_strategy != TimestampStrategy::InheritSource
+ && strategy == TimestampStrategy::InheritSource
+ {
+ app.set_timestamp_strategy(strategy);
+ }
+ });
+ ui.label("• Use when the loaded source already contains a trusted timestamp.");
+ ui.label(format!(
+ "• Relies on: Source timestamp ({}).",
+ availability_text(source_timestamp, "available", "unavailable")
+ ));
+ if let Some(ts) = source_timestamp {
+ ui.small(format!(" Source value: {}", ts.format(TIMESTAMP_FORMAT)));
+ }
+ });
+ });
+
+ ui.add_space(6.0);
+
+ ui.group(|ui| {
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ let response = ui.radio_value(
+ &mut strategy,
+ TimestampStrategy::SasRules,
+ "Use SAS prefix rules",
+ );
+ if recommended_strategy == Some(TimestampStrategy::SasRules) {
+ recommended_badge(ui);
+ }
+ if response.changed()
+ && app.timestamp_strategy != TimestampStrategy::SasRules
+ && strategy == TimestampStrategy::SasRules
+ {
+ app.set_timestamp_strategy(strategy);
+ }
+ });
+ ui.label("• Use when project names follow SAS conventions for deterministic scheduling.");
+ let project_name = project_name_text(app);
+ ui.label(format!(
+ "• Relies on: Project name ({project_name}) and timestamp rules (planned value {}).",
+ availability_text(planned_timestamp, "available", "unavailable")
+ ));
+ if let Some(ts) = planned_timestamp {
+ ui.small(format!(" Planned value: {}", ts.format(TIMESTAMP_FORMAT)));
+ }
+ });
+ });
+
+ ui.add_space(6.0);
+
+ let mut manual_timestamp_changed = false;
+
+ ui.group(|ui| {
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ let response = ui.radio_value(
+ &mut strategy,
+ TimestampStrategy::Manual,
+ "Manual timestamp",
+ );
+ if recommended_strategy == Some(TimestampStrategy::Manual) {
+ recommended_badge(ui);
+ }
+ if response.changed()
+ && app.timestamp_strategy != TimestampStrategy::Manual
+ && strategy == TimestampStrategy::Manual
+ {
+ app.set_timestamp_strategy(strategy);
+ }
+ });
+ ui.label("• Use when you must pin the archive to an explicit, reviewer-approved timestamp.");
+ ui.label("• Relies on: Manual date and time you enter here.");
+
+ if strategy == TimestampStrategy::Manual
+ && app.manual_timestamp.is_none()
+ {
+ app.manual_timestamp = Some(default_timestamp);
+ app.refresh_timestamp_from_strategy();
+ }
+
+ if strategy == TimestampStrategy::Manual {
+ let mut timestamp = app.manual_timestamp.unwrap_or(default_timestamp);
+ let mut date: NaiveDate = timestamp.date();
+ let time = timestamp.time();
+ let mut hour = time.hour();
+ let mut minute = time.minute();
+ let mut second = time.second();
+ let mut changed = false;
+
+ ui.add_space(6.0);
+ ui.horizontal(|ui| {
+ let date_response = ui.add(
+ DatePickerButton::new(&mut date)
+ .id_source("metadata_timestamp_date_picker"),
+ );
+ changed |= date_response.changed();
+
+ ui.label("Time");
+ changed |= ui
+ .add(
+ egui::DragValue::new(&mut hour)
+ .clamp_range(0..=23)
+ .suffix(" h"),
+ )
+ .changed();
+ changed |= ui
+ .add(
+ egui::DragValue::new(&mut minute)
+ .clamp_range(0..=59)
+ .suffix(" m"),
+ )
+ .changed();
+ changed |= ui
+ .add(
+ egui::DragValue::new(&mut second)
+ .clamp_range(0..=59)
+ .suffix(" s"),
+ )
+ .changed();
+ });
+
+ if changed {
+ if let Some(new_time) = NaiveTime::from_hms_opt(hour, minute, second) {
+ timestamp = NaiveDateTime::new(date, new_time);
+ app.manual_timestamp = Some(timestamp);
+ manual_timestamp_changed = true;
+ }
+ } else if app.manual_timestamp != Some(timestamp) {
+ app.manual_timestamp = Some(timestamp);
+ manual_timestamp_changed = true;
+ }
+
+ if let Some(ts) = app.manual_timestamp {
+ ui.small(format!("Selected: {}", ts.format(TIMESTAMP_FORMAT)));
+ }
+
+ if let Some(planned) = planned_timestamp {
+ if ui.button("Copy planned timestamp").clicked() {
+ app.manual_timestamp = Some(planned);
+ manual_timestamp_changed = true;
+ }
+ }
+ }
+ });
+ });
+
+ if strategy != app.timestamp_strategy {
+ app.set_timestamp_strategy(strategy);
+ }
+
+ if manual_timestamp_changed {
+ app.refresh_timestamp_from_strategy();
+ }
+
+ ui.add_space(8.0);
+
+ let summary_title = current_strategy_title(app.timestamp_strategy);
+ let summary_reason = current_strategy_reason(app, source_timestamp, planned_timestamp);
+ let summary_text = format!("Currently using: {summary_title} because {summary_reason}.");
+
+ ui.group(|ui| {
+ ui.label(egui::RichText::new(summary_text).strong());
+ });
+
+ ui.add_space(6.0);
+ });
+}
+
+fn recommended_timestamp_strategy(
+ source_timestamp: Option,
+ planned_timestamp: Option,
+) -> Option {
+ if source_timestamp.is_some() {
+ Some(TimestampStrategy::InheritSource)
+ } else if planned_timestamp.is_some() {
+ Some(TimestampStrategy::SasRules)
+ } else {
+ Some(TimestampStrategy::Manual)
+ }
+}
+
+fn availability_text(
+ timestamp: Option,
+ available_text: &str,
+ unavailable_text: &str,
+) -> String {
+ if timestamp.is_some() {
+ available_text.to_string()
+ } else {
+ unavailable_text.to_string()
+ }
+}
+
+fn project_name_text(app: &PackerApp) -> String {
+ let name = app.folder_name();
+ if name.trim().is_empty() {
+ "not set".to_string()
+ } else {
+ name
+ }
+}
+
+fn current_strategy_title(strategy: TimestampStrategy) -> &'static str {
+ match strategy {
+ TimestampStrategy::None => "No timestamp",
+ TimestampStrategy::InheritSource => "Inherited source timestamp",
+ TimestampStrategy::SasRules => "SAS rules timestamp",
+ TimestampStrategy::Manual => "Manual timestamp",
+ }
+}
+
+fn current_strategy_reason(
+ app: &PackerApp,
+ source_timestamp: Option,
+ planned_timestamp: Option,
+) -> String {
+ match app.timestamp_strategy {
+ TimestampStrategy::None => {
+ "timestamps are intentionally omitted from the archive".to_string()
+ }
+ TimestampStrategy::InheritSource => match source_timestamp {
+ Some(ts) => format!(
+ "the loaded source provided {} to preserve",
+ ts.format(TIMESTAMP_FORMAT)
+ ),
+ None => "no source timestamp was found to inherit".to_string(),
+ },
+ TimestampStrategy::SasRules => match planned_timestamp {
+ Some(ts) => format!(
+ "SAS rules computed {} for {}",
+ ts.format(TIMESTAMP_FORMAT),
+ app.folder_name()
+ ),
+ None => "automatic SAS rules could not determine a timestamp".to_string(),
+ },
+ TimestampStrategy::Manual => match app.manual_timestamp {
+ Some(ts) => format!("you entered {}", ts.format(TIMESTAMP_FORMAT)),
+ None => "a manual timestamp is required until other data is provided".to_string(),
+ },
+ }
+}
+
+pub(crate) fn timestamp_rules_editor(app: &mut PackerApp, ui: &mut egui::Ui) {
+ app.timestamp_rules_ui.ensure_matches(&app.timestamp_rules);
+
+ ui.heading(theme::display_heading_text(ui, "Automatic timestamp rules"));
+ ui.small("Adjust deterministic timestamp spacing, category order, and aliases.");
+
+ if let Some(error) = &app.timestamp_rules_error {
+ ui.add_space(6.0);
+ ui.colored_label(egui::Color32::YELLOW, error);
+ }
+
+ if let Some(path) = app.timestamp_rules_path() {
+ ui.label(format!("Configuration file: {}", path.display()));
+ } else {
+ ui.small("Select a project folder to save these settings alongside psu.toml.");
+ }
+
+ if app.timestamp_rules_modified {
+ ui.colored_label(egui::Color32::LIGHT_YELLOW, "Unsaved changes");
+ }
+
+ ui.add_space(8.0);
+ egui::Grid::new("timestamp_rules_settings")
+ .num_columns(2)
+ .spacing(egui::vec2(12.0, 6.0))
+ .show(ui, |ui| {
+ ui.label("Seconds between items");
+ let mut seconds = app.timestamp_rules.seconds_between_items.max(2);
+ if ui
+ .add(
+ egui::DragValue::new(&mut seconds)
+ .clamp_range(2..=3600)
+ .speed(1.0),
+ )
+ .changed()
+ {
+ let mut coerced = if seconds % 2 == 0 {
+ seconds
+ } else {
+ seconds + 1
+ };
+ coerced = coerced.clamp(2, 3600);
+ if app.timestamp_rules.seconds_between_items != coerced {
+ app.timestamp_rules.seconds_between_items = coerced;
+ app.mark_timestamp_rules_modified();
+ }
+ }
+ ui.end_row();
+
+ ui.label("Slots per category");
+ let mut slots = app.timestamp_rules.slots_per_category.max(1);
+ if ui
+ .add(
+ egui::DragValue::new(&mut slots)
+ .clamp_range(1..=200_000)
+ .speed(10.0),
+ )
+ .changed()
+ {
+ app.timestamp_rules.slots_per_category = slots.max(1);
+ app.mark_timestamp_rules_modified();
+ }
+ ui.end_row();
+ });
+
+ ui.add_space(12.0);
+ ui.heading(theme::display_heading_text(
+ ui,
+ "Category order and aliases",
+ ));
+ ui.small("Toggle canonical aliases to map known unprefixed names to their categories.");
+ ui.add_space(6.0);
+
+ let mut move_request: Option<(usize, MoveDirection)> = None;
+ let category_len = app.timestamp_rules.categories.len();
+
+ for index in 0..category_len {
+ let category = app.timestamp_rules.categories[index].clone();
+ let key = category.key.clone();
+ let alias_count = category.aliases.len();
+ let header_title = if alias_count == 1 {
+ format!("{key} (1 alias)")
+ } else {
+ format!("{key} ({alias_count} aliases)")
+ };
+
+ egui::CollapsingHeader::new(header_title)
+ .id_source(format!("timestamp_category_{index}"))
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ if ui
+ .add_enabled(index > 0, egui::Button::new("Move up"))
+ .clicked()
+ {
+ move_request = Some((index, MoveDirection::Up));
+ }
+ if ui
+ .add_enabled(index + 1 < category_len, egui::Button::new("Move down"))
+ .clicked()
+ {
+ move_request = Some((index, MoveDirection::Down));
+ }
+ });
+
+ ui.label("Canonical aliases:");
+ let allowed_aliases = sas_timestamps::canonical_aliases_for_category(&key);
+ if allowed_aliases.is_empty() {
+ ui.small("No canonical aliases are defined for this category.");
+ } else {
+ let mut selected: HashSet =
+ category.aliases.iter().cloned().collect();
+
+ for alias in allowed_aliases {
+ let mut is_selected = selected.contains(*alias);
+ if ui.checkbox(&mut is_selected, *alias).changed() {
+ if is_selected {
+ selected.insert((*alias).to_string());
+ } else {
+ selected.remove(*alias);
+ }
+
+ let new_selection: Vec = allowed_aliases
+ .iter()
+ .filter(|candidate| selected.contains(**candidate))
+ .map(|candidate| (*candidate).to_string())
+ .collect();
+ app.set_timestamp_aliases(index, new_selection);
+ }
+ }
+
+ if selected.is_empty() {
+ let alias_list = allowed_aliases.join(", ");
+ let warning = format!(
+ "No aliases selected. Unprefixed names ({alias_list}) will fall back to DEFAULT scheduling.",
+ );
+ ui.colored_label(egui::Color32::from_rgb(229, 115, 115), warning);
+ }
+ }
+ });
+ ui.add_space(6.0);
+ }
+
+ if let Some((index, direction)) = move_request {
+ match direction {
+ MoveDirection::Up => app.move_timestamp_category_up(index),
+ MoveDirection::Down => app.move_timestamp_category_down(index),
+ }
+ }
+
+ ui.add_space(10.0);
+ ui.horizontal(|ui| {
+ if ui.button("Restore defaults").clicked() {
+ app.reset_timestamp_rules_to_default();
+ }
+
+ let save_enabled = app.folder.is_some();
+ if ui
+ .add_enabled(save_enabled, egui::Button::new("Save"))
+ .clicked()
+ {
+ match app.save_timestamp_rules() {
+ Ok(path) => {
+ app.status = format!("Saved timestamp rules to {}", path.display());
+ app.clear_error_message();
+ if matches!(app.timestamp_strategy, TimestampStrategy::SasRules) {
+ app.apply_planned_timestamp();
+ }
+ }
+ Err(err) => app.set_error_message(err),
+ }
+ }
+
+ if ui
+ .add_enabled(save_enabled, egui::Button::new("Reload from disk"))
+ .clicked()
+ {
+ if let Some(folder) = app.folder.clone() {
+ app.load_timestamp_rules_from_folder(&folder);
+ if matches!(app.timestamp_strategy, TimestampStrategy::SasRules) {
+ app.apply_planned_timestamp();
+ }
+ }
+ }
+ });
+}
+
+fn default_timestamp() -> NaiveDateTime {
+ let now = Local::now().naive_local();
+ now.with_nanosecond(0).unwrap_or(now)
+}
+
+#[derive(Clone, Copy)]
+enum MoveDirection {
+ Up,
+ Down,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{PackerApp, SasPrefix, TimestampStrategy};
+ use chrono::{Duration, NaiveDate};
+ use eframe::egui;
+
+ #[test]
+ fn summary_references_source_when_inheriting() {
+ let mut app = PackerApp::default();
+ let source = NaiveDate::from_ymd_opt(2024, 1, 2)
+ .unwrap()
+ .and_hms_opt(3, 4, 5)
+ .unwrap();
+ app.source_timestamp = Some(source);
+ app.timestamp_strategy = TimestampStrategy::InheritSource;
+ app.refresh_timestamp_from_strategy();
+
+ let rendered = render_metadata_text(&mut app);
+
+ assert!(rendered.contains("No timestamp"));
+ assert!(rendered.contains("Source timestamp (available)"));
+ assert!(rendered.contains(
+ "Currently using: Inherited source timestamp because the loaded source provided 2024-01-02 03:04:05 to preserve."
+ ));
+ assert!(rendered.contains("Recommended"));
+ }
+
+ #[test]
+ fn summary_references_planned_when_using_sas_rules() {
+ let mut app = PackerApp::default();
+ app.source_timestamp = None;
+ app.selected_prefix = SasPrefix::App;
+ app.folder_base_name = "TEST".to_string();
+ app.timestamp_strategy = TimestampStrategy::SasRules;
+ app.refresh_timestamp_from_strategy();
+
+ let rendered = render_metadata_text(&mut app);
+
+ assert!(rendered.contains("Project name (APP_TEST)"));
+ assert!(rendered.contains("planned value available"));
+ assert!(rendered.contains("SAS rules timestamp because SAS rules computed"));
+ assert!(rendered.contains("Recommended"));
+ }
+
+ #[test]
+ fn manual_summary_updates_after_manual_timestamp_change() {
+ let mut app = PackerApp::default();
+ app.source_timestamp = None;
+ app.timestamp_strategy = TimestampStrategy::Manual;
+ let initial = NaiveDate::from_ymd_opt(2024, 5, 6)
+ .unwrap()
+ .and_hms_opt(7, 8, 9)
+ .unwrap();
+ app.manual_timestamp = Some(initial);
+ app.refresh_timestamp_from_strategy();
+
+ let rendered = render_metadata_text(&mut app);
+ assert!(rendered.contains(
+ "Currently using: Manual timestamp because you entered 2024-05-06 07:08:09."
+ ));
+
+ let updated = initial + Duration::minutes(5);
+ app.manual_timestamp = Some(updated);
+ app.refresh_timestamp_from_strategy();
+
+ let rerendered = render_metadata_text(&mut app);
+ assert!(rerendered.contains(
+ "Currently using: Manual timestamp because you entered 2024-05-06 07:13:09."
+ ));
+ }
+
+ fn render_metadata_text(app: &mut PackerApp) -> String {
+ let ctx = egui::Context::default();
+ ctx.begin_frame(egui::RawInput::default());
+ egui::CentralPanel::default().show(&ctx, |ui| {
+ metadata_timestamp_section(app, ui);
+ });
+ let full_output = ctx.end_frame();
+ let mut texts = Vec::new();
+ collect_text_from_clipped_shapes(&full_output.shapes, &mut texts);
+ texts.join("\n")
+ }
+
+ fn collect_text_from_clipped_shapes(
+ shapes: &[egui::epaint::ClippedShape],
+ output: &mut Vec,
+ ) {
+ for clipped in shapes {
+ collect_text_from_shape(&clipped.shape, output);
+ }
+ }
+
+ fn collect_text_from_shape(shape: &egui::epaint::Shape, output: &mut Vec) {
+ match shape {
+ egui::epaint::Shape::Vec(shapes) => {
+ for nested in shapes {
+ collect_text_from_shape(nested, output);
+ }
+ }
+ egui::epaint::Shape::Text(text_shape) => {
+ output.push(text_shape.galley.text().to_string());
+ }
+ _ => {}
+ }
+ }
+}
diff --git a/crates/psu-packer/Cargo.toml b/crates/psu-packer/Cargo.toml
index c4e31af..39e5391 100644
--- a/crates/psu-packer/Cargo.toml
+++ b/crates/psu-packer/Cargo.toml
@@ -13,6 +13,9 @@ argh = { version = "0.1.13" }
chrono = "0.4.42"
colored = "3.0.0"
+[dev-dependencies]
+tempfile = "3.14.0"
+
[profile.release]
opt-level = "z"
lto = true
diff --git a/crates/psu-packer/src/lib.rs b/crates/psu-packer/src/lib.rs
new file mode 100644
index 0000000..2a5f9d5
--- /dev/null
+++ b/crates/psu-packer/src/lib.rs
@@ -0,0 +1,906 @@
+use chrono::{DateTime, Local, NaiveDateTime};
+use colored::Colorize;
+use ps2_filetypes::color::Color;
+use ps2_filetypes::{
+ ColorF, IconSys, PSUEntry, PSUEntryKind, PSUWriter, Vector, DIR_ID, FILE_ID, PSU,
+};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+#[derive(Debug)]
+pub struct Config {
+ pub name: String,
+ pub timestamp: Option,
+ pub include: Option>,
+ pub exclude: Option>,
+ pub icon_sys: Option,
+}
+
+mod date_format {
+ use chrono::NaiveDateTime;
+ use serde::{self, Deserialize, Deserializer, Serializer};
+
+ const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
+
+ pub fn serialize(value: &Option, serializer: S) -> Result
+ where
+ S: Serializer,
+ {
+ match value {
+ Some(value) => serializer.serialize_some(&value.format(FORMAT).to_string()),
+ None => serializer.serialize_none(),
+ }
+ }
+
+ pub fn deserialize<'de, D>(deserialize: D) -> Result