2 Commits

Author SHA1 Message Date
MechaCat02
d92c7ddaea feat(viewer): ISO / extracted-dir loading pipeline + native file dialog
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 7m14s
CI / WASM — Web (push) Failing after 6m35s
CI / Formatting (push) Failing after 35s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
WIP: add iso_loader.rs — bridges the async XisoReader / synchronous
GameAssets to Bevy's ECS via background threads + mpsc channels polled
per frame (no main-thread blocking); UI open-iso/open-dir/file-select
events → texture preview. Register IsoLoaderPlugin; wire asset_loader
and the egui UI. Deps: rfd (native file dialog, workspace + viewer),
futures, and bevy tonemapping_luts feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:18:45 +02:00
MechaCat02
ce9fe08bec refactor(formats): rework X360 texture descriptor + xiso reader
WIP: restructure texture.rs (X360TextureDesc accessors / decode path) and
adjust the xiso reader. Builds clean (sylpheed-formats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:18:37 +02:00
9 changed files with 1343 additions and 265 deletions

489
Cargo.lock generated
View File

@@ -276,6 +276,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",
"serde",
"serde_repr",
"url",
"zbus",
]
[[package]]
name = "assert_type_match"
version = "0.1.1"
@@ -297,6 +315,18 @@ dependencies = [
"futures-core",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener 5.4.1",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
@@ -334,6 +364,24 @@ dependencies = [
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.4",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -345,6 +393,64 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener 5.4.1",
"futures-lite",
"rustix 1.1.4",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.4",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -438,7 +544,7 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fd901b3be016088c4dda2f628bda96b7cb578b9bc8ae684bbf30bec0a9483e"
dependencies = [
"async-broadcast",
"async-broadcast 0.5.1",
"async-fs",
"async-lock",
"atomicow",
@@ -719,6 +825,8 @@ dependencies = [
"derive_more",
"futures-lite",
"image",
"ktx2",
"ruzstd",
"serde",
"wgpu",
]
@@ -952,6 +1060,7 @@ dependencies = [
"futures-lite",
"image",
"js-sys",
"ktx2",
"naga",
"naga_oil",
"nonmax",
@@ -1711,7 +1820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"nix 0.31.2",
"windows-sys 0.61.2",
]
@@ -1907,6 +2016,33 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "epaint"
version = "0.29.1"
@@ -2389,6 +2525,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hexasphere"
version = "15.1.0"
@@ -2688,6 +2830,15 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "ktx2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87d65e08a9ec02e409d27a0139eaa6b9756b4d81fe7cde71f6941a83730ce838"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2773,7 +2924,7 @@ version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
dependencies = [
"twox-hash",
"twox-hash 2.1.2",
]
[[package]]
@@ -2820,6 +2971,15 @@ dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "metal"
version = "0.29.0"
@@ -2953,6 +3113,19 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
[[package]]
name = "nix"
version = "0.31.2"
@@ -3043,6 +3216,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"
@@ -3313,6 +3497,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 = "offset-allocator"
version = "0.2.0"
@@ -3345,6 +3538,16 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -3487,6 +3690,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
[[package]]
name = "pori"
version = "0.0.0"
@@ -3754,6 +3963,29 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rfd"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251"
dependencies = [
"ashpd",
"block",
"dispatch",
"js-sys",
"log",
"objc",
"objc-foundation",
"objc_id",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.48.0",
]
[[package]]
name = "ron"
version = "0.8.1"
@@ -3813,6 +4045,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ruzstd"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fad02996bfc73da3e301efe90b1837be9ed8f4a462b6ed410aa35d00381de89f"
dependencies = [
"twox-hash 1.6.3",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3911,6 +4152,28 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha3"
version = "0.10.8"
@@ -4095,6 +4358,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"binrw",
"flate2",
"futures",
"serde",
"serde_json",
@@ -4110,6 +4374,8 @@ version = "0.1.0"
dependencies = [
"bevy",
"bevy_egui",
"futures",
"rfd",
"sylpheed-formats",
"thiserror 2.0.18",
"tracing",
@@ -4149,6 +4415,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -4448,6 +4727,16 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "twox-hash"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675"
dependencies = [
"cfg-if",
"static_assertions",
]
[[package]]
name = "twox-hash"
version = "2.1.2"
@@ -4472,6 +4761,17 @@ version = "1.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -4512,8 +4812,15 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4982,6 +5289,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[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"
@@ -5018,6 +5334,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5051,6 +5382,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5063,6 +5400,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5075,6 +5418,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5099,6 +5448,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5111,6 +5466,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5123,6 +5484,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5135,6 +5502,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5267,6 +5640,16 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "xdvdfs"
version = "0.8.3"
@@ -5335,6 +5718,68 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast 0.7.2",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener 5.4.1",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix 0.29.0",
"ordered-stream",
"rand",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.47"
@@ -5429,3 +5874,41 @@ checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"url",
"zvariant_derive",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]

View File

@@ -39,6 +39,9 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# ── Native file dialog ────────────────────────────────────────────────────────
rfd = "0.14"
# ── CLI ───────────────────────────────────────────────────────────────────────
clap = { version = "4", features = ["derive"] }

View File

@@ -1,16 +1,43 @@
//! Xbox 360 texture format parsing and de-tiling.
//!
//! ## The Problem: GPU Tiling
//! The Xbox 360 Xenos GPU stores textures in a "tiled" memory layout for
//! cache efficiency. The tiles are 32×32 texel macro-tiles, and within
//! each macro-tile the DXT compression blocks are arranged in Morton
//! (Z-order curve) order. Before we can upload a texture to a modern GPU,
//! we must "de-tile" it back to a standard linear (row-major) layout.
//! ## XPR2 Container Layout
//!
//! ## Reference implementations
//! - Xenia emulator: `src/xenia/gpu/texture_util.cc`
//! - RareView (C#): Xbox 360 texture de-tiling
//! - swizzleinator crate: general console texture unswizzling
//! ```text
//! Offset Size Field
//! 0x00 4 Magic: "XPR2"
//! 0x04 4 header_size — pixel data section starts at this file offset (e.g. 0x2800)
//! 0x08 4 data_size — size of the pixel data section (e.g. 0x8A000)
//! 0x0C 4 num_resources
//! 0x10 16*n Resource directory: n × 16-byte Xpr2ResourceEntry structs
//! [type_tag:4][data_offset:4][data_size:4][name_offset:4]
//! … … Resource descriptors (TX2D = 52-byte D3DBaseTexture2D structs)
//! 0x2800 … Pixel data section
//! ```
//!
//! ## GPUTEXTURE_FETCH_CONSTANT (GPUFC)
//!
//! Each TX2D descriptor is 52 bytes. The 6-dword (24-byte) GPUFC starts at
//! descriptor offset +0x18:
//!
//! ```text
//! GPUFC[0] (+0x18): tiled flag at bit 31, pitch at bits[23:8]
//! GPUFC[1] (+0x1C): TextureFormat at bits[5:0], base_address at bits[31:12]
//! GPUFC[2] (+0x20): width-1 at bits[12:0], height-1 at bits[25:13] (size_2d)
//! GPUFC[3] (+0x24): swizzle, filter
//! GPUFC[4] (+0x28): mip_max at bits[9:6] → mip_count = mip_max + 1
//! GPUFC[5] (+0x2C): mip_address, packed_mips, dimension
//! ```
//!
//! ## GPU Tiling
//!
//! Xbox 360 Xenos stores textures in 32×32 texel macro-tiles. Within each
//! macro-tile the DXT blocks are arranged in Morton (Z-order) order.
//! GPUFC[0] bit 31 = 1 → tiled (de-tiling required); = 0 → linear.
//!
//! ## References
//!
//! - Xenia: `src/xenia/gpu/xenos.h` (GPUTEXTUREFORMAT enum, GPUFC bitfields)
//! - Xenia: `src/xenia/gpu/texture_util.cc` (de-tiling algorithm)
use binrw::{BinRead, binread};
use thiserror::Error;
@@ -22,6 +49,9 @@ pub enum TextureError {
#[error("Invalid texture header magic: expected {expected:?}, got {got:?}")]
BadMagic { expected: [u8; 4], got: [u8; 4] },
#[error("No TX2D texture resource found in XPR2 file")]
NoTextureFound,
#[error("Unsupported texture format: 0x{0:02X}")]
UnsupportedFormat(u8),
@@ -37,56 +67,58 @@ pub enum TextureError {
// ── Texture formats ───────────────────────────────────────────────────────────
/// D3DFORMAT values used by the Xbox 360 SDK for texture data.
/// These appear in XPR2 headers and in-memory texture descriptors.
/// GPUTEXTUREFORMAT values from Xenia's `xenos.h`.
///
/// Format codes sourced from:
/// - Xbox 360 SDK documentation (leaked)
/// - Xenia emulator source (gpu/xenos.h)
/// - ZenHAX community research
/// These 6-bit codes live in GPUFC dword_1 bits[5:0] — NOT the old D3DFORMAT
/// codes. The mapping is:
/// k_8_8_8_8 = 6, k_DXT1 = 18, k_DXT2_3 = 19, k_DXT4_5 = 20,
/// k_DXN = 49, k_DXT5A = 59
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum X360TextureFormat {
/// DXT1 / BC1 — 4 bpp, 1-bit alpha
Dxt1 = 0x52,
/// DXT3 / BC2 — 8 bpp, 4-bit explicit alpha
Dxt3 = 0x53,
/// DXT5 / BC38 bpp, 8-bit interpolated alpha
Dxt5 = 0x54,
/// DXN / BC5 / ATI2 — normal maps, two-channel
Dxn = 0x71,
/// Uncompressed A8R8G8B832 bpp
A8R8G8B8 = 0x06,
/// Uncompressed X8R8G8B8 — 32 bpp, no alpha
X8R8G8B8 = 0x07,
/// A8R8G8B8 — uncompressed 32 bpp (k_8_8_8_8 = 6)
A8R8G8B8 = 6,
/// X8R8G8B8 — uncompressed 32 bpp, no alpha (k_8_8_8_8_AS_16_16_16_16 = 7)
X8R8G8B8 = 7,
/// DXT1 / BC14 bpp, 1-bit alpha (k_DXT1 = 18)
Dxt1 = 18,
/// DXT2/3 / BC2 — 8 bpp, 4-bit explicit alpha (k_DXT2_3 = 19)
Dxt3 = 19,
/// DXT4/5 / BC38 bpp, 8-bit interpolated alpha (k_DXT4_5 = 20)
Dxt5 = 20,
/// DXN / BC5 / ATI2N — two-channel normal maps (k_DXN = 49)
Dxn = 49,
/// DXT5A / BC4 / ATI1N — single alpha channel (k_DXT5A = 59)
/// Used for gloss, specular, luminance, and reflection maps in Project Sylpheed.
Dxt5A = 59,
}
impl X360TextureFormat {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0x52 => Some(Self::Dxt1),
0x53 => Some(Self::Dxt3),
0x54 => Some(Self::Dxt5),
0x71 => Some(Self::Dxn),
0x06 => Some(Self::A8R8G8B8),
0x07 => Some(Self::X8R8G8B8),
_ => None,
6 => Some(Self::A8R8G8B8),
7 => Some(Self::X8R8G8B8),
18 => Some(Self::Dxt1),
19 => Some(Self::Dxt3),
20 => Some(Self::Dxt5),
49 => Some(Self::Dxn),
59 => Some(Self::Dxt5A),
_ => None,
}
}
/// Bytes per compressed block (4×4 texel group).
/// For uncompressed formats, bytes per pixel instead.
/// Bytes per compressed block (4×4 texel group) or per pixel for uncompressed.
pub fn bytes_per_block(&self) -> usize {
match self {
Self::Dxt1 => 8,
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
Self::Dxt1 | Self::Dxt5A => 8,
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
}
}
/// Is this a BCn block-compressed format?
pub fn is_block_compressed(&self) -> bool {
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn)
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn | Self::Dxt5A)
}
/// Texels per block side (4 for BCn, 1 for uncompressed).
@@ -97,176 +129,160 @@ impl X360TextureFormat {
// ── XPR2 container format ─────────────────────────────────────────────────────
/// XPR2 is Microsoft's Xbox Packed Resource v2 format.
/// It stores D3D resources (textures, vertex buffers, index buffers)
/// in a single blob that can be DMA'd directly into GPU memory.
///
/// The header is big-endian (Xbox 360 is big-endian PowerPC).
/// NOTE: Project Sylpheed may use a custom container. If files don't
/// start with b"XPR2", check for game-specific magic bytes instead.
/// XPR2 container header (big-endian, 16 bytes total including magic).
#[binread]
#[br(magic = b"XPR2", big)]
#[derive(Debug, Clone)]
pub struct Xpr2Header {
/// Total file size in bytes
pub total_size: u32,
/// Size of the header section (texture data starts after this)
/// Byte offset where the pixel data section starts (= size of header region).
/// Example value: 0x2800 = 10240.
pub header_size: u32,
/// Number of resource entries in this file
/// Size of the pixel data section in bytes.
/// Example value: 0x8A000 = 565248.
pub data_size: u32,
/// Number of 16-byte resource entries in the directory at offset 0x10.
pub num_resources: u32,
}
/// A single resource entry within an XPR2 file.
/// Each entry describes one D3D resource (texture, buffer, etc.)
/// One 16-byte entry in the XPR2 resource directory (starts at file offset 0x10).
#[binread]
#[br(big)]
#[derive(Debug, Clone)]
pub struct Xpr2ResourceEntry {
/// Encoded type and reference count.
/// Bits [0..15] = ref_count
/// Bits [16..18] = resource type (4 = texture)
/// Bits [19..31] = flags
pub resource_type_and_flags: u32,
/// Offset of this resource's data within the XPR2 file
/// ASCII type tag: b"TX2D" for 2D textures, b"XBG7" for geometry, etc.
pub type_tag: [u8; 4],
/// Byte offset of this resource's descriptor, relative to directory base 0x10.
/// Actual file offset = data_offset + 0x10.
pub data_offset: u32,
/// Reserved / unknown
pub _unknown: u32,
/// Size of the resource descriptor in bytes (e.g. 0x34 = 52 for TX2D).
pub descriptor_size: u32,
/// Byte offset of this resource's name string, relative to directory base 0x10.
pub name_offset: u32,
}
impl Xpr2ResourceEntry {
pub fn resource_type(&self) -> u8 {
((self.resource_type_and_flags >> 16) & 0x7) as u8
}
pub fn is_texture(&self) -> bool {
self.resource_type() == 4
}
}
/// Xbox 360 D3D texture descriptor embedded in an XPR2 resource.
/// This is what the GPU register `NV097_SET_TEXTURE_FORMAT` receives.
///
/// Reference: xboxdevwiki.net/XPR
#[binread]
#[br(big)]
#[derive(Debug, Clone)]
pub struct X360TextureDesc {
/// Packed GPU texture format register
/// Bits [0..3] = DMA channel
/// Bits [4..7] = dimensionality (2 = 2D)
/// Bits [8..15] = D3DFORMAT (see X360TextureFormat)
/// Bits [16..19] = mip levels
/// Bits [20..23] = width as power-of-two: actual = 1 << value
/// Bits [24..27] = height as power-of-two: actual = 1 << value
/// Bits [28..31] = depth (for 3D textures)
pub gpu_format: u32,
/// For non-power-of-two textures, encodes actual dimensions
pub npot_size: u32,
}
impl X360TextureDesc {
pub fn format_code(&self) -> u8 {
((self.gpu_format >> 8) & 0xFF) as u8
}
pub fn format(&self) -> Option<X360TextureFormat> {
X360TextureFormat::from_u8(self.format_code())
}
pub fn mip_levels(&self) -> u32 {
(self.gpu_format >> 16) & 0xF
}
/// Width from the packed power-of-two field.
pub fn width_pow2(&self) -> u32 {
1 << ((self.gpu_format >> 20) & 0xF)
}
/// Height from the packed power-of-two field.
pub fn height_pow2(&self) -> u32 {
1 << ((self.gpu_format >> 24) & 0xF)
&self.type_tag == b"TX2D"
}
}
// ── Decoded texture ───────────────────────────────────────────────────────────
/// A decoded Xbox 360 texture, ready to upload to a modern GPU.
/// A decoded Xbox 360 texture ready for GPU upload.
///
/// After `from_xpr2()` or `from_raw_tiled()`, the `data` field contains
/// the texture in standard linear layout that Bevy / wgpu can consume.
/// After `from_xpr2()` the `data` field holds the texture in standard linear
/// (row-major) layout. BCn formats are kept as compressed block data; the GPU
/// decompresses in hardware.
#[derive(Debug, Clone)]
pub struct X360Texture {
pub width: u32,
pub height: u32,
pub format: X360TextureFormat,
pub mip_levels: u32,
/// De-tiled texture data in linear (row-major) order.
/// For BCn formats: standard DDS-style packed block data.
/// For ARGB: standard RGBA8 pixel data.
/// De-tiled texture data in linear order.
/// BCn: standard packed block data (DDS layout).
/// Uncompressed: BGRA8 pixel data.
pub data: Vec<u8>,
}
impl X360Texture {
/// Parse a texture from a raw XPR2 file's bytes.
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
///
/// This handles the full pipeline:
/// 1. Parse XPR2 header + resource descriptors
/// 2. Locate the texture resource
/// 3. De-tile the GPU memory layout → linear layout
/// Pipeline:
/// 1. Parse XPR2 header + resource directory
/// 2. Locate the first TX2D entry
/// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
/// 4. De-tile the pixel data (if tiled) → linear layout
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
// Parse main header (validates "XPR2" magic)
// Parse header validates "XPR2" magic, reads 3 × u32 (total 16 bytes)
let header = Xpr2Header::read(&mut cur)?;
// Parse resource entries
// Resource directory begins immediately after the 16-byte header (offset 0x10)
let mut entries = Vec::new();
for _ in 0..header.num_resources {
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
}
// Find the first texture resource
// Find the first TX2D texture resource
let tex_entry = entries.iter()
.find(|e| e.is_texture())
.ok_or_else(|| TextureError::UnsupportedFormat(0))?;
.ok_or(TextureError::NoTextureFound)?;
// The texture descriptor immediately follows the resource entries
let desc = X360TextureDesc::read(&mut cur)?;
// The descriptor is at file offset = data_offset + 0x10 (directory base)
const DIR_BASE: usize = 0x10;
let desc_file_offset = tex_entry.data_offset as usize + DIR_BASE;
let format = desc.format()
.ok_or(TextureError::UnsupportedFormat(desc.format_code()))?;
// GPUFC is a 6-dword (24-byte) block at descriptor offset +0x18
let gpufc_base = desc_file_offset + 0x18;
if bytes.len() < gpufc_base + 6 * 4 {
return Err(TextureError::BufferTooSmall {
needed: gpufc_base + 24,
have: bytes.len(),
});
}
let width = desc.width_pow2();
let height = desc.height_pow2();
// Read one big-endian u32 at the given file offset
let be_u32 = |offset: usize| -> u32 {
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap())
};
// Texture data is at header_size offset
let data_start = header.header_size as usize;
let gpufc0 = be_u32(gpufc_base); // +0x18
let gpufc1 = be_u32(gpufc_base + 0x04); // +0x1C
let gpufc2 = be_u32(gpufc_base + 0x08); // +0x20
let gpufc4 = be_u32(gpufc_base + 0x10); // +0x28
// GPUFC[1] bits[5:0] = GPUTEXTUREFORMAT
let fmt_code = (gpufc1 & 0x3F) as u8;
let format = X360TextureFormat::from_u8(fmt_code)
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
let base_address = (gpufc1 & 0xFFFFF000) as usize;
// GPUFC[2] / size_2d: width-1 in bits[12:0], height-1 in bits[25:13]
let width = (gpufc2 & 0x1FFF) + 1;
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
// GPUFC[4]: mip_max in bits[9:6]; mip_count = mip_max + 1
let mip_count = ((gpufc4 >> 6) & 0xF) + 1;
// GPUFC[0] bit 31 = 1 → tiled memory layout (requires de-tiling)
let is_tiled = (gpufc0 >> 31) != 0;
// Pixel data for this texture starts at: header_size + base_address
let data_start = header.header_size as usize + base_address;
if bytes.len() <= data_start {
return Err(TextureError::BufferTooSmall {
needed: data_start + 1,
have: bytes.len(),
});
}
let tiled_data = &bytes[data_start..];
let raw_data = &bytes[data_start..];
// De-tile!
let linear_data = detile(tiled_data, width, height, format)?;
let linear_data = if is_tiled {
detile(raw_data, width, height, format)?
} else {
// Linear layout — copy only the mip-0 slice
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
}
raw_data[..needed].to_vec()
};
Ok(X360Texture {
width,
height,
format,
mip_levels: desc.mip_levels().max(1),
data: linear_data,
})
Ok(X360Texture { width, height, format, mip_levels: mip_count, data: linear_data })
}
/// Parse a texture from already-known parameters + raw tiled data.
///
/// Use this when you've reverse-engineered a game-specific container
/// and extracted the raw tiled texture bytes yourself.
/// Use when you have reverse-engineered a container and extracted the
/// raw tiled bytes yourself.
pub fn from_raw_tiled(
tiled_data: &[u8],
width: u32,
@@ -274,30 +290,19 @@ impl X360Texture {
format: X360TextureFormat,
) -> Result<Self, TextureError> {
let linear_data = detile(tiled_data, width, height, format)?;
Ok(X360Texture {
width,
height,
format,
mip_levels: 1,
data: linear_data,
})
Ok(X360Texture { width, height, format, mip_levels: 1, data: linear_data })
}
}
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
/// De-tile an Xbox 360 GPU texture from tiled to linear layout.
/// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout.
///
/// Xbox 360 stores textures in a hierarchical tiled format:
/// - The texture is divided into 32×32 texel **macro-tiles**
/// - Within each macro-tile, DXT blocks are in **Morton (Z-order)** order
/// Xbox 360 stores textures in 32×32 texel macro-tiles. Within each
/// macro-tile the DXT blocks (or raw pixels) are in Morton (Z-order) order.
/// This function reverses that ordering for the mip-0 level.
///
/// This is required for ALL textures regardless of whether they are
/// DXT-compressed or uncompressed — the GPU expects tiled memory.
///
/// Algorithm based on:
/// - Xenia: `texture_util.cc` `TileTexture()`
/// - GTA IV Xbox 360 Texture Editor by Pimpin Tyler and Anthony
/// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`.
pub fn detile(
src: &[u8],
width: u32,
@@ -305,25 +310,21 @@ pub fn detile(
format: X360TextureFormat,
) -> Result<Vec<u8>, TextureError> {
let block_size = format.block_size() as u32;
let bpb = format.bytes_per_block(); // bytes per block
let bpb = format.bytes_per_block();
// Dimensions in blocks
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
// Texture dimensions in blocks
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
if src.len() < expected {
return Err(TextureError::BufferTooSmall {
needed: expected,
have: src.len(),
});
return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() });
}
let mut dst = vec![0u8; expected];
// Macro-tile dimensions (in blocks)
// Xbox 360 always uses 32×32 texel macro-tiles
let macro_tile_blocks = 32 / block_size; // = 8 for BCn (4×4 texels/block)
// Xbox 360 macro-tiles are always 32×32 texels → 8×8 blocks for BCn (4-texel blocks)
let macro_tile_blocks = 32 / block_size;
let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks;
let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks;
@@ -342,7 +343,7 @@ pub fn detile(
let block_x = macro_x * macro_tile_blocks + lx;
let block_y = macro_y * macro_tile_blocks + ly;
// Skip blocks that fall outside the actual texture dimensions
// Skip blocks outside the actual texture
if block_x >= blocks_wide || block_y >= blocks_tall {
continue;
}
@@ -363,10 +364,7 @@ pub fn detile(
/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
///
/// Morton encoding interleaves the bits of x and y coordinates:
/// index = ...y3 x3 y2 x2 y1 x1 y0 x0
///
/// This is the inverse operation — extract interleaved bits back to x, y.
/// Morton encoding interleaves bits: index = …y2 x2 y1 x1 y0 x0
#[inline]
pub fn morton_decode(index: u32) -> (u32, u32) {
let x = compact_bits(index);
@@ -374,15 +372,15 @@ pub fn morton_decode(index: u32) -> (u32, u32) {
(x, y)
}
/// Compact every other bit — the "de-interleave" operation.
/// Used by `morton_decode` to separate X and Y from a Morton index.
/// Extract every other bit and pack them into the low bits.
/// Used by `morton_decode` to de-interleave X and Y.
#[inline]
fn compact_bits(mut x: u32) -> u32 {
x &= 0x5555_5555; // x = -f-e -d-c -b-a -9-8 -7-6 -5-4 -3-2 -1-0
x = (x ^ (x >> 1)) & 0x3333_3333; // x = --fe --dc --ba --98 --76 --54 --32 --10
x = (x ^ (x >> 2)) & 0x0f0f_0f0f; // x = ----fedc ----ba98 ----7654 ----3210
x = (x ^ (x >> 4)) & 0x00ff_00ff; // x = --------fedcba98 --------76543210
x = (x ^ (x >> 8)) & 0x0000_ffff; // x = ----------------fedcba9876543210
x &= 0x5555_5555; // keep even-position bits
x = (x ^ (x >> 1)) & 0x3333_3333;
x = (x ^ (x >> 2)) & 0x0f0f_0f0f;
x = (x ^ (x >> 4)) & 0x00ff_00ff;
x = (x ^ (x >> 8)) & 0x0000_ffff;
x
}
@@ -394,20 +392,15 @@ mod tests {
#[test]
fn morton_decode_corners() {
// Index 0 → (0, 0)
assert_eq!(morton_decode(0), (0, 0));
// Index 1 → (1, 0) — bit 0 is x
assert_eq!(morton_decode(1), (1, 0));
// Index 2 → (0, 1) — bit 1 is y
assert_eq!(morton_decode(2), (0, 1));
// Index 3 → (1, 1)
assert_eq!(morton_decode(1), (1, 0)); // bit 0 x
assert_eq!(morton_decode(2), (0, 1)); // bit 1 → y
assert_eq!(morton_decode(3), (1, 1));
}
#[test]
fn detile_noop_for_1x1_block() {
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes
// De-tiling a single block should be identity
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes; de-tiling is identity
let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
assert_eq!(result, src);
@@ -415,8 +408,17 @@ mod tests {
#[test]
fn x360_format_bytes_per_block() {
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
assert_eq!(X360TextureFormat::Dxt5A.bytes_per_block(), 8);
assert_eq!(X360TextureFormat::Dxt5.bytes_per_block(), 16);
assert_eq!(X360TextureFormat::A8R8G8B8.bytes_per_block(), 4);
}
#[test]
fn format_from_u8_roundtrip() {
for code in [6u8, 7, 18, 19, 20, 49, 59] {
assert!(X360TextureFormat::from_u8(code).is_some(), "missing format {code}");
}
assert!(X360TextureFormat::from_u8(0x52).is_none(), "old D3DFORMAT 0x52 must not match");
}
}

View File

@@ -14,25 +14,48 @@ use std::path::Path;
use anyhow::{Context, Result};
use tracing::{debug, info};
use xdvdfs::blockdev::OffsetWrapper;
use xdvdfs::layout::VolumeDescriptor;
/// A handle to an open XISO image.
pub struct XisoReader<F: Read + Seek> {
///
/// The inner file is wrapped in an [`OffsetWrapper`] which probes the four
/// known XGD partition offsets (raw XISO, XGD1, XGD2, XGD3) at open time,
/// so both single-layer raw dumps **and** full dual-layer disc images are
/// supported transparently.
pub struct XisoReader<F: Read + Seek + Send + Sync> {
volume: VolumeDescriptor,
file: F,
/// Offset-aware block device — all sector reads are shifted by the
/// detected partition start offset automatically.
file: OffsetWrapper<F, std::io::Error>,
}
impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
pub async fn open(mut file: F) -> Result<Self> {
let volume = xdvdfs::read::read_volume(&mut file)
pub async fn open(file: F) -> Result<Self> {
// OffsetWrapper::new probes four known partition offsets:
// 0x00000000 — raw XISO (trimmed, sector 0 = XDVDFS start)
// 0x183E0000 — XGD1 (original Xbox)
// 0x0FD90000 — XGD2 (Xbox 360, most retail titles)
// 0x02080000 — XGD3 (Xbox 360, later dual-layer titles)
let mut wrapper = OffsetWrapper::new(file).await.map_err(|e| {
anyhow::anyhow!(
"No valid XDVDFS partition found in this disc image. \
Tried raw XISO, XGD1, XGD2, and XGD3 offsets. \
Is this a valid Xbox 360 (or original Xbox) disc image? \
(internal error: {e:?})"
)
})?;
// Re-read the volume descriptor via the wrapper (now at the correct offset).
let volume = xdvdfs::read::read_volume(&mut wrapper)
.await
.context("Failed to read XDVDFS volume descriptor. Is this a valid Xbox 360 ISO?")?;
.context("Found XDVDFS partition but failed to parse volume descriptor")?;
info!(
"Opened XISO: root directory table at sector {}",
{ let s = volume.root_table.region.sector; s }
);
Ok(Self { volume, file })
Ok(Self { volume, file: wrapper })
}
/// List all files in the disc image, recursively (directories excluded).
@@ -135,10 +158,13 @@ impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
}
}
/// Open an XISO from a file path (the common case).
/// Open a disc image from a file path.
///
/// Accepts raw XISO dumps and full XGD1/XGD2/XGD3 disc images — the correct
/// partition offset is detected automatically.
pub async fn open_iso(path: &Path) -> Result<XisoReader<std::fs::File>> {
let file = std::fs::File::open(path)
.with_context(|| format!("Cannot open ISO: {}", path.display()))?;
.with_context(|| format!("Cannot open disc image: {}", path.display()))?;
XisoReader::open(file).await
}

View File

@@ -31,11 +31,14 @@ bevy = { workspace = true, features = [
"bevy_winit",
"multi_threaded",
"png",
# Required for TonyMcMapFace tonemapping (default Camera3d tonemapper)
"tonemapping_luts",
# Linux display backends
"x11",
"wayland",
] }
bevy_egui = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
@@ -46,3 +49,4 @@ dev = ["bevy/dynamic_linking"]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tracing-subscriber = { workspace = true }
rfd = { workspace = true }

View File

@@ -12,10 +12,11 @@
//! 4. Bevy uploads it to the GPU automatically
use bevy::asset::io::Reader;
use bevy::asset::{AssetLoader, AsyncReadExt, LoadContext};
use bevy::asset::{AssetLoader, LoadContext};
use bevy::image::ImageSampler;
use bevy::prelude::*;
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use bevy::render::render_resource::{Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages};
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
// ── Plugin ────────────────────────────────────────────────────────────────────
@@ -86,24 +87,32 @@ impl AssetLoader for Xpr2TextureLoader {
/// Convert a decoded `X360Texture` into a Bevy-compatible `Image`.
///
/// Maps Xbox 360 D3DFORMAT codes to `wgpu::TextureFormat` values.
/// Bevy uses wgpu internally, so this mapping is direct.
/// Constructs the `Image` struct directly rather than using `Image::new()`,
/// because `Image::new()` calls `pixel_size()` which panics for BCn
/// block-compressed formats.
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
let bevy_format = x360_format_to_wgpu(&tex.format)?;
let format = x360_format_to_wgpu(&tex.format)?;
Ok(Image::new(
Extent3d {
width: tex.width,
height: tex.height,
depth_or_array_layers: 1,
Ok(Image {
data: tex.data,
texture_descriptor: TextureDescriptor {
label: None,
size: Extent3d {
width: tex.width,
height: tex.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
},
TextureDimension::D2,
tex.data,
bevy_format,
// Make the texture available on both CPU and GPU
// (RenderOnly would be more efficient once RE is complete)
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
))
sampler: ImageSampler::Default,
texture_view_descriptor: None,
asset_usage: RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
})
}
/// Map an `X360TextureFormat` to the corresponding `wgpu::TextureFormat`.
@@ -119,14 +128,12 @@ fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2
X360TextureFormat::Dxt3 => TextureFormat::Bc2RgbaUnormSrgb,
// DXT5 / BC3
X360TextureFormat::Dxt5 => TextureFormat::Bc3RgbaUnormSrgb,
// DXN / BC5 — normal maps (RG, not sRGB)
// DXN / BC5 — two-channel normal maps (RG, not sRGB)
X360TextureFormat::Dxn => TextureFormat::Bc5RgUnorm,
// Uncompressed ARGB — note Xbox 360 is BGRA order (big-endian)
// We may need to swizzle R and B channels
// DXT5A / BC4 — single-channel (gloss, specular, luminance maps)
X360TextureFormat::Dxt5A => TextureFormat::Bc4RUnorm,
// Uncompressed ARGB — Xbox 360 stores as BGRA in memory
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
// Xbox 360 stores as ARGB big-endian (BGRA in memory)
// wgpu uses Rgba8UnormSrgb — swizzle may be needed
// TODO: verify channel order against actual game textures
TextureFormat::Bgra8UnormSrgb
}
})

View File

@@ -0,0 +1,458 @@
//! ISO / extracted-directory loading pipeline for the viewer.
//!
//! Bridges the async `XisoReader` API (and synchronous `GameAssets`) with
//! Bevy's ECS via background threads and `std::sync::mpsc` channels polled
//! each frame — no blocking on the main thread.
//!
//! ## Data flow (native)
//!
//! ```text
//! UI click → Event → handle_open_iso / handle_open_dir / handle_file_selected
//! └─ std::thread ─→ mpsc::Sender<IsoLoaderMsg>
//! ↓ (next frame)
//! poll_loader_channel → FileBrowserState / PendingFileBytes
//! ↓
//! apply_loaded_texture → TexturePreview (Image + egui TextureId)
//! ↓
//! draw_viewer_ui → renders preview panel
//! ```
#[cfg(not(target_arch = "wasm32"))]
use std::sync::{mpsc, Mutex};
use std::path::PathBuf;
use bevy::prelude::*;
use bevy_egui::egui;
use sylpheed_formats::vfs::FileFormat;
use crate::ui::FileBrowserState;
// ── Events ────────────────────────────────────────────────────────────────────
/// Fired by the "Open ISO disc image…" menu item.
#[derive(Event, Default)]
pub struct RequestOpenIso;
/// Fired by the "Open extracted folder…" menu item.
#[derive(Event, Default)]
pub struct RequestOpenDir;
/// Fired when the user clicks a file in the browser panel.
#[derive(Event)]
pub struct FileSelected(pub String);
// ── Resources (platform-agnostic) ────────────────────────────────────────────
/// Which kind of source is currently open.
#[derive(Default, Clone)]
pub enum SourceKind {
#[default]
None,
#[cfg(not(target_arch = "wasm32"))]
Iso(PathBuf),
#[cfg(not(target_arch = "wasm32"))]
Directory(PathBuf),
}
/// State of the currently-open file source (ISO or extracted folder).
#[derive(Resource, Default)]
pub struct IsoState {
/// Short description shown in the status bar (filename or directory path).
pub source_label: Option<String>,
/// True while a background thread is loading.
pub loading: bool,
/// The most recent error message (cleared when a new operation starts).
pub error: Option<String>,
/// Which backend to use when reading individual files.
pub source_kind: SourceKind,
}
/// The texture currently shown in the central panel.
#[derive(Resource, Default)]
pub struct TexturePreview {
/// Bevy handle — kept alive so the GPU texture stays allocated.
pub handle: Option<Handle<Image>>,
/// egui texture ID registered via `EguiContexts::add_image`.
pub egui_id: Option<egui::TextureId>,
pub width: u32,
pub height: u32,
/// Human-readable format / dimension summary (or error message).
pub format_info: String,
}
/// Info shown for any selected file (texture or not).
#[derive(Resource, Default)]
pub struct FileInfo {
pub name: String,
pub size_bytes: usize,
pub detected_format: Option<FileFormat>,
}
// ── SystemSet label ───────────────────────────────────────────────────────────
/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
/// final state before rendering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct IsoLoaderSystemSet;
// ── Native-only types ─────────────────────────────────────────────────────────
/// Messages sent from background threads back to the Bevy main thread.
#[cfg(not(target_arch = "wasm32"))]
enum IsoLoaderMsg {
FilesListed {
source: SourceKind,
label: String,
files: Vec<String>,
},
FileLoaded {
path: String,
bytes: Vec<u8>,
},
/// User dismissed the file dialog — not an error.
Cancelled,
Error(String),
}
/// Channel resource. `Receiver<T>` is `!Sync`, so we wrap it in `Mutex`
/// to satisfy Bevy's `Resource: Send + Sync` bound.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource)]
struct IsoChannels {
sender: mpsc::Sender<IsoLoaderMsg>,
receiver: Mutex<mpsc::Receiver<IsoLoaderMsg>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for IsoChannels {
fn default() -> Self {
let (sender, receiver) = mpsc::channel();
Self {
sender,
receiver: Mutex::new(receiver),
}
}
}
/// One-frame staging buffer: `poll_loader_channel` deposits raw bytes here;
/// `apply_loaded_texture` (chained after) consumes them.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingFileBytes {
ready: bool,
path: String,
bytes: Vec<u8>,
}
// ── Plugin ────────────────────────────────────────────────────────────────────
pub struct IsoLoaderPlugin;
impl Plugin for IsoLoaderPlugin {
fn build(&self, app: &mut App) {
app.add_event::<RequestOpenIso>()
.add_event::<RequestOpenDir>()
.add_event::<FileSelected>()
.init_resource::<IsoState>()
.init_resource::<TexturePreview>()
.init_resource::<FileInfo>();
#[cfg(not(target_arch = "wasm32"))]
{
app.init_resource::<IsoChannels>()
.init_resource::<PendingFileBytes>()
.add_systems(
Update,
(
handle_open_iso,
handle_open_dir,
handle_file_selected,
poll_loader_channel,
apply_loaded_texture,
)
.chain()
.in_set(IsoLoaderSystemSet),
);
}
}
}
// ── Systems (native-only) ─────────────────────────────────────────────────────
/// Listens for `RequestOpenIso`, opens a native file-picker in a background
/// thread, then lists all files in the selected ISO.
#[cfg(not(target_arch = "wasm32"))]
fn handle_open_iso(
mut events: EventReader<RequestOpenIso>,
mut iso_state: ResMut<IsoState>,
channels: Res<IsoChannels>,
) {
let count = events.read().count();
if count == 0 || iso_state.loading {
return;
}
iso_state.loading = true;
iso_state.error = None;
let sender = channels.sender.clone();
std::thread::spawn(move || {
let path = rfd::FileDialog::new()
.set_title("Open Xbox 360 Disc Image")
.add_filter("Xbox 360 ISO", &["iso", "xiso"])
.pick_file();
let Some(path) = path else {
let _ = sender.send(IsoLoaderMsg::Cancelled);
return;
};
let label = path.display().to_string();
let iso_path = path.clone();
let result = futures::executor::block_on(async move {
let mut reader = sylpheed_formats::xiso::open_iso(&path)
.await
.map_err(|e| e.to_string())?;
let files = reader
.list_all_files()
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((iso_path, label, files))
});
match result {
Ok((iso_path, label, files)) => {
let _ = sender.send(IsoLoaderMsg::FilesListed {
source: SourceKind::Iso(iso_path),
label,
files,
});
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e));
}
}
});
}
/// Listens for `RequestOpenDir`, opens a folder picker, lists files via
/// `GameAssets` (synchronous).
#[cfg(not(target_arch = "wasm32"))]
fn handle_open_dir(
mut events: EventReader<RequestOpenDir>,
mut iso_state: ResMut<IsoState>,
channels: Res<IsoChannels>,
) {
let count = events.read().count();
if count == 0 || iso_state.loading {
return;
}
iso_state.loading = true;
iso_state.error = None;
let sender = channels.sender.clone();
std::thread::spawn(move || {
let path = rfd::FileDialog::new()
.set_title("Open Extracted Game Directory")
.pick_folder();
let Some(path) = path else {
let _ = sender.send(IsoLoaderMsg::Cancelled);
return;
};
let label = path.display().to_string();
let assets = sylpheed_formats::vfs::GameAssets::from_directory(&path);
match assets.list("") {
Ok(files) => {
let _ = sender.send(IsoLoaderMsg::FilesListed {
source: SourceKind::Directory(path),
label,
files,
});
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
}
}
});
}
/// Listens for `FileSelected`, reads the file from the active source,
/// sends bytes back via the channel.
#[cfg(not(target_arch = "wasm32"))]
fn handle_file_selected(
mut events: EventReader<FileSelected>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
for FileSelected(file_path) in events.read() {
let file_path = file_path.clone();
let sender = channels.sender.clone();
match &iso_state.source_kind {
SourceKind::Iso(iso_path) => {
let iso_path = iso_path.clone();
std::thread::spawn(move || {
let result = futures::executor::block_on(async {
let mut reader =
sylpheed_formats::xiso::open_iso(&iso_path)
.await
.map_err(|e| e.to_string())?;
let bytes = reader
.read_file(&file_path)
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((file_path, bytes))
});
match result {
Ok((path, bytes)) => {
let _ = sender
.send(IsoLoaderMsg::FileLoaded { path, bytes });
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e));
}
}
});
}
SourceKind::Directory(root) => {
let assets =
sylpheed_formats::vfs::GameAssets::from_directory(root);
match assets.read(&file_path) {
Ok(bytes) => {
let _ = sender.send(IsoLoaderMsg::FileLoaded {
path: file_path,
bytes,
});
}
Err(e) => {
let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
}
}
}
SourceKind::None => {}
}
}
}
/// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and
/// `PendingFileBytes` as messages arrive.
#[cfg(not(target_arch = "wasm32"))]
fn poll_loader_channel(
channels: Res<IsoChannels>,
mut iso_state: ResMut<IsoState>,
mut browser: ResMut<FileBrowserState>,
mut pending: ResMut<PendingFileBytes>,
) {
let receiver = channels.receiver.lock().unwrap();
loop {
use std::sync::mpsc::TryRecvError;
match receiver.try_recv() {
Ok(IsoLoaderMsg::FilesListed {
source,
label,
files,
}) => {
iso_state.loading = false;
iso_state.error = None;
iso_state.source_label = Some(label);
iso_state.source_kind = source;
browser.files = files;
browser.selected = None;
browser.filter.clear();
info!("Loaded {} files", browser.files.len());
}
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
pending.path = path;
pending.bytes = bytes;
pending.ready = true;
}
Ok(IsoLoaderMsg::Cancelled) => {
iso_state.loading = false;
}
Ok(IsoLoaderMsg::Error(msg)) => {
error!("ISO loader: {}", msg);
iso_state.loading = false;
iso_state.error = Some(msg);
}
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
}
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
/// and populates `TexturePreview` / `FileInfo`.
#[cfg(not(target_arch = "wasm32"))]
fn apply_loaded_texture(
mut pending: ResMut<PendingFileBytes>,
mut preview: ResMut<TexturePreview>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
mut contexts: bevy_egui::EguiContexts,
) {
if !pending.ready {
return;
}
pending.ready = false;
let bytes = std::mem::take(&mut pending.bytes);
let path = std::mem::take(&mut pending.path);
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
// Always free the previous texture first (GPU memory + egui slot).
if let Some(ref handle) = preview.handle {
contexts.remove_image(handle);
images.remove(handle.id());
}
*preview = TexturePreview::default();
// Populate FileInfo for the status / info panel.
file_info.name = path
.split('/')
.next_back()
.unwrap_or(&path)
.to_string();
file_info.size_bytes = bytes.len();
file_info.detected_format = Some(fmt);
if fmt != FileFormat::Xpr2Texture {
return; // Not a texture — FileInfo is sufficient
}
let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) {
Ok(t) => t,
Err(e) => {
preview.format_info = format!("XPR2 parse failed: {e}");
return;
}
};
let w = tex.width;
let h = tex.height;
let mips = tex.mip_levels;
let fmt_str = format!("{:?} {}×{} {} mip(s)", tex.format, w, h, mips);
let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) {
Ok(img) => img,
Err(e) => {
preview.format_info = format!("Bevy image conversion failed: {e}");
return;
}
};
let handle = images.add(image);
let egui_id = contexts.add_image(handle.clone_weak());
preview.handle = Some(handle);
preview.egui_id = Some(egui_id);
preview.width = w;
preview.height = h;
preview.format_info = fmt_str;
}

View File

@@ -20,6 +20,7 @@ use bevy_egui::EguiPlugin;
pub mod asset_loader;
pub mod camera;
pub mod iso_loader;
pub mod ui;
// ── Application state ────────────────────────────────────────────────────────
@@ -75,6 +76,7 @@ pub fn run() {
app.add_plugins(EguiPlugin);
app.add_plugins(asset_loader::SylpheedAssetPlugin);
app.add_plugins(iso_loader::IsoLoaderPlugin);
app.add_plugins(camera::OrbitCameraPlugin);
app.add_plugins(ui::ViewerUiPlugin);

View File

@@ -1,21 +1,27 @@
//! egui-based debug UI for the asset viewer.
//!
//! Provides panels for:
//! - File browser (list extracted game files)
//! - File browser (list ISO / extracted game files, filter by name)
//! - Texture inspector (preview + format info)
//! - Mesh inspector (vertex/index counts, materials)
//! - File info (size, detected format for non-texture files)
//! - RE notes (track discoveries during reverse engineering)
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
IsoLoaderSystemSet,
};
use crate::{ViewerState, ViewMode};
pub struct ViewerUiPlugin;
impl Plugin for ViewerUiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, draw_viewer_ui);
app.insert_resource(FileBrowserState::default());
// Run after the iso_loader chain so we see the frame's final state.
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
}
}
@@ -31,7 +37,12 @@ fn draw_viewer_ui(
mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>,
mut browser: ResMut<FileBrowserState>,
asset_server: Res<AssetServer>,
iso_state: Res<IsoState>,
preview: Res<TexturePreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>,
mut file_selected_events: EventWriter<FileSelected>,
) {
let ctx = contexts.ctx_mut();
@@ -39,26 +50,45 @@ fn draw_viewer_ui(
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Open extracted directory...").clicked() {
// TODO: file dialog (rfd crate on native, not available on WASM)
info!("TODO: open directory dialog");
ui.close_menu();
}
ui.separator();
#[cfg(not(target_arch = "wasm32"))]
if ui.button("Quit").clicked() {
std::process::exit(0);
{
if ui.button("Open ISO disc image…").clicked() {
open_iso_events.send_default();
ui.close_menu();
}
if ui.button("Open extracted folder…").clicked() {
open_dir_events.send_default();
ui.close_menu();
}
ui.separator();
if ui.button("Quit").clicked() {
std::process::exit(0);
}
}
#[cfg(target_arch = "wasm32")]
ui.colored_label(
egui::Color32::GRAY,
"File loading not available in browser",
);
});
ui.menu_button("View", |ui| {
if ui.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures").clicked() {
if ui
.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures")
.clicked()
{
viewer.current_mode = ViewMode::Textures;
}
if ui.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes").clicked() {
if ui
.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes")
.clicked()
{
viewer.current_mode = ViewMode::Mesh;
}
if ui.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio").clicked() {
if ui
.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio")
.clicked()
{
viewer.current_mode = ViewMode::Audio;
}
ui.separator();
@@ -66,7 +96,7 @@ fn draw_viewer_ui(
});
ui.menu_button("Help", |ui| {
if ui.button("Controls...").clicked() {
if ui.button("Controls").clicked() {
// TODO: controls popup
}
if ui.button("About").clicked() {
@@ -93,29 +123,39 @@ fn draw_viewer_ui(
// File list
egui::ScrollArea::vertical().show(ui, |ui| {
if browser.files.is_empty() {
if iso_state.loading {
ui.spinner();
ui.label("Loading…");
} else if let Some(err) = &iso_state.error {
ui.colored_label(
egui::Color32::RED,
format!("⚠ Error:\n{}", err),
);
} else if browser.files.is_empty() {
ui.colored_label(
egui::Color32::YELLOW,
"No files loaded.\nExtract your ISO first:\n\
xdvdfs unpack game.iso ./assets/",
"No files loaded.\nUse File → Open ISO disc image\n\
or File → Open extracted folder.",
);
} else {
let filter = browser.filter.to_lowercase();
// Collect owned strings so we can later mutate `browser.selected`
let filtered: Vec<(usize, String)> = browser.files
let filtered: Vec<(usize, String)> = browser
.files
.iter()
.enumerate()
.filter(|(_, f)| filter.is_empty() || f.to_lowercase().contains(&filter))
.filter(|(_, f)| {
filter.is_empty() || f.to_lowercase().contains(&filter)
})
.map(|(i, f)| (i, f.clone()))
.collect();
for (idx, file) in &filtered {
let is_selected = browser.selected == Some(*idx);
let label = egui::SelectableLabel::new(is_selected, file.as_str());
let label =
egui::SelectableLabel::new(is_selected, file.as_str());
if ui.add(label).clicked() {
browser.selected = Some(*idx);
info!("Selected file: {}", file);
// TODO: load the selected file into the viewer
file_selected_events.send(FileSelected(file.clone()));
}
}
}
@@ -125,31 +165,84 @@ fn draw_viewer_ui(
// ── Bottom panel: status bar ──────────────────────────────────────────
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
ui.horizontal(|ui| {
if let Some(label) = &iso_state.source_label {
ui.label(label);
ui.separator();
}
match viewer.current_mode {
ViewMode::Textures => ui.label("Mode: Texture Viewer"),
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
};
ui.separator();
ui.label(format!("{} files loaded", browser.files.len()));
ui.label(format!("{} files", browser.files.len()));
ui.separator();
ui.label("LMB drag: orbit | RMB drag: pan | Scroll: zoom | R: reset");
ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset");
});
});
// ── Central area: RE notes (shown when no asset is selected) ──────────
if browser.selected.is_none() {
// ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| {
if let Some(egui_id) = preview.egui_id {
// Texture preview
ui.heading(&file_info.name);
ui.label(&preview.format_info);
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
}
ui.separator();
// Scale to fit, preserving aspect ratio, never upscale
let avail = ui.available_size();
let scale = if preview.width == 0 || preview.height == 0 {
1.0_f32
} else {
(avail.x / preview.width as f32)
.min(avail.y / preview.height as f32)
.min(1.0)
};
let display_w = preview.width as f32 * scale;
let display_h = preview.height as f32 * scale;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
egui_id,
[display_w, display_h],
)));
} else {
// Non-texture file info
ui.heading(&file_info.name);
if !preview.format_info.is_empty() {
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
}
if file_info.size_bytes > 0 {
ui.separator();
ui.label(format!("Size: {} bytes", file_info.size_bytes));
if let Some(fmt) = file_info.detected_format {
ui.label(format!("Detected format: {:?}", fmt));
}
} else if iso_state.loading {
ui.spinner();
ui.label("Loading file…");
}
}
});
} else {
// No file selected — show welcome / RE notes
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Project Sylpheed: Arc of Deception");
ui.label("Asset Viewer — Milestone 1");
ui.separator();
ui.collapsing("Getting Started", |ui| {
ui.label("1. Dump your Project Sylpheed disc to an ISO");
ui.label("Option A — open an ISO directly:");
ui.code(" File → Open ISO disc image…");
ui.separator();
ui.label("Option B — extract first, then open folder:");
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
ui.label("2. Restart the viewer — files will appear in the left panel");
ui.label("3. Click a .xpr file to preview the texture");
ui.label("4. Use File → Open to select a different directory");
ui.code(" File → Open extracted folder…");
ui.separator();
ui.label("Click a .xpr file in the left panel to preview the texture.");
});
ui.collapsing("Reverse Engineering Notes", |ui| {