Compare commits
12 Commits
d92c7ddaea
...
b7028471e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7028471e9 | ||
|
|
e1ca95c0af | ||
|
|
10425aba8c | ||
|
|
e17bc0216d | ||
|
|
91bd2543f4 | ||
|
|
589659bec6 | ||
|
|
765e4a573b | ||
|
|
fbd4550d62 | ||
|
|
2658dd20a6 | ||
|
|
854fd8dfd3 | ||
|
|
405233e84f | ||
|
|
840db9c549 |
428
Cargo.lock
generated
428
Cargo.lock
generated
@@ -60,8 +60,8 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"paste",
|
||||
"static_assertions",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows 0.58.0",
|
||||
"windows-core 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -112,6 +112,28 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -121,10 +143,10 @@ dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.11.0",
|
||||
"cc",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
@@ -1248,7 +1270,25 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 2.1.3",
|
||||
"shlex",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
@@ -1420,6 +1460,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -1470,6 +1516,12 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@@ -1561,6 +1613,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claxon"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1764,6 +1822,49 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
|
||||
dependencies = [
|
||||
"bindgen 0.72.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1830,6 +1931,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
@@ -2453,7 +2560,7 @@ dependencies = [
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 1.0.69",
|
||||
"windows",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2548,6 +2655,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -2726,6 +2839,22 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -2845,6 +2974,17 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lewton"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"ogg",
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -2927,6 +3067,15 @@ dependencies = [
|
||||
"twox-hash 2.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -3047,7 +3196,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"log",
|
||||
"pp-rs",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"termcolor",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3068,12 +3217,26 @@ dependencies = [
|
||||
"once_cell",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3169,6 +3332,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3506,6 +3680,29 @@ dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "offset-allocator"
|
||||
version = "0.2.0"
|
||||
@@ -3516,6 +3713,15 @@ dependencies = [
|
||||
"nonmax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -3554,7 +3760,7 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
|
||||
dependencies = [
|
||||
"ttf-parser",
|
||||
"ttf-parser 0.25.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3986,6 +4192,19 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
|
||||
dependencies = [
|
||||
"claxon",
|
||||
"cpal",
|
||||
"hound",
|
||||
"lewton",
|
||||
"symphonia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.8.1"
|
||||
@@ -4004,6 +4223,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -4345,8 +4570,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"colored",
|
||||
"image",
|
||||
"indicatif",
|
||||
"sylpheed-formats",
|
||||
"texpresso",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -4365,6 +4592,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ttf-parser 0.24.1",
|
||||
"xdvdfs",
|
||||
]
|
||||
|
||||
@@ -4372,16 +4600,68 @@ dependencies = [
|
||||
name = "sylpheed-viewer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bevy",
|
||||
"bevy_egui",
|
||||
"futures",
|
||||
"image",
|
||||
"rfd",
|
||||
"rodio",
|
||||
"sylpheed-formats",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -4437,6 +4717,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "texpresso"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8550677e2259d675a7841cb1403db35f330cc9e58674c8c5caa12dd12c51dc71"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -4683,7 +4972,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528bdd1f0e27b5dd9a4ededf154e824b0532731e4af73bb531de46276e0aab1e"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bindgen 0.70.1",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -4721,6 +5010,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -5089,7 +5384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
@@ -5147,7 +5442,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"wgpu-hal",
|
||||
@@ -5189,14 +5484,14 @@ dependencies = [
|
||||
"range-alloc",
|
||||
"raw-window-handle",
|
||||
"renderdoc-sys",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu-types",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows 0.58.0",
|
||||
"windows-core 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5219,13 +5514,33 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
@@ -5237,7 +5552,7 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
@@ -5270,6 +5585,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
@@ -5285,10 +5609,19 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
@@ -5334,6 +5667,21 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
@@ -5382,6 +5730,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -5400,6 +5754,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -5418,6 +5778,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -5448,6 +5814,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -5466,6 +5838,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.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -5484,6 +5862,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.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -5502,6 +5886,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.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -5542,7 +5932,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
@@ -42,6 +42,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# ── Native file dialog ────────────────────────────────────────────────────────
|
||||
rfd = "0.14"
|
||||
|
||||
# ── Audio playback (native video player) ──────────────────────────────────────
|
||||
rodio = "0.20"
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -19,3 +19,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
colored = "2"
|
||||
indicatif = "0.17"
|
||||
# Texture PNG export: BCn software decode + image encode.
|
||||
texpresso = "2"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
@@ -272,6 +272,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
|
||||
"bin" => label.red().to_string(),
|
||||
"xpr" => label.green().to_string(),
|
||||
"dds" => label.green().to_string(),
|
||||
"txt" => label.cyan().to_string(),
|
||||
_ => label.yellow().to_string(),
|
||||
};
|
||||
|
||||
@@ -330,73 +331,71 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
||||
let bytes = std::fs::read(file)
|
||||
.with_context(|| format!("Cannot read {}", file.display()))?;
|
||||
|
||||
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
let tex = X360Texture::from_xpr2(&bytes)?;
|
||||
let rgba = decode_to_rgba8(&tex)
|
||||
.with_context(|| format!("decoding {:?} texture", tex.format))?;
|
||||
|
||||
// For BCn compressed textures we need to software-decompress to RGBA8
|
||||
// before saving to PNG. This uses the `texpresso` crate.
|
||||
//
|
||||
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
|
||||
// For now, print a helpful message.
|
||||
match tex.format {
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
// Raw BGRA8 — can save directly (with channel swizzle)
|
||||
println!(
|
||||
"{} TODO: save raw BGRA8 to PNG (add `image` crate)",
|
||||
"Note:".yellow()
|
||||
);
|
||||
}
|
||||
compressed_format => {
|
||||
println!(
|
||||
"{} Format {:?} needs BCn decompression before PNG export.",
|
||||
"Note:".yellow(), compressed_format
|
||||
);
|
||||
println!(
|
||||
" Add `texpresso` crate and implement decompression in texture.rs"
|
||||
);
|
||||
println!(
|
||||
" Alternatively, use the Bevy viewer to inspect textures visually."
|
||||
);
|
||||
}
|
||||
}
|
||||
image::save_buffer(
|
||||
output,
|
||||
&rgba,
|
||||
tex.width,
|
||||
tex.height,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||
|
||||
println!(
|
||||
" Texture parsed OK: {}×{} {:?}",
|
||||
tex.width, tex.height, tex.format
|
||||
"{} {}×{} {:?}{} → {}",
|
||||
"Exported".green().bold(),
|
||||
tex.width,
|
||||
tex.height,
|
||||
tex.format,
|
||||
if tex.is_cubemap { " (cubemap face 0)" } else { "" },
|
||||
output.display().to_string().cyan(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
|
||||
///
|
||||
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
|
||||
/// is byte-swizzled from the Xenos in-memory BGRA order.
|
||||
fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u8>> {
|
||||
use sylpheed_formats::texture::X360TextureFormat as F;
|
||||
let (w, h) = (tex.width as usize, tex.height as usize);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
|
||||
let bc = |fmt: texpresso::Format, rgba: &mut [u8]| {
|
||||
fmt.decompress(&tex.data, w, h, rgba);
|
||||
};
|
||||
|
||||
match tex.format {
|
||||
F::Dxt1 => bc(texpresso::Format::Bc1, &mut rgba),
|
||||
F::Dxt3 => bc(texpresso::Format::Bc2, &mut rgba),
|
||||
F::Dxt5 => bc(texpresso::Format::Bc3, &mut rgba),
|
||||
F::A8R8G8B8 | F::X8R8G8B8 => {
|
||||
// After the k8in32 endian swap in from_xpr2, k_8_8_8_8 pixels are in
|
||||
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
|
||||
// Emit RGBA. X8 has no meaningful alpha.
|
||||
let opaque = matches!(tex.format, F::X8R8G8B8);
|
||||
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||
out[0] = px[1]; // R
|
||||
out[1] = px[2]; // G
|
||||
out[2] = px[3]; // B
|
||||
out[3] = if opaque { 0xFF } else { px[0] };
|
||||
}
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("PNG export for {other:?} (BC4/BC5) not implemented yet");
|
||||
}
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
|
||||
// ── pak list ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Best-effort human label for a decompressed entry's inner format.
|
||||
fn inner_label(payload: &[u8]) -> String {
|
||||
if payload.len() < 4 {
|
||||
return "empty".into();
|
||||
}
|
||||
let m = &payload[0..4];
|
||||
if m == b"IDXD" {
|
||||
"IDXD".into()
|
||||
} else if payload.starts_with(b"<?xml") {
|
||||
"xml".into()
|
||||
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
|
||||
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
|
||||
String::from_utf8_lossy(m).into_owned()
|
||||
} else {
|
||||
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
|
||||
}
|
||||
}
|
||||
|
||||
/// For an IDXD entry, a short identity string (ID / Name / Model if present).
|
||||
fn idxd_identity(obj: &IdxdObject) -> String {
|
||||
for key in ["ID", "Name", "Model"] {
|
||||
if let Some(v) = obj.get_raw(key) {
|
||||
return format!("{key}={v}");
|
||||
}
|
||||
}
|
||||
format!("schema {:08x}", obj.schema_hash)
|
||||
}
|
||||
use sylpheed_formats::pak::inner_format_label as inner_label;
|
||||
|
||||
/// Try to recover an IDXD entry's original TOC path from its identity tokens.
|
||||
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
|
||||
@@ -443,7 +442,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
|
||||
}
|
||||
let (detail, name) = if is_idxd {
|
||||
match IdxdObject::parse(&payload) {
|
||||
Ok(o) => (idxd_identity(&o), idxd_toc_name(&o, e.name_hash)),
|
||||
Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)),
|
||||
Err(_) => (String::new(), None),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ authors.workspace = true
|
||||
xdvdfs = { workspace = true }
|
||||
binrw = { workspace = true }
|
||||
flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe)
|
||||
ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
64
crates/sylpheed-formats/src/font.rs
Normal file
64
crates/sylpheed-formats/src/font.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Font metadata for the embedded UI fonts.
|
||||
//!
|
||||
//! The packs carry sfnt fonts (`\x00\x01\x00\x00` TrueType, `OTTO` OpenType/CFF)
|
||||
//! and TrueType Collections (`ttcf`). We only need light metadata to *present*
|
||||
//! them — family name, face count, glyph count — which `ttf-parser` extracts
|
||||
//! safely (malformed input yields `None`, never a panic). The raw bytes are
|
||||
//! handed to egui separately for a live sample.
|
||||
|
||||
use ttf_parser::{fonts_in_collection, name_id, Face};
|
||||
|
||||
/// Light font metadata for the viewer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FontInfo {
|
||||
/// "TrueType", "OpenType (CFF)", or "TrueType Collection".
|
||||
pub kind: &'static str,
|
||||
/// Family / full name (best-effort; empty if the name table is unreadable).
|
||||
pub family: String,
|
||||
/// Number of faces (1 for a plain sfnt, N for a `ttcf` collection).
|
||||
pub faces: u32,
|
||||
/// Glyph count of the first face.
|
||||
pub glyphs: u16,
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with a recognized font signature.
|
||||
pub fn is_font(bytes: &[u8]) -> bool {
|
||||
matches!(
|
||||
bytes.get(0..4),
|
||||
Some(b"\x00\x01\x00\x00") | Some(b"OTTO") | Some(b"true") | Some(b"ttcf")
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract light metadata, or `None` if the font can't be parsed.
|
||||
pub fn parse_info(bytes: &[u8]) -> Option<FontInfo> {
|
||||
let is_collection = bytes.get(0..4) == Some(b"ttcf");
|
||||
let faces = fonts_in_collection(bytes).unwrap_or(1).max(1);
|
||||
|
||||
// Face 0 gives the representative family + glyph count.
|
||||
let face = Face::parse(bytes, 0).ok()?;
|
||||
let glyphs = face.number_of_glyphs();
|
||||
|
||||
let kind = if is_collection {
|
||||
"TrueType Collection"
|
||||
} else if bytes.get(0..4) == Some(b"OTTO") {
|
||||
"OpenType (CFF)"
|
||||
} else {
|
||||
"TrueType"
|
||||
};
|
||||
|
||||
// Prefer the typographic/full family name; fall back to any readable name.
|
||||
let family = face
|
||||
.names()
|
||||
.into_iter()
|
||||
.filter(|n| n.name_id == name_id::FULL_NAME || n.name_id == name_id::FAMILY)
|
||||
.find_map(|n| n.to_string())
|
||||
.or_else(|| face.names().into_iter().find_map(|n| n.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(FontInfo {
|
||||
kind,
|
||||
family,
|
||||
faces,
|
||||
glyphs,
|
||||
})
|
||||
}
|
||||
@@ -171,6 +171,18 @@ impl IdxdObject {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A short identity string for the object: the first of `ID` / `Name` /
|
||||
/// `Model` present, else the schema hash. Shared by the CLI `pak list` and
|
||||
/// the GUI pack browser.
|
||||
pub fn identity(&self) -> String {
|
||||
for key in ["ID", "Name", "Model"] {
|
||||
if let Some(v) = self.get_raw(key) {
|
||||
return format!("{key}={v}");
|
||||
}
|
||||
}
|
||||
format!("schema {:08x}", self.schema_hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the trailing string pool. Finds the smallest offset whose suffix is
|
||||
|
||||
187
crates/sylpheed-formats/src/ixud.rs
Normal file
187
crates/sylpheed-formats/src/ixud.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! IXUD localized-string / subtitle table reader.
|
||||
//!
|
||||
//! `IXUD` entries in the language packs (`dat/movie/<lang>.pak`, the
|
||||
//! `GP_MAIN_GAME_<lang>` tables, …) hold localized UTF-16**BE** strings. The
|
||||
//! movie language packs use them as **subtitle cue lists**: a `SUBTITLE` header
|
||||
//! followed by alternating `(text, timecode)` tokens, e.g.
|
||||
//!
|
||||
//! ```text
|
||||
//! SUBTITLE
|
||||
//! "Calm down!" 00:09.40-00:11.00
|
||||
//! "Why are you taking Margras away?" 00:18.50-00:19.70
|
||||
//! ```
|
||||
//!
|
||||
//! Reference-style tracks store a message key (`MSG_DEMO_240`) plus a single
|
||||
//! start timecode instead of inline text; the actual string then lives in a
|
||||
//! separate global table (not resolved here).
|
||||
//!
|
||||
//! ## Binary layout (as far as needed)
|
||||
//! ```text
|
||||
//! 0x00 4 Magic "IXUD"
|
||||
//! 0x04 4 version (1)
|
||||
//! 0x08 4 schema/type hash (constant 0x6CC83E70)
|
||||
//! .. .. record directory { key_hash u32, offset u32, len u32 } × n
|
||||
//! .. .. UTF-16BE string pool, NUL-terminated entries
|
||||
//! ```
|
||||
//! We don't need the directory to *present* the track — decoding the pool and
|
||||
//! pairing tokens after the `SUBTITLE` header is enough and robust.
|
||||
|
||||
/// Magic at the start of every IXUD entry.
|
||||
pub const IXUD_MAGIC: [u8; 4] = *b"IXUD";
|
||||
|
||||
/// One subtitle cue: a start time (seconds), optional end time, and the text
|
||||
/// (or a `MSG_*` reference key for reference-style tracks).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Cue {
|
||||
pub start: f32,
|
||||
pub end: Option<f32>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A decoded subtitle track.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Subtitle {
|
||||
pub cues: Vec<Cue>,
|
||||
}
|
||||
|
||||
impl Subtitle {
|
||||
/// True when the track carries no inline text — every cue is a `MSG_*`
|
||||
/// reference whose string lives in a separate global table.
|
||||
pub fn is_reference_only(&self) -> bool {
|
||||
!self.cues.is_empty() && self.cues.iter().all(|c| c.text.starts_with("MSG_"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` is an IXUD entry.
|
||||
pub fn is_ixud(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == IXUD_MAGIC
|
||||
}
|
||||
|
||||
/// Parse an IXUD entry as a subtitle track. Returns `None` if not IXUD.
|
||||
/// Malformed pools yield as many well-formed cues as can be recovered.
|
||||
pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
|
||||
if !is_ixud(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Decode the whole payload as UTF-16BE and split into NUL-terminated,
|
||||
// printable runs. The binary header/directory decodes to control/CJK-range
|
||||
// junk that we skip by seeking to the `SUBTITLE` marker.
|
||||
let tokens = utf16be_tokens(bytes);
|
||||
let start = tokens
|
||||
.iter()
|
||||
.position(|t| t.ends_with("SUBTITLE"))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let rest = &tokens[start..];
|
||||
|
||||
// Pair (text, timecode). A token that parses as a timecode closes the cue
|
||||
// opened by the preceding text token; unpaired tokens are skipped so a
|
||||
// single glitch doesn't desync the rest.
|
||||
let mut cues = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < rest.len() {
|
||||
if let Some((s, e)) = parse_timing(&rest[i + 1]) {
|
||||
cues.push(Cue {
|
||||
start: s,
|
||||
end: e,
|
||||
text: rest[i].clone(),
|
||||
});
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Some(Subtitle { cues })
|
||||
}
|
||||
|
||||
/// Split the payload (interpreted as UTF-16BE) into printable, NUL-delimited
|
||||
/// tokens. Control chars terminate the current token.
|
||||
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
let u = u16::from_be_bytes([pair[0], pair[1]]);
|
||||
match char::from_u32(u as u32) {
|
||||
Some(c) if !c.is_control() => cur.push(c),
|
||||
_ => {
|
||||
if !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Parse `MM:SS.ss` (`" 9:04.40"` style, minutes may be blank/space-padded) or
|
||||
/// a `start-end` range into seconds.
|
||||
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
|
||||
let one = |p: &str| -> Option<f32> {
|
||||
let (mm, ss) = p.trim().split_once(':')?;
|
||||
let m: f32 = if mm.trim().is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
mm.trim().parse().ok()?
|
||||
};
|
||||
let sec: f32 = ss.trim().parse().ok()?;
|
||||
Some(m * 60.0 + sec)
|
||||
};
|
||||
match s.split_once('-') {
|
||||
Some((a, b)) => Some((one(a)?, Some(one(b)?))),
|
||||
None => Some((one(s)?, None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic IXUD payload: an 8-byte header stand-in + UTF-16BE
|
||||
/// NUL-terminated tokens.
|
||||
fn synth(tokens: &[&str]) -> Vec<u8> {
|
||||
let mut b = IXUD_MAGIC.to_vec();
|
||||
b.extend_from_slice(&[0, 0, 0, 1, 0x6C, 0xC8, 0x3E, 0x70]); // version + schema
|
||||
for t in tokens {
|
||||
for u in t.encode_utf16() {
|
||||
b.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0, 0]); // NUL
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_subtitle_track() {
|
||||
let b = synth(&[
|
||||
"SUBTITLE",
|
||||
"Calm down!",
|
||||
"00:09.40-00:11.00",
|
||||
"Let go of me!",
|
||||
"00:13.10-00:14.50",
|
||||
]);
|
||||
let sub = parse(&b).unwrap();
|
||||
assert_eq!(sub.cues.len(), 2);
|
||||
assert_eq!(sub.cues[0].text, "Calm down!");
|
||||
assert_eq!(sub.cues[0].start, 9.4);
|
||||
assert_eq!(sub.cues[0].end, Some(11.0));
|
||||
assert!(!sub.is_reference_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_track() {
|
||||
let b = synth(&["SUBTITLE", "MSG_DEMO_240", "00:01.00", "MSG_DEMO_241", "00:03.20"]);
|
||||
let sub = parse(&b).unwrap();
|
||||
assert_eq!(sub.cues.len(), 2);
|
||||
assert_eq!(sub.cues[0].end, None);
|
||||
assert!(sub.is_reference_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ixud() {
|
||||
assert!(parse(b"IDXD\0\0\0\0").is_none());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ pub mod pak;
|
||||
// IDXD reflective object (ship / weapon / effect / menu definitions) reader
|
||||
pub mod idxd;
|
||||
|
||||
// IXUD localized string / subtitle table reader
|
||||
pub mod ixud;
|
||||
|
||||
// Embedded font (OTF / TTF / ttcf) metadata
|
||||
pub mod font;
|
||||
|
||||
// T8aD 2D UI/HUD texture
|
||||
pub mod t8ad;
|
||||
|
||||
// RATC nested resource bundle
|
||||
pub mod ratc;
|
||||
|
||||
// LSTA sprite list (inline T8aD frames)
|
||||
pub mod lsta;
|
||||
|
||||
// XISO reading is not available in-browser
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod xiso;
|
||||
@@ -36,7 +51,11 @@ pub mod mesh;
|
||||
pub mod audio;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
pub use ixud::{Cue, Subtitle};
|
||||
pub use ratc::RatcChild;
|
||||
pub use t8ad::T8adImage;
|
||||
pub use pak::{PakArchive, PakEntry, PakError};
|
||||
pub use texture::{X360Texture, X360TextureFormat};
|
||||
pub use vfs::{GameAssets, VfsError};
|
||||
|
||||
78
crates/sylpheed-formats/src/lsta.rs
Normal file
78
crates/sylpheed-formats/src/lsta.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad)
|
||||
//! frames concatenated back-to-back.
|
||||
//!
|
||||
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame
|
||||
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame
|
||||
//! from its slice up to the next frame (or end).
|
||||
|
||||
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
|
||||
|
||||
/// Magic at the start of an LSTA sprite list.
|
||||
pub const LSTA_MAGIC: [u8; 4] = *b"LSTA";
|
||||
|
||||
/// Whether `bytes` is an LSTA list.
|
||||
pub fn is_lsta(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
|
||||
}
|
||||
|
||||
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD
|
||||
/// variant) are skipped. Returns `None` only for non-LSTA input.
|
||||
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
|
||||
if !is_lsta(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Offsets of each inline T8aD frame (search past the 4-byte magic).
|
||||
let mut offs = Vec::new();
|
||||
let mut i = 4;
|
||||
while i + 4 <= bytes.len() {
|
||||
if bytes[i..i + 4] == T8AD_MAGIC {
|
||||
offs.push(i);
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut frames = Vec::with_capacity(offs.len());
|
||||
for (idx, &off) in offs.iter().enumerate() {
|
||||
let next = offs.get(idx + 1).copied().unwrap_or(bytes.len());
|
||||
if let Some(img) = t8ad::parse(&bytes[off..next]) {
|
||||
frames.push(img);
|
||||
}
|
||||
}
|
||||
Some(frames)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 64];
|
||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_inline_frames() {
|
||||
let mut b = LSTA_MAGIC.to_vec();
|
||||
b.extend_from_slice(&2u32.to_be_bytes());
|
||||
b.extend_from_slice(&[0u8; 15]); // header remainder
|
||||
b.extend_from_slice(&t8ad_frame(4, 4));
|
||||
b.extend_from_slice(&t8ad_frame(2, 3));
|
||||
let frames = parse(&b).unwrap();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!((frames[0].width, frames[0].height), (4, 4));
|
||||
assert_eq!((frames[1].width, frames[1].height), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_lsta() {
|
||||
assert!(parse(b"T8aD....").is_none());
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,41 @@ fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
|
||||
/// Best-effort human label for a decompressed entry's inner format, from its
|
||||
/// first bytes: a known signature (`IDXD`, fonts, `png`, …), a printable 4-char
|
||||
/// tag (RATC, IXUD, LSTA, …), or a hex fallback for still-unidentified magics.
|
||||
/// Shared by the CLI `pak list` and the GUI pack browser.
|
||||
pub fn inner_format_label(payload: &[u8]) -> String {
|
||||
if payload.len() < 4 {
|
||||
return "empty".into();
|
||||
}
|
||||
let m = &payload[0..4];
|
||||
|
||||
// Known binary/text signatures → friendly short tags (so fonts/images don't
|
||||
// show as bare hex like `00010000` / `89504E47`).
|
||||
let known = match m {
|
||||
b"IDXD" => Some("IDXD"),
|
||||
b"\x89PNG" => Some("png"),
|
||||
b"\x00\x01\x00\x00" | b"true" | b"typ1" => Some("ttf"), // sfnt TrueType
|
||||
b"OTTO" => Some("otf"), // OpenType (CFF)
|
||||
b"ttcf" => Some("ttc"), // TrueType Collection
|
||||
b"RIFF" => Some("riff"),
|
||||
b"DDS " => Some("dds"),
|
||||
_ if payload.starts_with(b"<?xml") => Some("xml"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = known {
|
||||
return tag.into();
|
||||
}
|
||||
|
||||
if m.iter().all(|&b| b.is_ascii_graphic()) {
|
||||
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, …).
|
||||
String::from_utf8_lossy(m).into_owned()
|
||||
} else {
|
||||
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
131
crates/sylpheed-formats/src/ratc.rs
Normal file
131
crates/sylpheed-formats/src/ratc.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! `RATC` — a nested resource bundle.
|
||||
//!
|
||||
//! After a small header, a RATC holds named children — `foo.t32` (T8aD
|
||||
//! textures), `foo.rat` (nested RATC), fonts, PNG — each immediately preceded by
|
||||
//! its ASCII name string. The children are self-locating by their 4-char magic,
|
||||
//! so we list them by scanning for those magics and pairing each with the name
|
||||
//! run that precedes it. This is reliable for *listing* (names / types / sizes
|
||||
//! are literal bytes); decoding a child's pixels is delegated to that child's
|
||||
//! own parser ([`crate::t8ad`]).
|
||||
|
||||
/// A child resource inside a RATC bundle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RatcChild {
|
||||
/// Name string preceding the child (e.g. `prselectbtn_g04b.t32`), or empty.
|
||||
pub name: String,
|
||||
/// Child magic tag: `T8aD`, `RATC`, `ttcf`, `png`, …
|
||||
pub kind: String,
|
||||
/// Byte offset of the child (its magic) within the RATC payload.
|
||||
pub offset: usize,
|
||||
/// Byte length of the child, up to the next child (or end of payload).
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
/// Magic at the start of a RATC bundle.
|
||||
pub const RATC_MAGIC: [u8; 4] = *b"RATC";
|
||||
|
||||
/// Whether `bytes` is a RATC bundle.
|
||||
pub fn is_ratc(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == RATC_MAGIC
|
||||
}
|
||||
|
||||
/// Recognized child magics and their display tag.
|
||||
fn child_kind(m: &[u8]) -> Option<&'static str> {
|
||||
match m {
|
||||
b"T8aD" => Some("T8aD"),
|
||||
b"RATC" => Some("RATC"),
|
||||
b"ttcf" => Some("ttc"),
|
||||
b"\x89PNG" => Some("png"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// List the children of a RATC bundle (the self-magic at offset 0 is skipped).
|
||||
pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
|
||||
if !is_ratc(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Offsets of every recognized child magic (skip the self RATC at 0).
|
||||
let mut offs: Vec<(usize, &'static str)> = Vec::new();
|
||||
let mut i = 4;
|
||||
while i + 4 <= bytes.len() {
|
||||
if let Some(kind) = child_kind(&bytes[i..i + 4]) {
|
||||
offs.push((i, kind));
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut children = Vec::with_capacity(offs.len());
|
||||
for (idx, &(off, kind)) in offs.iter().enumerate() {
|
||||
let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len());
|
||||
children.push(RatcChild {
|
||||
name: name_before(bytes, off),
|
||||
kind: kind.to_string(),
|
||||
offset: off,
|
||||
size: next.saturating_sub(off),
|
||||
});
|
||||
}
|
||||
Some(children)
|
||||
}
|
||||
|
||||
/// The nearest name string preceding `off`: the *last* printable run (len ≥ 3)
|
||||
/// in the 96 bytes before the child magic. A few record-header bytes usually sit
|
||||
/// between the name and the magic, so an exact-adjacency scan isn't enough.
|
||||
fn name_before(bytes: &[u8], off: usize) -> String {
|
||||
let start = off.saturating_sub(96);
|
||||
let window = &bytes[start..off];
|
||||
let mut best = String::new();
|
||||
let mut run_start: Option<usize> = None;
|
||||
let flush = |from: usize, to: usize, best: &mut String| {
|
||||
if to - from >= 3 {
|
||||
*best = String::from_utf8_lossy(&window[from..to]).trim().to_string();
|
||||
}
|
||||
};
|
||||
for (i, &c) in window.iter().enumerate() {
|
||||
if (0x20..=0x7e).contains(&c) {
|
||||
run_start.get_or_insert(i);
|
||||
} else if let Some(s) = run_start.take() {
|
||||
flush(s, i, &mut best);
|
||||
}
|
||||
}
|
||||
if let Some(s) = run_start {
|
||||
flush(s, window.len(), &mut best);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_named_children() {
|
||||
let mut b = RATC_MAGIC.to_vec();
|
||||
b.extend_from_slice(&[0u8; 28]); // header padding
|
||||
b.extend_from_slice(b"logo.t32");
|
||||
let t8_off = b.len();
|
||||
b.extend_from_slice(b"T8aD");
|
||||
b.extend_from_slice(&[0u8; 40]); // some child bytes
|
||||
b.extend_from_slice(b"sub.rat");
|
||||
let ratc_off = b.len();
|
||||
b.extend_from_slice(b"RATC");
|
||||
b.extend_from_slice(&[0u8; 8]);
|
||||
|
||||
let kids = parse(&b).unwrap();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].kind, "T8aD");
|
||||
assert_eq!(kids[0].name, "logo.t32");
|
||||
assert_eq!(kids[0].offset, t8_off);
|
||||
assert_eq!(kids[0].size, ratc_off - t8_off);
|
||||
assert_eq!(kids[1].kind, "RATC");
|
||||
assert_eq!(kids[1].name, "sub.rat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ratc() {
|
||||
assert!(parse(b"T8aD....").is_none());
|
||||
}
|
||||
}
|
||||
134
crates/sylpheed-formats/src/t8ad.rs
Normal file
134
crates/sylpheed-formats/src/t8ad.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! `T8aD` — the game's 2D UI/HUD texture format.
|
||||
//!
|
||||
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with
|
||||
//! a small fixed-per-variant header:
|
||||
//!
|
||||
//! ```text
|
||||
//! 0x00 4 Magic "T8aD"
|
||||
//! 0x14 4 width (BE u32)
|
||||
//! 0x18 4 height (BE u32)
|
||||
//! 0x1c 4 type field → header size: 1→64 2→84 3→104 4→124 15→344
|
||||
//! <header> w*h*4 bytes of A8R8G8B8 pixel data, row-major
|
||||
//! ```
|
||||
//!
|
||||
//! Static forensics over the disc showed ~85% of entries decode exactly with
|
||||
//! this rule; the rest (unknown type field or a `w*h*4` that doesn't fit — most
|
||||
//! likely DXT / palettized variants) return `None` rather than a wrong image.
|
||||
//!
|
||||
//! Keying the header size off the type field (not `len - w*h*4`) makes the
|
||||
//! decoder **container-safe**: RATC/LSTA hand us a child slice that may have
|
||||
//! trailing padding before the next child, so we must not infer the header from
|
||||
//! the slice length.
|
||||
//!
|
||||
//! ⚠️ Colour correctness (channel order / endianness / sRGB) is unverified
|
||||
//! against the running game — see the RE backlog.
|
||||
|
||||
/// Magic at the start of every T8aD surface.
|
||||
pub const T8AD_MAGIC: [u8; 4] = *b"T8aD";
|
||||
|
||||
/// A decoded T8aD surface as tightly-packed RGBA8 (row-major, top-left origin).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct T8adImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with the T8aD magic.
|
||||
pub fn is_t8ad(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC
|
||||
}
|
||||
|
||||
/// Header size in bytes for a given type field (`@0x1c`), or `None` for an
|
||||
/// unrecognized variant.
|
||||
fn header_size(type_field: u32) -> Option<usize> {
|
||||
match type_field {
|
||||
1 => Some(64),
|
||||
2 => Some(84),
|
||||
3 => Some(104),
|
||||
4 => Some(124),
|
||||
15 => Some(344),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn be32(b: &[u8], off: usize) -> u32 {
|
||||
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
|
||||
}
|
||||
|
||||
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
|
||||
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses).
|
||||
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
if !is_t8ad(bytes) || bytes.len() < 0x40 {
|
||||
return None;
|
||||
}
|
||||
let width = be32(bytes, 0x14);
|
||||
let height = be32(bytes, 0x18);
|
||||
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
|
||||
return None;
|
||||
}
|
||||
let header = header_size(be32(bytes, 0x1c))?;
|
||||
let n = (width as usize) * (height as usize) * 4;
|
||||
if bytes.len() < header + n {
|
||||
return None; // DXT/palettized/short variant — defer, don't misdecode
|
||||
}
|
||||
|
||||
// A8R8G8B8 → RGBA8.
|
||||
let src = &bytes[header..header + n];
|
||||
let mut rgba = vec![0u8; n];
|
||||
for (px, out) in src.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
|
||||
out[0] = r;
|
||||
out[1] = g;
|
||||
out[2] = b;
|
||||
out[3] = a;
|
||||
}
|
||||
Some(T8adImage {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic type-1 (header 64) T8aD with a known A8R8G8B8 pattern.
|
||||
fn synth(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 64];
|
||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // type 1 → header 64
|
||||
for i in 0..(w * h) {
|
||||
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_argb_to_rgba() {
|
||||
let b = synth(4, 2);
|
||||
let img = parse(&b).expect("decodes");
|
||||
assert_eq!((img.width, img.height), (4, 2));
|
||||
assert_eq!(img.rgba.len(), 4 * 2 * 4);
|
||||
// pixel 0: A=0,R=0x24,G=0x63,B=0xB2 → RGBA = 24 63 B2 00
|
||||
assert_eq!(&img.rgba[0..4], &[0x24, 0x63, 0xB2, 0x00]);
|
||||
// pixel 1: A=1 → alpha byte
|
||||
assert_eq!(&img.rgba[4..8], &[0x24, 0x63, 0xB2, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_variant_and_short() {
|
||||
// type field 7 (unknown) → None
|
||||
let mut b = synth(2, 2);
|
||||
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
|
||||
assert!(parse(&b).is_none());
|
||||
// truncated pixel data → None
|
||||
let b = synth(64, 64);
|
||||
assert!(parse(&b[..200]).is_none());
|
||||
assert!(parse(b"IDXD\0\0\0\0").is_none());
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,11 @@ impl Xpr2ResourceEntry {
|
||||
pub fn is_texture(&self) -> bool {
|
||||
&self.type_tag == b"TX2D"
|
||||
}
|
||||
|
||||
/// Cubemap resource (`TXCM`) — 6 faces sharing one GPUFC descriptor.
|
||||
pub fn is_cubemap(&self) -> bool {
|
||||
&self.type_tag == b"TXCM"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decoded texture ───────────────────────────────────────────────────────────
|
||||
@@ -179,12 +184,34 @@ pub struct X360Texture {
|
||||
pub height: u32,
|
||||
pub format: X360TextureFormat,
|
||||
pub mip_levels: u32,
|
||||
/// True when the source resource was a cubemap (`TXCM`). `data` then holds
|
||||
/// only face 0 (the +X face) decoded as a 2D image — enough for a preview.
|
||||
pub is_cubemap: bool,
|
||||
/// De-tiled texture data in linear order.
|
||||
/// BCn: standard packed block data (DDS layout).
|
||||
/// Uncompressed: BGRA8 pixel data.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A decoded Xbox 360 cubemap (`TXCM`) — a world skybox. The 6 faces are each a
|
||||
/// de-tiled 2D surface in the same layout as [`X360Texture::data`], in D3D9
|
||||
/// `D3DCUBEMAP_FACES` order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cubemap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: X360TextureFormat,
|
||||
/// Exactly 6 faces, D3D9 order: +X, -X, +Y, -Y, +Z, -Z.
|
||||
pub faces: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Cubemap {
|
||||
/// The D3D9 cube-face name for slice `i` (0..6).
|
||||
pub fn face_label(i: usize) -> &'static str {
|
||||
["+X", "-X", "+Y", "-Y", "+Z", "-Z"].get(i).copied().unwrap_or("?")
|
||||
}
|
||||
}
|
||||
|
||||
impl X360Texture {
|
||||
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
|
||||
///
|
||||
@@ -206,10 +233,20 @@ impl X360Texture {
|
||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||
}
|
||||
|
||||
// Find the first TX2D texture resource
|
||||
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
|
||||
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
|
||||
// pixel section holds 6 faces. For a preview we decode face 0.
|
||||
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
|
||||
// the Nth texture resource (default 0) for RE/browse validation.
|
||||
let want = std::env::var("XPR_RES_INDEX")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let tex_entry = entries.iter()
|
||||
.find(|e| e.is_texture())
|
||||
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||
.nth(want)
|
||||
.ok_or(TextureError::NoTextureFound)?;
|
||||
let is_cubemap = tex_entry.is_cubemap();
|
||||
|
||||
// The descriptor is at file offset = data_offset + 0x10 (directory base)
|
||||
const DIR_BASE: usize = 0x10;
|
||||
@@ -239,6 +276,11 @@ impl X360Texture {
|
||||
let format = X360TextureFormat::from_u8(fmt_code)
|
||||
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
|
||||
|
||||
// GPUFC[1] bits[7:6] = endianness. X360 stores texture words byte-
|
||||
// swapped; without undoing this, BCn endpoints/indices and ARGB channels
|
||||
// decode to noise. (fetch-constant `endianness`, xenos.h Endian.)
|
||||
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
|
||||
|
||||
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
|
||||
let base_address = (gpufc1 & 0xFFFFF000) as usize;
|
||||
|
||||
@@ -260,23 +302,74 @@ impl X360Texture {
|
||||
have: bytes.len(),
|
||||
});
|
||||
}
|
||||
let raw_data = &bytes[data_start..];
|
||||
|
||||
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()
|
||||
};
|
||||
// De-tile + endian-correct the single (face-0) surface.
|
||||
let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;
|
||||
|
||||
Ok(X360Texture { width, height, format, mip_levels: mip_count, data: linear_data })
|
||||
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
|
||||
}
|
||||
|
||||
/// Decode all 6 faces of a cubemap (`TXCM`) resource, or `Ok(None)` if the
|
||||
/// selected resource is an ordinary 2D texture.
|
||||
///
|
||||
/// X360 cubemaps store the 6 faces back-to-back in the pixel section, each a
|
||||
/// full independently-tiled 2D surface whose stride is the tiled surface size
|
||||
/// rounded up to a 4 KiB subresource boundary (`kTextureSubresourceAlignmentBytes`).
|
||||
/// Faces are in D3D9 `D3DCUBEMAP_FACES` order: +X, -X, +Y, -Y, +Z, -Z.
|
||||
/// (Derived from xenia `texture_util.cc::GetGuestTextureLayout`; verified on
|
||||
/// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
|
||||
pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
|
||||
use std::io::Cursor;
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = Xpr2Header::read(&mut cur)?;
|
||||
let mut entries = Vec::new();
|
||||
for _ in 0..header.num_resources {
|
||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||
}
|
||||
let want = std::env::var("XPR_RES_INDEX")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let tex_entry = entries
|
||||
.iter()
|
||||
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||
.nth(want)
|
||||
.ok_or(TextureError::NoTextureFound)?;
|
||||
if !tex_entry.is_cubemap() {
|
||||
return Ok(None); // ordinary 2D texture — use `from_xpr2`
|
||||
}
|
||||
|
||||
const DIR_BASE: usize = 0x10;
|
||||
let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
|
||||
if bytes.len() < gpufc_base + 6 * 4 {
|
||||
return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
|
||||
}
|
||||
let be_u32 = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||
let gpufc0 = be_u32(gpufc_base);
|
||||
let gpufc1 = be_u32(gpufc_base + 0x04);
|
||||
let gpufc2 = be_u32(gpufc_base + 0x08);
|
||||
|
||||
let fmt_code = (gpufc1 & 0x3F) as u8;
|
||||
let format = X360TextureFormat::from_u8(fmt_code)
|
||||
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
|
||||
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
|
||||
let base_address = (gpufc1 & 0xFFFFF000) as usize;
|
||||
let width = (gpufc2 & 0x1FFF) + 1;
|
||||
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
|
||||
let is_tiled = (gpufc0 >> 31) != 0;
|
||||
|
||||
let data_start = header.header_size as usize + base_address;
|
||||
let stride = tiled_face_stride(width, height, format);
|
||||
|
||||
let mut faces = Vec::with_capacity(6);
|
||||
for f in 0..6 {
|
||||
let start = data_start + f * stride;
|
||||
let raw = bytes
|
||||
.get(start..)
|
||||
.ok_or(TextureError::BufferTooSmall { needed: start + 1, have: bytes.len() })?;
|
||||
faces.push(decode_surface(raw, width, height, format, is_tiled, endianness)?);
|
||||
}
|
||||
Ok(Some(Cubemap { width, height, format, faces }))
|
||||
}
|
||||
|
||||
/// Parse a texture from already-known parameters + raw tiled data.
|
||||
@@ -290,19 +383,163 @@ 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, is_cubemap: false, data: linear_data })
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the Xenos texture endian swap in place, as byte permutations over the
|
||||
/// data. Xbox 360 stores texture words big-endian; this converts them to the
|
||||
/// PC-standard little-endian layout that BCn decoders and wgpu expect.
|
||||
/// `endianness` is the fetch-constant `dword_1` bits[7:6]:
|
||||
/// 0 = none, 1 = k8in16, 2 = k8in32, 3 = k16in32 (xenia `xenos.h` `Endian`).
|
||||
pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
|
||||
match endianness {
|
||||
1 => {
|
||||
// k8in16 — swap the two bytes of each 16-bit half.
|
||||
for c in data.chunks_exact_mut(2) {
|
||||
c.swap(0, 1);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// k8in32 — reverse each 32-bit word.
|
||||
for c in data.chunks_exact_mut(4) {
|
||||
c.reverse();
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// k16in32 — swap the two 16-bit halves of each 32-bit word.
|
||||
for c in data.chunks_exact_mut(4) {
|
||||
c.swap(0, 2);
|
||||
c.swap(1, 3);
|
||||
}
|
||||
}
|
||||
_ => {} // kNone
|
||||
}
|
||||
}
|
||||
|
||||
/// De-tile (if tiled) + endian-correct one texture surface into linear PC
|
||||
/// layout. Shared by `from_xpr2` (face 0) and `cube_faces_from_xpr2` (6 faces).
|
||||
/// The `XPR_NO_DETILE` / `XPR_NO_ENDIAN` / `XPR_FORCE_ENDIAN` / `XPR_NO_BC_DWORD_SWAP`
|
||||
/// debug knobs are honoured here so both paths behave identically.
|
||||
fn decode_surface(
|
||||
raw_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: X360TextureFormat,
|
||||
is_tiled: bool,
|
||||
endianness: u8,
|
||||
) -> Result<Vec<u8>, TextureError> {
|
||||
let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
|
||||
let mut linear_data = if is_tiled && !force_linear {
|
||||
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()
|
||||
};
|
||||
|
||||
// Undo the X360 word byte-swap so block/pixel data is PC little-endian.
|
||||
let endianness = std::env::var("XPR_FORCE_ENDIAN")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
||||
.unwrap_or(endianness);
|
||||
if std::env::var("XPR_NO_ENDIAN").is_err() {
|
||||
apply_endian_swap(&mut linear_data, endianness);
|
||||
}
|
||||
|
||||
// BC1 colour-block dword swap (see the long note where this was discovered):
|
||||
// colour endpoints live in the high dword on X360; format-targeted so BC4/BC5
|
||||
// (byte-indexed alpha/normal blocks) are left as endian-only.
|
||||
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
|
||||
match format {
|
||||
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
|
||||
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
|
||||
for block in linear_data.chunks_exact_mut(16) {
|
||||
swap_bc_block_dwords(&mut block[8..16]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(linear_data)
|
||||
}
|
||||
|
||||
/// Byte stride between consecutive cubemap faces: the tiled surface size
|
||||
/// (`pitch_aligned × height_aligned × bpb`, both padded to 32-block macro tiles)
|
||||
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
|
||||
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
|
||||
let bs = format.block_size() as u32;
|
||||
let bw = ((width + bs - 1) / bs).max(1);
|
||||
let bh = ((height + bs - 1) / bs).max(1);
|
||||
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
|
||||
(surface + 0xFFF) & !0xFFF
|
||||
}
|
||||
|
||||
/// Swap the two 32-bit dwords within each 64-bit unit of `data`, in place.
|
||||
///
|
||||
/// Xbox 360 stores the BC1 *colour* block with its two 32-bit words in the
|
||||
/// opposite order to the PC/DDS layout, putting the colour endpoints ahead of
|
||||
/// the indices. Apply to a BC1 buffer (or the colour half of a BC2/BC3 block)
|
||||
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
|
||||
/// 8-byte group are left untouched.
|
||||
pub fn swap_bc_block_dwords(data: &mut [u8]) {
|
||||
for unit in data.chunks_exact_mut(8) {
|
||||
// [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
|
||||
let (lo, hi) = unit.split_at_mut(4);
|
||||
lo.swap_with_slice(hi);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
||||
|
||||
/// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout.
|
||||
/// Macro-tile side in blocks (Xenos tiles are 32×32 *blocks*, where a "block"
|
||||
/// is one BCn 4×4 group or one uncompressed texel). `texture_address.h`
|
||||
/// `kTextureTileWidthHeight` / `kMacroTileWidth`.
|
||||
const MACRO_TILE_BLOCKS: u32 = 32;
|
||||
/// Storage pitch/height alignment, in blocks (`kStoragePitchHeightAlignmentBlocks`).
|
||||
const STORAGE_ALIGN_BLOCKS: u32 = 32;
|
||||
|
||||
#[inline]
|
||||
fn align_up(v: u32, a: u32) -> u32 {
|
||||
(v + a - 1) & !(a - 1)
|
||||
}
|
||||
|
||||
/// Byte offset of block (x, y) within an Xbox 360 2D tiled surface.
|
||||
///
|
||||
/// 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.
|
||||
/// Faithful port of xenia-canary `texture_address.h::Tiled2D` + `TiledCombine`
|
||||
/// (documented Xenos hardware tiling — bank/pipe/macro-tile addressing, NOT a
|
||||
/// plain Morton curve). `x`/`y` and `pitch_aligned` are in block units;
|
||||
/// `bpb_log2` is log2(bytes-per-block). Returns a byte offset.
|
||||
#[inline]
|
||||
fn tiled_2d_offset(x: i32, y: i32, pitch_aligned: u32, bpb_log2: u32) -> i32 {
|
||||
let outer_blocks = ((y >> 5) * (pitch_aligned >> 5) as i32 + (x >> 5)) << 6;
|
||||
let inner_blocks = (((y >> 1) & 0b111) << 3) | (x & 0b111);
|
||||
let outer_inner_bytes = (outer_blocks | inner_blocks) << bpb_log2;
|
||||
let bank = (y >> 4) & 0b1;
|
||||
let pipe = ((x >> 3) & 0b11) ^ (((y >> 3) & 0b1) << 1);
|
||||
let y_lsb = y & 1;
|
||||
// TiledCombine: splice bank/pipe/y_lsb bits into the byte address.
|
||||
((y_lsb << 4) | (pipe << 6) | (bank << 11))
|
||||
| (outer_inner_bytes & 0b1111)
|
||||
| (((outer_inner_bytes >> 4) & 0b1) << 5)
|
||||
| (((outer_inner_bytes >> 5) & 0b111) << 8)
|
||||
| (outer_inner_bytes >> 8 << 12)
|
||||
}
|
||||
|
||||
/// De-tile an Xbox 360 GPU texture (mip-0) from tiled to linear (row-major)
|
||||
/// layout, using the exact Xenos address formula.
|
||||
///
|
||||
/// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`.
|
||||
/// `src` must hold the tiled mip-0 surface (its storage pitch/height are
|
||||
/// rounded up to 32 blocks, so it may be larger than the visible image). The
|
||||
/// returned buffer is tightly packed linear block data (DDS layout for BCn).
|
||||
pub fn detile(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
@@ -311,50 +548,30 @@ pub fn detile(
|
||||
) -> Result<Vec<u8>, TextureError> {
|
||||
let block_size = format.block_size() as u32;
|
||||
let bpb = format.bytes_per_block();
|
||||
let bpb_log2 = (bpb as u32).trailing_zeros();
|
||||
|
||||
// Texture dimensions in blocks
|
||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||
// Visible dimensions in blocks, and the padded storage pitch/height.
|
||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
||||
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
|
||||
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
if src.len() < expected {
|
||||
return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() });
|
||||
// The tiled surface occupies pitch_aligned × height_aligned blocks.
|
||||
let src_needed = pitch_aligned as usize * height_aligned as usize * bpb;
|
||||
if src.len() < src_needed {
|
||||
return Err(TextureError::BufferTooSmall { needed: src_needed, have: src.len() });
|
||||
}
|
||||
|
||||
let mut dst = vec![0u8; expected];
|
||||
let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
let mut dst = vec![0u8; dst_len];
|
||||
|
||||
// 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;
|
||||
let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize;
|
||||
|
||||
for macro_y in 0..macro_tiles_tall {
|
||||
for macro_x in 0..macro_tiles_wide {
|
||||
let macro_base = ((macro_y * macro_tiles_wide + macro_x) as usize)
|
||||
* blocks_per_macro_tile
|
||||
* bpb;
|
||||
|
||||
for local in 0..blocks_per_macro_tile as u32 {
|
||||
// Decode Morton (Z-order) index → (lx, ly) within macro-tile
|
||||
let (lx, ly) = morton_decode(local);
|
||||
|
||||
let block_x = macro_x * macro_tile_blocks + lx;
|
||||
let block_y = macro_y * macro_tile_blocks + ly;
|
||||
|
||||
// Skip blocks outside the actual texture
|
||||
if block_x >= blocks_wide || block_y >= blocks_tall {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_offset = macro_base + local as usize * bpb;
|
||||
let dst_offset = (block_y * blocks_wide + block_x) as usize * bpb;
|
||||
|
||||
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
||||
dst[dst_offset..dst_offset + bpb]
|
||||
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
||||
}
|
||||
for by in 0..blocks_tall {
|
||||
for bx in 0..blocks_wide {
|
||||
let src_offset = tiled_2d_offset(bx as i32, by as i32, pitch_aligned, bpb_log2) as usize;
|
||||
let dst_offset = (by * blocks_wide + bx) as usize * bpb;
|
||||
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
||||
dst[dst_offset..dst_offset + bpb]
|
||||
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,11 +616,58 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detile_noop_for_1x1_block() {
|
||||
// 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];
|
||||
fn tiled_offset_origin_is_zero() {
|
||||
// Block (0,0) always maps to byte offset 0 for any pitch / bpb.
|
||||
assert_eq!(tiled_2d_offset(0, 0, 32, 3), 0);
|
||||
assert_eq!(tiled_2d_offset(0, 0, 64, 4), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detile_single_block_reads_offset_zero() {
|
||||
// A 4×4 DXT1 texture = 1 visible block, but Xenos pads the tiled
|
||||
// surface to 32×32 blocks (8192 bytes). Block (0,0) sits at offset 0,
|
||||
// so the de-tiled output equals the first 8 source bytes.
|
||||
let mut src = vec![0u8; 32 * 32 * 8];
|
||||
src[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
|
||||
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
|
||||
assert_eq!(result, src);
|
||||
assert_eq!(result, &src[..8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_bc_dwords_swaps_each_64bit_half() {
|
||||
// One 8-byte BC1 block: X360 stores it as [indices][endpoints]; the swap
|
||||
// must move the endpoint dword to the front so BC decoders find it.
|
||||
let mut one = vec![0, 1, 2, 3, 4, 5, 6, 7];
|
||||
swap_bc_block_dwords(&mut one);
|
||||
assert_eq!(one, vec![4, 5, 6, 7, 0, 1, 2, 3]);
|
||||
|
||||
// A 16-byte BC3 block = two independent 8-byte halves; each is swapped.
|
||||
let mut two: Vec<u8> = (0..16).collect();
|
||||
swap_bc_block_dwords(&mut two);
|
||||
assert_eq!(
|
||||
two,
|
||||
vec![4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11]
|
||||
);
|
||||
|
||||
// Applying it twice is the identity (it's its own inverse).
|
||||
let mut back = two.clone();
|
||||
swap_bc_block_dwords(&mut back);
|
||||
assert_eq!(back, (0..16).collect::<Vec<u8>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubemap_face_stride_and_labels() {
|
||||
// Acheron: 1024×1024 A8R8G8B8 → 1024*1024*4 = 4 MiB, already 4 KiB-aligned.
|
||||
// Verified against the real file: data_size == 6 × 0x400000.
|
||||
assert_eq!(
|
||||
tiled_face_stride(1024, 1024, X360TextureFormat::A8R8G8B8),
|
||||
0x400000
|
||||
);
|
||||
// A tiny surface still occupies a full 32×32-block tile, 4 KiB-aligned.
|
||||
assert_eq!(tiled_face_stride(4, 4, X360TextureFormat::A8R8G8B8), 0x1000);
|
||||
assert_eq!(Cubemap::face_label(0), "+X");
|
||||
assert_eq!(Cubemap::face_label(2), "+Y");
|
||||
assert_eq!(Cubemap::face_label(5), "-Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -108,14 +108,23 @@ impl GameAssets {
|
||||
/// Use this to identify unknown file types during reverse engineering.
|
||||
pub fn identify_format(bytes: &[u8]) -> FileFormat {
|
||||
if bytes.len() < 4 {
|
||||
return FileFormat::Unknown;
|
||||
// Very short files can still be text (e.g. a one-line config).
|
||||
return if is_probably_text(bytes) {
|
||||
FileFormat::Text
|
||||
} else {
|
||||
FileFormat::Unknown
|
||||
};
|
||||
}
|
||||
match &bytes[..4] {
|
||||
b"XPR2" => FileFormat::Xpr2Texture,
|
||||
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
|
||||
b"DDS " => FileFormat::Dds,
|
||||
// No binary magic matched — fall back to a content heuristic so text
|
||||
// configs (`config.ini`, etc.) are recognised even though they have no
|
||||
// signature.
|
||||
_ if is_probably_text(bytes) => FileFormat::Text,
|
||||
_ => FileFormat::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -127,6 +136,8 @@ pub enum FileFormat {
|
||||
XwbAudio,
|
||||
Png,
|
||||
Dds,
|
||||
/// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar.
|
||||
Text,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -138,7 +149,162 @@ impl FileFormat {
|
||||
Self::XwbAudio => "xwb",
|
||||
Self::Png => "png",
|
||||
Self::Dds => "dds",
|
||||
Self::Text => "txt",
|
||||
Self::Unknown => "bin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text detection & decoding (pure std, WASM-safe) ───────────────────────────
|
||||
|
||||
/// True if `b` is a byte we'd expect inside a text file: common whitespace,
|
||||
/// printable ASCII, or any high byte (UTF-8 continuation / Latin-1). Mirrors the
|
||||
/// printable convention used by the IDXD string-pool extractor.
|
||||
#[inline]
|
||||
fn is_text_byte(b: u8) -> bool {
|
||||
matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b) || b >= 0x80
|
||||
}
|
||||
|
||||
/// Heuristic sniff for whether `bytes` is human-readable text.
|
||||
///
|
||||
/// Recognises UTF-8 / UTF-16 BOMs outright, BOM-less UTF-16LE by its
|
||||
/// characteristic odd-position NUL bytes, and otherwise requires a byte sample
|
||||
/// to be almost entirely text bytes with no embedded NUL (NUL is the strongest
|
||||
/// binary signal). Decoding is left to [`decode_text`].
|
||||
pub fn is_probably_text(bytes: &[u8]) -> bool {
|
||||
if bytes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Byte-order marks are unambiguous.
|
||||
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
|
||||
|| bytes.starts_with(&[0xFF, 0xFE]) // UTF-16LE
|
||||
|| bytes.starts_with(&[0xFE, 0xFF])
|
||||
// UTF-16BE
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cap the scan so huge files stay cheap.
|
||||
let sample = &bytes[..bytes.len().min(4096)];
|
||||
|
||||
// BOM-less UTF-16LE: ASCII text encodes as `<char> 0x00 <char> 0x00 …`, so
|
||||
// roughly half the bytes (the odd positions) are NUL and the even bytes are
|
||||
// printable.
|
||||
if sample.len() >= 16 {
|
||||
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
|
||||
if nul_odd * 2 >= sample.len() / 2 {
|
||||
let printable_even = sample.iter().step_by(2).filter(|&&b| is_text_byte(b)).count();
|
||||
if printable_even * 2 >= sample.len() / 2 - 2 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-byte (ASCII / UTF-8): an embedded NUL means binary; otherwise
|
||||
// require the overwhelming majority to be text bytes.
|
||||
if sample.contains(&0) {
|
||||
return false;
|
||||
}
|
||||
let text = sample.iter().filter(|&&b| is_text_byte(b)).count();
|
||||
text * 100 >= sample.len() * 95
|
||||
}
|
||||
|
||||
/// Decode `bytes` to a `String`, returning `(text, encoding_label)`.
|
||||
///
|
||||
/// Honours UTF-8 / UTF-16LE / UTF-16BE BOMs and BOM-less UTF-16LE, falling back
|
||||
/// to lossy UTF-8. Never fails — undecodable sequences become U+FFFD. Pure std,
|
||||
/// so both the viewer and CLI can share it.
|
||||
pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return (String::from_utf8_lossy(rest).into_owned(), "UTF-8 (BOM)");
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return (decode_utf16(rest, false), "UTF-16LE (BOM)");
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return (decode_utf16(rest, true), "UTF-16BE (BOM)");
|
||||
}
|
||||
// BOM-less UTF-16LE detection (same signal as `is_probably_text`).
|
||||
let sample = &bytes[..bytes.len().min(4096)];
|
||||
if sample.len() >= 16 {
|
||||
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
|
||||
if nul_odd * 2 >= sample.len() / 2 {
|
||||
return (decode_utf16(bytes, false), "UTF-16LE");
|
||||
}
|
||||
}
|
||||
(String::from_utf8_lossy(bytes).into_owned(), "UTF-8 / ASCII")
|
||||
}
|
||||
|
||||
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
|
||||
let units: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| {
|
||||
if big_endian {
|
||||
u16::from_be_bytes([c[0], c[1]])
|
||||
} else {
|
||||
u16::from_le_bytes([c[0], c[1]])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_config_is_text() {
|
||||
let ini = b"[Video]\r\nWidth=1280\r\nHeight=720\r\n";
|
||||
assert!(is_probably_text(ini));
|
||||
assert_eq!(identify_format(ini), FileFormat::Text);
|
||||
let (text, enc) = decode_text(ini);
|
||||
assert!(text.contains("Width=1280"));
|
||||
assert_eq!(enc, "UTF-8 / ASCII");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_takes_precedence_over_text() {
|
||||
// An XPR2 header happens to start with printable bytes; the magic match
|
||||
// must win so it's never misfiled as text.
|
||||
let mut xpr = b"XPR2".to_vec();
|
||||
xpr.extend_from_slice(&[0u8; 32]);
|
||||
assert_eq!(identify_format(&xpr), FileFormat::Xpr2Texture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_with_nul_is_not_text() {
|
||||
let bin = [0u8, 1, 2, 0xFF, 0x00, 0x89, 0x10, 0x00, 0x42];
|
||||
assert!(!is_probably_text(&bin));
|
||||
assert_eq!(identify_format(&bin), FileFormat::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16le_bom_is_text_and_decodes() {
|
||||
let data = [0xFF, 0xFE, b'H', 0x00, b'i', 0x00];
|
||||
assert!(is_probably_text(&data));
|
||||
assert_eq!(identify_format(&data), FileFormat::Text);
|
||||
let (text, enc) = decode_text(&data);
|
||||
assert_eq!(text, "Hi");
|
||||
assert_eq!(enc, "UTF-16LE (BOM)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bomless_utf16le_detected() {
|
||||
// "Hello, world!!!!" as UTF-16LE, no BOM (>=16 bytes so the heuristic runs).
|
||||
let mut data = Vec::new();
|
||||
for &b in b"Hello, world!!!!" {
|
||||
data.push(b);
|
||||
data.push(0);
|
||||
}
|
||||
assert!(is_probably_text(&data));
|
||||
let (text, enc) = decode_text(&data);
|
||||
assert_eq!(text, "Hello, world!!!!");
|
||||
assert_eq!(enc, "UTF-16LE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_is_not_text() {
|
||||
assert!(!is_probably_text(&[]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||||
|
||||
/// Locate the extracted disc root, or `None` to skip.
|
||||
@@ -122,3 +123,139 @@ fn name_lookup_resolves_weapon_tbl() {
|
||||
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
|
||||
assert!(obj.count > 0);
|
||||
}
|
||||
|
||||
/// Real T8aD entries decode to sane RGBA surfaces, and a good fraction of the
|
||||
/// pack's T8aD entries are decodable (the RGBA-variant rule holds broadly).
|
||||
#[test]
|
||||
fn hangar_t8ad_textures_decode() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||
let (mut seen, mut decoded) = (0usize, 0usize);
|
||||
for e in arc.entries() {
|
||||
if e.comp_size > 4_000_000 {
|
||||
continue;
|
||||
}
|
||||
let Ok(p) = arc.read(e) else { continue };
|
||||
if !sylpheed_formats::t8ad::is_t8ad(&p) {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if let Some(img) = sylpheed_formats::t8ad::parse(&p) {
|
||||
decoded += 1;
|
||||
assert!((1..=4096).contains(&img.width) && (1..=4096).contains(&img.height));
|
||||
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
|
||||
}
|
||||
}
|
||||
assert!(seen > 100, "expected many T8aD, saw {seen}");
|
||||
// The RGBA rule should cover the large majority (≈85% disc-wide).
|
||||
assert!(decoded * 100 >= seen * 70, "decoded {decoded}/{seen}");
|
||||
}
|
||||
|
||||
/// A real LSTA sprite list yields inline T8aD frames.
|
||||
#[test]
|
||||
fn lsta_sprite_list_decodes() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||
let mut found = false;
|
||||
for e in arc.entries() {
|
||||
let Ok(p) = arc.read(e) else { continue };
|
||||
if !sylpheed_formats::lsta::is_lsta(&p) {
|
||||
continue;
|
||||
}
|
||||
if let Some(frames) = sylpheed_formats::lsta::parse(&p) {
|
||||
if !frames.is_empty() {
|
||||
found = true;
|
||||
assert!(frames.iter().all(|f| !f.rgba.is_empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found, "no decodable LSTA found");
|
||||
}
|
||||
|
||||
/// A real RATC bundle lists named children including a decodable T8aD.
|
||||
#[test]
|
||||
fn ratc_bundle_lists_children() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||
let mut ok = false;
|
||||
for e in arc.entries() {
|
||||
if e.comp_size > 4_000_000 {
|
||||
continue;
|
||||
}
|
||||
let Ok(p) = arc.read(e) else { continue };
|
||||
if !sylpheed_formats::ratc::is_ratc(&p) {
|
||||
continue;
|
||||
}
|
||||
let Some(kids) = sylpheed_formats::ratc::parse(&p) else { continue };
|
||||
let has_named = kids.iter().any(|c| c.name.contains('.'));
|
||||
let has_t8ad = kids
|
||||
.iter()
|
||||
.any(|c| c.kind == "T8aD" && sylpheed_formats::t8ad::parse(&p[c.offset..c.offset + c.size]).is_some());
|
||||
if !kids.is_empty() && has_named && has_t8ad {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(ok, "no RATC with named children + a decodable T8aD");
|
||||
}
|
||||
|
||||
/// The English movie subtitle track (an `IXUD` entry) decodes to timed cues.
|
||||
#[test]
|
||||
fn eng_movie_subtitle_track() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||||
// S04A's track — the longest inline English one (starts "Calm down!").
|
||||
let bytes = arc.read_by_hash(0x73d2_2a3b).expect("entry present").unwrap();
|
||||
let sub = sylpheed_formats::ixud::parse(&bytes).expect("IXUD parses as subtitle");
|
||||
assert!(sub.cues.len() > 50, "cues={}", sub.cues.len());
|
||||
assert!(!sub.is_reference_only());
|
||||
assert_eq!(sub.cues[0].text, "Calm down!");
|
||||
assert!((sub.cues[0].start - 9.4).abs() < 0.01);
|
||||
assert_eq!(sub.cues[0].end, Some(11.0));
|
||||
}
|
||||
|
||||
/// The subtitle tracks are genuinely localized: the same entry hash differs
|
||||
/// between the English and German packs.
|
||||
#[test]
|
||||
fn subtitles_are_localized() {
|
||||
skip_without_disc!(root);
|
||||
let eng = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||||
let deu = PakArchive::open(root.join("dat/movie/deu.pak")).unwrap();
|
||||
let e = eng.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
|
||||
let d = deu.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
|
||||
assert_ne!(e, d, "eng/deu subtitle payloads should differ");
|
||||
}
|
||||
|
||||
/// The English movie pack's embedded subtitle font parses to sane metadata.
|
||||
#[test]
|
||||
fn eng_movie_font_metadata() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||||
let bytes = arc.read_by_hash(0x5cd0_fca6).expect("entry present").unwrap();
|
||||
assert!(sylpheed_formats::font::is_font(&bytes));
|
||||
let info = sylpheed_formats::font::parse_info(&bytes).expect("font parses");
|
||||
assert!(info.glyphs > 0, "glyphs={}", info.glyphs);
|
||||
assert!(!info.family.is_empty(), "family should be readable");
|
||||
}
|
||||
|
||||
/// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's
|
||||
/// face 0 matches the (validated) 2D `from_xpr2` face-0 decode.
|
||||
#[test]
|
||||
fn acheron_world_cubemap_six_faces() {
|
||||
skip_without_disc!(root);
|
||||
let bytes = std::fs::read(root.join("hidden/resource3d/BG_Acheron.xpr")).unwrap();
|
||||
|
||||
let cube = X360Texture::cube_faces_from_xpr2(&bytes)
|
||||
.expect("cube decode ok")
|
||||
.expect("BG_Acheron is a TXCM cubemap");
|
||||
assert_eq!((cube.width, cube.height), (1024, 1024));
|
||||
assert_eq!(cube.faces.len(), 6);
|
||||
// A8R8G8B8 → 4 bytes/texel, de-tiled to width×height.
|
||||
for face in &cube.faces {
|
||||
assert_eq!(face.len(), 1024 * 1024 * 4);
|
||||
}
|
||||
// Face 0 must equal what the existing 2D path decodes (the green planet).
|
||||
let face0_2d = X360Texture::from_xpr2(&bytes).unwrap();
|
||||
assert!(face0_2d.is_cubemap);
|
||||
assert_eq!(cube.faces[0], face0_2d.data);
|
||||
}
|
||||
|
||||
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Integration test: run the XPR2 texture pipeline against REAL `.xpr` files
|
||||
//! read directly from the retail disc image, reproducing exactly what the
|
||||
//! viewer's texture-preview path does (`identify_format` → `X360Texture::
|
||||
//! from_xpr2`). Skipped unless `SYLPHEED_ISO` points at the disc (or the
|
||||
//! default dev path exists).
|
||||
//!
|
||||
//! Run: `cargo test -p sylpheed-formats --test texture_disc -- --ignored --nocapture`
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
use sylpheed_formats::vfs::identify_format;
|
||||
|
||||
fn iso_path() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_ISO") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = PathBuf::from(
|
||||
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
|
||||
);
|
||||
default.is_file().then_some(default)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires the retail ISO — set SYLPHEED_ISO"]
|
||||
async fn xpr_pipeline_over_disc_sample() {
|
||||
let Some(iso) = iso_path() else {
|
||||
eprintln!("SKIP: ISO not found (set SYLPHEED_ISO)");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut reader = sylpheed_formats::xiso::open_iso(&iso).await.unwrap();
|
||||
let all = reader.list_all_files().await.unwrap();
|
||||
let mut xprs: Vec<String> = all
|
||||
.into_iter()
|
||||
.filter(|f| f.to_lowercase().ends_with(".xpr"))
|
||||
.collect();
|
||||
xprs.sort();
|
||||
println!("found {} .xpr files", xprs.len());
|
||||
|
||||
// Sample across the set so we hit different texture sizes/formats.
|
||||
let sample: Vec<String> = xprs.iter().step_by((xprs.len() / 24).max(1)).cloned().collect();
|
||||
|
||||
let mut ok = 0usize;
|
||||
let mut fail = 0usize;
|
||||
let mut by_format: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
let mut nonxpr = 0usize;
|
||||
|
||||
// Optionally dump the sampled .xpr bytes so the CLI can export them to PNG
|
||||
// for visual de-tiling validation: `DUMP_XPR_DIR=/tmp/xpr cargo test …`.
|
||||
let dump_dir = std::env::var("DUMP_XPR_DIR").ok();
|
||||
if let Some(d) = &dump_dir {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
for path in &sample {
|
||||
let bytes = reader.read_file(path).await.unwrap();
|
||||
if let Some(d) = &dump_dir {
|
||||
let base = path.rsplit('/').next().unwrap();
|
||||
std::fs::write(format!("{d}/{base}"), &bytes).unwrap();
|
||||
}
|
||||
let fmt = identify_format(&bytes);
|
||||
let magic: String = bytes
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
|
||||
.collect();
|
||||
|
||||
if fmt != sylpheed_formats::vfs::FileFormat::Xpr2Texture {
|
||||
nonxpr += 1;
|
||||
println!(" [{path}] NOT XPR2 (magic {magic:?}, {} bytes)", bytes.len());
|
||||
continue;
|
||||
}
|
||||
|
||||
match X360Texture::from_xpr2(&bytes) {
|
||||
Ok(t) => {
|
||||
ok += 1;
|
||||
*by_format.entry(format!("{:?}", t.format)).or_default() += 1;
|
||||
// Sanity: does the decoded data length match the descriptor
|
||||
// dimensions (what the GPU upload will require)?
|
||||
let bs = t.format.block_size() as u32;
|
||||
let bw = ((t.width + bs - 1) / bs).max(1) as usize;
|
||||
let bh = ((t.height + bs - 1) / bs).max(1) as usize;
|
||||
let need = bw * bh * t.format.bytes_per_block();
|
||||
let size_ok = if need == t.data.len() { "ok" } else { "MISMATCH" };
|
||||
println!(
|
||||
" [{path}] {:?} {}x{} mips={} data={} need={} {size_ok}",
|
||||
t.format, t.width, t.height, t.mip_levels, t.data.len(), need
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
fail += 1;
|
||||
println!(" [{path}] from_xpr2 FAILED: {e}");
|
||||
dump_xpr2_structure(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nSUMMARY: ok={ok} fail={fail} non-xpr2={nonxpr} formats={by_format:?}");
|
||||
}
|
||||
|
||||
/// Dump the XPR2 header + resource directory the way `from_xpr2` reads it,
|
||||
/// so we can see why a file's TX2D scan comes up empty.
|
||||
fn dump_xpr2_structure(bytes: &[u8]) {
|
||||
let be = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||
let magic: String = bytes[..4].iter().map(|b| *b as char).collect();
|
||||
println!(
|
||||
" hdr magic={magic:?} header_size=0x{:X} data_size=0x{:X} num_resources={}",
|
||||
be(0x04),
|
||||
be(0x08),
|
||||
be(0x0C)
|
||||
);
|
||||
let n = be(0x0C).min(16); // guard against garbage counts
|
||||
for i in 0..n {
|
||||
let base = 0x10 + i as usize * 16;
|
||||
if base + 16 > bytes.len() {
|
||||
break;
|
||||
}
|
||||
let tag: String = bytes[base..base + 4]
|
||||
.iter()
|
||||
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
|
||||
.collect();
|
||||
println!(
|
||||
" res[{i}] tag={tag:?} data_off=0x{:X} desc_size=0x{:X} name_off=0x{:X}",
|
||||
be(base + 4),
|
||||
be(base + 8),
|
||||
be(base + 12)
|
||||
);
|
||||
}
|
||||
// First 48 bytes hex for orientation.
|
||||
let hx: String = bytes
|
||||
.iter()
|
||||
.take(48)
|
||||
.map(|b| format!("{b:02X} "))
|
||||
.collect();
|
||||
println!(" hex[0..48] {hx}");
|
||||
}
|
||||
@@ -50,3 +50,9 @@ dev = ["bevy/dynamic_linking"]
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tracing-subscriber = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
# Audio for the WMV video player (pulls cpal → alsa on Linux).
|
||||
rodio = { workspace = true }
|
||||
# PNG decode for pak image entries (pure Rust; Bevy already pulls it in).
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# Off-thread rasterization of embedded-font samples (avoids egui's global fonts).
|
||||
ab_glyph = "0.2"
|
||||
|
||||
@@ -93,8 +93,28 @@ impl AssetLoader for Xpr2TextureLoader {
|
||||
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
|
||||
let format = x360_format_to_wgpu(&tex.format)?;
|
||||
|
||||
// Uncompressed k_8_8_8_8: after `from_xpr2`'s k8in32 endian swap the bytes
|
||||
// are in [A,R,G,B] order (verified against the retail Acheron backdrop).
|
||||
// wgpu has no ARGB format, so reorder to [R,G,B,A] and upload as Rgba8
|
||||
// (see `x360_format_to_wgpu`). BCn data is already GPU-ready.
|
||||
let data = match tex.format {
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
let opaque = matches!(tex.format, X360TextureFormat::X8R8G8B8);
|
||||
let mut out = tex.data;
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
|
||||
px[0] = r;
|
||||
px[1] = g;
|
||||
px[2] = b;
|
||||
px[3] = if opaque { 0xFF } else { a };
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => tex.data,
|
||||
};
|
||||
|
||||
Ok(Image {
|
||||
data: tex.data,
|
||||
data,
|
||||
texture_descriptor: TextureDescriptor {
|
||||
label: None,
|
||||
size: Extent3d {
|
||||
@@ -132,9 +152,9 @@ fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2
|
||||
X360TextureFormat::Dxn => TextureFormat::Bc5RgUnorm,
|
||||
// DXT5A / BC4 — single-channel (gloss, specular, luminance maps)
|
||||
X360TextureFormat::Dxt5A => TextureFormat::Bc4RUnorm,
|
||||
// Uncompressed ARGB — Xbox 360 stores as BGRA in memory
|
||||
// Uncompressed — reordered to [R,G,B,A] by `x360_texture_to_bevy_image`.
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
TextureFormat::Bgra8UnormSrgb
|
||||
TextureFormat::Rgba8UnormSrgb
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,33 +28,16 @@ pub mod ui;
|
||||
/// Global viewer state, shared across all UI and rendering systems.
|
||||
#[derive(Resource)]
|
||||
pub struct ViewerState {
|
||||
/// Which type of asset is currently being browsed / previewed.
|
||||
pub current_mode: ViewMode,
|
||||
/// Whether to render meshes in wireframe mode (toggle with F2).
|
||||
pub wireframe: bool,
|
||||
/// Whether the plain-text viewer wraps long lines.
|
||||
pub text_wrap: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_mode: ViewMode::Textures,
|
||||
wireframe: false,
|
||||
}
|
||||
Self { text_wrap: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// The active view / asset type being inspected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ViewMode {
|
||||
/// XPR2 texture preview (Milestone 1).
|
||||
#[default]
|
||||
Textures,
|
||||
/// 3D mesh viewer (Milestone 2+).
|
||||
Mesh,
|
||||
/// Audio waveform / playback (Milestone 2+).
|
||||
Audio,
|
||||
}
|
||||
|
||||
// ── App builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build and run the viewer application.
|
||||
|
||||
@@ -10,10 +10,10 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
|
||||
IsoLoaderSystemSet,
|
||||
FileInfo, FileSelected, ImageRgba, IsoState, PakContent, PakView, RequestOpenDir,
|
||||
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::{ViewerState, ViewMode};
|
||||
use crate::ViewerState;
|
||||
|
||||
pub struct ViewerUiPlugin;
|
||||
|
||||
@@ -31,6 +31,96 @@ pub struct FileBrowserState {
|
||||
pub files: Vec<String>,
|
||||
pub selected: Option<usize>,
|
||||
pub filter: String,
|
||||
/// True from the moment a file is clicked until its content is applied —
|
||||
/// drives the "Loading…" indicator so the click registers immediately.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
// ── Directory tree ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
|
||||
/// list each frame; leaves carry their original index into `browser.files` so
|
||||
/// selection stays stable regardless of tree shape or filtering.
|
||||
#[derive(Default)]
|
||||
struct TreeDir {
|
||||
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
|
||||
dirs: std::collections::BTreeMap<String, TreeDir>,
|
||||
/// Leaf files: (display name, original index into `browser.files`).
|
||||
files: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
|
||||
/// data body, not standalone files — the `.pak` index is what the user opens, so
|
||||
/// they're hidden from the tree (their content loads when the `.pak` is opened).
|
||||
fn is_pak_segment(leaf: &str) -> bool {
|
||||
matches!(leaf.rsplit_once('.'), Some((_, ext))
|
||||
if ext.len() == 3
|
||||
&& ext.as_bytes()[0] == b'p'
|
||||
&& ext.as_bytes()[1].is_ascii_digit()
|
||||
&& ext.as_bytes()[2].is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Split a full path into (directory components, leaf name).
|
||||
fn split_for_tree(full: &str) -> (Vec<String>, String) {
|
||||
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
|
||||
let leaf = comps.pop().unwrap_or_default();
|
||||
(comps, leaf)
|
||||
}
|
||||
|
||||
/// Build the directory tree, keeping only leaves whose full path contains
|
||||
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
|
||||
/// segments are skipped — only their `.pak` index is shown.
|
||||
fn build_tree(files: &[String], filter: &str) -> TreeDir {
|
||||
let filter = filter.to_lowercase();
|
||||
let mut root = TreeDir::default();
|
||||
for (idx, full) in files.iter().enumerate() {
|
||||
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
|
||||
continue;
|
||||
}
|
||||
let (dirs, leaf) = split_for_tree(full);
|
||||
if is_pak_segment(&leaf) {
|
||||
continue;
|
||||
}
|
||||
let mut node = &mut root;
|
||||
for comp in dirs {
|
||||
node = node.dirs.entry(comp).or_default();
|
||||
}
|
||||
node.files.push((leaf, idx));
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Render a directory node recursively: sub-directories (collapsible) first,
|
||||
/// then leaf files as selectable labels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_dir(
|
||||
ui: &mut egui::Ui,
|
||||
node: &TreeDir,
|
||||
force_open: bool,
|
||||
selected: &mut Option<usize>,
|
||||
loading: &mut bool,
|
||||
events: &mut EventWriter<FileSelected>,
|
||||
files: &[String],
|
||||
) {
|
||||
for (name, child) in &node.dirs {
|
||||
egui::CollapsingHeader::new(format!("📁 {name}"))
|
||||
.id_salt(name)
|
||||
.default_open(force_open)
|
||||
.show(ui, |ui| {
|
||||
render_dir(ui, child, force_open, selected, loading, events, files);
|
||||
});
|
||||
}
|
||||
for (leaf, idx) in &node.files {
|
||||
let is_selected = *selected == Some(*idx);
|
||||
if ui
|
||||
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
|
||||
.clicked()
|
||||
{
|
||||
*selected = Some(*idx);
|
||||
*loading = true; // show the spinner until the content is applied
|
||||
events.send(FileSelected(files[*idx].clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
@@ -39,6 +129,10 @@ fn draw_viewer_ui(
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
iso_state: Res<IsoState>,
|
||||
preview: Res<TexturePreview>,
|
||||
text_preview: Res<TextPreview>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
skybox: Res<SkyboxPreview>,
|
||||
mut video: ResMut<VideoPreview>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||
@@ -72,29 +166,6 @@ fn draw_viewer_ui(
|
||||
);
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
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()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Mesh;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Audio;
|
||||
}
|
||||
ui.separator();
|
||||
ui.checkbox(&mut viewer.wireframe, "Wireframe (F2)");
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Controls…").clicked() {
|
||||
// TODO: controls popup
|
||||
@@ -138,53 +209,101 @@ fn draw_viewer_ui(
|
||||
or File → Open extracted folder.",
|
||||
);
|
||||
} else {
|
||||
let filter = browser.filter.to_lowercase();
|
||||
let filtered: Vec<(usize, String)> = browser
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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());
|
||||
if ui.add(label).clicked() {
|
||||
browser.selected = Some(*idx);
|
||||
file_selected_events.send(FileSelected(file.clone()));
|
||||
}
|
||||
}
|
||||
// Build a directory tree from the flat path list. Cheap to
|
||||
// rebuild every frame (a few hundred short paths); cloning
|
||||
// `files` first sidesteps borrowing `browser` immutably and
|
||||
// mutably (selected) at the same time.
|
||||
let files = browser.files.clone();
|
||||
let tree = build_tree(&files, &browser.filter);
|
||||
let force_open = !browser.filter.is_empty();
|
||||
let FileBrowserState {
|
||||
selected, loading, ..
|
||||
} = &mut *browser;
|
||||
render_dir(
|
||||
ui,
|
||||
&tree,
|
||||
force_open,
|
||||
selected,
|
||||
loading,
|
||||
&mut file_selected_events,
|
||||
&files,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// `horizontal_wrapped` so the bar wraps instead of overflowing the
|
||||
// window on narrow widths; the controls hint is right-aligned and only
|
||||
// the source's final path component is shown (full path on hover).
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if let Some(label) = &iso_state.source_label {
|
||||
ui.label(label);
|
||||
let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
|
||||
ui.label(short).on_hover_text(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"),
|
||||
};
|
||||
ui.separator();
|
||||
ui.label(format!("{} files", browser.files.len()));
|
||||
ui.separator();
|
||||
ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if let Some(egui_id) = preview.egui_id {
|
||||
if browser.loading {
|
||||
// A file was just clicked — show immediate feedback while the
|
||||
// background read + decode runs, instead of the previous item.
|
||||
let name = browser
|
||||
.selected
|
||||
.and_then(|i| browser.files.get(i))
|
||||
.map(|p| p.rsplit('/').next().unwrap_or(p))
|
||||
.unwrap_or("");
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(format!("Loading {name}…"));
|
||||
});
|
||||
});
|
||||
} else if video.active {
|
||||
draw_video_player(ui, &mut video);
|
||||
} else if !skybox.faces.is_empty() {
|
||||
draw_skybox_grid(ui, &skybox);
|
||||
} else if pak_view.loaded {
|
||||
draw_pak_browser(ui, &mut pak_view);
|
||||
} else if let Some(text) = &text_preview.content {
|
||||
// ── Plain-text viewer ──
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&file_info.name);
|
||||
ui.separator();
|
||||
ui.label(&text_preview.encoding);
|
||||
ui.separator();
|
||||
ui.checkbox(&mut viewer.text_wrap, "Wrap");
|
||||
if text_preview.truncated {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::both().show(ui, |ui| {
|
||||
// Read-only: `&str` is a non-editable TextBuffer, and
|
||||
// `.interactive(false)` keeps it selectable/copyable.
|
||||
let mut buf = text.as_str();
|
||||
let mut edit = egui::TextEdit::multiline(&mut buf)
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.interactive(false);
|
||||
if !viewer.text_wrap {
|
||||
// Don't wrap: let ScrollArea scroll horizontally.
|
||||
edit = edit.clip_text(false);
|
||||
}
|
||||
ui.add(edit);
|
||||
});
|
||||
} else if let Some(egui_id) = preview.egui_id {
|
||||
// Texture preview
|
||||
ui.heading(&file_info.name);
|
||||
ui.label(&preview.format_info);
|
||||
@@ -278,3 +397,549 @@ fn draw_viewer_ui(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
|
||||
|
||||
/// Master-detail view for an open `.pak`: entry list on the left, the selected
|
||||
/// entry's content on the right (IDXD table, subtitle cues, font sample, image,
|
||||
/// or text — whatever `classify_content` decoded off-thread).
|
||||
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&pak.name);
|
||||
ui.separator();
|
||||
ui.label(format!("{} entries", pak.rows.len()));
|
||||
});
|
||||
if let Some(err) = &pak.error {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::RED, format!("⚠ {err}"));
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// Destructure so the columns can read `rows` while mutating the selection +
|
||||
// the egui-side caches — without cloning the (now heavy) rows each frame.
|
||||
let PakView {
|
||||
rows,
|
||||
selected,
|
||||
img_tex,
|
||||
..
|
||||
} = pak;
|
||||
let mut new_selection = *selected;
|
||||
|
||||
ui.columns(2, |cols| {
|
||||
// Left — entry list.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pak_list")
|
||||
.show(&mut cols[0], |ui| {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let text = egui::RichText::new(format!(
|
||||
"{:08x} {:<7} {}",
|
||||
row.hash,
|
||||
row.format,
|
||||
row_kind(row)
|
||||
))
|
||||
.monospace();
|
||||
if ui
|
||||
.add(egui::SelectableLabel::new(*selected == Some(i), text))
|
||||
.clicked()
|
||||
{
|
||||
new_selection = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Right — detail of the selected entry, dispatched on decoded content.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pak_detail")
|
||||
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
|
||||
None => {
|
||||
ui.label("Select an entry to inspect.");
|
||||
}
|
||||
Some(row) => match &row.content {
|
||||
PakContent::Subtitle(sub) => draw_subtitle_detail(ui, row, sub),
|
||||
PakContent::Font { info, sample } => {
|
||||
draw_font_detail(ui, row, info, sample.as_ref(), img_tex)
|
||||
}
|
||||
PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
|
||||
PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
|
||||
PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
|
||||
PakContent::Ratc(children) => draw_ratc_detail(ui, row, children, img_tex),
|
||||
PakContent::Text { text, encoding } => {
|
||||
draw_text_detail(ui, row, text, encoding)
|
||||
}
|
||||
PakContent::None => draw_idxd_detail(ui, row),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
*selected = new_selection;
|
||||
}
|
||||
|
||||
/// Short kind hint for the entry list (subtitle/font/image/text), falling back
|
||||
/// to the IDXD identity.
|
||||
fn row_kind(row: &crate::iso_loader::PakRow) -> String {
|
||||
match &row.content {
|
||||
PakContent::Subtitle(s) => format!("subtitle · {} cues", s.cues.len()),
|
||||
PakContent::Font { info, .. } => {
|
||||
if info.family.is_empty() {
|
||||
"font".into()
|
||||
} else {
|
||||
info.family.clone()
|
||||
}
|
||||
}
|
||||
PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
|
||||
PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
|
||||
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
|
||||
PakContent::Ratc(c) => format!("RATC · {} item(s)", c.len()),
|
||||
PakContent::Text { .. } => "text".into(),
|
||||
PakContent::None => row.identity.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `mm:ss.SS` timestamp for subtitle cues.
|
||||
fn fmt_ts(secs: f32) -> String {
|
||||
let s = secs.max(0.0);
|
||||
format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0)
|
||||
}
|
||||
|
||||
/// IDXD object detail (or bare format/size for other un-presentable entries).
|
||||
fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
|
||||
match &row.detail {
|
||||
Some(d) => {
|
||||
let title = if row.identity.is_empty() {
|
||||
row.format.as_str()
|
||||
} else {
|
||||
row.identity.as_str()
|
||||
};
|
||||
ui.heading(title);
|
||||
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
|
||||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||||
ui.separator();
|
||||
if d.fields.is_empty() {
|
||||
ui.label("(no explicit-value fields)");
|
||||
} else {
|
||||
egui::Grid::new("pak_fields")
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
for (k, v) in &d.fields {
|
||||
ui.label(k);
|
||||
ui.monospace(v);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.heading(&row.format);
|
||||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||||
ui.separator();
|
||||
ui.label("No decoded preview for this format yet.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IXUD subtitle track: a timecode + text cue table.
|
||||
fn draw_subtitle_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
sub: &sylpheed_formats::ixud::Subtitle,
|
||||
) {
|
||||
ui.heading("Subtitle track");
|
||||
let note = if sub.is_reference_only() {
|
||||
" · reference-only (MSG_* keys)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
ui.label(format!(
|
||||
"IXUD · {} cues · hash {:08x}{note}",
|
||||
sub.cues.len(),
|
||||
row.hash
|
||||
));
|
||||
ui.separator();
|
||||
if sub.cues.is_empty() {
|
||||
ui.label("(empty — no cues)");
|
||||
return;
|
||||
}
|
||||
egui::Grid::new("subtitle_cues")
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Time");
|
||||
ui.strong("Text");
|
||||
ui.end_row();
|
||||
for c in &sub.cues {
|
||||
let t = match c.end {
|
||||
Some(e) => format!("{}–{}", fmt_ts(c.start), fmt_ts(e)),
|
||||
None => fmt_ts(c.start),
|
||||
};
|
||||
ui.monospace(t);
|
||||
ui.label(&c.text);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-entry egui texture cache: `(entry hash, one texture per shown image)`.
|
||||
type ImgCache = Option<(u32, Vec<egui::TextureHandle>)>;
|
||||
|
||||
/// Ensure `cache` holds egui textures for `imgs` under `hash`, rebuilding when
|
||||
/// the selected entry changes. All image content comes from off-thread decodes;
|
||||
/// this never touches egui's global font system.
|
||||
fn ensure_textures(ui: &egui::Ui, hash: u32, imgs: &[&ImageRgba], cache: &mut ImgCache) {
|
||||
if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
|
||||
let texs = imgs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, img)| {
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[img.width as usize, img.height as usize],
|
||||
&img.rgba,
|
||||
);
|
||||
ui.ctx().load_texture(
|
||||
format!("pak_img_{hash:08x}_{i}"),
|
||||
color,
|
||||
egui::TextureOptions::LINEAR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
*cache = Some((hash, texs));
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache + draw a single `ImageRgba`, scaled to fit the available width (capped
|
||||
/// by `max_scale`). Shared by the PNG / T8aD / font-sample views.
|
||||
fn show_cached_image(
|
||||
ui: &mut egui::Ui,
|
||||
hash: u32,
|
||||
img: &ImageRgba,
|
||||
cache: &mut ImgCache,
|
||||
max_scale: f32,
|
||||
) {
|
||||
ensure_textures(ui, hash, &[img], cache);
|
||||
if let Some(tex) = cache.as_ref().and_then(|(_, t)| t.first()) {
|
||||
let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale);
|
||||
let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale);
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[dw, dh],
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Embedded font: metadata + a pre-rasterized sample line (rendered off-thread
|
||||
/// with `ab_glyph`, shown as an image — no global egui font install).
|
||||
fn draw_font_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
info: &sylpheed_formats::FontInfo,
|
||||
sample: Option<&ImageRgba>,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(if info.family.is_empty() {
|
||||
"Font"
|
||||
} else {
|
||||
&info.family
|
||||
});
|
||||
ui.label(format!(
|
||||
"{} · {} face(s) · {} glyphs · hash {:08x}",
|
||||
info.kind, info.faces, info.glyphs, row.hash
|
||||
));
|
||||
ui.separator();
|
||||
match sample {
|
||||
Some(img) => {
|
||||
ui.label("Sample:");
|
||||
show_cached_image(ui, row.hash, img, img_tex, 1.0);
|
||||
}
|
||||
None => {
|
||||
ui.label("(no sample — font collection or unsupported outlines)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded PNG image, shown via a cached egui texture.
|
||||
fn draw_png_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
img: &ImageRgba,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(format!("PNG image {}×{}", img.width, img.height));
|
||||
ui.label(format!("hash {:08x}", row.hash));
|
||||
ui.separator();
|
||||
show_cached_image(ui, row.hash, img, img_tex, 2.0);
|
||||
}
|
||||
|
||||
/// A decoded T8aD 2D texture. Colours are not yet verified against the game.
|
||||
fn draw_t8ad_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
img: &ImageRgba,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(format!("T8aD 2D texture {}×{}", img.width, img.height));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("hash {:08x}", row.hash));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
show_cached_image(ui, row.hash, img, img_tex, 2.0);
|
||||
}
|
||||
|
||||
/// An LSTA sprite list — the inline T8aD frames in a grid.
|
||||
fn draw_lsta_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
frames: &[ImageRgba],
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("LSTA sprite list");
|
||||
ui.separator();
|
||||
ui.label(format!("{} sprite(s)", frames.len()));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
let refs: Vec<&ImageRgba> = frames.iter().collect();
|
||||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||||
const CELL: f32 = 150.0;
|
||||
egui::Grid::new("lsta_sprites")
|
||||
.num_columns(3)
|
||||
.spacing([10.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(i)) {
|
||||
let aspect = frame.height as f32 / frame.width.max(1) as f32;
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[CELL, CELL * aspect],
|
||||
)));
|
||||
}
|
||||
if (i + 1) % 3 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A RATC bundle — a list of its named children, with thumbnails for the
|
||||
/// decoded T8aD ones.
|
||||
fn draw_ratc_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
children: &[crate::iso_loader::RatcEntry],
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("RATC bundle");
|
||||
ui.separator();
|
||||
ui.label(format!("{} item(s)", children.len()));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
|
||||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||||
|
||||
let mut img_i = 0;
|
||||
for c in children {
|
||||
ui.horizontal(|ui| {
|
||||
if c.image.is_some() {
|
||||
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(img_i)) {
|
||||
let sz = tex.size_vec2();
|
||||
let scale = (48.0 / sz.y.max(1.0)).min(1.0);
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[sz.x * scale, sz.y * scale],
|
||||
)));
|
||||
}
|
||||
img_i += 1;
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
let name = if c.name.is_empty() { "(unnamed)" } else { &c.name };
|
||||
ui.strong(name);
|
||||
ui.weak(format!("{} · {} bytes", c.kind, c.size));
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-text / XML entry in a read-only monospace box.
|
||||
fn draw_text_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
text: &str,
|
||||
encoding: &str,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&row.format);
|
||||
ui.separator();
|
||||
ui.label(encoding);
|
||||
ui.separator();
|
||||
ui.label(format!("{} bytes", row.size));
|
||||
});
|
||||
ui.separator();
|
||||
let mut buf = text;
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.interactive(false),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Video player (WMV cutscenes) ──────────────────────────────────────────────
|
||||
|
||||
/// Format seconds as `mm:ss`.
|
||||
fn fmt_time(secs: f32) -> String {
|
||||
let s = secs.max(0.0) as u32;
|
||||
format!("{:02}:{:02}", s / 60, s % 60)
|
||||
}
|
||||
|
||||
/// Central-panel video player: the current frame plus a transport bar
|
||||
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
|
||||
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
|
||||
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
|
||||
/// `advance_video_playback`.
|
||||
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
|
||||
let (mut toggle_play, mut skip) = (false, 0.0_f32);
|
||||
ui.input_mut(|i| {
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
|
||||
toggle_play = true;
|
||||
}
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
|
||||
skip += 10.0;
|
||||
}
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
|
||||
skip -= 10.0;
|
||||
}
|
||||
});
|
||||
if toggle_play {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
if skip != 0.0 {
|
||||
let t = (video.position + skip).clamp(0.0, video.duration);
|
||||
video.position = t;
|
||||
video.scrub_request = Some(t); // instant preview via the grabber…
|
||||
video.seek_request = Some(t); // …then the stream restarts here
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&video.name);
|
||||
ui.separator();
|
||||
ui.label(format!("{}×{} · wmv3 / wmapro", video.width, video.height));
|
||||
if !video.has_audio {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::YELLOW, "no audio");
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// Reserve room for the transport row so the frame never pushes it off-screen.
|
||||
const CONTROLS_H: f32 = 34.0;
|
||||
let avail = ui.available_size();
|
||||
let frame_h = (avail.y - CONTROLS_H).max(0.0);
|
||||
let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
|
||||
let scale = (avail.x / w).min(frame_h / h);
|
||||
let (disp_w, disp_h) = (w * scale, h * scale);
|
||||
|
||||
if let Some(egui_id) = video.egui_id {
|
||||
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
// Click the frame to toggle play/pause.
|
||||
let resp = ui.add(
|
||||
egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if resp.clicked() {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Transport bar ──
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
ui.label(fmt_time(video.position));
|
||||
|
||||
// Timeline fills the space left after reserving room for the total-time
|
||||
// label and the volume control on the right. Dragging scrubs live (the
|
||||
// grabber shows the frame under the knob); release commits the seek.
|
||||
let reserved = 170.0;
|
||||
let tl_width = (ui.available_width() - reserved).max(80.0);
|
||||
ui.spacing_mut().slider_width = tl_width;
|
||||
let mut pos = video.position;
|
||||
let resp =
|
||||
ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
|
||||
if resp.changed() {
|
||||
video.position = pos; // knob + label follow immediately
|
||||
video.scrub_request = Some(pos);
|
||||
if resp.dragged() {
|
||||
video.scrubbing = true;
|
||||
} else {
|
||||
// A click on the track (no drag) → commit right away.
|
||||
video.seek_request = Some(pos);
|
||||
video.scrubbing = false;
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
video.seek_request = Some(pos);
|
||||
video.scrubbing = false;
|
||||
}
|
||||
ui.label(fmt_time(video.duration));
|
||||
|
||||
ui.separator();
|
||||
ui.label("🔊");
|
||||
ui.spacing_mut().slider_width = 90.0;
|
||||
ui.add_enabled(
|
||||
video.has_audio,
|
||||
egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
|
||||
|
||||
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
|
||||
/// interactive skybox is a follow-up — the face data + order are validated, but
|
||||
/// the wgpu cube-sampling handedness needs visual confirmation first.
|
||||
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("World cubemap");
|
||||
ui.separator();
|
||||
ui.label(&skybox.info);
|
||||
});
|
||||
ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z).");
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::both().show(ui, |ui| {
|
||||
const CELL: f32 = 240.0;
|
||||
egui::Grid::new("cube_faces")
|
||||
.num_columns(3)
|
||||
.spacing([10.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
for (i, face) in skybox.faces.iter().enumerate() {
|
||||
ui.vertical(|ui| {
|
||||
ui.strong(face.label);
|
||||
let aspect = face.height as f32 / face.width.max(1) as f32;
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
face.egui_id,
|
||||
[CELL, CELL * aspect],
|
||||
)));
|
||||
});
|
||||
if (i + 1) % 3 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
29
docs/re/INDEX.md
Normal file
29
docs/re/INDEX.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# RE knowledge index
|
||||
|
||||
Confidence: ✅ `CONFIRMED` · 🟡 `PROBABLE` · ❔ `HYPOTHESIS`. See [README](README.md).
|
||||
|
||||
Formats we've already reversed are, for now, **documented by their parser + disc round-trip
|
||||
tests** (the executable spec) rather than a prose file — the "Spec" column points there.
|
||||
Promote to a prose `structures/…md` file when a format needs behavioural notes beyond layout.
|
||||
|
||||
## Data structures / formats
|
||||
|
||||
| Format | Conf. | Spec (parser + tests) | Notes |
|
||||
|--------|-------|-----------------------|-------|
|
||||
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
|
||||
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
|
||||
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
|
||||
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) |
|
||||
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours unverified**; ~15% variants deferred |
|
||||
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
|
||||
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
||||
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||
|
||||
## Functions / code paths
|
||||
|
||||
_None documented yet — populated during the dynamic-RE phase._
|
||||
|
||||
| Function | Conf. | Reimpl. | Summary |
|
||||
|----------|-------|---------|---------|
|
||||
| — | — | — | — |
|
||||
88
docs/re/README.md
Normal file
88
docs/re/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Reverse-engineering knowledge base
|
||||
|
||||
This directory is the **spec-side** of the clean-room: it records what the original
|
||||
*Project Sylpheed* binary **does** (behaviour) and how its **data is laid out**, so that
|
||||
the Rust port can be implemented **from these specs** without re-deriving anything and
|
||||
without ever copying original code.
|
||||
|
||||
It exists to answer one question fast: *"do we already know how X works, and how sure are we?"*
|
||||
|
||||
---
|
||||
|
||||
## The one rule that matters
|
||||
|
||||
> **Never document a claim more confidently than the evidence supports, and never
|
||||
> paste original code here.**
|
||||
|
||||
A wrong-but-confident note is worse than no note: someone builds on it and the bug hides
|
||||
for weeks. Every entry therefore carries an explicit **confidence** and its **evidence**.
|
||||
This mirrors the project method — *measure the oracle, never infer; refute before believing.*
|
||||
|
||||
### Clean-room firewall
|
||||
|
||||
- ✅ Allowed: behaviour descriptions, field offsets/types, formulas, state machines,
|
||||
observed input→output pairs, and **references** to the original by address
|
||||
(`sub_821B68C0`) or to `xenia-rs/sylpheed.db`.
|
||||
- ❌ Forbidden here and in `crates/`: pasted decompiled C/C++ or verbatim disassembled
|
||||
function bodies presented as the thing to reimplement. Cite the address; describe the
|
||||
behaviour in your own words. Disassembly is a tool for *understanding*, not a source to copy.
|
||||
|
||||
---
|
||||
|
||||
## Confidence levels
|
||||
|
||||
| Level | Meaning | Bar to reach it |
|
||||
|-------|---------|-----------------|
|
||||
| `CONFIRMED` | Behaviour verified against ground truth. | ≥2 independent observations **or** one observation cross-checked against an oracle (canary framebuffer, a known-correct value, a second code path). |
|
||||
| `PROBABLE` | Strong single-source inference. | One clean observation, or an unambiguous static read of the disassembly. |
|
||||
| `HYPOTHESIS` | Educated guess, not yet tested. | Anything else. Must say what would confirm/refute it. |
|
||||
|
||||
**Promotion requires new evidence, not re-reading the old evidence.** A `HYPOTHESIS` that
|
||||
"looks right again" is still a `HYPOTHESIS`. Only an *independent* check promotes it.
|
||||
If evidence later contradicts an entry, **demote it and record the contradiction** — do
|
||||
not silently edit the conclusion.
|
||||
|
||||
---
|
||||
|
||||
## When to document
|
||||
|
||||
- **Right after** a function/structure crosses from `HYPOTHESIS` to at least `PROBABLE` —
|
||||
before moving to the next code path, so the knowledge isn't lost or re-derived.
|
||||
- **Whenever confidence changes** (up or down) — append to the Evidence log, don't overwrite.
|
||||
- **Not** while it's still a pure guess with no evidence — a one-liner in the relevant
|
||||
backlog/plan is enough until there's something to stand on.
|
||||
|
||||
## What to document
|
||||
|
||||
- **Functions/code paths** → `docs/re/functions/<name>.md` (one file per function or tight cluster).
|
||||
- **Data structures / formats** → `docs/re/structures/<name>.md`.
|
||||
- Keep the index in [`INDEX.md`](INDEX.md) (one line each: name · confidence · one-line summary).
|
||||
|
||||
Use the templates: [`_TEMPLATE.function.md`](_TEMPLATE.function.md),
|
||||
[`_TEMPLATE.structure.md`](_TEMPLATE.structure.md).
|
||||
|
||||
---
|
||||
|
||||
## How we find and confirm code paths (the toolchain)
|
||||
|
||||
Everything joins on the **guest virtual address (PC)** — code addresses are fixed by the
|
||||
XEX load, identical across our emulator and canary.
|
||||
|
||||
- **Static (cheap, try first):** `xenia-rs/sylpheed.db` (DuckDB: 25 481 functions, xrefs,
|
||||
strings, vtables, imports). Query with `xenia-rs/zq.py` — `zq.py grep <str>`,
|
||||
`zq.py xref <addr>`, `zq.py dis <lo> <hi>`, `zq.py fn <pc>`. Entry points are usually a
|
||||
**string** (`zq.py grep MSG_DEMO`) or an **import** (movie/XMA API) xref'd back to the loader.
|
||||
- **Dynamic (when static is ambiguous):** run `xenia-rs` with its probe suite —
|
||||
`--pc-probe` / `--audit-pc-probe-hex` (fires at block entry), `--mem-watch` (mid-block
|
||||
reads/writes of a VA), `--lr-trace` (call/return chains), `--trace-instructions`,
|
||||
`--dump-addr` (read guest memory). These already exist; prefer them over hacking canary.
|
||||
- **Oracle (correctness ground truth):** canary — the **Wine cross-build**
|
||||
`xenia-canary/build-cross/bin/Windows/Debug/xenia_canary.exe` (the native Linux ELF
|
||||
**crashes / does not run** — do not use it). This is the only emulator that reaches the
|
||||
in-game menu; our `xenia-rs` never got past the intro video. Use canary to *observe output*
|
||||
(capture its framebuffer for texture colours), not usually to instrument code — though its
|
||||
`build-cross` toolchain does compile, so small C++ probes + rebuild are possible when needed.
|
||||
Run **muted, one emulator process at a time**, point it at the real ISO (not the symlink).
|
||||
|
||||
> ⚠️ **VA-equality caveat:** join **code** by PC (fixed), but **never** assume a data VA
|
||||
> holds the same bytes across emulators — allocators differ. Compare data by content/layout.
|
||||
23
docs/re/_TEMPLATE.function.md
Normal file
23
docs/re/_TEMPLATE.function.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# `<function name / sub_XXXXXXXX>`
|
||||
|
||||
- **Address:** `0x________` (in `sylpheed.db`; `zq.py fn 0x________`)
|
||||
- **Confidence:** `HYPOTHESIS | PROBABLE | CONFIRMED`
|
||||
- **Reimplemented in:** `crates/…/…rs::fn` — or *not yet*
|
||||
- **Related:** other RE entries, `[[memory-slug]]`
|
||||
|
||||
## What it does
|
||||
One-paragraph behavioural summary, in your own words. No pasted disassembly.
|
||||
|
||||
## Signature / calling convention
|
||||
Inputs (registers/args + meaning), outputs, side effects (memory it writes, globals, imports it calls).
|
||||
|
||||
## Behaviour
|
||||
Step-by-step of the observable behaviour — the *spec* to implement from. Formulas, branches,
|
||||
state transitions. Cite addresses for detail (`the loop at 0x…`), don't transcribe code.
|
||||
|
||||
## Evidence log (append-only; newest last)
|
||||
- `YYYY-MM-DD` — *how we know*: static disasm read / `--lr-trace` from 0x… / canary output /
|
||||
N observations. What it established. → confidence set to `…`.
|
||||
|
||||
## Open questions / what would raise confidence
|
||||
The specific check that would confirm or refute the current claim.
|
||||
29
docs/re/_TEMPLATE.structure.md
Normal file
29
docs/re/_TEMPLATE.structure.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# `<structure / format name>`
|
||||
|
||||
- **Confidence:** `HYPOTHESIS | PROBABLE | CONFIRMED`
|
||||
- **Parser in:** `crates/sylpheed-formats/src/…rs` — or *not yet*
|
||||
- **Seen in:** which pak / asset / memory region
|
||||
- **Related:** other RE entries, `[[memory-slug]]`
|
||||
|
||||
## Layout
|
||||
```text
|
||||
offset size type name notes
|
||||
0x00 4 magic "____"
|
||||
0x__ 4 u32be …
|
||||
…
|
||||
```
|
||||
Endianness, alignment, variable-length rules, how child/section sizes are derived.
|
||||
|
||||
## Field semantics
|
||||
What each field *means* and its observed value range. Distinguish **confirmed** fields from
|
||||
**guessed** ones explicitly (a guessed field with a plausible value is still a guess).
|
||||
|
||||
## Coverage / limits
|
||||
What fraction of real instances this decodes; which variants are deferred and why.
|
||||
|
||||
## Evidence log (append-only; newest last)
|
||||
- `YYYY-MM-DD` — how established (forensic scan over N disc entries / round-trip test /
|
||||
oracle comparison). → confidence `…`.
|
||||
|
||||
## Open questions / what would raise confidence
|
||||
e.g. "colours unverified until compared against canary's framebuffer."
|
||||
38
docs/re/structures/texture-color-k8888.md
Normal file
38
docs/re/structures/texture-color-k8888.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures)
|
||||
|
||||
- **Confidence:** mixed — see per-claim tags below.
|
||||
- **Parser in:** `sylpheed-formats/src/t8ad.rs`, `src/texture.rs` (XPR2)
|
||||
- **Applies to:** T8aD 2D UI textures, XPR2 32bpp surfaces — the "colours look a bit odd" concern.
|
||||
- **Related:** [[reborn-dynamic-re-plan]], INDEX rows T8aD / XPR2.
|
||||
|
||||
## What this resolves
|
||||
Whether our decoded 32bpp textures use the right **sRGB-vs-linear** interpretation and the right
|
||||
**channel order**. Ground truth = Canary's Vulkan texture cache, which is the code that actually
|
||||
produces correct on-screen colour.
|
||||
|
||||
## Findings
|
||||
|
||||
### ✅ CONFIRMED — plain `k_8_8_8_8` is LINEAR (UNORM), never sRGB
|
||||
Canary's host-format table maps plain `k_8_8_8_8` → `VK_FORMAT_R8G8B8A8_UNORM` with a straight
|
||||
32-bit copy load shader (`kLoadShaderIndex32bpb`) and an **identity** format swizzle
|
||||
(`XE_GPU_TEXTURE_SWIZZLE_RGBA`). There is **no** `..._SRGB` host format anywhere in the table;
|
||||
gamma is a separate explicit path (`k_8_8_8_8_GAMMA_EDRAM` only). Therefore a plain 32bpp texture
|
||||
is sampled as **raw UNORM bytes — apply no sRGB/gamma decode.**
|
||||
→ *Implication:* if the viewer/engine applies an sRGB→linear (or linear→sRGB) step to these, that is
|
||||
wrong. Show the bytes as-is (raw RGBA8), gamma only where the game explicitly requests it.
|
||||
|
||||
### ❔ HYPOTHESIS — exact source **channel order** (needs measurement, do not infer)
|
||||
Our decoders currently assume on-disk **A8R8G8B8** and reorder `[a,r,g,b] → [r,g,b,a]`. Canary
|
||||
proves the *host* target is R8G8B8A8 identity-swizzled, but the *guest→host* byte order also depends
|
||||
on the texture's runtime **endian** field (e.g. `8in32`), which is set by the game at fetch time and
|
||||
**cannot be read statically** from the format table. So whether our reorder matches is unverified.
|
||||
→ *What would confirm/refute it:* one **measured** decoded texel — either capture Canary's
|
||||
framebuffer over a texture with distinct R≠G≠B pixels of known colour, or patch Canary's already-
|
||||
present-but-unwired `TextureDump` (`src/xenia/gpu/texture_dump.cc`) to dump the decoded texture and
|
||||
diff bytes against our decode. **Measure, don't guess** — a symmetric colour (white logo) can't
|
||||
disambiguate channel order.
|
||||
|
||||
## Evidence log
|
||||
- `2026-07-11` — static read of Canary `src/xenia/gpu/vulkan/vulkan_texture_cache.cc` host-format
|
||||
table + confirmed no `R8G8B8A8_SRGB` entry exists. → sRGB/UNORM claim `CONFIRMED`; channel-order
|
||||
claim remains `HYPOTHESIS` pending a measured texel.
|
||||
Reference in New Issue
Block a user