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, D::Error> + where + D: Deserializer<'de>, + { + let s: Option = Option::deserialize(deserialize)?; + if let Some(s) = s { + Ok(Some( + NaiveDateTime::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)?, + )) + } else { + Ok(None) + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct ConfigFile { + config: ConfigSection, + #[serde(default, skip_serializing_if = "Option::is_none")] + icon_sys: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ConfigSection { + name: String, + #[serde(default, with = "date_format", skip_serializing_if = "Option::is_none")] + timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + include: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + exclude: Option>, +} + +impl From for Config { + fn from(file: ConfigFile) -> Self { + let ConfigFile { config, icon_sys } = file; + Self { + name: config.name, + timestamp: config.timestamp, + include: config.include, + exclude: config.exclude, + icon_sys, + } + } +} + +impl Config { + pub fn to_toml_string(&self) -> Result { + let config_section = ConfigSection { + name: self.name.clone(), + timestamp: self.timestamp, + include: self.include.clone(), + exclude: self.exclude.clone(), + }; + + let config_file = ConfigFile { + config: config_section, + icon_sys: self.icon_sys.clone(), + }; + + toml::to_string_pretty(&config_file) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct IconSysConfig { + pub flags: IconSysFlags, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linebreak_pos: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_transparency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_colors: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub light_directions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub light_colors: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ambient_color: Option, +} + +impl IconSysConfig { + fn to_bytes(&self) -> Result, Error> { + let icon_sys = self.build_icon_sys()?; + icon_sys + .to_bytes() + .map_err(|err| Error::ConfigError(err.to_string())) + } + + fn build_icon_sys(&self) -> Result { + let mut background_colors = DEFAULT_BACKGROUND_COLORS; + if let Some(colors) = &self.background_colors { + if colors.len() != background_colors.len() { + return Err(Error::ConfigError(format!( + "icon_sys.background_colors must contain exactly {} entries", + background_colors.len() + ))); + } + + for (target, value) in background_colors.iter_mut().zip(colors.iter()) { + *target = (*value).into(); + } + } + + let mut light_directions = DEFAULT_LIGHT_DIRECTIONS; + if let Some(directions) = &self.light_directions { + if directions.len() != light_directions.len() { + return Err(Error::ConfigError(format!( + "icon_sys.light_directions must contain exactly {} entries", + light_directions.len() + ))); + } + + for (target, value) in light_directions.iter_mut().zip(directions.iter()) { + *target = (*value).into(); + } + } + + let mut light_colors = DEFAULT_LIGHT_COLORS; + if let Some(colors) = &self.light_colors { + if colors.len() != light_colors.len() { + return Err(Error::ConfigError(format!( + "icon_sys.light_colors must contain exactly {} entries", + light_colors.len() + ))); + } + + for (target, value) in light_colors.iter_mut().zip(colors.iter()) { + *target = (*value).into(); + } + } + + let ambient_color = self + .ambient_color + .map(|color| color.into()) + .unwrap_or(DEFAULT_AMBIENT_COLOR); + + let background_transparency = self + .background_transparency + .unwrap_or(DEFAULT_BACKGROUND_TRANSPARENCY); + + let linebreak_pos = self.linebreak_pos.unwrap_or(DEFAULT_LINEBREAK_POS); + + Ok(IconSys { + flags: self.flags.value(), + linebreak_pos, + background_transparency, + background_colors, + light_directions, + light_colors, + ambient_color, + title: self.title.clone(), + icon_file: ICON_FILE_NAME.to_string(), + icon_copy_file: ICON_FILE_NAME.to_string(), + icon_delete_file: ICON_FILE_NAME.to_string(), + }) + } +} + +impl IconSysConfig { + pub const fn default_linebreak_pos() -> u16 { + DEFAULT_LINEBREAK_POS + } + + pub const fn default_background_transparency() -> u32 { + DEFAULT_BACKGROUND_TRANSPARENCY + } + + pub const fn default_background_colors() -> [ColorConfig; 4] { + [ + ColorConfig { + r: DEFAULT_BACKGROUND_COLORS[0].r, + g: DEFAULT_BACKGROUND_COLORS[0].g, + b: DEFAULT_BACKGROUND_COLORS[0].b, + a: DEFAULT_BACKGROUND_COLORS[0].a, + }, + ColorConfig { + r: DEFAULT_BACKGROUND_COLORS[1].r, + g: DEFAULT_BACKGROUND_COLORS[1].g, + b: DEFAULT_BACKGROUND_COLORS[1].b, + a: DEFAULT_BACKGROUND_COLORS[1].a, + }, + ColorConfig { + r: DEFAULT_BACKGROUND_COLORS[2].r, + g: DEFAULT_BACKGROUND_COLORS[2].g, + b: DEFAULT_BACKGROUND_COLORS[2].b, + a: DEFAULT_BACKGROUND_COLORS[2].a, + }, + ColorConfig { + r: DEFAULT_BACKGROUND_COLORS[3].r, + g: DEFAULT_BACKGROUND_COLORS[3].g, + b: DEFAULT_BACKGROUND_COLORS[3].b, + a: DEFAULT_BACKGROUND_COLORS[3].a, + }, + ] + } + + pub const fn default_light_directions() -> [VectorConfig; 3] { + [ + VectorConfig { + x: DEFAULT_LIGHT_DIRECTIONS[0].x, + y: DEFAULT_LIGHT_DIRECTIONS[0].y, + z: DEFAULT_LIGHT_DIRECTIONS[0].z, + w: DEFAULT_LIGHT_DIRECTIONS[0].w, + }, + VectorConfig { + x: DEFAULT_LIGHT_DIRECTIONS[1].x, + y: DEFAULT_LIGHT_DIRECTIONS[1].y, + z: DEFAULT_LIGHT_DIRECTIONS[1].z, + w: DEFAULT_LIGHT_DIRECTIONS[1].w, + }, + VectorConfig { + x: DEFAULT_LIGHT_DIRECTIONS[2].x, + y: DEFAULT_LIGHT_DIRECTIONS[2].y, + z: DEFAULT_LIGHT_DIRECTIONS[2].z, + w: DEFAULT_LIGHT_DIRECTIONS[2].w, + }, + ] + } + + pub const fn default_light_colors() -> [ColorFConfig; 3] { + [ + ColorFConfig { + r: DEFAULT_LIGHT_COLORS[0].r, + g: DEFAULT_LIGHT_COLORS[0].g, + b: DEFAULT_LIGHT_COLORS[0].b, + a: DEFAULT_LIGHT_COLORS[0].a, + }, + ColorFConfig { + r: DEFAULT_LIGHT_COLORS[1].r, + g: DEFAULT_LIGHT_COLORS[1].g, + b: DEFAULT_LIGHT_COLORS[1].b, + a: DEFAULT_LIGHT_COLORS[1].a, + }, + ColorFConfig { + r: DEFAULT_LIGHT_COLORS[2].r, + g: DEFAULT_LIGHT_COLORS[2].g, + b: DEFAULT_LIGHT_COLORS[2].b, + a: DEFAULT_LIGHT_COLORS[2].a, + }, + ] + } + + pub const fn default_ambient_color() -> ColorFConfig { + ColorFConfig { + r: DEFAULT_AMBIENT_COLOR.r, + g: DEFAULT_AMBIENT_COLOR.g, + b: DEFAULT_AMBIENT_COLOR.b, + a: DEFAULT_AMBIENT_COLOR.a, + } + } + + pub fn background_transparency_value(&self) -> u32 { + self.background_transparency + .unwrap_or(Self::default_background_transparency()) + } + + pub fn background_colors_array(&self) -> [ColorConfig; 4] { + let mut colors = Self::default_background_colors(); + if let Some(values) = &self.background_colors { + for (target, value) in colors.iter_mut().zip(values.iter()) { + *target = *value; + } + } + colors + } + + pub fn light_directions_array(&self) -> [VectorConfig; 3] { + let mut directions = Self::default_light_directions(); + if let Some(values) = &self.light_directions { + for (target, value) in directions.iter_mut().zip(values.iter()) { + *target = *value; + } + } + directions + } + + pub fn light_colors_array(&self) -> [ColorFConfig; 3] { + let mut colors = Self::default_light_colors(); + if let Some(values) = &self.light_colors { + for (target, value) in colors.iter_mut().zip(values.iter()) { + *target = *value; + } + } + colors + } + + pub fn ambient_color_value(&self) -> ColorFConfig { + self.ambient_color + .unwrap_or_else(Self::default_ambient_color) + } + + pub fn linebreak_position(&self) -> u16 { + self.linebreak_pos.unwrap_or(Self::default_linebreak_pos()) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub struct ColorConfig { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl From for Color { + fn from(value: ColorConfig) -> Self { + Color { + r: value.r, + g: value.g, + b: value.b, + a: value.a, + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub struct ColorFConfig { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl From for ColorF { + fn from(value: ColorFConfig) -> Self { + ColorF { + r: value.r, + g: value.g, + b: value.b, + a: value.a, + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub struct VectorConfig { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +impl From for Vector { + fn from(value: VectorConfig) -> Self { + Vector { + x: value.x, + y: value.y, + z: value.z, + w: value.w, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IconSysFlags(u16); + +impl IconSysFlags { + pub const fn new(value: u16) -> Self { + Self(value) + } + + pub const fn value(self) -> u16 { + self.0 + } +} + +impl From for IconSysFlags { + fn from(value: u16) -> Self { + Self::new(value) + } +} + +impl From for u16 { + fn from(value: IconSysFlags) -> Self { + value.value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn icon_sys_serializes_two_16_char_lines() { + let line1 = "ABCDEFGHIJKLMNOP"; + let line2 = "QRSTUVWXYZ012345"; + let title = format!("{line1}{line2}"); + let icon_sys = IconSysConfig { + flags: IconSysFlags::new(0), + title: title.clone(), + linebreak_pos: Some(line1.chars().count() as u16), + preset: None, + background_transparency: None, + background_colors: None, + light_directions: None, + light_colors: None, + ambient_color: None, + }; + + assert_eq!(icon_sys.linebreak_position(), line1.chars().count() as u16); + + let config = Config { + name: "Example".to_string(), + timestamp: None, + include: None, + exclude: None, + icon_sys: Some(icon_sys.clone()), + }; + + let toml = config + .to_toml_string() + .expect("icon_sys config serializes to TOML"); + let parsed: toml::Value = toml::from_str(&toml).expect("parse TOML output"); + let icon_sys_table = parsed + .get("icon_sys") + .and_then(|value| value.as_table()) + .expect("icon_sys table present"); + + assert_eq!( + icon_sys_table.get("title").and_then(|value| value.as_str()), + Some(title.as_str()) + ); + assert_eq!( + icon_sys_table + .get("linebreak_pos") + .and_then(|value| value.as_integer()), + Some(line1.chars().count() as i64) + ); + } +} + +impl<'de> Deserialize<'de> for IconSysFlags { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct IconSysFlagsVisitor; + + impl<'de> serde::de::Visitor<'de> for IconSysFlagsVisitor { + type Value = IconSysFlags; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an icon.sys flag value or descriptive name") + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + if value > u16::MAX as u64 { + return Err(E::custom("icon_sys.flags must be between 0 and 65535")); + } + Ok(IconSysFlags::new(value as u16)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + if !(0..=u16::MAX as i64).contains(&value) { + return Err(E::custom("icon_sys.flags must be between 0 and 65535")); + } + Ok(IconSysFlags::new(value as u16)) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + parse_flag_string(value) + .map(IconSysFlags::new) + .map_err(E::custom) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + self.visit_str(&value) + } + } + + deserializer.deserialize_any(IconSysFlagsVisitor) + } +} + +impl Serialize for IconSysFlags { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u16(self.value()) + } +} + +fn parse_flag_string(value: &str) -> Result { + let trimmed = value.trim(); + if let Some(mapped) = parse_named_flag(trimmed) { + return Ok(mapped); + } + + if let Some(stripped) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + return u16::from_str_radix(stripped, 16) + .map_err(|_| format!("Invalid hexadecimal icon_sys flag: {trimmed}")); + } + + trimmed + .parse::() + .map_err(|_| format!("Invalid icon_sys flag value: {trimmed}")) +} + +fn parse_named_flag(value: &str) -> Option { + let normalized: String = value + .to_ascii_lowercase() + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '_' && *c != '(' && *c != ')') + .collect(); + + match normalized.as_str() { + "ps2savefile" | "savefile" => Some(0), + "softwareps2" | "software" => Some(1), + "unrecognizeddata" | "unrecognized" | "data" => Some(2), + "softwarepocketstation" | "pocketstation" => Some(3), + "settingsps2" | "settings" => Some(4), + "systemdriver" | "driver" => Some(5), + _ => None, + } +} + +const DEFAULT_LINEBREAK_POS: u16 = 0; +const DEFAULT_BACKGROUND_TRANSPARENCY: u32 = 0; +const DEFAULT_BACKGROUND_COLORS: [Color; 4] = [ + Color { + r: 0, + g: 0, + b: 0, + a: 0, + }, + Color { + r: 0, + g: 0, + b: 0, + a: 0, + }, + Color { + r: 0, + g: 0, + b: 0, + a: 0, + }, + Color { + r: 0, + g: 0, + b: 0, + a: 0, + }, +]; +const DEFAULT_LIGHT_DIRECTIONS: [Vector; 3] = [ + Vector { + x: 0.0, + y: 0.0, + z: 1.0, + w: 0.0, + }, + Vector { + x: 0.0, + y: 0.0, + z: 1.0, + w: 0.0, + }, + Vector { + x: 0.0, + y: 0.0, + z: 1.0, + w: 0.0, + }, +]; +const DEFAULT_LIGHT_COLORS: [ColorF; 3] = [ + 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.3, + g: 0.3, + b: 0.3, + a: 1.0, + }, +]; +const DEFAULT_AMBIENT_COLOR: ColorF = ColorF { + r: 0.2, + g: 0.2, + b: 0.2, + a: 1.0, +}; +const ICON_FILE_NAME: &str = "icon.icn"; + +pub fn load_config(folder: &Path) -> Result { + let config_file = folder.join("psu.toml"); + let str = std::fs::read_to_string(&config_file)?; + let config_file = + toml::from_str::(&str).map_err(|e| Error::ConfigError(e.to_string()))?; + Ok(config_file.into()) +} + +pub fn pack_psu(folder: &Path, output: &Path) -> Result<(), Error> { + let config = load_config(folder)?; + pack_with_config(folder, output, config) +} + +pub fn pack_with_config(folder: &Path, output: &Path, cfg: Config) -> Result<(), Error> { + let Config { + name, + timestamp, + include, + exclude, + icon_sys, + } = cfg; + + if !check_name(&name) { + return Err(Error::NameError); + } + + let mut psu = PSU::default(); + + let icon_sys_path = folder.join("icon.sys"); + if let Some(icon_config) = &icon_sys { + let bytes = icon_config.to_bytes()?; + std::fs::write(&icon_sys_path, bytes)?; + } + + let raw_included_files = if let Some(include) = include { + include + .into_iter() + .filter_map(|file| { + if file.contains(|c| matches!(c, '\\' | '/')) { + eprintln!( + "{} {} {}", + "File".dimmed(), + file.dimmed(), + "exists in subfolder, skipping".dimmed() + ); + None + } else { + let candidate = folder.join(&file); + if !candidate.exists() { + eprintln!( + "{} {} {}", + "File".dimmed(), + file.dimmed(), + "does not exist, skipping".dimmed() + ); + None + } else { + Some(candidate) + } + } + }) + .collect::>() + } else { + std::fs::read_dir(folder)? + .into_iter() + .flatten() + .map(|d| d.path()) + .collect::>() + }; + + let mut files = filter_files(&raw_included_files); + + if let Some(exclude) = exclude { + let mut exclude_set = HashSet::new(); + + for file in exclude { + if file.contains(|c| matches!(c, '\\' | '/')) { + eprintln!( + "{} {} {}", + "File".dimmed(), + file.dimmed(), + "exists in subfolder, skipping exclude".dimmed() + ); + continue; + } + + let candidate = folder.join(&file); + if !candidate.exists() { + eprintln!( + "{} {} {}", + "File".dimmed(), + file.dimmed(), + "does not exist, skipping exclude".dimmed() + ); + continue; + } + + exclude_set.insert(file); + } + + if !exclude_set.is_empty() { + files = files + .into_iter() + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| !exclude_set.contains(name)) + .unwrap_or(true) + }) + .collect::>(); + } + } + + if icon_sys.is_some() { + if !files.iter().any(|path| path == &icon_sys_path) { + files.push(icon_sys_path); + } + } + + let timestamp_value = timestamp.unwrap_or_default(); + add_psu_defaults(&mut psu, &name, files.len(), timestamp_value); + add_files_to_psu(&mut psu, &files, timestamp)?; + std::fs::write(output, PSUWriter::new(psu).to_bytes()?)?; + Ok(()) +} + +fn check_name(name: &str) -> bool { + for c in name.chars() { + if !matches!(c, 'a'..'z'|'A'..'Z'|'0'..'9'|'_'|'-'|' ') { + return false; + } + } + true +} + +fn filter_files(files: &[PathBuf]) -> Vec { + files + .iter() + .filter_map(|f| { + if f.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case("psu.toml")) + .unwrap_or(false) + { + None + } else if !f.is_file() { + println!( + "{} {}", + f.display().to_string().dimmed(), + "is not a file, skipping".dimmed() + ); + None + } else { + Some(f.to_owned()) + } + }) + .collect() +} + +fn add_psu_defaults(psu: &mut PSU, name: &str, file_count: usize, timestamp: NaiveDateTime) { + psu.entries.push(PSUEntry { + id: DIR_ID, + size: file_count as u32 + 2, + created: timestamp, + sector: 0, + modified: timestamp, + name: name.to_owned(), + kind: PSUEntryKind::Directory, + contents: None, + }); + psu.entries.push(PSUEntry { + id: DIR_ID, + size: 0, + created: timestamp, + sector: 0, + modified: timestamp, + name: ".".to_string(), + kind: PSUEntryKind::Directory, + contents: None, + }); + psu.entries.push(PSUEntry { + id: DIR_ID, + size: 0, + created: timestamp, + sector: 0, + modified: timestamp, + name: "..".to_string(), + kind: PSUEntryKind::Directory, + contents: None, + }); +} + +fn add_files_to_psu( + psu: &mut PSU, + files: &[PathBuf], + timestamp: Option, +) -> Result<(), Error> { + for file in files { + let name = file.file_name().unwrap().to_str().unwrap(); + + let f = std::fs::read(file)?; + let (created, modified) = if let Some(timestamp) = timestamp { + (timestamp, timestamp) + } else { + let stat = std::fs::metadata(file)?; + ( + convert_timestamp(stat.created()?), + convert_timestamp(stat.modified()?), + ) + }; + + println!("+ {} {}", "Adding", name.green()); + + psu.entries.push(PSUEntry { + id: FILE_ID, + size: f.len() as u32, + created, + sector: 0, + modified, + name: name.to_owned(), + kind: PSUEntryKind::File, + contents: Some(f), + }) + } + + Ok(()) +} + +fn convert_timestamp(time: SystemTime) -> NaiveDateTime { + let duration = time.duration_since(UNIX_EPOCH).unwrap(); + DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos()) + .unwrap() + .with_timezone(&Local) + .naive_local() +} + +#[derive(Debug)] +pub enum Error { + NameError, + IOError(std::io::Error), + ConfigError(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::NameError => write!(f, "Name must match [a-zA-Z0-9._-\\s]+"), + Error::IOError(err) => write!(f, "{err:?}"), + Error::ConfigError(err) => write!(f, "{err}"), + } + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::IOError(err) + } +} diff --git a/crates/psu-packer/src/main.rs b/crates/psu-packer/src/main.rs index 6b1651a..2f521fa 100644 --- a/crates/psu-packer/src/main.rs +++ b/crates/psu-packer/src/main.rs @@ -1,13 +1,13 @@ -use chrono::{DateTime, Local, NaiveDateTime}; +use argh::FromArgs; use colored::Colorize; -use ps2_filetypes::{PSUEntry, PSUEntryKind, PSUWriter, DIR_ID, FILE_ID, PSU}; -use serde::Deserialize; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; -use argh::FromArgs; + +use psu_packer::{load_config, pack_psu, Error}; #[derive(Debug, FromArgs)] -#[argh(description = "Expects a folder with a psu.toml file that follows this format\n\t[config]\n\tname = \"Test PSU\"\t\t\t# Folder name on Memory Card\n\tinclude = [ \"BOOT.ELF\", \"icon.sys\" ]\t# using `exclude` will automatically include all files except the specified ones\n\ttimestamp = \"2024-10-10 10:30:00\"\t# Optional, but recommended\n")] +#[argh( + description = "Expects a folder with a psu.toml file that follows this format\n\t[config]\n\tname = \"Test PSU\"\t\t\t# Folder name on Memory Card\n\tinclude = [ \"BOOT.ELF\", \"icon.sys\" ]\t# using `exclude` will automatically include all files except the specified ones\n\ttimestamp = \"2024-10-10 10:30:00\"\t# Optional, but recommended\n" +)] struct Args { /// folder to package to psu #[argh(positional)] @@ -17,244 +17,16 @@ struct Args { output: Option, } -#[derive(Debug, Deserialize)] -struct Config { - name: String, - #[serde(default, with = "date_format")] - timestamp: Option, - include: Option>, - exclude: Option>, -} - -mod date_format { - use chrono::NaiveDateTime; - use serde::{self, Deserialize, Deserializer}; - - pub fn deserialize<'de, D>(deserialize: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let s: Option = Option::deserialize(deserialize)?; - if let Some(s) = s { - Ok(Some( - NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") - .map_err(serde::de::Error::custom)?, - )) - } else { - Ok(None) - } - } -} - -#[derive(Debug, Deserialize)] -struct ConfigFile { - config: Config, -} - -fn check_name(name: &str) -> bool { - for c in name.chars() { - if !matches!(c, 'a'..'z'|'A'..'Z'|'0'..'9'|'_'|'-'|' ') { - return false - } - } - true -} - fn main() -> Result<(), Error> { let args: Args = argh::from_env(); - let folder = PathBuf::from(args.folder); - - let config_file = folder.join("psu.toml"); - - if config_file.exists() { - let str = std::fs::read_to_string(&config_file)?; - let config = toml::from_str::(&str) - .expect("Failed to parse config file") - .config; - - let output_file = args.output.unwrap_or(format!("{}.psu", config.name)); + let folder = PathBuf::from(&args.folder); - if !check_name(&config.name) { - return Err(Error::NameError); - } + let config = load_config(&folder)?; + let output_file = args.output.unwrap_or(format!("{}.psu", config.name)); + let output_path = PathBuf::from(&output_file); - if config.include.is_some() && config.exclude.is_some() { - return Err(Error::IncludeExcludeError); - } - - let mut psu = PSU::default(); - - let files = if let Some(include) = config.include { - include - .iter() - .filter_map(|file| { - if file.contains(|c| matches!(c, '\\' | '/')) { - eprintln!( - "{} {} {}", - "File".dimmed(), - file.dimmed(), - "exists in subfolder, skipping".dimmed() - ); - None - } else if !folder.join(file).exists() { - eprintln!( - "{} {} {}", - "File".dimmed(), - file.dimmed(), - "does not exist, skipping".dimmed() - ); - None - } else { - Some(folder.join(file)) - } - }) - .collect::>() - } else if let Some(exclude) = config.exclude { - std::fs::read_dir(folder)? - .into_iter() - .flatten() - .filter_map(|d| { - if !exclude.contains(&d.file_name().to_str().unwrap().to_string()) { - Some(d.path()) - } else { - None - } - }) - .collect::>() - } else { - // Include all - std::fs::read_dir(folder)? - .into_iter() - .flatten() - .map(|d| d.path()) - .collect::>() - }; - let files = filter_files(&files); - add_psu_defaults( - &mut psu, - &config.name, - files.len(), - config.timestamp.unwrap_or_default(), - ); - add_files_to_psu(&mut psu, &files)?; - std::fs::write(&output_file, PSUWriter::new(psu).to_bytes()?)?; - println!("Wrote {}! {}", output_file.green(), "".clear()); - } else { - eprintln!("{}", "Failed to find psu.toml".red()); - } + pack_psu(&folder, &output_path)?; + println!("Wrote {}! {}", output_file.green(), "".clear()); Ok(()) } - -fn filter_files(files: &[PathBuf]) -> Vec { - files - .iter() - .filter_map(|f| { - if !f.is_file() { - println!( - "{} {}", - f.display().to_string().dimmed(), - "is not a file, skipping".dimmed() - ); - None - } else { - Some(f.to_owned()) - } - }) - .collect() -} - -fn add_psu_defaults(psu: &mut PSU, name: &str, file_count: usize, timestamp: NaiveDateTime) { - psu.entries.push(PSUEntry { - id: DIR_ID, - size: file_count as u32 + 2, // +2 to include . and .. - created: timestamp, - sector: 0, - modified: timestamp, - name: name.to_owned(), - kind: PSUEntryKind::Directory, - contents: None, - }); - psu.entries.push(PSUEntry { - id: DIR_ID, - size: 0, - created: timestamp, - sector: 0, - modified: timestamp, - name: ".".to_string(), - kind: PSUEntryKind::Directory, - contents: None, - }); - psu.entries.push(PSUEntry { - id: DIR_ID, - size: 0, - created: timestamp, - sector: 0, - modified: timestamp, - name: "..".to_string(), - kind: PSUEntryKind::Directory, - contents: None, - }); -} - -fn add_files_to_psu(psu: &mut PSU, files: &[PathBuf]) -> Result<(), Error> { - for file in files { - let name = file.file_name().unwrap().to_str().unwrap(); - - let f = std::fs::read(file)?; - let stat = std::fs::metadata(file)?; - - println!("+ {} {}", "Adding", name.green()); - - psu.entries.push(PSUEntry { - id: FILE_ID, - size: f.len() as u32, - created: convert_timestamp(stat.created()?), - sector: 0, - modified: convert_timestamp(stat.modified()?), - name: name.to_owned(), - kind: PSUEntryKind::File, - contents: Some(f), - }) - } - - Ok(()) -} - -fn convert_timestamp(time: SystemTime) -> NaiveDateTime { - let duration = time.duration_since(UNIX_EPOCH).unwrap(); - let local = DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos()) - .unwrap() - .with_timezone(&Local) - .naive_local(); - - local -} - -enum Error { - NameError, - IOError(std::io::Error), - IncludeExcludeError, -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Error::NameError => write!(f, "Name must match [a-zA-Z0-9._-\\s]+"), - Error::IncludeExcludeError => write!(f, "Exclude cannot be used in include mode"), - Error::IOError(err) => write!(f, "{err:?}"), - } - } -} - -impl std::fmt::Debug for Error { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self) - } -} - -impl From for Error { - fn from(err: std::io::Error) -> Self { - Error::IOError(err) - } -} diff --git a/crates/psu-packer/tests/exclude_psu_toml.rs b/crates/psu-packer/tests/exclude_psu_toml.rs new file mode 100644 index 0000000..a21dfc8 --- /dev/null +++ b/crates/psu-packer/tests/exclude_psu_toml.rs @@ -0,0 +1,63 @@ +use std::fs; +use std::path::Path; + +use ps2_filetypes::{PSUEntryKind, PSU}; +use psu_packer::{pack_with_config, Config}; +use tempfile::tempdir; + +fn write_file(path: &Path, contents: &[u8]) { + fs::write(path, contents).expect("write file"); +} + +fn packed_psu_contains_file(output: &Path, name: &str) -> bool { + let data = fs::read(output).expect("read packed psu"); + let archive = PSU::new(data); + archive.entries.iter().any(|entry| { + matches!(entry.kind, PSUEntryKind::File) && entry.name.eq_ignore_ascii_case(name) + }) +} + +#[test] +fn psu_toml_is_never_packed() { + let workspace = tempdir().expect("temp dir"); + let project = workspace.path(); + + write_file(&project.join("DATA.BIN"), b"payload"); + write_file(&project.join("psu.toml"), b"[config]\nname = \"Test\"\n"); + + let config_include_all = Config { + name: "Test Save".to_string(), + timestamp: None, + include: None, + exclude: None, + icon_sys: None, + }; + let output_include_all = project.join("include-all.psu"); + pack_with_config(project, &output_include_all, config_include_all) + .expect("pack with automatic include"); + + assert!( + packed_psu_contains_file(&output_include_all, "DATA.BIN"), + "expected data file to be present" + ); + assert!( + !packed_psu_contains_file(&output_include_all, "psu.toml"), + "psu.toml should always be omitted" + ); + + let config_with_explicit_include = Config { + name: "Test Save".to_string(), + timestamp: None, + include: Some(vec!["DATA.BIN".to_string(), "psu.toml".to_string()]), + exclude: None, + icon_sys: None, + }; + let output_with_explicit = project.join("explicit.psu"); + pack_with_config(project, &output_with_explicit, config_with_explicit_include) + .expect("pack with explicit include"); + + assert!( + !packed_psu_contains_file(&output_with_explicit, "psu.toml"), + "psu.toml should be filtered even when explicitly included" + ); +} diff --git a/crates/psu-packer/tests/timestamp.rs b/crates/psu-packer/tests/timestamp.rs new file mode 100644 index 0000000..82169fc --- /dev/null +++ b/crates/psu-packer/tests/timestamp.rs @@ -0,0 +1,78 @@ +use std::fs; +use std::path::Path; + +use chrono::{NaiveDate, NaiveDateTime}; +use ps2_filetypes::{PSUEntryKind, PSU}; +use psu_packer::{pack_with_config, Config}; +use tempfile::tempdir; + +fn create_sample_file(path: &Path) { + fs::write(path, b"example").expect("write sample file"); +} + +#[test] +fn pack_with_or_without_timestamp_controls_entry_times() { + let tempdir = tempdir().expect("temp dir"); + let folder = tempdir.path(); + let source_file = folder.join("DATA.BIN"); + create_sample_file(&source_file); + let output_dir = folder.join("output"); + fs::create_dir(&output_dir).expect("create output dir"); + + let timestamp = NaiveDate::from_ymd_opt(2024, 1, 2) + .unwrap() + .and_hms_opt(3, 4, 5) + .unwrap(); + let config_with_timestamp = Config { + name: "Test Save".to_string(), + timestamp: Some(timestamp), + include: None, + exclude: None, + icon_sys: None, + }; + let output_with_timestamp = output_dir.join("with-timestamp.psu"); + pack_with_config(folder, &output_with_timestamp, config_with_timestamp) + .expect("pack with timestamp"); + + let packed_with_timestamp = PSU::new(fs::read(&output_with_timestamp).expect("read output")); + for entry in packed_with_timestamp.entries.iter() { + assert_eq!( + entry.created, timestamp, + "created timestamp should match config" + ); + assert_eq!( + entry.modified, timestamp, + "modified timestamp should match config" + ); + } + + // Legacy behaviour: omit the timestamp and expect filesystem metadata to be used for files. + let output_without_timestamp = output_dir.join("without-timestamp.psu"); + let legacy_config = Config { + name: "Test Save".to_string(), + timestamp: None, + include: None, + exclude: None, + icon_sys: None, + }; + pack_with_config(folder, &output_without_timestamp, legacy_config) + .expect("pack without timestamp"); + + let packed_without_timestamp = + PSU::new(fs::read(&output_without_timestamp).expect("read output")); + let mut file_timestamp = None; + for entry in packed_without_timestamp.entries.iter() { + match entry.kind { + PSUEntryKind::Directory => { + assert_eq!(entry.created, NaiveDateTime::default()); + assert_eq!(entry.modified, NaiveDateTime::default()); + } + PSUEntryKind::File => { + file_timestamp = Some(entry.created); + } + } + } + + let file_timestamp = file_timestamp.expect("file entry present"); + assert_ne!(file_timestamp, NaiveDateTime::default()); +} diff --git a/crates/suitcase/Cargo.toml b/crates/suitcase/Cargo.toml index cdd2382..16d1f8d 100644 --- a/crates/suitcase/Cargo.toml +++ b/crates/suitcase/Cargo.toml @@ -3,6 +3,11 @@ name = "suitcase" version = "0.1.0" edition = "2021" +[features] +default = ["glow", "wgpu"] +glow = [] +wgpu = ["eframe/wgpu"] + [dependencies] macros = { path = "../macros" } eframe = { version = "0.31.1", features = ["default", "persistence"] } @@ -10,7 +15,7 @@ egui_extras = { version = "0.31.1", features = ["svg", "image"] } egui_dock = "0.16.0" cgmath = "0.18.0" ps2-filetypes = { path = "../ps2-filetypes" } -image = { version = "0.25.6" } +image = { version = "0.25.6", features = ["ico"] } rfd = "0.15.3" wavefront_obj = "11.0.0" bytesize = "2.0.1" @@ -21,3 +26,6 @@ relative-path = "2.0.1" [build-dependencies] winresource = "0.1.20" + +[dev-dependencies] +tempfile = "3.10.1" diff --git a/crates/suitcase/assets/Polished Gold Card on Checkered Background.png b/crates/suitcase/assets/Polished Gold Card on Checkered Background.png new file mode 100644 index 0000000..0397e85 Binary files /dev/null and b/crates/suitcase/assets/Polished Gold Card on Checkered Background.png differ diff --git a/crates/suitcase/assets/ps2.icns b/crates/suitcase/assets/app_icon.icns similarity index 100% rename from crates/suitcase/assets/ps2.icns rename to crates/suitcase/assets/app_icon.icns diff --git a/crates/suitcase/assets/ps2.ico b/crates/suitcase/assets/app_icon.ico similarity index 100% rename from crates/suitcase/assets/ps2.ico rename to crates/suitcase/assets/app_icon.ico diff --git a/crates/suitcase/assets/ps2.png b/crates/suitcase/assets/app_icon.png similarity index 100% rename from crates/suitcase/assets/ps2.png rename to crates/suitcase/assets/app_icon.png diff --git a/crates/suitcase/assets/blank.png b/crates/suitcase/assets/blank.png new file mode 100644 index 0000000..163ad7e Binary files /dev/null and b/crates/suitcase/assets/blank.png differ diff --git a/crates/suitcase/assets/circular.png b/crates/suitcase/assets/circular.png new file mode 100644 index 0000000..c8b2540 Binary files /dev/null and b/crates/suitcase/assets/circular.png differ diff --git a/crates/suitcase/assets/framedbutton.png b/crates/suitcase/assets/framedbutton.png new file mode 100644 index 0000000..d00e017 Binary files /dev/null and b/crates/suitcase/assets/framedbutton.png differ diff --git a/crates/suitcase/assets/goldframe.png b/crates/suitcase/assets/goldframe.png new file mode 100644 index 0000000..b112e98 Binary files /dev/null and b/crates/suitcase/assets/goldframe.png differ diff --git a/crates/suitcase/assets/icon.ico b/crates/suitcase/assets/icon.ico new file mode 100644 index 0000000..5ff38c7 Binary files /dev/null and b/crates/suitcase/assets/icon.ico differ diff --git a/crates/suitcase/assets/icon.png b/crates/suitcase/assets/icon.png new file mode 100644 index 0000000..55a528f Binary files /dev/null and b/crates/suitcase/assets/icon.png differ diff --git a/crates/suitcase/assets/psupackergui.ico b/crates/suitcase/assets/psupackergui.ico new file mode 100644 index 0000000..c00b7f3 Binary files /dev/null and b/crates/suitcase/assets/psupackergui.ico differ diff --git a/crates/suitcase/assets/psupackergui.png b/crates/suitcase/assets/psupackergui.png new file mode 100644 index 0000000..6e6a864 Binary files /dev/null and b/crates/suitcase/assets/psupackergui.png differ diff --git a/crates/suitcase/build.rs b/crates/suitcase/build.rs index ffa6c81..3e7e908 100644 --- a/crates/suitcase/build.rs +++ b/crates/suitcase/build.rs @@ -1,17 +1,24 @@ use { - std::{ - env, - io, - }, + std::{env, io}, winresource::WindowsResource, }; fn main() -> io::Result<()> { + let has_wgpu = env::var_os("CARGO_FEATURE_WGPU").is_some(); + let has_glow = env::var_os("CARGO_FEATURE_GLOW").is_some(); + + if !has_wgpu && !has_glow { + return Err(io::Error::new( + io::ErrorKind::Other, + "PS2Suitcase requires either the `wgpu` or `glow` feature to be enabled.", + )); + } + if env::var_os("CARGO_CFG_WINDOWS").is_some() { WindowsResource::new() // This path can be absolute, or relative to your crate root. - .set_icon("assets/ps2.ico") + .set_icon("assets/icon.ico") .compile()?; } Ok(()) -} \ No newline at end of file +} diff --git a/crates/suitcase/src/components/file_tree.rs b/crates/suitcase/src/components/file_tree.rs index e51bb68..d254837 100644 --- a/crates/suitcase/src/components/file_tree.rs +++ b/crates/suitcase/src/components/file_tree.rs @@ -5,8 +5,25 @@ use eframe::egui::{ include_image, vec2, Align, Button, Color32, Id, ImageSource, Layout, ScrollArea, Stroke, Style, TextWrapMode, Ui, }; -use std::collections::HashMap; -use std::path::PathBuf; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +fn is_symlink_or_junction(file_type: &std::fs::FileType) -> bool { + if file_type.is_symlink() { + true + } else { + #[cfg(windows)] + { + use std::os::windows::fs::FileTypeExt; + file_type.is_symlink_dir() || file_type.is_symlink_file() + } + #[cfg(not(windows))] + { + false + } + } +} pub struct FileTree { show_timestamp: bool, @@ -14,6 +31,7 @@ pub struct FileTree { id: Id, expanded: HashMap, dir_cache: HashMap>, + visited_dirs: HashSet, } fn set_menu_style(style: &mut Style) { @@ -32,6 +50,7 @@ impl FileTree { id: Id::new("file_tree"), expanded: HashMap::new(), dir_cache: HashMap::new(), + visited_dirs: HashSet::new(), } } @@ -48,18 +67,20 @@ impl FileTree { pub fn show(&mut self, ui: &mut Ui, state: &mut AppState) { set_menu_style(ui.style_mut()); - ScrollArea::new([true, true]).show(ui, |ui| { - ui.with_layout( - Layout::top_down(Align::Min).with_cross_justify(true), - |ui| { - self.show_folder(ui, state.opened_folder.clone().unwrap(), state); - }, - ); - }); + if let Some(folder) = state.opened_folder.clone() { + ScrollArea::new([true, true]).show(ui, |ui| { + ui.with_layout( + Layout::top_down(Align::Min).with_cross_justify(true), + |ui| { + self.show_folder(ui, folder.clone(), state); + }, + ); + }); + } } pub fn show_folder(&mut self, ui: &mut Ui, path: PathBuf, state: &mut AppState) { - let file_name = path.file_name().unwrap().to_str().unwrap().to_owned(); + let file_name = Self::display_name(&path); let (_, response, _) = CollapsingState::load_with_default_open(ui.ctx(), Id::new(&path), false) @@ -99,7 +120,7 @@ impl FileTree { } pub fn show_file(&mut self, ui: &mut Ui, path: PathBuf, state: &mut AppState) { - let file_name = path.file_name().unwrap().to_str().unwrap().to_owned(); + let file_name = Self::display_name(&path); let response = ui.add( Button::image_and_text(Self::icon(&file_name), file_name.clone()) @@ -115,28 +136,108 @@ impl FileTree { } } - fn index_folder_internal(&mut self, root: &PathBuf) { + fn display_name(path: &Path) -> String { + let file_name = path.file_name(); + + if let Some(valid) = file_name.and_then(OsStr::to_str) { + valid.to_owned() + } else if let Some(name) = file_name { + name.to_string_lossy().into_owned() + } else { + path.display().to_string() + } + } + + fn index_folder_internal(&mut self, root: &Path) -> std::io::Result<()> { + let canonical_root = match root.canonicalize() { + Ok(path) => path, + Err(err) => { + eprintln!( + "Failed to canonicalize directory '{}': {err}", + root.display() + ); + return Err(err); + } + }; + + if !self.visited_dirs.insert(canonical_root.clone()) { + return Ok(()); + } + let mut folders = Vec::new(); let mut files = Vec::new(); - let children = std::fs::read_dir(&root).expect("failed to read root directory"); + let children = match std::fs::read_dir(root) { + Ok(children) => children, + Err(err) => { + eprintln!("Failed to read contents of '{}': {err}", root.display()); + self.visited_dirs.remove(&canonical_root); + return Err(err); + } + }; for entry in children { - let path = entry.unwrap().path(); - if path.is_dir() { - self.index_folder_internal(&path); - folders.push(path); - } else { - files.push(path); + match entry { + Ok(entry) => { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + eprintln!( + "Failed to read metadata for '{}': {err}", + path.display() + ); + continue; + } + }; + + if is_symlink_or_junction(&file_type) { + continue; + } + + if file_type.is_dir() { + match self.index_folder_internal(&path) { + Ok(()) => { + if self.dir_cache.contains_key(&path) { + folders.push(path); + } + } + Err(err) => { + eprintln!( + "Skipping subdirectory '{}' due to error: {err}", + path.display() + ); + } + } + } else { + files.push(path); + } + } + Err(err) => { + eprintln!( + "Failed to access directory entry in '{}': {err}", + root.display() + ); + } } } self.dir_cache - .insert(root.clone(), [folders, files].concat()); + .insert(root.to_path_buf(), [folders, files].concat()); + + Ok(()) } - pub fn index_folder(&mut self, root: &PathBuf) { + pub fn index_folder(&mut self, root: &PathBuf) -> std::io::Result<()> { self.dir_cache.clear(); - self.index_folder_internal(root); + self.visited_dirs.clear(); + match self.index_folder_internal(root.as_path()) { + Ok(()) => Ok(()), + Err(err) => { + self.dir_cache.clear(); + self.visited_dirs.clear(); + Err(err) + } + } } // pub fn show(&mut self, ui: &mut Ui, app: &mut AppState) { diff --git a/crates/suitcase/src/components/menu_bar.rs b/crates/suitcase/src/components/menu_bar.rs index e8b2ea7..305c04d 100644 --- a/crates/suitcase/src/components/menu_bar.rs +++ b/crates/suitcase/src/components/menu_bar.rs @@ -66,6 +66,14 @@ pub fn menu_bar(ui: &mut Ui, app: &mut AppState) { app.save_file(); ui.close_menu(); } + if ui.menu_item("Create psu.toml from template").clicked() { + app.create_psu_toml(); + ui.close_menu(); + } + if ui.menu_item("Create title.cfg from template").clicked() { + app.create_title_cfg(); + ui.close_menu(); + } // ui.separator(); // if ui // .menu_item_shortcut("Create ICN", &CREATE_ICN_KEYBOARD_SHORTCUT) diff --git a/crates/suitcase/src/components/tab_viewer.rs b/crates/suitcase/src/components/tab_viewer.rs index b1f91b0..acda1aa 100644 --- a/crates/suitcase/src/components/tab_viewer.rs +++ b/crates/suitcase/src/components/tab_viewer.rs @@ -1,7 +1,7 @@ use crate::tabs::Tab; -use eframe::egui::{Id, Ui, WidgetText}; +use crate::tabs::{ICNViewer, IconSysViewer, PsuTomlViewer, TitleCfgViewer}; use crate::AppState; -use crate::tabs::{IconSysViewer, TitleCfgViewer, ICNViewer}; +use eframe::egui::{Id, Ui, WidgetText}; pub struct TabViewer<'a> { pub(crate) app: &'a mut AppState, @@ -11,6 +11,7 @@ pub enum TabType { IconSysViewer(IconSysViewer), TitleCfgViewer(TitleCfgViewer), ICNViewer(ICNViewer), + PsuTomlViewer(PsuTomlViewer), } impl TabType { @@ -19,6 +20,7 @@ impl TabType { TabType::IconSysViewer(tab) => tab.get_id(), TabType::TitleCfgViewer(tab) => tab.get_id(), TabType::ICNViewer(tab) => tab.get_id(), + TabType::PsuTomlViewer(tab) => tab.get_id(), } } @@ -27,6 +29,7 @@ impl TabType { TabType::IconSysViewer(tab) => tab.get_title(), TabType::TitleCfgViewer(tab) => tab.get_title(), TabType::ICNViewer(tab) => tab.get_title(), + TabType::PsuTomlViewer(tab) => tab.get_title(), } } @@ -35,6 +38,7 @@ impl TabType { TabType::IconSysViewer(tab) => tab.get_modified(), TabType::TitleCfgViewer(tab) => tab.get_modified(), TabType::ICNViewer(tab) => tab.get_modified(), + TabType::PsuTomlViewer(tab) => tab.get_modified(), } } @@ -43,6 +47,7 @@ impl TabType { TabType::IconSysViewer(tab) => tab.save(), TabType::TitleCfgViewer(tab) => tab.save(), TabType::ICNViewer(tab) => tab.save(), + TabType::PsuTomlViewer(tab) => tab.save(), } } } @@ -55,7 +60,8 @@ impl<'a> egui_dock::TabViewer for TabViewer<'a> { format!("* {}", tab.get_title()) } else { tab.get_title() - }.into() + } + .into() } fn ui(&mut self, ui: &mut Ui, tab: &mut Self::Tab) { @@ -69,6 +75,9 @@ impl<'a> egui_dock::TabViewer for TabViewer<'a> { TabType::TitleCfgViewer(tab) => { tab.show(ui); } + TabType::PsuTomlViewer(tab) => { + tab.show(ui); + } } } diff --git a/crates/suitcase/src/components/toolbar.rs b/crates/suitcase/src/components/toolbar.rs index 27e81b3..03a6ffa 100644 --- a/crates/suitcase/src/components/toolbar.rs +++ b/crates/suitcase/src/components/toolbar.rs @@ -39,7 +39,7 @@ pub fn toolbar(ui: &mut Ui, app: &mut AppState) -> Response { toolbar_item( ui, include_image!("../../assets/hidpi/main_mk_titlecfg.png"), - "Make title configuration", + "Create title.cfg from template", ) .clicked() .then(|| app.create_title_cfg()); diff --git a/crates/suitcase/src/data/state.rs b/crates/suitcase/src/data/state.rs index 0d0cbc7..d2d41bc 100644 --- a/crates/suitcase/src/data/state.rs +++ b/crates/suitcase/src/data/state.rs @@ -12,6 +12,7 @@ pub enum AppEvent { SaveFile, OpenSave, CreateICN, + CreatePsuToml, CreateTitleCfg, OpenSettings, StartPCSX2, @@ -53,6 +54,9 @@ impl AppState { pub fn create_icn(&mut self) { self.events.push(AppEvent::CreateICN); } + pub fn create_psu_toml(&mut self) { + self.events.push(AppEvent::CreatePsuToml); + } pub fn create_title_cfg(&mut self) { self.events.push(AppEvent::CreateTitleCfg); } diff --git a/crates/suitcase/src/io/read_folder.rs b/crates/suitcase/src/io/read_folder.rs index 7033af6..a3df0c0 100644 --- a/crates/suitcase/src/io/read_folder.rs +++ b/crates/suitcase/src/io/read_folder.rs @@ -3,17 +3,49 @@ use crate::data::virtual_file::VirtualFile; use std::path::PathBuf; pub fn read_folder(folder: PathBuf) -> std::io::Result { - let files = std::fs::read_dir(folder)? + let files = std::fs::read_dir(&folder)? .into_iter() - .flatten() - .filter_map(|entry| { - if entry.file_type().ok()?.is_file() { - Some(VirtualFile { - name: entry.file_name().into_string().unwrap(), - file_path: entry.path(), - size: entry.path().metadata().unwrap().len(), - }) - } else { + .filter_map(|entry| match entry { + Ok(entry) => { + if entry.file_type().ok()?.is_file() { + let file_path = entry.path(); + let name = match entry.file_name().into_string() { + Ok(name) => name, + Err(os_string) => { + eprintln!( + "Skipping file '{:?}' in '{}' due to invalid UTF-8 name", + os_string, + folder.display() + ); + return None; + } + }; + let metadata = match file_path.metadata() { + Ok(metadata) => metadata, + Err(err) => { + eprintln!( + "Skipping file '{}' in '{}' due to metadata error: {err}", + file_path.display(), + folder.display() + ); + return None; + } + }; + + Some(VirtualFile { + name, + file_path, + size: metadata.len(), + }) + } else { + None + } + } + Err(err) => { + eprintln!( + "Failed to read directory entry in '{}': {err}", + folder.display() + ); None } }) diff --git a/crates/suitcase/src/main.rs b/crates/suitcase/src/main.rs index 3626462..0bd1e4c 100644 --- a/crates/suitcase/src/main.rs +++ b/crates/suitcase/src/main.rs @@ -1,5 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(not(any(feature = "wgpu", feature = "glow")))] +compile_error!("PS2Suitcase must be built with at least one renderer feature enabled: enable either the \"wgpu\" feature, the \"glow\" feature, or both."); + mod components; mod data; mod io; @@ -16,43 +19,99 @@ use crate::{ components::menu_bar::{handle_accelerators, menu_bar}, components::tab_viewer::{TabType, TabViewer}, components::toolbar::toolbar, + data::files::Files, data::state::{AppEvent, AppState}, data::virtual_file::VirtualFile, io::export_psu::export_psu, io::file_watcher::FileWatcher, io::read_folder::read_folder, - tabs::{ICNViewer, IconSysViewer, TitleCfgViewer}, + tabs::{ICNViewer, IconSysViewer, PsuTomlViewer, TitleCfgViewer}, wizards::create_icn::create_icn_wizard, }; use eframe::egui::{Context, Frame, IconData, Margin, ViewportCommand}; use eframe::{egui, NativeOptions, Storage}; use egui_dock::{AllowedSplits, DockArea, DockState, NodeIndex, SurfaceIndex, TabIndex}; -use ps2_filetypes::TitleCfg; -use std::path::PathBuf; +use ps2_filetypes::templates::{PSU_TOML_TEMPLATE, TITLE_CFG_TEMPLATE}; +use std::any::Any; +use std::fmt; +use std::panic::{self, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; use std::process::Command; fn main() -> eframe::Result<()> { - let options = NativeOptions { + let mut attempts: Vec<(eframe::Renderer, &str, u16)> = Vec::new(); + + #[cfg(feature = "wgpu")] + { + attempts.push((eframe::Renderer::Wgpu, "WGPU", 4)); + attempts.push((eframe::Renderer::Wgpu, "WGPU", 1)); + } + + #[cfg(feature = "glow")] + { + attempts.push((eframe::Renderer::Glow, "Glow", 4)); + attempts.push((eframe::Renderer::Glow, "Glow", 1)); + } + + let mut last_failure = None; + let total_attempts = attempts.len(); + + for (index, (renderer, name, multisampling)) in attempts.into_iter().enumerate() { + match try_run_renderer(renderer, multisampling) { + Ok(()) => return Ok(()), + Err(failure) => { + let has_fallback = index + 1 < total_attempts; + report_renderer_error(name, multisampling, &failure, has_fallback); + last_failure = Some(failure); + } + } + } + + if let Some(failure) = last_failure { + Err(failure.into_error()) + } else { + Ok(()) + } +} + +fn create_native_options(renderer: eframe::Renderer, multisampling: u16) -> NativeOptions { + NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1920.0, 1080.0]) .with_icon({ - let icon = include_bytes!("../assets/ps2.png"); - let result = image::load_from_memory(icon).expect("Failed to load icon"); - - let width = result.width(); - let height = result.height(); - - IconData { - rgba: result.as_rgba8().unwrap().clone().into_raw(), - width, - height, + let icon = include_bytes!("../assets/icon.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, + } + } } }), - multisampling: 4, - depth_buffer: 1, - renderer: eframe::Renderer::Glow, + multisampling, + // Request a standard 24-bit depth buffer. WGPU expects at least 24 bits + // on most platforms, and Glow gracefully ignores the request when it + // cannot provide a depth buffer. + depth_buffer: 24, + renderer, ..Default::default() - }; + } +} + +fn run_app(options: NativeOptions) -> eframe::Result<()> { eframe::run_native( "PS2Suitcase", options, @@ -63,6 +122,104 @@ fn main() -> eframe::Result<()> { ) } +fn report_renderer_error( + renderer: &str, + multisampling: u16, + failure: &RendererFailure, + has_fallback: bool, +) { + let msaa_description = if multisampling > 1 { + format!("{multisampling}x MSAA") + } else { + "no MSAA".to_owned() + }; + + let mut message = + format!("Failed to initialize {renderer} renderer with {msaa_description}: {failure}"); + + if has_fallback { + message.push_str("\n\nAttempting fallback..."); + } + + eprintln!("{message}"); + + #[cfg(target_os = "windows")] + { + use rfd::MessageDialog; + + MessageDialog::new() + .set_title("PS2Suitcase") + .set_description(&message) + .set_buttons(rfd::MessageButtons::Ok) + .show(); + } +} + +fn try_run_renderer(renderer: eframe::Renderer, multisampling: u16) -> Result<(), RendererFailure> { + let options = create_native_options(renderer, multisampling); + match panic::catch_unwind(AssertUnwindSafe(|| run_app(options))) { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(RendererFailure::Error(err)), + Err(payload) => Err(RendererFailure::Panic(panic_message(payload))), + } +} + +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() + } +} + +enum RendererFailure { + Panic(String), + Error(eframe::Error), +} + +impl RendererFailure { + fn into_error(self) -> eframe::Error { + match self { + RendererFailure::Panic(message) => { + eframe::Error::AppCreation(Box::new(PanicAppError(message))) + } + RendererFailure::Error(err) => err, + } + } +} + +impl fmt::Display for RendererFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RendererFailure::Panic(message) => write!(f, "panic: {message}"), + RendererFailure::Error(err) => write!(f, "{err}"), + } + } +} + +impl fmt::Debug for RendererFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RendererFailure::Panic(message) => f.debug_tuple("Panic").field(message).finish(), + RendererFailure::Error(err) => f.debug_tuple("Error").field(err).finish(), + } + } +} + +#[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 {} + struct PSUBuilderApp { tree: DockState>, state: AppState, @@ -104,7 +261,13 @@ impl PSUBuilderApp { let folder = config.opened_folder?; if folder.exists() { - self.do_open_folder(folder).ok() + match self.do_open_folder(folder.clone()) { + Ok(_) => Some(()), + Err(err) => { + self.report_folder_error(&folder, &err, false); + None + } + } } else { None } @@ -142,6 +305,9 @@ impl PSUBuilderApp { AppEvent::CreateICN => { self.show_create_icn = true; } + AppEvent::CreatePsuToml => { + self.create_psu_toml(); + } AppEvent::CreateTitleCfg => { self.create_title_cfg(); } @@ -189,7 +355,19 @@ impl PSUBuilderApp { fn handle_fs_events(&mut self) { while let Ok(_event) = self.file_watcher.event_rx.try_recv() { - self.state.files = read_folder(self.state.opened_folder.clone().unwrap()).unwrap(); + let Some(folder) = self.state.opened_folder.clone() else { + continue; + }; + + match read_folder(folder.clone()) { + Ok(files) => { + self.state.files = files; + } + Err(err) => { + self.report_folder_error(&folder, &err, false); + self.clear_opened_folder(); + } + } } } @@ -219,6 +397,10 @@ impl PSUBuilderApp { "cfg" | "cnf" | "dat" | "txt" => Some(TabType::TitleCfgViewer( TitleCfgViewer::new(&file, &self.state), )), + "toml" => Some(TabType::PsuTomlViewer(PsuTomlViewer::new( + &file, + &self.state, + ))), _ => None, }; @@ -261,11 +443,16 @@ impl PSUBuilderApp { fn open_folder(&mut self) { if let Some(folder) = rfd::FileDialog::new().pick_folder() { - self.do_open_folder(folder).expect("Failed to open folder"); + if let Err(err) = self.do_open_folder(folder.clone()) { + self.report_folder_error(&folder, &err, true); + } } } fn do_open_folder(&mut self, folder: PathBuf) -> std::io::Result<()> { + let files = read_folder(folder.clone())?; + self.file_tree.index_folder(&folder)?; + self.state.opened_folder = Some(folder.clone()); self.state.set_title( folder @@ -275,35 +462,91 @@ impl PSUBuilderApp { .to_string(), ); self.file_watcher.change_path(&folder); - self.file_tree.index_folder(&folder); - self.state.files = read_folder(folder)?; + self.state.files = files; Ok(()) } + fn clear_opened_folder(&mut self) { + self.state.opened_folder = None; + self.state.set_title("PS2Suitcase".to_owned()); + self.state.files = Files::default(); + } + + fn report_folder_error(&self, folder: &Path, err: &std::io::Error, show_dialog: bool) { + eprintln!("Failed to load folder '{}': {err}", folder.display()); + + if show_dialog { + #[cfg(not(target_arch = "wasm32"))] + { + use rfd::{MessageButtons, MessageDialog}; + + MessageDialog::new() + .set_title("PS2Suitcase") + .set_description(&format!( + "Failed to load folder '{}':\n{}", + folder.display(), + err + )) + .set_buttons(MessageButtons::Ok) + .show(); + } + } + } + fn create_title_cfg(&mut self) { + let Some(directory) = self.state.opened_folder.clone() else { + return; + }; + if let Some(filepath) = rfd::FileDialog::new() .set_title("Select a folder to create title.cfg in") .set_file_name("title.cfg") .add_filter("title.cfg", &["cfg"]) - .set_directory(self.state.opened_folder.clone().unwrap()) + .set_directory(directory) .save_file() { - std::fs::write( - filepath.clone(), - TitleCfg::new("".to_string()) - .add_missing_fields() - .to_string() - .into_bytes(), - ) - .expect("Failed to title.cfg"); + std::fs::write(&filepath, TITLE_CFG_TEMPLATE) + .expect("Failed to write title.cfg template"); + let file_name = filepath.file_name().unwrap().to_str().unwrap().to_string(); self.handle_open(VirtualFile { - name: filepath.file_name().unwrap().to_str().unwrap().to_string(), + name: file_name, size: 0, file_path: filepath, }); - self.file_tree - .index_folder(&self.state.opened_folder.clone().unwrap()); + if let Some(folder) = &self.state.opened_folder { + if let Err(err) = self.file_tree.index_folder(folder) { + self.report_folder_error(folder.as_path(), &err, false); + } + } + } + } + + fn create_psu_toml(&mut self) { + let Some(directory) = self.state.opened_folder.clone() else { + return; + }; + + if let Some(filepath) = rfd::FileDialog::new() + .set_title("Select a folder to create psu.toml in") + .set_file_name("psu.toml") + .add_filter("psu.toml", &["toml"]) + .set_directory(directory) + .save_file() + { + std::fs::write(&filepath, PSU_TOML_TEMPLATE) + .expect("Failed to write psu.toml template"); + let file_name = filepath.file_name().unwrap().to_str().unwrap().to_string(); + self.handle_open(VirtualFile { + name: file_name, + size: 0, + file_path: filepath, + }); + if let Some(folder) = &self.state.opened_folder { + if let Err(err) = self.file_tree.index_folder(folder) { + self.report_folder_error(folder.as_path(), &err, false); + } + } } } } diff --git a/crates/suitcase/src/rendering/program.rs b/crates/suitcase/src/rendering/program.rs index 708a80d..fa83d04 100644 --- a/crates/suitcase/src/rendering/program.rs +++ b/crates/suitcase/src/rendering/program.rs @@ -1,5 +1,7 @@ use eframe::glow; use eframe::glow::HasContext; +use std::collections::HashMap; +use std::fmt; pub enum UniformValueInternal { Int(i32), @@ -10,6 +12,73 @@ pub enum UniformValueInternal { Vector4f(cgmath::Vector4), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UniformType { + Int, + Float, + Matrix4, + Vector2, + Vector3, + Vector4, + Sampler2D, + Unknown(u32), +} + +impl UniformType { + fn from_gl_type(gl_type: u32) -> Self { + match gl_type { + glow::FLOAT => UniformType::Float, + glow::FLOAT_MAT4 => UniformType::Matrix4, + glow::FLOAT_VEC2 => UniformType::Vector2, + glow::FLOAT_VEC3 => UniformType::Vector3, + glow::FLOAT_VEC4 => UniformType::Vector4, + glow::INT => UniformType::Int, + glow::SAMPLER_2D => UniformType::Sampler2D, + _ => UniformType::Unknown(gl_type), + } + } + + fn from_uniform_value(value: &UniformValueInternal) -> Self { + match value { + UniformValueInternal::Int(_) => UniformType::Int, + UniformValueInternal::Float(_) => UniformType::Float, + UniformValueInternal::Matrix4f(_) => UniformType::Matrix4, + UniformValueInternal::Vector2f(_) => UniformType::Vector2, + UniformValueInternal::Vector3f(_) => UniformType::Vector3, + UniformValueInternal::Vector4f(_) => UniformType::Vector4, + } + } + + fn accepts(self, actual: UniformType) -> bool { + match self { + UniformType::Sampler2D => matches!(actual, UniformType::Int), + UniformType::Unknown(_) => true, + _ => self == actual, + } + } +} + +impl fmt::Display for UniformType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UniformType::Int => write!(f, "int"), + UniformType::Float => write!(f, "float"), + UniformType::Matrix4 => write!(f, "mat4"), + UniformType::Vector2 => write!(f, "vec2"), + UniformType::Vector3 => write!(f, "vec3"), + UniformType::Vector4 => write!(f, "vec4"), + UniformType::Sampler2D => write!(f, "sampler2D"), + UniformType::Unknown(gl_type) => write!(f, "unknown(0x{gl_type:04X})"), + } + } +} + +#[derive(Clone, Debug)] +struct UniformMetadata { + name: String, + uniform_type: UniformType, +} + pub trait UniformValue { fn uniform_value(&self) -> UniformValueInternal; } @@ -53,6 +122,7 @@ impl UniformValue for cgmath::Vector2 { #[derive(Clone, Debug)] pub struct Program { program: glow::Program, + uniforms: HashMap, } impl Program { @@ -98,16 +168,22 @@ impl Program { gl.delete_shader(shader); } - // TODO: Determine if we need type checking - // let active_uniforms = gl.get_active_uniforms(program); - // - // for i in 0..active_uniforms { - // let ActiveUniform{name, size, utype} = gl.get_active_uniform(program, i).unwrap(); - // println!("{} {} {}", name, size, utype); - // } + let mut uniforms = HashMap::new(); + let active_uniforms = gl.get_active_uniforms(program); + for index in 0..active_uniforms { + if let Some(active_uniform) = gl.get_active_uniform(program, index) { + let name = active_uniform.name; + let uniform_type = UniformType::from_gl_type(active_uniform.utype); + let metadata = UniformMetadata { + name: name.clone(), + uniform_type, + }; + uniforms.insert(name, metadata); + } + } - Self { program } + Self { program, uniforms } } pub fn gl(&self) -> glow::Program { @@ -121,10 +197,29 @@ impl Program { pub fn set(&self, gl: &glow::Context, name: &str, value: impl UniformValue) { unsafe { let program = self.program; - let location = gl.get_uniform_location(self.program, name).expect(format!("Failed to get location {}", name).as_str()); + let uniform_value = value.uniform_value(); + + let Some(metadata) = self.uniform_metadata(name) else { + eprintln!("Attempted to set unknown uniform `{name}`"); + return; + }; + + let actual_type = UniformType::from_uniform_value(&uniform_value); + if !metadata.uniform_type.accepts(actual_type) { + eprintln!( + "Type mismatch for uniform `{}` (requested as `{name}`): expected {}, found {}", + metadata.name, metadata.uniform_type, actual_type, + ); + return; + } + + let Some(location) = gl.get_uniform_location(self.program, name) else { + eprintln!("Failed to get location `{name}`"); + return; + }; let location = Some(&location); - match value.uniform_value() { + match uniform_value { UniformValueInternal::Int(i) => { gl.program_uniform_1_i32(program, location, i); } @@ -132,7 +227,12 @@ impl Program { gl.program_uniform_1_f32(program, location, f); } UniformValueInternal::Matrix4f(mat4) => { - gl.program_uniform_matrix_4_f32_slice(program, location, false, &convert_matrix(mat4)); + gl.program_uniform_matrix_4_f32_slice( + program, + location, + false, + &convert_matrix(mat4), + ); } UniformValueInternal::Vector2f(vec2) => { gl.program_uniform_2_f32(program, location, vec2[0], vec2[1]); @@ -142,11 +242,17 @@ impl Program { } UniformValueInternal::Vector4f(vec4) => { gl.program_uniform_4_f32(program, location, vec4[0], vec4[1], vec4[2], vec4[3]); - }, + } } } } + fn uniform_metadata(&self, name: &str) -> Option<&UniformMetadata> { + self.uniforms.get(name).or_else(|| { + canonical_uniform_name(name).and_then(|canonical| self.uniforms.get(&canonical)) + }) + } + pub fn drop(&self, gl: &glow::Context) { unsafe { gl.delete_program(self.program); @@ -159,4 +265,36 @@ fn convert_matrix(mat: cgmath::Matrix4) -> Vec { mat.x.x, mat.x.y, mat.x.z, mat.x.w, mat.y.x, mat.y.y, mat.y.z, mat.y.w, mat.z.x, mat.z.y, mat.z.z, mat.z.w, mat.w.x, mat.w.y, mat.w.z, mat.w.w, ] -} \ No newline at end of file +} + +fn canonical_uniform_name(name: &str) -> Option { + if !name.contains('[') { + return None; + } + + let mut canonical = String::with_capacity(name.len()); + let mut chars = name.chars().peekable(); + + while let Some(ch) = chars.next() { + canonical.push(ch); + + if ch == '[' { + while let Some(next_ch) = chars.peek() { + if *next_ch == ']' { + break; + } + chars.next(); + } + + canonical.push('0'); + + match chars.next() { + Some(']') => canonical.push(']'), + Some(other) => canonical.push(other), + None => return None, + } + } + } + + Some(canonical) +} diff --git a/crates/suitcase/src/tabs/icon_sys_viewer.rs b/crates/suitcase/src/tabs/icon_sys_viewer.rs index dfdb78d..e581eed 100644 --- a/crates/suitcase/src/tabs/icon_sys_viewer.rs +++ b/crates/suitcase/src/tabs/icon_sys_viewer.rs @@ -242,13 +242,13 @@ impl IconSysViewer { ui.end_row(); ui.label("X"); - ui.add(egui::Slider::new(&mut light.direction.x, 0.0..=1.0)); + ui.add(egui::Slider::new(&mut light.direction.x, -1.0..=1.0)); ui.end_row(); ui.label("Y"); - ui.add(egui::Slider::new(&mut light.direction.y, 0.0..=1.0)); + ui.add(egui::Slider::new(&mut light.direction.y, -1.0..=1.0)); ui.end_row(); ui.label("Z"); - ui.add(egui::Slider::new(&mut light.direction.z, 0.0..=1.0)); + ui.add(egui::Slider::new(&mut light.direction.z, -1.0..=1.0)); ui.end_row(); Ui::separator(ui); @@ -408,3 +408,96 @@ fn draw_background(ui: &mut Ui, colors: &[PS2RgbaInterface; 4]) { painter.add(egui::Shape::mesh(mesh)); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tabs::Tab; + use tempfile::tempdir; + + #[test] + fn icon_sys_viewer_preserves_negative_light_directions_when_loading_and_saving() { + let temp_dir = tempdir().expect("failed to create temp dir"); + let icon_sys_path = temp_dir.path().join("icon.sys"); + + let background_color = Color::new(10, 20, 30, 255); + let light_direction = Vector { + x: -0.5, + y: 0.75, + z: -1.0, + w: 1.0, + }; + + let original_icon_sys = IconSys { + flags: 0, + linebreak_pos: 0, + background_transparency: 0, + background_colors: [ + background_color, + background_color, + background_color, + background_color, + ], + light_directions: [light_direction, light_direction, light_direction], + light_colors: [ + ColorF { + r: 0.1, + g: 0.2, + b: 0.3, + a: 1.0, + }, + ColorF { + r: 0.4, + g: 0.5, + b: 0.6, + a: 1.0, + }, + ColorF { + r: 0.7, + g: 0.8, + b: 0.9, + a: 1.0, + }, + ], + ambient_color: ColorF { + r: 0.2, + g: 0.3, + b: 0.4, + a: 1.0, + }, + title: "Test".into(), + icon_file: "test.icn".into(), + icon_copy_file: "copy.icn".into(), + icon_delete_file: "delete.icn".into(), + }; + + let bytes = original_icon_sys + .to_bytes() + .expect("failed to serialize icon.sys"); + std::fs::write(&icon_sys_path, &bytes).expect("failed to write icon.sys"); + + let virtual_file = VirtualFile { + name: "icon.sys".into(), + file_path: icon_sys_path.clone(), + size: bytes.len() as u64, + }; + + let mut app_state = AppState::new(); + app_state.opened_folder = Some(temp_dir.path().to_path_buf()); + + let mut viewer = IconSysViewer::new(&virtual_file, &app_state); + + assert_eq!(viewer.lights[0].direction.x, light_direction.x); + assert_eq!(viewer.lights[0].direction.y, light_direction.y); + assert_eq!(viewer.lights[0].direction.z, light_direction.z); + + viewer.save(); + + let reloaded_icon_sys = + IconSys::new(std::fs::read(icon_sys_path).expect("failed to read icon.sys")); + + assert_eq!(reloaded_icon_sys.light_directions[0].x, light_direction.x); + assert_eq!(reloaded_icon_sys.light_directions[0].y, light_direction.y); + assert_eq!(reloaded_icon_sys.light_directions[0].z, light_direction.z); + } +} diff --git a/crates/suitcase/src/tabs/mod.rs b/crates/suitcase/src/tabs/mod.rs index ed61ad9..da8303c 100644 --- a/crates/suitcase/src/tabs/mod.rs +++ b/crates/suitcase/src/tabs/mod.rs @@ -1,9 +1,11 @@ pub mod icn_viewer; pub mod icon_sys_viewer; +pub mod psu_toml_viewer; pub mod tab; pub mod title_cfg_viewer; pub use icn_viewer::*; pub use icon_sys_viewer::*; +pub use psu_toml_viewer::*; pub use tab::*; pub use title_cfg_viewer::*; diff --git a/crates/suitcase/src/tabs/psu_toml_viewer.rs b/crates/suitcase/src/tabs/psu_toml_viewer.rs new file mode 100644 index 0000000..e429aaf --- /dev/null +++ b/crates/suitcase/src/tabs/psu_toml_viewer.rs @@ -0,0 +1,272 @@ +use crate::data::state::AppState; +use crate::tabs::Tab; +use crate::VirtualFile; +use eframe::egui::{self, menu, Color32, DragValue, ScrollArea, TextEdit, Ui}; +use relative_path::PathExt; +use std::path::PathBuf; +use toml::value::Table; +use toml::Value; + +pub struct PsuTomlViewer { + file: String, + file_path: PathBuf, + contents: String, + structured: Option, + parse_error: Option, + show_raw: bool, + modified: bool, +} + +impl PsuTomlViewer { + pub fn new(file: &VirtualFile, state: &AppState) -> Self { + let contents = std::fs::read_to_string(&file.file_path).unwrap_or_default(); + let file_path = file.file_path.clone(); + let file = file + .file_path + .relative_to(state.opened_folder.clone().unwrap()) + .unwrap() + .to_string(); + + Self { + file, + file_path, + contents, + structured: None, + parse_error: None, + show_raw: true, + modified: false, + } + } + + pub fn show(&mut self, ui: &mut Ui) { + ui.vertical(|ui| { + menu::bar(ui, |ui| { + ui.set_height(25.0); + if ui.button("Save").clicked() { + self.save(); + } + let toggle_label = if self.show_raw { + "Structured View" + } else { + "Raw View" + }; + if ui.button(toggle_label).clicked() { + self.toggle_view(); + } + }); + + if let Some(error) = &self.parse_error { + ui.colored_label(Color32::RED, format!("Failed to parse TOML: {error}")); + } + + ui.separator(); + + ScrollArea::vertical().show(ui, |ui| { + if self.show_raw { + self.show_raw_editor(ui); + } else { + self.show_structured_editor(ui); + } + }); + }); + } + + fn show_raw_editor(&mut self, ui: &mut Ui) { + let response = ui.add( + TextEdit::multiline(&mut self.contents) + .desired_width(ui.available_width()) + .code_editor(), + ); + + if response.changed() { + self.modified = true; + self.structured = None; + self.parse_error = None; + } + } + + fn show_structured_editor(&mut self, ui: &mut Ui) { + if let Some(value) = &mut self.structured { + let changed = match value { + Value::Table(table) => Self::render_table(ui, "", table), + Value::Array(array) => Self::render_array(ui, "", array), + _ => Self::render_entry(ui, "", "value", value), + }; + + if changed { + self.modified = true; + self.contents = Self::value_to_string(value); + } + } else { + ui.label("Switch back to the raw editor to resolve parsing issues."); + } + } + + fn toggle_view(&mut self) { + if self.show_raw { + match self.parse_contents() { + Ok(value) => { + self.structured = Some(value); + self.parse_error = None; + self.show_raw = false; + } + Err(err) => { + self.parse_error = Some(err); + } + } + } else { + if let Some(value) = &self.structured { + self.contents = Self::value_to_string(value); + } + self.show_raw = true; + self.structured = None; + self.parse_error = None; + } + } + + fn parse_contents(&self) -> Result { + self.contents + .parse::() + .map_err(|err| err.to_string()) + } + + fn render_table(ui: &mut Ui, path: &str, table: &mut Table) -> bool { + let mut changed = false; + for (key, value) in table.iter_mut() { + let child_path = Self::join_path(path, key); + if Self::render_entry(ui, &child_path, key, value) { + changed = true; + } + } + changed + } + + fn render_array(ui: &mut Ui, path: &str, array: &mut Vec) -> bool { + let mut changed = false; + for (index, value) in array.iter_mut().enumerate() { + let label = format!("[{index}]"); + let child_path = Self::join_index(path, index); + if Self::render_entry(ui, &child_path, &label, value) { + changed = true; + } + } + changed + } + + fn render_entry(ui: &mut Ui, path: &str, label: &str, value: &mut Value) -> bool { + match value { + Value::Table(table) => egui::CollapsingHeader::new(label) + .id_source(path.to_owned()) + .default_open(true) + .show(ui, |ui| Self::render_table(ui, path, table)) + .body_returned + .unwrap_or(false), + Value::Array(array) => egui::CollapsingHeader::new(label) + .id_source(path.to_owned()) + .default_open(true) + .show(ui, |ui| Self::render_array(ui, path, array)) + .body_returned + .unwrap_or(false), + Value::String(text) => { + ui.horizontal(|ui| { + ui.label(label); + ui.text_edit_singleline(text).changed() + }) + .inner + } + Value::Integer(integer) => { + ui.horizontal(|ui| { + ui.label(label); + let mut value = *integer; + let changed = ui.add(DragValue::new(&mut value)).changed(); + if changed { + *integer = value; + } + changed + }) + .inner + } + Value::Float(float) => { + ui.horizontal(|ui| { + ui.label(label); + let mut value = *float; + let changed = ui.add(DragValue::new(&mut value).speed(0.1)).changed(); + if changed { + *float = value; + } + changed + }) + .inner + } + Value::Boolean(boolean) => ui.checkbox(boolean, label).changed(), + Value::Datetime(datetime) => { + ui.horizontal(|ui| { + ui.label(label); + let mut text = datetime.to_string(); + let response = ui.text_edit_singleline(&mut text); + if response.changed() { + match text.parse() { + Ok(parsed) => { + *datetime = parsed; + true + } + Err(_) => { + ui.colored_label(Color32::RED, "Invalid datetime"); + false + } + } + } else { + false + } + }) + .inner + } + } + } + + fn join_path(path: &str, segment: &str) -> String { + if path.is_empty() { + segment.to_owned() + } else { + format!("{path}.{segment}") + } + } + + fn join_index(path: &str, index: usize) -> String { + if path.is_empty() { + format!("[{index}]") + } else { + format!("{path}[{index}]") + } + } + + fn value_to_string(value: &Value) -> String { + toml::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) + } +} + +impl Tab for PsuTomlViewer { + fn get_id(&self) -> &str { + &self.file + } + + fn get_title(&self) -> String { + self.file.clone() + } + + fn get_modified(&self) -> bool { + self.modified + } + + fn save(&mut self) { + if !self.show_raw { + if let Some(value) = &self.structured { + self.contents = Self::value_to_string(value); + } + } + + std::fs::write(&self.file_path, self.contents.as_bytes()) + .expect("Failed to write psu.toml"); + self.modified = false; + } +} diff --git a/crates/suitcase/src/tabs/title_cfg_viewer.rs b/crates/suitcase/src/tabs/title_cfg_viewer.rs index a577f82..18f3375 100644 --- a/crates/suitcase/src/tabs/title_cfg_viewer.rs +++ b/crates/suitcase/src/tabs/title_cfg_viewer.rs @@ -1,7 +1,7 @@ use crate::data::state::AppState; use crate::tabs::Tab; use crate::VirtualFile; -use eframe::egui::{menu, CornerRadius, Id, PopupCloseBehavior, Response, TextEdit, Ui}; +use eframe::egui::{self, menu, CornerRadius, Id, PopupCloseBehavior, Response, TextEdit, Ui}; use ps2_filetypes::TitleCfg; use relative_path::PathExt; use std::ops::Add; @@ -51,94 +51,95 @@ impl TitleCfgViewer { }); ui.separator(); - if self.is_raw_editor { - eframe::egui::Grid::new(Id::from("TitleCfgEditor")) - .num_columns(1) - .min_col_width(ui.available_width()) - .show(ui, |ui| { - ui.add( - TextEdit::multiline(&mut self.title_cfg.contents) - .desired_width(ui.available_width()), - ) + egui::ScrollArea::vertical().show(ui, |ui| { + if self.is_raw_editor { + self.show_raw_editor(ui); + } else { + self.show_structured_editor(ui); + } + }); + }); + } + + fn show_raw_editor(&mut self, ui: &mut Ui) { + eframe::egui::Grid::new(Id::from("TitleCfgEditor")) + .num_columns(1) + .min_col_width(ui.available_width()) + .show(ui, |ui| { + ui.add( + TextEdit::multiline(&mut self.title_cfg.contents) + .desired_width(ui.available_width()), + ) + .changed() + .then(|| self.modified = true); + }); + } + + fn show_structured_editor(&mut self, ui: &mut Ui) { + eframe::egui::Grid::new(Id::from("TitleCfgEditor")) + .num_columns(3) + .min_col_width(200.0) + .max_col_width(ui.available_width()) + .show(ui, |ui| { + if self.encoding_error { + ui.colored_label( + eframe::egui::Color32::RED, + "Encoding error, please use valid ASCII or UTF-8 encoding.", + ); + return; + } + + if !self.title_cfg.has_mandatory_fields() { + ui.colored_label(eframe::egui::Color32::RED, "Missing mandatory fields."); + ui.button("Fix").clicked().then(|| { + self.title_cfg.add_missing_fields(); + self.modified = true; + }); + ui.end_row(); + } + + for (key, value) in self.title_cfg.index_map.iter_mut() { + let key_helper = self.title_cfg.helper.get(key); + + let mut tooltip_content = "".to_string(); + if key_helper.is_some_and(|key| key.get("tooltip").is_some()) { + tooltip_content = key_helper.unwrap().get("tooltip").unwrap().to_string(); + } + + let key_label = ui.label(key.to_string()); + if !tooltip_content.is_empty() { + key_label.on_hover_ui(|ui| { + ui.label(tooltip_content); + }); + } + + if key == "Description" { + ui.add(TextEdit::multiline(value).desired_rows(6)) .changed() .then(|| self.modified = true); - }); - } else { - eframe::egui::Grid::new(Id::from("TitleCfgEditor")) - .num_columns(3) - .min_col_width(200.0) - .max_col_width(ui.available_width()) - .show(ui, |ui| { - if self.encoding_error { - ui.colored_label( - eframe::egui::Color32::RED, - "Encoding error, please use valid ASCII or UTF-8 encoding.", - ); - return; - } - - if !self.title_cfg.has_mandatory_fields() { + if value.len() > MAXIMUM_DESCRIPTION_LENGTH { ui.colored_label( eframe::egui::Color32::RED, - "Missing mandatory fields.", + format!( + "Description too long, it will be truncated in OPL. {}/{}", + value.len(), + MAXIMUM_DESCRIPTION_LENGTH, + ), ); - ui.button("Fix").clicked().then(|| { - self.title_cfg.add_missing_fields(); - self.modified = true; - }); - ui.end_row(); } + } else if key_helper.is_some_and(|key| key.get("values").is_some()) { + value_select(ui, key, value, key_helper.unwrap().get("values").unwrap()) + .changed() + .then(|| self.modified = true); + } else { + ui.text_edit_singleline(value) + .changed() + .then(|| self.modified = true); + } - for (key, value) in self.title_cfg.index_map.iter_mut() { - let key_helper = self.title_cfg.helper.get(key); - - let mut tooltip_content = "".to_string(); - if key_helper.is_some_and(|key| key.get("tooltip").is_some()) { - tooltip_content = - key_helper.unwrap().get("tooltip").unwrap().to_string(); - } - - let key_label = ui.label(key.to_string()); - if !tooltip_content.is_empty() { - key_label.on_hover_ui(|ui| { - ui.label(tooltip_content); - }); - } - - if key == "Description" { - ui.add(TextEdit::multiline(value).desired_rows(6)) - .changed() - .then(|| self.modified = true); - if value.len() > MAXIMUM_DESCRIPTION_LENGTH { - ui.colored_label( - eframe::egui::Color32::RED, - format!( - "Description too long, it will be truncated in OPL. {}/{}", - value.len(), - MAXIMUM_DESCRIPTION_LENGTH, - ), - ); - } - } else if key_helper.is_some_and(|key| key.get("values").is_some()) { - value_select( - ui, - key, - value, - key_helper.unwrap().get("values").unwrap(), - ) - .changed() - .then(|| self.modified = true); - } else { - ui.text_edit_singleline(value) - .changed() - .then(|| self.modified = true); - } - - ui.end_row(); - } - }); - } - }); + ui.end_row(); + } + }); } pub fn toggle_editors(&mut self) { diff --git a/crates/xtask-build-app/src/main.rs b/crates/xtask-build-app/src/main.rs index c3b4242..05ea628 100644 --- a/crates/xtask-build-app/src/main.rs +++ b/crates/xtask-build-app/src/main.rs @@ -1,6 +1,6 @@ use std::fs::{create_dir_all, remove_dir_all}; -use std::{env, io}; use std::io::ErrorKind; +use std::{env, io}; fn cwd_to_workspace_root() -> io::Result<()> { let pkg_root = env!("CARGO_MANIFEST_DIR"); @@ -10,25 +10,33 @@ fn cwd_to_workspace_root() -> io::Result<()> { fn main() -> io::Result<()> { if !cfg!(target_os = "macos") { - return Err(io::Error::new(ErrorKind::Other, "unsupported operating system")); + return Err(io::Error::new( + ErrorKind::Other, + "unsupported operating system", + )); } - + cwd_to_workspace_root()?; - + let cur_dir = env::current_dir()?; - + let contents_path = cur_dir.join("build/PSU Builder.app/Contents"); let mac_os_path = contents_path.join("MacOS"); let resources_path = contents_path.join("Resources"); - + remove_dir_all(cur_dir.join("build/PSU Builder.app"))?; create_dir_all(&mac_os_path)?; create_dir_all(&resources_path)?; std::fs::copy("target/debug/suitcase", mac_os_path.join("PSU Builder"))?; - std::fs::copy("../../suitcase/assets/ps2.icns", resources_path.join("icon.icns"))?; - std::fs::copy("../../suitcase/assets/Info.plist", contents_path.join("Info.plist"))?; - - + std::fs::copy( + "../../suitcase/assets/app_icon.icns", + resources_path.join("icon.icns"), + )?; + std::fs::copy( + "../../suitcase/assets/Info.plist", + contents_path.join("Info.plist"), + )?; + Ok(()) } diff --git a/timestampdiscrepancyfixtest1.py b/timestampdiscrepancyfixtest1.py new file mode 100644 index 0000000..6dcb5b4 --- /dev/null +++ b/timestampdiscrepancyfixtest1.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +SAS-TIMESTAMPS (FAT-safe ordering, free-form PS2 bias, dash-ignoring, slots-per-category config) + +Windows-only utility: deterministically set creation/modified/access times so root-level +folders (and everything inside them) are ordered newest→oldest by a stable mapping from +folder name → timestamp. Timestamps are accurate to FAT/VFAT realities. + +Key behavior: +- Base time = 12/31/2098 23:59:59 (LOCAL). We subtract a deterministic offset per folder. +- Newest → oldest category blocks in this order: + APP_* → APPS → PS1_* → EMU_* → GME_* → DST_* → DBG_* → RAA_* → RTE_* → DEFAULT → SYS_* → ZZY_* → ZZZ_* +- Unprefixed special names are mapped to an **effective**, prefixed name (e.g., BOOT → SYS_BOOT) + and that **effective** name is used both for: + (1) category selection, + (2) within-category alphabetical ordering. +- **Dashes ('-') are ignored for ordering** (removed before lex ordering). +- **FAT-safe mode** (`--fat-safe`): snaps all timestamps to **even seconds** (microseconds=0) and uses **2 s spacing**, + matching FAT/VFAT mtime precision and preventing copy/rounding drift. +- **PS2 bias** (`--ps2-bias-seconds`): applies an additional signed seconds offset so the PS2 browser's displayed times + match Windows Explorer display exactly, even if the PS2 reader/RTC applies a nonstandard skew. +- Dry-run (`--dry-run`) writes SAS-TIMESTAMPS-dryrun.tsv (newest→oldest) in the current working directory. + +NOTE: Requires Windows (uses SetFileTime via ctypes). +""" + +import argparse +import ctypes +import os +import sys +from datetime import datetime, timezone, timedelta + +# ========================= +# ===== USER CONFIG ======= +# ========================= +# FAT-safe default spacing: 2 seconds (FAT mtime has 2-second granularity) +SECONDS_BETWEEN_ITEMS = 2 + +# Big slot budget so each name gets a unique second within its category, even with many items. +# 86_400 seconds ≈ 1 day per category. Nice for viewing in file browser as each category will be a different day. +SLOTS_PER_CATEGORY = 86_400 + +# Comma-separated lists of names (no prefixes) to be treated as if they belong to these categories. +# Edit these to add your own folder names (case-insensitive). Whitespace is ignored. +UNPREFIXED_IN_CATEGORY_CSV = { + "APP_": "OSDXMB, XEBPLUS", + "APPS": "", # exact "APPS" is its own name + "PS1_": "", + "EMU_": "", + "GME_": "", + "DST_": "", + "DBG_": "", + "RAA_": "RESTART, POWEROFF", + "RTE_": "NEUTRINO", + "SYS_": "BOOT", + "ZZY_": "EXPLOITS", + "ZZZ_": "BM, MATRIXTEAM, OPL", +} + +# Category order (newest → oldest). +CATEGORY_ORDER = [ + "APP_", + "APPS", + "PS1_", + "EMU_", + "GME_", + "DST_", + "DBG_", + "RAA_", + "RTE_", + "DEFAULT", # non-matching fallbacks + "SYS_", + "ZZY_", + "ZZZ_", +] + +# ========================= +# ===== END CONFIG ======= +# ========================= + +# --- Build quick-lookup from CSV config --- +def _parse_csv(s: str): + return {x.strip().upper() for x in s.split(",") if x.strip()} if s else set() + +UNPREFIXED_MAP = {k: _parse_csv(v) for k, v in UNPREFIXED_IN_CATEGORY_CSV.items()} + +# --- Windows FILETIME helpers (ctypes) --- +_EPOCH_AS_FILETIME = 11644473600 +_HUNDREDS_OF_NS = 10_000_000 + +kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + +CreateFileW = kernel32.CreateFileW +CreateFileW.argtypes = [ + ctypes.c_wchar_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p +] +CreateFileW.restype = ctypes.c_void_p + +SetFileTime = kernel32.SetFileTime +SetFileTime.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p] +SetFileTime.restype = ctypes.c_int + +CloseHandle = kernel32.CloseHandle +CloseHandle.argtypes = [ctypes.c_void_p] +CloseHandle.restype = ctypes.c_int + +GENERIC_WRITE = 0x40000000 +FILE_SHARE_READ = 0x00000001 +FILE_SHARE_WRITE = 0x00000002 +FILE_SHARE_DELETE = 0x00000004 +OPEN_EXISTING = 3 +FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 # needed to open directories + +class FILETIME(ctypes.Structure): + _fields_ = [("dwLowDateTime", ctypes.c_uint32), + ("dwHighDateTime", ctypes.c_uint32)] + +def _dt_to_filetime(dt_utc: datetime) -> FILETIME: + unix_seconds = dt_utc.timestamp() + ft = int((unix_seconds + _EPOCH_AS_FILETIME) * _HUNDREDS_OF_NS) + return FILETIME(ft & 0xFFFFFFFF, ft >> 32) + +def _set_times_windows(path: str, dt_utc: datetime) -> None: + handle = CreateFileW( + path, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + None + ) + if handle == ctypes.c_void_p(-1).value or handle is None: + raise OSError(f"Failed to open handle for: {path} (WinError {ctypes.get_last_error()})") + try: + ft = _dt_to_filetime(dt_utc) + if not SetFileTime(handle, + ctypes.byref(ft), + ctypes.byref(ft), + ctypes.byref(ft)): + raise OSError(f"SetFileTime failed for: {path} (WinError {ctypes.get_last_error()})") + finally: + CloseHandle(handle) + +# --- Category + name → slot mapping --- +CHARSET = tuple(" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_-.") +CHAR_INDEX = {ch: i for i, ch in enumerate(CHARSET)} +BASE = len(CHARSET) + +CATEGORY_BLOCK_SECONDS = SLOTS_PER_CATEGORY * SECONDS_BETWEEN_ITEMS +CATEGORY_INDEX = {name: idx for idx, name in enumerate(CATEGORY_ORDER)} + +def _effective_category_key(eff: str) -> str: + if eff.startswith("APP_"): return "APP_" + if eff == "APPS": return "APPS" + if eff.startswith("PS1_"): return "PS1_" + if eff.startswith("EMU_"): return "EMU_" + if eff.startswith("GME_"): return "GME_" + if eff.startswith("DST_"): return "DST_" + if eff.startswith("DBG_"): return "DBG_" + if eff.startswith("RAA_"): return "RAA_" + if eff.startswith("RTE_"): return "RTE_" + if eff.startswith("SYS_") or eff == "SYS": return "SYS_" + if eff.startswith("ZZY_"): return "ZZY_" + if eff.startswith("ZZZ_"): return "ZZZ_" + return "DEFAULT" + +def _category_label_for_effective(eff: str) -> str: + key = _effective_category_key(eff) + return "DEFAULT" if key == "DEFAULT" else (key if key == "APPS" else f"{key}*") + +def _payload_for_effective(eff: str) -> str: + """Use only the part after the category key, ignoring dashes for ordering.""" + key = _effective_category_key(eff) + if key == "APPS": return "APPS" + if key == "DEFAULT": return eff.replace("-", "") + payload = eff[len(key):] if eff.startswith(key) else eff + return payload.replace("-", "") + +def _lex_fraction(payload: str) -> float: + """Map payload string to [0,1) preserving lexicographic order (dashes ignored already).""" + s = payload.upper() + total = 0.0 + scale = 1.0 + for ch in s[:128]: + scale *= BASE + code = CHAR_INDEX.get(ch, BASE - 1) + total += (code + 1) / scale + return total + +def _normalize_name_for_rules(name: str) -> str: + """ + Return the EFFECTIVE (possibly prefixed) name for all logic. + We do not strip dashes here; dashes are ignored later during ordering only. + """ + n = name.strip().upper() + + # 1) User-configured "no-prefix" names + for cat_key, names in UNPREFIXED_MAP.items(): + if n in names: + return "APPS" if cat_key == "APPS" else f"{cat_key}{n}" + + # 2) Built-in defaults + if n in ("OSDXMB", "XEBPLUS"): + return "APP_" + n + if n in ("RESTART", "POWEROFF"): + return "RAA_" + n + if n == "NEUTRINO": + return "RTE_" + n + if n == "BOOT": + return "SYS_BOOT" + if n == "EXPLOITS": + return "ZZY_EXPLOITS" + if n in ("BM", "MATRIXTEAM", "OPL"): + return "ZZZ_" + n + + # 3) Otherwise, leave as-is + return n + +def _category_priority_index(effective: str) -> int: + key = _effective_category_key(effective) + return CATEGORY_INDEX[key] + +def _slot_index_within_category(effective: str) -> int: + """ + Compute the within-category slot index using the EFFECTIVE name (e.g., 'SYS_BOOT'). + Dashes are ignored for ordering; underscores are kept. + """ + payload = _payload_for_effective(effective) + frac = _lex_fraction(payload) # [0,1) + slot = int(frac * SLOTS_PER_CATEGORY) + if slot >= SLOTS_PER_CATEGORY: + slot = SLOTS_PER_CATEGORY - 1 + return slot + +def _deterministic_offset_seconds(folder_name: str): + """ + Returns (offset_seconds, cat_idx, slot, effective_name). + Note: No 0/1-second nudge; rely on 2-second spacing for FAT safety. + """ + eff = _normalize_name_for_rules(folder_name) + cat_idx = _category_priority_index(eff) + slot = _slot_index_within_category(eff) + + nudge = 0 # removed to avoid FAT rounding collisions + + cat_offset = cat_idx * CATEGORY_BLOCK_SECONDS + name_offset = (slot * SECONDS_BETWEEN_ITEMS) + nudge + return cat_offset + name_offset, cat_idx, slot, eff + +# --- Timestamp planner --- +def _base_datetime_local_to_utc() -> datetime: + """ + Convert the local anchor time to UTC (base reference for deterministic subtraction). + """ + local_naive = datetime(2098, 12, 31, 23, 59, 59) # can be 23:59:58 if you want an inherently even anchor + local_tz = datetime.now().astimezone().tzinfo + local_aware = local_naive.replace(tzinfo=local_tz) + return local_aware.astimezone(timezone.utc) + +def _planned_timestamp_for_folder(folder_name: str): + """ + Return a tuple (utc_dt, effective_name, category_label, cat_idx, slot_idx, offset_sec). + """ + base_utc = _base_datetime_local_to_utc() + offset_sec, cat_idx, slot_idx, eff = _deterministic_offset_seconds(folder_name) + ts_utc = datetime.fromtimestamp(base_utc.timestamp() - offset_sec, tz=timezone.utc) + return ts_utc, eff, _category_label_for_effective(eff), cat_idx, slot_idx, offset_sec + +# --- FAT-safe snapping --- +def _snap_even_second(dt: datetime) -> datetime: + """ + Force timestamp to an even second and zero microseconds. + """ + dt = dt.replace(microsecond=0) + if dt.second % 2 == 1: + dt = dt + timedelta(seconds=1) + return dt + +# --- Walk and set --- +def _set_folder_and_contents_times(root_folder: str, dt_utc: datetime, verbose=False): + # Recurse and set times, then set root last (so mtime doesn't bump) + for dirpath, dirnames, filenames in os.walk(root_folder): + for fname in filenames: + fpath = os.path.join(dirpath, fname) + try: + _set_times_windows(fpath, dt_utc) + if verbose: print(f"Set file : {fpath}") + except Exception as e: + print(f"[WARN] Could not set times for file {fpath}: {e}", file=sys.stderr) + for dname in dirnames: + dpath = os.path.join(dirpath, dname) + try: + _set_times_windows(dpath, dt_utc) + if verbose: print(f"Set dir : {dpath}") + except Exception as e: + print(f"[WARN] Could not set times for dir {dpath}: {e}", file=sys.stderr) + try: + _set_times_windows(root_folder, dt_utc) + if verbose: print(f"Set ROOT : {root_folder}") + except Exception as e: + print(f"[WARN] Could not set times for ROOT {root_folder}: {e}", file=sys.stderr) + +# --- Dry-run writer --- +def _write_dryrun_tsv(plan, base_path: str, verbose=False) -> str: + """ + plan: list of tuples (name, ts_utc, eff, cat_lbl, cat_idx, slot_idx, offset_sec) + Writes TSV in CWD (not base_path), sorted newest→oldest by ts_utc. + """ + cwd = os.getcwd() + out_path = os.path.join(cwd, "SAS-TIMESTAMPS-dryrun.tsv") + plan_sorted = sorted(plan, key=lambda x: x[1], reverse=True) + with open(out_path, "w", encoding="utf-8", newline="") as f: + f.write("Order\tCategory\tCatIndex\tSlot\tOffsetSec\tName\tEffectiveName\tLocalTime\tUTC\tFullPath\n") + for idx, (name, ts_utc, eff, cat_lbl, cat_idx, slot_idx, offset_sec) in enumerate(plan_sorted, start=1): + local_str = ts_utc.astimezone().strftime("%m/%d/%Y %H:%M:%S %Z") + utc_str = ts_utc.strftime("%Y-%m-%d %H:%M:%S UTC") + full = os.path.join(base_path, name) + f.write(f"{idx}\t{cat_lbl}\t{cat_idx}\t{slot_idx}\t{offset_sec}\t{name}\t{eff}\t{local_str}\t{utc_str}\t{full}\n") + if verbose: + print(f"[DRY-RUN] Wrote plan to: {out_path}") + print(f"[DRY-RUN] {len(plan_sorted)} root folders listed (newest → oldest).") + return out_path + +# --- Main --- +def main(): + ap = argparse.ArgumentParser( + description="Deterministically set ctime/mtime recursively by folder name and category." + ) + ap.add_argument("path", nargs="?", default=".", + help="Top-level directory containing the root folders to timestamp (default: current dir).") + ap.add_argument("--dry-run", action="store_true", + help="Do NOT modify timestamps; output SAS-TIMESTAMPS-dryrun.tsv in the current working directory.") + ap.add_argument("--verbose", action="store_true", help="Extra logging.") + ap.add_argument("--fat-safe", action="store_true", + help="Snap all times to even seconds (0 µs) to match FAT/VFAT mtime precision.") + ap.add_argument("--ps2-bias-seconds", type=int, default=0, + help="Signed seconds to bias planned timestamps so PS2 display matches Windows. " + "Example: -3563 to counter a +59m23s skew on PS2.") + + args = ap.parse_args() + + base_path = os.path.abspath(args.path) + if not os.path.isdir(base_path): + print(f"Not a directory: {base_path}", file=sys.stderr) + sys.exit(1) + + root_folders = [d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d))] + + if args.verbose: + print(f"Found {len(root_folders)} root folders under {base_path}") + + plan = [] + for name in root_folders: + try: + ts, eff, cat_lbl, cat_idx, slot_idx, offset_sec = _planned_timestamp_for_folder(name) + + # Apply free-form PS2 bias (to counter PS2 skew so displays match) + if args.ps2_bias_seconds: + ts = ts + timedelta(seconds=args.ps2_bias_seconds) + + # FAT-safe snapping to even seconds (prevents copy/rounding drift) + if args.fat_safe: + ts = _snap_even_second(ts) + + except Exception as e: + print(f"[WARN] Failed to compute timestamp for {name}: {e}", file=sys.stderr) + continue + plan.append((name, ts, eff, cat_lbl, cat_idx, slot_idx, offset_sec)) + + if args.dry_run: + tsv_path = _write_dryrun_tsv(plan, base_path, verbose=args.verbose) + print(f"Dry-run complete. Plan written to: {tsv_path}") + return + + for name, ts, eff, cat_lbl, cat_idx, slot_idx, offset_sec in plan: + full = os.path.join(base_path, name) + if args.verbose: + print(f"=== {name} [{cat_lbl}] cat={cat_idx} slot={slot_idx} offset={offset_sec}s -> " + f"{ts.astimezone().strftime('%m/%d/%Y %H:%M:%S %Z')} (UTC {ts.strftime('%Y-%m-%d %H:%M:%S')}) ===") + _set_folder_and_contents_times(full, ts, verbose=args.verbose) + +if __name__ == "__main__": + if os.name != "nt": + print("This script is intended for Windows (uses SetFileTime).", file=sys.stderr) + sys.exit(1) + main() \ No newline at end of file