From 86da451bfee4ddb08073cf0f198d1c007dd49c41 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 13 Sep 2026 19:31:49 +0200 Subject: [PATCH] wip: extract the xexdb tool closure --- Cargo.lock | 763 +++++- Cargo.toml | 3 + crates/sylpheed-ppc/Cargo.toml | 10 + .../examples/decode_table_check.rs | 37 + crates/sylpheed-ppc/src/decoder.rs | 1245 ++++++++++ crates/sylpheed-ppc/src/disasm.rs | 2128 +++++++++++++++++ crates/sylpheed-ppc/src/lib.rs | 12 + crates/sylpheed-ppc/src/opcode.rs | 308 +++ crates/sylpheed-xex/Cargo.toml | 15 + crates/sylpheed-xex/src/header.rs | 139 ++ crates/sylpheed-xex/src/lib.rs | 16 + crates/sylpheed-xex/src/loader.rs | 591 +++++ crates/sylpheed-xex/src/lzx.rs | 692 ++++++ crates/sylpheed-xex/src/pdata.rs | 219 ++ crates/sylpheed-xex/src/pe.rs | 68 + crates/sylpheed-xex/src/resources.rs | 127 + crates/sylpheed-xex/src/tls.rs | 172 ++ crates/sylpheed-xex/src/vfs/device.rs | 58 + crates/sylpheed-xex/src/vfs/disc_image.rs | 343 +++ crates/sylpheed-xex/src/vfs/mod.rs | 43 + crates/sylpheed-xexdb/Cargo.toml | 23 + crates/sylpheed-xexdb/SCHEMA.md | 570 +++++ crates/sylpheed-xexdb/build.rs | 87 + crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs | 825 +++++++ crates/sylpheed-xexdb/src/db.rs | 1957 +++++++++++++++ crates/sylpheed-xexdb/src/demangle.rs | 376 +++ crates/sylpheed-xexdb/src/disasm.rs | 154 ++ crates/sylpheed-xexdb/src/eh_scope.rs | 296 +++ crates/sylpheed-xexdb/src/formatter.rs | 281 +++ crates/sylpheed-xexdb/src/func.rs | 714 ++++++ crates/sylpheed-xexdb/src/funcptr_arrays.rs | 257 ++ .../sylpheed-xexdb/src/ind_dispatch_typed.rs | 711 ++++++ crates/sylpheed-xexdb/src/indirect.rs | 474 ++++ crates/sylpheed-xexdb/src/jumptables.rs | 758 ++++++ crates/sylpheed-xexdb/src/lib.rs | 26 + crates/sylpheed-xexdb/src/lookup.rs | 222 ++ crates/sylpheed-xexdb/src/ordinals.rs | 1 + crates/sylpheed-xexdb/src/ppc.rs | 28 + crates/sylpheed-xexdb/src/rtti.rs | 453 ++++ crates/sylpheed-xexdb/src/sinks/duckdb.rs | 39 + crates/sylpheed-xexdb/src/sinks/json.rs | 65 + crates/sylpheed-xexdb/src/sinks/mod.rs | 8 + crates/sylpheed-xexdb/src/sinks/text.rs | 71 + crates/sylpheed-xexdb/src/sql_views.rs | 285 +++ crates/sylpheed-xexdb/src/static_init.rs | 399 ++++ crates/sylpheed-xexdb/src/strings.rs | 479 ++++ crates/sylpheed-xexdb/src/vtables.rs | 841 +++++++ crates/sylpheed-xexdb/src/xdbf.rs | 450 ++++ crates/sylpheed-xexdb/src/xref.rs | 563 +++++ .../sylpheed-xexdb/tests/db_schema_golden.rs | 450 ++++ crates/sylpheed-xexdb/tests/disasm_goldens.rs | 123 + tools/zq.py | 216 ++ 52 files changed, 19190 insertions(+), 1 deletion(-) create mode 100644 crates/sylpheed-ppc/Cargo.toml create mode 100644 crates/sylpheed-ppc/examples/decode_table_check.rs create mode 100644 crates/sylpheed-ppc/src/decoder.rs create mode 100644 crates/sylpheed-ppc/src/disasm.rs create mode 100644 crates/sylpheed-ppc/src/lib.rs create mode 100644 crates/sylpheed-ppc/src/opcode.rs create mode 100644 crates/sylpheed-xex/Cargo.toml create mode 100644 crates/sylpheed-xex/src/header.rs create mode 100644 crates/sylpheed-xex/src/lib.rs create mode 100644 crates/sylpheed-xex/src/loader.rs create mode 100644 crates/sylpheed-xex/src/lzx.rs create mode 100644 crates/sylpheed-xex/src/pdata.rs create mode 100644 crates/sylpheed-xex/src/pe.rs create mode 100644 crates/sylpheed-xex/src/resources.rs create mode 100644 crates/sylpheed-xex/src/tls.rs create mode 100644 crates/sylpheed-xex/src/vfs/device.rs create mode 100644 crates/sylpheed-xex/src/vfs/disc_image.rs create mode 100644 crates/sylpheed-xex/src/vfs/mod.rs create mode 100644 crates/sylpheed-xexdb/Cargo.toml create mode 100644 crates/sylpheed-xexdb/SCHEMA.md create mode 100644 crates/sylpheed-xexdb/build.rs create mode 100644 crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs create mode 100644 crates/sylpheed-xexdb/src/db.rs create mode 100644 crates/sylpheed-xexdb/src/demangle.rs create mode 100644 crates/sylpheed-xexdb/src/disasm.rs create mode 100644 crates/sylpheed-xexdb/src/eh_scope.rs create mode 100644 crates/sylpheed-xexdb/src/formatter.rs create mode 100644 crates/sylpheed-xexdb/src/func.rs create mode 100644 crates/sylpheed-xexdb/src/funcptr_arrays.rs create mode 100644 crates/sylpheed-xexdb/src/ind_dispatch_typed.rs create mode 100644 crates/sylpheed-xexdb/src/indirect.rs create mode 100644 crates/sylpheed-xexdb/src/jumptables.rs create mode 100644 crates/sylpheed-xexdb/src/lib.rs create mode 100644 crates/sylpheed-xexdb/src/lookup.rs create mode 100644 crates/sylpheed-xexdb/src/ordinals.rs create mode 100644 crates/sylpheed-xexdb/src/ppc.rs create mode 100644 crates/sylpheed-xexdb/src/rtti.rs create mode 100644 crates/sylpheed-xexdb/src/sinks/duckdb.rs create mode 100644 crates/sylpheed-xexdb/src/sinks/json.rs create mode 100644 crates/sylpheed-xexdb/src/sinks/mod.rs create mode 100644 crates/sylpheed-xexdb/src/sinks/text.rs create mode 100644 crates/sylpheed-xexdb/src/sql_views.rs create mode 100644 crates/sylpheed-xexdb/src/static_init.rs create mode 100644 crates/sylpheed-xexdb/src/strings.rs create mode 100644 crates/sylpheed-xexdb/src/vtables.rs create mode 100644 crates/sylpheed-xexdb/src/xdbf.rs create mode 100644 crates/sylpheed-xexdb/src/xref.rs create mode 100644 crates/sylpheed-xexdb/tests/db_schema_golden.rs create mode 100644 crates/sylpheed-xexdb/tests/disasm_goldens.rs create mode 100755 tools/zq.py diff --git a/Cargo.lock b/Cargo.lock index 2c962ed3..7bbf4e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -239,6 +250,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arbitrary-int" version = "1.3.0" @@ -283,6 +303,169 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" @@ -490,6 +673,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -518,6 +710,18 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bevy" version = "0.15.3" @@ -1504,6 +1708,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.57" @@ -1549,6 +1759,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "ciso" version = "0.2.1" @@ -1664,6 +1895,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1917,6 +2159,28 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1962,6 +2226,17 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -2058,6 +2333,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "duckdb" +version = "1.10505.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970e05eedd3f55c435194d9104f90a9b4a79a80d6e73251bc9ff43e178130c4e" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "strum", +] + [[package]] name = "ecolor" version = "0.29.1" @@ -2251,6 +2543,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -2286,6 +2590,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2312,6 +2626,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2612,6 +2927,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -2641,6 +2957,21 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -2682,6 +3013,46 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.58.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -2830,6 +3201,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3006,12 +3386,86 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libduckdb-sys" +version = "1.10505.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb514dab5e271e849235c1cb98bd65a2ae107fbd619a6740219319c54a71d95" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "serde", + "serde_json", + "tar", + "ureq", + "vcpkg", + "zip", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3165,6 +3619,16 @@ dependencies = [ "paste", ] +[[package]] +name = "metrics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" +dependencies = [ + "ahash", + "portable-atomic", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3202,6 +3666,16 @@ dependencies = [ "pxfm", ] +[[package]] +name = "msvc-demangler" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeff6bd154a309b2ada5639b2661ca6ae4599b34e8487dc276d2cd637da2d76" +dependencies = [ + "bitflags 2.11.0", + "itoa", +] + [[package]] name = "naga" version = "23.1.0" @@ -3353,6 +3827,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -3364,6 +3857,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4233,6 +4735,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rodio" version = "0.20.1" @@ -4252,7 +4768,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "base64", + "base64 0.21.7", "bitflags 2.11.0", "serde", "serde_derive", @@ -4305,6 +4821,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4320,6 +4871,12 @@ dependencies = [ "twox-hash 1.6.3", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4604,6 +5161,33 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "sylpheed-cli" version = "0.1.0" @@ -4669,6 +5253,15 @@ dependencies = [ "xdvdfs", ] +[[package]] +name = "sylpheed-ppc" +version = "0.1.0" +dependencies = [ + "bitflags 2.11.0", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "sylpheed-viewer" version = "0.1.0" @@ -4687,6 +5280,38 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "sylpheed-xex" +version = "0.1.0" +dependencies = [ + "aes", + "anyhow", + "byteorder", + "metrics", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "sylpheed-xexdb" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "duckdb", + "encoding_rs", + "metrics", + "msvc-demangler", + "serde", + "serde_json", + "sylpheed-ppc", + "sylpheed-xex", + "tracing", + "tracing-subscriber", +] + [[package]] name = "symphonia" version = "0.5.5" @@ -4769,6 +5394,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5171,6 +5807,40 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5190,6 +5860,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5218,6 +5894,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -5467,6 +6149,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5579,6 +6270,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5588,6 +6295,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.54.0" @@ -6098,6 +6811,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -6285,6 +7008,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.3" @@ -6318,12 +7047,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index ea7604ff..b1581e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ members = [ "crates/sylpheed-viewer", "crates/sylpheed-cli", "crates/sylpheed-export", + "crates/sylpheed-xex", + "crates/sylpheed-ppc", + "crates/sylpheed-xexdb", ] resolver = "2" diff --git a/crates/sylpheed-ppc/Cargo.toml b/crates/sylpheed-ppc/Cargo.toml new file mode 100644 index 00000000..bdf7992c --- /dev/null +++ b/crates/sylpheed-ppc/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sylpheed-ppc" +version = "0.1.0" +edition = "2024" +description = "PowerPC decode and disassembly, for static analysis" + +[dependencies] +tracing = "0.1" +bitflags = "2" +thiserror = "1" diff --git a/crates/sylpheed-ppc/examples/decode_table_check.rs b/crates/sylpheed-ppc/examples/decode_table_check.rs new file mode 100644 index 00000000..1328eb27 --- /dev/null +++ b/crates/sylpheed-ppc/examples/decode_table_check.rs @@ -0,0 +1,37 @@ +//! Cross-check our decoder against xenia-canary's authoritative encoding table. +//! +//! Canary's `ppc_opcode_table_gen.cc` lists, for every opcode it knows, a +//! representative instruction word with the operand fields zeroed. Feeding each +//! word to our decoder must yield the matching opcode — anything else is a hole +//! or a mis-decode in our tables. +//! +//! ```text +//! cargo run --release -p xenia-cpu --example decode_table_check -- +//! ``` +//! where each line is `0xWORD name`. +use std::io::BufRead; + +fn main() -> Result<(), Box> { + let path = std::env::args().nth(1).ok_or("usage: decode_table_check ")?; + let f = std::io::BufReader::new(std::fs::File::open(path)?); + let (mut ok, mut bad, mut invalid) = (0u32, 0u32, 0u32); + for line in f.lines() { + let line = line?; + let mut it = line.split_whitespace(); + let (Some(w), Some(name)) = (it.next(), it.next()) else { continue }; + let word = u32::from_str_radix(w.trim_start_matches("0x"), 16)?; + let d = sylpheed_ppc::decoder::decode(word, 0x8200_0000); + let got = format!("{:?}", d.opcode); + if got == name { + ok += 1; + } else if got == "Invalid" { + invalid += 1; + println!("MISSING {w} {name:<14} -> Invalid"); + } else { + bad += 1; + println!("MISMATCH {w} {name:<14} -> {got}"); + } + } + println!("\nmatched {ok}, mismatched {bad}, missing {invalid}"); + Ok(()) +} diff --git a/crates/sylpheed-ppc/src/decoder.rs b/crates/sylpheed-ppc/src/decoder.rs new file mode 100644 index 00000000..e29b6d2a --- /dev/null +++ b/crates/sylpheed-ppc/src/decoder.rs @@ -0,0 +1,1245 @@ +use crate::opcode::PpcOpcode; + +/// Extract bits [a..=b] from a 32-bit value (PPC bit numbering: 0 = MSB). +#[inline(always)] +const fn extract_bits(v: u32, a: u32, b: u32) -> u32 { + (v >> (32 - 1 - b)) & ((1 << (b - a + 1)) - 1) +} + +/// Decoded PPC instruction with extracted operand fields. +#[derive(Debug, Clone, Copy)] +pub struct DecodedInstr { + pub opcode: PpcOpcode, + pub raw: u32, + pub addr: u32, +} + +impl DecodedInstr { + // Common field extractors (PPC bit numbering) + + /// Primary opcode (bits 0-5) + #[inline] pub fn op(&self) -> u32 { extract_bits(self.raw, 0, 5) } + + /// rD/rS/rT (bits 6-10) - destination/source register + #[inline] pub fn rd(&self) -> usize { extract_bits(self.raw, 6, 10) as usize } + #[inline] pub fn rs(&self) -> usize { self.rd() } + #[inline] pub fn rt(&self) -> usize { self.rd() } + + /// rA (bits 11-15) + #[inline] pub fn ra(&self) -> usize { extract_bits(self.raw, 11, 15) as usize } + + /// rB (bits 16-20) + #[inline] pub fn rb(&self) -> usize { extract_bits(self.raw, 16, 20) as usize } + + /// rC (bits 21-25) - for 4-operand instructions + #[inline] pub fn rc(&self) -> usize { extract_bits(self.raw, 21, 25) as usize } + + /// SIMM/UIMM (bits 16-31) - signed/unsigned immediate + #[inline] pub fn simm16(&self) -> i16 { (self.raw & 0xFFFF) as i16 } + #[inline] pub fn uimm16(&self) -> u16 { (self.raw & 0xFFFF) as u16 } + + /// D-form displacement (signed, bits 16-31) + #[inline] pub fn d(&self) -> i32 { self.simm16() as i32 } + + /// DS-form displacement (signed, bits 16-29, shifted left 2) + #[inline] pub fn ds(&self) -> i32 { (self.raw & 0xFFFC) as i16 as i32 } + + /// LI field for branch (bits 6-29, sign-extended, shifted left 2) + #[inline] pub fn li(&self) -> i32 { + let li = extract_bits(self.raw, 6, 29); + // Sign-extend from 24 bits, then shift left 2 + let sign_extended = ((li as i32) << 8) >> 8; + sign_extended << 2 + } + + /// BD field for conditional branch (bits 16-29, sign-extended, shifted left 2) + #[inline] pub fn bd(&self) -> i32 { + let bd = extract_bits(self.raw, 16, 29); + let sign_extended = ((bd as i32) << 18) >> 18; + sign_extended << 2 + } + + /// BO field (bits 6-10) - branch options + #[inline] pub fn bo(&self) -> u32 { extract_bits(self.raw, 6, 10) } + + /// BI field (bits 11-15) - branch condition + #[inline] pub fn bi(&self) -> u32 { extract_bits(self.raw, 11, 15) } + + /// AA bit (bit 30) - absolute address + #[inline] pub fn aa(&self) -> bool { (self.raw >> 1) & 1 != 0 } + + /// LK bit (bit 31) - link (update LR) + #[inline] pub fn lk(&self) -> bool { self.raw & 1 != 0 } + + /// Rc bit (bit 31) - record CR0 + #[inline] pub fn rc_bit(&self) -> bool { self.raw & 1 != 0 } + + /// Rc for VC-form vector compare instructions — PPC bit 21 = host bit 10. + #[inline] pub fn vc_rc_bit(&self) -> bool { (self.raw >> 10) & 1 != 0 } + /// Rc for VX128_R-form vector compare instructions — PPC bit 27 = host bit 4. + /// VX128_R Rc bit — PPC bit 25 (host bit 6) per canary's FormatVX128_R + /// bitfield layout. PPCBUG-700. + #[inline] pub fn vx128r_rc_bit(&self) -> bool { (self.raw >> 6) & 1 != 0 } + + /// IMM field for VX128_4-form instructions (vrlimi128) — 5-bit blend mask at PPC bits 11-15. + #[inline] pub fn vx128_4_imm(&self) -> u32 { extract_bits(self.raw, 11, 15) } + /// z field for VX128_4-form instructions (vrlimi128) — 2-bit rotation index at PPC bits 24-25. + #[inline] pub fn vx128_4_z(&self) -> u32 { extract_bits(self.raw, 24, 25) } + + /// OE bit (bit 21) - overflow enable + #[inline] pub fn oe(&self) -> bool { extract_bits(self.raw, 21, 21) != 0 } + + /// TO field (bits 6-10) for tw/twi/td/tdi trap instructions. + #[inline] pub fn to(&self) -> u32 { extract_bits(self.raw, 6, 10) } + + /// MB, ME fields for rotate instructions + #[inline] pub fn mb(&self) -> u32 { extract_bits(self.raw, 21, 25) } + #[inline] pub fn me(&self) -> u32 { extract_bits(self.raw, 26, 30) } + + /// SH field (bits 16-20) for shift instructions + #[inline] pub fn sh(&self) -> u32 { extract_bits(self.raw, 16, 20) } + + /// SH field for 64-bit shifts (bits 16-20 + bit 30) + #[inline] pub fn sh64(&self) -> u32 { + (extract_bits(self.raw, 30, 30) << 5) | extract_bits(self.raw, 16, 20) + } + + /// MB/ME field for MD-form and MDS-form instructions (6-bit field, split encoding). + /// MB[4:0] at PPC bits 21-25; MB[5] at PPC bit 26. + #[inline] pub fn mb_md(&self) -> u32 { + extract_bits(self.raw, 21, 25) | (extract_bits(self.raw, 26, 26) << 5) + } + + /// SPR field (bits 11-20, swapped halves) + #[inline] pub fn spr(&self) -> u32 { + let spr_raw = extract_bits(self.raw, 11, 20); + ((spr_raw & 0x1F) << 5) | ((spr_raw >> 5) & 0x1F) + } + + /// CRM field (bits 12-19) for mtcrf + #[inline] pub fn crm(&self) -> u32 { extract_bits(self.raw, 12, 19) } + + /// crfD (bits 6-8) - condition register field destination + #[inline] pub fn crfd(&self) -> usize { extract_bits(self.raw, 6, 8) as usize } + + /// crfS (bits 11-13) + #[inline] pub fn crfs(&self) -> usize { extract_bits(self.raw, 11, 13) as usize } + + /// L bit (bit 10) - 64-bit compare + #[inline] pub fn l(&self) -> bool { extract_bits(self.raw, 10, 10) != 0 } + + /// crbD (bits 6-10) + #[inline] pub fn crbd(&self) -> u32 { extract_bits(self.raw, 6, 10) } + /// crbA (bits 11-15) + #[inline] pub fn crba(&self) -> u32 { extract_bits(self.raw, 11, 15) } + /// crbB (bits 16-20) + #[inline] pub fn crbb(&self) -> u32 { extract_bits(self.raw, 16, 20) } + + // VMX128 field extractors — bit positions match canary's + // FormatVX128/VX128_2/VX128_4/VX128_5/VX128_R bitfield layout + // (xenia-canary `ppc_decode_data.h:484-663`, LSB-first packed). PPCBUG-700. + + /// VA128 = VA128l(5) | VA128h(1) << 5 | VA128H(1) << 6. + /// Canonical 7-bit register selector: PPC 11-15 (low), PPC 26 (mid), PPC 21 (high). + #[inline] pub fn va128(&self) -> usize { + (extract_bits(self.raw, 11, 15) + | (extract_bits(self.raw, 26, 26) << 5) + | (extract_bits(self.raw, 21, 21) << 6)) as usize + } + + /// VB128 = VB128l(5) | VB128h(2) << 5. Canary's VB128h is a 2-bit + /// contiguous field at PPC 30-31 (host bits 0-1). + #[inline] pub fn vb128(&self) -> usize { + (extract_bits(self.raw, 16, 20) + | (extract_bits(self.raw, 30, 31) << 5)) as usize + } + + /// VD128 = VD128l(5) | VD128h(2) << 5. Canary's VD128h is a 2-bit + /// contiguous field at PPC 28-29 (host bits 2-3). + #[inline] pub fn vd128(&self) -> usize { + (extract_bits(self.raw, 6, 10) + | (extract_bits(self.raw, 28, 29) << 5)) as usize + } + + /// VS128 - same encoding as VD128 + #[inline] pub fn vs128(&self) -> usize { self.vd128() } + + /// VC register for VX128_2-form instructions (vperm128) — 3-bit at PPC bits 23-25. + #[inline] pub fn vc128_2(&self) -> usize { extract_bits(self.raw, 23, 25) as usize } + + /// NB field (bits 16-20) for lswi/stswi + #[inline] pub fn nb(&self) -> u32 { extract_bits(self.raw, 16, 20) } + + /// PERM field for VX128_P-form instructions (vpermwi128) — 8-bit split encoding. + /// PERMl (5 bits) at PPC bits 11-15; PERMh (3 bits) at PPC bits 23-25. + #[inline] pub fn vx128_p_perm(&self) -> u32 { + extract_bits(self.raw, 11, 15) | (extract_bits(self.raw, 23, 25) << 5) + } + + /// SH field for VX128_5-form instructions (vsldoi128) — 4-bit shift at PPC bits 22-25. + #[inline] pub fn vx128_5_sh(&self) -> u32 { extract_bits(self.raw, 22, 25) } +} + +/// Extract the 5-bit `UIMM` (`VX128_3`) / `IMM` (`VX128_4`) field. Canary +/// packs both formats with LSB-bits 16-20 holding the field, which is +/// MSB bits 11-15 in our `extract_bits` convention. For `vpkd3d128` / +/// `vupkd3d128` the decoded selector is `type = UIMM >> 2` (3 bits; valid +/// values 0-6 per [`crate::vmx::D3dPackType`], 7 is undocumented / +/// undefined in canary) and `pack = UIMM & 0x3` (output-slot layout for +/// `vpkd3d128` only, `vupkd3d128` ignores it). +/// +/// First-Pixels M3: the interpreter previously used a hand-rolled +/// `(instr.raw >> 6) & 0x7` that was **LSB-numbered** and extracted +/// bits from a completely different part of the word (the +/// secondary-opcode region). Centralizing the extractor here matches +/// canary's `FormatVX128_{3,4}::{UIMM,IMM}` field semantics exactly. +#[inline] +pub fn extract_vx128_uimm5(raw: u32) -> u32 { + extract_bits(raw, 11, 15) +} + +/// Decode a 32-bit PPC instruction into its opcode. +/// Direct translation of the C++ LookupOpcode from ppc_opcode_lookup_gen.cc. +pub fn decode(raw: u32, addr: u32) -> DecodedInstr { + let opcode = lookup_opcode(raw); + DecodedInstr { opcode, raw, addr } +} + +// Perf tier-2 — direct-mapped PC-keyed decode cache. +// +// The interpreter hot path spends ~15-25% of its time in `decode()` +// parsing the raw u32 and walking the primary+secondary opcode tables. +// For non-self-modifying guest code — the common case past the XEX +// loader — `decode(raw, pc)` is purely a function of `(raw, pc)` and +// the output is `Copy + 16B`. A direct-mapped cache indexed by +// `(pc >> 2) & MASK` gives the interpreter a 1-comparison fast path, +// at the cost of one branch and a 1.5 MiB region of memory. +// +// Invalidation piggybacks on `sylpheed_xex::GuestMemory::page_version` +// (P5 texture-cache invalidation): every cache entry carries the page +// version that was active at decode time; on lookup we compare against +// the current version of the containing 4 KiB page. Any write to the +// page bumps the counter, so the next decode on that PC is a miss that +// refills. + +/// Number of direct-mapped entries. 2^16 = 65,536 slots, one PPC +/// instruction address per slot — enough for every hot code path in a +/// typical Xbox 360 title to stay resident without collision. +const DECODE_CACHE_SIZE: usize = 1 << 16; +const DECODE_CACHE_MASK: u32 = (DECODE_CACHE_SIZE - 1) as u32; + +#[derive(Clone, Copy)] +struct DecodeCacheEntry { + /// Guest PC this entry was decoded at. Used as the tag on lookup; a + /// mismatch means the slot was last populated by a different PC that + /// shares the same low-16 index. + pc: u32, + /// Page version at decode time (from `GuestMemory::page_version(pc)`). + /// Zero means "unused slot" since real page versions start at 1. + page_version: u64, + decoded: DecodedInstr, +} + +impl DecodeCacheEntry { + const fn empty() -> Self { + // `Invalid` is the decoder's "unrecognized opcode" sentinel; we + // use it here as the empty-slot marker. Real misses compare `pc`, + // not the opcode, so the sentinel choice is cosmetic. + Self { + pc: 0, + page_version: 0, + decoded: DecodedInstr { + opcode: PpcOpcode::Invalid, + raw: 0, + addr: 0, + }, + } + } +} + +/// Direct-mapped PC-keyed decode cache. One instance shared across all +/// HW threads (PC is thread-independent; entries are read-only once +/// filled). Not thread-safe — the single scheduler thread owns it. +pub struct DecodeCache { + slots: Box<[DecodeCacheEntry]>, + hits: u64, + misses: u64, + invalidations: u64, +} + +impl Default for DecodeCache { + fn default() -> Self { + Self::new() + } +} + +impl DecodeCache { + pub fn new() -> Self { + Self { + slots: vec![DecodeCacheEntry::empty(); DECODE_CACHE_SIZE].into_boxed_slice(), + hits: 0, + misses: 0, + invalidations: 0, + } + } + + /// Look up (or fill) the decoded form of the instruction at `pc`. + /// `raw` is the fetched instruction word; `current_page_version` is + /// `mem.page_version(pc)` — the caller has it cheaper than we do, + /// since they're already touching `mem` to fetch `raw`. + #[inline] + pub fn lookup(&mut self, pc: u32, raw: u32, current_page_version: u64) -> DecodedInstr { + let idx = ((pc >> 2) & DECODE_CACHE_MASK) as usize; + // Safety: `idx` is masked into `[0, DECODE_CACHE_SIZE)` so the + // slice access is always in-bounds. Opt-out of the bounds check + // for the hot path. + let entry = unsafe { self.slots.get_unchecked_mut(idx) }; + if entry.pc == pc && entry.page_version == current_page_version { + self.hits += 1; + return entry.decoded; + } + if entry.pc == pc && entry.page_version != current_page_version { + self.invalidations += 1; + } + self.misses += 1; + let decoded = decode(raw, pc); + *entry = DecodeCacheEntry { + pc, + page_version: current_page_version, + decoded, + }; + decoded + } + + pub fn hits(&self) -> u64 { + self.hits + } + pub fn misses(&self) -> u64 { + self.misses + } + pub fn invalidations(&self) -> u64 { + self.invalidations + } +} + +fn lookup_opcode(code: u32) -> PpcOpcode { + match extract_bits(code, 0, 5) { + 2 => PpcOpcode::tdi, + 3 => PpcOpcode::twi, + 4 => decode_op4(code), + 5 => decode_op5(code), + 6 => decode_op6(code), + 7 => PpcOpcode::mulli, + 8 => PpcOpcode::subficx, + 10 => PpcOpcode::cmpli, + 11 => PpcOpcode::cmpi, + 12 => PpcOpcode::addic, + 13 => PpcOpcode::addicx, + 14 => PpcOpcode::addi, + 15 => PpcOpcode::addis, + 16 => PpcOpcode::bcx, + 17 => PpcOpcode::sc, + 18 => PpcOpcode::bx, + 19 => decode_op19(code), + 20 => PpcOpcode::rlwimix, + 21 => PpcOpcode::rlwinmx, + 23 => PpcOpcode::rlwnmx, + 24 => PpcOpcode::ori, + 25 => PpcOpcode::oris, + 26 => PpcOpcode::xori, + 27 => PpcOpcode::xoris, + 28 => PpcOpcode::andix, + 29 => PpcOpcode::andisx, + 30 => decode_op30(code), + 31 => decode_op31(code), + 32 => PpcOpcode::lwz, + 33 => PpcOpcode::lwzu, + 34 => PpcOpcode::lbz, + 35 => PpcOpcode::lbzu, + 36 => PpcOpcode::stw, + 37 => PpcOpcode::stwu, + 38 => PpcOpcode::stb, + 39 => PpcOpcode::stbu, + 40 => PpcOpcode::lhz, + 41 => PpcOpcode::lhzu, + 42 => PpcOpcode::lha, + 43 => PpcOpcode::lhau, + 44 => PpcOpcode::sth, + 45 => PpcOpcode::sthu, + 46 => PpcOpcode::lmw, + 47 => PpcOpcode::stmw, + 48 => PpcOpcode::lfs, + 49 => PpcOpcode::lfsu, + 50 => PpcOpcode::lfd, + 51 => PpcOpcode::lfdu, + 52 => PpcOpcode::stfs, + 53 => PpcOpcode::stfsu, + 54 => PpcOpcode::stfd, + 55 => PpcOpcode::stfdu, + 58 => match extract_bits(code, 30, 31) { + 0b00 => PpcOpcode::ld, + 0b01 => PpcOpcode::ldu, + 0b10 => PpcOpcode::lwa, + _ => PpcOpcode::Invalid, + }, + 59 => match extract_bits(code, 26, 30) { + 0b10010 => PpcOpcode::fdivsx, + 0b10100 => PpcOpcode::fsubsx, + 0b10101 => PpcOpcode::faddsx, + 0b10110 => PpcOpcode::fsqrtsx, + 0b11000 => PpcOpcode::fresx, + 0b11001 => PpcOpcode::fmulsx, + 0b11100 => PpcOpcode::fmsubsx, + 0b11101 => PpcOpcode::fmaddsx, + 0b11110 => PpcOpcode::fnmsubsx, + 0b11111 => PpcOpcode::fnmaddsx, + _ => PpcOpcode::Invalid, + }, + 62 => match extract_bits(code, 30, 31) { + 0b00 => PpcOpcode::std, + 0b01 => PpcOpcode::stdu, + _ => PpcOpcode::Invalid, + }, + 63 => decode_op63(code), + _ => PpcOpcode::Invalid, + } +} + +fn decode_op4(code: u32) -> PpcOpcode { + // VMX128 load/store (op=4, bits 21-27 << 4 | bits 30-31) + let key1 = (extract_bits(code, 21, 27) << 4) | extract_bits(code, 30, 31); + match key1 { + 0b00000000011 => return PpcOpcode::lvsl128, + 0b00001000011 => return PpcOpcode::lvsr128, + 0b00010000011 => return PpcOpcode::lvewx128, + 0b00011000011 => return PpcOpcode::lvx128, + 0b00110000011 => return PpcOpcode::stvewx128, + 0b00111000011 => return PpcOpcode::stvx128, + 0b01011000011 => return PpcOpcode::lvxl128, + 0b01111000011 => return PpcOpcode::stvxl128, + 0b10000000011 => return PpcOpcode::lvlx128, + 0b10001000011 => return PpcOpcode::lvrx128, + 0b10100000011 => return PpcOpcode::stvlx128, + 0b10101000011 => return PpcOpcode::stvrx128, + 0b11000000011 => return PpcOpcode::lvlxl128, + 0b11001000011 => return PpcOpcode::lvrxl128, + 0b11100000011 => return PpcOpcode::stvlxl128, + 0b11101000011 => return PpcOpcode::stvrxl128, + _ => {} + } + + // Standard VMX (op=4, bits 21-31) + let key2 = extract_bits(code, 21, 31); + match key2 { + 0b00000000000 => return PpcOpcode::vaddubm, + 0b00000000010 => return PpcOpcode::vmaxub, + 0b00000000100 => return PpcOpcode::vrlb, + 0b00000001000 => return PpcOpcode::vmuloub, + 0b00000001010 => return PpcOpcode::vaddfp, + 0b00000001100 => return PpcOpcode::vmrghb, + 0b00000001110 => return PpcOpcode::vpkuhum, + 0b00001000000 => return PpcOpcode::vadduhm, + 0b00001000010 => return PpcOpcode::vmaxuh, + 0b00001000100 => return PpcOpcode::vrlh, + 0b00001001000 => return PpcOpcode::vmulouh, + 0b00001001010 => return PpcOpcode::vsubfp, + 0b00001001100 => return PpcOpcode::vmrghh, + 0b00001001110 => return PpcOpcode::vpkuwum, + 0b00010000000 => return PpcOpcode::vadduwm, + 0b00010000010 => return PpcOpcode::vmaxuw, + 0b00010000100 => return PpcOpcode::vrlw, + 0b00010001100 => return PpcOpcode::vmrghw, + 0b00010001110 => return PpcOpcode::vpkuhus, + 0b00011001110 => return PpcOpcode::vpkuwus, + 0b00100000010 => return PpcOpcode::vmaxsb, + 0b00100000100 => return PpcOpcode::vslb, + 0b00100001000 => return PpcOpcode::vmulosb, + 0b00100001010 => return PpcOpcode::vrefp, + 0b00100001100 => return PpcOpcode::vmrglb, + 0b00100001110 => return PpcOpcode::vpkshus, + 0b00101000010 => return PpcOpcode::vmaxsh, + 0b00101000100 => return PpcOpcode::vslh, + 0b00101001000 => return PpcOpcode::vmulosh, + 0b00101001010 => return PpcOpcode::vrsqrtefp, + 0b00101001100 => return PpcOpcode::vmrglh, + 0b00101001110 => return PpcOpcode::vpkswus, + 0b00110000000 => return PpcOpcode::vaddcuw, + 0b00110000010 => return PpcOpcode::vmaxsw, + 0b00110000100 => return PpcOpcode::vslw, + 0b00110001010 => return PpcOpcode::vexptefp, + 0b00110001100 => return PpcOpcode::vmrglw, + 0b00110001110 => return PpcOpcode::vpkshss, + 0b00111000100 => return PpcOpcode::vsl, + 0b00111001010 => return PpcOpcode::vlogefp, + 0b00111001110 => return PpcOpcode::vpkswss, + 0b01000000000 => return PpcOpcode::vaddubs, + 0b01000000010 => return PpcOpcode::vminub, + 0b01000000100 => return PpcOpcode::vsrb, + 0b01000001000 => return PpcOpcode::vmuleub, + 0b01000001010 => return PpcOpcode::vrfin, + 0b01000001100 => return PpcOpcode::vspltb, + 0b01000001110 => return PpcOpcode::vupkhsb, + 0b01001000000 => return PpcOpcode::vadduhs, + 0b01001000010 => return PpcOpcode::vminuh, + 0b01001000100 => return PpcOpcode::vsrh, + 0b01001001000 => return PpcOpcode::vmuleuh, + 0b01001001010 => return PpcOpcode::vrfiz, + 0b01001001100 => return PpcOpcode::vsplth, + 0b01001001110 => return PpcOpcode::vupkhsh, + 0b01010000000 => return PpcOpcode::vadduws, + 0b01010000010 => return PpcOpcode::vminuw, + 0b01010000100 => return PpcOpcode::vsrw, + 0b01010001010 => return PpcOpcode::vrfip, + 0b01010001100 => return PpcOpcode::vspltw, + 0b01010001110 => return PpcOpcode::vupklsb, + 0b01011000100 => return PpcOpcode::vsr, + 0b01011001010 => return PpcOpcode::vrfim, + 0b01011001110 => return PpcOpcode::vupklsh, + 0b01100000000 => return PpcOpcode::vaddsbs, + 0b01100000010 => return PpcOpcode::vminsb, + 0b01100000100 => return PpcOpcode::vsrab, + 0b01100001000 => return PpcOpcode::vmulesb, + 0b01100001010 => return PpcOpcode::vcfux, + 0b01100001100 => return PpcOpcode::vspltisb, + 0b01100001110 => return PpcOpcode::vpkpx, + 0b01101000000 => return PpcOpcode::vaddshs, + 0b01101000010 => return PpcOpcode::vminsh, + 0b01101000100 => return PpcOpcode::vsrah, + 0b01101001000 => return PpcOpcode::vmulesh, + 0b01101001010 => return PpcOpcode::vcfsx, + 0b01101001100 => return PpcOpcode::vspltish, + 0b01101001110 => return PpcOpcode::vupkhpx, + 0b01110000000 => return PpcOpcode::vaddsws, + 0b01110000010 => return PpcOpcode::vminsw, + 0b01110000100 => return PpcOpcode::vsraw, + 0b01110001010 => return PpcOpcode::vctuxs, + 0b01110001100 => return PpcOpcode::vspltisw, + 0b01111001010 => return PpcOpcode::vctsxs, + 0b01111001110 => return PpcOpcode::vupklpx, + 0b10000000000 => return PpcOpcode::vsububm, + 0b10000000010 => return PpcOpcode::vavgub, + 0b10000000100 => return PpcOpcode::vand, + 0b10000001010 => return PpcOpcode::vmaxfp, + 0b10000001100 => return PpcOpcode::vslo, + 0b10001000000 => return PpcOpcode::vsubuhm, + 0b10001000010 => return PpcOpcode::vavguh, + 0b10001000100 => return PpcOpcode::vandc, + 0b10001001010 => return PpcOpcode::vminfp, + 0b10001001100 => return PpcOpcode::vsro, + 0b10010000000 => return PpcOpcode::vsubuwm, + 0b10010000010 => return PpcOpcode::vavguw, + 0b10010000100 => return PpcOpcode::vor, + 0b10011000100 => return PpcOpcode::vxor, + 0b10100000010 => return PpcOpcode::vavgsb, + 0b10100000100 => return PpcOpcode::vnor, + 0b10101000010 => return PpcOpcode::vavgsh, + 0b10110000000 => return PpcOpcode::vsubcuw, + 0b10110000010 => return PpcOpcode::vavgsw, + 0b11000000000 => return PpcOpcode::vsububs, + 0b11000000100 => return PpcOpcode::mfvscr, + 0b11000001000 => return PpcOpcode::vsum4ubs, + 0b11001000000 => return PpcOpcode::vsubuhs, + 0b11001000100 => return PpcOpcode::mtvscr, + 0b11001001000 => return PpcOpcode::vsum4shs, + 0b11010000000 => return PpcOpcode::vsubuws, + 0b11010001000 => return PpcOpcode::vsum2sws, + 0b11100000000 => return PpcOpcode::vsubsbs, + 0b11100001000 => return PpcOpcode::vsum4sbs, + 0b11101000000 => return PpcOpcode::vsubshs, + 0b11110000000 => return PpcOpcode::vsubsws, + 0b11110001000 => return PpcOpcode::vsumsws, + _ => {} + } + + // VMX compare (op=4, bits 22-31) + let key3 = extract_bits(code, 22, 31); + match key3 { + 0b0000000110 => return PpcOpcode::vcmpequb, + 0b0001000110 => return PpcOpcode::vcmpequh, + 0b0010000110 => return PpcOpcode::vcmpequw, + 0b0011000110 => return PpcOpcode::vcmpeqfp, + 0b0111000110 => return PpcOpcode::vcmpgefp, + 0b1000000110 => return PpcOpcode::vcmpgtub, + 0b1001000110 => return PpcOpcode::vcmpgtuh, + 0b1010000110 => return PpcOpcode::vcmpgtuw, + 0b1011000110 => return PpcOpcode::vcmpgtfp, + 0b1100000110 => return PpcOpcode::vcmpgtsb, + 0b1101000110 => return PpcOpcode::vcmpgtsh, + 0b1110000110 => return PpcOpcode::vcmpgtsw, + 0b1111000110 => return PpcOpcode::vcmpbfp, + _ => {} + } + + // VMX 4-operand (op=4, bits 26-31) + let key4 = extract_bits(code, 26, 31); + match key4 { + 0b100000 => return PpcOpcode::vmhaddshs, + 0b100001 => return PpcOpcode::vmhraddshs, + 0b100010 => return PpcOpcode::vmladduhm, + 0b100100 => return PpcOpcode::vmsumubm, + 0b100101 => return PpcOpcode::vmsummbm, + 0b100110 => return PpcOpcode::vmsumuhm, + 0b100111 => return PpcOpcode::vmsumuhs, + 0b101000 => return PpcOpcode::vmsumshm, + 0b101001 => return PpcOpcode::vmsumshs, + 0b101010 => return PpcOpcode::vsel, + 0b101011 => return PpcOpcode::vperm, + 0b101100 => return PpcOpcode::vsldoi, + 0b101110 => return PpcOpcode::vmaddfp, + 0b101111 => return PpcOpcode::vnmsubfp, + _ => {} + } + + // vsldoi128 (op=4, bit 27) + if extract_bits(code, 27, 27) == 1 { + return PpcOpcode::vsldoi128; + } + + PpcOpcode::Invalid +} + +fn decode_op5(code: u32) -> PpcOpcode { + // vperm128 (op=5, bits 22,27) + let key1 = (extract_bits(code, 22, 22) << 5) | extract_bits(code, 27, 27); + if key1 == 0b000000 { + return PpcOpcode::vperm128; + } + + let key2 = (extract_bits(code, 22, 25) << 2) | extract_bits(code, 27, 27); + match key2 { + 0b000001 => PpcOpcode::vaddfp128, + 0b000101 => PpcOpcode::vsubfp128, + 0b001001 => PpcOpcode::vmulfp128, + 0b001101 => PpcOpcode::vmaddfp128, + 0b010001 => PpcOpcode::vmaddcfp128, + 0b010101 => PpcOpcode::vnmsubfp128, + 0b011001 => PpcOpcode::vmsum3fp128, + 0b011101 => PpcOpcode::vmsum4fp128, + 0b100000 => PpcOpcode::vpkshss128, + 0b100001 => PpcOpcode::vand128, + 0b100100 => PpcOpcode::vpkshus128, + 0b100101 => PpcOpcode::vandc128, + 0b101000 => PpcOpcode::vpkswss128, + 0b101001 => PpcOpcode::vnor128, + 0b101100 => PpcOpcode::vpkswus128, + 0b101101 => PpcOpcode::vor128, + 0b110000 => PpcOpcode::vpkuhum128, + 0b110001 => PpcOpcode::vxor128, + 0b110100 => PpcOpcode::vpkuhus128, + 0b110101 => PpcOpcode::vsel128, + 0b111000 => PpcOpcode::vpkuwum128, + 0b111001 => PpcOpcode::vslo128, + 0b111100 => PpcOpcode::vpkuwus128, + 0b111101 => PpcOpcode::vsro128, + _ => PpcOpcode::Invalid, + } +} + +fn decode_op6(code: u32) -> PpcOpcode { + // vpermwi128 + let key1 = (extract_bits(code, 21, 22) << 5) | extract_bits(code, 26, 27); + if key1 == 0b0100001 { + return PpcOpcode::vpermwi128; + } + + // vpkd3d128, vrlimi128 + let key2 = (extract_bits(code, 21, 23) << 4) | extract_bits(code, 26, 27); + match key2 { + 0b1100001 => return PpcOpcode::vpkd3d128, + 0b1110001 => return PpcOpcode::vrlimi128, + _ => {} + } + + // Unary VMX128 ops + let key3 = extract_bits(code, 21, 27); + match key3 { + 0b0100011 => return PpcOpcode::vcfpsxws128, + 0b0100111 => return PpcOpcode::vcfpuxws128, + 0b0101011 => return PpcOpcode::vcsxwfp128, + 0b0101111 => return PpcOpcode::vcuxwfp128, + 0b0110011 => return PpcOpcode::vrfim128, + 0b0110111 => return PpcOpcode::vrfin128, + 0b0111011 => return PpcOpcode::vrfip128, + 0b0111111 => return PpcOpcode::vrfiz128, + 0b1100011 => return PpcOpcode::vrefp128, + 0b1100111 => return PpcOpcode::vrsqrtefp128, + 0b1101011 => return PpcOpcode::vexptefp128, + 0b1101111 => return PpcOpcode::vlogefp128, + 0b1110011 => return PpcOpcode::vspltw128, + 0b1110111 => return PpcOpcode::vspltisw128, + 0b1111111 => return PpcOpcode::vupkd3d128, + _ => {} + } + + // VMX128 compare (VX128_R form). Single dispatch path: bit 27 = 0 always + // for these opcodes per canary's table (`ppc_opcode_table_gen.cc:295-305`). + // The Rc bit is at PPC 25 (host bit 6) per the FormatVX128_R bitfield — + // it's a runtime modifier read by the interpreter, NOT part of the + // secondary-opcode discrimination. PPCBUG-700. + let key4_nd = (extract_bits(code, 22, 24) << 3) | extract_bits(code, 27, 27); + match key4_nd { + 0b000000 => return PpcOpcode::vcmpeqfp128, + 0b001000 => return PpcOpcode::vcmpgefp128, + 0b010000 => return PpcOpcode::vcmpgtfp128, + 0b011000 => return PpcOpcode::vcmpbfp128, + 0b100000 => return PpcOpcode::vcmpequw128, + _ => {} + } + + // VMX128 shift/merge + let key5 = (extract_bits(code, 22, 25) << 2) | extract_bits(code, 27, 27); + match key5 { + 0b000101 => return PpcOpcode::vrlw128, + 0b001101 => return PpcOpcode::vslw128, + 0b010101 => return PpcOpcode::vsraw128, + 0b011101 => return PpcOpcode::vsrw128, + 0b101000 => return PpcOpcode::vmaxfp128, + 0b101100 => return PpcOpcode::vminfp128, + 0b110000 => return PpcOpcode::vmrghw128, + 0b110100 => return PpcOpcode::vmrglw128, + 0b111000 => return PpcOpcode::vupkhsb128, + 0b111100 => return PpcOpcode::vupklsb128, + _ => {} + } + + PpcOpcode::Invalid +} + +fn decode_op19(code: u32) -> PpcOpcode { + match extract_bits(code, 21, 30) { + 0b0000000000 => PpcOpcode::mcrf, + 0b0000010000 => PpcOpcode::bclrx, + 0b0000100001 => PpcOpcode::crnor, + 0b0010000001 => PpcOpcode::crandc, + 0b0010010110 => PpcOpcode::isync, + 0b0011000001 => PpcOpcode::crxor, + 0b0011100001 => PpcOpcode::crnand, + 0b0100000001 => PpcOpcode::crand, + 0b0100100001 => PpcOpcode::creqv, + 0b0110100001 => PpcOpcode::crorc, + 0b0111000001 => PpcOpcode::cror, + 0b1000010000 => PpcOpcode::bcctrx, + _ => PpcOpcode::Invalid, + } +} + +fn decode_op30(code: u32) -> PpcOpcode { + match extract_bits(code, 27, 29) { + 0b000 => PpcOpcode::rldiclx, + 0b001 => PpcOpcode::rldicrx, + 0b010 => PpcOpcode::rldicx, + 0b011 => PpcOpcode::rldimix, + _ => match extract_bits(code, 27, 30) { + 0b1000 => PpcOpcode::rldclx, + 0b1001 => PpcOpcode::rldcrx, + _ => PpcOpcode::Invalid, + }, + } +} + +fn decode_op31(code: u32) -> PpcOpcode { + // sradix has a unique 10-bit key (bits 21-29) + if extract_bits(code, 21, 29) == 0b110011101 { + return PpcOpcode::sradix; + } + + // Main op31 table (bits 21-30) + let key = extract_bits(code, 21, 30); + match key { + 0b0000000000 => return PpcOpcode::cmp, + 0b0000000100 => return PpcOpcode::tw, + 0b0000000110 => return PpcOpcode::lvsl, + 0b0000000111 => return PpcOpcode::lvebx, + 0b0000010011 => return PpcOpcode::mfcr, + 0b0000010100 => return PpcOpcode::lwarx, + 0b0000010101 => return PpcOpcode::ldx, + 0b0000010111 => return PpcOpcode::lwzx, + 0b0000011000 => return PpcOpcode::slwx, + 0b0000011010 => return PpcOpcode::cntlzwx, + 0b0000011011 => return PpcOpcode::sldx, + 0b0000011100 => return PpcOpcode::andx, + 0b0000100000 => return PpcOpcode::cmpl, + 0b0000100110 => return PpcOpcode::lvsr, + 0b0000100111 => return PpcOpcode::lvehx, + 0b0000110101 => return PpcOpcode::ldux, + 0b0000110110 => return PpcOpcode::dcbst, + 0b0000110111 => return PpcOpcode::lwzux, + 0b0000111010 => return PpcOpcode::cntlzdx, + 0b0000111100 => return PpcOpcode::andcx, + 0b0001000100 => return PpcOpcode::td, + 0b0001000111 => return PpcOpcode::lvewx, + 0b0001010011 => return PpcOpcode::mfmsr, + 0b0001010100 => return PpcOpcode::ldarx, + 0b0001010110 => return PpcOpcode::dcbf, + 0b0001010111 => return PpcOpcode::lbzx, + 0b0001100111 => return PpcOpcode::lvx, + 0b0001110111 => return PpcOpcode::lbzux, + 0b0001111100 => return PpcOpcode::norx, + 0b0010000111 => return PpcOpcode::stvebx, + 0b0010010000 => return PpcOpcode::mtcrf, + 0b0010010010 => return PpcOpcode::mtmsr, + 0b0010010101 => return PpcOpcode::stdx, + 0b0010010110 => return PpcOpcode::stwcx, + 0b0010010111 => return PpcOpcode::stwx, + 0b0010100111 => return PpcOpcode::stvehx, + 0b0010110010 => return PpcOpcode::mtmsrd, + 0b0010110101 => return PpcOpcode::stdux, + 0b0010110111 => return PpcOpcode::stwux, + 0b0011000111 => return PpcOpcode::stvewx, + 0b0011010110 => return PpcOpcode::stdcx, + 0b0011010111 => return PpcOpcode::stbx, + 0b0011100111 => return PpcOpcode::stvx, + 0b0011110110 => return PpcOpcode::dcbtst, + 0b0011110111 => return PpcOpcode::stbux, + 0b0100010110 => return PpcOpcode::dcbt, + 0b0100010111 => return PpcOpcode::lhzx, + 0b0100011100 => return PpcOpcode::eqvx, + 0b0100110111 => return PpcOpcode::lhzux, + 0b0100111100 => return PpcOpcode::xorx, + 0b0101010011 => return PpcOpcode::mfspr, + 0b0101010101 => return PpcOpcode::lwax, + 0b0101010111 => return PpcOpcode::lhax, + 0b0101100111 => return PpcOpcode::lvxl, + 0b0101110011 => return PpcOpcode::mftb, + 0b0101110101 => return PpcOpcode::lwaux, + 0b0101110111 => return PpcOpcode::lhaux, + 0b0110010111 => return PpcOpcode::sthx, + 0b0110011100 => return PpcOpcode::orcx, + 0b0110110111 => return PpcOpcode::sthux, + 0b0110111100 => return PpcOpcode::orx, + 0b0111010011 => return PpcOpcode::mtspr, + 0b0111010110 => return PpcOpcode::dcbi, + 0b0111011100 => return PpcOpcode::nandx, + 0b0111100111 => return PpcOpcode::stvxl, + 0b1000000000 => return PpcOpcode::mcrxr, + 0b1000000111 => return PpcOpcode::lvlx, + 0b1000010100 => return PpcOpcode::ldbrx, + 0b1000010101 => return PpcOpcode::lswx, + 0b1000010110 => return PpcOpcode::lwbrx, + 0b1000010111 => return PpcOpcode::lfsx, + 0b1000011000 => return PpcOpcode::srwx, + 0b1000011011 => return PpcOpcode::srdx, + 0b1000100111 => return PpcOpcode::lvrx, + 0b1000110111 => return PpcOpcode::lfsux, + 0b1001010101 => return PpcOpcode::lswi, + 0b1001010110 => return PpcOpcode::sync, + 0b1001010111 => return PpcOpcode::lfdx, + 0b1001110111 => return PpcOpcode::lfdux, + 0b1010000111 => return PpcOpcode::stvlx, + 0b1010010100 => return PpcOpcode::stdbrx, + 0b1010010101 => return PpcOpcode::stswx, + 0b1010010110 => return PpcOpcode::stwbrx, + 0b1010010111 => return PpcOpcode::stfsx, + 0b1010100111 => return PpcOpcode::stvrx, + 0b1010110111 => return PpcOpcode::stfsux, + 0b1011010101 => return PpcOpcode::stswi, + 0b1011010111 => return PpcOpcode::stfdx, + 0b1011110111 => return PpcOpcode::stfdux, + 0b1100000111 => return PpcOpcode::lvlxl, + 0b1100010110 => return PpcOpcode::lhbrx, + 0b1100011000 => return PpcOpcode::srawx, + 0b1100011010 => return PpcOpcode::sradx, + 0b1100100111 => return PpcOpcode::lvrxl, + 0b1100111000 => return PpcOpcode::srawix, + 0b1101010110 => return PpcOpcode::eieio, + 0b1110000111 => return PpcOpcode::stvlxl, + 0b1110010110 => return PpcOpcode::sthbrx, + 0b1110011010 => return PpcOpcode::extshx, + 0b1110100111 => return PpcOpcode::stvrxl, + 0b1110111010 => return PpcOpcode::extsbx, + 0b1111010110 => return PpcOpcode::icbi, + 0b1111010111 => return PpcOpcode::stfiwx, + 0b1111011010 => return PpcOpcode::extswx, + _ => {} + } + + // Arithmetic op31 (bits 22-30) + let key2 = extract_bits(code, 22, 30); + match key2 { + 0b000001000 => return PpcOpcode::subfcx, + 0b000001001 => return PpcOpcode::mulhdux, + 0b000001010 => return PpcOpcode::addcx, + 0b000001011 => return PpcOpcode::mulhwux, + 0b000101000 => return PpcOpcode::subfx, + 0b001001001 => return PpcOpcode::mulhdx, + 0b001001011 => return PpcOpcode::mulhwx, + 0b001101000 => return PpcOpcode::negx, + 0b010001000 => return PpcOpcode::subfex, + 0b010001010 => return PpcOpcode::addex, + 0b011001000 => return PpcOpcode::subfzex, + 0b011001010 => return PpcOpcode::addzex, + 0b011101000 => return PpcOpcode::subfmex, + 0b011101001 => return PpcOpcode::mulldx, + 0b011101010 => return PpcOpcode::addmex, + 0b011101011 => return PpcOpcode::mullwx, + 0b100001010 => return PpcOpcode::addx, + 0b111001001 => return PpcOpcode::divdux, + 0b111001011 => return PpcOpcode::divwux, + 0b111101001 => return PpcOpcode::divdx, + 0b111101011 => return PpcOpcode::divwx, + _ => {} + } + + // dcbz/dcbz128 special case + let key3 = (extract_bits(code, 6, 10) << 20) | (extract_bits(code, 21, 30)); + match key3 { + 0b0000000000000001111110110 => return PpcOpcode::dcbz, + 0b0000100000000001111110110 => return PpcOpcode::dcbz128, + _ => {} + } + + PpcOpcode::Invalid +} + +fn decode_op63(code: u32) -> PpcOpcode { + // Primary op63 table (bits 21-30) + match extract_bits(code, 21, 30) { + 0b0000000000 => return PpcOpcode::fcmpu, + 0b0000001100 => return PpcOpcode::frspx, + 0b0000001110 => return PpcOpcode::fctiwx, + 0b0000001111 => return PpcOpcode::fctiwzx, + 0b0000100000 => return PpcOpcode::fcmpo, + 0b0000100110 => return PpcOpcode::mtfsb1x, + 0b0000101000 => return PpcOpcode::fnegx, + 0b0001000000 => return PpcOpcode::mcrfs, + 0b0001000110 => return PpcOpcode::mtfsb0x, + 0b0001001000 => return PpcOpcode::fmrx, + 0b0010000110 => return PpcOpcode::mtfsfix, + 0b0010001000 => return PpcOpcode::fnabsx, + 0b0100001000 => return PpcOpcode::fabsx, + 0b1001000111 => return PpcOpcode::mffsx, + 0b1011000111 => return PpcOpcode::mtfsfx, + 0b1100101110 => return PpcOpcode::fctidx, + 0b1100101111 => return PpcOpcode::fctidzx, + 0b1101001110 => return PpcOpcode::fcfidx, + _ => {} + } + + // FPU arithmetic (bits 26-30) + match extract_bits(code, 26, 30) { + 0b10010 => PpcOpcode::fdivx, + 0b10100 => PpcOpcode::fsubx, + 0b10101 => PpcOpcode::faddx, + 0b10110 => PpcOpcode::fsqrtx, + 0b10111 => PpcOpcode::fselx, + 0b11001 => PpcOpcode::fmulx, + 0b11010 => PpcOpcode::frsqrtex, + 0b11100 => PpcOpcode::fmsubx, + 0b11101 => PpcOpcode::fmaddx, + 0b11110 => PpcOpcode::fnmsubx, + 0b11111 => PpcOpcode::fnmaddx, + _ => PpcOpcode::Invalid, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_addi() { + // addi r3, r1, 0x10 => opcode 14, rD=3, rA=1, SIMM=0x10 + let raw: u32 = (14 << 26) | (3 << 21) | (1 << 16) | 0x10; + let instr = decode(raw, 0); + assert_eq!(instr.opcode, PpcOpcode::addi); + assert_eq!(instr.rd(), 3); + assert_eq!(instr.ra(), 1); + assert_eq!(instr.simm16(), 0x10); + } + + #[test] + fn test_decode_lwz() { + // lwz r5, 0x20(r1) => opcode 32 + let raw: u32 = (32 << 26) | (5 << 21) | (1 << 16) | 0x20; + let instr = decode(raw, 0); + assert_eq!(instr.opcode, PpcOpcode::lwz); + assert_eq!(instr.rd(), 5); + assert_eq!(instr.ra(), 1); + assert_eq!(instr.d(), 0x20); + } + + #[test] + fn decode_cache_miss_fills_then_hit() { + let mut cache = DecodeCache::new(); + let raw: u32 = (14 << 26) | (3 << 21) | (1 << 16) | 0x10; + let pc = 0x8200_0000u32; + let first = cache.lookup(pc, raw, 1); + assert_eq!(first.opcode, PpcOpcode::addi); + assert_eq!(cache.hits(), 0); + assert_eq!(cache.misses(), 1); + // Same pc, same version → cache hit, no new decode. + let second = cache.lookup(pc, raw, 1); + assert_eq!(second.opcode, PpcOpcode::addi); + assert_eq!(cache.hits(), 1); + assert_eq!(cache.misses(), 1); + } + + #[test] + fn decode_cache_stale_version_refills() { + let mut cache = DecodeCache::new(); + // First fill with an `addi`. + let raw_addi: u32 = (14 << 26) | (3 << 21) | (1 << 16) | 0x10; + let pc = 0x8200_0000u32; + cache.lookup(pc, raw_addi, 1); + // Guest rewrote the page: same pc, different raw + bumped version. + // Cache must refill — not return the stale `addi`. + let raw_lwz: u32 = (32 << 26) | (5 << 21) | (1 << 16) | 0x20; + let refreshed = cache.lookup(pc, raw_lwz, 2); + assert_eq!(refreshed.opcode, PpcOpcode::lwz); + assert_eq!(cache.invalidations(), 1); + assert_eq!(cache.misses(), 2); + } + + #[test] + fn decode_cache_pc_collision_refills() { + // Two PCs that hash to the same slot (pc >> 2 low 16 bits equal) + // must not alias. Slot index = ((pc >> 2) & 0xFFFF) — pick two + // PCs 4 * 2^16 bytes apart. + let mut cache = DecodeCache::new(); + let pc_a = 0x8200_0000u32; + let pc_b = pc_a.wrapping_add(0x0004_0000u32); // (>> 2) differs by 2^16 + let raw_addi: u32 = (14 << 26) | (3 << 21) | (1 << 16) | 0x10; + let raw_lwz: u32 = (32 << 26) | (5 << 21) | (1 << 16) | 0x20; + cache.lookup(pc_a, raw_addi, 1); + // Different pc but same slot → miss + refill. + cache.lookup(pc_b, raw_lwz, 1); + // First pc comes back → miss + refill (slot was taken by pc_b). + let back = cache.lookup(pc_a, raw_addi, 1); + assert_eq!(back.opcode, PpcOpcode::addi); + assert_eq!(cache.misses(), 3); + } + + #[test] + fn test_decode_branch() { + // b +0x100 => opcode 18, LI=0x40 (shifted left 2 = 0x100), AA=0, LK=0 + let raw: u32 = (18 << 26) | (0x40 << 2); + let instr = decode(raw, 0); + assert_eq!(instr.opcode, PpcOpcode::bx); + assert_eq!(instr.li(), 0x100); + assert!(!instr.aa()); + assert!(!instr.lk()); + } + + #[test] + fn test_decode_stw() { + // stw r7, 0x8(r2) + let raw: u32 = (36 << 26) | (7 << 21) | (2 << 16) | 0x8; + let instr = decode(raw, 0); + assert_eq!(instr.opcode, PpcOpcode::stw); + assert_eq!(instr.rs(), 7); + assert_eq!(instr.ra(), 2); + } + + #[test] + fn test_decode_ori_nop() { + // ori r0, r0, 0 = NOP + let raw: u32 = 24 << 26; + let instr = decode(raw, 0); + assert_eq!(instr.opcode, PpcOpcode::ori); + } + + #[test] + fn test_extract_bits() { + assert_eq!(extract_bits(0xFFFF_FFFF, 0, 5), 0x3F); + assert_eq!(extract_bits(0x8000_0000, 0, 0), 1); + assert_eq!(extract_bits(0x0000_0001, 31, 31), 1); + } + + // VMX128 register-name extraction. Locks the canonical bit positions + // (decoder.rs is the single source of truth — the analysis crate's + // old `ppc.rs` had different positions, which produced wrong printed + // register names; the bug was silent because the interpreter never + // used those extractors). Each test poke-bits exactly the slots the + // accessor reads and asserts the assembled register number. + + /// Build a VMX128 test word for the canary-compliant register layout. + /// `vd128 = vd_lo | (vd_hi << 5)` where vd_lo is 5 bits (PPC 6-10) and + /// vd_hi is 2 bits (PPC 28-29). Same shape for vb128 (vb_lo at PPC 16-20, + /// vb_hi 2 bits at PPC 30-31). va128 = va_lo | (va_h26<<5) | (va_h21<<6) + /// per canary's 7-bit VA selector. + fn vmx128_test_word(vd_lo: u32, vd_hi: u32, va_lo: u32, va_h26: u32, va_h21: u32, + vb_lo: u32, vb_hi: u32) -> u32 { + // PPC bit i -> host bit (31-i). + (vd_lo << (31 - 10)) // VD128l: PPC 6-10 = host 21-25 + | (vd_hi << (31 - 29)) // VD128h: PPC 28-29 = host 2-3 (LSB at host 2) + | (va_lo << (31 - 15)) // VA128l: PPC 11-15 = host 16-20 + | (va_h26 << (31 - 26)) // VA128h: PPC 26 = host 5 + | (va_h21 << (31 - 21)) // VA128H: PPC 21 = host 10 + | (vb_lo << (31 - 20)) // VB128l: PPC 16-20 = host 11-15 + | (vb_hi << (31 - 31)) // VB128h: PPC 30-31 = host 0-1 (LSB at host 0) + } + + #[test] + fn vmx128_vd128_low_5_bits_only() { + // vd_lo = 0..31, vd_hi = 0 → vd128 = vd_lo + for r in 0..32u32 { + let raw = (r as u32) << (31 - 10); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vd128(), r as usize, "vd_lo={r}"); + } + } + + #[test] + fn vmx128_vd128_high_low_bit_adds_32() { + // vd_lo = 0, VD128h = 0b01 (LSB only at host bit 2 = PPC 29) → vd128 = 32 + let raw = (1u32 << (31 - 29)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vd128(), 32); + } + + #[test] + fn vmx128_vd128_high_high_bit_adds_64() { + // vd_lo = 0, VD128h = 0b10 (MSB only at host bit 3 = PPC 28) → vd128 = 64 + let raw = (1u32 << (31 - 28)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vd128(), 64); + } + + #[test] + fn vmx128_vd128_full_127() { + // vd_lo = 31, VD128h = 0b11 → vd128 = 127 + let raw = (31u32 << (31 - 10)) + | (1u32 << (31 - 28)) + | (1u32 << (31 - 29)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vd128(), 127); + } + + #[test] + fn vmx128_va128_canary_layout() { + // va_lo = 7 at PPC 11-15, VA128h = 1 at PPC 26 → va128 = 7 | 32 = 39 + let raw = (7u32 << (31 - 15)) | (1u32 << (31 - 26)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.va128(), 39); + // VA128H = 1 at PPC 21 → va128 += 64 = 103 + let raw = raw | (1u32 << (31 - 21)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.va128(), 7 | 32 | 64); + } + + #[test] + fn vmx128_vb128_uses_bits30_31() { + // vb_lo = 5 at PPC 16-20. VB128h = 0b01 (LSB at PPC 31 = host 0) → +32. + // VB128h = 0b11 → +96. + let raw = (5u32 << (31 - 20)) | (1u32 << (31 - 31)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vb128(), 5 | 32); + let raw = raw | (1u32 << (31 - 30)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vb128(), 5 | 32 | 64); + } + + #[test] + fn vmx128_vs128_aliases_vd128() { + // vs128 must always equal vd128. + for r in [0u32, 31, 32, 64, 96, 127] { + let lo = r & 0x1F; + let hi = (r >> 5) & 0x3; + let raw = (lo << (31 - 10)) + | (hi << (31 - 29)); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vd128(), r as usize, "vd128 mismatch for r={r}"); + assert_eq!(d.vs128(), r as usize, "vs128 mismatch for r={r}"); + assert_eq!(d.vd128(), d.vs128()); + } + } + + #[test] + #[allow(dead_code)] + fn _vmx128_test_word_helper_compiles() { + // Keep the helper validated against the real accessor. + // vd_lo=5, vd_hi=0b11 → vd128 = 5 | 96 = 101 + let raw = vmx128_test_word(5, 3, 0, 0, 0, 0, 0); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vd128(), 5 | 32 | 64); + } + + #[test] + fn vx128_5_sh_bit_positions() { + // SH=8 (binary 1000): bit 3 = 1, bits 0-2 = 0. + // Host bit 9 = 1 (PPC bit 22), host bits 6-8 = 0. + // So raw bit 9 set = raw |= 1 << 9 = 0x200 + let raw = 0x200u32; // host bit 9 set only + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_5_sh(), 8, "SH=8: MSB at PPC bit 22"); + + // SH=1 (binary 0001): host bit 6 set = raw |= 1 << 6 = 0x40 + let raw = 0x40u32; + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_5_sh(), 1, "SH=1: LSB at PPC bit 25"); + + // SH=15 (binary 1111): host bits 6-9 all set = raw |= 0xF << 6 = 0x3C0 + let raw = 0x3C0u32; + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_5_sh(), 15, "SH=15: all 4 bits set"); + + // SH=0: raw=0 + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw: 0, addr: 0 }; + assert_eq!(d.vx128_5_sh(), 0, "SH=0"); + } + + #[test] + fn vx128_4_accessors_correct_bit_positions() { + // z=3 (binary 11) at PPC bits 24-25 = host bits 6-7 + let raw = 0b11u32 << 6; + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_4_z(), 3, "z=3 from host bits 6-7"); + + // IMM=0x15 (binary 10101) at PPC bits 11-15 = host bits 16-20 + let raw2 = 0x15u32 << 16; + let d2 = DecodedInstr { opcode: PpcOpcode::Invalid, raw: raw2, addr: 0 }; + assert_eq!(d2.vx128_4_imm(), 0x15, "IMM=0x15 from host bits 16-20"); + + // Combined: z=1, IMM=0xA — fields must not bleed into each other + let raw3 = (0x1u32 << 6) | (0xAu32 << 16); + let d3 = DecodedInstr { opcode: PpcOpcode::Invalid, raw: raw3, addr: 0 }; + assert_eq!(d3.vx128_4_z(), 1, "z=1 combined"); + assert_eq!(d3.vx128_4_imm(), 0xA, "IMM=0xA combined"); + + // z=2, IMM=0xF — max 4-bit blend mask, exercises the full lower nibble + let raw4 = (0b10u32 << 6) | (0xFu32 << 16); + let d4 = DecodedInstr { opcode: PpcOpcode::Invalid, raw: raw4, addr: 0 }; + assert_eq!(d4.vx128_4_z(), 2, "z=2 from binary 10"); + assert_eq!(d4.vx128_4_imm(), 0xF, "IMM=0xF all-ones nibble"); + } + + #[test] + fn vc128_2_extracts_ppc_bits_23_25() { + // VC=5 (binary 101) at PPC bits 23-25 = host bits 6-8 + // extract_bits(raw, 23, 25) = (raw >> (31-25)) & 0x7 = (raw >> 6) & 0x7 + let raw = 5u32 << 6; // host bits 6-8 = 5 + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vc128_2(), 5); + + let d0 = DecodedInstr { opcode: PpcOpcode::Invalid, raw: 0, addr: 0 }; + assert_eq!(d0.vc128_2(), 0); + + let d7 = DecodedInstr { opcode: PpcOpcode::Invalid, raw: 7u32 << 6, addr: 0 }; + assert_eq!(d7.vc128_2(), 7); + + let d1 = DecodedInstr { opcode: PpcOpcode::Invalid, raw: 1u32 << 6, addr: 0 }; + assert_eq!(d1.vc128_2(), 1); + } + + #[test] + fn vx128_p_perm_assembles_correctly() { + // PERMl=0x1F (all 5 bits set) at host bits 16-20: raw = 0x1F << 16 + let raw = 0x1Fu32 << 16; + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_p_perm(), 0x1F, "PERMl only"); + + // PERMh=0x7 (all 3 bits set) at host bits 6-8: raw = 0x7 << 6 = 0x1C0 + let raw = 0x7u32 << 6; + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_p_perm(), 0x7 << 5, "PERMh only: bits 5-7"); + + // PERMl=0xA, PERMh=0x5: raw = (0xA << 16) | (0x5 << 6) + let raw = (0xAu32 << 16) | (0x5u32 << 6); + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw, addr: 0 }; + assert_eq!(d.vx128_p_perm(), 0xA | (0x5 << 5)); + + // PERMl and PERMh bits must not bleed into each other + let d = DecodedInstr { opcode: PpcOpcode::Invalid, raw: 0, addr: 0 }; + assert_eq!(d.vx128_p_perm(), 0); + } +} diff --git a/crates/sylpheed-ppc/src/disasm.rs b/crates/sylpheed-ppc/src/disasm.rs new file mode 100644 index 00000000..90b0052b --- /dev/null +++ b/crates/sylpheed-ppc/src/disasm.rs @@ -0,0 +1,2128 @@ +//! PowerPC (Xbox 360 Xenon) text disassembler. +//! +//! Single source of truth for assembly text formatting. Sits on top of the +//! canonical decoder in [`crate::decoder`] and consumes [`DecodedInstr`] +//! (8-byte `Copy`, no allocations) so the interpreter's decode cache stays +//! lean — formatting allocates, but only when a sink calls [`format`]. +//! +//! [`format`] returns a [`DisasmText`] carrying both base and extended +//! (simplified) mnemonic forms. Callers (text printer, JSON sink, DuckDB +//! row writer) consume the fields directly instead of re-parsing. + +use crate::decoder::{DecodedInstr, extract_vx128_uimm5}; +use crate::opcode::PpcOpcode; + +/// Formatted disassembly of a single instruction. +/// +/// Owns its strings. `mnemonic`/`operands` are the structured base form +/// (e.g. `"addi"`, `"r3, r1, 16"`); `disasm` is the legacy padded display +/// form (e.g. `"addi r3, r1, 16"`). The `ext_*` triple is `Some` when +/// a simplified/extended mnemonic applies (e.g. `addi r3,0,imm` → +/// `li r3, imm`). `branch_target` is the resolved absolute target for +/// direct branches (`b`/`bl`/`bc`/`bcl`); `None` for indirect branches +/// and non-branches. +#[derive(Debug, Clone)] +pub struct DisasmText { + pub mnemonic: String, + pub operands: String, + pub disasm: String, + pub ext_mnemonic: Option, + pub ext_operands: Option, + pub ext_disasm: Option, + pub branch_target: Option, +} + +impl DisasmText { + /// Preferred display form: extended if present, else base. + #[inline] + pub fn display(&self) -> &str { + self.ext_disasm.as_deref().unwrap_or(&self.disasm) + } +} + +// ── Internal builders ─────────────────────────────────────────────────────── + +#[inline] +fn pad_into(mnem: &str, operands: &str, width: usize) -> String { + if width <= mnem.len() + 1 { + // No padding fits — fall back to single-space join. + if operands.is_empty() { mnem.to_string() } + else { format!("{mnem} {operands}") } + } else { + format!("{: DisasmText { + let disasm = pad_into(mnem, &operands, pad); + DisasmText { + mnemonic: mnem.to_string(), + operands, + disasm, + ext_mnemonic: None, + ext_operands: None, + ext_disasm: None, + branch_target: None, + } +} + +fn with_ext( + base_mnem: &str, base_ops: String, base_pad: usize, + ext_mnem: &str, ext_ops: String, ext_pad: usize, +) -> DisasmText { + let disasm = pad_into(base_mnem, &base_ops, base_pad); + let ext_disasm = pad_into(ext_mnem, &ext_ops, ext_pad); + DisasmText { + mnemonic: base_mnem.to_string(), + operands: base_ops, + disasm, + ext_mnemonic: Some(ext_mnem.to_string()), + ext_operands: Some(ext_ops), + ext_disasm: Some(ext_disasm), + branch_target: None, + } +} + +fn with_target(mut t: DisasmText, target: u32) -> DisasmText { + t.branch_target = Some(target); + t +} + +fn long_word(raw: u32) -> DisasmText { + let operands = format!("0x{raw:08X}"); + base(".long", operands, 8) +} + +// ── Helpers (register names, sign extension, condition decoding) ──────────── + +#[inline] fn gpr(r: usize) -> String { format!("r{r}") } +#[inline] fn fpr(r: usize) -> String { format!("f{r}") } +#[inline] fn vr(r: usize) -> String { format!("v{r}") } + +fn crb(b: u32) -> String { + let cr = b / 4; + let bit = b % 4; + let bit_name = ["lt", "gt", "eq", "so"][bit as usize]; + if cr == 0 { bit_name.to_string() } else { format!("4*cr{cr}+{bit_name}") } +} + +fn spr_name(spr: u32) -> String { + match spr { + 1 => "XER".into(), + 8 => "LR".into(), + 9 => "CTR".into(), + _ => format!("spr{spr}"), + } +} + +#[inline] fn sign_ext(val: u32, bits: u32) -> i32 { + let shift = 32 - bits; + ((val << shift) as i32) >> shift +} + +/// Map trap TO field to condition suffix (e.g. 16 → "lt", 4 → "eq"). +/// Unsigned variants (`lgt`/`llt`/`lge`/`lle`) cover bits 1-3 of the TO +/// encoding which `tw`/`td` use for logical-compare conditions. +fn trap_cond(to: u32) -> Option<&'static str> { + match to { + 1 => Some("lgt"), + 2 => Some("llt"), + 3 => Some("lne"), + 4 => Some("eq"), + 5 => Some("lge"), + 6 => Some("lle"), + 8 => Some("gt"), + 12 => Some("ge"), + 16 => Some("lt"), + 20 => Some("le"), + 24 => Some("ne"), + 31 => Some(""), // unconditional + _ => None, + } +} + +/// For non-decrementing conditional branches: returns Some((cond_name, cr_prefix)) +/// where cr_prefix is e.g. "" or "cr2, ". +fn cond_branch_ext(bo: u32, bi: u32) -> Option<(&'static str, String)> { + let cond_true = bo & 0x08 != 0; + let no_cond = bo & 0x10 != 0; + let decr = bo & 0x04 == 0; + if no_cond || decr { return None; } + + let cr_field = bi / 4; + let cr_bit = bi % 4; + let cond_name = match (cr_bit, cond_true) { + (0, true) => "lt", (0, false) => "ge", + (1, true) => "gt", (1, false) => "le", + (2, true) => "eq", (2, false) => "ne", + (3, true) => "so", (3, false) => "ns", + _ => return None, + }; + let cr = if cr_field == 0 { String::new() } else { format!("cr{cr_field}, ") }; + Some((cond_name, cr)) +} + +#[inline] fn rc_dot(instr: &DecodedInstr) -> &'static str { + if instr.rc_bit() { "." } else { "" } +} + +// ── Public entrypoints ────────────────────────────────────────────────────── + +/// Format a decoded instruction into structured disassembly text. +pub fn format(instr: &DecodedInstr) -> DisasmText { + match instr.opcode { + // ── Branch ────────────────────────────────────────────────────────── + PpcOpcode::bx => fmt_b(instr), + PpcOpcode::bcx => fmt_bc(instr), + PpcOpcode::bclrx => fmt_bclr(instr), + PpcOpcode::bcctrx => fmt_bcctr(instr), + PpcOpcode::sc => base("sc", String::new(), 0), + + // ── Trap ──────────────────────────────────────────────────────────── + PpcOpcode::tdi => fmt_trap_imm(instr, "tdi", "td"), + PpcOpcode::twi => fmt_trap_imm(instr, "twi", "tw"), + PpcOpcode::td => fmt_trap_reg(instr, "td"), + PpcOpcode::tw => fmt_trap_reg(instr, "tw"), + + // ── D-form ALU/logical ────────────────────────────────────────────── + PpcOpcode::addi => fmt_addi(instr), + PpcOpcode::addis => fmt_addis(instr), + PpcOpcode::addic => fmt_d_add(instr, "addic"), + PpcOpcode::addicx => fmt_d_add(instr, "addic."), + PpcOpcode::subficx => fmt_d_imm_simple(instr, "subfic"), + PpcOpcode::mulli => fmt_d_imm_simple(instr, "mulli"), + PpcOpcode::cmpi => fmt_cmp_imm(instr, "cmpi", true), + PpcOpcode::cmpli => fmt_cmp_imm(instr, "cmpli", false), + PpcOpcode::ori => fmt_ori(instr), + PpcOpcode::oris => fmt_d_logic(instr, "oris"), + PpcOpcode::xori => fmt_d_logic(instr, "xori"), + PpcOpcode::xoris => fmt_d_logic(instr, "xoris"), + PpcOpcode::andix => fmt_d_logic(instr, "andi."), + PpcOpcode::andisx => fmt_d_logic(instr, "andis."), + + // ── D-form load/store ─────────────────────────────────────────────── + PpcOpcode::lwz => fmt_ld(instr, "lwz", false), + PpcOpcode::lwzu => fmt_ld(instr, "lwzu", false), + PpcOpcode::lbz => fmt_ld(instr, "lbz", false), + PpcOpcode::lbzu => fmt_ld(instr, "lbzu", false), + PpcOpcode::lhz => fmt_ld(instr, "lhz", false), + PpcOpcode::lhzu => fmt_ld(instr, "lhzu", false), + PpcOpcode::lha => fmt_ld(instr, "lha", false), + PpcOpcode::lhau => fmt_ld(instr, "lhau", false), + PpcOpcode::lmw => fmt_ld(instr, "lmw", false), + PpcOpcode::lfs => fmt_ld(instr, "lfs", true), + PpcOpcode::lfsu => fmt_ld(instr, "lfsu", true), + PpcOpcode::lfd => fmt_ld(instr, "lfd", true), + PpcOpcode::lfdu => fmt_ld(instr, "lfdu", true), + PpcOpcode::stw => fmt_st(instr, "stw", false), + PpcOpcode::stwu => fmt_st(instr, "stwu", false), + PpcOpcode::stb => fmt_st(instr, "stb", false), + PpcOpcode::stbu => fmt_st(instr, "stbu", false), + PpcOpcode::sth => fmt_st(instr, "sth", false), + PpcOpcode::sthu => fmt_st(instr, "sthu", false), + PpcOpcode::stmw => fmt_st(instr, "stmw", false), + PpcOpcode::stfs => fmt_st(instr, "stfs", true), + PpcOpcode::stfsu => fmt_st(instr, "stfsu", true), + PpcOpcode::stfd => fmt_st(instr, "stfd", true), + PpcOpcode::stfdu => fmt_st(instr, "stfdu", true), + + // ── DS-form load/store ────────────────────────────────────────────── + PpcOpcode::ld => fmt_ds(instr, "ld"), + PpcOpcode::ldu => fmt_ds(instr, "ldu"), + PpcOpcode::lwa => fmt_ds(instr, "lwa"), + PpcOpcode::std => fmt_ds(instr, "std"), + PpcOpcode::stdu => fmt_ds(instr, "stdu"), + + // ── Rotate ───────────────────────────────────────────────────────── + PpcOpcode::rlwimix => fmt_rlwimi(instr), + PpcOpcode::rlwinmx => fmt_rlwinm(instr), + PpcOpcode::rlwnmx => fmt_rlwnm(instr), + PpcOpcode::rldiclx => fmt_rldicl(instr), + PpcOpcode::rldicrx => fmt_rldicr(instr), + PpcOpcode::rldicx => fmt_rldic(instr), + PpcOpcode::rldimix => fmt_rldimi(instr), + PpcOpcode::rldclx => fmt_rldcl(instr), + PpcOpcode::rldcrx => fmt_rldcr(instr), + + // ── Compare (X-form) ─────────────────────────────────────────────── + PpcOpcode::cmp => fmt_cmp_reg(instr, "cmp"), + PpcOpcode::cmpl => fmt_cmp_reg(instr, "cmpl"), + + // ── X-form ALU (3-register) with OE/Rc ───────────────────────────── + PpcOpcode::addx => fmt_xo_3op(instr, "add"), + PpcOpcode::addcx => fmt_xo_3op(instr, "addc"), + PpcOpcode::addex => fmt_xo_3op(instr, "adde"), + PpcOpcode::addmex => fmt_xo_2op(instr, "addme"), + PpcOpcode::addzex => fmt_xo_2op(instr, "addze"), + PpcOpcode::subfx => fmt_subf(instr, "subf", "sub"), + PpcOpcode::subfcx => fmt_subf(instr, "subfc", "subc"), + PpcOpcode::subfex => fmt_xo_3op(instr, "subfe"), + PpcOpcode::subfmex => fmt_xo_2op(instr, "subfme"), + PpcOpcode::subfzex => fmt_xo_2op(instr, "subfze"), + PpcOpcode::negx => fmt_xo_2op(instr, "neg"), + PpcOpcode::mullwx => fmt_xo_3op(instr, "mullw"), + PpcOpcode::mulhwx => fmt_xo_3op_no_oe(instr, "mulhw"), + PpcOpcode::mulhwux => fmt_xo_3op_rc_only(instr, "mulhwu"), + PpcOpcode::divwx => fmt_xo_3op(instr, "divw"), + PpcOpcode::divwux => fmt_xo_3op(instr, "divwu"), + PpcOpcode::mulldx => fmt_xo_3op(instr, "mulld"), + PpcOpcode::mulhdx => fmt_xo_3op_rc_only(instr, "mulhd"), + PpcOpcode::mulhdux => fmt_xo_3op_rc_only(instr, "mulhdu"), + PpcOpcode::divdx => fmt_xo_3op(instr, "divd"), + PpcOpcode::divdux => fmt_xo_3op(instr, "divdu"), + + // ── X-form logical (Rc) ──────────────────────────────────────────── + PpcOpcode::andx => fmt_logic_and(instr), + PpcOpcode::andcx => fmt_x_logic(instr, "andc"), + PpcOpcode::orx => fmt_logic_or(instr), + PpcOpcode::orcx => fmt_x_logic(instr, "orc"), + PpcOpcode::xorx => fmt_x_logic(instr, "xor"), + PpcOpcode::norx => fmt_logic_nor(instr), + PpcOpcode::nandx => fmt_x_logic(instr, "nand"), + PpcOpcode::eqvx => fmt_x_logic(instr, "eqv"), + PpcOpcode::extsbx => fmt_x_unary_rc(instr, "extsb"), + PpcOpcode::extshx => fmt_x_unary_rc(instr, "extsh"), + PpcOpcode::extswx => fmt_x_unary_rc(instr, "extsw"), + PpcOpcode::cntlzwx => fmt_x_unary_rc(instr, "cntlzw"), + PpcOpcode::cntlzdx => fmt_x_unary_rc(instr, "cntlzd"), + + // ── Shift (32 / 64) ───────────────────────────────────────────────── + PpcOpcode::slwx => fmt_x_logic(instr, "slw"), + PpcOpcode::srwx => fmt_x_logic(instr, "srw"), + PpcOpcode::srawx => fmt_x_logic(instr, "sraw"), + PpcOpcode::sldx => fmt_x_logic(instr, "sld"), + PpcOpcode::srdx => fmt_x_logic(instr, "srd"), + PpcOpcode::sradx => fmt_x_logic(instr, "srad"), + PpcOpcode::srawix => fmt_srawi(instr), + PpcOpcode::sradix => fmt_sradi(instr), + + // ── Special register moves ───────────────────────────────────────── + PpcOpcode::mfspr => fmt_mfspr(instr), + PpcOpcode::mtspr => fmt_mtspr(instr), + PpcOpcode::mfcr => fmt_mfcr(instr), + PpcOpcode::mtcrf => fmt_mtcrf(instr), + PpcOpcode::mfmsr => base("mfmsr", gpr(instr.rd()), 8), + PpcOpcode::mtmsr => base("mtmsr", gpr(instr.rs()), 8), + PpcOpcode::mtmsrd => base("mtmsrd", gpr(instr.rs()), 8), + PpcOpcode::mftb => fmt_mftb(instr), + PpcOpcode::mcrxr => base("mcrxr", format!("cr{}", instr.crfd()), 8), + PpcOpcode::mcrf => base("mcrf", format!("cr{}, cr{}", instr.crfd(), instr.crfs()), 8), + + // ── X-form indexed load/store ────────────────────────────────────── + PpcOpcode::lwzx => fmt_x_load(instr, "lwzx", false), + PpcOpcode::lwzux => fmt_x_load(instr, "lwzux", false), + PpcOpcode::lbzx => fmt_x_load(instr, "lbzx", false), + PpcOpcode::lbzux => fmt_x_load(instr, "lbzux", false), + PpcOpcode::lhzx => fmt_x_load(instr, "lhzx", false), + PpcOpcode::lhzux => fmt_x_load(instr, "lhzux", false), + PpcOpcode::lhax => fmt_x_load(instr, "lhax", false), + PpcOpcode::lhaux => fmt_x_load(instr, "lhaux", false), + PpcOpcode::lwax => fmt_x_load(instr, "lwax", false), + PpcOpcode::lwaux => fmt_x_load(instr, "lwaux", false), + PpcOpcode::ldx => fmt_x_load(instr, "ldx", false), + PpcOpcode::ldux => fmt_x_load(instr, "ldux", false), + PpcOpcode::lwbrx => fmt_x_load(instr, "lwbrx", false), + PpcOpcode::lhbrx => fmt_x_load(instr, "lhbrx", false), + PpcOpcode::ldbrx => fmt_x_load(instr, "ldbrx", false), + PpcOpcode::lwarx => fmt_x_load(instr, "lwarx", false), + PpcOpcode::ldarx => fmt_x_load(instr, "ldarx", false), + PpcOpcode::lswx => fmt_x_load(instr, "lswx", false), + PpcOpcode::lswi => fmt_lswi_stswi(instr, "lswi"), + PpcOpcode::lfsx => fmt_x_load(instr, "lfsx", true), + PpcOpcode::lfsux => fmt_x_load(instr, "lfsux", true), + PpcOpcode::lfdx => fmt_x_load(instr, "lfdx", true), + PpcOpcode::lfdux => fmt_x_load(instr, "lfdux", true), + PpcOpcode::stwx => fmt_x_store(instr, "stwx", false), + PpcOpcode::stwux => fmt_x_store(instr, "stwux", false), + PpcOpcode::stbx => fmt_x_store(instr, "stbx", false), + PpcOpcode::stbux => fmt_x_store(instr, "stbux", false), + PpcOpcode::sthx => fmt_x_store(instr, "sthx", false), + PpcOpcode::sthux => fmt_x_store(instr, "sthux", false), + PpcOpcode::stdx => fmt_x_store(instr, "stdx", false), + PpcOpcode::stdux => fmt_x_store(instr, "stdux", false), + PpcOpcode::stwbrx => fmt_x_store(instr, "stwbrx", false), + PpcOpcode::sthbrx => fmt_x_store(instr, "sthbrx", false), + PpcOpcode::stdbrx => fmt_x_store(instr, "stdbrx", false), + PpcOpcode::stwcx => fmt_x_store(instr, "stwcx.", false), + PpcOpcode::stdcx => fmt_x_store(instr, "stdcx.", false), + PpcOpcode::stswx => fmt_x_store(instr, "stswx", false), + PpcOpcode::stswi => fmt_lswi_stswi(instr, "stswi"), + PpcOpcode::stfsx => fmt_x_store(instr, "stfsx", true), + PpcOpcode::stfsux => fmt_x_store(instr, "stfsux", true), + PpcOpcode::stfdx => fmt_x_store(instr, "stfdx", true), + PpcOpcode::stfdux => fmt_x_store(instr, "stfdux", true), + PpcOpcode::stfiwx => fmt_x_store(instr, "stfiwx", true), + + // ── Cache / sync ──────────────────────────────────────────────────── + PpcOpcode::dcbf => fmt_cache(instr, "dcbf"), + PpcOpcode::dcbi => fmt_cache(instr, "dcbi"), + PpcOpcode::dcbst => fmt_cache(instr, "dcbst"), + PpcOpcode::dcbt => fmt_cache(instr, "dcbt"), + PpcOpcode::dcbtst => fmt_cache(instr, "dcbtst"), + PpcOpcode::dcbz => fmt_cache(instr, "dcbz"), + PpcOpcode::dcbz128 => fmt_cache(instr, "dcbz128"), + PpcOpcode::icbi => fmt_cache(instr, "icbi"), + PpcOpcode::sync => { + // L-field at PPC bit 10 (host bit 21) selects lwsync (L=1), the + // acquire barrier in every Xbox 360 spinlock. PPCBUG-641. + if (instr.raw >> 21) & 1 == 1 { + with_ext("sync", String::new(), 0, "lwsync", String::new(), 0) + } else { + base("sync", String::new(), 0) + } + } + PpcOpcode::eieio => base("eieio", String::new(), 0), + PpcOpcode::isync => base("isync", String::new(), 0), + + // ── CR logical ────────────────────────────────────────────────────── + PpcOpcode::crand => fmt_cr_logic(instr, "crand"), + PpcOpcode::crandc => fmt_cr_logic(instr, "crandc"), + PpcOpcode::creqv => fmt_creqv(instr), + PpcOpcode::crnand => fmt_cr_logic(instr, "crnand"), + PpcOpcode::crnor => fmt_crnor(instr), + PpcOpcode::cror => fmt_cror(instr), + PpcOpcode::crorc => fmt_cr_logic(instr, "crorc"), + PpcOpcode::crxor => fmt_crxor(instr), + + // ── FPU (op59 / op63) ────────────────────────────────────────────── + PpcOpcode::fdivsx => fmt_a_3op(instr, "fdivs", false), + PpcOpcode::fsubsx => fmt_a_3op(instr, "fsubs", false), + PpcOpcode::faddsx => fmt_a_3op(instr, "fadds", false), + PpcOpcode::fsqrtsx => fmt_a_unary(instr, "fsqrts"), + PpcOpcode::fresx => fmt_a_unary(instr, "fres"), + PpcOpcode::fmulsx => fmt_a_3op(instr, "fmuls", true), + PpcOpcode::fmsubsx => fmt_a_4op(instr, "fmsubs"), + PpcOpcode::fmaddsx => fmt_a_4op(instr, "fmadds"), + PpcOpcode::fnmsubsx => fmt_a_4op(instr, "fnmsubs"), + PpcOpcode::fnmaddsx => fmt_a_4op(instr, "fnmadds"), + + PpcOpcode::fdivx => fmt_a_3op(instr, "fdiv", false), + PpcOpcode::fsubx => fmt_a_3op(instr, "fsub", false), + PpcOpcode::faddx => fmt_a_3op(instr, "fadd", false), + PpcOpcode::fsqrtx => fmt_a_unary(instr, "fsqrt"), + PpcOpcode::fselx => fmt_a_4op(instr, "fsel"), + PpcOpcode::fmulx => fmt_a_3op(instr, "fmul", true), + PpcOpcode::frsqrtex => fmt_a_unary(instr, "frsqrte"), + PpcOpcode::fmsubx => fmt_a_4op(instr, "fmsub"), + PpcOpcode::fmaddx => fmt_a_4op(instr, "fmadd"), + PpcOpcode::fnmsubx => fmt_a_4op(instr, "fnmsub"), + PpcOpcode::fnmaddx => fmt_a_4op(instr, "fnmadd"), + + PpcOpcode::fcmpu => fmt_fcmp(instr, "fcmpu"), + PpcOpcode::fcmpo => fmt_fcmp(instr, "fcmpo"), + PpcOpcode::frspx => fmt_x_fpu_unary(instr, "frsp"), + PpcOpcode::fctiwx => fmt_x_fpu_unary(instr, "fctiw"), + PpcOpcode::fctiwzx => fmt_x_fpu_unary(instr, "fctiwz"), + PpcOpcode::fnegx => fmt_x_fpu_unary(instr, "fneg"), + PpcOpcode::fmrx => fmt_x_fpu_unary(instr, "fmr"), + PpcOpcode::fnabsx => fmt_x_fpu_unary(instr, "fnabs"), + PpcOpcode::fabsx => fmt_x_fpu_unary(instr, "fabs"), + PpcOpcode::fctidx => fmt_x_fpu_unary(instr, "fctid"), + PpcOpcode::fctidzx => fmt_x_fpu_unary(instr, "fctidz"), + PpcOpcode::fcfidx => fmt_x_fpu_unary(instr, "fcfid"), + PpcOpcode::mffsx => { + let rc = rc_dot(instr); + base(&format!("mffs{rc}"), fpr(instr.rd()), 8) + } + PpcOpcode::mtfsfx => { + let rc = rc_dot(instr); + let fxm = (instr.raw >> 17) & 0xFF; + let frb = (instr.raw >> 11) & 0x1F; + base(&format!("mtfsf{rc}"), format!("0x{fxm:02X}, {}", fpr(frb as usize)), 8) + } + PpcOpcode::mtfsb1x => fmt_mtfsb(instr, "mtfsb1"), + PpcOpcode::mtfsb0x => fmt_mtfsb(instr, "mtfsb0"), + PpcOpcode::mtfsfix => { + let rc = rc_dot(instr); + let bf = instr.crfd(); + let imm = (instr.raw >> 12) & 0xF; + base(&format!("mtfsfi{rc}"), format!("cr{bf}, {imm}"), 8) + } + PpcOpcode::mcrfs => base("mcrfs", format!("cr{}, cr{}", instr.crfd(), instr.crfs()), 8), + + // ── Standard VMX (5-bit registers) ──────────────────────────────── + // 3-operand VD, VA, VB + // `vor vD,vA,vA` is the canonical vector register move, and + // `vnor vD,vA,vA` the canonical vector complement. Both are extremely + // common (1,535 and 9 sites here) and both read as noise in base form. + PpcOpcode::vor if instr.ra() == instr.rb() => { + fmt_vmx_move(instr, "vor", "vmr") + } + PpcOpcode::vnor if instr.ra() == instr.rb() => { + fmt_vmx_move(instr, "vnor", "vnot") + } + + PpcOpcode::vaddubm | PpcOpcode::vmaxub | PpcOpcode::vrlb | PpcOpcode::vmuloub | + PpcOpcode::vaddfp | PpcOpcode::vmrghb | PpcOpcode::vpkuhum | + PpcOpcode::vadduhm | PpcOpcode::vmaxuh | PpcOpcode::vrlh | PpcOpcode::vmulouh | + PpcOpcode::vsubfp | PpcOpcode::vmrghh | PpcOpcode::vpkuwum | + PpcOpcode::vadduwm | PpcOpcode::vmaxuw | PpcOpcode::vrlw | PpcOpcode::vmrghw | + PpcOpcode::vpkuhus | PpcOpcode::vpkuwus | + PpcOpcode::vmaxsb | PpcOpcode::vslb | PpcOpcode::vmulosb | PpcOpcode::vmrglb | + PpcOpcode::vpkshus | PpcOpcode::vmaxsh | PpcOpcode::vslh | PpcOpcode::vmulosh | + PpcOpcode::vmrglh | PpcOpcode::vpkswus | PpcOpcode::vaddcuw | PpcOpcode::vmaxsw | + PpcOpcode::vslw | PpcOpcode::vmrglw | PpcOpcode::vpkshss | PpcOpcode::vsl | + PpcOpcode::vpkswss | PpcOpcode::vaddubs | PpcOpcode::vminub | PpcOpcode::vsrb | + PpcOpcode::vmuleub | PpcOpcode::vadduhs | PpcOpcode::vminuh | PpcOpcode::vsrh | + PpcOpcode::vmuleuh | PpcOpcode::vadduws | PpcOpcode::vminuw | PpcOpcode::vsrw | + PpcOpcode::vsr | PpcOpcode::vaddsbs | PpcOpcode::vminsb | PpcOpcode::vsrab | + PpcOpcode::vmulesb | PpcOpcode::vpkpx | PpcOpcode::vaddshs | PpcOpcode::vminsh | + PpcOpcode::vsrah | PpcOpcode::vmulesh | PpcOpcode::vaddsws | PpcOpcode::vminsw | + PpcOpcode::vsraw | PpcOpcode::vsububm | PpcOpcode::vavgub | PpcOpcode::vand | + PpcOpcode::vmaxfp | PpcOpcode::vslo | PpcOpcode::vsubuhm | PpcOpcode::vavguh | + PpcOpcode::vandc | PpcOpcode::vminfp | PpcOpcode::vsro | PpcOpcode::vsubuwm | + PpcOpcode::vavguw | PpcOpcode::vor | PpcOpcode::vxor | PpcOpcode::vavgsb | + PpcOpcode::vnor | PpcOpcode::vavgsh | PpcOpcode::vsubcuw | PpcOpcode::vavgsw | + PpcOpcode::vsububs | PpcOpcode::vsum4ubs| PpcOpcode::vsubuhs | PpcOpcode::vsum4shs | + PpcOpcode::vsubuws | PpcOpcode::vsum2sws| PpcOpcode::vsubsbs | PpcOpcode::vsum4sbs | + PpcOpcode::vsubshs | PpcOpcode::vsubsws | PpcOpcode::vsumsws => { + fmt_vmx_3op(instr, opcode_name(instr.opcode)) + } + + + // VMX unary VD, VB + PpcOpcode::vrefp | PpcOpcode::vrsqrtefp | PpcOpcode::vexptefp | + PpcOpcode::vlogefp | PpcOpcode::vrfin | PpcOpcode::vrfiz | + PpcOpcode::vrfip | PpcOpcode::vrfim | PpcOpcode::vupkhsb | + PpcOpcode::vupkhsh | PpcOpcode::vupklsb | PpcOpcode::vupklsh | + PpcOpcode::vupkhpx | PpcOpcode::vupklpx => { + fmt_vmx_unary(instr, opcode_name(instr.opcode)) + } + + // VMX VD, VB, UIMM (VA = uimm field) + PpcOpcode::vspltb | PpcOpcode::vsplth | PpcOpcode::vspltw | + PpcOpcode::vcfux | PpcOpcode::vcfsx | + PpcOpcode::vctuxs | PpcOpcode::vctsxs => { + fmt_vmx_uimm(instr, opcode_name(instr.opcode)) + } + + // VMX VD, SIMM (VA field as 5-bit signed immediate) + PpcOpcode::vspltisb => fmt_vmx_simm(instr, "vspltisb"), + PpcOpcode::vspltish => fmt_vmx_simm(instr, "vspltish"), + PpcOpcode::vspltisw => fmt_vmx_simm(instr, "vspltisw"), + + PpcOpcode::mfvscr => base("mfvscr", vr(instr.rd()), 8), + PpcOpcode::mtvscr => base("mtvscr", vr(instr.rb()), 8), + + // VMX compare (Rc bit at bit 21) + PpcOpcode::vcmpequb | PpcOpcode::vcmpequh | PpcOpcode::vcmpequw | + PpcOpcode::vcmpeqfp | PpcOpcode::vcmpgefp | PpcOpcode::vcmpgtub | + PpcOpcode::vcmpgtuh | PpcOpcode::vcmpgtuw | PpcOpcode::vcmpgtfp | + PpcOpcode::vcmpgtsb | PpcOpcode::vcmpgtsh | PpcOpcode::vcmpgtsw | + PpcOpcode::vcmpbfp => fmt_vmx_cmp(instr, opcode_name(instr.opcode)), + + // VMX 4-operand VD, VA, VB, VC + PpcOpcode::vmhaddshs | PpcOpcode::vmhraddshs | PpcOpcode::vmladduhm | + PpcOpcode::vmsumubm | PpcOpcode::vmsummbm | PpcOpcode::vmsumuhm | + PpcOpcode::vmsumuhs | PpcOpcode::vmsumshm | PpcOpcode::vmsumshs | + PpcOpcode::vsel | PpcOpcode::vperm => { + fmt_vmx_4op(instr, opcode_name(instr.opcode)) + } + + PpcOpcode::vsldoi => fmt_vsldoi(instr), + PpcOpcode::vmaddfp => fmt_vmx_4op_swap(instr, "vmaddfp"), + PpcOpcode::vnmsubfp => fmt_vmx_4op_swap(instr, "vnmsubfp"), + + // ── VMX128 load/store (uses GPR addressing + vd128 dest) ─────────── + PpcOpcode::lvsl128 => fmt_vmx128_ls(instr, "lvsl128"), + PpcOpcode::lvsr128 => fmt_vmx128_ls(instr, "lvsr128"), + PpcOpcode::lvewx128 => fmt_vmx128_ls(instr, "lvewx128"), + PpcOpcode::lvx128 => fmt_vmx128_ls(instr, "lvx128"), + PpcOpcode::lvxl128 => fmt_vmx128_ls(instr, "lvxl128"), + PpcOpcode::lvlx128 => fmt_vmx128_ls(instr, "lvlx128"), + PpcOpcode::lvrx128 => fmt_vmx128_ls(instr, "lvrx128"), + PpcOpcode::lvlxl128 => fmt_vmx128_ls(instr, "lvlxl128"), + PpcOpcode::lvrxl128 => fmt_vmx128_ls(instr, "lvrxl128"), + PpcOpcode::stvewx128 => fmt_vmx128_ls(instr, "stvewx128"), + PpcOpcode::stvx128 => fmt_vmx128_ls(instr, "stvx128"), + PpcOpcode::stvxl128 => fmt_vmx128_ls(instr, "stvxl128"), + PpcOpcode::stvlx128 => fmt_vmx128_ls(instr, "stvlx128"), + PpcOpcode::stvrx128 => fmt_vmx128_ls(instr, "stvrx128"), + PpcOpcode::stvlxl128 => fmt_vmx128_ls(instr, "stvlxl128"), + PpcOpcode::stvrxl128 => fmt_vmx128_ls(instr, "stvrxl128"), + + // Standard AltiVec load/store indexed (5-bit vr0-vr31) + PpcOpcode::lvsl => fmt_vmx_ls(instr, "lvsl"), + PpcOpcode::lvsr => fmt_vmx_ls(instr, "lvsr"), + PpcOpcode::lvebx => fmt_vmx_ls(instr, "lvebx"), + PpcOpcode::lvehx => fmt_vmx_ls(instr, "lvehx"), + PpcOpcode::lvewx => fmt_vmx_ls(instr, "lvewx"), + PpcOpcode::lvx => fmt_vmx_ls(instr, "lvx"), + PpcOpcode::lvxl => fmt_vmx_ls(instr, "lvxl"), + PpcOpcode::lvlx => fmt_vmx_ls(instr, "lvlx"), + PpcOpcode::lvrx => fmt_vmx_ls(instr, "lvrx"), + PpcOpcode::lvlxl => fmt_vmx_ls(instr, "lvlxl"), + PpcOpcode::lvrxl => fmt_vmx_ls(instr, "lvrxl"), + PpcOpcode::stvebx => fmt_vmx_ls(instr, "stvebx"), + PpcOpcode::stvehx => fmt_vmx_ls(instr, "stvehx"), + PpcOpcode::stvewx => fmt_vmx_ls(instr, "stvewx"), + PpcOpcode::stvx => fmt_vmx_ls(instr, "stvx"), + PpcOpcode::stvxl => fmt_vmx_ls(instr, "stvxl"), + PpcOpcode::stvlx => fmt_vmx_ls(instr, "stvlx"), + PpcOpcode::stvrx => fmt_vmx_ls(instr, "stvrx"), + PpcOpcode::stvlxl => fmt_vmx_ls(instr, "stvlxl"), + PpcOpcode::stvrxl => fmt_vmx_ls(instr, "stvrxl"), + + // ── VMX128 op5 (3-op and 4-op fp/pack/logic) ─────────────────────── + PpcOpcode::vaddfp128 => fmt_vmx128_3op(instr, "vaddfp128"), + PpcOpcode::vsubfp128 => fmt_vmx128_3op(instr, "vsubfp128"), + PpcOpcode::vmulfp128 => fmt_vmx128_3op(instr, "vmulfp128"), + PpcOpcode::vmsum3fp128 => fmt_vmx128_3op(instr, "vmsum3fp128"), + PpcOpcode::vmsum4fp128 => fmt_vmx128_3op(instr, "vmsum4fp128"), + PpcOpcode::vpkshss128 => fmt_vmx128_3op(instr, "vpkshss128"), + PpcOpcode::vpkshus128 => fmt_vmx128_3op(instr, "vpkshus128"), + PpcOpcode::vpkswss128 => fmt_vmx128_3op(instr, "vpkswss128"), + PpcOpcode::vpkswus128 => fmt_vmx128_3op(instr, "vpkswus128"), + PpcOpcode::vpkuhum128 => fmt_vmx128_3op(instr, "vpkuhum128"), + PpcOpcode::vpkuhus128 => fmt_vmx128_3op(instr, "vpkuhus128"), + PpcOpcode::vpkuwum128 => fmt_vmx128_3op(instr, "vpkuwum128"), + PpcOpcode::vpkuwus128 => fmt_vmx128_3op(instr, "vpkuwus128"), + PpcOpcode::vand128 => fmt_vmx128_3op(instr, "vand128"), + PpcOpcode::vandc128 => fmt_vmx128_3op(instr, "vandc128"), + PpcOpcode::vnor128 => fmt_vmx128_3op(instr, "vnor128"), + PpcOpcode::vor128 => fmt_vmx128_3op(instr, "vor128"), + PpcOpcode::vxor128 => fmt_vmx128_3op(instr, "vxor128"), + PpcOpcode::vsel128 => fmt_vmx128_3op(instr, "vsel128"), + PpcOpcode::vslo128 => fmt_vmx128_3op(instr, "vslo128"), + PpcOpcode::vsro128 => fmt_vmx128_3op(instr, "vsro128"), + + PpcOpcode::vmaddfp128 => fmt_vmaddfp128(instr), + PpcOpcode::vmaddcfp128 => fmt_vmx128_madd_vd_vb(instr, "vmaddcfp128"), + PpcOpcode::vnmsubfp128 => fmt_vmx128_madd_vd_vb(instr, "vnmsubfp128"), + + PpcOpcode::vperm128 => fmt_vperm128(instr), + PpcOpcode::vsldoi128 => fmt_vsldoi128(instr), + PpcOpcode::vpermwi128 => fmt_vpermwi128(instr), + + // ── VMX128 op6 special ───────────────────────────────────────────── + PpcOpcode::vpkd3d128 => fmt_vmx128_pack_d3d(instr, "vpkd3d128"), + PpcOpcode::vrlimi128 => fmt_vmx128_pack_d3d(instr, "vrlimi128"), + PpcOpcode::vrfim128 => fmt_vmx128_unary(instr, "vrfim128"), + PpcOpcode::vrfin128 => fmt_vmx128_unary(instr, "vrfin128"), + PpcOpcode::vrfip128 => fmt_vmx128_unary(instr, "vrfip128"), + PpcOpcode::vrfiz128 => fmt_vmx128_unary(instr, "vrfiz128"), + PpcOpcode::vrefp128 => fmt_vmx128_unary(instr, "vrefp128"), + PpcOpcode::vrsqrtefp128 => fmt_vmx128_unary(instr, "vrsqrtefp128"), + PpcOpcode::vexptefp128 => fmt_vmx128_unary(instr, "vexptefp128"), + PpcOpcode::vlogefp128 => fmt_vmx128_unary(instr, "vlogefp128"), + PpcOpcode::vcfpsxws128 => fmt_vmx128_uimm(instr, "vcfpsxws128"), + PpcOpcode::vcfpuxws128 => fmt_vmx128_uimm(instr, "vcfpuxws128"), + PpcOpcode::vcsxwfp128 => fmt_vmx128_uimm(instr, "vcsxwfp128"), + PpcOpcode::vcuxwfp128 => fmt_vmx128_uimm(instr, "vcuxwfp128"), + PpcOpcode::vspltw128 => fmt_vmx128_uimm(instr, "vspltw128"), + PpcOpcode::vupkd3d128 => fmt_vmx128_uimm(instr, "vupkd3d128"), + PpcOpcode::vspltisw128 => { + let vd = instr.vd128(); + let simm = sign_ext(extract_vx128_uimm5(instr.raw), 5); + base("vspltisw128", format!("{}, {simm}", vr(vd)), 14) + } + PpcOpcode::vcmpeqfp128 => fmt_vmx128_cmp(instr, "vcmpeqfp128"), + PpcOpcode::vcmpgefp128 => fmt_vmx128_cmp(instr, "vcmpgefp128"), + PpcOpcode::vcmpgtfp128 => fmt_vmx128_cmp(instr, "vcmpgtfp128"), + PpcOpcode::vcmpbfp128 => fmt_vmx128_cmp(instr, "vcmpbfp128"), + PpcOpcode::vcmpequw128 => fmt_vmx128_cmp(instr, "vcmpequw128"), + PpcOpcode::vrlw128 => fmt_vmx128_3op(instr, "vrlw128"), + PpcOpcode::vslw128 => fmt_vmx128_3op(instr, "vslw128"), + PpcOpcode::vsraw128 => fmt_vmx128_3op(instr, "vsraw128"), + PpcOpcode::vsrw128 => fmt_vmx128_3op(instr, "vsrw128"), + PpcOpcode::vmaxfp128 => fmt_vmx128_3op(instr, "vmaxfp128"), + PpcOpcode::vminfp128 => fmt_vmx128_3op(instr, "vminfp128"), + PpcOpcode::vmrghw128 => fmt_vmx128_3op(instr, "vmrghw128"), + PpcOpcode::vmrglw128 => fmt_vmx128_3op(instr, "vmrglw128"), + PpcOpcode::vupkhsb128 => fmt_vmx128_3op(instr, "vupkhsb128"), + PpcOpcode::vupklsb128 => fmt_vmx128_3op(instr, "vupklsb128"), + + PpcOpcode::Invalid => long_word(instr.raw), + } +} + +/// Disassemble a decoded instruction into PPC assembly text. +/// +/// Back-compat entry point: returns the same single-string the legacy +/// formatter produced, preferring the extended form when present. +pub fn disassemble(instr: &DecodedInstr) -> String { + format(instr).display().to_string() +} + +/// Disassemble a range of instructions from a byte slice. +pub fn disassemble_block(data: &[u8], base_addr: u32, count: usize) -> Vec<(u32, String)> { + let mut result = Vec::new(); + for i in 0..count { + let offset = i * 4; + if offset + 4 > data.len() { + break; + } + let raw = u32::from_be_bytes([ + data[offset], data[offset + 1], data[offset + 2], data[offset + 3], + ]); + let addr = base_addr + offset as u32; + let instr = crate::decoder::decode(raw, addr); + let text = disassemble(&instr); + result.push((addr, text)); + } + result +} + +/// One yielded instruction from [`iter_disasm`]. Carries the absolute VA, +/// raw word, decoded opcode and the formatted text — everything a sink +/// needs to render or persist a single row without re-parsing. +#[derive(Debug, Clone)] +pub struct DisasmItem { + pub addr: u32, + pub raw: u32, + pub opcode: PpcOpcode, + pub text: DisasmText, +} + +/// Iterate over instructions in the VA range `[va_start, va_end)` of an +/// image-mapped byte slice. `image[rva]` must hold the byte at absolute VA +/// `image_base + rva` (the layout produced by [`sylpheed_xex::loader`]). +/// +/// Stops on a truncated tail (less than 4 bytes remaining at the cursor). +/// Yields nothing if `va_start >= va_end` or the start RVA is beyond the +/// image. +pub fn iter_disasm( + image: &[u8], + image_base: u32, + va_start: u32, + va_end: u32, +) -> impl Iterator + '_ { + DisasmIter { image, image_base, va: va_start, end: va_end } +} + +struct DisasmIter<'a> { + image: &'a [u8], + image_base: u32, + va: u32, + end: u32, +} + +impl Iterator for DisasmIter<'_> { + type Item = DisasmItem; + #[inline] + fn next(&mut self) -> Option { + if self.va >= self.end { + return None; + } + let rva = self.va.wrapping_sub(self.image_base) as usize; + if rva + 4 > self.image.len() { + return None; + } + let raw = u32::from_be_bytes([ + self.image[rva], + self.image[rva + 1], + self.image[rva + 2], + self.image[rva + 3], + ]); + let abs = self.va; + let decoded = crate::decoder::decode(raw, abs); + let text = format(&decoded); + self.va = self.va.wrapping_add(4); + Some(DisasmItem { addr: abs, raw, opcode: decoded.opcode, text }) + } +} + +// ── Per-class formatters ─────────────────────────────────────────────────── + +fn opcode_name(op: PpcOpcode) -> &'static str { + // Used for VMX where the enum variant name matches the canonical mnemonic. + // For ALU/FPU variants ending in "x", use hardcoded strings instead. + match op { + PpcOpcode::vaddubm => "vaddubm", PpcOpcode::vmaxub => "vmaxub", PpcOpcode::vrlb => "vrlb", + PpcOpcode::vmuloub => "vmuloub", PpcOpcode::vaddfp => "vaddfp", PpcOpcode::vmrghb => "vmrghb", + PpcOpcode::vpkuhum => "vpkuhum", PpcOpcode::vadduhm => "vadduhm", PpcOpcode::vmaxuh => "vmaxuh", + PpcOpcode::vrlh => "vrlh", PpcOpcode::vmulouh => "vmulouh", PpcOpcode::vsubfp => "vsubfp", + PpcOpcode::vmrghh => "vmrghh", PpcOpcode::vpkuwum => "vpkuwum", + PpcOpcode::vadduwm => "vadduwm", PpcOpcode::vmaxuw => "vmaxuw", PpcOpcode::vrlw => "vrlw", + PpcOpcode::vmrghw => "vmrghw", PpcOpcode::vpkuhus => "vpkuhus", PpcOpcode::vpkuwus => "vpkuwus", + PpcOpcode::vmaxsb => "vmaxsb", PpcOpcode::vslb => "vslb", PpcOpcode::vmulosb => "vmulosb", + PpcOpcode::vmrglb => "vmrglb", PpcOpcode::vpkshus => "vpkshus", PpcOpcode::vmaxsh => "vmaxsh", + PpcOpcode::vslh => "vslh", PpcOpcode::vmulosh => "vmulosh", PpcOpcode::vmrglh => "vmrglh", + PpcOpcode::vpkswus => "vpkswus", PpcOpcode::vaddcuw => "vaddcuw", PpcOpcode::vmaxsw => "vmaxsw", + PpcOpcode::vslw => "vslw", PpcOpcode::vmrglw => "vmrglw", PpcOpcode::vpkshss => "vpkshss", + PpcOpcode::vsl => "vsl", PpcOpcode::vpkswss => "vpkswss", + PpcOpcode::vaddubs => "vaddubs", PpcOpcode::vminub => "vminub", PpcOpcode::vsrb => "vsrb", + PpcOpcode::vmuleub => "vmuleub", PpcOpcode::vadduhs => "vadduhs", PpcOpcode::vminuh => "vminuh", + PpcOpcode::vsrh => "vsrh", PpcOpcode::vmuleuh => "vmuleuh", + PpcOpcode::vadduws => "vadduws", PpcOpcode::vminuw => "vminuw", PpcOpcode::vsrw => "vsrw", + PpcOpcode::vsr => "vsr", + PpcOpcode::vaddsbs => "vaddsbs", PpcOpcode::vminsb => "vminsb", PpcOpcode::vsrab => "vsrab", + PpcOpcode::vmulesb => "vmulesb", PpcOpcode::vpkpx => "vpkpx", + PpcOpcode::vaddshs => "vaddshs", PpcOpcode::vminsh => "vminsh", PpcOpcode::vsrah => "vsrah", + PpcOpcode::vmulesh => "vmulesh", + PpcOpcode::vaddsws => "vaddsws", PpcOpcode::vminsw => "vminsw", PpcOpcode::vsraw => "vsraw", + PpcOpcode::vsububm => "vsububm", PpcOpcode::vavgub => "vavgub", PpcOpcode::vand => "vand", + PpcOpcode::vmaxfp => "vmaxfp", PpcOpcode::vslo => "vslo", + PpcOpcode::vsubuhm => "vsubuhm", PpcOpcode::vavguh => "vavguh", PpcOpcode::vandc => "vandc", + PpcOpcode::vminfp => "vminfp", PpcOpcode::vsro => "vsro", + PpcOpcode::vsubuwm => "vsubuwm", PpcOpcode::vavguw => "vavguw", PpcOpcode::vor => "vor", + PpcOpcode::vxor => "vxor", PpcOpcode::vavgsb => "vavgsb", PpcOpcode::vnor => "vnor", + PpcOpcode::vavgsh => "vavgsh", PpcOpcode::vsubcuw => "vsubcuw", PpcOpcode::vavgsw => "vavgsw", + PpcOpcode::vsububs => "vsububs", PpcOpcode::vsum4ubs => "vsum4ubs", + PpcOpcode::vsubuhs => "vsubuhs", PpcOpcode::vsum4shs => "vsum4shs", + PpcOpcode::vsubuws => "vsubuws", PpcOpcode::vsum2sws => "vsum2sws", + PpcOpcode::vsubsbs => "vsubsbs", PpcOpcode::vsum4sbs => "vsum4sbs", + PpcOpcode::vsubshs => "vsubshs", PpcOpcode::vsubsws => "vsubsws", + PpcOpcode::vsumsws => "vsumsws", + + PpcOpcode::vrefp => "vrefp", PpcOpcode::vrsqrtefp => "vrsqrtefp", + PpcOpcode::vexptefp => "vexptefp", PpcOpcode::vlogefp => "vlogefp", + PpcOpcode::vrfin => "vrfin", PpcOpcode::vrfiz => "vrfiz", + PpcOpcode::vrfip => "vrfip", PpcOpcode::vrfim => "vrfim", + PpcOpcode::vupkhsb => "vupkhsb", PpcOpcode::vupkhsh => "vupkhsh", + PpcOpcode::vupklsb => "vupklsb", PpcOpcode::vupklsh => "vupklsh", + PpcOpcode::vupkhpx => "vupkhpx", PpcOpcode::vupklpx => "vupklpx", + + PpcOpcode::vspltb => "vspltb", PpcOpcode::vsplth => "vsplth", PpcOpcode::vspltw => "vspltw", + PpcOpcode::vcfux => "vcfux", PpcOpcode::vcfsx => "vcfsx", + PpcOpcode::vctuxs => "vctuxs", PpcOpcode::vctsxs => "vctsxs", + + PpcOpcode::vcmpequb => "vcmpequb", PpcOpcode::vcmpequh => "vcmpequh", + PpcOpcode::vcmpequw => "vcmpequw", PpcOpcode::vcmpeqfp => "vcmpeqfp", + PpcOpcode::vcmpgefp => "vcmpgefp", PpcOpcode::vcmpgtub => "vcmpgtub", + PpcOpcode::vcmpgtuh => "vcmpgtuh", PpcOpcode::vcmpgtuw => "vcmpgtuw", + PpcOpcode::vcmpgtfp => "vcmpgtfp", PpcOpcode::vcmpgtsb => "vcmpgtsb", + PpcOpcode::vcmpgtsh => "vcmpgtsh", PpcOpcode::vcmpgtsw => "vcmpgtsw", + PpcOpcode::vcmpbfp => "vcmpbfp", + + PpcOpcode::vmhaddshs => "vmhaddshs", PpcOpcode::vmhraddshs => "vmhraddshs", + PpcOpcode::vmladduhm => "vmladduhm", + PpcOpcode::vmsumubm => "vmsumubm", PpcOpcode::vmsummbm => "vmsummbm", + PpcOpcode::vmsumuhm => "vmsumuhm", PpcOpcode::vmsumuhs => "vmsumuhs", + PpcOpcode::vmsumshm => "vmsumshm", PpcOpcode::vmsumshs => "vmsumshs", + PpcOpcode::vsel => "vsel", PpcOpcode::vperm => "vperm", + _ => "?", + } +} + +// Branches (I-form: b/bl/ba/bla) — produces base + extended forms. +fn fmt_b(instr: &DecodedInstr) -> DisasmText { + let aa = instr.aa(); + let lk = instr.lk(); + let target = if aa { instr.li() as u32 } + else { instr.addr.wrapping_add(instr.li() as u32) }; + let mnem = match (aa, lk) { + (false, false) => "b", + (false, true) => "bl", + (true, false) => "ba", + (true, true) => "bla", + }; + let ops = format!("0x{target:08X}"); + with_target(base(mnem, ops, 8), target) +} + +/// Static branch-prediction hint suffix for a `BO` field. +/// +/// PowerISA gives several `BO` encodings an `at` pair — `001at`, `011at`, +/// `1a00t`, `1a01t` — where `at=0b10` means "unlikely" (`-`) and `0b11` means +/// "likely" (`+`); `0b00` is "no hint" and `0b01` is reserved. The forms whose +/// low bit is the reserved `z` (`0000z`, `0001z`, `0100z`, `0101z`) carry no +/// hint at all. Dropping the suffix loses the compiler's static prediction, +/// which is the only place it is recorded. +fn hint_suffix(bo: u32) -> &'static str { + let b = |i: u32| (bo >> (4 - i)) & 1; // b(0) is the MSB of the 5-bit field + let at = match (b(0), b(2), b(3)) { + // 1z1zz — branch always, no hint. + (1, 1, _) => return "", + // 1a00t / 1a01t — the `a` bit is b1. + (1, 0, _) => (b(1) << 1) | b(4), + // 001at / 011at — the `a` bit is b3. + (0, 1, _) => (b(3) << 1) | b(4), + // 0000z / 0001z / 0100z / 0101z — low bit reserved, no hint. + _ => return "", + }; + match at { + 0b10 => "-", + 0b11 => "+", + _ => "", + } +} + +fn fmt_bc(instr: &DecodedInstr) -> DisasmText { + let bo = instr.bo(); + let bi = instr.bi(); + let aa = instr.aa(); + let lk = instr.lk(); + let target = if aa { instr.bd() as u32 } + else { instr.addr.wrapping_add(instr.bd() as u32) }; + + let a = if aa { "a" } else { "" }; + let l = if lk { "l" } else { "" }; + let base_mnem = format!("bc{a}{l}"); + let base_ops = format!("{bo}, {}, 0x{target:08X}", crb(bi)); + + // Extended forms. + let cr_field = bi / 4; + let cr_bit = bi % 4; + let decr = bo & 0x04 == 0; + let uncond = bo & 0x10 != 0; + + let hint = hint_suffix(bo); + let result = if uncond && !decr { + // Unconditional branch. + let ext_mnem = format!("b{a}{l}"); + let ext_ops = format!("0x{target:08X}"); + with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8) + } else { + let cond_true = bo & 0x08 != 0; + let cond_name_opt: Option<&'static str> = match (cr_bit, cond_true) { + (0, true) => Some("lt"), (0, false) => Some("ge"), + (1, true) => Some("gt"), (1, false) => Some("le"), + (2, true) => Some("eq"), (2, false) => Some("ne"), + (3, true) => Some("so"), (3, false) => Some("ns"), + _ => None, + }; + let cr = if cr_field == 0 { String::new() } else { format!("cr{cr_field}, ") }; + + if decr { + let z = if bo & 0x02 != 0 { "z" } else { "nz" }; + if uncond { + // BO bit 4 set means CR is ignored — a pure CTR-decrement + // branch. Without this guard bdnz/bdz would emit a spurious + // `ge` suffix derived from the don't-care BI=0 / + // cond_true=false pair (PPCBUG-640). + let ext_mnem = format!("bd{z}{a}{l}{hint}"); + let ext_ops = format!("0x{target:08X}"); + with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8) + } else { + // Combined CTR + condition. PowerISA names these `bdnzt` / + // `bdnzf` / `bdzt` / `bdzf` with the CR bit as an operand — + // not a condition-suffixed `bdnzne`, which is an invention no + // assembler accepts. + let t = if cond_true { "t" } else { "f" }; + let ext_mnem = format!("bd{z}{t}{a}{l}{hint}"); + let ext_ops = format!("{}, 0x{target:08X}", crb(bi)); + with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8) + } + } else if let Some(cond_name) = cond_name_opt { + let ext_mnem = format!("b{cond_name}{a}{l}{hint}"); + let ext_ops = format!("{cr}0x{target:08X}"); + with_ext(&base_mnem, base_ops, 8, &ext_mnem, ext_ops, 8) + } else { + base(&base_mnem, base_ops, 8) + } + }; + with_target(result, target) +} + +fn fmt_bclr(instr: &DecodedInstr) -> DisasmText { + let bo = instr.bo(); + let bi = instr.bi(); + let lk = instr.lk(); + let l = if lk { "l" } else { "" }; + let base_mnem = format!("bclr{l}"); + let base_ops = format!("{bo}, {}", crb(bi)); + let hint = hint_suffix(bo); + + // BO=20 (binary 10100) sets both "ignore CTR" and "ignore CR" bits, making + // the branch unconditional regardless of BI. BI is don't-care by spec, so + // the simplified `blr`/`blrl` form applies for any BI value. + if bo == 20 { + let ext = if lk { "blrl" } else { "blr" }; + return with_ext(&base_mnem, base_ops, 8, ext, String::new(), 0); + } + if let Some((cond, cr)) = cond_branch_ext(bo, bi) { + let cr_no_comma = cr.trim_end_matches(", "); + let ext_mnem = format!("b{cond}lr{l}{hint}"); + if cr_no_comma.is_empty() { + return with_ext(&base_mnem, base_ops, 8, &ext_mnem, String::new(), 0); + } else { + return with_ext(&base_mnem, base_ops, 8, &ext_mnem, cr_no_comma.to_string(), 8); + } + } + let decr = bo & 0x04 == 0; + let uncond = bo & 0x10 != 0; + if decr && uncond { + let z = if bo & 0x02 != 0 { "z" } else { "nz" }; + let ext_mnem = format!("bd{z}lr{l}{hint}"); + return with_ext(&base_mnem, base_ops, 8, &ext_mnem, String::new(), 0); + } + base(&base_mnem, base_ops, 8) +} + +fn fmt_bcctr(instr: &DecodedInstr) -> DisasmText { + let bo = instr.bo(); + let bi = instr.bi(); + let lk = instr.lk(); + let l = if lk { "l" } else { "" }; + let base_mnem = format!("bcctr{l}"); + let base_ops = format!("{bo}, {}", crb(bi)); + let hint = hint_suffix(bo); + + // BO=20 unconditional pattern: BI is don't-care (see fmt_bclr). + if bo == 20 { + let ext = if lk { "bctrl" } else { "bctr" }; + return with_ext(&base_mnem, base_ops, 8, ext, String::new(), 0); + } + if let Some((cond, cr)) = cond_branch_ext(bo, bi) { + let cr_no_comma = cr.trim_end_matches(", "); + let ext_mnem = format!("b{cond}ctr{l}{hint}"); + if cr_no_comma.is_empty() { + return with_ext(&base_mnem, base_ops, 8, &ext_mnem, String::new(), 0); + } else { + return with_ext(&base_mnem, base_ops, 8, &ext_mnem, cr_no_comma.to_string(), 8); + } + } + base(&base_mnem, base_ops, 8) +} + +// Trap immediate / register +fn fmt_trap_imm(instr: &DecodedInstr, mnem: &str, simplified_prefix: &str) -> DisasmText { + let to = instr.to(); + let ra = instr.ra(); + let imm = instr.simm16() as i32; + let base_ops = format!("{to}, {}, {imm}", gpr(ra)); + if let Some(cond) = trap_cond(to) { + if cond.is_empty() { + // TO=31 traps unconditionally. The register form has `trap`; the + // immediate form's counterpart is `twui`/`tdui` (binutils), which + // is what every other disassembler prints for these 16 sites. + let ext_mnem = format!("{simplified_prefix}ui"); + let ext_ops = format!("{}, {imm}", gpr(ra)); + with_ext(mnem, base_ops, 8, &ext_mnem, ext_ops, 8) + } else { + let ext_mnem = format!("{simplified_prefix}{cond}i"); + let ext_ops = format!("{}, {imm}", gpr(ra)); + with_ext(mnem, base_ops, 8, &ext_mnem, ext_ops, 8) + } + } else { + base(mnem, base_ops, 8) + } +} + +fn fmt_trap_reg(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let to = instr.to(); + let ra = instr.ra(); + let rb = instr.rb(); + let base_ops = format!("{to}, {}, {}", gpr(ra), gpr(rb)); + if to == 31 && ra == 0 && rb == 0 { + return with_ext(mnem, base_ops, 8, "trap", String::new(), 0); + } + if let Some(cond) = trap_cond(to) + && !cond.is_empty() + { + let ext_mnem = format!("{mnem}{cond}"); + let ext_ops = format!("{}, {}", gpr(ra), gpr(rb)); + return with_ext(mnem, base_ops, 8, &ext_mnem, ext_ops, 8); + } + base(mnem, base_ops, 8) +} + +// D-form ALU +fn fmt_addi(instr: &DecodedInstr) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let imm = instr.simm16() as i32; + let base_ops = format!("{}, {}, {imm}", gpr(rt), gpr(ra)); + if ra == 0 { + with_ext("addi", base_ops, 8, "li", format!("{}, {imm}", gpr(rt)), 8) + } else if imm < 0 { + with_ext("addi", base_ops, 8, "subi", format!("{}, {}, {}", gpr(rt), gpr(ra), -imm), 8) + } else { + base("addi", base_ops, 8) + } +} + +fn fmt_addis(instr: &DecodedInstr) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let imm = instr.simm16() as i32; + let imm_u = imm as u16 as u32; + let base_ops = format!("{}, {}, 0x{imm_u:X}", gpr(rt), gpr(ra)); + if ra == 0 { + with_ext("addis", base_ops, 8, "lis", format!("{}, 0x{imm_u:X}", gpr(rt)), 8) + } else if imm < 0 { + let neg = (-imm) as u16 as u32; + with_ext("addis", base_ops, 8, "subis", format!("{}, {}, 0x{neg:X}", gpr(rt), gpr(ra)), 8) + } else { + base("addis", base_ops, 8) + } +} + +fn fmt_d_add(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let imm = instr.simm16() as i32; + let base_ops = format!("{}, {}, {imm}", gpr(rt), gpr(ra)); + if imm < 0 { + let ext_mnem = mnem.replace("addic", "subic"); + with_ext(mnem, base_ops, 8, &ext_mnem, format!("{}, {}, {}", gpr(rt), gpr(ra), -imm), 8) + } else { + base(mnem, base_ops, 8) + } +} + +fn fmt_d_imm_simple(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let imm = instr.simm16() as i32; + base(mnem, format!("{}, {}, {imm}", gpr(rt), gpr(ra)), 8) +} + +fn fmt_cmp_imm(instr: &DecodedInstr, mnem: &str, signed: bool) -> DisasmText { + let bf = instr.crfd(); + let l_bit = if instr.l() { 1 } else { 0 }; + let ra = instr.ra(); + let imm_str = if signed { + format!("{}", instr.simm16() as i32) + } else { + format!("0x{:X}", instr.uimm16()) + }; + let cr = if bf == 0 { String::new() } else { format!("cr{bf}, ") }; + let base_ops = format!("{cr}{l_bit}, {}, {imm_str}", gpr(ra)); + + let size = if l_bit == 0 { "w" } else { "d" }; + let ext_mnem = if mnem == "cmpi" { + format!("cmp{size}i") + } else { + format!("cmpl{size}i") + }; + let ext_ops = format!("{cr}{}, {imm_str}", gpr(ra)); + with_ext(mnem, base_ops, 8, &ext_mnem, ext_ops, 8) +} + +fn fmt_cmp_reg(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let bf = instr.crfd(); + let l_bit = if instr.l() { 1 } else { 0 }; + let ra = instr.ra(); + let rb = instr.rb(); + let cr = if bf == 0 { String::new() } else { format!("cr{bf}, ") }; + let base_ops = format!("{cr}{l_bit}, {}, {}", gpr(ra), gpr(rb)); + let size = if l_bit == 0 { "w" } else { "d" }; + let ext_mnem = format!("{mnem}{size}"); + let ext_ops = format!("{cr}{}, {}", gpr(ra), gpr(rb)); + with_ext(mnem, base_ops, 8, &ext_mnem, ext_ops, 8) +} + +fn fmt_ori(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let uimm = instr.uimm16() as u32; + let base_ops = format!("{}, {}, 0x{uimm:X}", gpr(ra), gpr(rs)); + if rs == 0 && ra == 0 && uimm == 0 { + with_ext("ori", base_ops, 8, "nop", String::new(), 0) + } else { + base("ori", base_ops, 8) + } +} + +fn fmt_d_logic(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let uimm = instr.uimm16() as u32; + base(mnem, format!("{}, {}, 0x{uimm:X}", gpr(ra), gpr(rs)), 8) +} + +// D-form load/store. `is_fpr` selects between fX and rX for the data register. +fn fmt_ld(instr: &DecodedInstr, mnem: &str, is_fpr: bool) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let d = instr.d(); + let rn = if is_fpr { fpr(rt) } else { gpr(rt) }; + base(mnem, format!("{rn}, {d}({})", gpr(ra)), 8) +} + +fn fmt_st(instr: &DecodedInstr, mnem: &str, is_fpr: bool) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let d = instr.d(); + let rn = if is_fpr { fpr(rs) } else { gpr(rs) }; + base(mnem, format!("{rn}, {d}({})", gpr(ra)), 8) +} + +// DS-form load/store. +fn fmt_ds(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let r = instr.rd(); + let ra = instr.ra(); + let ds = instr.ds(); + base(mnem, format!("{}, {ds}({})", gpr(r), gpr(ra)), 8) +} + +// Rotate (32-bit). +fn fmt_rlwimi(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let sh = instr.sh(); + let mb = instr.mb(); + let me = instr.me(); + let rc = rc_dot(instr); + let mnem = format!("rlwimi{rc}"); + let base_ops = format!("{}, {}, {sh}, {mb}, {me}", gpr(ra), gpr(rs)); + // inslwi rA, rS, n, b = rlwimi rA, rS, 32-b, b, b+n-1 + if mb <= me && sh == (32u32.wrapping_sub(mb)) % 32 && sh != 31u32.wrapping_sub(me) { + let n = me - mb + 1; + let b = mb; + let ext_mnem = format!("inslwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext_mnem, format!("{}, {}, {n}, {b}", gpr(ra), gpr(rs)), 8); + } + // insrwi rA, rS, n, b = rlwimi rA, rS, 32-(b+n), b, b+n-1 + if mb <= me && sh == 31u32.wrapping_sub(me) % 32 { + let n = me - mb + 1; + let b = mb; + let ext_mnem = format!("insrwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext_mnem, format!("{}, {}, {n}, {b}", gpr(ra), gpr(rs)), 8); + } + base(&mnem, base_ops, 8) +} + +fn fmt_rlwinm(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let sh = instr.sh(); + let mb = instr.mb(); + let me = instr.me(); + let rc = rc_dot(instr); + let mnem = format!("rlwinm{rc}"); + let base_ops = format!("{}, {}, {sh}, {mb}, {me}", gpr(ra), gpr(rs)); + + // Priority-ordered simplified forms. + // + // `slwi` is deliberately not gated on `sh > 0`: `rlwinm rA,rS,0,0,31` is a + // rotate-by-zero under a full mask, which the ISA's table still names + // `slwi rA,rS,0` (and which LLVM/capstone print that way). It is the single + // most common `rlwinm` encoding in this binary — 3,720 sites — so gating it + // away left the largest simplified-mnemonic gap we had. + if mb == 0 && me == 31 - sh { + let ext = format!("slwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8); + } + if sh > 0 && me == 31 && sh + mb == 32 { + let ext = format!("srwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {}", gpr(ra), gpr(rs), 32 - sh), 8); + } + if sh > 0 && mb == 0 && me == 31 { + let ext = format!("rotlwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8); + } + if sh == 0 && me == 31 && mb > 0 { + let ext = format!("clrlwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {mb}", gpr(ra), gpr(rs)), 8); + } + if sh == 0 && mb == 0 && me < 31 { + let ext = format!("clrrwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {}", gpr(ra), gpr(rs), 31 - me), 8); + } + if mb == 0 && sh > 0 && me < 31 { + let n = me + 1; + let ext = format!("extlwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {n}, {sh}", gpr(ra), gpr(rs)), 8); + } + if me == 31 && mb > 0 && sh > 0 { + let n = 32 - mb; + let b = sh.wrapping_sub(n) % 32; + let ext = format!("extrwi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {n}, {b}", gpr(ra), gpr(rs)), 8); + } + base(&mnem, base_ops, 8) +} + +fn fmt_rlwnm(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let mb = instr.mb(); + let me = instr.me(); + let rc = rc_dot(instr); + let mnem = format!("rlwnm{rc}"); + let base_ops = format!("{}, {}, {}, {mb}, {me}", gpr(ra), gpr(rs), gpr(rb)); + if mb == 0 && me == 31 { + let ext = format!("rotlw{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {}", gpr(ra), gpr(rs), gpr(rb)), 8); + } + base(&mnem, base_ops, 8) +} + +// 64-bit MD/MDS-form rotate. +fn fmt_rldicl(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rc = rc_dot(instr); + let sh = instr.sh64(); + let mb = mb_md(instr.raw); + let mnem = format!("rldicl{rc}"); + let base_ops = format!("{}, {}, {sh}, {mb}", gpr(ra), gpr(rs)); + if sh == 0 && mb > 0 { + let ext = format!("clrldi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {mb}", gpr(ra), gpr(rs)), 8); + } + if mb > 0 && sh == (64u32.wrapping_sub(mb)) & 63 { + let ext = format!("srdi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {mb}", gpr(ra), gpr(rs)), 8); + } + if sh > 0 && mb == 0 { + let ext = format!("rotldi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8); + } + base(&mnem, base_ops, 8) +} + +fn fmt_rldicr(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rc = rc_dot(instr); + let sh = instr.sh64(); + let me = mb_md(instr.raw); + let mnem = format!("rldicr{rc}"); + let base_ops = format!("{}, {}, {sh}, {me}", gpr(ra), gpr(rs)); + if sh > 0 && me == (63u32.wrapping_sub(sh)) & 63 { + let ext = format!("sldi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8); + } + if sh == 0 && me < 63 { + let ext = format!("clrrdi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {}", gpr(ra), gpr(rs), 63 - me), 8); + } + base(&mnem, base_ops, 8) +} + +fn fmt_rldic(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rc = rc_dot(instr); + let sh = instr.sh64(); + let mb = mb_md(instr.raw); + base(&format!("rldic{rc}"), format!("{}, {}, {sh}, {mb}", gpr(ra), gpr(rs)), 8) +} + +fn fmt_rldimi(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rc = rc_dot(instr); + let sh = instr.sh64(); + let mb = mb_md(instr.raw); + let mnem = format!("rldimi{rc}"); + let base_ops = format!("{}, {}, {sh}, {mb}", gpr(ra), gpr(rs)); + if mb > 0 { + let n = (64u32.wrapping_sub(sh).wrapping_sub(mb)) & 63; + if n > 0 { + let ext = format!("insrdi{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {n}, {mb}", gpr(ra), gpr(rs)), 8); + } + } + base(&mnem, base_ops, 8) +} + +fn fmt_rldcl(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let mb = mb_md(instr.raw); + let mnem = format!("rldcl{rc}"); + let base_ops = format!("{}, {}, {}, {mb}", gpr(ra), gpr(rs), gpr(rb)); + if mb == 0 { + let ext = format!("rotld{rc}"); + return with_ext(&mnem, base_ops, 8, &ext, format!("{}, {}, {}", gpr(ra), gpr(rs), gpr(rb)), 8); + } + base(&mnem, base_ops, 8) +} + +fn fmt_rldcr(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let me = mb_md(instr.raw); + base(&format!("rldcr{rc}"), format!("{}, {}, {}, {me}", gpr(ra), gpr(rs), gpr(rb)), 8) +} + +/// MD/MDS-form mb/me field: 6 bits packed as bits 21-25 + bit 26 (low bit). +#[inline] +fn mb_md(raw: u32) -> u32 { + let lo5 = (raw >> 6) & 0x1F; // bits 21-25 + let hi = (raw >> 5) & 0x1; // bit 26 + lo5 | (hi << 5) +} + +// XO-form ALU +fn fmt_xo_3op(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let oe = if instr.oe() { "o" } else { "" }; + let full = format!("{mnem}{oe}{rc}"); + base(&full, format!("{}, {}, {}", gpr(rt), gpr(ra), gpr(rb)), 8) +} + +fn fmt_xo_2op(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let rc = rc_dot(instr); + let oe = if instr.oe() { "o" } else { "" }; + let full = format!("{mnem}{oe}{rc}"); + base(&full, format!("{}, {}", gpr(rt), gpr(ra)), 8) +} + +fn fmt_xo_3op_no_oe(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let oe = if instr.oe() { "o" } else { "" }; + let full = format!("{mnem}{oe}{rc}"); + base(&full, format!("{}, {}, {}", gpr(rt), gpr(ra), gpr(rb)), 8) +} + +fn fmt_xo_3op_rc_only(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let full = format!("{mnem}{rc}"); + base(&full, format!("{}, {}, {}", gpr(rt), gpr(ra), gpr(rb)), 8) +} + +fn fmt_subf(instr: &DecodedInstr, base_mnem: &str, ext_mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let oe = if instr.oe() { "o" } else { "" }; + let bm = format!("{base_mnem}{oe}{rc}"); + let em = format!("{ext_mnem}{oe}{rc}"); + let bo = format!("{}, {}, {}", gpr(rt), gpr(ra), gpr(rb)); + let eo = format!("{}, {}, {}", gpr(rt), gpr(rb), gpr(ra)); + with_ext(&bm, bo, 8, &em, eo, 8) +} + +// X-form logical +fn fmt_x_logic(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let full = format!("{mnem}{rc}"); + base(&full, format!("{}, {}, {}", gpr(ra), gpr(rs), gpr(rb)), 8) +} + +fn fmt_x_unary_rc(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rc = rc_dot(instr); + let full = format!("{mnem}{rc}"); + base(&full, format!("{}, {}", gpr(ra), gpr(rs)), 8) +} + +fn fmt_logic_and(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let bm = format!("and{rc}"); + let bo = format!("{}, {}, {}", gpr(ra), gpr(rs), gpr(rb)); + if rs == rb { + let em = format!("mr{rc}"); + with_ext(&bm, bo, 8, &em, format!("{}, {}", gpr(ra), gpr(rs)), 8) + } else { + base(&bm, bo, 8) + } +} + +fn fmt_logic_or(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let bm = format!("or{rc}"); + let bo = format!("{}, {}, {}", gpr(ra), gpr(rs), gpr(rb)); + if rs == rb { + let em = format!("mr{rc}"); + with_ext(&bm, bo, 8, &em, format!("{}, {}", gpr(ra), gpr(rs)), 8) + } else { + base(&bm, bo, 8) + } +} + +fn fmt_logic_nor(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rc = rc_dot(instr); + let bm = format!("nor{rc}"); + let bo = format!("{}, {}, {}", gpr(ra), gpr(rs), gpr(rb)); + if rs == rb { + let em = format!("not{rc}"); + with_ext(&bm, bo, 8, &em, format!("{}, {}", gpr(ra), gpr(rs)), 8) + } else { + base(&bm, bo, 8) + } +} + +// Shift immediate +fn fmt_srawi(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let sh = instr.sh(); + let rc = rc_dot(instr); + base(&format!("srawi{rc}"), format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8) +} + +fn fmt_sradi(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let sh = instr.sh64(); + let rc = rc_dot(instr); + base(&format!("sradi{rc}"), format!("{}, {}, {sh}", gpr(ra), gpr(rs)), 8) +} + +// Special-purpose register moves +fn fmt_mfspr(instr: &DecodedInstr) -> DisasmText { + let rd = instr.rd(); + let spr = instr.spr(); + let base_ops = format!("{}, {}", gpr(rd), spr_name(spr)); + let ext = match spr { + 8 => Some(("mflr", format!("{}", gpr(rd)))), + 9 => Some(("mfctr", format!("{}", gpr(rd)))), + 1 => Some(("mfxer", format!("{}", gpr(rd)))), + _ => None, + }; + match ext { + Some((em, eo)) => with_ext("mfspr", base_ops, 8, em, eo, 8), + None => base("mfspr", base_ops, 8), + } +} + +fn fmt_mtspr(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let spr = instr.spr(); + let base_ops = format!("{}, {}", spr_name(spr), gpr(rs)); + let ext = match spr { + 8 => Some(("mtlr", format!("{}", gpr(rs)))), + 9 => Some(("mtctr", format!("{}", gpr(rs)))), + 1 => Some(("mtxer", format!("{}", gpr(rs)))), + _ => None, + }; + match ext { + Some((em, eo)) => with_ext("mtspr", base_ops, 8, em, eo, 8), + None => base("mtspr", base_ops, 8), + } +} + +/// `mfcr` and `mfocrf` share XO=19 and are told apart by bit 11. +/// +/// With bit 11 clear the instruction copies the whole CR into `rD`; with it set +/// this is `mfocrf`, which copies only the single CR field named by `FXM` and +/// leaves the rest of `rD` undefined. Printing the latter as a bare `mfcr rD` +/// loses which field was read — and on this title 163 of 165 sites are the +/// one-field form. (The reference emulator folds both into one handler because +/// the wide read is a safe superset at runtime; a disassembler cannot.) +fn fmt_mfcr(instr: &DecodedInstr) -> DisasmText { + let rd = instr.rd(); + if instr.raw & (1 << 20) != 0 { + let fxm = (instr.raw >> 12) & 0xFF; + base("mfocrf", format!("{}, 0x{fxm:02X}", gpr(rd)), 8) + } else { + base("mfcr", gpr(rd), 8) + } +} + +/// `mtcrf` and `mtocrf` share XO=144, told apart by bit 11 exactly as +/// [`fmt_mfcr`] describes. +fn fmt_mtcrf(instr: &DecodedInstr) -> DisasmText { + let rs = instr.rs(); + let fxm = (instr.raw >> 12) & 0xFF; + if instr.raw & (1 << 20) != 0 { + return base("mtocrf", format!("0x{fxm:02X}, {}", gpr(rs)), 8); + } + let bo = format!("0x{fxm:02X}, {}", gpr(rs)); + if fxm == 0xFF { + with_ext("mtcrf", bo, 8, "mtcr", gpr(rs), 8) + } else { + base("mtcrf", bo, 8) + } +} + +fn fmt_mftb(instr: &DecodedInstr) -> DisasmText { + let rd = instr.rd(); + let tbr = instr.spr(); + let base_ops = format!("{}, {tbr}", gpr(rd)); + match tbr { + 268 => with_ext("mftb", base_ops, 8, "mftb", gpr(rd), 8), + 269 => with_ext("mftb", base_ops, 8, "mftbu", gpr(rd), 8), + _ => base("mftb", base_ops, 8), + } +} + +// X-form indexed load/store. +fn fmt_x_load(instr: &DecodedInstr, mnem: &str, is_fpr: bool) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let rb = instr.rb(); + let rn = if is_fpr { fpr(rt) } else { gpr(rt) }; + base(mnem, format!("{rn}, {}, {}", gpr(ra), gpr(rb)), 8) +} + +fn fmt_x_store(instr: &DecodedInstr, mnem: &str, is_fpr: bool) -> DisasmText { + let rs = instr.rs(); + let ra = instr.ra(); + let rb = instr.rb(); + let rn = if is_fpr { fpr(rs) } else { gpr(rs) }; + base(mnem, format!("{rn}, {}, {}", gpr(ra), gpr(rb)), 8) +} + +fn fmt_lswi_stswi(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let rt = instr.rd(); + let ra = instr.ra(); + let nb = instr.nb(); + base(mnem, format!("{}, {}, {nb}", gpr(rt), gpr(ra)), 8) +} + +fn fmt_cache(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let ra = instr.ra(); + let rb = instr.rb(); + base(mnem, format!("{}, {}", gpr(ra), gpr(rb)), 8) +} + +// CR logical +fn fmt_cr_logic(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let bt = instr.crbd(); + let ba = instr.crba(); + let bb = instr.crbb(); + base(mnem, format!("{}, {}, {}", crb(bt), crb(ba), crb(bb)), 8) +} + +fn fmt_crnor(instr: &DecodedInstr) -> DisasmText { + let bt = instr.crbd(); + let ba = instr.crba(); + let bb = instr.crbb(); + let bo = format!("{}, {}, {}", crb(bt), crb(ba), crb(bb)); + if ba == bb { + with_ext("crnor", bo, 8, "crnot", format!("{}, {}", crb(bt), crb(ba)), 8) + } else { + base("crnor", bo, 8) + } +} + +fn fmt_crxor(instr: &DecodedInstr) -> DisasmText { + let bt = instr.crbd(); + let ba = instr.crba(); + let bb = instr.crbb(); + let bo = format!("{}, {}, {}", crb(bt), crb(ba), crb(bb)); + if bt == ba && ba == bb { + with_ext("crxor", bo, 8, "crclr", crb(bt), 8) + } else { + base("crxor", bo, 8) + } +} + +fn fmt_creqv(instr: &DecodedInstr) -> DisasmText { + let bt = instr.crbd(); + let ba = instr.crba(); + let bb = instr.crbb(); + let bo = format!("{}, {}, {}", crb(bt), crb(ba), crb(bb)); + if bt == ba && ba == bb { + with_ext("creqv", bo, 8, "crset", crb(bt), 8) + } else { + base("creqv", bo, 8) + } +} + +fn fmt_cror(instr: &DecodedInstr) -> DisasmText { + let bt = instr.crbd(); + let ba = instr.crba(); + let bb = instr.crbb(); + let bo = format!("{}, {}, {}", crb(bt), crb(ba), crb(bb)); + if ba == bb { + with_ext("cror", bo, 8, "crmove", format!("{}, {}", crb(bt), crb(ba)), 8) + } else { + base("cror", bo, 8) + } +} + +// FPU +fn fmt_a_3op(instr: &DecodedInstr, mnem: &str, use_frc: bool) -> DisasmText { + let frt = instr.rd(); + let fra = instr.ra(); + let frb = instr.rb(); + let frc = instr.rc(); + let rc = rc_dot(instr); + let full = format!("{mnem}{rc}"); + let ops = if use_frc { + format!("{}, {}, {}", fpr(frt), fpr(fra), fpr(frc)) + } else { + format!("{}, {}, {}", fpr(frt), fpr(fra), fpr(frb)) + }; + base(&full, ops, 8) +} + +fn fmt_a_unary(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let frt = instr.rd(); + let frb = instr.rb(); + let rc = rc_dot(instr); + base(&format!("{mnem}{rc}"), format!("{}, {}", fpr(frt), fpr(frb)), 8) +} + +fn fmt_a_4op(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let frt = instr.rd(); + let fra = instr.ra(); + let frb = instr.rb(); + let frc = instr.rc(); + let rc = rc_dot(instr); + base(&format!("{mnem}{rc}"), + format!("{}, {}, {}, {}", fpr(frt), fpr(fra), fpr(frc), fpr(frb)), 8) +} + +fn fmt_fcmp(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let bf = instr.crfd(); + let fra = instr.ra(); + let frb = instr.rb(); + base(mnem, format!("cr{bf}, {}, {}", fpr(fra), fpr(frb)), 8) +} + +fn fmt_x_fpu_unary(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let frt = instr.rd(); + let frb = instr.rb(); + let rc = rc_dot(instr); + base(&format!("{mnem}{rc}"), format!("{}, {}", fpr(frt), fpr(frb)), 8) +} + +fn fmt_mtfsb(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let bt = instr.crbd(); + let rc = rc_dot(instr); + base(&format!("{mnem}{rc}"), format!("{bt}"), 8) +} + +// VMX (5-bit registers). +/// A VX-form op whose two sources are the same register, so it degenerates to +/// a move/complement: emit the base form plus the two-operand simplified one. +fn fmt_vmx_move(instr: &DecodedInstr, base_mnem: &str, ext_mnem: &str) -> DisasmText { + let vd = instr.rd(); + let va = instr.ra(); + let vb = instr.rb(); + with_ext( + base_mnem, + format!("{}, {}, {}", vr(vd), vr(va), vr(vb)), 8, + ext_mnem, + format!("{}, {}", vr(vd), vr(va)), 8, + ) +} + +fn fmt_vmx_3op(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let va = instr.ra(); + let vb = instr.rb(); + base(mnem, format!("{}, {}, {}", vr(vd), vr(va), vr(vb)), 8) +} + +fn fmt_vmx_unary(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let vb = instr.rb(); + base(mnem, format!("{}, {}", vr(vd), vr(vb)), 8) +} + +fn fmt_vmx_uimm(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let vb = instr.rb(); + let uimm = instr.ra() as u32; + base(mnem, format!("{}, {}, {uimm}", vr(vd), vr(vb)), 8) +} + +fn fmt_vmx_simm(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let simm = sign_ext(instr.ra() as u32, 5); + base(mnem, format!("{}, {simm}", vr(vd)), 9) +} + +fn fmt_vmx_cmp(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let va = instr.ra(); + let vb = instr.rb(); + // Rc bit at position 22 (0-indexed from MSB) + let rc = if (instr.raw >> 10) & 1 != 0 { "." } else { "" }; + let full = format!("{mnem}{rc}"); + base(&full, format!("{}, {}, {}", vr(vd), vr(va), vr(vb)), 12) +} + +fn fmt_vmx_4op(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let va = instr.ra(); + let vb = instr.rb(); + let vc = instr.rc(); + base(mnem, format!("{}, {}, {}, {}", vr(vd), vr(va), vr(vb), vr(vc)), 12) +} + +fn fmt_vmx_4op_swap(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let va = instr.ra(); + let vb = instr.rb(); + let vc = instr.rc(); + base(mnem, format!("{}, {}, {}, {}", vr(vd), vr(va), vr(vc), vr(vb)), 9) +} + +fn fmt_vsldoi(instr: &DecodedInstr) -> DisasmText { + let vd = instr.rd(); + let va = instr.ra(); + let vb = instr.rb(); + let sh = (instr.raw >> 6) & 0xF; + base("vsldoi", format!("{}, {}, {}, {sh}", vr(vd), vr(va), vr(vb)), 8) +} + +fn fmt_vmx_ls(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.rd(); + let ra = instr.ra(); + let rb = instr.rb(); + base(mnem, format!("{}, {}, {}", vr(vd), gpr(ra), gpr(rb)), 8) +} + +// VMX128 — uses canonical va128/vb128/vd128 accessors from decoder.rs. +// (Silently fixes the prior ppc.rs bug where these used wrong bit positions.) +fn fmt_vmx128_ls(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let ra = instr.ra(); + let rb = instr.rb(); + base(mnem, format!("{}, {}, {}", vr(vd), gpr(ra), gpr(rb)), 12) +} + +fn fmt_vmx128_3op(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let va = instr.va128(); + let vb = instr.vb128(); + base(mnem, format!("{}, {}, {}", vr(vd), vr(va), vr(vb)), 12) +} + +// VMX128 multiply-add forms (VX128_2): the addend is the VD register +// re-used, not a separate VC field. Operand order differs between +// `vmaddfp128` (VD, VA, VB, VD) and the `vmaddcfp128`/`vnmsubfp128` +// pair (VD, VA, VD, VB), per canary's authoritative formatters in +// xenia-canary/src/xenia/cpu/ppc/ppc_opcode_disasm_gen.cc. +fn fmt_vmaddfp128(instr: &DecodedInstr) -> DisasmText { + let vd = instr.vd128(); + let va = instr.va128(); + let vb = instr.vb128(); + base( + "vmaddfp128", + format!("{}, {}, {}, {}", vr(vd), vr(va), vr(vb), vr(vd)), + 12, + ) +} + +fn fmt_vmx128_madd_vd_vb(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let va = instr.va128(); + let vb = instr.vb128(); + base( + mnem, + format!("{}, {}, {}, {}", vr(vd), vr(va), vr(vd), vr(vb)), + 12, + ) +} + +fn fmt_vperm128(instr: &DecodedInstr) -> DisasmText { + let vd = instr.vd128(); + let va = instr.va128(); + let vb = instr.vb128(); + let vc = (instr.raw >> 6) & 0x7; + base("vperm128", format!("{}, {}, {}, {vc}", vr(vd), vr(va), vr(vb)), 9) +} + +fn fmt_vsldoi128(instr: &DecodedInstr) -> DisasmText { + let vd = instr.vd128(); + let va = instr.va128(); + let vb = instr.vb128(); + let sh = (instr.raw >> 6) & 0xF; + base("vsldoi128", format!("{}, {}, {}, {sh}", vr(vd), vr(va), vr(vb)), 10) +} + +fn fmt_vpermwi128(instr: &DecodedInstr) -> DisasmText { + let vd = instr.vd128(); + let vb = instr.vb128(); + // UIMM combines bits 11-15 (low 5) with bits 23-25 (upper 3). + let lo = (instr.raw >> 16) & 0x1F; + let hi = (instr.raw >> 6) & 0x7; + let uimm = lo | (hi << 5); + base("vpermwi128", format!("{}, {}, 0x{uimm:X}", vr(vd), vr(vb)), 11) +} + +fn fmt_vmx128_pack_d3d(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let vb = instr.vb128(); + let imm = (instr.raw >> 16) & 0x1F; + let z = (instr.raw >> 6) & 0x3; + base(mnem, format!("{}, {}, {imm}, {z}", vr(vd), vr(vb)), 10) +} + +fn fmt_vmx128_unary(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let vb = instr.vb128(); + base(mnem, format!("{}, {}", vr(vd), vr(vb)), 12) +} + +fn fmt_vmx128_uimm(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let vb = instr.vb128(); + let uimm = extract_vx128_uimm5(instr.raw); + base(mnem, format!("{}, {}, {uimm}", vr(vd), vr(vb)), 12) +} + +fn fmt_vmx128_cmp(instr: &DecodedInstr, mnem: &str) -> DisasmText { + let vd = instr.vd128(); + let va = instr.va128(); + let vb = instr.vb128(); + // Rc bit at position 25 in VMX128 cmp form. + let rc = if (instr.raw >> 6) & 1 != 0 { "." } else { "" }; + let full = format!("{mnem}{rc}"); + base(&full, format!("{}, {}, {}", vr(vd), vr(va), vr(vb)), 14) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decoder::decode; + + #[test] + fn nop_collapses_via_extended() { + let instr = decode(0x60000000, 0); + let t = format(&instr); + assert_eq!(t.mnemonic, "ori"); + assert_eq!(t.ext_mnemonic.as_deref(), Some("nop")); + assert_eq!(t.display(), "nop"); + } + + #[test] + fn addi_to_li_when_ra_zero() { + // addi r3, r0, 16 + let raw = (14u32 << 26) | (3 << 21) | (0 << 16) | 16; + let instr = decode(raw, 0); + let t = format(&instr); + assert_eq!(t.mnemonic, "addi"); + assert_eq!(t.ext_mnemonic.as_deref(), Some("li")); + assert_eq!(t.ext_operands.as_deref(), Some("r3, 16")); + } + + #[test] + fn rlwinm_dot_preserves_record_bit() { + // Same pattern as the Sylpheed graphics-callback test: + // rlwinm. r11, r11, 0, 31, 31 with Rc=1 + let raw = (21u32 << 26) | (11 << 21) | (11 << 16) + | (0 << 11) | (31 << 6) | (31 << 1) | 1; + let instr = decode(raw, 0); + let t = format(&instr); + assert!(t.disasm.starts_with("rlwinm."), "got: {}", t.disasm); + } + + #[test] + fn rlwinm_no_dot_when_rc_unset() { + let raw = (21u32 << 26) | (11 << 21) | (11 << 16) + | (0 << 11) | (31 << 6) | (31 << 1); + let instr = decode(raw, 0); + let t = format(&instr); + assert_eq!(t.mnemonic, "rlwinm"); + assert!(!t.mnemonic.ends_with('.')); + } + + #[test] + fn or_with_same_source_is_mr() { + // or r3, r4, r4 → mr r3, r4 + let raw = (31u32 << 26) | (4 << 21) | (3 << 16) | (4 << 11) | (444 << 1); + let instr = decode(raw, 0); + let t = format(&instr); + assert_eq!(t.ext_mnemonic.as_deref(), Some("mr")); + assert_eq!(t.ext_operands.as_deref(), Some("r3, r4")); + } + + #[test] + fn unconditional_branch_resolves_target() { + // b +0x100 with addr=0x82000000 + let raw = (18u32 << 26) | (0x40 << 2); + let instr = decode(raw, 0x82000000); + let t = format(&instr); + assert_eq!(t.mnemonic, "b"); + assert_eq!(t.branch_target, Some(0x82000100)); + assert_eq!(t.operands, "0x82000100"); + } + + #[test] + fn bclr_unconditional_is_blr() { + // bclr 20, 0 + let raw = (19u32 << 26) | (20 << 21) | (0 << 16) | (16 << 1); + let instr = decode(raw, 0); + let t = format(&instr); + assert_eq!(t.ext_mnemonic.as_deref(), Some("blr")); + } + + #[test] + fn back_compat_disassemble_returns_display() { + let instr = decode(0x60000000, 0); + assert_eq!(disassemble(&instr), "nop"); + } + + #[test] + fn iter_disasm_walks_byte_slice_in_order() { + // Three instructions at 0x82000000: nop, addi r3,r0,16, b +0x100. + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x60000000u32.to_be_bytes()); // nop + bytes.extend_from_slice(&((14u32 << 26) | (3 << 21) | (0 << 16) | 16).to_be_bytes()); // addi + bytes.extend_from_slice(&((18u32 << 26) | (0x40 << 2)).to_be_bytes()); // b +0x100 + + let items: Vec<_> = super::iter_disasm(&bytes, 0x82000000, 0x82000000, 0x82000000 + 12) + .collect(); + assert_eq!(items.len(), 3); + assert_eq!(items[0].addr, 0x82000000); + assert_eq!(items[0].text.ext_mnemonic.as_deref(), Some("nop")); + assert_eq!(items[1].addr, 0x82000004); + assert_eq!(items[1].text.ext_mnemonic.as_deref(), Some("li")); + assert_eq!(items[2].addr, 0x82000008); + assert_eq!(items[2].text.branch_target, Some(0x82000108)); + } + + #[test] + fn iter_disasm_stops_on_truncated_tail() { + // 6 bytes — one full instruction + 2 dangling. Iterator must yield exactly 1. + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x60000000u32.to_be_bytes()); + bytes.push(0x60); bytes.push(0x00); + + let items: Vec<_> = super::iter_disasm(&bytes, 0, 0, 6).collect(); + assert_eq!(items.len(), 1); + } + /// `mfocrf` shares XO=19 with `mfcr`, differing only in bit 11. Printing it + /// as `mfcr` drops the FXM field naming which CR field is actually read — + /// 163 of 165 sites in the reference title are this form. + #[test] + fn mfocrf_is_distinguished_from_mfcr() { + // 0x7d502026: mfocrf r10, 0x02 (bit 11 set) + let d = crate::decoder::decode(0x7d50_2026, 0x8200_0000); + let t = format(&d); + assert_eq!(t.mnemonic, "mfocrf"); + assert_eq!(t.operands, "r10, 0x02"); + + // Same encoding with bit 11 clear is a plain whole-CR read. + let d = crate::decoder::decode(0x7d50_2026 & !(1 << 20), 0x8200_0000); + let t = format(&d); + assert_eq!(t.mnemonic, "mfcr"); + assert_eq!(t.operands, "r10"); + } + + /// The mirror case on the write side. + #[test] + fn mtocrf_is_distinguished_from_mtcrf() { + let base_word = 0x7c10_1120u32; // mtcrf-form, XO=144 + let d = crate::decoder::decode(base_word | (1 << 20), 0x8200_0000); + assert_eq!(format(&d).mnemonic, "mtocrf"); + let d = crate::decoder::decode(base_word & !(1 << 20), 0x8200_0000); + assert_eq!(format(&d).mnemonic, "mtcrf"); + } + + /// `rlwinm rA,rS,0,0,31` is a rotate-by-zero under a full mask. It is the + /// most common `rlwinm` encoding in the reference title (3,720 sites) and + /// was falling through to the base form because every simplified branch was + /// gated on `sh > 0`. + #[test] + fn rlwinm_shift_zero_still_simplifies() { + // 0x5548003e: rlwinm r8, r10, 0, 0, 31 + let d = crate::decoder::decode(0x5548_003e, 0x8200_0000); + let t = format(&d); + assert_eq!(t.mnemonic, "rlwinm"); + assert_eq!(t.ext_mnemonic.as_deref(), Some("slwi")); + assert_eq!(t.ext_operands.as_deref(), Some("r8, r10, 0")); + + // The record-bit form keeps its dot. + let d = crate::decoder::decode(0x5569_003f, 0x8200_0000); + assert_eq!(format(&d).ext_mnemonic.as_deref(), Some("slwi.")); + } + + /// A genuine bit-extraction has no short name and must stay in base form. + #[test] + fn rlwinm_bit_extract_has_no_simplified_form() { + // rlwinm rA,rS,0,30,30 — extracts one bit; not a shift or clear. + let word = 0x5548_0000 | (0 << 11) | (30 << 6) | (30 << 1); + let t = format(&crate::decoder::decode(word, 0x8200_0000)); + assert_eq!(t.mnemonic, "rlwinm"); + assert_eq!(t.ext_mnemonic, None); + } + + /// `vor vD,vA,vA` is the vector register move; `vnor vD,vA,vA` the vector + /// complement. Both only apply when the two sources are the same register. + #[test] + fn vor_and_vnor_simplify_only_when_sources_match() { + // 0x11800484: vor v12, v0, v0 + let t = format(&crate::decoder::decode(0x1180_0484, 0x8200_0000)); + assert_eq!(t.mnemonic, "vor"); + assert_eq!(t.ext_mnemonic.as_deref(), Some("vmr")); + assert_eq!(t.ext_operands.as_deref(), Some("v12, v0")); + + // 0x10000504: vnor v0, v0, v0 + let t = format(&crate::decoder::decode(0x1000_0504, 0x8200_0000)); + assert_eq!(t.ext_mnemonic.as_deref(), Some("vnot")); + + // Distinct sources: a real bitwise OR, no simplification. + let t = format(&crate::decoder::decode(0x1180_1484, 0x8200_0000)); + assert_eq!(t.mnemonic, "vor"); + assert_eq!(t.ext_mnemonic, None); + } + + /// PowerISA names the combined CTR+condition branches `bdnzt`/`bdnzf` + /// (and `bdzt`/`bdzf`) with the CR bit as an operand. We used to synthesise + /// `bdnzne` by gluing on a condition suffix — readable, but not a mnemonic + /// any assembler accepts. + #[test] + fn bdnz_with_condition_uses_the_isa_t_f_form() { + // 0x4002fff8: BO=00000 (dec CTR, branch if CTR!=0 and CR[BI]==0), BI=eq + let t = format(&crate::decoder::decode(0x4002_fff8, 0x8200_0000)); + assert_eq!(t.ext_mnemonic.as_deref(), Some("bdnzf")); + assert!(t.ext_operands.as_deref().unwrap().starts_with("eq,")); + } + + /// The `at` hint bits are the only record of the compiler's static branch + /// prediction, so they must survive into the text. + #[test] + fn branch_prediction_hints_are_preserved() { + // 0x4320fff0: bdnz with at=0b11 -> "+" + assert_eq!( + format(&crate::decoder::decode(0x4320_fff0, 0x8200_0000)).ext_mnemonic.as_deref(), + Some("bdnz+") + ); + // 0x41c20024: beq with at=0b10 -> "-" + assert_eq!( + format(&crate::decoder::decode(0x41c2_0024, 0x8200_0000)).ext_mnemonic.as_deref(), + Some("beq-") + ); + // 0x4de20020: beqlr with at=0b11 -> "+" + assert_eq!( + format(&crate::decoder::decode(0x4de2_0020, 0x8200_0000)).ext_mnemonic.as_deref(), + Some("beqlr+") + ); + } + + /// A branch with no hint bits set must stay unsuffixed, and `blr` (BO=20, + /// the branch-always form) never takes a hint at all. + #[test] + fn unhinted_branches_gain_no_suffix() { + // 0x4182000c: beq, at=0b00 + let t = format(&crate::decoder::decode(0x4182_000c, 0x8200_0000)); + assert_eq!(t.ext_mnemonic.as_deref(), Some("beq")); + // 0x4e800020: blr + let t = format(&crate::decoder::decode(0x4e80_0020, 0x8200_0000)); + assert_eq!(t.ext_mnemonic.as_deref(), Some("blr")); + } + + /// The whole trap family, checked against the reference table: TO=31 is + /// unconditional, which the register form calls `trap` and the immediate + /// form `twui` — the latter was the one gap. + #[test] + fn trap_extended_mnemonics_cover_the_table() { + // 0x0fe00016: twi 31, r0, 22 -> unconditional + let t = format(&crate::decoder::decode(0x0fe0_0016, 0x8200_0000)); + assert_eq!(t.mnemonic, "twi"); + assert_eq!(t.ext_mnemonic.as_deref(), Some("twui")); + + // TO=6 is "logically less than or equal" — the divide-by-zero guard + // MSVC emits, and the most common trap in the reference title. + let word = 0x0c00_0000 | (6 << 21) | (3 << 16) | 0; + assert_eq!( + format(&crate::decoder::decode(word, 0x8200_0000)).ext_mnemonic.as_deref(), + Some("twllei") + ); + // TO=5 is "logically greater than or equal". + let word = 0x0c00_0000 | (5 << 21) | (3 << 16) | 0; + assert_eq!( + format(&crate::decoder::decode(word, 0x8200_0000)).ext_mnemonic.as_deref(), + Some("twlgei") + ); + // tw 31,0,0 stays the register-form `trap`. + let t = format(&crate::decoder::decode(0x7fe0_0008, 0x8200_0000)); + assert_eq!(t.ext_mnemonic.as_deref(), Some("trap")); + } + +} diff --git a/crates/sylpheed-ppc/src/lib.rs b/crates/sylpheed-ppc/src/lib.rs new file mode 100644 index 00000000..6aa847ec --- /dev/null +++ b/crates/sylpheed-ppc/src/lib.rs @@ -0,0 +1,12 @@ +//! PowerPC decode and disassembly, for static analysis. +//! +//! Lifted from `xenia-rs`'s `xenia-cpu` when that emulator was retired. Only +//! three modules came: the interpreter, JIT, scheduler and VMX are the parts +//! this project no longer runs, and `sylpheed-xexdb` never referenced them — +//! it used exactly `decoder::decode`, `disasm::DisasmItem` and `disasm::format`. +//! +//! See `docs/agents/CONSOLIDATION.md` Phase 3. + +pub mod decoder; +pub mod disasm; +pub mod opcode; diff --git a/crates/sylpheed-ppc/src/opcode.rs b/crates/sylpheed-ppc/src/opcode.rs new file mode 100644 index 00000000..7ccf1205 --- /dev/null +++ b/crates/sylpheed-ppc/src/opcode.rs @@ -0,0 +1,308 @@ +/// All PPC opcodes supported by the Xbox 360, including VMX128 extensions. +/// Directly mirrors the C++ PPCOpcode enum from ppc_opcode.h. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +#[allow(non_camel_case_types)] +pub enum PpcOpcode { + // ALU + addcx, addex, addi, addic, addicx, addis, addmex, addx, addzex, + andcx, andisx, andix, andx, + // Branch + bcctrx, bclrx, bcx, bx, + // Compare + cmp, cmpi, cmpl, cmpli, + // Count leading zeros + cntlzdx, cntlzwx, + // Condition register + crand, crandc, creqv, crnand, crnor, cror, crorc, crxor, + // Data cache + dcbf, dcbi, dcbst, dcbt, dcbtst, dcbz, dcbz128, + // Division + divdux, divdx, divwux, divwx, + // Sync/barrier + eieio, + // Logical + eqvx, extsbx, extshx, extswx, + // FPU + fabsx, faddsx, faddx, fcfidx, fcmpo, fcmpu, fctidx, fctidzx, fctiwx, fctiwzx, + fdivsx, fdivx, fmaddsx, fmaddx, fmrx, fmsubsx, fmsubx, fmulsx, fmulx, + fnabsx, fnegx, fnmaddsx, fnmaddx, fnmsubsx, fnmsubx, fresx, frspx, frsqrtex, + fselx, fsqrtsx, fsqrtx, fsubsx, fsubx, + // Instruction cache + icbi, isync, + // Load byte + lbz, lbzu, lbzux, lbzx, + // Load doubleword + ld, ldarx, ldbrx, ldu, ldux, ldx, + // Load float + lfd, lfdu, lfdux, lfdx, lfs, lfsu, lfsux, lfsx, + // Load halfword + lha, lhau, lhaux, lhax, lhbrx, lhz, lhzu, lhzux, lhzx, + // Load multiple/string + lmw, lswi, lswx, + // Load vector + lvebx, lvehx, lvewx, lvewx128, lvlx, lvlx128, lvlxl, lvlxl128, + lvrx, lvrx128, lvrxl, lvrxl128, + lvsl, lvsl128, lvsr, lvsr128, + lvx, lvx128, lvxl, lvxl128, + // Load word + lwa, lwarx, lwaux, lwax, lwbrx, lwz, lwzu, lwzux, lwzx, + // Move CR + mcrf, mcrfs, mcrxr, + // Move from special + mfcr, mffsx, mfmsr, mfspr, mftb, mfvscr, + // Move to special + mtcrf, mtfsb0x, mtfsb1x, mtfsfix, mtfsfx, mtmsr, mtmsrd, mtspr, mtvscr, + // Multiply + mulhdux, mulhdx, mulhwux, mulhwx, mulldx, mulli, mullwx, + // Logical + nandx, negx, norx, orcx, ori, oris, orx, + // Rotate + rldclx, rldcrx, rldiclx, rldicrx, rldicx, rldimix, rlwimix, rlwinmx, rlwnmx, + // System call + sc, + // Shift + sldx, slwx, sradix, sradx, srawix, srawx, srdx, srwx, + // Store byte + stb, stbu, stbux, stbx, + // Store doubleword + std, stdbrx, stdcx, stdu, stdux, stdx, + // Store float + stfd, stfdu, stfdux, stfdx, stfiwx, stfs, stfsu, stfsux, stfsx, + // Store halfword + sth, sthbrx, sthu, sthux, sthx, + // Store multiple/string + stmw, stswi, stswx, + // Store vector + stvebx, stvehx, stvewx, stvewx128, stvlx, stvlx128, stvlxl, stvlxl128, + stvrx, stvrx128, stvrxl, stvrxl128, + stvx, stvx128, stvxl, stvxl128, + // Store word + stw, stwbrx, stwcx, stwu, stwux, stwx, + // Subtract + subfcx, subfex, subficx, subfmex, subfx, subfzex, + // Sync + sync, + // Trap + td, tdi, tw, twi, + // VMX integer + vaddcuw, vaddfp, vaddfp128, vaddsbs, vaddshs, vaddsws, + vaddubm, vaddubs, vadduhm, vadduhs, vadduwm, vadduws, + vand, vand128, vandc, vandc128, + vavgsb, vavgsh, vavgsw, vavgub, vavguh, vavguw, + vcfpsxws128, vcfpuxws128, vcfsx, vcfux, + vcmpbfp, vcmpbfp128, vcmpeqfp, vcmpeqfp128, + vcmpequb, vcmpequh, vcmpequw, vcmpequw128, + vcmpgefp, vcmpgefp128, vcmpgtfp, vcmpgtfp128, + vcmpgtsb, vcmpgtsh, vcmpgtsw, vcmpgtub, vcmpgtuh, vcmpgtuw, + vcsxwfp128, vctsxs, vctuxs, vcuxwfp128, + vexptefp, vexptefp128, vlogefp, vlogefp128, + vmaddcfp128, vmaddfp, vmaddfp128, + vmaxfp, vmaxfp128, vmaxsb, vmaxsh, vmaxsw, vmaxub, vmaxuh, vmaxuw, + vmhaddshs, vmhraddshs, + vminfp, vminfp128, vminsb, vminsh, vminsw, vminub, vminuh, vminuw, + vmladduhm, + vmrghb, vmrghh, vmrghw, vmrghw128, vmrglb, vmrglh, vmrglw, vmrglw128, + vmsum3fp128, vmsum4fp128, + vmsummbm, vmsumshm, vmsumshs, vmsumubm, vmsumuhm, vmsumuhs, + vmulesb, vmulesh, vmuleub, vmuleuh, vmulfp128, + vmulosb, vmulosh, vmuloub, vmulouh, + vnmsubfp, vnmsubfp128, vnor, vnor128, + vor, vor128, + vperm, vperm128, vpermwi128, vpkd3d128, + vpkpx, vpkshss, vpkshss128, vpkshus, vpkshus128, + vpkswss, vpkswss128, vpkswus, vpkswus128, + vpkuhum, vpkuhum128, vpkuhus, vpkuhus128, + vpkuwum, vpkuwum128, vpkuwus, vpkuwus128, + vrefp, vrefp128, + vrfim, vrfim128, vrfin, vrfin128, vrfip, vrfip128, vrfiz, vrfiz128, + vrlb, vrlh, vrlimi128, vrlw, vrlw128, + vrsqrtefp, vrsqrtefp128, + vsel, vsel128, + vsl, vslb, vsldoi, vsldoi128, vslh, vslo, vslo128, vslw, vslw128, + vspltb, vsplth, vspltisb, vspltish, vspltisw, vspltisw128, vspltw, vspltw128, + vsr, vsrab, vsrah, vsraw, vsraw128, vsrb, vsrh, vsro, vsro128, vsrw, vsrw128, + vsubcuw, vsubfp, vsubfp128, vsubsbs, vsubshs, vsubsws, + vsububm, vsububs, vsubuhm, vsubuhs, vsubuwm, vsubuws, + vsum2sws, vsum4sbs, vsum4shs, vsum4ubs, vsumsws, + vupkd3d128, vupkhpx, vupkhsb, vupkhsb128, vupkhsh, + vupklpx, vupklsb, vupklsb128, vupklsh, + vxor, vxor128, + // XOR immediate + xori, xoris, xorx, + // Invalid + Invalid, +} + +impl PpcOpcode { + /// Returns true if this opcode is a branch instruction. + pub fn is_branch(&self) -> bool { + matches!(self, Self::bx | Self::bcx | Self::bclrx | Self::bcctrx) + } + + /// Returns true if this opcode is a system call. + pub fn is_syscall(&self) -> bool { + matches!(self, Self::sc) + } + + /// Returns true if this opcode unconditionally ends a basic block: + /// any branch, system call, trap, or `Invalid` (decoder couldn't + /// recognize the instruction — execution will hit the + /// `Unimplemented` arm and we don't want to swallow the boundary + /// inside a cached block). + /// + /// Notably *not* terminating: `mtmsr`/`mtmsrd`/`isync`/`mfmsr`. + /// On real hardware these have synchronization semantics (a context + /// synchronizing event for `isync`, MSR rewrite for the `mt*`s) but + /// our interpreter has no asynchronous-exception model and no + /// out-of-order execution — they execute as plain ALU/move ops and + /// don't change control flow synchronously. Block-cache replay is + /// still bit-for-bit identical to per-instruction dispatch for + /// those. + /// + /// Used by the basic-block cache (`block_cache.rs`) to know when to + /// stop accumulating instructions during a forward decode walk. + pub fn terminates_block(&self) -> bool { + matches!( + self, + Self::bx | Self::bcx | Self::bclrx | Self::bcctrx + | Self::sc + | Self::td | Self::tdi | Self::tw | Self::twi + | Self::Invalid + ) + } + + /// Returns true if this is a load instruction. + pub fn is_load(&self) -> bool { + matches!(self, + Self::lbz | Self::lbzu | Self::lbzux | Self::lbzx | + Self::lhz | Self::lhzu | Self::lhzux | Self::lhzx | + Self::lha | Self::lhau | Self::lhaux | Self::lhax | + Self::lwz | Self::lwzu | Self::lwzux | Self::lwzx | + Self::lwa | Self::lwax | Self::lwaux | + Self::ld | Self::ldu | Self::ldux | Self::ldx | + Self::lfs | Self::lfsu | Self::lfsux | Self::lfsx | + Self::lfd | Self::lfdu | Self::lfdux | Self::lfdx | + Self::lhbrx | Self::lwbrx | Self::ldbrx | + Self::lmw | Self::lswi | Self::lswx | + Self::lwarx | Self::ldarx + ) + } + + /// Returns true if this is a store instruction. + pub fn is_store(&self) -> bool { + matches!(self, + Self::stb | Self::stbu | Self::stbux | Self::stbx | + Self::sth | Self::sthu | Self::sthux | Self::sthx | + Self::stw | Self::stwu | Self::stwux | Self::stwx | + Self::std | Self::stdu | Self::stdux | Self::stdx | + Self::stfs | Self::stfsu | Self::stfsux | Self::stfsx | + Self::stfd | Self::stfdu | Self::stfdux | Self::stfdx | + Self::sthbrx | Self::stwbrx | Self::stdbrx | + Self::stmw | Self::stswi | Self::stswx | + Self::stwcx | Self::stdcx | Self::stfiwx + ) + } + + /// Returns true if this opcode is a cross-thread synchronization + /// point at which the superblock runner MUST yield back to the + /// round-robin scheduler so the lockstep interleaving stays + /// fine-grained enough to preserve correct cross-thread ordering: + /// + /// - reserved load/store (`lwarx`/`ldarx`/`stwcx.`/`stdcx.`): the + /// atomic primitive other threads race on. Running past one + /// without returning to the scheduler would let a single slot + /// win/lose a reservation across many blocks before any peer + /// observes it. + /// - memory barriers (`sync`/`eieio`/`isync`): the guest explicitly + /// demands a global ordering point here; honour it by ending the + /// superblock so the scheduler re-interleaves. + /// + /// Purely a function of the opcode (no guest data), so the yield + /// decision is deterministic and the schedule reproduces byte-identically. + /// Note: `sc` (syscall) and traps already `terminates_block`, and + /// import-thunk / halt-sentinel PCs are handled by the per-block + /// prologue re-check in the superblock loop — they are not listed here. + #[inline] + pub fn is_sync_sensitive(&self) -> bool { + matches!( + self, + Self::lwarx | Self::ldarx | Self::stwcx | Self::stdcx + | Self::sync | Self::eieio | Self::isync + ) + } + + pub fn name(&self) -> &'static str { + match self { + Self::Invalid => "invalid", + _ => { + // Use debug formatting to get the variant name + // This is a placeholder - in practice we'd have a lookup table + "?" + } + } + } +} + +impl std::fmt::Display for PpcOpcode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminates_block_includes_all_branches() { + assert!(PpcOpcode::bx.terminates_block()); + assert!(PpcOpcode::bcx.terminates_block()); + assert!(PpcOpcode::bclrx.terminates_block()); + assert!(PpcOpcode::bcctrx.terminates_block()); + } + + #[test] + fn terminates_block_includes_sc_and_traps() { + assert!(PpcOpcode::sc.terminates_block()); + assert!(PpcOpcode::td.terminates_block()); + assert!(PpcOpcode::tdi.terminates_block()); + assert!(PpcOpcode::tw.terminates_block()); + assert!(PpcOpcode::twi.terminates_block()); + } + + #[test] + fn terminates_block_includes_invalid() { + // Decoder failure must end the block — otherwise an unknown + // opcode would be replayed inside a cached block without going + // through the per-instruction Unimplemented path. + assert!(PpcOpcode::Invalid.terminates_block()); + } + + #[test] + fn terminates_block_excludes_straight_line_ops() { + // Common ALU and load/store ops must NOT terminate a block. + assert!(!PpcOpcode::addi.terminates_block()); + assert!(!PpcOpcode::addis.terminates_block()); + assert!(!PpcOpcode::addx.terminates_block()); + assert!(!PpcOpcode::cmpi.terminates_block()); + assert!(!PpcOpcode::cmp.terminates_block()); + assert!(!PpcOpcode::lwz.terminates_block()); + assert!(!PpcOpcode::stw.terminates_block()); + assert!(!PpcOpcode::lbzx.terminates_block()); + assert!(!PpcOpcode::ori.terminates_block()); + assert!(!PpcOpcode::oris.terminates_block()); + assert!(!PpcOpcode::rlwinmx.terminates_block()); + } + + #[test] + fn terminates_block_excludes_msr_and_sync_ops() { + // Documented decision: synchronizing ops execute as ALU within + // a block since the interpreter has no async-exception model. + assert!(!PpcOpcode::mtmsr.terminates_block()); + assert!(!PpcOpcode::mtmsrd.terminates_block()); + assert!(!PpcOpcode::isync.terminates_block()); + assert!(!PpcOpcode::sync.terminates_block()); + assert!(!PpcOpcode::mfmsr.terminates_block()); + } +} diff --git a/crates/sylpheed-xex/Cargo.toml b/crates/sylpheed-xex/Cargo.toml new file mode 100644 index 00000000..291a686a --- /dev/null +++ b/crates/sylpheed-xex/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "sylpheed-xex" +version = "0.1.0" +edition = "2024" +description = "XEX2 container: decrypt, LZX, PE image, resources — and the disc image it lives in" + +[dependencies] +tracing = "0.1" +byteorder = "1" +thiserror = "1" +anyhow = "1" +aes = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +metrics = "0.23" diff --git a/crates/sylpheed-xex/src/header.rs b/crates/sylpheed-xex/src/header.rs new file mode 100644 index 00000000..a52d5d13 --- /dev/null +++ b/crates/sylpheed-xex/src/header.rs @@ -0,0 +1,139 @@ +use serde::Serialize; + +/// XEX2 file header. Parsed from the beginning of an Xbox 360 executable. +#[derive(Debug, Serialize)] +pub struct Xex2Header { + pub magic: u32, + pub module_flags: u32, + pub header_size: u32, + pub security_offset: u32, + pub header_count: u32, + pub optional_headers: Vec, + pub security_info: Option, + /// Parsed file format info (if present). + pub file_format_info: Option, + /// Parsed import libraries (addresses only until resolve_imports is called). + pub import_libraries: Vec, + /// Execution info (title ID, media ID, etc.). + pub execution_info: Option, + /// Original PE name from the XEX header. + pub original_pe_name: Option, +} + +#[derive(Debug, Serialize)] +pub struct Xex2OptionalHeader { + pub key: u32, + pub value: u32, +} + +#[derive(Debug, Serialize)] +pub struct Xex2SecurityInfo { + pub image_size: u32, + pub load_address: u32, + pub export_table_address: u32, + pub image_flags: u32, + /// Encrypted session key (decrypted with retail/devkit key to get actual session key). + pub aes_key: [u8; 16], + pub page_descriptors: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct Xex2PageDescriptor { + pub size_and_info: u32, +} + +impl Xex2PageDescriptor { + pub fn page_count(&self) -> u32 { + self.size_and_info >> 4 + } + + pub fn info(&self) -> u32 { + self.size_and_info & 0xF + } +} + +/// File format info (compression and encryption types). +#[derive(Debug, Clone, Serialize)] +pub struct FileFormatInfo { + pub info_size: u32, + pub encryption_type: u16, + pub compression_type: u16, + /// For basic compression: list of (data_size, zero_size) block pairs. + pub basic_blocks: Vec, + /// For normal (LZX) compression: window size. + pub normal_window_size: u32, + /// For normal (LZX) compression: first block size (from header). + pub normal_first_block_size: u32, + /// For normal (LZX) compression: first block hash (from header). + pub normal_first_block_hash: [u8; 20], +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct BasicCompressionBlock { + pub data_size: u32, + pub zero_size: u32, +} + +/// An imported library with its resolved imports. +#[derive(Debug, Clone, Serialize)] +pub struct ImportLibrary { + pub name: String, + pub id: u32, + pub version_min: u32, + pub version_cur: u32, + /// Import entries. Before `resolve_imports`, these contain addresses but no ordinals. + /// After `resolve_imports`, ordinals and record types are filled in from the PE image. + pub imports: Vec, +} + +/// A single import entry within an import library. +#[derive(Debug, Clone, Serialize)] +pub struct ImportEntry { + pub ordinal: u16, + pub record_type: u8, // 0 = variable, 1 = thunk + pub address: u32, +} + +/// Execution info parsed from the XEX header. +#[derive(Debug, Clone, Serialize)] +pub struct ExecutionInfo { + pub media_id: u32, + pub title_id: u32, + pub disc_number: u8, + pub disc_count: u8, +} + +/// XEX2 magic: "XEX2" +pub const XEX2_MAGIC: u32 = 0x58455832; + +/// Compression types +pub const COMPRESSION_NONE: u16 = 0; +pub const COMPRESSION_BASIC: u16 = 1; +pub const COMPRESSION_NORMAL: u16 = 2; + +/// Encryption types +pub const ENCRYPTION_NONE: u16 = 0; +pub const ENCRYPTION_NORMAL: u16 = 1; + +/// Optional header keys +pub mod header_keys { + pub const ENTRY_POINT: u32 = 0x00010100; + pub const IMAGE_BASE_ADDRESS: u32 = 0x00010201; + pub const IMPORT_LIBRARIES: u32 = 0x000103FF; + // These two were swapped. `0x00020104` is TLS_INFO and `0x00020200` is + // DEFAULT_STACK_SIZE — confirmed against the reference implementation + // (xenia-canary `kernel/util/xex2_info.h`) and against this title, whose + // `0x00020104` header points at a TLS descriptor (slot_count 64) while + // `0x00020200` carries the inline value 0x80000 (512 KiB), a sane stack. + // Swapped, `get_stack_size` returned the TLS descriptor's file offset. + pub const TLS_INFO: u32 = 0x00020104; + pub const EXECUTION_INFO: u32 = 0x00040006; + pub const DEFAULT_STACK_SIZE: u32 = 0x00020200; + pub const ORIGINAL_PE_NAME: u32 = 0x000183FF; + pub const FILE_FORMAT_INFO: u32 = 0x000003FF; + pub const SYSTEM_FLAGS: u32 = 0x00030000; + pub const RESOURCE_INFO: u32 = 0x000002FF; + pub const STATIC_LIBRARIES: u32 = 0x000200FF; + pub const CHECKSUM_TIMESTAMP: u32 = 0x00018002; + pub const GAME_RATINGS: u32 = 0x00040310; +} diff --git a/crates/sylpheed-xex/src/lib.rs b/crates/sylpheed-xex/src/lib.rs new file mode 100644 index 00000000..ed9f46c7 --- /dev/null +++ b/crates/sylpheed-xex/src/lib.rs @@ -0,0 +1,16 @@ +//! XEX2 container: header, decrypt, LZX, PE image, resources — and the disc +//! image it may live inside (`vfs`). +//! +//! From `xenia-rs`'s `xenia-xex` + `xenia-vfs` when that emulator was retired. +//! `docs/agents/CONSOLIDATION.md` Phase 3. + +pub mod vfs; +pub mod header; +pub mod loader; +pub mod lzx; +pub mod pe; +pub mod pdata; +pub mod resources; +pub mod tls; + +pub use header::Xex2Header; diff --git a/crates/sylpheed-xex/src/loader.rs b/crates/sylpheed-xex/src/loader.rs new file mode 100644 index 00000000..17d6bc14 --- /dev/null +++ b/crates/sylpheed-xex/src/loader.rs @@ -0,0 +1,591 @@ +use crate::header::*; +use aes::cipher::{BlockDecrypt, KeyInit}; +use aes::Aes128; +use byteorder::{BigEndian, ReadBytesExt}; +use std::io::{self, Cursor, Read, Seek, SeekFrom}; + +/// Parse a XEX2 header from raw file data. +pub fn parse_xex2_header(data: &[u8]) -> io::Result { + let mut cursor = Cursor::new(data); + + let magic = cursor.read_u32::()?; + if magic != XEX2_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid XEX2 magic: {:#010x} (expected {:#010x})", magic, XEX2_MAGIC), + )); + } + + let module_flags = cursor.read_u32::()?; + let header_size = cursor.read_u32::()?; + let _reserved = cursor.read_u32::()?; + let security_offset = cursor.read_u32::()?; + let header_count = cursor.read_u32::()?; + + let mut optional_headers = Vec::new(); + for _ in 0..header_count { + let key = cursor.read_u32::()?; + let value = cursor.read_u32::()?; + optional_headers.push(Xex2OptionalHeader { key, value }); + } + + // Parse security info + let security_info = if (security_offset as usize) < data.len() { + cursor.seek(SeekFrom::Start(security_offset as u64))?; + Some(parse_security_info(&mut cursor)?) + } else { + None + }; + + // Parse file format info + let file_format_info = parse_file_format_info(data, &optional_headers); + + // Parse import libraries (addresses only; call resolve_imports after decompression) + let import_libraries = parse_import_libraries(data, &optional_headers); + + // Parse execution info + let execution_info = parse_execution_info(data, &optional_headers); + + // Parse original PE name + let original_pe_name = parse_original_pe_name(data, &optional_headers); + + Ok(Xex2Header { + magic, + module_flags, + header_size, + security_offset, + header_count, + optional_headers, + security_info, + file_format_info, + import_libraries, + execution_info, + original_pe_name, + }) +} + +fn parse_security_info(cursor: &mut Cursor<&[u8]>) -> io::Result { + // xex2_security_info layout (from xex2_info.h): + // 0x000: header_size (u32) + // 0x004: image_size (u32) + // 0x008: rsa_signature (0x100 bytes) + // 0x108: unk_108 (u32) + // 0x10C: image_flags (u32) + // 0x110: load_address (u32) + // 0x114: section_digest (0x14 bytes) + // 0x128: import_table_count (u32) + // 0x12C: import_table_digest (0x14 bytes) + // 0x140: xgd2_media_id (0x10 bytes) + // 0x150: aes_key (0x10 bytes) + // 0x160: export_table (u32) + // 0x164: header_digest (0x14 bytes) + // 0x178: region (u32) + // 0x17C: allowed_media_types (u32) + // 0x180: page_descriptor_count (u32) + // 0x184: page_descriptors[] (each is 0x18 bytes: u32 value + 0x14 digest) + + let _header_size = cursor.read_u32::()?; // 0x000 + let image_size = cursor.read_u32::()?; // 0x004 + + // Skip RSA signature (0x100 bytes) + let mut rsa_sig = [0u8; 0x100]; + cursor.read_exact(&mut rsa_sig)?; // 0x008 + + let _unk_108 = cursor.read_u32::()?; // 0x108 + let image_flags = cursor.read_u32::()?; // 0x10C + let load_address = cursor.read_u32::()?; // 0x110 + + // Skip section_digest (0x14 bytes) + let mut digest = [0u8; 0x14]; + cursor.read_exact(&mut digest)?; // 0x114 + + let _import_table_count = cursor.read_u32::()?; // 0x128 + + // Skip import_table_digest (0x14 bytes) + cursor.read_exact(&mut digest)?; // 0x12C + + // Skip xgd2_media_id (0x10 bytes) + let mut media_id = [0u8; 0x10]; + cursor.read_exact(&mut media_id)?; // 0x140 + + // Read aes_key (0x10 bytes) + let mut aes_key = [0u8; 0x10]; + cursor.read_exact(&mut aes_key)?; // 0x150 + + let export_table_address = cursor.read_u32::()?; // 0x160 + + // Skip header_digest (0x14 bytes) + cursor.read_exact(&mut digest)?; // 0x164 + + let _region = cursor.read_u32::()?; // 0x178 + let _allowed_media = cursor.read_u32::()?; // 0x17C + + let page_descriptor_count = cursor.read_u32::()?; // 0x180 + + let mut page_descriptors = Vec::new(); + for _ in 0..page_descriptor_count { + let size_and_info = cursor.read_u32::()?; + // Skip data_digest (0x14 bytes per descriptor) + cursor.read_exact(&mut digest)?; + page_descriptors.push(Xex2PageDescriptor { size_and_info }); + } + + Ok(Xex2SecurityInfo { + image_size, + load_address, + export_table_address, + image_flags, + aes_key, + page_descriptors, + }) +} + +/// Parse file format info from the optional header data. +fn parse_file_format_info(data: &[u8], headers: &[Xex2OptionalHeader]) -> Option { + // The key format: low 8 bits indicate the data size category + // 0xFF = data offset is a pointer to variable-size data in the header area + let header = headers.iter().find(|h| h.key == header_keys::FILE_FORMAT_INFO)?; + let offset = header.value as usize; + if offset + 8 > data.len() { + return None; + } + + let mut cursor = Cursor::new(data); + cursor.seek(SeekFrom::Start(offset as u64)).ok()?; + + let info_size = cursor.read_u32::().ok()?; + let encryption_type = cursor.read_u16::().ok()?; + let compression_type = cursor.read_u16::().ok()?; + + let mut basic_blocks = Vec::new(); + let mut normal_window_size = 0u32; + let mut normal_first_block_size = 0u32; + let mut normal_first_block_hash = [0u8; 20]; + + match compression_type { + COMPRESSION_BASIC => { + // Basic compression blocks: (data_size, zero_size) pairs + // Number of blocks = (info_size - 8) / 8 + let block_count = if info_size > 8 { (info_size - 8) / 8 } else { 0 }; + for _ in 0..block_count { + let data_size = cursor.read_u32::().ok()?; + let zero_size = cursor.read_u32::().ok()?; + basic_blocks.push(BasicCompressionBlock { data_size, zero_size }); + } + } + COMPRESSION_NORMAL => { + normal_window_size = cursor.read_u32::().ok()?; + // Read first_block: block_size (4) + block_hash (20) + normal_first_block_size = cursor.read_u32::().ok()?; + cursor.read_exact(&mut normal_first_block_hash).ok()?; + } + _ => {} + } + + Some(FileFormatInfo { + info_size, + encryption_type, + compression_type, + basic_blocks, + normal_window_size, + normal_first_block_size, + normal_first_block_hash, + }) +} + +/// Parse import libraries from the optional header data. +/// At this stage, only record addresses are read; ordinals and record types +/// are resolved later by `resolve_imports` once the PE image is decompressed. +fn parse_import_libraries(data: &[u8], headers: &[Xex2OptionalHeader]) -> Vec { + let header = match headers.iter().find(|h| h.key == header_keys::IMPORT_LIBRARIES) { + Some(h) => h, + None => return Vec::new(), + }; + + let offset = header.value as usize; + if offset + 12 > data.len() { + return Vec::new(); + } + + fn be_u32(data: &[u8], off: usize) -> u32 { + u32::from_be_bytes([data[off], data[off+1], data[off+2], data[off+3]]) + } + fn be_u16(data: &[u8], off: usize) -> u16 { + u16::from_be_bytes([data[off], data[off+1]]) + } + + let total_size = be_u32(data, offset) as usize; + let string_table_size = be_u32(data, offset + 4) as usize; + let string_count = be_u32(data, offset + 8) as usize; + + // Parse string table (null-terminated, 4-byte aligned) + let string_data_start = offset + 12; + let mut strings = Vec::new(); + let mut spos = 0usize; + for _ in 0..string_count { + let start = string_data_start + spos; + let mut end = start; + while end < data.len() && data[end] != 0 { end += 1; } + let name = std::str::from_utf8(&data[start..end]).unwrap_or("???").to_string(); + spos += name.len() + 1; + // 4-byte alignment + if !spos.is_multiple_of(4) { spos += 4 - (spos % 4); } + strings.push(name); + } + + // Parse libraries + let mut libs = Vec::new(); + let mut lib_off = offset + 12 + string_table_size; + + while lib_off + 0x28 <= data.len() && lib_off < offset + total_size { + let lib_size = be_u32(data, lib_off) as usize; + if lib_size == 0 { break; } + + let id = be_u32(data, lib_off + 0x18); + let version_cur = be_u32(data, lib_off + 0x1C); + let version_min = be_u32(data, lib_off + 0x20); + let name_index = (be_u16(data, lib_off + 0x24) & 0xFF) as usize; + let count = be_u16(data, lib_off + 0x26) as usize; + + let lib_name = strings.get(name_index).cloned().unwrap_or_else(|| format!("lib_{name_index}")); + + let mut imports = Vec::new(); + for i in 0..count { + let record_addr = be_u32(data, lib_off + 0x28 + i * 4); + imports.push(ImportEntry { + ordinal: 0, + record_type: 0xFF, + address: record_addr, + }); + } + + libs.push(ImportLibrary { + name: lib_name, + id, + version_min, + version_cur, + imports, + }); + lib_off += lib_size; + } + + libs +} + +/// Resolve import ordinals and record types from the decompressed PE image. +/// Must be called after `load_image` provides the PE data. +pub fn resolve_imports(header: &mut Xex2Header, pe_image: &[u8]) { + let image_base = get_image_base(header).unwrap_or(0); + + for lib in &mut header.import_libraries { + for imp in &mut lib.imports { + let pe_off = imp.address.wrapping_sub(image_base) as usize; + if pe_off + 4 <= pe_image.len() { + // PE image values are big-endian (Xbox 360 native) + let val = u32::from_be_bytes([ + pe_image[pe_off], pe_image[pe_off+1], + pe_image[pe_off+2], pe_image[pe_off+3], + ]); + imp.record_type = ((val >> 24) & 0xFF) as u8; + imp.ordinal = (val & 0xFFFF) as u16; + } + } + } +} + +/// Parse execution info from optional header data. +fn parse_execution_info(data: &[u8], headers: &[Xex2OptionalHeader]) -> Option { + // EXECUTION_INFO key is 0x00040006 — the low byte 0x06 means the value + // is an inline struct of 6 u32 words (24 bytes total). + // Layout: media_id(4), version(4), base_version(4), title_id(4), + // platform(1), exec_type(1), disc_number(1), disc_count(1) + let header = headers.iter().find(|h| h.key == header_keys::EXECUTION_INFO)?; + let off = header.value as usize; + if off + 20 > data.len() { + return None; + } + + let media_id = u32::from_be_bytes([data[off], data[off+1], data[off+2], data[off+3]]); + let title_id = u32::from_be_bytes([data[off+12], data[off+13], data[off+14], data[off+15]]); + let disc_number = data[off + 18]; + let disc_count = data[off + 19]; + + Some(ExecutionInfo { + media_id, + title_id, + disc_number, + disc_count, + }) +} + +/// Parse original PE name from optional header data. +fn parse_original_pe_name(data: &[u8], headers: &[Xex2OptionalHeader]) -> Option { + let header = headers.iter().find(|h| h.key == header_keys::ORIGINAL_PE_NAME)?; + let off = header.value as usize; + if off + 4 > data.len() { + return None; + } + + let size = u32::from_be_bytes([data[off], data[off+1], data[off+2], data[off+3]]) as usize; + if off + size > data.len() || size <= 4 { + return None; + } + + let name_bytes = &data[off + 4..off + size]; + Some(String::from_utf8_lossy(name_bytes).trim_end_matches('\0').to_string()) +} + +/// Get an optional header value by key. +pub fn get_opt_header(header: &Xex2Header, key: u32) -> Option { + header.optional_headers.iter() + .find(|h| h.key == key) + .map(|h| h.value) +} + +/// Get the entry point address from the XEX2 header. +pub fn get_entry_point(header: &Xex2Header) -> Option { + get_opt_header(header, header_keys::ENTRY_POINT) +} + +/// Get the image base address. +pub fn get_image_base(header: &Xex2Header) -> Option { + get_opt_header(header, header_keys::IMAGE_BASE_ADDRESS) +} + +/// Get the default stack size. +pub fn get_stack_size(header: &Xex2Header) -> u32 { + get_opt_header(header, header_keys::DEFAULT_STACK_SIZE).unwrap_or(0x10_0000) // Default 1MB +} + +/// XEX `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) — the privilege bitmap +/// queried by `XexCheckExecutablePrivilege`. Low byte 0x00 means the inline +/// `value` field is the u32 itself (canary `xex_module.cc:103-108`). Returns +/// 0 when the header is absent (matches canary's `GetOptHeader` zero-init). +pub fn get_system_flags(header: &Xex2Header) -> u32 { + get_opt_header(header, header_keys::SYSTEM_FLAGS).unwrap_or(0) +} + +/// Load the XEX image data into a flat buffer (decompressing if needed). +/// Returns the decompressed image bytes ready to map into guest memory. +#[tracing::instrument(skip_all, fields(bytes = data.len()))] +pub fn load_image(data: &[u8], header: &Xex2Header) -> io::Result> { + let started = std::time::Instant::now(); + let source = &data[header.header_size as usize..]; + let bytes_in = source.len(); + + let output = match &header.file_format_info { + Some(info) if info.compression_type == COMPRESSION_BASIC => { + tracing::debug!(compression = "basic", "decompressing"); + load_basic_compressed(source, info)? + } + Some(info) if info.compression_type == COMPRESSION_NORMAL => { + tracing::debug!(compression = "normal/LZX", "decompressing"); + load_normal_compressed(source, info, header)? + } + _ => source.to_vec(), + }; + + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("xex.load_image_ms").record(elapsed_ms); + metrics::counter!("xex.bytes_in").increment(bytes_in as u64); + metrics::counter!("xex.bytes_out").increment(output.len() as u64); + let ratio = if bytes_in == 0 { 0.0 } else { output.len() as f64 / bytes_in as f64 }; + tracing::info!(bytes_in, bytes_out = output.len(), ratio, elapsed_ms, "image loaded"); + Ok(output) +} + +/// Load basic compressed image data. +fn load_basic_compressed(source: &[u8], info: &FileFormatInfo) -> io::Result> { + // Calculate total uncompressed size + let total_size: u64 = info.basic_blocks.iter() + .map(|b| b.data_size as u64 + b.zero_size as u64) + .sum(); + + let mut output = vec![0u8; total_size as usize]; + let mut src_offset = 0usize; + let mut dst_offset = 0usize; + + for block in &info.basic_blocks { + let data_size = block.data_size as usize; + let zero_size = block.zero_size as usize; + + if src_offset + data_size > source.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("Basic compression block data extends past end of file (src_offset={:#x}, data_size={:#x}, source_len={:#x})", + src_offset, data_size, source.len()), + )); + } + + // Copy data block + if dst_offset + data_size <= output.len() { + output[dst_offset..dst_offset + data_size] + .copy_from_slice(&source[src_offset..src_offset + data_size]); + } + src_offset += data_size; + dst_offset += data_size; + + // Zero-filled gap (already zeroed from vec initialization) + dst_offset += zero_size; + } + + Ok(output) +} + +/// Xbox 360 retail AES key for XEX2 session key decryption. +const XEX2_RETAIL_KEY: [u8; 16] = [ + 0x20, 0xB1, 0x85, 0xA5, 0x9D, 0x28, 0xFD, 0xC3, + 0x40, 0x58, 0x3F, 0xBB, 0x08, 0x96, 0xBF, 0x91, +]; + +/// Xbox 360 devkit AES key (all zeros). +#[allow(dead_code)] +const XEX2_DEVKIT_KEY: [u8; 16] = [0u8; 16]; + +/// AES-128-CBC decryption with zero IV (matching Xbox 360 XEX decryption). +#[tracing::instrument(skip_all, fields(bytes = input.len()))] +fn aes_decrypt_cbc(key: &[u8; 16], input: &[u8]) -> Vec { + let cipher = Aes128::new(key.into()); + let mut output = vec![0u8; input.len()]; + let mut iv = [0u8; 16]; + + for (i, chunk) in input.chunks(16).enumerate() { + if chunk.len() < 16 { + // Partial block at end - copy as-is + output[i * 16..i * 16 + chunk.len()].copy_from_slice(chunk); + break; + } + let mut block = aes::Block::clone_from_slice(chunk); + cipher.decrypt_block(&mut block); + // XOR with IV (previous ciphertext block) + for j in 0..16 { + block[j] ^= iv[j]; + } + iv.copy_from_slice(chunk); + output[i * 16..(i + 1) * 16].copy_from_slice(&block); + } + + output +} + +/// Derive the session key by decrypting the XEX's aes_key field with the retail key. +/// Falls back to devkit key if retail produces invalid results. +fn derive_session_key(header: &Xex2Header) -> [u8; 16] { + let sec = match &header.security_info { + Some(s) => s, + None => return [0u8; 16], + }; + + let decrypted = aes_decrypt_cbc(&XEX2_RETAIL_KEY, &sec.aes_key); + let mut session_key = [0u8; 16]; + session_key.copy_from_slice(&decrypted[..16]); + session_key +} + +/// De-block compressed data: strip block headers and extract chunk payloads. +/// +/// The first block's size comes from the file format header (first_block_size). +/// Each block in the data starts with a block_info struct for the NEXT block: +/// - block_size: u32 BE (size of the next block) +/// - block_hash: [u8; 20] (SHA1 of the next block) +/// Followed by chunks: { chunk_size: u16 BE, data: [u8; chunk_size] }, terminated by chunk_size=0 +fn deblock(input: &[u8], first_block_size: u32) -> io::Result> { + let mut output = Vec::new(); + let mut pos = 0usize; + let mut cur_block_size = first_block_size as usize; + + while cur_block_size > 0 && pos < input.len() { + let next_block_pos = pos + cur_block_size; + + // Read next block's info from start of current block data + let next_block_size = if pos + 4 <= input.len() { + u32::from_be_bytes([ + input[pos], input[pos + 1], input[pos + 2], input[pos + 3], + ]) as usize + } else { + 0 + }; + + // Skip block_info header (4 bytes size + 20 bytes hash) + let mut p = pos + 4 + 20; + + // Read chunks within this block + loop { + if p + 2 > input.len() { + break; + } + let chunk_size = ((input[p] as usize) << 8) | (input[p + 1] as usize); + p += 2; + if chunk_size == 0 { + break; + } + if p + chunk_size > input.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("De-block chunk extends past input (pos={:#x}, chunk_size={:#x}, input_len={:#x})", + p, chunk_size, input.len()), + )); + } + output.extend_from_slice(&input[p..p + chunk_size]); + p += chunk_size; + } + + if next_block_pos <= pos { + break; // Prevent infinite loop + } + pos = next_block_pos; + cur_block_size = next_block_size; + } + + Ok(output) +} + +/// Load normal (LZX) compressed image data. +/// Pipeline: decrypt → de-block → LZX decompress (pure Rust) +#[tracing::instrument(skip_all, fields(bytes_in = source.len()))] +fn load_normal_compressed(source: &[u8], info: &FileFormatInfo, header: &Xex2Header) -> io::Result> { + let uncompressed_size = header.security_info.as_ref() + .map(|s| s.image_size as usize) + .unwrap_or(0); + + if uncompressed_size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Cannot decompress: image_size is 0", + )); + } + + // Step 1: Decrypt if needed + let decrypted; + let input = if info.encryption_type == ENCRYPTION_NORMAL { + let session_key = derive_session_key(header); + decrypted = aes_decrypt_cbc(&session_key, source); + &decrypted + } else { + source + }; + + // Step 2: De-block (strip block headers, extract chunk payloads) + let deblocked = deblock(input, info.normal_first_block_size)?; + + if deblocked.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "De-blocking produced no data", + )); + } + + // Step 3: LZX decompress using pure Rust decoder + let window_bits = match info.normal_window_size { + s if s == 0 => 15, // default + s => (s as f64).log2() as u32, + }; + + let mut decoder = crate::lzx::LzxDecoder::new(window_bits); + let output = decoder.decompress(&deblocked, uncompressed_size) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("LZX decompression failed: {e}")))?; + + tracing::info!("LZX decompressed: {} -> {} bytes", deblocked.len(), uncompressed_size); + + Ok(output) +} diff --git a/crates/sylpheed-xex/src/lzx.rs b/crates/sylpheed-xex/src/lzx.rs new file mode 100644 index 00000000..2a8bf2c1 --- /dev/null +++ b/crates/sylpheed-xex/src/lzx.rs @@ -0,0 +1,692 @@ +//! LZX decompressor for Xbox 360 XEX2 "normal compression". +//! Ported from libmspack lzxd.c (C) 2003-2013 Stuart Caie, LGPL 2.1. + +use std::fmt; + +// ── LZX constants ─────────────────────────────────────────────────────────── + +const LZX_MIN_MATCH: usize = 2; +const LZX_NUM_CHARS: usize = 256; +const LZX_BLOCKTYPE_VERBATIM: u8 = 1; +const LZX_BLOCKTYPE_ALIGNED: u8 = 2; +const LZX_BLOCKTYPE_UNCOMPRESSED: u8 = 3; +const LZX_NUM_PRIMARY_LENGTHS: usize = 7; +const LZX_NUM_SECONDARY_LENGTHS: usize = 249; +const LZX_FRAME_SIZE: usize = 32768; +const HUFF_MAXBITS: usize = 16; + +const PRETREE_MAXSYMS: usize = 20; +const PRETREE_TABLEBITS: usize = 6; +const MAINTREE_MAXSYMS: usize = LZX_NUM_CHARS + 290 * 8; // 2576 +const MAINTREE_TABLEBITS: usize = 12; +const LENGTH_MAXSYMS: usize = LZX_NUM_SECONDARY_LENGTHS + 1; // 250 +const LENGTH_TABLEBITS: usize = 12; +const ALIGNED_MAXSYMS: usize = 8; +const ALIGNED_TABLEBITS: usize = 7; +const LENTABLE_SAFETY: usize = 64; + +const BITBUF_WIDTH: u32 = 32; + +// ── Static tables ─────────────────────────────────────────────────────────── + +static POSITION_SLOTS: [u32; 11] = [30, 32, 34, 36, 38, 42, 50, 66, 98, 162, 290]; + +static EXTRA_BITS: [u8; 36] = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, + 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, + 15, 15, 16, 16, +]; + +#[rustfmt::skip] +static POSITION_BASE: [u32; 290] = [ + 0, 1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, + 768, 1024, 1536, 2048, 3072, 4096, 6144, 8192, 12288, 16384, 24576, 32768, + 49152, 65536, 98304, 131072, 196608, 262144, 393216, 524288, 655360, + 786432, 917504, 1048576, 1179648, 1310720, 1441792, 1572864, 1703936, + 1835008, 1966080, 2097152, 2228224, 2359296, 2490368, 2621440, 2752512, + 2883584, 3014656, 3145728, 3276800, 3407872, 3538944, 3670016, 3801088, + 3932160, 4063232, 4194304, 4325376, 4456448, 4587520, 4718592, 4849664, + 4980736, 5111808, 5242880, 5373952, 5505024, 5636096, 5767168, 5898240, + 6029312, 6160384, 6291456, 6422528, 6553600, 6684672, 6815744, 6946816, + 7077888, 7208960, 7340032, 7471104, 7602176, 7733248, 7864320, 7995392, + 8126464, 8257536, 8388608, 8519680, 8650752, 8781824, 8912896, 9043968, + 9175040, 9306112, 9437184, 9568256, 9699328, 9830400, 9961472, 10092544, + 10223616, 10354688, 10485760, 10616832, 10747904, 10878976, 11010048, + 11141120, 11272192, 11403264, 11534336, 11665408, 11796480, 11927552, + 12058624, 12189696, 12320768, 12451840, 12582912, 12713984, 12845056, + 12976128, 13107200, 13238272, 13369344, 13500416, 13631488, 13762560, + 13893632, 14024704, 14155776, 14286848, 14417920, 14548992, 14680064, + 14811136, 14942208, 15073280, 15204352, 15335424, 15466496, 15597568, + 15728640, 15859712, 15990784, 16121856, 16252928, 16384000, 16515072, + 16646144, 16777216, 16908288, 17039360, 17170432, 17301504, 17432576, + 17563648, 17694720, 17825792, 17956864, 18087936, 18219008, 18350080, + 18481152, 18612224, 18743296, 18874368, 19005440, 19136512, 19267584, + 19398656, 19529728, 19660800, 19791872, 19922944, 20054016, 20185088, + 20316160, 20447232, 20578304, 20709376, 20840448, 20971520, 21102592, + 21233664, 21364736, 21495808, 21626880, 21757952, 21889024, 22020096, + 22151168, 22282240, 22413312, 22544384, 22675456, 22806528, 22937600, + 23068672, 23199744, 23330816, 23461888, 23592960, 23724032, 23855104, + 23986176, 24117248, 24248320, 24379392, 24510464, 24641536, 24772608, + 24903680, 25034752, 25165824, 25296896, 25427968, 25559040, 25690112, + 25821184, 25952256, 26083328, 26214400, 26345472, 26476544, 26607616, + 26738688, 26869760, 27000832, 27131904, 27262976, 27394048, 27525120, + 27656192, 27787264, 27918336, 28049408, 28180480, 28311552, 28442624, + 28573696, 28704768, 28835840, 28966912, 29097984, 29229056, 29360128, + 29491200, 29622272, 29753344, 29884416, 30015488, 30146560, 30277632, + 30408704, 30539776, 30670848, 30801920, 30932992, 31064064, 31195136, + 31326208, 31457280, 31588352, 31719424, 31850496, 31981568, 32112640, + 32243712, 32374784, 32505856, 32636928, 32768000, 32899072, 33030144, + 33161216, 33292288, 33423360, +]; + +// ── Error type ────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum LzxError { + BadHuffmanTable, + Decrunch(String), +} + +impl fmt::Display for LzxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadHuffmanTable => write!(f, "failed to build Huffman table"), + Self::Decrunch(msg) => write!(f, "LZX decrunch error: {msg}"), + } + } +} + +impl std::error::Error for LzxError {} + +// ── Bit reader (MSB order, 16-bit LE pairs) ──────────────────────────────── + +struct BitReader<'a> { + data: &'a [u8], + pos: usize, + buf: u32, + left: i32, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0, buf: 0, left: 0 } + } + + /// Inject one 16-bit little-endian pair into MSB bit buffer. + fn fill(&mut self) { + let b0 = if self.pos < self.data.len() { + let b = self.data[self.pos]; self.pos += 1; b as u32 + } else { 0 }; + let b1 = if self.pos < self.data.len() { + let b = self.data[self.pos]; self.pos += 1; b as u32 + } else { 0 }; + let word = (b1 << 8) | b0; + self.buf |= word << (16 - self.left as u32); + self.left += 16; + } + + #[inline] + fn ensure(&mut self, n: i32) { + while self.left < n { self.fill(); } + } + + #[inline] + fn peek(&self, n: u32) -> u32 { + self.buf >> (BITBUF_WIDTH - n) + } + + #[inline] + fn remove(&mut self, n: u32) { + self.buf <<= n; + self.left -= n as i32; + } + + #[inline] + fn read(&mut self, n: u32) -> u32 { + self.ensure(n as i32); + let v = self.peek(n); + self.remove(n); + v + } + + /// Read a raw byte directly (for UNCOMPRESSED blocks). + fn raw_byte(&mut self) -> u8 { + if self.pos < self.data.len() { + let b = self.data[self.pos]; self.pos += 1; b + } else { 0 } + } + + /// Re-align the bitstream at a frame boundary. + fn align_frame(&mut self) { + if self.left > 0 { self.ensure(16); } + let r = self.left & 15; + if r != 0 { self.remove(r as u32); } + } +} + +// ── Huffman table builder (MSB order) ─────────────────────────────────────── + +fn make_decode_table( + nsyms: usize, + nbits: usize, + length: &[u8], + table: &mut [u16], +) -> bool { + let mut pos: usize = 0; + let table_mask = 1usize << nbits; + let mut bit_mask = table_mask >> 1; + + // Short codes: direct mapping + for bit_num in 1..=nbits { + for sym in 0..nsyms { + if length[sym] as usize != bit_num { continue; } + let leaf = pos; + pos += bit_mask; + if pos > table_mask { return true; } + for i in leaf..leaf + bit_mask { + table[i] = sym as u16; + } + } + bit_mask >>= 1; + } + + if pos == table_mask { return false; } + + // Mark remaining entries as unused + for i in pos..table_mask { + table[i] = 0xFFFF; + } + + let mut next_symbol = if (table_mask >> 1) < nsyms { nsyms } else { table_mask >> 1 }; + + let mut pos32 = (pos as u32) << 16; + let table_mask32 = (table_mask as u32) << 16; + let mut bit_mask32: u32 = 1 << 15; + + // Long codes: tree traversal + for bit_num in (nbits + 1)..=HUFF_MAXBITS { + for sym in 0..nsyms { + if length[sym] as usize != bit_num { continue; } + if pos32 >= table_mask32 { return true; } + + let mut leaf = (pos32 >> 16) as usize; + + for fill in 0..(bit_num - nbits) { + if table[leaf] == 0xFFFF { + table[next_symbol << 1] = 0xFFFF; + table[(next_symbol << 1) + 1] = 0xFFFF; + table[leaf] = next_symbol as u16; + next_symbol += 1; + } + leaf = (table[leaf] as usize) << 1; + if (pos32 >> (15 - fill as u32)) & 1 != 0 { + leaf += 1; + } + } + table[leaf] = sym as u16; + pos32 += bit_mask32; + } + bit_mask32 >>= 1; + } + + pos32 != table_mask32 +} + +// ── Huffman symbol decoder ────────────────────────────────────────────────── + +fn read_huffsym( + br: &mut BitReader, + table: &[u16], + lens: &[u8], + tablebits: usize, + maxsyms: usize, +) -> Result { + br.ensure(HUFF_MAXBITS as i32); + let mut sym = table[br.peek(tablebits as u32) as usize] as usize; + if sym >= maxsyms { + let mut i: u32 = 1 << (BITBUF_WIDTH - tablebits as u32); + loop { + i >>= 1; + if i == 0 { return Err(LzxError::BadHuffmanTable); } + sym = table[(sym << 1) | if br.buf & i != 0 { 1 } else { 0 }] as usize; + if sym < maxsyms { break; } + } + } + br.remove(lens[sym] as u32); + Ok(sym) +} + +// ── LZX decoder state ─────────────────────────────────────────────────────── + +pub struct LzxDecoder { + window: Vec, + window_size: usize, + window_posn: usize, + frame_posn: usize, + frame: usize, + num_offsets: usize, + + r0: u32, + r1: u32, + r2: u32, + + block_type: u8, + block_length: usize, + block_remaining: usize, + + header_read: bool, + intel_filesize: i32, + intel_curpos: i32, + intel_started: bool, + + // Huffman code lengths + pretree_len: Vec, + maintree_len: Vec, + length_len: Vec, + aligned_len: Vec, + + // Huffman decode tables + pretree_table: Vec, + maintree_table: Vec, + length_table: Vec, + aligned_table: Vec, + + length_empty: bool, +} + +impl LzxDecoder { + pub fn new(window_bits: u32) -> Self { + assert!((15..=21).contains(&window_bits)); + let window_size = 1usize << window_bits; + let num_offsets = (POSITION_SLOTS[(window_bits - 15) as usize] as usize) << 3; + + Self { + window: vec![0u8; window_size], + window_size, + window_posn: 0, + frame_posn: 0, + frame: 0, + num_offsets, + r0: 1, r1: 1, r2: 1, + block_type: 0, + block_length: 0, + block_remaining: 0, + header_read: false, + intel_filesize: 0, + intel_curpos: 0, + intel_started: false, + pretree_len: vec![0u8; PRETREE_MAXSYMS + LENTABLE_SAFETY], + maintree_len: vec![0u8; MAINTREE_MAXSYMS + LENTABLE_SAFETY], + length_len: vec![0u8; LENGTH_MAXSYMS + LENTABLE_SAFETY], + aligned_len: vec![0u8; ALIGNED_MAXSYMS + LENTABLE_SAFETY], + pretree_table: vec![0u16; (1 << PRETREE_TABLEBITS) + PRETREE_MAXSYMS * 2], + maintree_table: vec![0u16; (1 << MAINTREE_TABLEBITS) + MAINTREE_MAXSYMS * 2], + length_table: vec![0u16; (1 << LENGTH_TABLEBITS) + LENGTH_MAXSYMS * 2], + aligned_table: vec![0u16; (1 << ALIGNED_TABLEBITS) + ALIGNED_MAXSYMS * 2], + length_empty: false, + } + } + + fn build_table( + lens: &[u8], table: &mut [u16], maxsyms: usize, tablebits: usize, + ) -> Result<(), LzxError> { + if make_decode_table(maxsyms, tablebits, lens, table) { + Err(LzxError::BadHuffmanTable) + } else { + Ok(()) + } + } + + fn build_table_maybe_empty( + lens: &[u8], table: &mut [u16], maxsyms: usize, tablebits: usize, + ) -> Result { + if make_decode_table(maxsyms, tablebits, lens, table) { + // Check if table is simply empty (all lengths zero) + for i in 0..maxsyms { + if lens[i] > 0 { + return Err(LzxError::BadHuffmanTable); + } + } + Ok(true) // empty + } else { + Ok(false) // not empty + } + } + + /// Read Huffman code lengths using the pretree (lzxd_read_lens). + fn read_lens( + br: &mut BitReader, + lens: &mut [u8], + pretree_len: &mut [u8], + pretree_table: &mut [u16], + first: usize, + last: usize, + ) -> Result<(), LzxError> { + // Build pretree: 20 symbols, 4 bits each + for i in 0..20 { + pretree_len[i] = br.read(4) as u8; + } + Self::build_table(pretree_len, pretree_table, PRETREE_MAXSYMS, PRETREE_TABLEBITS)?; + + let mut x = first; + while x < last { + let z = read_huffsym(br, pretree_table, pretree_len, PRETREE_TABLEBITS, PRETREE_MAXSYMS)?; + if z == 17 { + // Run of zeros: [read 4 bits] + 4 + let mut y = br.read(4) as usize + 4; + while y > 0 && x < last { lens[x] = 0; x += 1; y -= 1; } + } else if z == 18 { + // Run of zeros: [read 5 bits] + 20 + let mut y = br.read(5) as usize + 20; + while y > 0 && x < last { lens[x] = 0; x += 1; y -= 1; } + } else if z == 19 { + // Run of same: [read 1 bit] + 4, then read symbol + let mut y = br.read(1) as usize + 4; + let z2 = read_huffsym(br, pretree_table, pretree_len, PRETREE_TABLEBITS, PRETREE_MAXSYMS)?; + let mut val = lens[x] as i32 - z2 as i32; + if val < 0 { val += 17; } + while y > 0 && x < last { lens[x] = val as u8; x += 1; y -= 1; } + } else { + // Delta: code 0..16 + let mut val = lens[x] as i32 - z as i32; + if val < 0 { val += 17; } + lens[x] = val as u8; + x += 1; + } + } + Ok(()) + } + + /// Decompress the full LZX stream into the output buffer. + pub fn decompress(&mut self, input: &[u8], output_len: usize) -> Result, LzxError> { + let mut br = BitReader::new(input); + let mut output = Vec::with_capacity(output_len); + let mut offset: usize = 0; + + let end_frame = (output_len / LZX_FRAME_SIZE) + 1; + + while self.frame < end_frame { + // Read header once + if !self.header_read { + let i_bit = br.read(1); + let (hi, lo) = if i_bit != 0 { + (br.read(16), br.read(16)) + } else { + (0, 0) + }; + self.intel_filesize = ((hi << 16) | lo) as i32; + self.header_read = true; + } + + // Frame size + let frame_size = if output_len > 0 && (output_len - offset) < LZX_FRAME_SIZE { + output_len - offset + } else { + LZX_FRAME_SIZE + }; + + let mut bytes_todo = (self.frame_posn + frame_size).wrapping_sub(self.window_posn) as i32; + + while bytes_todo > 0 { + // New block? + if self.block_remaining == 0 { + // Realign after odd UNCOMPRESSED block + if self.block_type == LZX_BLOCKTYPE_UNCOMPRESSED && (self.block_length & 1) != 0 { + br.raw_byte(); + } + // Read block type (3 bits) and length (24 bits) + self.block_type = br.read(3) as u8; + let hi = br.read(16) as usize; + let lo = br.read(8) as usize; + self.block_length = (hi << 8) | lo; + self.block_remaining = self.block_length; + + match self.block_type { + LZX_BLOCKTYPE_ALIGNED => { + for i in 0..8 { self.aligned_len[i] = br.read(3) as u8; } + Self::build_table(&self.aligned_len, &mut self.aligned_table, ALIGNED_MAXSYMS, ALIGNED_TABLEBITS)?; + // Fall through to verbatim tree reading + Self::read_lens(&mut br, &mut self.maintree_len, &mut self.pretree_len, &mut self.pretree_table, 0, 256)?; + Self::read_lens(&mut br, &mut self.maintree_len, &mut self.pretree_len, &mut self.pretree_table, 256, LZX_NUM_CHARS + self.num_offsets)?; + Self::build_table(&self.maintree_len, &mut self.maintree_table, MAINTREE_MAXSYMS, MAINTREE_TABLEBITS)?; + if self.maintree_len[0xE8] != 0 { self.intel_started = true; } + Self::read_lens(&mut br, &mut self.length_len, &mut self.pretree_len, &mut self.pretree_table, 0, LZX_NUM_SECONDARY_LENGTHS)?; + self.length_empty = Self::build_table_maybe_empty(&self.length_len, &mut self.length_table, LENGTH_MAXSYMS, LENGTH_TABLEBITS)?; + } + LZX_BLOCKTYPE_VERBATIM => { + Self::read_lens(&mut br, &mut self.maintree_len, &mut self.pretree_len, &mut self.pretree_table, 0, 256)?; + Self::read_lens(&mut br, &mut self.maintree_len, &mut self.pretree_len, &mut self.pretree_table, 256, LZX_NUM_CHARS + self.num_offsets)?; + Self::build_table(&self.maintree_len, &mut self.maintree_table, MAINTREE_MAXSYMS, MAINTREE_TABLEBITS)?; + if self.maintree_len[0xE8] != 0 { self.intel_started = true; } + Self::read_lens(&mut br, &mut self.length_len, &mut self.pretree_len, &mut self.pretree_table, 0, LZX_NUM_SECONDARY_LENGTHS)?; + self.length_empty = Self::build_table_maybe_empty(&self.length_len, &mut self.length_table, LENGTH_MAXSYMS, LENGTH_TABLEBITS)?; + } + LZX_BLOCKTYPE_UNCOMPRESSED => { + self.intel_started = true; + // Align to byte boundary + if br.left == 0 { br.ensure(16); } + br.left = 0; + br.buf = 0; + // Read R0, R1, R2 (12 bytes, little-endian u32s) + let mut buf = [0u8; 12]; + for b in &mut buf { *b = br.raw_byte(); } + self.r0 = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + self.r1 = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]); + self.r2 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); + } + _ => return Err(LzxError::Decrunch("bad block type".into())), + } + } + + let mut this_run = self.block_remaining as i32; + if this_run > bytes_todo { this_run = bytes_todo; } + bytes_todo -= this_run; + self.block_remaining -= this_run as usize; + + let window_size = self.window_size; + + match self.block_type { + LZX_BLOCKTYPE_VERBATIM => { + while this_run > 0 { + let main_element = read_huffsym(&mut br, &self.maintree_table, &self.maintree_len, MAINTREE_TABLEBITS, MAINTREE_MAXSYMS)?; + if main_element < LZX_NUM_CHARS { + self.window[self.window_posn] = main_element as u8; + self.window_posn += 1; + this_run -= 1; + } else { + let me = main_element - LZX_NUM_CHARS; + let mut match_length = me & LZX_NUM_PRIMARY_LENGTHS; + if match_length == LZX_NUM_PRIMARY_LENGTHS { + if self.length_empty { return Err(LzxError::Decrunch("LENGTH tree empty".into())); } + let footer = read_huffsym(&mut br, &self.length_table, &self.length_len, LENGTH_TABLEBITS, LENGTH_MAXSYMS)?; + match_length += footer; + } + match_length += LZX_MIN_MATCH; + + let mut match_offset = (me >> 3) as u32; + match match_offset { + 0 => match_offset = self.r0, + 1 => { match_offset = self.r1; self.r1 = self.r0; self.r0 = match_offset; } + 2 => { match_offset = self.r2; self.r2 = self.r0; self.r0 = match_offset; } + 3 => { match_offset = 1; self.r2 = self.r1; self.r1 = self.r0; self.r0 = match_offset; } + _ => { + let extra = if match_offset >= 36 { 17 } else { EXTRA_BITS[match_offset as usize] as u32 }; + let verbatim_bits = br.read(extra); + match_offset = POSITION_BASE[match_offset as usize] - 2 + verbatim_bits; + self.r2 = self.r1; self.r1 = self.r0; self.r0 = match_offset; + } + } + + if self.window_posn + match_length > window_size { + return Err(LzxError::Decrunch("match overrun".into())); + } + self.copy_match(match_offset as usize, match_length); + this_run -= match_length as i32; + } + } + } + LZX_BLOCKTYPE_ALIGNED => { + while this_run > 0 { + let main_element = read_huffsym(&mut br, &self.maintree_table, &self.maintree_len, MAINTREE_TABLEBITS, MAINTREE_MAXSYMS)?; + if main_element < LZX_NUM_CHARS { + self.window[self.window_posn] = main_element as u8; + self.window_posn += 1; + this_run -= 1; + } else { + let me = main_element - LZX_NUM_CHARS; + let mut match_length = me & LZX_NUM_PRIMARY_LENGTHS; + if match_length == LZX_NUM_PRIMARY_LENGTHS { + if self.length_empty { return Err(LzxError::Decrunch("LENGTH tree empty".into())); } + let footer = read_huffsym(&mut br, &self.length_table, &self.length_len, LENGTH_TABLEBITS, LENGTH_MAXSYMS)?; + match_length += footer; + } + match_length += LZX_MIN_MATCH; + + let mut match_offset = (me >> 3) as u32; + match match_offset { + 0 => match_offset = self.r0, + 1 => { match_offset = self.r1; self.r1 = self.r0; self.r0 = match_offset; } + 2 => { match_offset = self.r2; self.r2 = self.r0; self.r0 = match_offset; } + _ => { + let extra = if match_offset >= 36 { 17 } else { EXTRA_BITS[match_offset as usize] as u32 }; + match_offset = POSITION_BASE[match_offset as usize] - 2; + if extra > 3 { + let verbatim_bits = br.read(extra - 3); + match_offset += verbatim_bits << 3; + let aligned = read_huffsym(&mut br, &self.aligned_table, &self.aligned_len, ALIGNED_TABLEBITS, ALIGNED_MAXSYMS)?; + match_offset += aligned as u32; + } else if extra == 3 { + let aligned = read_huffsym(&mut br, &self.aligned_table, &self.aligned_len, ALIGNED_TABLEBITS, ALIGNED_MAXSYMS)?; + match_offset += aligned as u32; + } else if extra > 0 { + let verbatim_bits = br.read(extra); + match_offset += verbatim_bits; + } else { + match_offset = 1; + } + self.r2 = self.r1; self.r1 = self.r0; self.r0 = match_offset; + } + } + + if self.window_posn + match_length > window_size { + return Err(LzxError::Decrunch("match overrun".into())); + } + self.copy_match(match_offset as usize, match_length); + this_run -= match_length as i32; + } + } + } + LZX_BLOCKTYPE_UNCOMPRESSED => { + let run = this_run as usize; + for _ in 0..run { + self.window[self.window_posn] = br.raw_byte(); + self.window_posn += 1; + } + } + _ => return Err(LzxError::Decrunch("bad block type in decode".into())), + } + + // Overrun accounting + if this_run < 0 { + let overrun = (-this_run) as usize; + if overrun > self.block_remaining { + return Err(LzxError::Decrunch("overrun past block end".into())); + } + self.block_remaining -= overrun; + } + } + + // Frame boundary check + if (self.window_posn.wrapping_sub(self.frame_posn)) != frame_size { + return Err(LzxError::Decrunch(format!( + "decode beyond frame: {} != {}", self.window_posn - self.frame_posn, frame_size + ))); + } + + // Re-align bitstream + br.align_frame(); + + // Intel E8 postprocessing + if self.intel_started && self.intel_filesize != 0 + && self.frame <= 32768 && frame_size > 10 + { + let mut e8_buf = vec![0u8; frame_size]; + e8_buf.copy_from_slice(&self.window[self.frame_posn..self.frame_posn + frame_size]); + + let mut i = 0usize; + let limit = frame_size - 10; + let mut curpos = self.intel_curpos; + let filesize = self.intel_filesize; + + while i < limit { + if e8_buf[i] != 0xE8 { i += 1; curpos += 1; continue; } + let abs_off = e8_buf[i+1] as i32 + | (e8_buf[i+2] as i32) << 8 + | (e8_buf[i+3] as i32) << 16 + | (e8_buf[i+4] as i32) << 24; + + if abs_off >= -curpos && abs_off < filesize { + let rel_off = if abs_off >= 0 { abs_off - curpos } else { abs_off + filesize }; + e8_buf[i+1] = rel_off as u8; + e8_buf[i+2] = (rel_off >> 8) as u8; + e8_buf[i+3] = (rel_off >> 16) as u8; + e8_buf[i+4] = (rel_off >> 24) as u8; + } + i += 5; + curpos += 5; + } + self.intel_curpos += frame_size as i32; + + let to_write = frame_size.min(output_len - offset); + output.extend_from_slice(&e8_buf[..to_write]); + offset += to_write; + } else { + if self.intel_filesize != 0 { self.intel_curpos += frame_size as i32; } + let to_write = frame_size.min(output_len - offset); + output.extend_from_slice(&self.window[self.frame_posn..self.frame_posn + to_write]); + offset += to_write; + } + + // Advance frame + self.frame_posn += frame_size; + self.frame += 1; + if self.window_posn == self.window_size { self.window_posn = 0; } + if self.frame_posn == self.window_size { self.frame_posn = 0; } + } + + Ok(output) + } + + /// Copy a match from the window (handles wrap-around). + fn copy_match(&mut self, match_offset: usize, match_length: usize) { + let window_size = self.window_size; + let mut remaining = match_length; + + if match_offset > self.window_posn { + // Source wraps around window end + let j = match_offset - self.window_posn; + let mut src = window_size - j; + if j < remaining { + remaining -= j; + for _ in 0..j { + self.window[self.window_posn] = self.window[src]; + self.window_posn += 1; + src += 1; + } + src = 0; // wrap to start + } + for _ in 0..remaining { + self.window[self.window_posn] = self.window[src]; + self.window_posn += 1; + src += 1; + } + } else { + let mut src = self.window_posn - match_offset; + for _ in 0..remaining { + self.window[self.window_posn] = self.window[src]; + self.window_posn += 1; + src += 1; + } + } + } +} diff --git a/crates/sylpheed-xex/src/pdata.rs b/crates/sylpheed-xex/src/pdata.rs new file mode 100644 index 00000000..1a0c08f8 --- /dev/null +++ b/crates/sylpheed-xex/src/pdata.rs @@ -0,0 +1,219 @@ +//! PE32 `.pdata` exception data parser for PowerPC Xbox 360 binaries. +//! +//! Each `RUNTIME_FUNCTION` entry is 8 bytes, big-endian on disk: +//! ```text +//! word 0: BeginAddress (absolute VA, not RVA — Xbox 360 convention) +//! word 1: packed metadata (read as a single big-endian u32; MSVC +//! bit-field layout packs LSB-first): +//! bits 0.. 7 (low 8) : prolog_length (instruction count, dwords) +//! bits 8..29 (mid 22): function_length (instruction count, dwords) +//! bit 30 : 32-bit code flag (always 1 on PPC) +//! bit 31 : exception-handler-present flag +//! ``` +//! +//! Reference: Microsoft PE32+ exception data spec (PowerPC RUNTIME_FUNCTION); +//! xenia-canary `src/xenia/cpu/xex_module.cc:1570-1587` (canary only reads +//! `BeginAddress`; the metadata layout above is the authoritative spec). +//! +//! `BeginAddress = 0` terminates the table early in some images (canary breaks +//! on this; we mirror). + +use crate::pe::PeSection; + +/// One parsed `RUNTIME_FUNCTION` entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdataEntry { + /// Absolute VA of the function's first instruction. + pub begin_address: u32, + /// Function size in bytes (function_length_dwords * 4). + pub function_length: u32, + /// Prolog size in bytes (prolog_length_dwords * 4). + pub prolog_length: u32, + /// Raw 2-bit flags lifted from the packed word's top two bits, i.e. + /// `(meta >> 30) & 3`. So **bit 0 mirrors packed bit 30 (32-bit-code, set + /// on essentially every PPC entry) and bit 1 mirrors packed bit 31 + /// (exception handler registered)** — test `flags & 2` for "has EH". + pub flags: u8, +} + +impl PdataEntry { + /// One-past-the-last instruction (exclusive). + pub fn end_address(&self) -> u32 { + self.begin_address.wrapping_add(self.function_length) + } +} + +/// Parse the `.pdata` section out of a decompressed PE image. +/// +/// `pe` is the full image buffer (image_base-relative); `image_base` and the +/// `.pdata` section descriptor come from `sylpheed_xex::pe::parse_sections`. +/// Returns an empty vec if no `.pdata` section is present or it falls outside +/// the buffer — never an error (the caller already validated the section list). +pub fn parse_pdata(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Vec { + let pdata = match sections.iter().find(|s| s.name == ".pdata") { + Some(s) => s, + None => return Vec::new(), + }; + + let off = pdata.virtual_address as usize; + let len = pdata.virtual_size as usize; + if off.saturating_add(len) > pe.len() { + return Vec::new(); + } + + // Each entry is 8 bytes; truncate any partial trailing entry. + let n_entries = len / 8; + let mut out = Vec::with_capacity(n_entries); + + for i in 0..n_entries { + let p = off + i * 8; + let begin = u32::from_be_bytes([pe[p], pe[p + 1], pe[p + 2], pe[p + 3]]); + let meta = u32::from_be_bytes([pe[p + 4], pe[p + 5], pe[p + 6], pe[p + 7]]); + + // Sentinel: BeginAddress=0 marks early termination (canary `xex_module.cc:1583`). + if begin == 0 { + break; + } + + let prolog_dwords = meta & 0xFF; + let function_dwords = (meta >> 8) & 0x003F_FFFF; + let flags = ((meta >> 30) & 0x3) as u8; + + out.push(PdataEntry { + begin_address: begin, + function_length: function_dwords * 4, + prolog_length: prolog_dwords * 4, + flags, + }); + } + + // Sanity: drop any entry whose begin_address falls outside the image bounds. + // Image high water = image_base + the largest virtual_address+virtual_size. + let high = sections + .iter() + .map(|s| image_base.wrapping_add(s.virtual_address).wrapping_add(s.virtual_size)) + .max() + .unwrap_or(u32::MAX); + out.retain(|e| e.begin_address >= image_base && e.begin_address < high); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pe::PeSection; + + fn mk_pe(image_base: u32, text_va: u32, text_size: u32, pdata: &[(u32, u32)]) -> (Vec, Vec) { + // Build a synthetic PE image with .text and .pdata. + // Layout: pdata at RVA 0x1000, .text at RVA 0x2000. + let pdata_rva = 0x1000u32; + let pdata_size = (pdata.len() * 8) as u32; + let total = (text_va + text_size).max(pdata_rva + pdata_size) as usize; + let mut buf = vec![0u8; total]; + + for (i, &(begin, packed)) in pdata.iter().enumerate() { + let p = pdata_rva as usize + i * 8; + buf[p..p + 4].copy_from_slice(&begin.to_be_bytes()); + buf[p + 4..p + 8].copy_from_slice(&packed.to_be_bytes()); + } + + let sections = vec![ + PeSection { + name: ".pdata".into(), + virtual_address: pdata_rva, + virtual_size: pdata_size, + raw_offset: pdata_rva, + raw_size: pdata_size, + flags: 0x4000_0040, // INITIALIZED_DATA | READ + }, + PeSection { + name: ".text".into(), + virtual_address: text_va, + virtual_size: text_size, + raw_offset: text_va, + raw_size: text_size, + flags: 0x6000_0020, // CODE | EXECUTE | READ + }, + ]; + let _ = image_base; // image_base only matters for high-water bound + (buf, sections) + } + + /// Pack metadata in the on-disk layout: prolog in low 8 bits, function + /// in next 22, flags in top 2. + fn pack(prolog_dwords: u32, function_dwords: u32, flags: u32) -> u32 { + ((flags & 0x3) << 30) | ((function_dwords & 0x3F_FFFF) << 8) | (prolog_dwords & 0xFF) + } + + #[test] + fn parses_simple_pdata() { + // function at 0x82001000, 32 bytes long (8 dwords), 8-dword prolog (32 bytes). + let packed = pack(8, 8, 0b01); // 32-bit-code flag set + let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x100, &[(0x8200_1000, packed)]); + let entries = parse_pdata(&pe, 0x8200_0000, §ions); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].begin_address, 0x8200_1000); + assert_eq!(entries[0].prolog_length, 32); + assert_eq!(entries[0].function_length, 32); + assert_eq!(entries[0].flags, 0b01); + assert_eq!(entries[0].end_address(), 0x8200_1020); + } + + #[test] + fn stops_on_zero_sentinel() { + let packed = pack(4, 4, 0b01); + let entries = vec![ + (0x8200_1000, packed), + (0u32, 0u32), // sentinel + (0x8200_2000, packed), + ]; + let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x4000, &entries); + let parsed = parse_pdata(&pe, 0x8200_0000, §ions); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].begin_address, 0x8200_1000); + } + + #[test] + fn drops_out_of_range_entries() { + let packed = pack(4, 4, 0b01); + let entries = vec![ + (0x8200_1000, packed), + (0x4000_0000, packed), // outside image — drop + ]; + let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x100, &entries); + let parsed = parse_pdata(&pe, 0x8200_0000, §ions); + assert_eq!(parsed.len(), 1); + } + + #[test] + fn decodes_real_world_layout() { + // Mimics a real-world entry: function_length 306 dwords (1224 bytes), + // 0 prolog dwords, 32-bit-code flag set. Verify the bit-packed value + // round-trips correctly through parse_pdata. + let packed = pack(0, 306, 0b01); + let begin = 0x8200_2000u32; // inside the synthetic .text region + let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x1000, &[(begin, packed)]); + let entries = parse_pdata(&pe, 0x8200_0000, §ions); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].function_length, 306 * 4); + assert_eq!(entries[0].prolog_length, 0); + assert_eq!(entries[0].flags, 0b01); + assert_eq!(entries[0].end_address(), begin + 1224); + } + + #[test] + fn returns_empty_when_no_pdata_section() { + let sections = vec![PeSection { + name: ".text".into(), + virtual_address: 0x1000, + virtual_size: 0x100, + raw_offset: 0x1000, + raw_size: 0x100, + flags: 0x6000_0020, + }]; + let pe = vec![0u8; 0x2000]; + assert!(parse_pdata(&pe, 0x8200_0000, §ions).is_empty()); + } +} diff --git a/crates/sylpheed-xex/src/pe.rs b/crates/sylpheed-xex/src/pe.rs new file mode 100644 index 00000000..e7ec272f --- /dev/null +++ b/crates/sylpheed-xex/src/pe.rs @@ -0,0 +1,68 @@ +//! Minimal PE parser for Xbox 360 executables. +//! PE headers are little-endian even on the big-endian Xbox 360. + +use serde::Serialize; + +#[derive(Serialize, Debug, Clone)] +pub struct PeSection { + pub name: String, + pub virtual_address: u32, + pub virtual_size: u32, + pub raw_offset: u32, + pub raw_size: u32, + pub flags: u32, +} + +impl PeSection { + pub fn is_code(&self) -> bool { + self.flags & 0x20000000 != 0 // IMAGE_SCN_MEM_EXECUTE + } +} + +fn le_u16(data: &[u8], off: usize) -> u16 { + u16::from_le_bytes([data[off], data[off + 1]]) +} + +fn le_u32(data: &[u8], off: usize) -> u32 { + u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) +} + +pub fn parse_sections(pe: &[u8]) -> anyhow::Result> { + anyhow::ensure!(pe.len() >= 64, "PE too small"); + anyhow::ensure!(pe[0] == b'M' && pe[1] == b'Z', "not a PE (bad MZ)"); + + let e_lfanew = le_u32(pe, 0x3C) as usize; + anyhow::ensure!(e_lfanew + 4 <= pe.len(), "e_lfanew out of bounds"); + + let nt_sig = le_u32(pe, e_lfanew); + anyhow::ensure!(nt_sig == 0x00004550, "bad PE signature: 0x{nt_sig:08X}"); + + let file_header_off = e_lfanew + 4; + let num_sections = le_u16(pe, file_header_off + 2) as usize; + let opt_header_size = le_u16(pe, file_header_off + 16) as usize; + + let section_table_off = file_header_off + 20 + opt_header_size; + + let mut sections = Vec::new(); + for i in 0..num_sections { + let s = section_table_off + i * 40; + if s + 40 > pe.len() { break; } + + let name_bytes = &pe[s..s + 8]; + let name = std::str::from_utf8(name_bytes) + .unwrap_or("???") + .trim_end_matches('\0') + .to_string(); + + sections.push(PeSection { + name, + virtual_size: le_u32(pe, s + 8), + virtual_address: le_u32(pe, s + 12), + raw_size: le_u32(pe, s + 16), + raw_offset: le_u32(pe, s + 20), + flags: le_u32(pe, s + 36), + }); + } + + Ok(sections) +} diff --git a/crates/sylpheed-xex/src/resources.rs b/crates/sylpheed-xex/src/resources.rs new file mode 100644 index 00000000..38223a6c --- /dev/null +++ b/crates/sylpheed-xex/src/resources.rs @@ -0,0 +1,127 @@ +//! XEX `XEX_HEADER_RESOURCE_INFO` (key `0x000002FF`) — the embedded resource table. +//! +//! The header points at a length-prefixed table of fixed 16-byte records: +//! +//! ```text +//! u32 size total table size in bytes, including this field +//! record[] entries (size - 4) / 16 of: +//! char[8] name resource name, NUL-padded (the title's is its +//! title id in uppercase hex, e.g. "535107D4") +//! u32 address absolute VA of the resource inside the loaded image +//! u32 size resource length in bytes +//! ``` +//! +//! For a title the named resource is its **XDBF/SPA package** — achievements, +//! localized strings, and images. See `sylpheed_xexdb::xdbf`. +//! +//! Reference: xenia-canary `kernel/util/xex2_info.h` (`xex2_resource`). + +use crate::header::{Xex2Header, header_keys}; + +/// One entry of the XEX resource table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XexResource { + /// Resource name from the table, trailing NULs stripped. + pub name: String, + /// Absolute VA of the resource within the loaded image. + pub address: u32, + /// Resource length in bytes. + pub size: u32, +} + +impl XexResource { + /// Offset of this resource within an image-base-relative buffer. + pub fn image_offset(&self, image_base: u32) -> Option { + self.address.checked_sub(image_base).map(|o| o as usize) + } +} + +/// Parse the resource table out of the raw XEX bytes. +/// +/// `data` is the whole XEX file (the optional-header value is a file offset +/// into it, not a VA). Returns an empty vec when the header is absent or the +/// table is truncated — never an error. +pub fn parse_resources(data: &[u8], header: &Xex2Header) -> Vec { + let Some(off) = header + .optional_headers + .iter() + .find(|h| h.key == header_keys::RESOURCE_INFO) + .map(|h| h.value as usize) + else { + return Vec::new(); + }; + if off + 4 > data.len() { + return Vec::new(); + } + let size = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize; + // The size field counts itself; anything smaller than one record is junk. + if size < 4 + 16 || off + size > data.len() { + return Vec::new(); + } + let count = (size - 4) / 16; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let p = off + 4 + i * 16; + let name = String::from_utf8_lossy(&data[p..p + 8]) + .trim_end_matches('\0') + .to_string(); + let address = u32::from_be_bytes([data[p + 8], data[p + 9], data[p + 10], data[p + 11]]); + let rsize = u32::from_be_bytes([data[p + 12], data[p + 13], data[p + 14], data[p + 15]]); + out.push(XexResource { name, address, size: rsize }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::header::{Xex2Header, Xex2OptionalHeader}; + + fn mk_header(opt: Vec) -> Xex2Header { + Xex2Header { + magic: crate::header::XEX2_MAGIC, + module_flags: 0, + header_size: 0, + security_offset: 0, + header_count: opt.len() as u32, + optional_headers: opt, + security_info: None, + file_format_info: None, + import_libraries: Vec::new(), + execution_info: None, + original_pe_name: None, + } + } + + fn with_resource(value: u32) -> Xex2Header { + mk_header(vec![Xex2OptionalHeader { key: header_keys::RESOURCE_INFO, value }]) + } + + #[test] + fn parses_one_resource() { + let mut data = vec![0u8; 0x100]; + let off = 0x40usize; + data[off..off + 4].copy_from_slice(&(4u32 + 16).to_be_bytes()); + data[off + 4..off + 12].copy_from_slice(b"535107D4"); + data[off + 12..off + 16].copy_from_slice(&0x828F_B900u32.to_be_bytes()); + data[off + 16..off + 20].copy_from_slice(&0x0002_1FCFu32.to_be_bytes()); + let r = parse_resources(&data, &with_resource(off as u32)); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name, "535107D4"); + assert_eq!(r[0].address, 0x828F_B900); + assert_eq!(r[0].size, 0x0002_1FCF); + assert_eq!(r[0].image_offset(0x8200_0000), Some(0x8F_B900)); + } + + #[test] + fn absent_header_yields_nothing() { + assert!(parse_resources(&[0u8; 0x100], &mk_header(Vec::new())).is_empty()); + } + + #[test] + fn truncated_table_yields_nothing() { + let mut data = vec![0u8; 0x20]; + data[0..4].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); + assert!(parse_resources(&data, &with_resource(0)).is_empty()); + } +} diff --git a/crates/sylpheed-xex/src/tls.rs b/crates/sylpheed-xex/src/tls.rs new file mode 100644 index 00000000..c9e1e3c0 --- /dev/null +++ b/crates/sylpheed-xex/src/tls.rs @@ -0,0 +1,172 @@ +//! `.tls` section parser for PE32 PowerPC. +//! +//! When MSVC links a binary that uses `__declspec(thread)` storage, it emits +//! a `.tls` section plus an IMAGE_TLS_DIRECTORY32 inside `.rdata`. The +//! directory points at: +//! - the raw initialised TLS data range (start, end VAs) +//! - the address of the index field (a u32 written at runtime by the +//! loader to identify which TLS slot was assigned) +//! - an array of TLS callback function pointers (NUL-terminated) +//! - the size of the zero-fill area appended after raw data +//! +//! Xbox 360 binaries follow the standard PE layout. Sylpheed has no `.tls` +//! section and no TLS directory — the parser simply returns `None` and +//! callers emit zero rows. +//! +//! Reference: Microsoft PE/COFF spec, IMAGE_TLS_DIRECTORY32 layout. + +use crate::pe::PeSection; + +/// One TLS callback function pointer extracted from the directory's +/// callback array. +#[derive(Debug, Clone, Copy)] +pub struct TlsCallback { + pub address: u32, +} + +/// Parsed `.tls` directory information. All fields are absolute VAs. +#[derive(Debug, Clone)] +pub struct TlsInfo { + /// VA of the start of the initialised raw TLS data (template). + pub raw_data_start: u32, + /// VA of one-past-end of the raw TLS data. + pub raw_data_end: u32, + /// VA of the u32 the loader writes the assigned slot index into. + pub index_address: u32, + /// VA of the zero-terminated callback array; 0 when no callbacks. + pub callback_array: u32, + /// Bytes of zero-fill appended after the raw template at thread init. + pub zero_fill_size: u32, + /// Characteristics flags (alignment / etc). + pub characteristics: u32, + /// Resolved TLS callbacks (parsed from `callback_array`). + pub callbacks: Vec, +} + +/// Parse the `.tls` section. Returns `None` if the binary has no `.tls` +/// section or the directory is malformed. +pub fn parse_tls(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Option { + // Find the `.tls` section. The IMAGE_TLS_DIRECTORY32 lives somewhere + // in `.rdata`; rather than hunt the IMAGE_DATA_DIRECTORY entry through + // the optional header, we accept any 24-byte struct at the start of + // `.tls` if the section's raw data looks like a valid directory. + // + // Per MS docs, IMAGE_TLS_DIRECTORY32 layout (24 bytes): + // +0x00 StartAddressOfRawData (VA, 4) + // +0x04 EndAddressOfRawData (VA, 4) + // +0x08 AddressOfIndex (VA, 4) + // +0x0C AddressOfCallBacks (VA, 4 — array of FN ptrs, NUL-terminated) + // +0x10 SizeOfZeroFill (4) + // +0x14 Characteristics (4) + let tls_section = sections.iter().find(|s| s.name == ".tls")?; + let off = tls_section.virtual_address as usize; + if off + 24 > pe.len() { return None; } + + // Xbox 360 PE bodies are big-endian; this is consistent with how we + // parse the PE elsewhere (e.g. xref scanning reads BE u32 from PE). + let read_u32 = |start: usize| -> u32 { + u32::from_be_bytes([pe[start], pe[start + 1], pe[start + 2], pe[start + 3]]) + }; + + let raw_data_start = read_u32(off); + let raw_data_end = read_u32(off + 4); + let index_address = read_u32(off + 8); + let callback_array = read_u32(off + 12); + let zero_fill_size = read_u32(off + 16); + let characteristics = read_u32(off + 20); + + // Sanity: raw_data_start should land somewhere inside the image. + if raw_data_start == 0 && raw_data_end == 0 && index_address == 0 { + return None; + } + + // Walk the callback array (zero-terminated array of u32 VAs). + let mut callbacks = Vec::new(); + if callback_array != 0 { + let mut p = callback_array.wrapping_sub(image_base) as usize; + while p + 4 <= pe.len() { + let v = read_u32(p); + if v == 0 { break; } + callbacks.push(TlsCallback { address: v }); + p += 4; + if callbacks.len() >= 64 { break; } // sanity cap + } + } + + Some(TlsInfo { + raw_data_start, + raw_data_end, + index_address, + callback_array, + zero_fill_size, + characteristics, + callbacks, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pe::PeSection; + + fn mk_section(name: &str, va: u32, size: u32) -> PeSection { + PeSection { + name: name.into(), + virtual_address: va, + virtual_size: size, + raw_offset: va, + raw_size: size, + flags: 0x4000_0040, + } + } + + #[test] + fn returns_none_when_no_tls_section() { + let pe = vec![0u8; 0x100]; + let sections = vec![mk_section(".text", 0x10, 0x40)]; + assert!(parse_tls(&pe, 0x82000000, §ions).is_none()); + } + + #[test] + fn parses_directory_and_callback_array() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x4000]; + + // Place the .tls section at RVA 0x100 with the directory. + let tls_va: u32 = 0x100; + let cb_va: u32 = 0x200; + // Directory fields: + let raw_start = 0x800u32; + let raw_end = 0x900u32; + let idx = 0x1000u32; + let zero_fill = 0x40u32; + let chars = 0x0u32; + let cb_array = image_base + cb_va; + for (i, v) in [ + image_base + raw_start, image_base + raw_end, + image_base + idx, cb_array, zero_fill, chars, + ].iter().enumerate() { + pe[tls_va as usize + i * 4..tls_va as usize + i * 4 + 4] + .copy_from_slice(&v.to_be_bytes()); + } + + // Two callbacks + NUL terminator at cb_va. + let cb1 = image_base + 0x500; + let cb2 = image_base + 0x600; + pe[cb_va as usize..cb_va as usize + 4].copy_from_slice(&cb1.to_be_bytes()); + pe[cb_va as usize + 4..cb_va as usize + 8].copy_from_slice(&cb2.to_be_bytes()); + // pe[cb_va + 8..cb_va + 12] already zero (terminator). + + let sections = vec![mk_section(".tls", tls_va, 0x100)]; + let info = parse_tls(&pe, image_base, §ions).expect("parses"); + + assert_eq!(info.raw_data_start, image_base + raw_start); + assert_eq!(info.raw_data_end, image_base + raw_end); + assert_eq!(info.index_address, image_base + idx); + assert_eq!(info.callback_array, cb_array); + assert_eq!(info.zero_fill_size, zero_fill); + assert_eq!(info.callbacks.len(), 2); + assert_eq!(info.callbacks[0].address, cb1); + assert_eq!(info.callbacks[1].address, cb2); + } +} diff --git a/crates/sylpheed-xex/src/vfs/device.rs b/crates/sylpheed-xex/src/vfs/device.rs new file mode 100644 index 00000000..896ab875 --- /dev/null +++ b/crates/sylpheed-xex/src/vfs/device.rs @@ -0,0 +1,58 @@ +use super::{VfsDevice, VfsEntry, VfsError}; +use std::path::{Path, PathBuf}; + +/// Host filesystem pass-through device. +pub struct HostPathDevice { + name: String, + root: PathBuf, +} + +impl HostPathDevice { + pub fn new(name: impl Into, root: impl AsRef) -> Self { + Self { + name: name.into(), + root: root.as_ref().to_path_buf(), + } + } +} + +impl VfsDevice for HostPathDevice { + fn name(&self) -> &str { + &self.name + } + + fn list_root(&self) -> Result, VfsError> { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(&self.root)? { + let entry = entry?; + let metadata = entry.metadata()?; + entries.push(VfsEntry { + name: entry.file_name().to_string_lossy().into_owned(), + is_directory: metadata.is_dir(), + size: metadata.len(), + offset: 0, + // Host FS carries no Xbox attribute byte; synthesise the + // DIRECTORY/NORMAL split like canary's HostPathDevice. + attributes: if metadata.is_dir() { 0x10 } else { 0x80 }, + }); + } + Ok(entries) + } + + fn read_file(&self, path: &str) -> Result, VfsError> { + let full_path = self.root.join(path); + std::fs::read(&full_path).map_err(VfsError::from) + } + + fn stat(&self, path: &str) -> Result { + let full_path = self.root.join(path); + let metadata = std::fs::metadata(&full_path)?; + Ok(VfsEntry { + name: path.to_string(), + is_directory: metadata.is_dir(), + size: metadata.len(), + offset: 0, + attributes: if metadata.is_dir() { 0x10 } else { 0x80 }, + }) + } +} diff --git a/crates/sylpheed-xex/src/vfs/disc_image.rs b/crates/sylpheed-xex/src/vfs/disc_image.rs new file mode 100644 index 00000000..0254d106 --- /dev/null +++ b/crates/sylpheed-xex/src/vfs/disc_image.rs @@ -0,0 +1,343 @@ +use super::{VfsDevice, VfsEntry, VfsError}; +use std::io::{Read, Seek, SeekFrom}; + +/// XISO disc image device. Parses Xbox 360 disc images (GDFX/XISO format). +/// +/// Caches the fully-resolved entry list at open() — GDFX is a directory +/// tree, and resolving any nested path (`dat/tables.pak`, `media/x.wav`) +/// requires descending into subdirectories. A prior version only scanned +/// the root buffer, so any file under a subdirectory was reported as +/// missing. We read each directory's buffer from disk once at open time +/// and emit full paths into `entries`. +pub struct DiscImageDevice { + name: String, + path: std::path::PathBuf, + game_offset: u64, + /// Flattened file + directory tree, each with its full path relative + /// to the partition root ("dat/tables.pak", etc.). Populated once at + /// `open()` so lookups are O(n) over a cached vec instead of rereading + /// the tree on every NtCreateFile. + entries: Vec, +} + +/// XISO sector size +pub const SECTOR_SIZE: u64 = 0x800; + +/// GDFX magic string +const GDFX_MAGIC: &[u8; 20] = b"MICROSOFT*XBOX*MEDIA"; + +/// File attribute: directory +const FILE_ATTRIBUTE_DIRECTORY: u8 = 0x10; + +/// File attribute: read-only. Canary OR's this into every GDFX entry's +/// attribute byte because a pressed disc is inherently read-only +/// (`disc_image_device.cc:154`: `attributes | kFileAttributeReadOnly`). +const FILE_ATTRIBUTE_READONLY: u8 = 0x01; + +/// Known game partition offsets to try +const LIKELY_OFFSETS: &[u64] = &[ + 0x0000_0000, + 0x0000_FB20, + 0x0002_0600, + 0x0208_0000, + 0x0FD9_0000, +]; + +impl DiscImageDevice { + pub fn open(name: impl Into, path: &std::path::Path) -> Result { + let mut file = std::fs::File::open(path)?; + + // Find the game partition by locating the GDFX magic at sector 32 + let mut game_offset = 0u64; + let mut magic_found = false; + let mut magic_buf = [0u8; 20]; + + for &offset in LIKELY_OFFSETS { + let magic_pos = offset + 32 * SECTOR_SIZE; + if file.seek(SeekFrom::Start(magic_pos)).is_ok() + && file.read_exact(&mut magic_buf).is_ok() + && magic_buf == *GDFX_MAGIC + { + game_offset = offset; + magic_found = true; + break; + } + } + + if !magic_found { + return Err(VfsError::InvalidFormat( + "GDFX magic not found - not a valid XISO disc image".into(), + )); + } + + // Read root directory info from sector 32 header + let fs_ptr = game_offset + 32 * SECTOR_SIZE; + file.seek(SeekFrom::Start(fs_ptr + 20))?; + let mut buf4 = [0u8; 4]; + file.read_exact(&mut buf4)?; + let root_sector = u32::from_le_bytes(buf4) as u64; + file.read_exact(&mut buf4)?; + let root_size = u32::from_le_bytes(buf4) as u64; + + let root_byte_offset = game_offset + root_sector * SECTOR_SIZE; + + // Read the root directory buffer into memory (typically small) + file.seek(SeekFrom::Start(root_byte_offset))?; + let mut root_buffer = vec![0u8; root_size as usize]; + file.read_exact(&mut root_buffer)?; + + let mut dev = Self { + name: name.into(), + path: path.to_path_buf(), + game_offset, + entries: Vec::new(), + }; + dev.collect_entries(&mut file, &root_buffer, 0, "")?; + Ok(dev) + } + + /// Walk one directory's B-tree buffer, emit each file/directory into + /// `out` with its full relative path, and recurse into subdirectory + /// buffers on disk. + /// + /// `prefix` is the current parent path (empty at the root). Names + /// concatenate as `/` so the final path matches what + /// guest callers like `NtCreateFile("dat/tables.pak")` expect. + /// + /// `file` is the already-open disc image handle, reused for every + /// subdirectory read so we don't pay a fresh open per directory on + /// deep trees. + fn collect_entries( + &mut self, + file: &mut std::fs::File, + buffer: &[u8], + ordinal: u16, + prefix: &str, + ) -> Result<(), VfsError> { + let p = ordinal as usize * 4; + if p + 14 > buffer.len() { + return Ok(()); + } + + let node_l = u16::from_le_bytes([buffer[p], buffer[p + 1]]); + let node_r = u16::from_le_bytes([buffer[p + 2], buffer[p + 3]]); + let sector = u32::from_le_bytes([buffer[p + 4], buffer[p + 5], buffer[p + 6], buffer[p + 7]]) as u64; + let length = u32::from_le_bytes([buffer[p + 8], buffer[p + 9], buffer[p + 10], buffer[p + 11]]) as u64; + let attributes = buffer[p + 12]; + let name_length = buffer[p + 13] as usize; + + if p + 14 + name_length > buffer.len() { + return Ok(()); + } + + if node_l != 0 && node_l != 0xFFFF { + self.collect_entries(file, buffer, node_l, prefix)?; + } + + let name = String::from_utf8_lossy(&buffer[p + 14..p + 14 + name_length]).to_string(); + let is_directory = (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + // Match canary: the on-disc attribute byte (DIRECTORY/HIDDEN/SYSTEM/ + // ARCHIVE/NORMAL bits as authored) OR the implicit READONLY bit for + // pressed media. We forward the FULL byte, not a path-shape guess, so + // attribute queries report exactly what the disc records. + let attributes = (attributes | FILE_ATTRIBUTE_READONLY) as u32; + let file_offset = self.game_offset + sector * SECTOR_SIZE; + let full_path = if prefix.is_empty() { + name.clone() + } else { + format!("{}/{}", prefix, name) + }; + + self.entries.push(VfsEntry { + name: full_path.clone(), + is_directory, + size: length, + offset: file_offset, + attributes, + }); + + // Descend into subdirectories. Zero-length directory entries exist + // (empty dirs) and must be skipped to avoid `read_exact` on 0 bytes. + if is_directory && length > 0 { + file.seek(SeekFrom::Start(file_offset))?; + let mut sub_buffer = vec![0u8; length as usize]; + file.read_exact(&mut sub_buffer)?; + self.collect_entries(file, &sub_buffer, 0, &full_path)?; + } + + if node_r != 0 && node_r != 0xFFFF { + self.collect_entries(file, buffer, node_r, prefix)?; + } + Ok(()) + } +} + +impl VfsDevice for DiscImageDevice { + fn name(&self) -> &str { + &self.name + } + + fn list_root(&self) -> Result, VfsError> { + // Return the full flattened tree. Callers of this method are + // dump/debug paths (see `xenia-rs dumpxiso`), which want to see + // every file — root-only was the old flat-enumeration bug. + Ok(self.entries.clone()) + } + + fn read_file(&self, path: &str) -> Result, VfsError> { + let entry = self + .entries + .iter() + .find(|e| e.name.eq_ignore_ascii_case(path) && !e.is_directory) + .ok_or_else(|| VfsError::NotFound(path.to_string()))?; + + let offset = entry.offset; + let size = entry.size as usize; + + // Read from file using seek + let mut file = std::fs::File::open(&self.path)?; + let file_len = file.seek(SeekFrom::End(0))?; + if offset + size as u64 > file_len { + return Err(VfsError::NotFound(format!( + "File data extends past end of image: {} (offset={:#x}, size={:#x}, image_len={:#x})", + path, offset, size, file_len + ))); + } + file.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; size]; + let bytes_read = file.read(&mut buf)?; + if bytes_read < size { + // Try reading the rest + let mut total = bytes_read; + while total < size { + let n = file.read(&mut buf[total..])?; + if n == 0 { + return Err(VfsError::NotFound(format!( + "Short read: got {} of {} bytes for {}", + total, size, path + ))); + } + total += n; + } + } + Ok(buf) + } + + fn stat(&self, path: &str) -> Result { + self.entries + .iter() + .find(|e| e.name.eq_ignore_ascii_case(path)) + .cloned() + .ok_or_else(|| VfsError::NotFound(path.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: the XISO reader used to only enumerate the root directory, + /// so any nested path (`dat/tables.pak`, `media/stream.xma`) failed to + /// open. Verified end-to-end by `browse` on the Sylpheed disc which + /// now lists 358 entries including `dat/*` files. + /// + /// This test runs only if an XISO is available in the parent of the repo + /// root — matches the developer's local layout for the real disc. CI + /// machines without the disc simply skip the test (early-return Ok). + #[test] + fn nested_file_resolves_when_disc_present() { + let disc_path = std::path::Path::new( + "../../../Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso", + ); + if !disc_path.exists() { + eprintln!("skipping: disc image not present at {:?}", disc_path); + return; + } + let dev = DiscImageDevice::open("disc", disc_path).expect("open xiso"); + // Both a top-level and a nested file must be visible. + assert!( + dev.entries.iter().any(|e| e.name == "default.xex"), + "default.xex must be at the root" + ); + assert!( + dev.entries + .iter() + .any(|e| e.name.eq_ignore_ascii_case("dat/tables.pak")), + "nested entry dat/tables.pak missing — subdirectory enumeration broken", + ); + // And read_file must be able to fetch the nested bytes. + let bytes = dev + .read_file("dat/tables.pak") + .expect("read_file on nested path"); + assert!(!bytes.is_empty(), "nested read returned empty buffer"); + } + + /// Build a one-node GDFX directory buffer in memory and parse it with + /// `collect_entries`, asserting the real on-disc attribute byte is + /// forwarded into `VfsEntry.attributes` (with READONLY OR'd in, matching + /// canary `disc_image_device.cc:154`) rather than synthesised from the + /// path shape. + fn parse_single_entry(name: &str, on_disc_attr: u8) -> VfsEntry { + // GDFX dirent: node_l(u16) node_r(u16) sector(u32) length(u32) + // attributes(u8) name_length(u8) name(bytes). The directory bit + // gates subdirectory descent; use length=0 so a "directory" entry + // is treated as an empty leaf and we don't recurse off the buffer. + let mut buf = Vec::new(); + buf.extend_from_slice(&0u16.to_le_bytes()); // node_l + buf.extend_from_slice(&0u16.to_le_bytes()); // node_r + buf.extend_from_slice(&0u32.to_le_bytes()); // sector + buf.extend_from_slice(&0u32.to_le_bytes()); // length (0 => leaf) + buf.push(on_disc_attr); // attributes + buf.push(name.len() as u8); // name_length + buf.extend_from_slice(name.as_bytes()); + + let mut dev = DiscImageDevice { + name: "test".into(), + path: std::path::PathBuf::new(), + game_offset: 0, + entries: Vec::new(), + }; + // `file` is only touched when descending into a non-empty directory; + // our length=0 entries never recurse, so a dummy handle is fine. + let mut file = std::fs::File::open("/dev/null").expect("open /dev/null"); + dev.collect_entries(&mut file, &buf, 0, "").expect("parse"); + assert_eq!(dev.entries.len(), 1); + dev.entries.into_iter().next().unwrap() + } + + #[test] + fn directory_entry_reports_directory_attribute() { + // On-disc 0x10 (DIRECTORY) -> attributes carries 0x10 and READONLY. + let e = parse_single_entry("dat", FILE_ATTRIBUTE_DIRECTORY); + assert!(e.is_directory, "directory bit not decoded"); + assert_ne!( + e.attributes & 0x10, + 0, + "FILE_ATTRIBUTE_DIRECTORY must be set for a directory entry" + ); + assert_ne!(e.attributes & 0x01, 0, "READONLY must be OR'd in (canary)"); + } + + #[test] + fn file_entry_has_no_directory_attribute() { + // On-disc 0x80 (NORMAL) -> not a directory; READONLY still OR'd in. + let e = parse_single_entry("default.xex", 0x80); + assert!(!e.is_directory, "non-directory misdecoded as directory"); + assert_eq!( + e.attributes & 0x10, + 0, + "FILE_ATTRIBUTE_DIRECTORY must be clear for a file entry" + ); + assert_ne!(e.attributes & 0x80, 0, "NORMAL bit must be preserved"); + assert_ne!(e.attributes & 0x01, 0, "READONLY must be OR'd in (canary)"); + } + + #[test] + fn archive_and_hidden_bits_are_preserved() { + // ARCHIVE(0x20) | HIDDEN(0x02) authored on disc must survive intact. + let e = parse_single_entry("save.dat", 0x20 | 0x02); + assert_eq!(e.attributes & 0x20, 0x20, "ARCHIVE bit dropped"); + assert_eq!(e.attributes & 0x02, 0x02, "HIDDEN bit dropped"); + assert_eq!(e.attributes & 0x10, 0, "spurious DIRECTORY bit"); + } +} diff --git a/crates/sylpheed-xex/src/vfs/mod.rs b/crates/sylpheed-xex/src/vfs/mod.rs new file mode 100644 index 00000000..55b1d4b7 --- /dev/null +++ b/crates/sylpheed-xex/src/vfs/mod.rs @@ -0,0 +1,43 @@ +pub mod device; +pub mod disc_image; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VfsError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Invalid format: {0}")] + InvalidFormat(String), + + #[error("File not found: {0}")] + NotFound(String), +} + +/// A virtual filesystem entry (file or directory). +#[derive(Debug, Clone)] +pub struct VfsEntry { + pub name: String, + pub is_directory: bool, + pub size: u64, + pub offset: u64, + /// Xbox `FILE_ATTRIBUTE_*` bitmask for this entry, sourced from the + /// backing device's real on-disc metadata rather than inferred from + /// the path shape. For GDFX disc images this is the on-disc attribute + /// byte at dirent offset +12 OR'd with `FILE_ATTRIBUTE_READONLY` + /// (matches xenia-canary `disc_image_device.cc:154`: + /// `entry->attributes_ = attributes | kFileAttributeReadOnly`). + /// + /// Bit layout (canary `vfs/entry.h:66-76`): READONLY=0x01, HIDDEN=0x02, + /// SYSTEM=0x04, DIRECTORY=0x10, ARCHIVE=0x20, NORMAL=0x80. + pub attributes: u32, +} + +/// Trait for VFS device implementations (XISO, STFS, host path, etc.) +pub trait VfsDevice: Send + Sync { + fn name(&self) -> &str; + fn list_root(&self) -> Result, VfsError>; + fn read_file(&self, path: &str) -> Result, VfsError>; + fn stat(&self, path: &str) -> Result; +} diff --git a/crates/sylpheed-xexdb/Cargo.toml b/crates/sylpheed-xexdb/Cargo.toml new file mode 100644 index 00000000..3502c62d --- /dev/null +++ b/crates/sylpheed-xexdb/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "sylpheed-xexdb" +version = "0.1.0" +edition = "2024" +description = "Static analysis of the title's XEX into a DuckDB database" + +[[bin]] +name = "sylph-xexdb" +path = "src/bin/sylph-xexdb.rs" + +[dependencies] +sylpheed-xex = { path = "../sylpheed-xex" } +sylpheed-ppc = { path = "../sylpheed-ppc" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +metrics = "0.23" +duckdb = { version = "1", features = ["bundled"] } +msvc-demangler = "0.11" +encoding_rs = "0.8" +clap = { version = "4", features = ["derive"] } diff --git a/crates/sylpheed-xexdb/SCHEMA.md b/crates/sylpheed-xexdb/SCHEMA.md new file mode 100644 index 00000000..5fc52f46 --- /dev/null +++ b/crates/sylpheed-xexdb/SCHEMA.md @@ -0,0 +1,570 @@ +# `xenia-analysis` schema reference + +Authoritative documentation for the DuckDB tables and SQL views produced by +`xenia-rs dis --db sylpheed.db`. Track schema changes here alongside any +update to the `db_schema_golden` test fixture. + +The base + disasm tables (`metadata`, `sections`, `imports`, `functions`, +`labels`, `instructions`, `xrefs`, opt-in `exec_trace` / `import_calls` / +`branch_trace`) are documented inline in `src/db.rs` doc comment. This file +collects layered analysis additions and forward-work notes. + +--- + +## Layer M1 — `.pdata` boundary correction (landed) + +### Schema additions +- `functions.pdata_validated BOOLEAN NOT NULL` — `true` when the row's + `address` matches a `RUNTIME_FUNCTION.BeginAddress` from `.pdata`. Linker + ground truth. +- `functions.pdata_length BIGINT NULL` — `function_length` (bytes) from the + matching pdata entry; `NULL` when the row is prologue-only. +- New table `pdata_entries(begin_address BIGINT PRIMARY KEY, end_address + BIGINT, function_length BIGINT, prolog_length BIGINT, flags BIGINT)` — every + parsed `.pdata` `RUNTIME_FUNCTION` entry (raw, before any merge with + prologue analysis). +- Index `idx_functions_pdata_validated` on `functions(pdata_validated)`. + +### What this layer does +- Parses `.pdata` 8-byte `RUNTIME_FUNCTION` entries (PowerPC PE32 layout): + word 0 `BeginAddress` (absolute VA), word 1 packed + `{prolog_length:8, function_length:22, flags:2}`, both big-endian. +- Unions pdata `BeginAddress` values into the function-candidate set fed to + the prologue walker, so functions our prologue heuristic missed still get + rows. +- When pdata supplies a longer `function_length` than the prologue walk + found, extends `end_address` to the pdata-implied end (catches mis-split + where the walker stopped at an early `blr`). +- After the walker, performs a forward pass that trims `function.end` to the + next start when they overlap (catches mis-merge where one row spanned two + prologues — the audit-031 `sub_824D23B0` / `sub_824D29F0` case). + +### What this layer does NOT do +- Does not adjust prolog-derived `frame_size` / `saved_gprs` from `.pdata`'s + `prolog_length` field — those remain prologue-only inferences. +- Does not classify functions further than the existing `is_leaf` / + `is_saverestore` columns. Class membership is M3. +- Does not detect functions whose entries are missing from BOTH `.pdata` + and the bl-target scan (extremely rare; would require executable-byte + linear sweep). + +### Reference docs +- Microsoft PE32+ exception data spec for PowerPC RUNTIME_FUNCTION. +- xenia-canary `src/xenia/cpu/xex_module.cc:1570-1587` — canary's reference + parser (extracts `BeginAddress` only; we additionally decode word 1). + +### Validation queries +```sql +-- All pdata entries found +SELECT COUNT(*) FROM pdata_entries; -- ~23073 for Sylpheed +-- Functions cross-validated against pdata +SELECT COUNT(*) FROM functions WHERE pdata_validated; +-- Functions detected ONLY by prologue (orphans of pdata) +SELECT COUNT(*) FROM functions WHERE NOT pdata_validated; +-- Pdata orphans NOT yet in functions (should be 0 after this layer) +SELECT COUNT(*) FROM pdata_entries p +LEFT JOIN functions f ON f.address = p.begin_address +WHERE f.address IS NULL; +-- Audit-031 mis-merge resolved: 0x824D29F0 should have its own row +SELECT name FROM functions WHERE address = 2186674160; -- 0x824D29F0 +``` + +--- + +## Layer M2 — MSVC C++ name demangler (landed) + +### Schema additions +- New table `demangled_names(address BIGINT NULL, mangled VARCHAR NOT NULL, + raw_demangled VARCHAR NOT NULL, namespace_path VARCHAR NULL, + class_name VARCHAR NULL, method_name VARCHAR NULL, + params_signature VARCHAR NULL)`. +- Indices on `address`, `class_name`, `method_name`. + +### What this layer does +- Wraps `msvc_demangler::demangle` (a Rust port of LLVM's + `MicrosoftDemangle.cpp`) and splits the formatted output into structured + fields via a heuristic top-level parser (handles templates and nested parens + correctly). +- Populates `demangled_names` from any label whose name starts with `?` plus + any import name that happens to be mangled (defensive — typical kernel + imports use C names). + +### What this layer does NOT do +- Does not parse the AST returned by `msvc_demangler::parse` — uses the formatted + string and a heuristic split. Adequate for typical class member functions + and RTTI strings; exotic template / lambda forms still get `raw_demangled` + populated but may have NULL structured fields. +- Does not yet ingest RTTI strings discovered in `.rdata` — that's M3's job; + M3 will append rows to this table at the addresses where it finds RTTI + TypeDescriptors. + +### Reference docs +- `msvc-demangler` crate (`https://docs.rs/msvc-demangler/0.11`). +- LLVM `MicrosoftDemangle.cpp` (the parser this crate ports). + +## Layer M3 — Vtable + RTTI detection (landed) + +### Schema additions +- `vtables(address PK, length, col_address NULL, class_name, rtti_present, + base_classes_json NULL)` — every detected static vtable. +- `methods(vtable_address, slot, function_address, mangled_name NULL, + demangled_name NULL, PRIMARY KEY (vtable_address, slot))` — one row per + method slot. +- `classes(name PK, vtable_address, rtti_present, base_classes_json NULL)` — + deduped by class name (first-detected vtable wins). +- Indices: `methods.function_address`, `classes.rtti_present`. + +### What this layer does +- Walks `.rdata` and `.data` looking for runs of ≥3 consecutive 4-byte BE + values where each value is a known function start (from M1's corrected + `functions` table). Single-2-method vtables are intentionally rejected to + control false-positive rate. +- Attempts the MSVC RTTI walk `vtable[-1] → CompleteObjectLocator → TypeDescriptor` + for each candidate. When successful, the demangled `class ClassName` + string fills `class_name` and a best-effort + `RTTIClassHierarchyDescriptor` walk fills `base_classes_json` (JSON array + of base class names). +- Falls back to `ANON_Class_<8-hex>` keyed by FNV-1a hash of the sorted + method-PC tuple when RTTI is absent (typical for shipped game binaries). + Identical vtables across the binary (multiple instances) collapse to the + same anonymous name. + +### What this layer does NOT do +- Vtables built at runtime in heap-allocated memory (e.g. by ctors copying + static templates) are out of scope — only static `.rdata`/`.data` content. +- Multiple-inheritance "extra" vftables (one per base subobject) are detected + as independent vtables with no link between them. +- Inheritance-tree walking beyond `RTTIClassHierarchyDescriptor`'s direct + base list is not attempted. + +### Reference docs +- openrce.org "Reversing Microsoft Visual C++" — RTTI layout articles + (CompleteObjectLocator at vtable[-1]; TypeDescriptor at COL+0xC; mangled + name at TD+0x8). + +## Layer M4 — Class-aware probe targeting (landed) + +CLI extension only — no schema changes. The probe-token grammar adds three +symbolic forms on top of the existing `0xADDR` literal: + +- `Class::method` — joins `classes` × `methods` × `demangled_names` to find + every PC whose vtable belongs to that class and whose demangled + `method_name` matches. +- `Class::*` — joins `classes` × `methods` to find every method PC of that + class. +- `function_name` — falls back to `functions.name` lookup for free functions + / saverestore stubs / labels. + +Numeric tokens never touch the DB (preserves zero-IO fast path; lockstep +digest unaffected). Symbolic tokens require the DuckDB at `--probe-db PATH` +or `XENIA_PROBE_DB`; default is `sylpheed.db` next to the .iso when present. + +Resolution happens BEFORE guest exec begins, so it cannot affect the +lockstep digest. + +See `crates/xenia-analysis/src/lookup.rs`. + +--- + +## Layer M5 — Indirect-dispatch reachability (landed) + +### Schema additions +- New value `'ind_call'` in the `xrefs.kind` set. +- New SQL view `v_indirect_reachability_from_entry` — strict superset of + `v_reachability_from_entry`, taking `ind_call` edges in the BFS. + +### What this layer does +- Walks each `FuncAnalysis.functions` entry with a per-basic-block register + tracker. Recognises the canonical static-vtable pattern: + `lis+addi → lwz off(rA) → mtctr → bcctrl`, where `rA` ends up holding a + known vtable's start address from M3. +- Honours the PowerPC ABI: `bl`-style calls (op 18 / 16 with LK=1) clobber + volatile r0..r12 + ctr but preserve non-volatile r13..r31, so a vtable + pointer parked in r30/r31 before a call survives. +- Treats every M3 `loc_*` label as a basic-block boundary (kills register + state) so jump-IN paths cannot induce false positives. + +### What this layer does NOT do (and observed impact) +- Vtable pointer loaded from a `this`-pointer field + (`lwz r_vt, off(rA)` where `rA = this`) — by far the dominant pattern in + real C++ — is unresolvable without alias / points-to analysis. +- On Sylpheed: the layer detects 0 edges. The binary's 1,001 lis+addi + references into vtables are mostly constructor-side **vptr writes** + (`stw rVtable, vptr_offset(this)`), not direct dispatches. The renderer + hunt's audit-009 cluster therefore needs a future M5.5 with `this`-flow + tracking before this layer surfaces it. + +### Reference docs +- IBM PowerPC ABI: register-save convention (volatile r0..r12 + ctr, + non-volatile r13..r31). + +## Layer M7 — String / constant-pool detection (landed) + +### Schema additions +- New table `strings(address PK, encoding, length, content)`. +- Index `idx_strings_encoding`. + +### What this layer does +- Scans `.rdata` for runs of length ≥ 6 of printable ASCII bytes followed by + a NUL terminator. +- Scans `.rdata` for UTF-16LE runs of length ≥ 6 code units (printable-ASCII + basic plane only) followed by a u16 NUL terminator. +- Cross-reference is implicit: existing `xrefs.kind='ref'` rows whose + `target` falls in `strings.address`'s exact match set name the referencing + PCs. SQL: `SELECT s.content, x.source FROM xrefs x JOIN strings s + ON s.address = x.target WHERE x.kind='ref'`. + +### What this layer does NOT do +- No UTF-8 multibyte / non-ASCII basic plane in either encoding. +- No `.data` scan (read-only-section bias). +- No multi-byte CJK encodings — Japanese text in localised builds may be + represented in shift_jis / utf-8 with non-printable bytes that this + scanner skips. + +### Sylpheed yield +- 6,311 ASCII strings (including full embedded HLSL shader source). +- 0 UTF-16LE strings (binary uses ASCII / native CJK encoding). +- 9,132 lis+addi sites cross-reference into the detected strings — names + the source PCs that reference each string. + +## Layer M6 — Extended store-class xrefs + `addr_mode` column (landed) + +### Schema additions +- `xrefs.addr_mode VARCHAR NULL` — sub-classifies how the source instruction + computes its target. NULL for control-flow edges (call / ind_call / j / + br); one of the following tags for data edges: + - `d_form` — standard signed-16 displacement (lwz/stw/lfs/stfs/etc.) + - `lis_addi` — address materialised via `lis + addi` register tracking + - `lis_ori` — address materialised via `lis + ori` + - `multiword` — `lmw / stmw` (one xref per slot; up to 32-rS slots) + - `x_form_indexed` — `stwx / stbx / sthx / stwux / stbux / sthux / stdx / + stdux / lwzx / lbzx / lhzx / lhax / lwzux / lbzux / lhzux / lhaux / ldx / + ldux` — emitted only when both rA and rB are tracked constants + - `x_form_byterev` — `stwbrx / sthbrx / lwbrx / lhbrx` + - `atomic` — `stwcx. / stdcx.` reservation-conditional stores + - `dcbz` — cache-line clear (32-byte zero at rA+rB) +- Index `idx_xrefs_addr_mode`. + +### What this layer does +- Tags every existing data xref with its addressing mode (`d_form` for the + bulk; `lis_addi` / `lis_ori` for the lift-and-add cases that produce + DataRef rows). +- Adds new dispatch for opcode 47 (`stmw`) and 46 (`lmw`), expanding to + per-slot DataWrite / DataRead rows. +- Adds new dispatch for opcode 31 X-form: stores, atomic, byte-reverse, + dcbz. X-form rows are emitted ONLY when both rA and rB resolve to known + constants (otherwise the address is runtime-dependent and we skip). + +### What this layer does NOT do +- VMX / VMX128 vector stores (opcode 31 with vector XO codes) are not + emitted — they always have register-indexed addresses that the + lis+addi tracker can't usually resolve, and detecting them adds noise + without improving target resolution. +- The dominant runtime-of-stwx pattern (rA = base, rB = runtime index) is + not resolved — by design; mem-watch covers the runtime side per VERIFY-B. + +### Sylpheed yield +- 28,834 `lis_addi` refs, 18,485 `d_form` reads, 3,288 `d_form` writes — + the existing baseline now properly tagged. +- **442 newly-detected `x_form_indexed` reads** — primarily lwzx/lhzx + reads from in-table dispatch (each pair (rA,rB) resolved statically). +- **40 newly-detected `atomic` writes** — every `stwcx.` site with a + resolvable address; useful for reservation-table audits. +- 9 `lis_ori` refs. +- 0 multiword / dcbz / byterev — these instructions exist in the binary + but are not in lis+addi-tracked code paths. + +## Layer M8 + M11 — Function-pointer arrays beyond vtables (landed) + +### Schema additions +- New table `function_pointer_arrays(address PK, length, kind)` where + `kind` is `'vtable'` (M3 re-emit), `'dispatch_table'` (M8), or + `'static_init'` (M11). +- New table `function_pointer_array_entries(array_address, slot, + function_address, PRIMARY KEY (array_address, slot))` — one row per + slot of every detected array (vtable + non-vtable). +- Indices on `function_pointer_arrays.kind` and + `function_pointer_array_entries.function_address`. + +### What this layer does +- Walks `.rdata` (only — `.data` produces too many false positives) for + runs of ≥ 2 consecutive 4-byte BE values where each value is a known + function entry from M1's `functions` table. +- Skips runs whose start matches an M3 vtable head — those are re-emitted + in this table with `kind='vtable'` for unified queries but not + re-classified. +- Heuristically classifies non-vtable runs: + - `static_init` (M11): every entry's first instruction is `mfspr r12, LR` + AND the next is `stwu r1, -N(r1)` with `N ≤ 0x80` (or a save-stub `bl`). + Mirrors the typical C++ static-initialiser prologue. + - `dispatch_table` (M8): everything else. + +### What this layer does NOT do +- Does not parse symbol-table-bracketed regions like `__xc_a` / `__xc_z` + / `__xi_a` / `__xi_z` directly — Sylpheed's symbol table is stripped. +- Does not chain multi-segment static-init drivers; future M11.5 could + walk the entry-point's static-init driver call chain to surface + ground-truth ctor PCs. +- 2-slot runs in `.rdata` may be false positives where two struct fields + happen to alias function VAs; downstream queries should use a length + filter (`WHERE length >= 3`) when high precision matters. + +### Sylpheed yield +- 722 vtables (M3 re-emit) + 388 dispatch_tables = 1,110 arrays in + `function_pointer_arrays`. +- 0 static_init detected — Sylpheed's ctors don't all match the + conservative prologue heuristic. Lengths concentrate at 2 slots + (typical of switch-case jump tables). + +## Layer M9 — `has_eh` from `.pdata` exception flag (landed) + +### Schema additions +- `functions.has_eh BOOLEAN NOT NULL` — true when `.pdata`'s exception- + handler-present bit (bit 31 of word 1, the high bit) is set. +- Index `idx_functions_has_eh`. + +### What this layer does +- Derived directly from M1's already-parsed `pdata.flags` bit field (no + new parsing). The bit was always available in `pdata_entries.flags`; + this layer surfaces it as a first-class column on `functions`. + +### What this layer does NOT do +- Does not parse the actual `__CxxFrameHandler` / `__C_specific_handler` + scope-table records that the exception bit gates. Walking those tables + would let us name try/catch ranges and per-state cleanup actions, but + is out of scope for a derive-only milestone. + +### Sylpheed yield +- 2,975 of 23,073 pdata-validated functions have `has_eh=true` (12.9%) — + plausible MSVC C++ EH coverage rate. Largest EH function: 26,328 bytes + (`sub_823518F0`). + +## Layer M10 — `.tls` section / TLS directory (landed) + +### Schema additions +- New table `tls_info(raw_data_start, raw_data_end, index_address, + callback_array, zero_fill_size, characteristics)` — at most one row + (the IMAGE_TLS_DIRECTORY32). +- New table `tls_callbacks(slot PK, address)` — one row per resolved TLS + callback function. + +### What this layer does +- Reads the first 24 bytes of the `.tls` section as an + `IMAGE_TLS_DIRECTORY32` and walks the zero-terminated callback array. +- All addresses stored as absolute VAs. + +### What this layer does NOT do +- Does not parse the raw TLS template content (the variable initialiser + block); just records its start/end VAs. + +### Sylpheed yield +- 0 rows — Sylpheed has no `.tls` section. Infrastructure ready for any + binary that uses `__declspec(thread)` storage. + +## Layer M12 — `--lr-trace` runtime canary-diff harness (landed) + +### Runtime additions (no DB) +- New CLI flag `--lr-trace=PC[,PC,...]` on `exec` — comma-separated PCs + to capture as JSONL records on every fire. Symbolic tokens (`Class::method`) + resolve via M4's lookup against `--probe-db`. Settable via + `XENIA_LR_TRACE`. +- New CLI flag `--lr-trace-out=PATH` — writes JSONL to a file (one + record per line). Stdout when omitted. Settable via `XENIA_LR_TRACE_OUT`. +- New kernel state fields `lr_trace_pcs: HashSet` + + `lr_trace_writer: Option>` and helper + `KernelState::fire_lr_trace_if_match(hw_id)` invoked from the + per-instruction probe slot. + +### JSONL record fields +`pc, tid, hw, cycle, r3, r4, r5, r6, lr` — superset of what +xenia-canary's `--log_lr_on_pc` patch emits, with a cycle counter added +for cross-run reproducibility. + +### What this layer does NOT do +- Does not capture VMX / FP register state (only GPRs r3..r6). +- Does not buffer / batch records — one `write_all` per fire. For + high-frequency probes (e.g. tight loops at >1M fires/sec), redirect + to a file and use a SSD. + +### Determinism +Lockstep digest unaffected: probe firing happens after the per-instr +hooks for ctor/branch probes and only emits side-channel output. Verified +end-of-session: `check sylpheed.iso --stable-digest -n 2M` ×2 produced +byte-identical digests (`instructions=2000005`). + +--- + +## Layer M5.5 — `this`-flow indirect-dispatch resolution (landed) + +### Schema additions +- New table `vptr_writes(writer_pc, vtable_address, vptr_offset, writer_function)` — + every detected `stw rVtable, vptr_off(rThis)` site. +- New table `indirect_dispatch_sites(dispatch_pc PK, vptr_offset, slot, candidate_count)` — + one row per resolved dispatch. +- New table `indirect_dispatch_candidates(dispatch_pc, vtable_address, method_address)` — + one row per (dispatch × candidate vtable). Joined to existing + `xrefs.kind='ind_call'` edges (one ind_call row per candidate). +- New indices on `vptr_writes.vtable_address`, `vptr_writes.vptr_offset`, + `indirect_dispatch_candidates.method_address`, + `indirect_dispatch_candidates.vtable_address`, + `indirect_dispatch_sites.(vptr_offset, slot)`. + +### What this layer does (class-membership inference) +1. **Phase 1 — vptr-write scan**: walk every function with the lis+addi + tracker; whenever `stw rA, off(rB)` writes a known M3 vtable address, + record `(vtable_addr, vptr_offset, writer_pc)`. +2. **Phase 2 — invert**: build `vtables_by_offset[vptr_off] = {V}` for the + set of vtables ever written at that offset. +3. **Phase 3 — dispatch detection**: walk back ≤16 instructions from each + `bcctrl`/`bctr LK=1`, find the canonical + `lwz vt, off(this); lwz fn, slot*4(vt); mtctr fn` chain. Extract + `(vptr_off, slot)`. Bail on register clobber, branch, or label + boundary. +4. **Phase 4 — emit**: for each `(dispatch_pc, vptr_off, slot)`, emit one + `xrefs.kind='ind_call'` row per candidate vtable that has a + matching slot. Multi-candidate rows are an over-approximation. + +### What this layer does NOT do +- No alias resolution at multi-candidate sites — emits one edge per + matching vtable. Downstream queries should filter + `indirect_dispatch_sites WHERE candidate_count=1` for high-confidence + edges. +- No flow-sensitive analysis: register state is killed at every label + (basic-block boundary) and at `bl`/`bcl` calls (volatile r0..r12 + + ctr). We do NOT propagate values across calls in the chain-walker. +- No tracking of vptr writes via X-form indexed (`stwx`), VMX, or + multiword stores. Only D-form `stw rA, off(rB)`. +- Does not synthesise vptr writes for inlined / elided constructors. + If a class never has a writer at offset `vptr_off`, dispatches + through that offset find no candidates. + +### Sylpheed yield +- 567 vptr writes covering 214 distinct vtables (~30% of M3's 722). +- 29 distinct vptr offsets used; offset 0 dominates (501/567 = 88%, + single-inheritance). +- **6,842 dispatch sites resolved**: 97 single-candidate + (high-confidence) + 6,745 multi-candidate (over-approximation). +- 687,963 `ind_call` xref rows total. +- **2,746 newly-reachable functions** via the M5 BFS view + (`v_indirect_reachability_from_entry`) compared to call/j/br alone. +- Audit-009 cluster (renderer plateau): functions newly visible + include `0x823BC9E0`, `0x823BC290`, `0x823BC5A0`, `0x823BB158`, + `0x823BB1E0`, `0x823BCAF0`, `0x823BC4C8` — actionable starting + points for the cluster's reachability hunt. + +### Reference docs +- IBM PowerPC ABI (volatile/non-volatile register partition). +- Itanium C++ ABI on vtable layout (offset-from-`this` model adapted + by MSVC for Win32 PPC). + +## Layer M9.5 — `__CxxFrameHandler` scope-table parsing (landed) + +### Schema additions +- New table `eh_funcinfo(address PK, magic, max_state, p_unwind_map, + n_try_blocks, p_try_block_map, n_ip_map_entries, p_ip_to_state_map, + p_es_type_list, eh_flags)`. +- New table `eh_unwind_map(funcinfo_address, state_index, to_state, action_pc, + PRIMARY KEY (funcinfo_address, state_index))`. +- New table `eh_try_blocks(funcinfo_address, try_index, try_low, try_high, + catch_high, n_catches, p_handler_array, + PRIMARY KEY (funcinfo_address, try_index))`. + +### What this layer does +- Magic-scans `.rdata` for the documented MSVC FuncInfo signatures + (0x19930520 / 0x19930521 / 0x19930522), reading 4-byte BE values + on 4-byte alignment. +- Sanity-checks `max_state` ≤ 10,000, `n_try_blocks` ≤ 1,000, all + internal pointers landing in valid sections. +- Walks `pUnwindMap` (8-byte UnwindMapEntry) and `pTryBlockMap` + (20-byte TryBlockMapEntry) into one row each. + +### What this layer does NOT do +- Does not associate FuncInfo records with their owning function via + the `bl __CxxFrameHandler` registration site — joins to `functions` + by best-effort PC-range queries. A future M9.6 can chase the + registration to make the link explicit. +- Does not parse `pHandlerArray` (per-try-block catch type info). + +### Sylpheed yield +- 2,588 FuncInfo records (all version 0x19930522). +- 10,019 unwind-map entries. +- 315 try-blocks across the binary. + +## Layer M11.5 — Static-init driver chain detection (landed) + +### Schema additions +- Reuses existing `function_pointer_arrays` table — drivers' arrays are + emitted with `kind='static_init'`, replacing M11's prologue-heuristic + output where the structurally-grounded pattern fires. + +### What this layer does +- Walks every detected function looking for the canonical `_initterm`- + style loop: `lwz cursor; mtctr; bcctrl; addi cursor, cursor, 4` + bounded by a comparison against another constant register. +- Extracts `(array_start, array_end)` from the cursor's initial + constant value and the end-comparand register. +- Reads the array, validates each entry against + `func_analysis.functions`, and emits the array as `static_init`. + +### What this layer does NOT do +- Doesn't handle drivers with multiple back-to-back trampoline loops. +- Doesn't follow `_initterm_e` return-status semantics — both + `_initterm` and `_initterm_e` match if the loop body matches. + +### Sylpheed yield +- 0 drivers detected. Sylpheed's static-init structure does not match + the canonical CRT loop pattern; the binary likely calls ctors via + another mechanism (inline at the entry point, or via a different + driver shape). Infrastructure ready for any binary with the + documented MSVC pattern. + +## Layer VMX — Vector-store xrefs (M6 follow-up, landed) + +Extends the M6 X-form opcode-31 dispatch in `xref.rs` with AltiVec/VMX +vector loads and stores. New entries (XO codes): + +- `lvx` (103), `lvxl` (359), `lvebx` (7), `lvehx` (39), `lvewx` (71) + — `addr_mode='x_form_indexed'`, `kind='read'`. +- `stvx` (231), `stvxl` (487), `stvebx` (135), `stvehx` (167), + `stvewx` (199) — `addr_mode='x_form_indexed'`, `kind='write'`. + +Same constraint as M6: rows emitted only when both `rA` and `rB` +resolve to known constants (rare but useful). + +### Sylpheed yield +- 110 `stvx` writes newly resolved. + +## Layer SJIS+UTF-8 — Localised-string detection (M7 follow-up, landed) + +Extends `xenia_analysis::strings::analyze` with two additional scanners. + +### Shift_JIS detection +Per JIS X 0208: lead byte ∈ [0x81, 0x9F] ∪ [0xE0, 0xEF]; +trail byte ∈ [0x40, 0x7E] ∪ [0x80, 0xFC]. Single-byte ASCII and JIS +half-width katakana (0xA1..=0xDF) are passed through. At least one +multi-byte pair must be present (so we don't double-count pure ASCII). +SJIS bytes are rendered as `\\xHH` escapes in the `content` column for +diagnostic readability — full SJIS→UTF-8 decoding is a future +enhancement. + +### UTF-8 detection +Validates 2-byte (`110xxxxx 10xxxxxx`) and 3-byte +(`1110xxxx 10xxxxxx 10xxxxxx`) sequences plus printable ASCII. Skips +4-byte (supplementary plane) which is rare in game text. + +### Sylpheed yield +- 790 Shift_JIS strings (Japanese debug + UI text, including + `[WARNING] ノードに割り当てるエフェクトIDの指定がない ノードデータが見つからない` style mission strings). +- 39 UTF-8 strings. +- 6,311 ASCII strings (unchanged from M7). + +## Forward work (not yet landed) + +- **M9.6** — link `eh_funcinfo` records back to their owning functions + via `bl __CxxFrameHandler` registration sites + per-try-block + `pHandlerArray` parsing. +- **M11.6** — relax M11.5 to detect non-canonical static-init driver + shapes (`_initterm_e` with status return, custom drivers). +- Full SJIS → UTF-8 decoding in the `strings.content` column. +- VMX128 (opcode 4) vector-store xrefs — separate encoding space, low + ROI; document if Sylpheed's renderer cluster uses it. diff --git a/crates/sylpheed-xexdb/build.rs b/crates/sylpheed-xexdb/build.rs new file mode 100644 index 00000000..0a8ef346 --- /dev/null +++ b/crates/sylpheed-xexdb/build.rs @@ -0,0 +1,87 @@ +//! Build script: parse xenia's xboxkrnl_table.inc and xam_table.inc to generate +//! ordinal->name lookup tables at compile time. + +use std::env; +use std::fs; +use std::io::Write; +use std::path::Path; + +fn parse_table(path: &Path) -> Vec<(u32, String, String)> { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + eprintln!("cargo:warning=could not read {}: {}", path.display(), e); + return Vec::new(); + } + }; + + let mut entries = Vec::new(); + for line in content.lines() { + let line = line.trim(); + // XE_EXPORT(module, 0xNNNNNNNN, Name, kType), + if !line.starts_with("XE_EXPORT(") { continue; } + let inner = match line.strip_prefix("XE_EXPORT(").and_then(|s| s.strip_suffix("),")) { + Some(s) => s, + None => continue, + }; + let parts: Vec<&str> = inner.splitn(4, ',').map(|s| s.trim()).collect(); + if parts.len() < 4 { continue; } + let module = parts[0].to_string(); + let ordinal = match u32::from_str_radix(parts[1].trim_start_matches("0x").trim_start_matches("0X"), 16) { + Ok(n) => n, + Err(_) => continue, + }; + let name = parts[2].to_string(); + entries.push((ordinal, name, module)); + } + entries +} + +fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let dest = Path::new(&out_dir).join("ordinals.rs"); + let mut f = fs::File::create(&dest).unwrap(); + + // Locate xenia tables relative to the workspace root + // crates/xenia-analysis/ -> ../../ -> workspace root -> ../xenia-canary/ + let manifest = env::var("CARGO_MANIFEST_DIR").unwrap(); + let workspace_root = Path::new(&manifest).parent().unwrap().parent().unwrap(); + let project_root = workspace_root.parent().unwrap(); + + let krnl_path = project_root + .join("xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_table.inc"); + let xam_path = project_root + .join("xenia-canary/src/xenia/kernel/xam/xam_table.inc"); + + println!("cargo:rerun-if-changed={}", krnl_path.display()); + println!("cargo:rerun-if-changed={}", xam_path.display()); + + let krnl = parse_table(&krnl_path); + let xam = parse_table(&xam_path); + + writeln!(f, "/// Auto-generated from xenia's export tables.").unwrap(); + writeln!(f, "pub fn resolve_ordinal(lib: &str, ordinal: u16) -> Option<&'static str> {{").unwrap(); + writeln!(f, " match lib {{").unwrap(); + + // xboxkrnl.exe + writeln!(f, " \"xboxkrnl.exe\" => match ordinal {{").unwrap(); + for (ord, name, _) in &krnl { + writeln!(f, " 0x{ord:04X} => Some(\"{name}\"),").unwrap(); + } + writeln!(f, " _ => None,").unwrap(); + writeln!(f, " }},").unwrap(); + + // xam.xex + writeln!(f, " \"xam.xex\" => match ordinal {{").unwrap(); + for (ord, name, _) in &xam { + writeln!(f, " 0x{ord:04X} => Some(\"{name}\"),").unwrap(); + } + writeln!(f, " _ => None,").unwrap(); + writeln!(f, " }},").unwrap(); + + writeln!(f, " _ => None,").unwrap(); + writeln!(f, " }}").unwrap(); + writeln!(f, "}}").unwrap(); + + eprintln!("ordinals.rs: {} xboxkrnl + {} xam entries", krnl.len(), xam.len()); +} diff --git a/crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs b/crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs new file mode 100644 index 00000000..efbeddec --- /dev/null +++ b/crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs @@ -0,0 +1,825 @@ +//! `sylph-xexdb` — static analysis of the title's XEX: extract, disassemble, +//! and build the DuckDB database the RE work queries through `tools/zq.py`. +//! +//! This was `xenia-rs`'s CLI. When that emulator was retired the five commands +//! that do static analysis came here and the rest — `exec`, `check`, the 4,233 +//! line `cmd_exec_inner` — did not. The oracle is Xenia Canary now. +//! +//! ⚠️ The database is **DuckDB**, not SQLite. `xenia-rs`'s own `--db` help said +//! SQLite in two places and was wrong; `docs/agents/CONSOLIDATION.md` Phase 3. + +use anyhow::Result; +use clap::{Parser, Subcommand, ValueEnum}; +use tracing::{debug, info, instrument, warn}; + +#[derive(Parser)] +#[command(name = "sylph-xexdb", about = "XEX static analysis: extract, disassemble, and build the analysis DB")] +struct Cli { + #[command(subcommand)] + command: Commands, + /// Tracing filter, e.g. `info` or `debug,sylpheed_xexdb=trace`. + #[arg(long, global = true)] + log_filter: Option, +} + +#[derive(Subcommand)] +enum Commands { + + /// Display XEX header information + Info { + /// Path to XEX file + path: String, + }, + + /// Disassemble a XEX file from its entry point (or an arbitrary address via `--at`) + Disasm { + /// Path to XEX file + path: String, + /// Number of instructions to disassemble + #[arg(short = 'n', default_value = "64")] + count: usize, + /// Start address (hex with or without `0x` prefix). Defaults to + /// the XEX entry point. Must fall inside the loaded image range. + /// + /// Example: `--at 0x824be9a0` to inspect a graphics-interrupt callback. + #[arg(long, value_parser = parse_hex_u32)] + at: Option, + }, + + /// Browse XISO disc image contents + Browse { + /// Path to XISO file + path: String, + }, + + /// Extract PE image and metadata from a XEX file + Extract { + /// Path to XEX or ISO file + path: String, + /// Output directory (default: same directory as input) + #[arg(short, long)] + output: Option, + /// Write base tables (metadata, sections, imports) to a SQLite database + #[arg(long)] + db: Option, + }, + + /// Full disassembly with function detection, cross-references, and optional database + Dis { + /// Path to XEX or ISO file + path: String, + /// Output .asm file (default: stdout) + #[arg(short, long)] + output: Option, + /// Output SQLite database (also includes the base extract tables) + #[arg(long)] + db: Option, + /// Output JSON Lines file: one structured row per instruction with + /// section/function/label/branch_target columns. Suitable for + /// `jq`, pandas, or DuckDB's `read_json_auto`. + #[arg(long)] + json: Option, + /// Choose how analysis tables are produced when `--db` is set. + /// + /// - `rust` (default): only the Rust passes (`func.rs`, `xref.rs`) + /// populate `functions`/`labels`/`xrefs`. No SQL views. + /// - `sql`: Rust passes still run (function detection and data-ref + /// resolution are Rust-only by design); additive SQL views + /// (`v_branch_xrefs`, `v_call_graph`, `v_reachability_from_entry`, + /// `v_function_first_instruction`, `v_imports_called`) are + /// created on top of the same tables. + /// - `both`: same as `sql`, plus a Rust-vs-SQL cross-check on + /// branch xrefs. Disagreement is logged as a warning (non-fatal). + #[arg(long, value_enum, default_value_t = AnalyzeMode::Rust)] + analyze: AnalyzeMode, + /// Ceiling on candidates materialised per unresolved virtual-call site. + /// + /// A `bcctrl` through `this->vptr` is resolved by matching + /// `(vptr_offset, slot)` against every class installing a vtable at + /// that offset. At offset 0 that matches almost every class, so the + /// result is a cross product rather than an answer — one site can + /// claim 700+ callees. Sites above this ceiling are still recorded in + /// `indirect_dispatch_sites` (with `truncated` set and a truthful + /// `candidate_count`), but emit no `indirect_dispatch_candidates` rows + /// and no `ind_call` xrefs. Raise it to get the full cross product back. + #[arg(long, default_value_t = sylpheed_xexdb::ind_dispatch_typed::DEFAULT_MAX_CANDIDATES)] + max_indirect_candidates: usize, + /// Suppress assembly text output (DB-only mode) + #[arg(long)] + quiet: bool, + }, +} + +fn parse_hex_u32(s: &str) -> Result { + let t = s.trim_start_matches("0x").trim_start_matches("0X"); + u32::from_str_radix(t, 16).map_err(|e| format!("bad hex address `{s}`: {e}")) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let filter = cli.log_filter.clone().unwrap_or_else(|| "info".to_string()); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(filter)) + .init(); + match cli.command { + Commands::Info { path } => cmd_info(&path), + Commands::Disasm { path, count, at } => cmd_disasm(&path, count, at), + Commands::Browse { path } => cmd_browse(&path), + Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()), + Commands::Dis { path, output, db, json, analyze, max_indirect_candidates, quiet } => + cmd_dis(&path, output.as_deref(), db.as_deref(), json.as_deref(), analyze, max_indirect_candidates, quiet), + } +} + +fn cmd_info(path: &str) -> Result<()> { + let started = Instant::now(); + let data = load_xex_data(path)?; + let header = sylpheed_xex::loader::parse_xex2_header(&data)?; + + println!("=== XEX2 Header ==="); + println!("Magic: {:#010x}", header.magic); + println!("Module Flags: {:#010x}", header.module_flags); + println!("Header Size: {:#x}", header.header_size); + println!("Headers: {}", header.header_count); + + if let Some(entry) = sylpheed_xex::loader::get_entry_point(&header) { + println!("Entry Point: {:#010x}", entry); + } + if let Some(base) = sylpheed_xex::loader::get_image_base(&header) { + println!("Image Base: {:#010x}", base); + } + + println!("\n=== Optional Headers ==="); + for h in &header.optional_headers { + println!(" Key: {:#010x} Value: {:#010x}", h.key, h.value); + } + + if let Some(ref sec) = header.security_info { + println!("\n=== Security Info ==="); + println!("Image Size: {:#x}", sec.image_size); + println!("Load Address: {:#010x}", sec.load_address); + println!("Image Flags: {:#010x}", sec.image_flags); + println!("Page Descs: {}", sec.page_descriptors.len()); + } + + if let Some(ref ffi) = header.file_format_info { + println!("\n=== File Format ==="); + println!("Encryption: {}", match ffi.encryption_type { + 0 => "None", 1 => "Normal (AES)", _ => "Unknown" + }); + println!("Compression: {}", match ffi.compression_type { + 0 => "None", 1 => "Basic", 2 => "Normal (LZX)", _ => "Unknown" + }); + if !ffi.basic_blocks.is_empty() { + println!("Basic blocks: {}", ffi.basic_blocks.len()); + } + if ffi.normal_window_size != 0 { + println!("LZX Window: {:#x}", ffi.normal_window_size); + } + } + + if let Some(ref name) = header.original_pe_name { + println!("\nOriginal PE: {}", name); + } + + if let Some(ref ei) = header.execution_info { + println!("\n=== Execution Info ==="); + println!("Title ID: {:#010x}", ei.title_id); + println!("Media ID: {:#010x}", ei.media_id); + println!("Disc: {} of {}", ei.disc_number, ei.disc_count); + } + + if !header.import_libraries.is_empty() { + println!("\n=== Import Libraries ==="); + for lib in &header.import_libraries { + println!(" {} (v{:#010x}, {} imports)", lib.name, lib.version_cur, lib.imports.len()); + } + } + + info!(wall_ms = started.elapsed().as_millis() as u64, "info complete"); + Ok(()) +} + +/// Clap parser for `--at` — accepts decimal, 0x-prefixed hex, or bare hex. +fn parse_hex_u32(s: &str) -> Result { + let t = s.trim(); + let (digits, radix) = if let Some(rest) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) { + (rest, 16) + } else if t.chars().all(|c| c.is_ascii_digit()) { + (t, 10) + } else { + (t, 16) + }; + u32::from_str_radix(digits, radix) + .map_err(|e| format!("invalid u32 {:?}: {e} (try `0x824be9a0`)", t)) +} + +#[instrument(skip_all, fields(path = %path, count))] + +fn cmd_disasm(path: &str, count: usize, at: Option) -> Result<()> { + let started = Instant::now(); + let data = load_xex_data(path)?; + let header = sylpheed_xex::loader::parse_xex2_header(&data)?; + + let entry = sylpheed_xex::loader::get_entry_point(&header) + .ok_or_else(|| anyhow::anyhow!("No entry point found in XEX2 header"))?; + let base = sylpheed_xex::loader::get_image_base(&header) + .ok_or_else(|| anyhow::anyhow!("No image base found in XEX2 header"))?; + + info!(entry = format_args!("{:#010x}", entry), base = format_args!("{:#010x}", base), "XEX entry/base"); + + let image_data = sylpheed_xex::loader::load_image(&data, &header)?; + info!(bytes = image_data.len(), "image decompressed"); + + let start = at.unwrap_or(entry); + let label = if at.is_some() { "requested address" } else { "entry point" }; + println!("Disassembly from {} {:#010x} ({} instructions):\n", label, start, count); + + if start < base { + return Err(anyhow::anyhow!( + "address {:#x} is below image base {:#x}", + start, + base + )); + } + let offset = (start - base) as usize; + if offset + count * 4 > image_data.len() { + return Err(anyhow::anyhow!( + "address {:#x} (offset {:#x}) + {} instructions extends past image end ({:#x} bytes)", + start, + offset, + count, + image_data.len() + )); + } + let block = sylpheed_ppc::disasm::disassemble_block(&image_data[offset..], start, count); + for (addr, text) in block { + println!(" {:#010x}: {}", addr, text); + } + + info!(wall_ms = started.elapsed().as_millis() as u64, "disasm complete"); + Ok(()) +} + +#[instrument(skip_all, fields(path = %path, ui))] + +fn cmd_browse(path: &str) -> Result<()> { + use sylpheed_xex::vfs::VfsDevice; + + let disc = sylpheed_xex::vfs::disc_image::DiscImageDevice::open("disc", std::path::Path::new(path)) + .map_err(|e| anyhow::anyhow!("Failed to open disc image: {}", e))?; + + println!("=== XISO Contents: {} ===", path); + match disc.list_root() { + Ok(entries) => { + for entry in entries { + let kind = if entry.is_directory { "DIR " } else { "FILE" }; + println!(" {} {:>10} {}", kind, entry.size, entry.name); + } + } + Err(e) => tracing::error!(%e, "error listing contents"), + } + + Ok(()) +} + +/// Helper: load XEX, parse header, decompress PE, resolve imports, parse sections. +#[instrument(skip_all, fields(path = %path))] +/// Load a XEX and prepare it for analysis. +/// +/// Returns the parsed header, the decompressed image, its sections, and the +/// **raw XEX bytes**. The raw bytes are needed because optional-header values +/// are file offsets into the container, not image VAs — the resource table +/// (and so the embedded XDBF package) is only reachable through them. +fn load_and_prepare(path: &str) -> Result<(sylpheed_xex::Xex2Header, Vec, Vec, Vec)> { + let data = load_xex_data(path)?; + let mut header = sylpheed_xex::loader::parse_xex2_header(&data)?; + + let entry = sylpheed_xex::loader::get_entry_point(&header) + .ok_or_else(|| anyhow::anyhow!("No entry point found in XEX2 header"))?; + let base = sylpheed_xex::loader::get_image_base(&header) + .ok_or_else(|| anyhow::anyhow!("No image base found in XEX2 header"))?; + + info!( + entry = format_args!("{:#010x}", entry), + base = format_args!("{:#010x}", base), + "XEX entry/base" + ); + + let pe_image = sylpheed_xex::loader::load_image(&data, &header)?; + info!(bytes = pe_image.len(), "image decompressed"); + + // Resolve import ordinals and record types from the PE image + sylpheed_xex::loader::resolve_imports(&mut header, &pe_image); + + // Parse PE sections + let sections = sylpheed_xex::pe::parse_sections(&pe_image)?; + info!(sections = sections.len(), "parsed PE sections"); + + Ok((header, pe_image, sections, data)) +} + +#[instrument(skip_all, fields(path = %path))] + +fn cmd_extract(path: &str, output_dir: Option<&str>, db_path: Option<&str>) -> Result<()> { + use serde::Serialize; + + let (header, pe_image, sections, _xex_data) = load_and_prepare(path)?; + + let entry = sylpheed_xex::loader::get_entry_point(&header).unwrap(); + let base = sylpheed_xex::loader::get_image_base(&header).unwrap(); + let image_size = header.security_info.as_ref().map(|s| s.image_size).unwrap_or(0); + + // Build JSON-serializable info struct + #[derive(Serialize)] + struct Xex2Info<'a> { + module_flags: u32, + image_base: u32, + entry_point: u32, + image_size: u32, + original_pe_name: Option<&'a str>, + execution_info: &'a Option, + import_libraries: &'a [sylpheed_xex::header::ImportLibrary], + sections: &'a [sylpheed_xex::pe::PeSection], + } + + let info = Xex2Info { + module_flags: header.module_flags, + image_base: base, + entry_point: entry, + image_size, + original_pe_name: header.original_pe_name.as_deref(), + execution_info: &header.execution_info, + import_libraries: &header.import_libraries, + sections: §ions, + }; + + // Determine output directory + let input_path = std::path::Path::new(path); + let out_dir = match output_dir { + Some(d) => std::path::PathBuf::from(d), + None => input_path.parent().unwrap_or(std::path::Path::new(".")).to_path_buf(), + }; + std::fs::create_dir_all(&out_dir)?; + + let stem = input_path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("output"); + + // Write PE image + let pe_path = out_dir.join(format!("{stem}.pe")); + std::fs::write(&pe_path, &pe_image)?; + info!( + path = %pe_path.display(), + bytes = pe_image.len(), + "wrote PE image" + ); + + // Write JSON metadata + let json_path = out_dir.join(format!("{stem}.xex.json")); + let json = serde_json::to_string_pretty(&info)?; + std::fs::write(&json_path, &json)?; + info!(path = %json_path.display(), "wrote metadata JSON"); + + // Print summary + let total_imports: usize = header.import_libraries.iter().map(|l| l.imports.len()).sum(); + println!("Extracted: {} sections, {} import libraries ({} imports)", + sections.len(), header.import_libraries.len(), total_imports); + if let Some(ref ei) = header.execution_info { + println!("Title ID: 0x{:08X} Media ID: 0x{:08X}", ei.title_id, ei.media_id); + } + + // Write base tables to SQLite if requested + if let Some(db) = db_path { + let disasm_info = sylpheed_xexdb::formatter::DisasmInfo { + image_base: base, + entry_point: entry, + original_pe_name: header.original_pe_name.as_deref(), + title_id: header.execution_info.as_ref().map(|e| e.title_id), + media_id: header.execution_info.as_ref().map(|e| e.media_id), + sections: §ions, + import_libraries: &header.import_libraries, + xex_header: Some(&header), + }; + info!(db = %db, "writing base tables"); + let mut w = sylpheed_xexdb::DbWriter::open_fresh(std::path::Path::new(db))?; + w.write_base(&disasm_info)?; + info!(db = %db, "database written"); + } + + Ok(()) +} + +#[instrument(skip_all, fields(path = %path))] + +fn cmd_dis( + path: &str, + output: Option<&str>, + db_path: Option<&str>, + json_path: Option<&str>, + analyze: AnalyzeMode, + max_indirect_candidates: usize, + quiet: bool, +) -> Result<()> { + use std::collections::HashMap; + + let started = Instant::now(); + let (header, pe_image, sections, xex_data) = load_and_prepare(path)?; + + let entry = sylpheed_xex::loader::get_entry_point(&header).unwrap(); + let base = sylpheed_xex::loader::get_image_base(&header).unwrap(); + + // Build import address -> name map + let mut import_map: HashMap = HashMap::new(); + for lib in &header.import_libraries { + for imp in &lib.imports { + let resolved = sylpheed_xexdb::resolve_ordinal(&lib.name, imp.ordinal); + let name = match resolved { + Some(n) => format!("{}::{}", lib.name, n), + None => format!("{}::ordinal_{:#06X}", lib.name, imp.ordinal), + }; + import_map.insert(imp.address, name); + } + } + info!(thunks = import_map.len(), "resolved import thunks"); + + // Function analysis (with .pdata-validated boundaries when present) + let code_sections: Vec<(u32, u32, u32)> = sections.iter() + .filter(|s| s.is_code()) + .map(|s| (s.virtual_address, s.virtual_size, s.flags)) + .collect(); + let pdata_entries = sylpheed_xex::pdata::parse_pdata(&pe_image, base, §ions); + info!(pdata_entries = pdata_entries.len(), "parsed .pdata RUNTIME_FUNCTION entries"); + let func_analysis = sylpheed_xexdb::func::analyze_with_pdata( + &pe_image, base, entry, &code_sections, &pdata_entries, + ); + info!( + functions = func_analysis.functions.len(), + pdata_validated = func_analysis.functions.values().filter(|f| f.pdata_validated).count(), + "function detection complete", + ); + + // M12 — switch / jump-table recovery. Emits one `jt` xref per distinct + // case body so the case bodies stop looking unreachable, and reports the + // table extents so the linear disassembler can flag them as data. + let jump_tables = sylpheed_xexdb::jumptables::analyze( + &pe_image, base, §ions, &func_analysis, + ); + let jt_data_words = sylpheed_xexdb::jumptables::data_word_addresses(&jump_tables); + info!( + jump_tables = jump_tables.len(), + cases = jump_tables.iter().map(|t| t.targets.len()).sum::(), + data_words = jt_data_words.len(), + "jump-table recovery complete", + ); + + // Cross-reference analysis + let mut xref_result = sylpheed_xexdb::xref::analyze_xrefs_skipping( + &pe_image, base, entry, §ions, &func_analysis, &import_map, &jt_data_words, + ); + + // Feed the recovered `switch` edges into the xref graph, so case bodies + // stop looking unreachable and get a label of their own. + let mut jt_edges = 0usize; + for jt in &jump_tables { + for target in jt.distinct_targets() { + xref_result.xrefs + .entry(target) + .or_default() + .push(sylpheed_xexdb::xref::Xref { + source: jt.bctr_pc, + kind: sylpheed_xexdb::xref::XrefKind::JumpTable, + addr_mode: None, + }); + xref_result.labels + .entry(target) + .or_insert_with(|| format!("case_{target:08X}")); + jt_edges += 1; + } + xref_result.labels + .entry(jt.table_address) + .or_insert_with(|| format!("jpt_{:08X}", jt.table_address)); + } + info!(case_edges = jt_edges, "switch edges added to xref graph"); + let total_xrefs: usize = xref_result.xrefs.values().map(|v| v.len()).sum(); + info!( + labels = xref_result.labels.len(), + xrefs = total_xrefs, + "xref analysis complete" + ); + + // Vtable + RTTI scan (M3). Uses M1's corrected function-start set as the + // pointer-validity oracle; runs over .rdata + .data. + let function_starts: std::collections::BTreeSet = + func_analysis.functions.keys().copied().collect(); + // Anchor discovery: recover vtable bases from constructor vptr-write + // stores so a vtable with non-function head words (null / pure-virtual / + // unrecognised thunk slots) isn't fragmented away by the contiguity + // heuristic. (Fixes e.g. the XMV engine vtable 0x8200a908.) + let vptr_anchor_funcs: std::collections::BTreeMap = func_analysis + .functions + .iter() + .map(|(&s, fi)| (s, (fi.end, fi.is_saverestore))) + .collect(); + let vptr_block_boundaries: std::collections::HashSet = + xref_result.labels.keys().copied().collect(); + let mut vtable_anchors = sylpheed_xexdb::vtables::scan_vptr_write_constants( + &pe_image, base, &vptr_anchor_funcs, §ions, &vptr_block_boundaries, + ); + info!(vtable_anchors = vtable_anchors.len(), "vptr-write anchor scan complete"); + + // M13 — authoritative MSVC RTTI walk. Every `vftable[-1] -> COL` link the + // linker emitted is an anchor the heuristic scan must not miss, and the + // class names it recovers override anything the contiguity scan guessed. + let rtti = sylpheed_xexdb::rtti::analyze(&pe_image, base, §ions); + let rtti_anchors = rtti.vtable_anchors(); + let rtti_new_anchors = rtti_anchors.difference(&vtable_anchors).count(); + vtable_anchors.extend(rtti_anchors.iter().copied()); + info!( + rtti_vtables = rtti_anchors.len(), + new_anchors = rtti_new_anchors, + "RTTI anchors merged", + ); + + let mut vtables = sylpheed_xexdb::vtables::analyze_with_anchors( + &pe_image, base, §ions, &function_starts, &vtable_anchors, + ); + let named = sylpheed_xexdb::vtables::apply_rtti_names(&mut vtables, &rtti); + let vtables = vtables; + let rtti_count = vtables.iter().filter(|v| v.rtti_present).count(); + info!( + vtables = vtables.len(), + rtti = rtti_count, + rtti_named = named, + anon = vtables.len() - rtti_count, + "vtable scan complete", + ); + + // Indirect-dispatch reachability (M5). Walks each function looking for + // the canonical lis+addi → lwz off(vtable) → mtctr → bcctrl pattern and + // emits one xref edge per resolvable site. Inserted into xrefs as + // kind='ind_call'. + let indirect_edges = sylpheed_xexdb::indirect::analyze( + &pe_image, base, &func_analysis, &vtables, &xref_result.labels, + ); + info!(indirect_edges = indirect_edges.len(), "indirect-dispatch scan complete"); + for edge in &indirect_edges { + xref_result.xrefs + .entry(edge.target) + .or_default() + .push(sylpheed_xexdb::xref::Xref { + source: edge.source, + kind: sylpheed_xexdb::xref::XrefKind::IndirectCall, + addr_mode: None, + }); + } + + // String / constant-pool detection (M7). + let strings = sylpheed_xexdb::strings::analyze(&pe_image, base, §ions); + info!(strings = strings.len(), "string scan complete"); + + // .tls directory parse (M10). None for binaries without a .tls section. + let tls_info = sylpheed_xex::tls::parse_tls(&pe_image, base, §ions); + if let Some(ref t) = tls_info { + info!(callbacks = t.callbacks.len(), "tls directory parsed"); + } else { + info!("no .tls section present"); + } + + // Generic function-pointer-array scan (M8 + M11). Re-emits M3 vtables + // plus dispatch tables and static-init tables in `.rdata`. + let mut fparrays = sylpheed_xexdb::funcptr_arrays::analyze( + &pe_image, base, §ions, &function_starts, &vtables, + ); + + // M11.5 — static-init driver chain detection. Replaces M11's prologue + // heuristic with a structurally-grounded result where the driver + // function shape matches. + let static_init = sylpheed_xexdb::static_init::analyze( + &pe_image, base, §ions, &func_analysis, &function_starts, + &xref_result.labels, + ); + info!( + static_init_drivers = static_init.drivers.len(), + static_init_arrays = static_init.arrays.len(), + "M11.5 static-init driver scan complete", + ); + // Merge M11.5 results into the funcptr_arrays vector. If an array's + // address already exists from M8/M11, upgrade its kind from + // 'dispatch_table'/'static_init' to a definitive 'static_init'. + let static_init_addrs: std::collections::HashSet = + static_init.arrays.iter().map(|a| a.address).collect(); + fparrays.retain(|a| !static_init_addrs.contains(&a.address)); + for a in &static_init.arrays { + fparrays.push(a.clone()); + } + info!( + funcptr_arrays = fparrays.len(), + dispatch_tables = fparrays.iter().filter(|a| a.kind == "dispatch_table").count(), + static_inits = fparrays.iter().filter(|a| a.kind == "static_init").count(), + "function-pointer array set finalised", + ); + + // M9.5 — MSVC __CxxFrameHandler scope-table magic-scan. + let eh_records = sylpheed_xexdb::eh_scope::analyze(&pe_image, base, §ions); + info!( + eh_funcinfo = eh_records.len(), + eh_unwind_entries = eh_records.iter().map(|r| r.unwind_map.len()).sum::(), + eh_try_blocks = eh_records.iter().map(|r| r.try_blocks.len()).sum::(), + "M9.5 EH scope-table scan complete", + ); + + // M5.5 — typed indirect-dispatch resolution (this->vptr → method). + let typed_ind = sylpheed_xexdb::ind_dispatch_typed::analyze( + &pe_image, base, &func_analysis, &vtables, &xref_result.labels, + max_indirect_candidates, + ); + let single = typed_ind.dispatches.iter().filter(|d| d.total_candidates == 1).count(); + let multi = typed_ind.dispatches.len() - single; + let typed_edges: usize = typed_ind.dispatches.iter().map(|d| d.method_pcs.len()).sum(); + info!( + vptr_writes = typed_ind.vptr_writes.len(), + dispatches = typed_ind.dispatches.len(), + single_candidate = single, + multi_candidate = multi, + edges = typed_edges, + "M5.5 typed indirect-dispatch scan complete", + ); + // Add ind_call edges for every (dispatch_pc, method) candidate. Sites the + // resolver could not narrow contribute nothing here — `method_pcs` is + // empty for them — which keeps `xrefs` a table of evidence rather than of + // possibilities. + for d in &typed_ind.dispatches { + for &method_pc in &d.method_pcs { + xref_result.xrefs + .entry(method_pc) + .or_default() + .push(sylpheed_xexdb::xref::Xref { + source: d.dispatch_pc, + kind: sylpheed_xexdb::xref::XrefKind::IndirectCall, + addr_mode: None, + }); + } + } + + // XDBF/SPA — the title metadata package the XEX names via its resource + // table (achievements, localized strings, images). Located through the + // resource table rather than by scanning for the magic, so the entry + // table's own accounting is what decides what exists. + let resources = sylpheed_xex::resources::parse_resources(&xex_data, &header); + let xdbf = resources.iter().find_map(|r| { + let off = r.image_offset(base)?; + let x = sylpheed_xexdb::xdbf::analyze(&pe_image, off)?; + info!( + resource = %r.name, + address = format_args!("{:#010x}", r.address), + size = r.size, + entries = x.entries.len(), + achievements = x.achievements.len(), + string_tables = x.string_tables.len(), + images = x.images.len(), + "XDBF package found", + ); + Some(x) + }); + if xdbf.is_none() && !resources.is_empty() { + info!(resources = resources.len(), "resource table present but no XDBF package"); + } + + // Build DisasmInfo + let disasm_info = sylpheed_xexdb::formatter::DisasmInfo { + image_base: base, + entry_point: entry, + original_pe_name: header.original_pe_name.as_deref(), + title_id: header.execution_info.as_ref().map(|e| e.title_id), + media_id: header.execution_info.as_ref().map(|e| e.media_id), + sections: §ions, + import_libraries: &header.import_libraries, + xex_header: Some(&header), + }; + + // SQLite database output (base + ingest + analyze layers) + if let Some(db) = db_path { + info!(db = %db, analyze = ?analyze, "writing database"); + let mut w = sylpheed_xexdb::DbWriter::open_fresh(std::path::Path::new(db))?; + w.write_base(&disasm_info)?; + w.ingest_instructions( + &pe_image, &disasm_info, &func_analysis, &xref_result.labels, &jt_data_words, + )?; + w.write_analysis_results( + &pe_image, + &disasm_info, + &func_analysis, + &xref_result.labels, + &xref_result.xrefs, + &vtables, + &strings, + &fparrays, + Some(&typed_ind), + &eh_records, + &jump_tables, + &rtti, + xdbf.as_ref(), + )?; + w.write_tls(tls_info.as_ref())?; + if matches!(analyze, AnalyzeMode::Sql | AnalyzeMode::Both) { + w.create_sql_views()?; + info!(db = %db, "SQL views created"); + } + if matches!(analyze, AnalyzeMode::Both) { + let (sql_only, rust_only) = w.cross_check_branch_xrefs()?; + if sql_only == 0 && rust_only == 0 { + info!(db = %db, "Rust/SQL branch xrefs agree"); + } else { + tracing::warn!( + db = %db, + sql_only, + rust_only, + "Rust/SQL branch xref disagreement — investigate formatter mnemonic vs xref.rs kind classification" + ); + } + } + info!(db = %db, "database written"); + } + + // JSON Lines output: one row per instruction, structured columns. + if let Some(json) = json_path { + info!(json = %json, "writing JSON Lines"); + let mut out = std::io::BufWriter::new(std::fs::File::create(json)?); + let mut total: u64 = 0; + for section in §ions { + if !section.is_code() { continue; } + let abs_start = base + section.virtual_address; + let abs_end = abs_start + section.virtual_size; + let items = sylpheed_xexdb::enrich_section( + &pe_image, base, §ion.name, abs_start, abs_end, + &func_analysis, &xref_result.labels, &jt_data_words, + ); + total += sylpheed_xexdb::sinks::json::write_jsonl(&mut out, items)?; + } + info!(json = %json, rows = total, "JSON Lines written"); + } + + // Assembly output (skipped when --quiet and no --output specified) + if !quiet || output.is_some() { + let mut out: Box = match output { + Some(path) => Box::new(std::io::BufWriter::new(std::fs::File::create(path)?)), + None => Box::new(std::io::BufWriter::new(std::io::stdout().lock())), + }; + + sylpheed_xexdb::formatter::write_asm( + &mut *out, + &pe_image, + &disasm_info, + &func_analysis, + &xref_result.labels, + &import_map, + &xref_result.xrefs, + &xref_result.data_annotations, + &jt_data_words, + )?; + + if let Some(path) = output { + info!(path, "wrote disassembly"); + } + } + + info!(wall_ms = started.elapsed().as_millis() as u64, "dis complete"); + Ok(()) +} + + +#[cfg(test)] +mod tests { + use super::parse_hex_u32; + + #[test] + fn parse_hex_u32_accepts_0x_prefix() { + assert_eq!(parse_hex_u32("0x824be9a0").unwrap(), 0x824be9a0); + assert_eq!(parse_hex_u32("0X82000000").unwrap(), 0x82000000); + } + + #[test] + fn parse_hex_u32_accepts_bare_hex() { + // No 0x prefix, contains hex letters — treated as hex. + assert_eq!(parse_hex_u32("824be9a0").unwrap(), 0x824be9a0); + } + + #[test] + fn parse_hex_u32_accepts_decimal() { + // All digits, no 0x — treated as decimal. + assert_eq!(parse_hex_u32("1000").unwrap(), 1000); + assert_eq!(parse_hex_u32("0").unwrap(), 0); + } + + #[test] + fn parse_hex_u32_rejects_garbage() { + assert!(parse_hex_u32("not a number").is_err()); + assert!(parse_hex_u32("0xZZZ").is_err()); + } + + #[test] + fn parse_hex_u32_tolerates_whitespace() { + assert_eq!(parse_hex_u32(" 0x82000000 ").unwrap(), 0x82000000); + } +} diff --git a/crates/sylpheed-xexdb/src/db.rs b/crates/sylpheed-xexdb/src/db.rs new file mode 100644 index 00000000..f21bd82b --- /dev/null +++ b/crates/sylpheed-xexdb/src/db.rs @@ -0,0 +1,1957 @@ +//! DuckDB writer for xenia-rs. +//! +//! Layered, streaming writes shared by `extract`, `dis`, and `exec`. +//! Each command's output is a superset of the previous: +//! - `extract --db` -> base tables (metadata, sections, imports) +//! - `dis --db` -> base + disasm tables (functions, labels, instructions, xrefs) +//! - `exec --db` -> base + disasm + opt-in trace tables (exec_trace, import_calls, branch_trace) +//! +//! Bulk inserts use the DuckDB Appender API, which bypasses the SQL layer and +//! writes directly to columnar storage — no transaction batching required. +//! +//! Trace kind values for `branch_trace.kind`: +//! - `"call"` : any branch with LK set (raw & 1 == 1) +//! - `"return"` : bclrx without LK +//! - `"jump"` : bcctrx without LK +//! - `"branch"` : bx/bcx without LK +//! +//! # Schema +//! +//! ## `metadata` +//! Key-value table, one row per XEX header field; values are strings. Beyond +//! the five columns tabulated below it also carries the module/image flag words +//! (raw + decoded), image size and load address, encryption + compression type, +//! disc number/count, per-import-library SDK versions +//! (`import_lib..version_cur`), and one `xex_optional_header.0x…` row for +//! every optional header present, so nothing in the XEX is silently dropped. +//! +//! | key | value format | meaning | +//! |--------------------|------------------|----------------------------------------------------| +//! | `image_base` | `"0xXXXXXXXX"` | Virtual address where the PE image is mapped | +//! | `entry_point` | `"0xXXXXXXXX"` | Absolute VA of the XEX entry point | +//! | `original_pe_name` | string | Original PE filename from XEX optional headers | +//! | `title_id` | `"0xXXXXXXXX"` | Xbox 360 Title ID (identifies the game) | +//! | `media_id` | `"0xXXXXXXXX"` | Disc/media ID (identifies the specific disc build) | +//! +//! ## `sections` +//! One row per PE section (`.text`, `.data`, etc.). +//! - `name` — PE section name +//! - `virtual_address` — RVA relative to `image_base` where the section is mapped in memory +//! - `virtual_size` — Size in memory; may exceed `raw_size` due to BSS zero-fill +//! - `raw_offset` — Byte offset of section data within the XEX/PE file +//! - `raw_size` — Size of section data on disk +//! - `flags` — `IMAGE_SCN_*` characteristics bit field +//! - `is_code` — `true` if `IMAGE_SCN_CNT_CODE` is set +//! +//! ## `imports` +//! One row per import record from the XEX import descriptor table. +//! - `library` — Module name (e.g. `xboxkrnl.exe`, `xam.xex`) +//! - `ordinal` — Numeric ordinal identifying the export within the library +//! - `name` — Resolved human-readable symbol name; `NULL` if not in symbol table +//! - `record_type` — XEX import record type: `0` = function thunk, `1` = variable +//! - `address` — Absolute VA of the import thunk or variable in the binary +//! +//! ## `functions` +//! One row per detected function. Candidates are `bl` targets ∪ `.pdata` +//! `BeginAddress`es ∪ tail-call targets ∪ the entry point. `.pdata` is the +//! linker's own function table and is treated as authoritative: where it +//! covers a function, `end_address` is its declared end rather than a +//! prologue-walk guess. +//! - `address` — Absolute VA of the function entry point (PK) +//! - `name` — Symbol name, or `sub_XXXXXXXX` if unresolved +//! - `end_address` — Absolute VA of last instruction + 4 (exclusive end) +//! - `frame_size` — Stack frame size in bytes (from prologue) +//! - `saved_gprs` — Bitmask of GPRs saved in prologue (bit N set ⇒ rN is saved) +//! - `is_leaf` — `true` if the function has no outgoing calls (no `bl`/`blr`) +//! - `is_saverestore` — `true` if this is a `__savegprlr_*`/`__restgprlr_*` compiler stub +//! - `pdata_validated` — `true` when `.pdata` declares a function at this VA +//! - `pdata_length` — Declared size in bytes; `NULL` when prologue-only +//! - `prolog_length` — Declared prolog size in bytes; `NULL` when prologue-only +//! - `has_eh` — `.pdata` exception-handler bit; function has C++ EH/SEH +//! +//! ## `pdata_entries` +//! The raw `.pdata` `RUNTIME_FUNCTION` table, one row per entry, so a query +//! can distinguish linker ground truth from this crate's inferences. +//! +//! ## `labels` +//! One row per named address; superset of functions. +//! - `address` — Absolute VA (PK) +//! - `name` — Symbol name +//! - `kind` — One of: `function`, `import`, `saverestore`, `local`, `data`, `other` +//! +//! ## `instructions` +//! One row per disassembled instruction. +//! - `address` — Absolute VA (PK) +//! - `raw` — 4-byte big-endian instruction word as integer +//! - `mnemonic` — Base mnemonic (e.g. `stw`, `bl`, `cmpwi`) +//! - `operands` — Operand string from base disassembly +//! - `disasm` — Full base disassembly string (`mnemonic + " " + operands`) +//! - `ext_mnemonic` — Simplified mnemonic (e.g. `mr` for `or rX,rY,rY`); `NULL` if none +//! - `ext_operands` — Operands for the extended form; `NULL` if none +//! - `ext_disasm` — Full extended disassembly string; `NULL` if none +//! - `target_hex` — Resolved absolute branch target for `b`/`bc` (and link/AA variants); `NULL` for indirect or non-branch instructions. SQL views (`v_branch_xrefs`) self-join on this column. +//! - `section` — Name of the PE section containing this instruction +//! - `function` — VA of the enclosing function; `NULL` if not inside a detected function +//! - `label` — Label name at this address; `NULL` if none +//! - `is_data` — `true` when this word is data embedded in a code section (a +//! recovered jump table or index map). The decoded `mnemonic` / +//! `disasm` columns are meaningless on such rows; filter them out +//! (`WHERE NOT is_data`) for any instruction-level analysis. +//! +//! ## `jump_tables` / `jump_table_entries` / `data_in_code` +//! Recovered `switch` dispatches (see [`crate::jumptables`]). `jump_tables` has +//! one row per resolved `bctr`; `jump_table_entries` one row per case value in +//! case order. `data_in_code` lists the byte ranges those tables occupy inside +//! code sections — every word listed there is also flagged `instructions.is_data`. +//! +//! ## `rtti_type_descriptors` / `rtti_locators` / `rtti_base_classes` +//! The MSVC RTTI walk (see [`crate::rtti`]). These are the authoritative source +//! of C++ class identity: `rtti_type_descriptors.demangled_name` is the name +//! the linker wrote, not a heuristic guess. `rtti_locators.vtable_address` +//! binds a class to its vftable (`subobject_offset` separates the primary +//! vftable from the extra ones a multiply-inheriting class emits), and +//! `rtti_base_classes` is the linearised inheritance list with the PMD +//! displacement triple for each base. +//! +//! ## `xrefs` +//! One row per cross-reference edge (call, jump, data access). +//! - `source` — Absolute VA of the instruction making the reference +//! - `target` — Absolute VA of the referenced destination +//! - `kind` — Reference type as the short tag from [`crate::xref::XrefKind::tag`]: +//! `call`, `ind_call` (resolved vtable `bcctrl`), +//! `jt` (recovered `switch` case), `j` (jump), +//! `br` (branch), `read` (data_read), +//! `write` (data_write), `ref` (data_ref). +//! Note: this is a different convention from `branch_trace.kind`, +//! which uses the long names (`call` / `return` / `jump` / `branch`). +//! - `instruction` — Mnemonic of the source instruction; `NULL` if address is not in binary +//! - `source_func` — VA of the function containing `source`; `NULL` if unknown +//! - `source_label` — Label at `source`; `NULL` if none +//! - `target_label` — Label at `target`; `NULL` if none +//! +//! ## `exec_trace` *(opt-in: `--trace-instructions`)* +//! One row per executed instruction. +//! - `address` — Absolute VA of the instruction +//! - `cycle` — Monotonic instruction counter (execution order) +//! - `r3`, `r4`, `lr`, `sp` — Snapshot of key GPRs at time of execution +//! +//! ## `import_calls` *(opt-in: `--trace-imports`)* +//! One row per intercepted kernel/import call. +//! - `address` — VA of the import thunk +//! - `cycle` — Instruction counter at point of interception +//! - `module` — Library name (e.g. `xboxkrnl.exe`) +//! - `ordinal` — Numeric ordinal within the module +//! - `name` — Resolved symbol name +//! - `arg_r3`–`arg_r6` — First four call arguments (PowerPC ABI: r3–r6) +//! - `return_value` — Value in r3 after the call returns +//! +//! ## `branch_trace` *(opt-in: `--trace-branches`)* +//! One row per taken branch. +//! - `cycle` — Instruction counter +//! - `source` — VA of the branch instruction +//! - `target` — VA of the branch destination +//! - `kind` — `call`, `return`, `jump`, or `branch` (see top-level doc) +//! - `lr` — Link register value at time of branch + +use std::collections::HashMap; +use std::path::Path; + +use duckdb::{Connection, params}; + +use crate::func::FuncAnalysis; +use crate::xref::{XrefMap, resolve_source_label}; +use crate::formatter::DisasmInfo; + +const DEFAULT_BATCH_SIZE: u64 = 100_000; + +/// Rows per trace buffer flush. Configurable via `XENIA_DB_BATCH_SIZE` env var (default 100_000). +/// Applies to `exec_trace` and `branch_trace` buffer thresholds. +/// `import_calls` always flushes at 1000 — low volume, not worth scaling. +fn batch_size() -> u64 { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + std::env::var("XENIA_DB_BATCH_SIZE") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&n| n > 0) + .unwrap_or(DEFAULT_BATCH_SIZE) + }) +} + +pub struct ExecTraceEntry { + pub address: u32, + pub cycle: u64, + pub r3: u64, + pub r4: u64, + pub lr: u64, + pub sp: u64, +} + +pub struct ImportCallEntry { + pub address: u32, + pub cycle: u64, + pub module: String, + pub ordinal: u16, + pub name: String, + pub arg_r3: u64, + pub arg_r4: u64, + pub arg_r5: u64, + pub arg_r6: u64, + pub return_value: u64, +} + +pub struct BranchTraceEntry { + pub source: u32, + pub target: u32, + pub cycle: u64, + pub kind: &'static str, + pub lr: u64, +} + +pub struct DbWriter { + conn: Connection, + exec_buffer: Vec, + import_buffer: Vec, + branch_buffer: Vec, + exec_count: u64, + import_count: u64, + branch_count: u64, + trace_instructions: bool, + trace_imports: bool, + trace_branches: bool, +} + +impl DbWriter { + /// Open a fresh database at `path`, removing any existing file first. + pub fn open_fresh(path: &Path) -> anyhow::Result { + if path.exists() { + std::fs::remove_file(path)?; + } + let conn = Connection::open(path)?; + let cap = batch_size() as usize; + Ok(Self { + conn, + exec_buffer: Vec::with_capacity(cap), + import_buffer: Vec::with_capacity(1024), + branch_buffer: Vec::with_capacity(cap), + exec_count: 0, + import_count: 0, + branch_count: 0, + trace_instructions: false, + trace_imports: false, + trace_branches: false, + }) + } + + // ── Base layer (written by extract/dis/exec) ───────────────────────────── + + /// Write metadata, sections, imports tables and their indices. + #[tracing::instrument(skip_all, name = "db.write_base")] + pub fn write_base(&mut self, info: &DisasmInfo) -> anyhow::Result<()> { + self.conn.execute_batch(" + CREATE TABLE metadata ( + key VARCHAR PRIMARY KEY, -- header field name + value VARCHAR NOT NULL -- hex-formatted or plain string value + ); + + CREATE TABLE sections ( + name VARCHAR NOT NULL, -- PE section name (e.g. .text, .rdata) + virtual_address BIGINT NOT NULL, -- RVA relative to image_base + virtual_size BIGINT NOT NULL, -- size in memory; may exceed raw_size (BSS) + raw_offset BIGINT NOT NULL, -- byte offset of section data in the file + raw_size BIGINT NOT NULL, -- size of section data on disk + flags BIGINT NOT NULL, -- IMAGE_SCN_* characteristics bit field + is_code BOOLEAN NOT NULL -- true if IMAGE_SCN_CNT_CODE is set + ); + + CREATE TABLE imports ( + library VARCHAR NOT NULL, -- module name (e.g. xboxkrnl.exe, xam.xex) + ordinal BIGINT NOT NULL, -- ordinal identifying the export within the library + name VARCHAR, -- resolved symbol name; NULL if not in symbol table + record_type BIGINT NOT NULL, -- 0 = function thunk, 1 = variable + address BIGINT NOT NULL -- absolute VA of the thunk or variable + ); + ")?; + + insert_metadata(&self.conn, info)?; + insert_sections(&self.conn, info.sections)?; + insert_imports(&self.conn, info)?; + + self.conn.execute_batch(" + CREATE INDEX idx_imports_library ON imports(library); + CREATE INDEX idx_imports_name ON imports(name); + ")?; + Ok(()) + } + + // ── Disasm layer (written by dis/exec) ─────────────────────────────────── + + /// Phase-3 ingest pass — purely mechanical disasm rows. Creates the + /// `instructions` table (and its indices) and streams every code-section + /// instruction through the iterator + DuckDB sink. Does NOT touch + /// `functions` / `labels` / `xrefs` — that's [`Self::write_analysis_results`]. + /// + /// `func_analysis` and `labels` are still required at this layer because + /// each row carries the rolling-window `function` and `label` columns for + /// downstream queries. + #[tracing::instrument(skip_all, name = "db.ingest_instructions")] + pub fn ingest_instructions( + &mut self, + pe: &[u8], + info: &DisasmInfo, + func_analysis: &FuncAnalysis, + labels: &HashMap, + data_words: &std::collections::BTreeSet, + ) -> anyhow::Result<()> { + self.conn.execute_batch(" + CREATE TABLE instructions ( + address BIGINT PRIMARY KEY, -- absolute VA + raw BIGINT NOT NULL, -- 4-byte big-endian instruction word as integer + mnemonic VARCHAR NOT NULL, -- base mnemonic (e.g. stw, bl, cmpwi) + operands VARCHAR NOT NULL, -- operand string from base disassembly + disasm VARCHAR NOT NULL, -- full base disassembly (mnemonic + operands) + ext_mnemonic VARCHAR, -- simplified mnemonic (e.g. mr); NULL if none + ext_operands VARCHAR, -- operands for the extended form; NULL if none + ext_disasm VARCHAR, -- full extended disassembly string; NULL if none + target_hex BIGINT, -- resolved absolute target for direct branches; NULL for indirect/non-branch + section VARCHAR NOT NULL, -- PE section name containing this instruction + function BIGINT, -- VA of the enclosing function; NULL if unknown + label VARCHAR, -- label at this address; NULL if none + is_data BOOLEAN NOT NULL -- M12: word is data embedded in code (jump table / index map), NOT an instruction + ); + ")?; + + insert_instructions_streaming(&self.conn, pe, info, func_analysis, labels, data_words)?; + + let indices = [ + ("idx_instructions_function", "CREATE INDEX idx_instructions_function ON instructions(function)"), + ("idx_instructions_mnemonic", "CREATE INDEX idx_instructions_mnemonic ON instructions(mnemonic)"), + ("idx_instructions_ext_mnemonic", "CREATE INDEX idx_instructions_ext_mnemonic ON instructions(ext_mnemonic)"), + ("idx_instructions_section", "CREATE INDEX idx_instructions_section ON instructions(section)"), + ("idx_instructions_label", "CREATE INDEX idx_instructions_label ON instructions(label)"), + ("idx_instructions_target_hex", "CREATE INDEX idx_instructions_target_hex ON instructions(target_hex)"), + ("idx_instructions_is_data", "CREATE INDEX idx_instructions_is_data ON instructions(is_data)"), + ]; + for (name, sql) in indices { + tracing::debug!(index = name, "creating instructions index"); + self.conn.execute_batch(sql)?; + } + Ok(()) + } + + /// Phase-3 analyze pass — writes the Rust-pass-derived tables + /// (`functions`, `labels`, `xrefs`) and their indices. Always executes + /// in `--analyze=rust` and `--analyze=both` modes; skipped only when + /// the caller deliberately chooses a Rust-free DB layout. + /// + /// `vtables` is the M3 result; pass an empty slice when the caller has + /// not run the vtable scan (the tables are still created, just empty). + /// `strings` is the M7 result; same convention. `funcptr_arrays` is the + /// M8/M11 result. `typed_ind` is the M5.5 result. `eh_records` is the + /// M9.5 result. `xdbf` is the embedded title package, `None` when the XEX + /// declares no resource. + #[tracing::instrument(skip_all, name = "db.write_analysis_results")] + pub fn write_analysis_results( + &mut self, + pe: &[u8], + info: &DisasmInfo, + func_analysis: &FuncAnalysis, + labels: &HashMap, + xrefs: &XrefMap, + vtables: &[crate::vtables::Vtable], + strings: &[crate::strings::DetectedString], + funcptr_arrays: &[crate::funcptr_arrays::FuncPtrArray], + typed_ind: Option<&crate::ind_dispatch_typed::TypedIndirectResult>, + eh_records: &[crate::eh_scope::EhFuncInfo], + jump_tables: &[crate::jumptables::JumpTable], + rtti: &crate::rtti::RttiResult, + xdbf: Option<&crate::xdbf::Xdbf>, + ) -> anyhow::Result<()> { + self.conn.execute_batch(" + CREATE TABLE functions ( + address BIGINT PRIMARY KEY, -- absolute VA of entry point + name VARCHAR NOT NULL, -- symbol name, or sub_XXXXXXXX if unresolved + end_address BIGINT NOT NULL, -- VA of last instruction + 4 (exclusive end) + frame_size BIGINT NOT NULL, -- stack frame size in bytes (from prologue) + saved_gprs BIGINT NOT NULL, -- bitmask of GPRs saved in prologue (bit N = rN) + is_leaf BOOLEAN NOT NULL, -- true if the function has no outgoing calls + is_saverestore BOOLEAN NOT NULL, -- true if __savegprlr_* / __restgprlr_* stub + pdata_validated BOOLEAN NOT NULL, -- true if .pdata RUNTIME_FUNCTION exists at this VA + pdata_length BIGINT, -- length in bytes per .pdata; NULL if no pdata entry + prolog_length BIGINT, -- prolog size in bytes per .pdata; NULL if no pdata entry + has_eh BOOLEAN NOT NULL -- M9: pdata exception-flag bit set; function has C++ EH/SEH + ); + + CREATE TABLE pdata_entries ( + begin_address BIGINT PRIMARY KEY, -- absolute VA of function start (RUNTIME_FUNCTION.BeginAddress) + end_address BIGINT NOT NULL, -- begin_address + function_length (exclusive) + function_length BIGINT NOT NULL, -- function size in bytes + prolog_length BIGINT NOT NULL, -- prolog size in bytes + flags BIGINT NOT NULL -- raw 2-bit flags (bit 1=32-bit-code, bit 0=exception) + ); + + CREATE TABLE labels ( + address BIGINT PRIMARY KEY, -- absolute VA + name VARCHAR NOT NULL, -- symbol name + kind VARCHAR NOT NULL -- function | import | saverestore | local | data | other + ); + + CREATE TABLE vtables ( + address BIGINT PRIMARY KEY, -- absolute VA of vtable[0] + length BIGINT NOT NULL, -- number of method slots + col_address BIGINT, -- VA of CompleteObjectLocator (NULL when no RTTI) + class_name VARCHAR NOT NULL, -- demangled class name OR ANON_Class_ when stripped + rtti_present BOOLEAN NOT NULL, -- true when COL → TypeDescriptor walk succeeded + base_classes_json VARCHAR -- JSON array of base class names (NULL if none / parse failure) + ); + + CREATE TABLE methods ( + vtable_address BIGINT NOT NULL, -- vtable this slot belongs to + slot BIGINT NOT NULL, -- 0-based slot index + function_address BIGINT NOT NULL, -- VA of the function this slot points at + mangled_name VARCHAR, -- raw label name when mangled (?...) + demangled_name VARCHAR, -- LLVM-style demangled output + PRIMARY KEY (vtable_address, slot) + ); + + CREATE TABLE classes ( + name VARCHAR PRIMARY KEY, -- class name (demangled or ANON_*) + vtable_address BIGINT NOT NULL, -- representative vtable (first detected) + rtti_present BOOLEAN NOT NULL, + base_classes_json VARCHAR -- JSON of base class names (NULL when stripped) + ); + + CREATE TABLE strings ( + address BIGINT PRIMARY KEY, -- absolute VA of first byte + encoding VARCHAR NOT NULL, -- 'ascii' | 'utf16le' | 'shift_jis' | 'utf8' + length BIGINT NOT NULL, -- length in bytes (excluding NUL terminator) + content VARCHAR NOT NULL, -- UTF-8 representation of the string + section VARCHAR NOT NULL -- PE section the string lives in (.rdata / .data) + ); + + CREATE TABLE tls_info ( + raw_data_start BIGINT NOT NULL, -- VA of TLS template start + raw_data_end BIGINT NOT NULL, -- VA one-past-end of TLS template + index_address BIGINT NOT NULL, -- VA of u32 the loader writes the assigned slot index into + callback_array BIGINT NOT NULL, -- VA of zero-terminated callback array (0 if none) + zero_fill_size BIGINT NOT NULL, -- bytes of zero-fill appended after raw template + characteristics BIGINT NOT NULL -- IMAGE_TLS_DIRECTORY characteristics flags + ); + + CREATE TABLE tls_callbacks ( + slot BIGINT PRIMARY KEY, -- 0-based index in the callback array + address BIGINT NOT NULL -- VA of callback function + ); + + CREATE TABLE function_pointer_arrays ( + address BIGINT PRIMARY KEY, -- absolute VA of the array's first slot + length BIGINT NOT NULL, -- number of slots + kind VARCHAR NOT NULL -- 'vtable' (M3) | 'dispatch_table' (M8) | 'static_init' (M11) + ); + + CREATE TABLE function_pointer_array_entries ( + array_address BIGINT NOT NULL, -- FK to function_pointer_arrays.address + slot BIGINT NOT NULL, -- 0-based slot index + function_address BIGINT NOT NULL, -- VA of the function this slot points at + PRIMARY KEY (array_address, slot) + ); + + -- M5.5 — typed indirect-dispatch resolutions. Each row is one + -- bcctrl site that matched the canonical lwz vt, off(this); + -- lwz fn, slot(vt); mtctr; bcctrl pattern. candidate_count > 1 + -- means the analysis could not pick a single class; downstream + -- queries should treat such rows as reachability-only. When + -- `truncated` is set the site had more candidates than the + -- ceiling and none were materialised — the call is virtual and + -- unresolved, and `candidate_count` says how unresolved. + CREATE TABLE indirect_dispatch_sites ( + dispatch_pc BIGINT PRIMARY KEY, + vptr_offset BIGINT NOT NULL, + slot BIGINT NOT NULL, + candidate_count BIGINT NOT NULL, -- candidates that matched, materialised or not + truncated BOOLEAN NOT NULL -- true => candidate_count exceeded the ceiling, + -- so no rows in indirect_dispatch_candidates + ); + + -- M5.5 — one row per (dispatch site × candidate vtable). The + -- ind_call xref edges in the `xrefs` table are derived from + -- this; this view lets you join back to vtable / method info. + CREATE TABLE indirect_dispatch_candidates ( + dispatch_pc BIGINT NOT NULL, + vtable_address BIGINT NOT NULL, + method_address BIGINT NOT NULL, + PRIMARY KEY (dispatch_pc, vtable_address) + ); + + -- M5.5 — every detected `stw rVtable, vptr_off(rThis)` writer + -- found in any function. Useful for diagnosing why a class + -- has (or does not have) coverage in the dispatch resolver. + CREATE TABLE vptr_writes ( + writer_pc BIGINT NOT NULL, + vtable_address BIGINT NOT NULL, + vptr_offset BIGINT NOT NULL, + writer_function BIGINT NOT NULL, + PRIMARY KEY (writer_pc, vtable_address, vptr_offset) + ); + + -- M9.5 — MSVC __CxxFrameHandler scope-table records found by + -- magic-number scan in .rdata. + CREATE TABLE eh_funcinfo ( + address BIGINT PRIMARY KEY, + magic BIGINT NOT NULL, -- 0x19930520/21/22 + max_state BIGINT NOT NULL, + p_unwind_map BIGINT NOT NULL, + n_try_blocks BIGINT NOT NULL, + p_try_block_map BIGINT NOT NULL, + n_ip_map_entries BIGINT NOT NULL, + p_ip_to_state_map BIGINT NOT NULL, + p_es_type_list BIGINT, + eh_flags BIGINT + ); + + CREATE TABLE eh_unwind_map ( + funcinfo_address BIGINT NOT NULL, -- FK to eh_funcinfo.address + state_index BIGINT NOT NULL, + to_state BIGINT NOT NULL, + action_pc BIGINT NOT NULL, + PRIMARY KEY (funcinfo_address, state_index) + ); + + CREATE TABLE eh_try_blocks ( + funcinfo_address BIGINT NOT NULL, -- FK to eh_funcinfo.address + try_index BIGINT NOT NULL, + try_low BIGINT NOT NULL, + try_high BIGINT NOT NULL, + catch_high BIGINT NOT NULL, + n_catches BIGINT NOT NULL, + p_handler_array BIGINT NOT NULL, + PRIMARY KEY (funcinfo_address, try_index) + ); + + -- XDBF/SPA package embedded in the XEX (see `crate::xdbf`). + -- One row per entry of the container's entry table. + CREATE TABLE xdbf_entries ( + namespace BIGINT NOT NULL, -- 1=metadata, 2=image, 3=string table + namespace_name VARCHAR NOT NULL, + id BIGINT NOT NULL, -- fourcc / language / image id per namespace + body_offset BIGINT NOT NULL, -- offset of the body within the image buffer + size BIGINT NOT NULL, + magic VARCHAR, -- leading fourcc of the body, when printable + PRIMARY KEY (namespace, id) + ); + + CREATE TABLE xdbf_achievements ( + id BIGINT PRIMARY KEY, -- 1-based achievement id + name VARCHAR, -- resolved via the default language's string table + unlocked_desc VARCHAR, + locked_desc VARCHAR, + label_id BIGINT NOT NULL, -- string ids, for joining other languages + description_id BIGINT NOT NULL, + unachieved_id BIGINT NOT NULL, + image_id BIGINT NOT NULL, -- FK to xdbf_images.id + gamerscore BIGINT NOT NULL, + flags BIGINT NOT NULL + ); + + -- Every localized string in the package. This is where the title + -- name, mission titles, game-phase labels and leaderboard names live. + CREATE TABLE xdbf_strings ( + language BIGINT NOT NULL, -- XLanguage value + language_name VARCHAR NOT NULL, + string_id BIGINT NOT NULL, + value VARCHAR NOT NULL, + PRIMARY KEY (language, string_id) + ); + + CREATE TABLE xdbf_images ( + id BIGINT PRIMARY KEY, -- image id referenced by achievements + is_title_icon BOOLEAN NOT NULL, -- id 0x8000 — the title's own icon + body_offset BIGINT NOT NULL, -- offset within the image buffer + size BIGINT NOT NULL, + format VARCHAR NOT NULL -- 'png' when the body carries the PNG signature + ); + + CREATE TABLE demangled_names ( + address BIGINT, -- VA the mangled name is associated with; NULL when from a non-address source (e.g. RTTI-only string) + mangled VARCHAR NOT NULL, -- original mangled symbol (e.g. ?Foo@Bar@@QEAAXXZ) + raw_demangled VARCHAR NOT NULL, -- LLVM-style demangled output (or mangled string on parse failure) + namespace_path VARCHAR, -- e.g. xe::apu (NULL = global / parser failure) + class_name VARCHAR, -- e.g. AudioSystem (NULL = free function / parser failure) + method_name VARCHAR, -- e.g. Setup (NULL on parser failure) + params_signature VARCHAR -- contents of the outermost (...) (NULL = not a function) + ); + + -- M12 — recovered `switch` dispatches. One row per `bctr` whose + -- jump table the analyzer could resolve and validate. + CREATE TABLE jump_tables ( + bctr_pc BIGINT PRIMARY KEY, -- VA of the dispatching bctr + function BIGINT, -- VA of the enclosing function + table_address BIGINT NOT NULL, -- VA of the absolute-target table + entry_count BIGINT NOT NULL, -- number of case values (after index-map expansion) + table_slots BIGINT NOT NULL, -- 4-byte slots occupied by the target table itself + index_map_address BIGINT, -- VA of the byte-wide index map (sparse switch only) + index_map_count BIGINT, -- bytes read from the index map + case_bound BIGINT, -- largest valid case index per the cmplwi bound check + kind VARCHAR NOT NULL -- 'direct' (table[idx]) | 'indexed' (table[map[idx]]) + ); + + -- M12 — one row per case value, in case order. `target_address` + -- repeats when several case values share a body. + CREATE TABLE jump_table_entries ( + bctr_pc BIGINT NOT NULL, -- FK to jump_tables.bctr_pc + case_index BIGINT NOT NULL, -- 0-based case value + target_address BIGINT NOT NULL, -- VA of the case body + PRIMARY KEY (bctr_pc, case_index) + ); + + -- M12 — byte ranges inside code sections that hold data, not + -- instructions. Anything listed here is a decoding hazard: linear + -- disassembly of these words produces garbage rows. + CREATE TABLE data_in_code ( + address BIGINT PRIMARY KEY, -- VA of the first byte + length BIGINT NOT NULL, -- byte length + kind VARCHAR NOT NULL -- 'jump_table' | 'jump_index_map' + ); + + -- M13 — MSVC RTTI. `rtti_type_descriptors` is the authoritative + -- source of C++ class identity: the linker wrote these names, they + -- are not inferred. + CREATE TABLE rtti_type_descriptors ( + address BIGINT PRIMARY KEY, -- VA of the TypeDescriptor + mangled_name VARCHAR NOT NULL, -- decorated name, e.g. .?AVSilph@silph@@ + demangled_name VARCHAR NOT NULL -- readable form, e.g. silph::Silph + ); + + -- M13 — RTTICompleteObjectLocator. One per emitted vftable; the + -- `subobject_offset` column separates a class's primary vftable (0) + -- from the extra vftables it emits for secondary base subobjects. + CREATE TABLE rtti_locators ( + address BIGINT PRIMARY KEY, + subobject_offset BIGINT NOT NULL, -- this-offset of the subobject this vftable serves + cd_offset BIGINT NOT NULL, -- constructor-displacement offset + type_descriptor BIGINT NOT NULL, -- FK to rtti_type_descriptors.address + class_hierarchy BIGINT NOT NULL, -- VA of the RTTIClassHierarchyDescriptor + vtable_address BIGINT -- VA of vftable[0]; NULL if no referencing word was found + ); + + -- M13 — linearised RTTIBaseClassArray. Index 0 is the class itself; + -- the remaining rows are its bases in MSVC's depth-first order, + -- each with the PMD displacement triple needed to locate the base + -- subobject inside an instance. + CREATE TABLE rtti_base_classes ( + class_hierarchy BIGINT NOT NULL, -- VA of the deriving class's hierarchy descriptor + base_index BIGINT NOT NULL, -- position in the base-class array + type_descriptor BIGINT NOT NULL, + name VARCHAR NOT NULL, + num_contained_bases BIGINT NOT NULL, + mdisp BIGINT NOT NULL, -- member displacement + pdisp BIGINT NOT NULL, -- vbtable displacement (-1 = non-virtual base) + vdisp BIGINT NOT NULL, -- displacement inside the vbtable + attributes BIGINT NOT NULL, + PRIMARY KEY (class_hierarchy, base_index) + ); + + CREATE TABLE xrefs ( + source BIGINT NOT NULL, -- VA of the referencing instruction + target BIGINT NOT NULL, -- VA of the referenced destination + kind VARCHAR NOT NULL, -- call | ind_call | j | br | read | write | ref + addr_mode VARCHAR, -- M6 sub-classification of how source computes target (NULL for control-flow) + instruction VARCHAR, -- mnemonic of source instruction; NULL if not in binary + source_func BIGINT, -- VA of the function containing source; NULL if unknown + source_label VARCHAR, -- label at source; NULL if none + target_label VARCHAR -- label at target; NULL if none + ); + ")?; + + // Every table above a few thousand rows goes through the DuckDB + // Appender rather than a row-at-a-time `INSERT`. + // + // This is not a micro-optimisation. DuckDB autocommits each statement, + // so a per-row `INSERT` loop pays a transaction + WAL flush per row: + // the 221k rows across `functions` / `labels` / `pdata_entries` alone + // took 20 minutes, and the 1.8M `indirect_dispatch_candidates` rows + // took ~59 more — 81 minutes for one database. Wrapping the lot in a + // single explicit transaction fixes the time but not the cause: DuckDB + // buffers per-statement, so the uncommitted set grew to ~16 GB RSS. + // The Appender writes directly to columnar storage in bounded chunks, + // which is both fast and flat in memory. It bypasses the SQL layer, + // so `ON CONFLICT DO NOTHING` is unavailable and each converted sink + // documents why its key cannot collide (or dedupes explicitly). + insert_functions(&self.conn, func_analysis, labels)?; + insert_pdata_entries(&self.conn, &func_analysis.pdata_entries)?; + insert_labels(&self.conn, labels)?; + insert_demangled_from_labels(&self.conn, labels, info.import_libraries)?; + insert_vtables(&self.conn, vtables, pe, info.image_base)?; + insert_methods_and_classes(&self.conn, vtables, labels)?; + insert_strings(&self.conn, strings)?; + insert_funcptr_arrays(&self.conn, funcptr_arrays)?; + insert_eh_records(&self.conn, eh_records)?; + insert_jump_tables(&self.conn, jump_tables)?; + insert_rtti(&self.conn, rtti)?; + insert_xdbf(&self.conn, xdbf)?; + if let Some(t) = typed_ind { + insert_typed_ind_dispatch(&self.conn, t)?; + } + insert_xrefs_streaming(&self.conn, xrefs, pe, info.image_base, func_analysis, labels)?; + + let indices = [ + ("idx_functions_name", "CREATE INDEX idx_functions_name ON functions(name)"), + ("idx_functions_pdata_validated", "CREATE INDEX idx_functions_pdata_validated ON functions(pdata_validated)"), + ("idx_functions_has_eh", "CREATE INDEX idx_functions_has_eh ON functions(has_eh)"), + ("idx_labels_kind", "CREATE INDEX idx_labels_kind ON labels(kind)"), + ("idx_labels_name", "CREATE INDEX idx_labels_name ON labels(name)"), + ("idx_demangled_address", "CREATE INDEX idx_demangled_address ON demangled_names(address)"), + ("idx_demangled_class", "CREATE INDEX idx_demangled_class ON demangled_names(class_name)"), + ("idx_demangled_method", "CREATE INDEX idx_demangled_method ON demangled_names(method_name)"), + ("idx_methods_function", "CREATE INDEX idx_methods_function ON methods(function_address)"), + ("idx_classes_rtti", "CREATE INDEX idx_classes_rtti ON classes(rtti_present)"), + ("idx_strings_encoding", "CREATE INDEX idx_strings_encoding ON strings(encoding)"), + ("idx_xrefs_addr_mode", "CREATE INDEX idx_xrefs_addr_mode ON xrefs(addr_mode)"), + ("idx_fparrays_kind", "CREATE INDEX idx_fparrays_kind ON function_pointer_arrays(kind)"), + ("idx_fpentries_function", "CREATE INDEX idx_fpentries_function ON function_pointer_array_entries(function_address)"), + ("idx_indcand_method", "CREATE INDEX idx_indcand_method ON indirect_dispatch_candidates(method_address)"), + ("idx_indcand_vtable", "CREATE INDEX idx_indcand_vtable ON indirect_dispatch_candidates(vtable_address)"), + ("idx_indsites_offset_slot", "CREATE INDEX idx_indsites_offset_slot ON indirect_dispatch_sites(vptr_offset, slot)"), + ("idx_vptrw_vtable", "CREATE INDEX idx_vptrw_vtable ON vptr_writes(vtable_address)"), + ("idx_vptrw_offset", "CREATE INDEX idx_vptrw_offset ON vptr_writes(vptr_offset)"), + ("idx_xrefs_target", "CREATE INDEX idx_xrefs_target ON xrefs(target)"), + ("idx_xrefs_source", "CREATE INDEX idx_xrefs_source ON xrefs(source)"), + ("idx_xrefs_source_func", "CREATE INDEX idx_xrefs_source_func ON xrefs(source_func)"), + ("idx_xrefs_kind", "CREATE INDEX idx_xrefs_kind ON xrefs(kind)"), + ("idx_xrefs_instruction", "CREATE INDEX idx_xrefs_instruction ON xrefs(instruction)"), + ("idx_xrefs_target_label", "CREATE INDEX idx_xrefs_target_label ON xrefs(target_label)"), + ]; + for (name, sql) in indices { + tracing::debug!(index = name, "creating analysis index"); + self.conn.execute_batch(sql)?; + } + Ok(()) + } + + /// Back-compat wrapper for callers that want the full pre-Phase-3 + /// "everything in one shot" behaviour. Equivalent to + /// `ingest_instructions` + `write_analysis_results` with no M3 vtables / + /// M7 strings. + #[tracing::instrument(skip_all, name = "db.write_disasm")] + pub fn write_disasm( + &mut self, + pe: &[u8], + info: &DisasmInfo, + func_analysis: &FuncAnalysis, + labels: &HashMap, + xrefs: &XrefMap, + ) -> anyhow::Result<()> { + let empty = std::collections::BTreeSet::new(); + self.ingest_instructions(pe, info, func_analysis, labels, &empty)?; + self.write_analysis_results( + pe, info, func_analysis, labels, xrefs, + &[], &[], &[], None, &[], &[], &crate::rtti::RttiResult::default(), None, + )?; + Ok(()) + } + + /// M10 — write the parsed `.tls` directory + callback array. No-op + /// when `tls` is `None` (binary has no `.tls` section). + #[tracing::instrument(skip_all, name = "db.write_tls")] + pub fn write_tls( + &mut self, + tls: Option<&sylpheed_xex::tls::TlsInfo>, + ) -> anyhow::Result<()> { + let Some(t) = tls else { return Ok(()); }; + self.conn.execute( + "INSERT INTO tls_info (raw_data_start, raw_data_end, index_address, + callback_array, zero_fill_size, characteristics) + VALUES (?, ?, ?, ?, ?, ?)", + params![ + t.raw_data_start as i64, + t.raw_data_end as i64, + t.index_address as i64, + t.callback_array as i64, + t.zero_fill_size as i64, + t.characteristics as i64, + ], + )?; + let mut stmt = self.conn.prepare( + "INSERT INTO tls_callbacks (slot, address) VALUES (?, ?)" + )?; + for (i, cb) in t.callbacks.iter().enumerate() { + stmt.execute(params![i as i64, cb.address as i64])?; + } + metrics::counter!("db.rows", "table" => "tls_callbacks").increment(t.callbacks.len() as u64); + tracing::info!(rows = t.callbacks.len(), table = "tls_callbacks", "tls write complete"); + Ok(()) + } + + /// Phase-3 SQL-views layer — defines additive read-only views over + /// `instructions` (and optionally `xrefs`/`functions`/`labels`). + /// See [`crate::sql_views`] for the SQL definitions. + /// + /// Called when `--analyze=sql` or `--analyze=both` is in effect. + #[tracing::instrument(skip_all, name = "db.create_sql_views")] + pub fn create_sql_views(&mut self) -> anyhow::Result<()> { + for (name, sql) in crate::sql_views::ALL_VIEWS { + tracing::debug!(view = name, "creating SQL view"); + self.conn.execute_batch(sql)?; + } + Ok(()) + } + + /// Cross-check: count branch xrefs found by the SQL view that are absent + /// from the Rust-pass `xrefs` table (and vice versa). Returns + /// `(sql_only, rust_only)` row counts. Both should be zero — the two + /// surfaces produce identical edges by construction. A non-zero count + /// signals drift between the formatter's `mnemonic` column and + /// `xref.rs`'s opcode classification, and is logged as a warning by the + /// caller. + #[tracing::instrument(skip_all, name = "db.cross_check_branch_xrefs")] + pub fn cross_check_branch_xrefs(&self) -> anyhow::Result<(u64, u64)> { + let sql_only: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM v_branch_xrefs vb \ + LEFT JOIN xrefs x \ + ON x.source = vb.source AND x.target = vb.target AND x.kind = vb.kind \ + WHERE x.source IS NULL", + [], |row| row.get(0) + )?; + let rust_only: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM xrefs x \ + LEFT JOIN v_branch_xrefs vb \ + ON vb.source = x.source AND vb.target = x.target AND vb.kind = x.kind \ + WHERE x.kind IN ('call','j','br') AND vb.source IS NULL", + [], |row| row.get(0) + )?; + Ok((sql_only as u64, rust_only as u64)) + } + + // ── Trace layer (written by exec when flags enabled) ───────────────────── + + /// Create the opt-in trace tables. No-op if all flags are false. + pub fn prepare_trace_tables( + &mut self, + trace_instructions: bool, + trace_imports: bool, + trace_branches: bool, + ) -> anyhow::Result<()> { + self.trace_instructions = trace_instructions; + self.trace_imports = trace_imports; + self.trace_branches = trace_branches; + + if trace_instructions { + self.conn.execute_batch(" + CREATE TABLE exec_trace ( + address BIGINT NOT NULL, -- absolute VA of the instruction + cycle BIGINT NOT NULL, -- monotonic instruction counter (execution order) + r3 BIGINT NOT NULL, -- r3 at time of execution + r4 BIGINT NOT NULL, -- r4 at time of execution + lr BIGINT NOT NULL, -- link register + sp BIGINT NOT NULL -- stack pointer + ); + ")?; + } + + if trace_imports { + self.conn.execute_batch(" + CREATE TABLE import_calls ( + address BIGINT NOT NULL, -- VA of the import thunk + cycle BIGINT NOT NULL, -- instruction counter at interception + module VARCHAR NOT NULL, -- library name (e.g. xboxkrnl.exe) + ordinal BIGINT NOT NULL, -- ordinal within the module + name VARCHAR NOT NULL, -- resolved symbol name + arg_r3 BIGINT NOT NULL, -- first argument (r3) + arg_r4 BIGINT NOT NULL, -- second argument (r4) + arg_r5 BIGINT NOT NULL, -- third argument (r5) + arg_r6 BIGINT NOT NULL, -- fourth argument (r6) + return_value BIGINT NOT NULL -- r3 after the call returns + ); + ")?; + } + + if trace_branches { + self.conn.execute_batch(" + CREATE TABLE branch_trace ( + cycle BIGINT NOT NULL, -- instruction counter + source BIGINT NOT NULL, -- VA of the branch instruction + target BIGINT NOT NULL, -- VA of the branch destination + kind VARCHAR NOT NULL, -- call | return | jump | branch + lr BIGINT NOT NULL -- link register at time of branch + ); + ")?; + } + + Ok(()) + } + + pub fn log_instruction(&mut self, entry: ExecTraceEntry) { + if !self.trace_instructions { return; } + self.exec_buffer.push(entry); + if self.exec_buffer.len() as u64 >= batch_size() { + self.flush_exec(); + } + } + + pub fn log_import_call(&mut self, entry: ImportCallEntry) { + if !self.trace_imports { return; } + self.import_buffer.push(entry); + if self.import_buffer.len() >= 1000 { + self.flush_imports(); + } + } + + pub fn log_branch(&mut self, entry: BranchTraceEntry) { + if !self.trace_branches { return; } + self.branch_buffer.push(entry); + if self.branch_buffer.len() as u64 >= batch_size() { + self.flush_branches(); + } + } + + fn flush_exec(&mut self) { + if self.exec_buffer.is_empty() { return; } + let mut appender = self.conn.appender("exec_trace").unwrap(); + for e in &self.exec_buffer { + appender.append_row(params![ + e.address as i64, + e.cycle as i64, + e.r3 as i64, + e.r4 as i64, + e.lr as i64, + e.sp as i64, + ]).ok(); + } + appender.flush().ok(); + self.exec_count += self.exec_buffer.len() as u64; + self.exec_buffer.clear(); + } + + fn flush_imports(&mut self) { + if self.import_buffer.is_empty() { return; } + let mut appender = self.conn.appender("import_calls").unwrap(); + for e in &self.import_buffer { + appender.append_row(params![ + e.address as i64, + e.cycle as i64, + e.module.as_str(), + e.ordinal as i64, + e.name.as_str(), + e.arg_r3 as i64, + e.arg_r4 as i64, + e.arg_r5 as i64, + e.arg_r6 as i64, + e.return_value as i64, + ]).ok(); + } + appender.flush().ok(); + self.import_count += self.import_buffer.len() as u64; + self.import_buffer.clear(); + } + + fn flush_branches(&mut self) { + if self.branch_buffer.is_empty() { return; } + let mut appender = self.conn.appender("branch_trace").unwrap(); + for e in &self.branch_buffer { + appender.append_row(params![ + e.cycle as i64, + e.source as i64, + e.target as i64, + e.kind, + e.lr as i64, + ]).ok(); + } + appender.flush().ok(); + self.branch_count += self.branch_buffer.len() as u64; + self.branch_buffer.clear(); + } + + /// Flush remaining trace buffers and create their indices. + #[tracing::instrument(skip_all, name = "db.finalize_traces")] + pub fn finalize_traces(&mut self) -> anyhow::Result<()> { + self.flush_exec(); + self.flush_imports(); + self.flush_branches(); + + if self.trace_instructions { + tracing::debug!("creating idx_exec_trace_address"); + self.conn.execute_batch("CREATE INDEX idx_exec_trace_address ON exec_trace(address);")?; + tracing::debug!("creating idx_exec_trace_cycle"); + self.conn.execute_batch("CREATE INDEX idx_exec_trace_cycle ON exec_trace(cycle);")?; + } + if self.trace_imports { + tracing::debug!("creating idx_import_calls_name"); + self.conn.execute_batch("CREATE INDEX idx_import_calls_name ON import_calls(name);")?; + tracing::debug!("creating idx_import_calls_cycle"); + self.conn.execute_batch("CREATE INDEX idx_import_calls_cycle ON import_calls(cycle);")?; + } + if self.trace_branches { + tracing::debug!("creating idx_branch_trace_source"); + self.conn.execute_batch("CREATE INDEX idx_branch_trace_source ON branch_trace(source);")?; + tracing::debug!("creating idx_branch_trace_target"); + self.conn.execute_batch("CREATE INDEX idx_branch_trace_target ON branch_trace(target);")?; + tracing::debug!("creating idx_branch_trace_kind"); + self.conn.execute_batch("CREATE INDEX idx_branch_trace_kind ON branch_trace(kind);")?; + tracing::debug!("creating idx_branch_trace_cycle"); + self.conn.execute_batch("CREATE INDEX idx_branch_trace_cycle ON branch_trace(cycle);")?; + } + + metrics::counter!("db.rows", "table" => "exec_trace").increment(self.exec_count); + metrics::counter!("db.rows", "table" => "import_calls").increment(self.import_count); + metrics::counter!("db.rows", "table" => "branch_trace").increment(self.branch_count); + tracing::info!( + instructions = self.exec_count, + imports = self.import_count, + branches = self.branch_count, + "trace totals" + ); + Ok(()) + } +} + +/// Backwards-compatible wrapper that writes the full base + disasm layers. +pub fn write_db( + path: &Path, + pe: &[u8], + info: &DisasmInfo, + func_analysis: &FuncAnalysis, + labels: &HashMap, + _import_map: &HashMap, + xrefs: &XrefMap, +) -> anyhow::Result<()> { + let mut w = DbWriter::open_fresh(path)?; + w.write_base(info)?; + w.write_disasm(pe, info, func_analysis, labels, xrefs)?; + Ok(()) +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +fn insert_metadata(conn: &Connection, info: &DisasmInfo) -> anyhow::Result<()> { + let mut stmt = conn.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")?; + let mut put = |k: &str, v: String| -> anyhow::Result<()> { + stmt.execute(params![k, v])?; + Ok(()) + }; + + put("image_base", format!("0x{:08X}", info.image_base))?; + put("entry_point", format!("0x{:08X}", info.entry_point))?; + if let Some(name) = info.original_pe_name { + put("original_pe_name", name.to_string())?; + } + if let Some(title_id) = info.title_id { + put("title_id", format!("0x{:08X}", title_id))?; + } + if let Some(media_id) = info.media_id { + put("media_id", format!("0x{:08X}", media_id))?; + } + + // Section geometry is useful enough on its own to be worth denormalising: + // a query that just wants "how big is the code" should not have to join. + let code_bytes: u64 = info.sections.iter().filter(|s| s.is_code()) + .map(|s| s.virtual_size as u64).sum(); + put("section_count", info.sections.len().to_string())?; + put("code_bytes", code_bytes.to_string())?; + + let Some(header) = info.xex_header else { return Ok(()) }; + + put("xex_module_flags", format!("0x{:08X}", header.module_flags))?; + put("xex_module_flags_decoded", decode_module_flags(header.module_flags))?; + put("xex_header_count", header.header_count.to_string())?; + + if let Some(sec) = &header.security_info { + put("image_size", format!("0x{:08X}", sec.image_size))?; + put("load_address", format!("0x{:08X}", sec.load_address))?; + put("image_flags", format!("0x{:08X}", sec.image_flags))?; + put("page_descriptor_count", sec.page_descriptors.len().to_string())?; + if sec.export_table_address != 0 { + put("export_table_address", format!("0x{:08X}", sec.export_table_address))?; + } + } + + if let Some(ff) = &header.file_format_info { + put("encryption_type", match ff.encryption_type { + 0 => "none".into(), + 1 => "normal (AES-128-CBC)".into(), + n => format!("unknown ({n})"), + })?; + put("compression_type", match ff.compression_type { + 0 => "none".into(), + 1 => "basic (raw + zero-fill blocks)".into(), + 2 => "normal (LZX)".into(), + n => format!("unknown ({n})"), + })?; + if ff.compression_type == 1 { + put("basic_block_count", ff.basic_blocks.len().to_string())?; + } + if ff.compression_type == 2 { + put("lzx_window_size", format!("0x{:08X}", ff.normal_window_size))?; + } + } + + if let Some(exec) = &header.execution_info { + put("disc_number", exec.disc_number.to_string())?; + put("disc_count", exec.disc_count.to_string())?; + } + + // Import libraries carry the SDK version each module was linked against — + // the single most useful "what toolchain built this" signal in the header. + put("import_library_count", header.import_libraries.len().to_string())?; + for lib in &header.import_libraries { + put(&format!("import_lib.{}.version_min", lib.name), format_xex_version(lib.version_min))?; + put(&format!("import_lib.{}.version_cur", lib.name), format_xex_version(lib.version_cur))?; + put(&format!("import_lib.{}.imports", lib.name), lib.imports.len().to_string())?; + } + + // Any optional header we do not model explicitly is still recorded by key, + // so nothing in the XEX is silently dropped. + for oh in &header.optional_headers { + put(&format!("xex_optional_header.0x{:08X}", oh.key), format!("0x{:08X}", oh.value))?; + } + + Ok(()) +} + +/// Render a XEX version word (`major.minor.build.qfe`, 4/4/16/8 bits). +fn format_xex_version(v: u32) -> String { + let major = (v >> 28) & 0xF; + let minor = (v >> 24) & 0xF; + let build = (v >> 8) & 0xFFFF; + let qfe = v & 0xFF; + format!("{major}.{minor}.{build}.{qfe}") +} + +/// Human-readable form of the XEX2 module flags bit field. +fn decode_module_flags(flags: u32) -> String { + const NAMES: &[(u32, &str)] = &[ + (0x0000_0001, "title_module"), + (0x0000_0002, "exports_to_title"), + (0x0000_0004, "system_debugger"), + (0x0000_0008, "dll_module"), + (0x0000_0010, "module_patch"), + (0x0000_0020, "patch_full"), + (0x0000_0040, "patch_delta"), + (0x0000_0080, "user_mode"), + ]; + let set: Vec<&str> = NAMES.iter().filter(|&&(b, _)| flags & b != 0).map(|&(_, n)| n).collect(); + if set.is_empty() { "none".to_string() } else { set.join("|") } +} + +fn insert_sections(conn: &Connection, sections: &[sylpheed_xex::pe::PeSection]) -> anyhow::Result<()> { + let mut stmt = conn.prepare( + "INSERT INTO sections (name, virtual_address, virtual_size, raw_offset, raw_size, flags, is_code) + VALUES (?, ?, ?, ?, ?, ?, ?)" + )?; + for s in sections { + stmt.execute(params![ + s.name, + s.virtual_address as i64, + s.virtual_size as i64, + s.raw_offset as i64, + s.raw_size as i64, + s.flags as i64, + s.is_code(), + ])?; + } + Ok(()) +} + +fn insert_imports(conn: &Connection, info: &DisasmInfo) -> anyhow::Result<()> { + let mut stmt = conn.prepare( + "INSERT INTO imports (library, ordinal, name, record_type, address) + VALUES (?, ?, ?, ?, ?)" + )?; + for lib in info.import_libraries { + for imp in &lib.imports { + let resolved = crate::resolve_ordinal(&lib.name, imp.ordinal); + stmt.execute(params![ + lib.name, + imp.ordinal as i64, + resolved, + imp.record_type as i64, + imp.address as i64, + ])?; + } + } + Ok(()) +} + +fn insert_functions( + conn: &Connection, + func_analysis: &FuncAnalysis, + labels: &HashMap, +) -> anyhow::Result<()> { + let mut appender = conn.appender("functions")?; + for (&addr, fi) in &func_analysis.functions { + let name = labels.get(&addr) + .cloned() + .unwrap_or_else(|| format!("sub_{addr:08X}")); + appender.append_row(params![ + addr as i64, + name, + fi.end as i64, + fi.frame_size as i64, + fi.saved_gprs as i64, + fi.is_leaf, + fi.is_saverestore, + fi.pdata_validated, + fi.pdata_length.map(|n| n as i64), + fi.pdata_prolog_length.map(|n| n as i64), + fi.has_eh, + ])?; + } + appender.flush()?; + Ok(()) +} + +fn insert_vtables( + conn: &Connection, + vtables: &[crate::vtables::Vtable], + _pe: &[u8], + _image_base: u32, +) -> anyhow::Result<()> { + if vtables.is_empty() { return Ok(()); } + let mut stmt = conn.prepare( + "INSERT INTO vtables + (address, length, col_address, class_name, rtti_present, base_classes_json) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING" + )?; + let mut count = 0u64; + for v in vtables { + stmt.execute(params![ + v.address as i64, + v.length as i64, + v.col_address.map(|a| a as i64), + v.class_name.as_str(), + v.rtti_present, + v.base_classes_json.as_deref(), + ])?; + count += 1; + } + metrics::counter!("db.rows", "table" => "vtables").increment(count); + tracing::info!(rows = count, table = "vtables", "bulk insert complete"); + Ok(()) +} + +fn insert_methods_and_classes( + conn: &Connection, + vtables: &[crate::vtables::Vtable], + labels: &HashMap, +) -> anyhow::Result<()> { + if vtables.is_empty() { return Ok(()); } + + // methods rows — keyed by (vtable_address, slot), which `methods_table` + // emits at most once each. + let methods = crate::vtables::methods_table(vtables, labels); + if !methods.is_empty() { + let mut appender = conn.appender("methods")?; + for (vt_addr, slot, fn_addr, mangled, demangled) in &methods { + appender.append_row(params![ + *vt_addr as i64, + *slot as i64, + *fn_addr as i64, + mangled.as_deref(), + demangled.as_deref(), + ])?; + } + appender.flush()?; + metrics::counter!("db.rows", "table" => "methods").increment(methods.len() as u64); + tracing::info!(rows = methods.len(), table = "methods", "bulk insert complete"); + } + + // classes rows (deduped by class_name, first-detected wins) + let classes = crate::vtables::classes_table(vtables); + if !classes.is_empty() { + let mut stmt = conn.prepare( + "INSERT INTO classes + (name, vtable_address, rtti_present, base_classes_json) + VALUES (?, ?, ?, ?) + ON CONFLICT DO NOTHING" + )?; + for (name, vt_addr, rtti, bases) in &classes { + stmt.execute(params![ + name.as_str(), + *vt_addr as i64, + *rtti, + bases.as_deref(), + ])?; + } + metrics::counter!("db.rows", "table" => "classes").increment(classes.len() as u64); + tracing::info!(rows = classes.len(), table = "classes", "bulk insert complete"); + } + + Ok(()) +} + +fn insert_strings( + conn: &Connection, + strings: &[crate::strings::DetectedString], +) -> anyhow::Result<()> { + if strings.is_empty() { return Ok(()); } + // The ascii / shift_jis / utf8 scans all run over the same bytes, so two + // of them can report a string at the same address. `address` is the + // primary key and the Appender cannot absorb that the way + // `ON CONFLICT DO NOTHING` did — keep the first detection per address. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut appender = conn.appender("strings")?; + let mut count = 0u64; + for s in strings { + if !seen.insert(s.address) { continue; } + appender.append_row(params![ + s.address as i64, + s.encoding, + s.length as i64, + s.content.as_str(), + s.section.as_str(), + ])?; + count += 1; + } + appender.flush()?; + metrics::counter!("db.rows", "table" => "strings").increment(count); + tracing::info!(rows = count, table = "strings", "bulk insert complete"); + Ok(()) +} + +fn insert_eh_records( + conn: &Connection, + records: &[crate::eh_scope::EhFuncInfo], +) -> anyhow::Result<()> { + if records.is_empty() { return Ok(()); } + let mut stmt_fi = conn.prepare( + "INSERT INTO eh_funcinfo + (address, magic, max_state, p_unwind_map, n_try_blocks, + p_try_block_map, n_ip_map_entries, p_ip_to_state_map, + p_es_type_list, eh_flags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING" + )?; + let mut n_fi = 0u64; + let mut kept: Vec<&crate::eh_scope::EhFuncInfo> = Vec::with_capacity(records.len()); + for r in records { + let inserted = stmt_fi.execute(params![ + r.address as i64, r.magic as i64, r.max_state as i64, + r.p_unwind_map as i64, r.n_try_blocks as i64, + r.p_try_block_map as i64, r.n_ip_map_entries as i64, + r.p_ip_to_state_map as i64, + r.p_es_type_list.map(|p| p as i64), + r.eh_flags.map(|f| f as i64), + ])?; + if inserted > 0 { + n_fi += 1; + kept.push(r); + } + } + drop(stmt_fi); + + // The child rows are keyed by (funcinfo_address, index) and each parent + // survived the ON CONFLICT above, so these cannot collide. + let mut n_unwind = 0u64; + { + let mut appender = conn.appender("eh_unwind_map")?; + for r in &kept { + for (i, e) in r.unwind_map.iter().enumerate() { + appender.append_row(params![ + r.address as i64, i as i64, e.to_state as i64, e.action_pc as i64, + ])?; + n_unwind += 1; + } + } + appender.flush()?; + } + + let mut n_try = 0u64; + { + let mut appender = conn.appender("eh_try_blocks")?; + for r in &kept { + for (i, t) in r.try_blocks.iter().enumerate() { + appender.append_row(params![ + r.address as i64, i as i64, + t.try_low as i64, t.try_high as i64, t.catch_high as i64, + t.n_catches as i64, t.p_handler_array as i64, + ])?; + n_try += 1; + } + } + appender.flush()?; + } + + metrics::counter!("db.rows", "table" => "eh_funcinfo").increment(n_fi); + metrics::counter!("db.rows", "table" => "eh_unwind_map").increment(n_unwind); + metrics::counter!("db.rows", "table" => "eh_try_blocks").increment(n_try); + tracing::info!( + funcinfo = n_fi, unwind = n_unwind, try_blocks = n_try, + "EH scope-table insert complete" + ); + Ok(()) +} + +fn insert_typed_ind_dispatch( + conn: &Connection, + t: &crate::ind_dispatch_typed::TypedIndirectResult, +) -> anyhow::Result<()> { + if !t.dispatches.is_empty() { + let mut stmt_site = conn.prepare( + "INSERT INTO indirect_dispatch_sites + (dispatch_pc, vptr_offset, slot, candidate_count, truncated) + VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + )?; + let mut n_sites = 0u64; + for d in &t.dispatches { + stmt_site.execute(params![ + d.dispatch_pc as i64, + d.vptr_offset as i64, + d.slot as i64, + d.total_candidates as i64, + d.truncated, + ])?; + n_sites += 1; + } + drop(stmt_site); + + // `indirect_dispatch_candidates` used to be by far the largest table + // this writer produced — 1.8M rows before unresolved sites stopped + // materialising their cross product (see + // `ind_dispatch_typed::analyze`). It still goes through the Appender: + // the ceiling is configurable and a caller that raises it gets the + // volume back. + // + // The Appender bypasses the SQL layer, which means `ON CONFLICT DO + // NOTHING` is not available to absorb duplicates and a repeated + // `(dispatch_pc, vtable_address)` would violate the primary key at + // flush. Dedupe up front instead. + let mut appender = conn.appender("indirect_dispatch_candidates")?; + let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new(); + let mut n_cand = 0u64; + for d in &t.dispatches { + for (vt, m) in d.candidate_vtables.iter().zip(d.method_pcs.iter()) { + if seen.insert((d.dispatch_pc, *vt)) { + appender.append_row(params![ + d.dispatch_pc as i64, *vt as i64, *m as i64, + ])?; + n_cand += 1; + } + } + } + appender.flush()?; + + metrics::counter!("db.rows", "table" => "indirect_dispatch_sites").increment(n_sites); + metrics::counter!("db.rows", "table" => "indirect_dispatch_candidates").increment(n_cand); + tracing::info!(sites = n_sites, candidates = n_cand, "typed indirect-dispatch insert complete"); + } + if !t.vptr_writes.is_empty() { + let mut stmt = conn.prepare( + "INSERT INTO vptr_writes + (writer_pc, vtable_address, vptr_offset, writer_function) + VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING" + )?; + let mut n = 0u64; + for w in &t.vptr_writes { + stmt.execute(params![ + w.writer_pc as i64, + w.vtable_addr as i64, + w.vptr_offset as i64, + w.writer_function as i64, + ])?; + n += 1; + } + metrics::counter!("db.rows", "table" => "vptr_writes").increment(n); + tracing::info!(rows = n, "vptr_writes insert complete"); + } + Ok(()) +} + +/// Write the XDBF package tables. Achievement names are resolved through the +/// package's own default-language string table (`XSTC`), falling back to +/// English and then to whatever table exists, so the text columns are populated +/// even for a title that ships no `XSTC`. +fn insert_xdbf(conn: &Connection, xdbf: Option<&crate::xdbf::Xdbf>) -> anyhow::Result<()> { + let Some(x) = xdbf else { return Ok(()) }; + + let mut stmt = conn.prepare( + "INSERT INTO xdbf_entries (namespace, namespace_name, id, body_offset, size, magic) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + )?; + for e in &x.entries { + let ns_name = match e.namespace { + 1 => "metadata", + 2 => "image", + 3 => "string_table", + _ => "unknown", + }; + stmt.execute(params![ + e.namespace as i64, ns_name, e.id as i64, + e.offset as i64, e.size as i64, e.magic.as_deref(), + ])?; + } + drop(stmt); + + let mut stmt = conn.prepare( + "INSERT INTO xdbf_strings (language, language_name, string_id, value) + VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING" + )?; + let mut n_strings = 0u64; + for t in &x.string_tables { + let name = crate::xdbf::language_name(t.language); + for (id, v) in &t.strings { + stmt.execute(params![t.language as i64, name, *id as i64, v.as_str()])?; + n_strings += 1; + } + } + drop(stmt); + + // Pick the table used to resolve achievement text. + let preferred = x.default_language.unwrap_or(1); + let lookup = x + .string_tables + .iter() + .find(|t| t.language == preferred) + .or_else(|| x.string_tables.iter().find(|t| t.language == 1)) + .or_else(|| x.string_tables.first()); + let text = |id: u16| -> Option { + lookup? + .strings + .iter() + .find(|(sid, _)| *sid == id) + .map(|(_, s)| s.clone()) + }; + + let mut stmt = conn.prepare( + "INSERT INTO xdbf_achievements + (id, name, unlocked_desc, locked_desc, label_id, description_id, + unachieved_id, image_id, gamerscore, flags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + )?; + for a in &x.achievements { + stmt.execute(params![ + a.id as i64, + text(a.label_id), + text(a.description_id), + text(a.unachieved_id), + a.label_id as i64, a.description_id as i64, a.unachieved_id as i64, + a.image_id as i64, a.gamerscore as i64, a.flags as i64, + ])?; + } + drop(stmt); + + let mut stmt = conn.prepare( + "INSERT INTO xdbf_images (id, is_title_icon, body_offset, size, format) + VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + )?; + for i in &x.images { + stmt.execute(params![ + i.id as i64, + i.id == crate::xdbf::ID_TITLE, + i.offset as i64, + i.size as i64, + i.format, + ])?; + } + drop(stmt); + + let mut meta = conn.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")?; + meta.execute(params!["xdbf.entry_count", x.entries.len().to_string()])?; + if let Some(l) = x.default_language { + meta.execute(params!["xdbf.default_language", crate::xdbf::language_name(l)])?; + } + if let Some(t) = x.title { + meta.execute(params!["xdbf.title_id", format!("{:#010X}", t.title_id)])?; + meta.execute(params![ + "xdbf.title_version", + format!("{}.{}.{}.{}", t.major, t.minor, t.build, t.revision) + ])?; + } + // The title's own name lives at the well-known string id 0x8000, in the + // package's default language. + if let Some(name) = text(crate::xdbf::ID_TITLE as u16) { + meta.execute(params!["xdbf.title_name", name])?; + } + + metrics::counter!("db.rows", "table" => "xdbf_strings").increment(n_strings); + tracing::info!( + entries = x.entries.len(), + achievements = x.achievements.len(), + strings = n_strings, + images = x.images.len(), + "XDBF tables written", + ); + Ok(()) +} + +fn insert_funcptr_arrays( + conn: &Connection, + arrays: &[crate::funcptr_arrays::FuncPtrArray], +) -> anyhow::Result<()> { + if arrays.is_empty() { return Ok(()); } + // Parents first (few, and `ON CONFLICT` decides which survive), then the + // entry rows in one Appender pass — only entries of a parent that was + // actually inserted. + let mut stmt_arr = conn.prepare( + "INSERT INTO function_pointer_arrays (address, length, kind) VALUES (?, ?, ?) + ON CONFLICT DO NOTHING" + )?; + let mut n_arr = 0u64; + let mut kept: Vec<&crate::funcptr_arrays::FuncPtrArray> = Vec::with_capacity(arrays.len()); + for a in arrays { + let inserted = stmt_arr.execute(params![ + a.address as i64, a.length as i64, a.kind, + ])?; + if inserted > 0 { + n_arr += 1; + kept.push(a); + } + } + drop(stmt_arr); + + let mut appender = conn.appender("function_pointer_array_entries")?; + let mut n_ent = 0u64; + for a in kept { + for (i, &fn_va) in a.entries.iter().enumerate() { + appender.append_row(params![a.address as i64, i as i64, fn_va as i64])?; + n_ent += 1; + } + } + appender.flush()?; + + metrics::counter!("db.rows", "table" => "function_pointer_arrays").increment(n_arr); + metrics::counter!("db.rows", "table" => "function_pointer_array_entries").increment(n_ent); + tracing::info!(arrays = n_arr, entries = n_ent, "function-pointer arrays insert complete"); + Ok(()) +} + +fn insert_demangled_from_labels( + conn: &Connection, + labels: &HashMap, + import_libraries: &[sylpheed_xex::header::ImportLibrary], +) -> anyhow::Result<()> { + let mut stmt = conn.prepare( + "INSERT INTO demangled_names + (address, mangled, raw_demangled, namespace_path, class_name, + method_name, params_signature) + VALUES (?, ?, ?, ?, ?, ?, ?)" + )?; + + let mut count = 0u64; + + for (&addr, name) in labels { + // The label table holds raw symbol names (`?...@...`). Imports come + // wrapped as `__imp__`; strip the `__imp__` prefix to + // recover any mangled inner name (rare for kernel imports but + // defensive). For now, skip imports entirely — they're handled below + // via `import_libraries`. + if name.starts_with("__imp_") { + continue; + } + if let Some(d) = crate::demangle::demangle(name) { + stmt.execute(params![ + addr as i64, + d.mangled, + d.raw_demangled, + d.namespace_path, + d.class_name, + d.method_name, + d.params_signature, + ])?; + count += 1; + } + } + + // Defensive: also demangle any import name that happens to be mangled. + for lib in import_libraries { + for imp in &lib.imports { + let resolved = crate::resolve_ordinal(&lib.name, imp.ordinal); + if let Some(name) = resolved + && let Some(d) = crate::demangle::demangle(name) + { + stmt.execute(params![ + imp.address as i64, + d.mangled, + d.raw_demangled, + d.namespace_path, + d.class_name, + d.method_name, + d.params_signature, + ])?; + count += 1; + } + } + } + + metrics::counter!("db.rows", "table" => "demangled_names").increment(count); + tracing::info!(rows = count, table = "demangled_names", "demangler complete"); + Ok(()) +} + +fn insert_pdata_entries( + conn: &Connection, + entries: &[sylpheed_xex::pdata::PdataEntry], +) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } + // `parse_pdata` already guarantees strictly ascending, unique + // `begin_address` values, so the primary key cannot collide. + let mut appender = conn.appender("pdata_entries")?; + for e in entries { + appender.append_row(params![ + e.begin_address as i64, + e.end_address() as i64, + e.function_length as i64, + e.prolog_length as i64, + e.flags as i64, + ])?; + } + appender.flush()?; + Ok(()) +} + +fn insert_labels( + conn: &Connection, + labels: &HashMap, +) -> anyhow::Result<()> { + // `labels` is keyed by address, so it cannot contain a duplicate primary + // key — the Appender is safe here without a dedupe pass. + let mut appender = conn.appender("labels")?; + for (&addr, name) in labels { + let kind = if name.starts_with("sub_") || name == "entry_point" { + "function" + } else if name.starts_with("__imp_") { + "import" + } else if name.starts_with("__savegprlr_") || name.starts_with("__restgprlr_") { + "saverestore" + } else if name.starts_with("loc_") { + "local" + } else if name.starts_with("dat_") { + "data" + } else { + "other" + }; + appender.append_row(params![addr as i64, name, kind])?; + } + appender.flush()?; + Ok(()) +} + +fn insert_instructions_streaming( + conn: &Connection, + pe: &[u8], + info: &DisasmInfo, + func_analysis: &FuncAnalysis, + labels: &HashMap, + data_words: &std::collections::BTreeSet, +) -> anyhow::Result<()> { + let mut appender = conn.appender("instructions")?; + let mut total: u64 = 0; + + for section in info.sections { + if !section.is_code() { continue; } + let va_start = info.image_base + section.virtual_address; + let va_end = info.image_base + section.virtual_address + section.virtual_size; + let items = crate::disasm::enrich_section( + pe, info.image_base, §ion.name, va_start, va_end, func_analysis, labels, + data_words, + ); + total += crate::sinks::duckdb::append_instructions(&mut appender, items)?; + } + + appender.flush()?; + metrics::counter!("db.rows", "table" => "instructions").increment(total); + tracing::info!(rows = total, table = "instructions", "bulk insert complete"); + Ok(()) +} + +/// Write the M12 jump-table tables plus the data-in-code extent list. +fn insert_jump_tables( + conn: &Connection, + tables: &[crate::jumptables::JumpTable], +) -> anyhow::Result<()> { + let mut t = conn.prepare( + "INSERT INTO jump_tables + (bctr_pc, function, table_address, entry_count, table_slots, + index_map_address, index_map_count, case_bound, kind) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + )?; + for jt in tables { + t.execute(params![ + jt.bctr_pc as i64, + jt.function.map(|f| f as i64), + jt.table_address as i64, + jt.entry_count as i64, + jt.table_slots as i64, + jt.index_map_address.map(|a| a as i64), + jt.index_map_count.map(|n| n as i64), + jt.bound.map(|n| n as i64), + jt.kind, + ])?; + } + drop(t); + + // Keyed by (bctr_pc, case_index); each bctr_pc yields one table. + let mut e = conn.appender("jump_table_entries")?; + for jt in tables { + for (i, &target) in jt.targets.iter().enumerate() { + e.append_row(params![jt.bctr_pc as i64, i as i64, target as i64])?; + } + } + e.flush()?; + drop(e); + + // `data_in_code` is keyed by address, so the per-table extents are merged + // first — two switches in the same function can share one table. + let mut kinds: HashMap = HashMap::new(); + for jt in tables { + kinds.insert(jt.table_address, "jump_table"); + if let Some(a) = jt.index_map_address { + kinds.insert(a, "jump_index_map"); + } + } + let mut d = conn.prepare("INSERT INTO data_in_code (address, length, kind) VALUES (?, ?, ?)")?; + for (addr, len) in crate::jumptables::data_regions(tables) { + let kind = kinds.get(&addr).copied().unwrap_or("jump_table"); + d.execute(params![addr as i64, len as i64, kind])?; + } + + metrics::counter!("db.rows", "table" => "jump_tables").increment(tables.len() as u64); + tracing::info!( + rows = tables.len(), + entries = tables.iter().map(|t| t.targets.len()).sum::(), + table = "jump_tables", + "insert complete", + ); + Ok(()) +} + +/// Write the M13 RTTI tables. +fn insert_rtti(conn: &Connection, rtti: &crate::rtti::RttiResult) -> anyhow::Result<()> { + let mut td = conn.prepare( + "INSERT INTO rtti_type_descriptors (address, mangled_name, demangled_name) + VALUES (?, ?, ?)" + )?; + // RTTI descriptors are the only mangled names a stripped retail binary + // still carries, so they are also the only thing `demangled_names` can be + // populated from — without this it stays empty on every shipped title. + let mut dn = conn.prepare( + "INSERT INTO demangled_names + (address, mangled, raw_demangled, namespace_path, class_name, + method_name, params_signature) + VALUES (?, ?, ?, ?, ?, ?, ?)" + )?; + for t in &rtti.type_descriptors { + td.execute(params![t.address as i64, t.mangled_name, t.demangled_name])?; + let (ns, cls) = match t.demangled_name.rfind("::") { + Some(i) => (Some(&t.demangled_name[..i]), &t.demangled_name[i + 2..]), + None => (None, t.demangled_name.as_str()), + }; + dn.execute(params![ + t.address as i64, + t.mangled_name, + t.demangled_name, + ns, + cls, + Option::<&str>::None, + Option::<&str>::None, + ])?; + } + + let mut col = conn.prepare( + "INSERT INTO rtti_locators + (address, subobject_offset, cd_offset, type_descriptor, class_hierarchy, vtable_address) + VALUES (?, ?, ?, ?, ?, ?)" + )?; + for c in &rtti.locators { + col.execute(params![ + c.address as i64, + c.offset as i64, + c.cd_offset as i64, + c.type_descriptor as i64, + c.class_hierarchy as i64, + c.vtable_address.map(|v| v as i64), + ])?; + } + + let mut bc = conn.prepare( + "INSERT INTO rtti_base_classes + (class_hierarchy, base_index, type_descriptor, name, num_contained_bases, + mdisp, pdisp, vdisp, attributes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + )?; + for b in &rtti.base_classes { + bc.execute(params![ + b.class_hierarchy as i64, + b.index as i64, + b.type_descriptor as i64, + b.name, + b.num_contained_bases as i64, + b.mdisp as i64, + b.pdisp as i64, + b.vdisp as i64, + b.attributes as i64, + ])?; + } + + tracing::info!( + type_descriptors = rtti.type_descriptors.len(), + locators = rtti.locators.len(), + base_classes = rtti.base_classes.len(), + "RTTI tables written", + ); + Ok(()) +} + +fn insert_xrefs_streaming( + conn: &Connection, + xrefs: &XrefMap, + pe: &[u8], + image_base: u32, + func_analysis: &FuncAnalysis, + labels: &HashMap, +) -> anyhow::Result<()> { + let mut appender = conn.appender("xrefs")?; + let mut count: u64 = 0; + + for (&target, refs) in xrefs { + let target_label = labels.get(&target).map(|s| s.as_str()); + + for xref in refs { + let kind = xref.kind.db_tag(); + + let instruction: Option = { + let off = xref.source.wrapping_sub(image_base) as usize; + if off + 4 <= pe.len() { + let raw = u32::from_be_bytes([pe[off], pe[off+1], pe[off+2], pe[off+3]]); + let d = sylpheed_ppc::decode(raw, xref.source); + let t = sylpheed_ppc::disasm::format(&d); + // Prefer the simplified mnemonic when present (matches what + // a human reading the .asm file sees for that line). + Some(t.ext_mnemonic.unwrap_or(t.mnemonic)) + } else { + None + } + }; + + let source_func = func_analysis.functions + .range(..=xref.source) + .next_back() + .map(|(&a, _)| a as i64); + + let source_label = resolve_source_label( + xref.source, func_analysis, labels, + ); + + let addr_mode = xref.addr_mode.map(|m| m.tag()); + appender.append_row(params![ + xref.source as i64, + target as i64, + kind, + addr_mode, + instruction.as_deref(), + source_func, + source_label.as_str(), + target_label, + ])?; + + count += 1; + } + } + + appender.flush()?; + metrics::counter!("db.rows", "table" => "xrefs").increment(count); + tracing::info!(rows = count, table = "xrefs", "bulk insert complete"); + Ok(()) +} diff --git a/crates/sylpheed-xexdb/src/demangle.rs b/crates/sylpheed-xexdb/src/demangle.rs new file mode 100644 index 00000000..e2c332a1 --- /dev/null +++ b/crates/sylpheed-xexdb/src/demangle.rs @@ -0,0 +1,376 @@ +//! MSVC C++ name demangling for Xbox 360 binaries. +//! +//! Wraps [`msvc_demangler::demangle`] (a Rust port of LLVM's +//! `MicrosoftDemangle.cpp`) and splits the resulting human-readable string +//! into structured fields (namespace path, class name, method name, params +//! signature) for storage in the `demangled_names` DB table. +//! +//! The structured split is heuristic — it operates on the formatted output, +//! not the parsed AST. This is good enough for typical RTTI strings of the +//! form `?AVClassName@Namespace@@` and standard member functions; exotic +//! template / lambda forms degrade gracefully (the structured fields end up +//! `None` while `raw_demangled` retains the full LLVM-style output). +//! +//! Reference: (LLVM `MicrosoftDemangle.cpp` port). + +use msvc_demangler::DemangleFlags; + +/// Structured view of one demangled MSVC symbol. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Demangled { + /// Original mangled string. + pub mangled: String, + /// Full LLVM-style demangled output (e.g. `xe::apu::AudioSystem::Setup(void)`). + pub raw_demangled: String, + /// `::`-joined namespace path leading up to the class, e.g. `xe::apu`. None + /// when the symbol is at global scope. + pub namespace_path: Option, + /// Class name for member functions, e.g. `AudioSystem`. None when the + /// symbol is a free function. + pub class_name: Option, + /// Method or free-function name, e.g. `Setup`. None when the heuristic + /// could not separate the name from the rest of the demangled string. + pub method_name: Option, + /// Parameter signature without the surrounding parens, e.g. `void` or + /// `int, char *`. None when not a function or no `(...)` was found. + pub params_signature: Option, +} + +/// Demangle one mangled MSVC C++ symbol. Returns `None` if the input does not +/// start with `?` (early-out for non-mangled names) OR if the underlying +/// demangler fails to parse it. Callers that want a "best effort" record +/// (NULL fields + raw=mangled) should use [`demangle_or_raw`] instead. +pub fn demangle(mangled: &str) -> Option { + if !mangled.starts_with('?') { + return None; + } + let raw = msvc_demangler::demangle(mangled, DemangleFlags::llvm()).ok()?; + Some(split_structured(mangled.to_string(), raw)) +} + +/// Demangle, or fall back to a record that just carries the original mangled +/// string in `raw_demangled` and leaves all structured fields `None`. Useful +/// for DB insert paths that want one row per mangled input regardless of +/// parser success. +pub fn demangle_or_raw(mangled: &str) -> Demangled { + if let Some(d) = demangle(mangled) { + return d; + } + Demangled { + mangled: mangled.to_string(), + raw_demangled: mangled.to_string(), + namespace_path: None, + class_name: None, + method_name: None, + params_signature: None, + } +} + +/// Split a fully-formatted demangled string into structured fields. +/// +/// Strategy: +/// 1. Find the first un-nested `(` — everything before it is the qualified +/// name; everything inside the matching parens is `params_signature`. +/// 2. Strip leading return-type tokens before the qualified name (everything +/// up to the LAST whitespace not inside `<...>` or `(...)` brackets). +/// 3. Split the qualified name on `::` (top-level only) — last segment is +/// `method_name`, second-to-last is `class_name`, the rest joined back +/// with `::` is `namespace_path`. +fn split_structured(mangled: String, raw: String) -> Demangled { + let raw_view = raw.as_str(); + + let (qualified_name, params) = match find_paren_split(raw_view) { + Some((before, inside)) => (before.trim_end().to_string(), Some(inside.to_string())), + None => (raw_view.to_string(), None), + }; + + // Drop any return-type prefix: keep everything after the last top-level + // whitespace boundary (where "top-level" means depth-0 in <...>/(...)). + let qname_clean = strip_return_type_prefix(&qualified_name); + + let (namespace_path, class_name, method_name) = split_qname(&qname_clean); + + Demangled { + mangled, + raw_demangled: raw, + namespace_path, + class_name, + method_name, + params_signature: params, + } +} + +/// Returns `(text_before_paren, text_inside_outer_parens)` for the first +/// top-level `(` in `s`. Returns `None` when no top-level paren is present. +fn find_paren_split(s: &str) -> Option<(&str, &str)> { + let bytes = s.as_bytes(); + let mut depth_angle: i32 = 0; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'<' => depth_angle += 1, + b'>' if depth_angle > 0 => depth_angle -= 1, + b'(' if depth_angle == 0 => { + // Find matching close at depth 0 on parens. + let mut depth_paren = 1i32; + let mut depth_angle2 = 0i32; + for (j, &b2) in bytes.iter().enumerate().skip(i + 1) { + match b2 { + b'<' => depth_angle2 += 1, + b'>' if depth_angle2 > 0 => depth_angle2 -= 1, + b'(' => depth_paren += 1, + b')' => { + depth_paren -= 1; + if depth_paren == 0 { + return Some((&s[..i], &s[i + 1..j])); + } + } + _ => {} + } + } + return None; + } + _ => {} + } + } + None +} + +/// Strip a leading return-type token (everything up to and including the +/// last top-level whitespace). E.g. `void __cdecl Foo::Bar` → `Foo::Bar`. +fn strip_return_type_prefix(s: &str) -> String { + let bytes = s.as_bytes(); + let mut depth_angle: i32 = 0; + let mut depth_paren: i32 = 0; + let mut last_ws_at: Option = None; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'<' => depth_angle += 1, + b'>' if depth_angle > 0 => depth_angle -= 1, + b'(' => depth_paren += 1, + b')' if depth_paren > 0 => depth_paren -= 1, + b' ' if depth_angle == 0 && depth_paren == 0 => last_ws_at = Some(i), + _ => {} + } + } + match last_ws_at { + Some(i) => s[i + 1..].to_string(), + None => s.to_string(), + } +} + +/// Split a fully-qualified name on top-level `::` and tag the parts. +fn split_qname(qname: &str) -> (Option, Option, Option) { + if qname.is_empty() { + return (None, None, None); + } + let parts = top_level_split_colon_colon(qname); + match parts.len() { + 0 => (None, None, None), + 1 => (None, None, Some(parts[0].clone())), + 2 => (None, Some(parts[0].clone()), Some(parts[1].clone())), + _ => { + let n = parts.len(); + let method = parts[n - 1].clone(); + let class = parts[n - 2].clone(); + let ns = parts[..n - 2].join("::"); + (Some(ns), Some(class), Some(method)) + } + } +} + +/// Split on top-level `::` — `::` inside `<...>` or `(...)` is preserved. +fn top_level_split_colon_colon(s: &str) -> Vec { + let bytes = s.as_bytes(); + let mut depth_angle: i32 = 0; + let mut depth_paren: i32 = 0; + let mut out: Vec = Vec::new(); + let mut start = 0usize; + let mut i = 0usize; + while i < bytes.len() { + let b = bytes[i]; + match b { + b'<' => depth_angle += 1, + b'>' if depth_angle > 0 => depth_angle -= 1, + b'(' => depth_paren += 1, + b')' if depth_paren > 0 => depth_paren -= 1, + b':' if depth_angle == 0 + && depth_paren == 0 + && i + 1 < bytes.len() + && bytes[i + 1] == b':' => + { + out.push(s[start..i].to_string()); + start = i + 2; + i += 2; + continue; + } + _ => {} + } + i += 1; + } + out.push(s[start..].to_string()); + out.into_iter().filter(|p| !p.is_empty()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn early_out_on_non_mangled() { + assert!(demangle("plain_c_name").is_none()); + assert!(demangle("Foo::Bar").is_none()); + } + + #[test] + fn demangle_or_raw_records_failures() { + let d = demangle_or_raw("not_mangled"); + assert_eq!(d.mangled, "not_mangled"); + assert_eq!(d.raw_demangled, "not_mangled"); + assert!(d.method_name.is_none()); + } + + #[test] + fn simple_member_function() { + // ?Setup@AudioSystem@apu@xe@@QEAAXXZ → public: __cdecl xe::apu::AudioSystem::Setup(void) + let d = demangle("?Setup@AudioSystem@apu@xe@@QEAAXXZ").expect("should parse"); + assert_eq!(d.method_name.as_deref(), Some("Setup")); + assert_eq!(d.class_name.as_deref(), Some("AudioSystem")); + assert_eq!(d.namespace_path.as_deref(), Some("xe::apu")); + assert_eq!(d.params_signature.as_deref(), Some("void")); + } + + #[test] + fn rtti_type_descriptor_string() { + // RTTI TypeDescriptor mangled name format: ".?AVClassName@@" → "class ClassName". + // We strip the leading "." and call demangle on the "?AV…" part below in M3. + // For now confirm the demangler handles the minimal class form. + let d = demangle("?AVAudioSystem@apu@xe@@").expect("should parse"); + assert!( + d.raw_demangled.contains("AudioSystem"), + "raw='{}'", + d.raw_demangled + ); + } + + #[test] + fn split_qname_handles_namespace_chain() { + let (ns, cls, m) = split_qname("a::b::c::Klass::method"); + assert_eq!(ns.as_deref(), Some("a::b::c")); + assert_eq!(cls.as_deref(), Some("Klass")); + assert_eq!(m.as_deref(), Some("method")); + } + + #[test] + fn paren_split_handles_template_in_args() { + // Templates inside the param list must not confuse paren matching. + let s = "void __cdecl Foo::Bar(std::vector, std::map)"; + let (before, inside) = find_paren_split(s).expect("paren found"); + assert_eq!(before, "void __cdecl Foo::Bar"); + assert_eq!(inside, "std::vector, std::map"); + } + + #[test] + fn double_colon_inside_template_not_split() { + let parts = top_level_split_colon_colon("a::b::e"); + assert_eq!(parts, vec!["a", "b", "e"]); + } +} + +// ── RTTI type-descriptor names ───────────────────────────────────────────── + +/// Demangle an RTTI `TypeDescriptor` decorated name into a readable class path. +/// +/// These are not ordinary symbols: they are *type* encodings prefixed with a +/// literal `.`, e.g. `.?AVSilph@silph@@` → `silph::Silph`, +/// `.?AUGAME_PART_PARAM@silph@@` → `silph::GAME_PART_PARAM`. +/// +/// A bare descriptor name is not a symbol the demangler accepts, and feeding it +/// one anyway silently mis-parses (`?AVSilph@silph@@` demangles to +/// `silph::AVSilph`, keeping the `AV` type tag as part of the class name). The +/// correct move is to rebuild the symbol MSVC would have emitted for this +/// descriptor — `??_R0@8` — demangle *that*, and strip the +/// ``::`RTTI Type Descriptor' `` suffix and the leading type keyword. That path +/// is the only one that renders template arguments properly +/// (`.?AV?$vector@H@std@@` → `std::vector`). +/// +/// If the demangler still declines, the decorated name is decoded directly: +/// strip the `.?A[VU]` tag, split the remainder on `@`, and re-join the +/// components in reverse (MSVC emits innermost scope first). The +/// anonymous-namespace component `?A0x` becomes `(anonymous namespace)`. +/// +/// Returns `None` only when the input is not a type descriptor at all. +pub fn demangle_type_descriptor(decorated: &str) -> Option { + let body = decorated.strip_prefix('.')?; + if !(body.starts_with("?AV") || body.starts_with("?AU") || body.starts_with("?AW")) { + return None; + } + + const RTTI_SUFFIX: &str = "::`RTTI Type Descriptor'"; + if let Ok(full) = msvc_demangler::demangle(&format!("??_R0{body}@8"), DemangleFlags::llvm()) + && let Some(qualified) = full.trim().strip_suffix(RTTI_SUFFIX) + { + let name = qualified + .trim_start_matches("class ") + .trim_start_matches("struct ") + .trim_start_matches("enum ") + .trim_start_matches("union ") + .trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + + let inner = body[3..].trim_end_matches('@'); + let mut parts: Vec = inner + .split('@') + .filter(|p| !p.is_empty()) + .map(|p| { + if p.starts_with("?A0x") { + "(anonymous namespace)".to_string() + } else { + p.to_string() + } + }) + .collect(); + if parts.is_empty() { + return None; + } + parts.reverse(); + Some(parts.join("::")) +} + +#[cfg(test)] +mod rtti_name_tests { + use super::demangle_type_descriptor; + + #[test] + fn plain_class_in_namespace() { + assert_eq!(demangle_type_descriptor(".?AVSilph@silph@@").as_deref(), Some("silph::Silph")); + } + + #[test] + fn struct_tag() { + assert_eq!( + demangle_type_descriptor(".?AUGAME_PART_PARAM@silph@@").as_deref(), + Some("silph::GAME_PART_PARAM"), + ); + } + + #[test] + fn global_scope_class() { + assert_eq!(demangle_type_descriptor(".?AVexception@std@@").as_deref(), Some("std::exception")); + } + + #[test] + fn anonymous_namespace_is_named() { + let got = demangle_type_descriptor(".?AVAct_Stop@?A0x5cc05762@unnamed_namespaces@@").unwrap(); + assert!(got.ends_with("Act_Stop"), "got {got}"); + assert!(got.starts_with("unnamed_namespaces"), "got {got}"); + } + + #[test] + fn rejects_non_descriptors() { + assert_eq!(demangle_type_descriptor("?Foo@@QAEXXZ"), None); + assert_eq!(demangle_type_descriptor("plain_name"), None); + } +} diff --git a/crates/sylpheed-xexdb/src/disasm.rs b/crates/sylpheed-xexdb/src/disasm.rs new file mode 100644 index 00000000..03056b16 --- /dev/null +++ b/crates/sylpheed-xexdb/src/disasm.rs @@ -0,0 +1,154 @@ +//! Analysis-side enrichment over [`sylpheed_ppc::disasm::iter_disasm`]. +//! +//! Turns a stream of decoder-only [`sylpheed_ppc::disasm::DisasmItem`]s into a +//! stream of [`RichDisasmItem`]s carrying section name + enclosing function + +//! label name. The three sinks in [`crate::sinks`] (text, JSON, DuckDB) all +//! consume `RichDisasmItem`. + +use std::collections::{BTreeSet, HashMap}; + +use sylpheed_ppc::disasm::DisasmItem; + +use crate::func::FuncAnalysis; + +/// `DisasmItem` plus the analysis context (section/function/label). +#[derive(Debug, Clone)] +pub struct RichDisasmItem<'a> { + pub item: DisasmItem, + pub section: &'a str, + pub function: Option, + pub label: Option<&'a str>, + /// True when this word is data embedded in a code section (a recovered + /// jump table or its index map), so its decoded text is meaningless. + pub is_data: bool, +} + +/// Walk one code section, yielding rich items annotated with section name, +/// enclosing function, and label-at-address. +/// +/// `function` is the function that actually *contains* the address: it is set +/// on crossing a function start and cleared again at that function's +/// `end_address`. It is deliberately `None` in the gaps between functions. +/// +/// It used to be a pure rolling window — set at each start and never cleared — +/// which silently attributed every gap word to whichever function happened to +/// precede it. On the reference title that mislabelled 55,227 instructions, +/// so `WHERE function = X` returned code that is not part of X, and the +/// resulting 100% attribution rate hid the fact that `.pdata` leaves ~450 KB +/// of `.text` unclaimed. +/// +/// `data_words` is the set of 4-byte-aligned addresses inside code sections +/// that are known to hold data (see [`crate::jumptables::data_word_addresses`]). +/// Rows at those addresses are still emitted — their `raw` value is the table +/// entry a consumer wants — but flagged so nothing mistakes the decoded text +/// for a real instruction. +pub fn enrich_section<'a>( + image: &'a [u8], + image_base: u32, + section_name: &'a str, + va_start: u32, + va_end: u32, + func_analysis: &'a FuncAnalysis, + labels: &'a HashMap, + data_words: &'a BTreeSet, +) -> impl Iterator> + 'a { + // (start, end) of the function currently being walked. + let mut current: Option<(u32, u32)> = None; + sylpheed_ppc::disasm::iter_disasm(image, image_base, va_start, va_end).map(move |item| { + // Leaving the current function must be handled before entering the + // next: a function often starts exactly at its predecessor's end. + if let Some((_, end)) = current + && item.addr >= end + { + current = None; + } + if let Some(fi) = func_analysis.functions.get(&item.addr) { + current = Some((item.addr, fi.end)); + } + let current_func = current.map(|(start, _)| start); + let label = labels.get(&item.addr).map(|s| s.as_str()); + let is_data = data_words.contains(&item.addr); + RichDisasmItem { + item, + section: section_name, + function: current_func, + label, + is_data, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::func::{FuncAnalysis, FuncInfo}; + use std::collections::BTreeMap; + + fn fi(start: u32, end: u32) -> FuncInfo { + FuncInfo { + start, end, + frame_size: 0, saved_gprs: 0, is_leaf: true, is_saverestore: false, + pdata_validated: true, pdata_length: Some(end - start), + pdata_prolog_length: None, has_eh: false, + } + } + + /// A word in the gap between two functions belongs to neither. Before the + /// containment check this walker carried the *preceding* function forward + /// across the gap, so `WHERE function = X` returned code outside X. + #[test] + fn gap_between_functions_is_unattributed() { + let image_base = 0x82000000u32; + // 6 words: [f0 f0] [gap gap] [f1 f1] + let image = vec![0x60u8; 0x40]; // `ori` — decodes cleanly, value irrelevant + let mut functions = BTreeMap::new(); + functions.insert(image_base, fi(image_base, image_base + 8)); + functions.insert(image_base + 16, fi(image_base + 16, image_base + 24)); + let fa = FuncAnalysis { + functions, + save_gpr_base: None, + restore_gpr_base: None, + pdata_entries: Vec::new(), + }; + let labels = HashMap::new(); + let data_words = BTreeSet::new(); + let got: Vec<(u32, Option)> = enrich_section( + &image, image_base, ".text", image_base, image_base + 24, + &fa, &labels, &data_words, + ).map(|r| (r.item.addr, r.function)).collect(); + + assert_eq!(got, vec![ + (image_base, Some(image_base)), // inside f0 + (image_base + 4, Some(image_base)), // inside f0 + (image_base + 8, None), // gap — was wrongly f0 + (image_base + 12, None), // gap — was wrongly f0 + (image_base + 16, Some(image_base + 16)), // f1 starts + (image_base + 20, Some(image_base + 16)), + ]); + } + + /// A function starting exactly at its predecessor's `end_address` must be + /// entered, not dropped: the leave check runs before the enter check. + #[test] + fn adjacent_functions_hand_over_cleanly() { + let image_base = 0x82000000u32; + let image = vec![0x60u8; 0x40]; + let mut functions = BTreeMap::new(); + functions.insert(image_base, fi(image_base, image_base + 8)); + functions.insert(image_base + 8, fi(image_base + 8, image_base + 16)); + let fa = FuncAnalysis { + functions, save_gpr_base: None, restore_gpr_base: None, + pdata_entries: Vec::new(), + }; + let labels = HashMap::new(); + let data_words = BTreeSet::new(); + let got: Vec> = enrich_section( + &image, image_base, ".text", image_base, image_base + 16, + &fa, &labels, &data_words, + ).map(|r| r.function).collect(); + assert_eq!(got, vec![ + Some(image_base), Some(image_base), + Some(image_base + 8), Some(image_base + 8), + ]); + } +} diff --git a/crates/sylpheed-xexdb/src/eh_scope.rs b/crates/sylpheed-xexdb/src/eh_scope.rs new file mode 100644 index 00000000..58c906a1 --- /dev/null +++ b/crates/sylpheed-xexdb/src/eh_scope.rs @@ -0,0 +1,296 @@ +//! M9.5 — MSVC `__CxxFrameHandler` scope-table parsing. +//! +//! When MSVC compiles C++ try/catch on Win32 PowerPC, the compiler emits +//! per-function `FuncInfo` records in `.rdata` containing the scope-state +//! tables that `__CxxFrameHandler` walks during unwinding. Each record +//! starts with one of the documented magic numbers: +//! +//! - `0x19930520` — original FuncInfo (no aligned-state-array) +//! - `0x19930521` — adds `pESTypeList` field +//! - `0x19930522` — adds `EHFlags` field +//! +//! Layout (4-byte little-endian on x86; **on Xbox 360 PowerPC PE the +//! struct is big-endian** because the binary is BE throughout): +//! +//! ```text +//! +0x00 uint32 magicNumber (one of 0x199305{20,21,22}) +//! +0x04 int32 maxState (number of UnwindMapEntry rows) +//! +0x08 uint32 pUnwindMap (VA → UnwindMapEntry[]) +//! +0x0C uint32 nTryBlocks +//! +0x10 uint32 pTryBlockMap (VA → TryBlockMapEntry[]) +//! +0x14 uint32 nIPMapEntries (ignored on x86; present on PPC) +//! +0x18 uint32 pIPtoStateMap (VA → IPtoStateMapEntry[]) +//! +0x1C uint32 pESTypeList (only when magic ≥ 0x19930521) +//! +0x20 uint32 EHFlags (only when magic = 0x19930522) +//! ``` +//! +//! Each `UnwindMapEntry` is 8 bytes: `(toState i32, action u32)`. +//! Each `TryBlockMapEntry` is 20 bytes: +//! `(tryLow i32, tryHigh i32, catchHigh i32, nCatches u32, pHandlerArray u32)`. +//! +//! ### What this module does +//! +//! - Magic-scan `.rdata` for the three FuncInfo signatures (read as BE u32). +//! - Parse the FuncInfo record + walk the unwind map and try-block map. +//! - Skip records whose internal pointers don't land in valid sections, +//! or whose lengths exceed sane caps. +//! +//! ### What this module does NOT do +//! +//! - Does not associate a FuncInfo back to its owning function. The +//! `bl __CxxFrameHandler` registration would name that linkage, but +//! it requires walking all `has_eh=true` functions' prologues; a +//! future M9.6 can do that. For now the FuncInfo record stands on its +//! own — joins to `functions` by best-effort PC range queries. +//! - Does not parse the `pHandlerArray` per try-block (catch type info). +//! +//! Reference: LLVM `llvm/lib/CodeGen/AsmPrinter/WinException.cpp`, +//! Microsoft openrce.org documentation on FuncInfo. + +use sylpheed_xex::pe::PeSection; + +const MAGIC_OLD: u32 = 0x1993_0520; +const MAGIC_V21: u32 = 0x1993_0521; +const MAGIC_V22: u32 = 0x1993_0522; + +#[derive(Debug, Clone, Copy)] +pub struct UnwindMapEntry { + pub to_state: i32, + pub action_pc: u32, // VA of the cleanup action; 0 if none +} + +#[derive(Debug, Clone, Copy)] +pub struct TryBlockMapEntry { + pub try_low: i32, + pub try_high: i32, + pub catch_high: i32, + pub n_catches: u32, + pub p_handler_array: u32, +} + +#[derive(Debug, Clone)] +pub struct EhFuncInfo { + pub address: u32, // VA of the FuncInfo record itself + pub magic: u32, + pub max_state: i32, + pub p_unwind_map: u32, + pub n_try_blocks: u32, + pub p_try_block_map: u32, + pub n_ip_map_entries: u32, + pub p_ip_to_state_map: u32, + pub p_es_type_list: Option, + pub eh_flags: Option, + pub unwind_map: Vec, + pub try_blocks: Vec, +} + +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + sections: &[PeSection], +) -> Vec { + let started = std::time::Instant::now(); + let mut out: Vec = Vec::new(); + + // Compute the union of valid VA ranges across all sections — used to + // sanity-check internal pointers in the FuncInfo records. + let valid_ranges: Vec<(u32, u32)> = sections.iter() + .map(|s| (image_base + s.virtual_address, + image_base + s.virtual_address + s.virtual_size)) + .collect(); + let in_valid = |va: u32| valid_ranges.iter().any(|(lo, hi)| va >= *lo && va < *hi); + + let read_u32 = |abs: u32| -> Option { + let off = abs.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) + }; + let read_i32 = |abs: u32| -> Option { read_u32(abs).map(|u| u as i32) }; + + for section in sections { + if section.name != ".rdata" { continue; } + let raw_start = section.virtual_address as usize; + let raw_end = (section.virtual_address + section.virtual_size) as usize; + if raw_end > pe.len() { continue; } + let bytes = &pe[raw_start..raw_end.min(pe.len())]; + let va_base = image_base + section.virtual_address; + + // Walk on 4-byte alignment looking for the magic. + let mut i = 0; + while i + 4 <= bytes.len() { + if !i.is_multiple_of(4) { i += 1; continue; } + let m = u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); + if m == MAGIC_OLD || m == MAGIC_V21 || m == MAGIC_V22 { + let addr = va_base + i as u32; + if let Some(rec) = parse_funcinfo(addr, m, &read_u32, &read_i32, &in_valid) { + out.push(rec); + } + } + i += 4; + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + let n_unwind: usize = out.iter().map(|r| r.unwind_map.len()).sum(); + let n_try: usize = out.iter().map(|r| r.try_blocks.len()).sum(); + metrics::histogram!("analysis.phase_ms", "phase" => "eh_scope").record(elapsed_ms); + tracing::info!( + records = out.len(), + unwind_entries = n_unwind, + try_blocks = n_try, + elapsed_ms, + "M9.5 EH scope-table scan complete", + ); + out +} + +fn parse_funcinfo( + addr: u32, + magic: u32, + read_u32: &impl Fn(u32) -> Option, + read_i32: &impl Fn(u32) -> Option, + in_valid: &impl Fn(u32) -> bool, +) -> Option { + let max_state = read_i32(addr + 0x04)?; + let p_unwind_map = read_u32(addr + 0x08)?; + let n_try_blocks = read_u32(addr + 0x0C)?; + let p_try_block_map = read_u32(addr + 0x10)?; + let n_ip_map_entries = read_u32(addr + 0x14)?; + let p_ip_to_state_map = read_u32(addr + 0x18)?; + + // Sanity caps: real FuncInfo records have max_state ≤ a few thousand, + // n_try_blocks ≤ a few hundred. Reject obviously bogus values that + // happened to alias the magic. + if !(0..=10_000).contains(&max_state) { return None; } + if n_try_blocks > 1_000 { return None; } + if n_ip_map_entries > 100_000 { return None; } + // Pointers must either be NULL or land in a valid section. + if p_unwind_map != 0 && !in_valid(p_unwind_map) { return None; } + if p_try_block_map != 0 && !in_valid(p_try_block_map) { return None; } + if p_ip_to_state_map != 0 && !in_valid(p_ip_to_state_map) { return None; } + + let (p_es_type_list, eh_flags) = if magic == MAGIC_V21 { + (read_u32(addr + 0x1C), None) + } else if magic == MAGIC_V22 { + (read_u32(addr + 0x1C), read_u32(addr + 0x20)) + } else { + (None, None) + }; + + // Walk unwind map (8-byte entries). + let mut unwind_map: Vec = Vec::with_capacity(max_state as usize); + if p_unwind_map != 0 && max_state > 0 { + for i in 0..max_state { + let p = p_unwind_map.wrapping_add((i * 8) as u32); + let to_state = read_i32(p)?; + let action_pc = read_u32(p + 4)?; + unwind_map.push(UnwindMapEntry { to_state, action_pc }); + } + } + + // Walk try-block map (20-byte entries). + let mut try_blocks: Vec = Vec::with_capacity(n_try_blocks as usize); + if p_try_block_map != 0 && n_try_blocks > 0 { + for i in 0..n_try_blocks { + let p = p_try_block_map.wrapping_add(i * 20); + let try_low = read_i32(p)?; + let try_high = read_i32(p + 4)?; + let catch_high = read_i32(p + 8)?; + let n_catches = read_u32(p + 12)?; + let p_handler_a = read_u32(p + 16)?; + try_blocks.push(TryBlockMapEntry { + try_low, try_high, catch_high, n_catches, p_handler_array: p_handler_a, + }); + } + } + + Some(EhFuncInfo { + address: addr, + magic, + max_state, + p_unwind_map, + n_try_blocks, + p_try_block_map, + n_ip_map_entries, + p_ip_to_state_map, + p_es_type_list, + eh_flags, + unwind_map, + try_blocks, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use sylpheed_xex::pe::PeSection; + + fn mk_section(name: &str, va: u32, size: u32) -> PeSection { + PeSection { + name: name.into(), + virtual_address: va, virtual_size: size, + raw_offset: va, raw_size: size, + flags: 0x4000_0040, + } + } + + fn write_be(pe: &mut [u8], at: usize, v: u32) { + pe[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + fn write_be_i32(pe: &mut [u8], at: usize, v: i32) { + pe[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + + #[test] + fn parses_minimal_funcinfo_v0() { + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let mut pe = vec![0u8; 0x4000]; + + // FuncInfo at .rdata + 0x10. + let fi_off = (rdata_va + 0x10) as usize; + let fi_va = image_base + rdata_va + 0x10; + let unwind_off = (rdata_va + 0x80) as usize; + let unwind_va = image_base + rdata_va + 0x80; + + write_be(&mut pe, fi_off, MAGIC_OLD); // magic + write_be_i32(&mut pe, fi_off + 4, 2); // maxState + write_be(&mut pe, fi_off + 8, unwind_va); // pUnwindMap + write_be(&mut pe, fi_off + 12, 0); // nTryBlocks + write_be(&mut pe, fi_off + 16, 0); // pTryBlockMap + write_be(&mut pe, fi_off + 20, 0); // nIPMapEntries + write_be(&mut pe, fi_off + 24, 0); // pIPtoStateMap + + // Two unwind entries. + write_be_i32(&mut pe, unwind_off, -1); // to_state + write_be(&mut pe, unwind_off + 4, image_base + 0x500); // action_pc + write_be_i32(&mut pe, unwind_off + 8, 0); + write_be(&mut pe, unwind_off + 12, image_base + 0x600); + + let sections = vec![mk_section(".rdata", rdata_va, 0x100)]; + let recs = analyze(&pe, image_base, §ions); + assert_eq!(recs.len(), 1); + let r = &recs[0]; + assert_eq!(r.address, fi_va); + assert_eq!(r.magic, MAGIC_OLD); + assert_eq!(r.max_state, 2); + assert_eq!(r.unwind_map.len(), 2); + assert_eq!(r.unwind_map[0].to_state, -1); + assert_eq!(r.unwind_map[0].action_pc, image_base + 0x500); + assert_eq!(r.try_blocks.len(), 0); + } + + #[test] + fn rejects_bogus_max_state() { + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let mut pe = vec![0u8; 0x4000]; + let fi_off = (rdata_va + 0x10) as usize; + write_be(&mut pe, fi_off, MAGIC_OLD); + write_be_i32(&mut pe, fi_off + 4, 0xFFFF); // bogus maxState + let sections = vec![mk_section(".rdata", rdata_va, 0x100)]; + let recs = analyze(&pe, image_base, §ions); + assert_eq!(recs.len(), 0); + } +} diff --git a/crates/sylpheed-xexdb/src/formatter.rs b/crates/sylpheed-xexdb/src/formatter.rs new file mode 100644 index 00000000..3c65a91c --- /dev/null +++ b/crates/sylpheed-xexdb/src/formatter.rs @@ -0,0 +1,281 @@ +//! Assembly text output formatter for Xbox 360 disassembly. + +use std::collections::{BTreeSet, HashMap}; +use std::io::Write; + +use sylpheed_xex::header::ImportLibrary; +use sylpheed_xex::pe::PeSection; + +use crate::disasm::enrich_section; +use crate::func::FuncAnalysis; +use crate::sinks::text::write_instr_line; +use crate::xref::{XrefKind, Xref, XrefMap, resolve_source_label}; + +/// Metadata passed to the formatter (avoids exposing full Xex2Header internals). +pub struct DisasmInfo<'a> { + pub image_base: u32, + pub entry_point: u32, + pub original_pe_name: Option<&'a str>, + pub title_id: Option, + pub media_id: Option, + pub sections: &'a [PeSection], + pub import_libraries: &'a [ImportLibrary], + /// Full parsed XEX2 header, when the caller loaded from a XEX/ISO. Drives + /// the extended `metadata` rows (module/system/image flags, image size, + /// compression + encryption, per-library versions, …). `None` when the + /// caller only had a bare PE. + pub xex_header: Option<&'a sylpheed_xex::header::Xex2Header>, +} + +/// Write full disassembly to the output stream. +pub fn write_asm( + out: &mut dyn Write, + pe: &[u8], + info: &DisasmInfo, + func_analysis: &FuncAnalysis, + labels: &HashMap, + import_map: &HashMap, + xrefs: &XrefMap, + data_annotations: &HashMap, + data_words: &BTreeSet, +) -> anyhow::Result<()> { + // Header + writeln!(out, "; ============================================================================")?; + writeln!(out, "; Xbox 360 Disassembly — generated by xenia-rs")?; + if let Some(name) = info.original_pe_name { + writeln!(out, "; Original PE: {name}")?; + } + if let (Some(title_id), Some(media_id)) = (info.title_id, info.media_id) { + writeln!(out, "; Title ID: 0x{title_id:08X} Media ID: 0x{media_id:08X}")?; + } + writeln!(out, "; Image base: 0x{:08X} Entry point: 0x{:08X}", info.image_base, info.entry_point)?; + writeln!(out, "; Functions detected: {}", func_analysis.functions.len())?; + writeln!(out, "; ============================================================================")?; + writeln!(out)?; + + // Import declarations + if !info.import_libraries.is_empty() { + writeln!(out, "; ── Imports ─────────────────────────────────────────────────────────────────")?; + for lib in info.import_libraries { + writeln!(out, "; Library: {}", lib.name)?; + for imp in &lib.imports { + let resolved = crate::resolve_ordinal(&lib.name, imp.ordinal); + let name = resolved.unwrap_or("???"); + let kind = if imp.record_type == 1 { "thunk" } else { "var" }; + writeln!(out, "; [{kind}] 0x{:08X} ordinal 0x{:04X} = {}", imp.address, imp.ordinal, name)?; + } + } + writeln!(out)?; + } + + // Disassemble each section + for section in info.sections { + writeln!(out, "; ── Section: {:8} VA=0x{:08X} Size=0x{:08X} Flags=0x{:08X} ──", + section.name, section.virtual_address, section.virtual_size, section.flags)?; + + let va_start = section.virtual_address; + let va_end = va_start + section.virtual_size; + let file_start = section.virtual_address as usize; + + // Pre-sort data labels in this section for break-at-label hex dump + let section_labels_sorted: Vec = if !section.is_code() { + let sec_start = info.image_base + va_start; + let sec_end = info.image_base + va_end; + let mut addrs: Vec = labels.keys() + .filter(|&&a| a >= sec_start && a < sec_end) + .copied() + .collect(); + addrs.sort(); + addrs + } else { + Vec::new() + }; + + if section.is_code() { + writeln!(out, ".text")?; + writeln!(out)?; + + let mut in_function = false; + let abs_start = info.image_base + va_start; + let abs_end = info.image_base + va_end; + + let items = enrich_section( + pe, info.image_base, §ion.name, abs_start, abs_end, func_analysis, labels, + data_words, + ); + for ri in items { + let abs_addr = ri.item.addr; + + // Function start? Emit separator + header + if let Some(fi) = func_analysis.get(abs_addr) { + if in_function { + writeln!(out, "; end function")?; + } + writeln!(out)?; + writeln!(out, "; ──────────────────────────────────────────────────────────────────────────")?; + + let lbl = labels.get(&abs_addr).cloned() + .unwrap_or_else(|| format!("sub_{abs_addr:08X}")); + + if fi.is_saverestore { + writeln!(out, "; FUNCTION: {lbl} (save/restore GPR helper)")?; + } else if fi.is_leaf { + writeln!(out, "; FUNCTION: {lbl} (leaf)")?; + } else { + let mut details = Vec::new(); + if fi.frame_size > 0 { + details.push(format!("frame={}", fi.frame_size)); + } + if fi.saved_gprs > 0 { + let first_reg = 32 - fi.saved_gprs; + details.push(format!("saves r{first_reg}-r31")); + } + let detail_str = if details.is_empty() { + String::new() + } else { + format!(" ({})", details.join(", ")) + }; + writeln!(out, "; FUNCTION: {lbl}{detail_str}")?; + } + + if let Some(xref_lines) = format_xrefs(abs_addr, xrefs, func_analysis, labels) { + for line in &xref_lines { + writeln!(out, "{line}")?; + } + } + + writeln!(out, "; ──────────────────────────────────────────────────────────────────────────")?; + in_function = true; + } + + // Label + if let Some(lbl) = labels.get(&abs_addr) { + if !func_analysis.is_function_start(abs_addr) { + writeln!(out)?; + if let Some(xref_lines) = format_xrefs(abs_addr, xrefs, func_analysis, labels) { + for line in &xref_lines { + writeln!(out, "{line}")?; + } + } + writeln!(out, "{lbl}:")?; + } else { + writeln!(out)?; + writeln!(out, "{lbl}:")?; + } + } + + // Import thunk annotation + if let Some(imp_name) = import_map.get(&abs_addr) { + writeln!(out, " ; IMPORT: {imp_name}")?; + } + + let data_annot = data_annotations.get(&abs_addr).copied(); + write_instr_line(out, &ri, labels, info.sections, info.image_base, data_annot)?; + } + if in_function { + writeln!(out, "; end function")?; + } + } else { + // Data section: hex dump + writeln!(out, ".data")?; + writeln!(out)?; + + let mut addr = va_start; + while addr < va_end { + let abs_addr = info.image_base + addr; + let off = (addr - va_start) as usize + file_start; + + if let Some(lbl) = labels.get(&abs_addr) { + writeln!(out)?; + // Xrefs for data labels + if let Some(xref_lines) = format_xrefs(abs_addr, xrefs, func_analysis, labels) { + for line in &xref_lines { + writeln!(out, "{line}")?; + } + } + writeln!(out, "{lbl}:")?; + } + + // Emit up to 16 bytes per line, but break at label boundaries + let mut line_end = std::cmp::min(addr + 16, va_end); + for &lbl_addr in §ion_labels_sorted { + let lbl_va = lbl_addr - info.image_base; + if lbl_va > addr && lbl_va < line_end { + line_end = lbl_va; + break; + } + if lbl_va >= line_end { break; } + } + let byte_count = (line_end - addr) as usize; + if off + byte_count > pe.len() { break; } + + write!(out, " {:08X}: ", abs_addr)?; + for i in 0..byte_count { + write!(out, "{:02X}", pe[off + i])?; + if i % 4 == 3 { write!(out, " ")?; } + } + // ASCII representation + let pad = (16 - byte_count) * 2 + (16 - byte_count) / 4; + write!(out, "{:>width$} |", "", width = pad)?; + for i in 0..byte_count { + let b = pe[off + i]; + let ch = if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' }; + write!(out, "{ch}")?; + } + writeln!(out, "|")?; + + addr = line_end; + } + } + writeln!(out)?; + } + + Ok(()) +} + +const XREF_DISPLAY_LIMIT: usize = 8; + +fn format_xrefs( + target: u32, + xrefs: &XrefMap, + func_analysis: &FuncAnalysis, + labels: &HashMap, +) -> Option> { + let refs = xrefs.get(&target)?; + if refs.is_empty() { return None; } + + let mut sorted: Vec = refs.clone(); + sorted.sort(); + sorted.dedup(); + + let total = sorted.len(); + let mut lines = Vec::new(); + + let calls = sorted.iter().filter(|x| x.kind == XrefKind::Call).count(); + let jumps = sorted.iter().filter(|x| x.kind == XrefKind::Jump).count(); + let branches = sorted.iter().filter(|x| x.kind == XrefKind::Branch).count(); + let reads = sorted.iter().filter(|x| x.kind == XrefKind::DataRead).count(); + let writes = sorted.iter().filter(|x| x.kind == XrefKind::DataWrite).count(); + let data_refs = sorted.iter().filter(|x| x.kind == XrefKind::DataRef).count(); + + let mut summary_parts = Vec::new(); + if calls > 0 { summary_parts.push(format!("{calls} call{}", if calls != 1 { "s" } else { "" })); } + if jumps > 0 { summary_parts.push(format!("{jumps} jump{}", if jumps != 1 { "s" } else { "" })); } + if branches > 0 { summary_parts.push(format!("{branches} branch{}", if branches != 1 { "es" } else { "" })); } + if reads > 0 { summary_parts.push(format!("{reads} read{}", if reads != 1 { "s" } else { "" })); } + if writes > 0 { summary_parts.push(format!("{writes} write{}", if writes != 1 { "s" } else { "" })); } + if data_refs > 0 { summary_parts.push(format!("{data_refs} ref{}", if data_refs != 1 { "s" } else { "" })); } + + lines.push(format!("; XREF: {} ({})", summary_parts.join(", "), total)); + + for (i, xref) in sorted.iter().enumerate() { + if i >= XREF_DISPLAY_LIMIT { + lines.push(format!("; ... and {} more", total - XREF_DISPLAY_LIMIT)); + break; + } + let source_label = resolve_source_label(xref.source, func_analysis, labels); + lines.push(format!("; {} from {}", xref.kind.tag(), source_label)); + } + + Some(lines) +} diff --git a/crates/sylpheed-xexdb/src/func.rs b/crates/sylpheed-xexdb/src/func.rs new file mode 100644 index 00000000..f5275813 --- /dev/null +++ b/crates/sylpheed-xexdb/src/func.rs @@ -0,0 +1,714 @@ +//! Function boundary detection via PPC prologue/epilogue pattern matching. +//! +//! Strategy (multi-pass): +//! 1. Identify all `bl` (branch-and-link) targets — these are call sites, +//! hence very likely function entry points. +//! 2. Scan the save/restore GPR helper region and label it. +//! 3. For each candidate entry, look for prologue patterns: +//! a) `mfspr rN, LR` (typically r0 or r12) +//! b) `bl __savegprlr_NN` (call into save stub) +//! c) `stwu r1, -N(r1)` (allocate stack frame) +//! If a prologue is confirmed, record the function and its stack frame size. +//! 4. Walk forward from each function entry to find the epilogue: +//! a) `blr` (return) +//! b) `b __restgprlr_NN` (tail-branch into restore stub which returns) +//! Mark the function's end address. +//! 5. Detect leaf functions: `bl` targets that lack a prologue but eventually `blr`. + +use std::collections::{HashMap, HashSet, BTreeMap}; + +/// Information about a detected function. +#[derive(Debug, Clone)] +pub struct FuncInfo { + /// Absolute start address. + pub start: u32, + /// Absolute end address (exclusive — one past last instruction). + pub end: u32, + /// Stack frame size (0 if unknown / leaf). + pub frame_size: u32, + /// Number of saved GPRs (via __savegprlr helper), 0 if unknown. + pub saved_gprs: u32, + /// True if this is a leaf function (no bl, no frame setup). + pub is_leaf: bool, + /// True if this is a save/restore GPR helper stub. + pub is_saverestore: bool, + /// True if `.pdata` has a RUNTIME_FUNCTION whose `BeginAddress` matches `start`. + /// Authoritative ground truth from the linker; rows without this flag are + /// prologue-detected only and may carry boundary errors. + pub pdata_validated: bool, + /// Function size in bytes per `.pdata`'s `function_length` field, if known. + /// Absent (None) when this row is prologue-only. + pub pdata_length: Option, + /// Prolog size in bytes per `.pdata`'s `prolog_length` field, if known. + /// The linker's own count — more reliable than the prologue pattern match. + pub pdata_prolog_length: Option, + /// True when `.pdata`'s exception-flag bit is set on this entry — the + /// function has a registered C++ EH (or SEH) frame handler. Always false + /// for entries without `.pdata` coverage. (M9) + pub has_eh: bool, +} + +/// Result of the function analysis pass. +pub struct FuncAnalysis { + /// address → FuncInfo for every detected function, sorted by address. + pub functions: BTreeMap, + /// Addresses in the save-GPR region (start of __savegprlr block). + pub save_gpr_base: Option, + /// Addresses in the restore-GPR region (start of __restgprlr block). + pub restore_gpr_base: Option, + /// Raw `.pdata` entries from the binary, in original order. Empty when no + /// `.pdata` was supplied. Mirrored into the DB as `pdata_entries`. + pub pdata_entries: Vec, +} + +// ── Instruction field helpers ────────────────────────────────────────────── + +fn op(instr: u32) -> u32 { (instr >> 26) & 0x3F } +fn bits(instr: u32, hi: u32, lo: u32) -> u32 { + (instr >> (31 - hi)) & ((1 << (hi - lo + 1)) - 1) +} + +fn is_mfspr_lr(instr: u32) -> Option { + // mfspr rD, LR → opcode 31, xo=339, spr=8 + if op(instr) != 31 { return None; } + let xo = bits(instr, 30, 21); + if xo != 339 { return None; } + let spr = (bits(instr, 20, 16) << 5) | bits(instr, 15, 11); + if spr != 8 { return None; } + Some(bits(instr, 10, 6)) // return rD +} + +#[allow(dead_code)] +fn is_mtspr_lr(instr: u32) -> bool { + // mtspr LR, rS → opcode 31, xo=467, spr=8 + if op(instr) != 31 { return false; } + let xo = bits(instr, 30, 21); + if xo != 467 { return false; } + let spr = (bits(instr, 20, 16) << 5) | bits(instr, 15, 11); + spr == 8 +} + +fn is_stwu_r1(instr: u32) -> Option { + // stwu r1, d(r1) → opcode 37, rS=1, rA=1 + if op(instr) != 37 { return None; } + let rs = bits(instr, 10, 6); + let ra = bits(instr, 15, 11); + if rs != 1 || ra != 1 { return None; } + let d = ((instr & 0xFFFF) as i16) as i32; + Some(d) // negative = frame allocation +} + +fn is_blr(instr: u32) -> bool { + instr == 0x4E800020 +} + +fn is_bctr(instr: u32) -> bool { + instr == 0x4E800420 +} + +fn is_bl(instr: u32) -> Option { + // bl target → opcode 18, LK=1, AA=0 + if op(instr) != 18 { return None; } + if instr & 1 == 0 { return None; } // must have LK bit + if instr & 2 != 0 { return None; } // not absolute + // Return the signed offset + let li = instr & 0x03FFFFFC; + Some(li) +} + +fn is_b(instr: u32) -> Option { + // b target → opcode 18, LK=0, AA=0 + if op(instr) != 18 { return None; } + if instr & 1 != 0 { return None; } // no LK bit + if instr & 2 != 0 { return None; } // not absolute + Some(instr & 0x03FFFFFC) +} + +fn sign_ext26(val: u32) -> i32 { + ((val << 6) as i32) >> 6 +} + +fn bl_target(instr: u32, addr: u32) -> Option { + is_bl(instr).map(|off| addr.wrapping_add(sign_ext26(off) as u32)) +} + +fn b_target(instr: u32, addr: u32) -> Option { + is_b(instr).map(|off| addr.wrapping_add(sign_ext26(off) as u32)) +} + +// ── Read instruction from PE ─────────────────────────────────────────────── + +fn read_instr(pe: &[u8], abs_addr: u32, image_base: u32) -> Option { + let off = abs_addr.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off+1], pe[off+2], pe[off+3]])) +} + +// ── Detect the save/restore GPR helper stubs ─────────────────────────────── +// +// These are a well-known pattern emitted by the Xbox 360 linker. +// Save block: a cascade of `std rN, offset(r1)` for r14..r31 + `stw r12, -8(r1)` + `blr` +// Restore: a cascade of `ld rN, offset(r1)` for r14..r31 + `lwz r12, -8(r1)` + `mtspr LR, r12` + `blr` +// +// We detect the save block by finding 18 consecutive `std rN, ...(r1)` instructions +// for r14 through r31. + +fn find_saverestore_stubs( + pe: &[u8], + image_base: u32, + code_ranges: &[(u32, u32)], // (abs_start, abs_end) +) -> (Option, Option) { + let mut save_base = None; + let mut restore_base = None; + + for &(start, end) in code_ranges { + let mut addr = start; + while addr + 4 * 18 < end { + // Check if this is `std r14, ...(r1)` — opcode 62 (std), rS=14, rA=1 + let instr = match read_instr(pe, addr, image_base) { Some(i) => i, None => { addr += 4; continue; } }; + if op(instr) == 62 && bits(instr, 10, 6) == 14 && bits(instr, 15, 11) == 1 && (instr & 3) == 0 { + // Verify it's a cascade: r14, r15, ..., r31 + let mut ok = true; + for i in 0u32..18 { + let check = match read_instr(pe, addr + i * 4, image_base) { Some(c) => c, None => { ok = false; break; } }; + if op(check) != 62 || bits(check, 10, 6) != 14 + i || bits(check, 15, 11) != 1 { + ok = false; + break; + } + } + if ok { + save_base = Some(addr); + // Restore block typically follows the save block + // After save: stw r12, -8(r1) + blr, then restore starts + let after_save = addr + 18 * 4 + 8; // skip stw r12 + blr + let check = read_instr(pe, after_save, image_base); + if let Some(c) = check { + // Should be `ld r14, ...(r1)` — opcode 58, rT=14, rA=1 + if op(c) == 58 && bits(c, 10, 6) == 14 && bits(c, 15, 11) == 1 { + restore_base = Some(after_save); + } + } + break; + } + } + addr += 4; + } + if save_base.is_some() { break; } + } + + (save_base, restore_base) +} + +// ── Main analysis ────────────────────────────────────────────────────────── + +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), entry_point = format_args!("{:#010x}", entry_point)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + entry_point: u32, + code_sections: &[(u32, u32, u32)], // (va_start, va_size, flags) +) -> FuncAnalysis { + analyze_with_pdata(pe, image_base, entry_point, code_sections, &[]) +} + +/// Same as [`analyze`] but also unions `.pdata` `RUNTIME_FUNCTION` entries +/// into the candidate set. Each surviving function carries `pdata_validated` +/// when its start matches a pdata `BeginAddress`, and `pdata_length` when +/// the linker-supplied length disagrees with the prologue walk. +/// +/// Pdata entries that have no prologue match (orphans) are still emitted, +/// using the linker-supplied length to bound the function. +/// +/// What this layer does NOT do: +/// - Does not edit the `prolog_length` we'd derive from prologue analysis; +/// `frame_size` and `saved_gprs` remain best-effort prologue inferences. +/// - Does not infer base/derived call edges — that's M3+M5. +/// - Does not discover functions that are neither in `.pdata` nor the target of +/// a `bl`. Some code does live in the `.pdata` gaps — small leaf helpers +/// reached only through a function-pointer table. Measured against a Ghidra +/// export of the reference title, 217 such entries exist that this pass does +/// not emit. Two obvious heuristics for them were evaluated and **rejected**: +/// "a data word that points into code outside any `.pdata` range" yields 1994 +/// new candidates of which Ghidra confirms 37, and "8-byte-aligned word in a +/// gap, preceded by `blr` + padding" yields 4011 of which Ghidra confirms +/// 106. Either would flood `functions` with several thousand unvalidated +/// rows and destroy the property that every emitted boundary is exact, in +/// exchange for a couple of hundred real ones. If this gap needs closing, it +/// wants a real recursive-descent walk seeded from the function-pointer +/// tables, not a pattern match. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), entry_point = format_args!("{:#010x}", entry_point), pdata_entries = pdata.len()))] +pub fn analyze_with_pdata( + pe: &[u8], + image_base: u32, + entry_point: u32, + code_sections: &[(u32, u32, u32)], + pdata: &[sylpheed_xex::pdata::PdataEntry], +) -> FuncAnalysis { + let started = std::time::Instant::now(); + let code_ranges: Vec<(u32, u32)> = code_sections.iter() + .map(|(va, sz, _)| (image_base + va, image_base + va + sz)) + .collect(); + + // 1. Find save/restore stubs + let (save_base, restore_base) = find_saverestore_stubs(pe, image_base, &code_ranges); + if let Some(sb) = save_base { + tracing::debug!(addr = format_args!("{:#010x}", sb), "__savegprlr stub"); + } + if let Some(rb) = restore_base { + tracing::debug!(addr = format_args!("{:#010x}", rb), "__restgprlr stub"); + } + + // Set of addresses in the save/restore region (to exclude from function detection) + let mut saverestore_addrs: HashSet = HashSet::new(); + if let Some(sb) = save_base { + // Save block: 18 std + stw + blr = 20 instructions + for i in 0..20 { saverestore_addrs.insert(sb + i * 4); } + } + if let Some(rb) = restore_base { + // Restore block: 18 ld + lwz + mtspr + blr = 21 instructions + for i in 0..21 { saverestore_addrs.insert(rb + i * 4); } + } + + // 2. Collect all bl targets as candidate function entries. + // Union: bl targets ∪ pdata BeginAddresses ∪ entry_point. + let mut call_targets: HashSet = HashSet::new(); + call_targets.insert(entry_point); + + for &(start, end) in &code_ranges { + let mut addr = start; + while addr < end { + if let Some(instr) = read_instr(pe, addr, image_base) + && let Some(target) = bl_target(instr, addr) { + // Don't count calls into save/restore stubs as function entries + if !saverestore_addrs.contains(&target) { + call_targets.insert(target); + } + } + addr += 4; + } + } + + // Index pdata by begin_address for O(1) prologue → length lookup. + let pdata_by_begin: HashMap = + pdata.iter().map(|e| (e.begin_address, e)).collect(); + for e in pdata { + if !saverestore_addrs.contains(&e.begin_address) { + call_targets.insert(e.begin_address); + } + } + + // Tail-call targets. + // + // `bl ∪ pdata` misses a function that is only ever entered by a tail call: + // it has no `bl` site, and small frameless helpers are frequently absent + // from `.pdata`. `0x82169630` in the reference title is one — it follows a + // `b 0x825F0FDC` that ends the previous function and is itself reached only + // by `b`, so nothing in the union nominates it. + // + // `.pdata` makes the test exact: a non-linking `b` whose target leaves the + // source's own linker-declared range, and that does not land inside any + // other declared range, is entering a *different* function — not branching + // within this one. Intra-function jumps and switch arms both stay inside + // the range and are therefore never nominated. + let pdata_sorted: Vec<(u32, u32)> = { + let mut v: Vec<(u32, u32)> = pdata.iter().map(|e| (e.begin_address, e.end_address())).collect(); + v.sort_unstable(); + v + }; + let containing = |addr: u32| -> Option<(u32, u32)> { + match pdata_sorted.binary_search_by_key(&addr, |&(b, _)| b) { + Ok(i) => Some(pdata_sorted[i]), + Err(0) => None, + Err(i) => { + let (b, e) = pdata_sorted[i - 1]; + (addr < e).then_some((b, e)) + } + } + }; + // + // `.pdata` does not cover the whole of `.text` here — roughly 450 KB of + // code sits in gaps between declared ranges, and both ends of a tail call + // can land there. When the source has no declared range to compare + // against, fall back on the standard entry test: the target is a function + // start if the instruction *before* it ends a function (`blr`, `bctr`, or + // an unconditional `b`). Code placed immediately after a terminator is + // unreachable by fallthrough, so something must enter it there. + let ends_function = |addr: u32| -> bool { + match read_instr(pe, addr, image_base) { + Some(i) => is_blr(i) || is_bctr(i) || is_b(i).is_some(), + None => false, + } + }; + let mut tail_call_targets = 0usize; + for &(start, end) in &code_ranges { + let mut addr = start; + while addr < end { + if let Some(instr) = read_instr(pe, addr, image_base) + && let Some(target) = b_target(instr, addr) + && !saverestore_addrs.contains(&target) + && containing(target).is_none() + && code_ranges.iter().any(|&(s, e)| target >= s && target < e) + && match containing(addr) { + // Source is declared: a jump out of its own range is a + // tail call, one inside it is ordinary control flow. + Some((src_lo, src_hi)) => target < src_lo || target >= src_hi, + // Source is in an undeclared gap: fall back to the + // preceding-terminator test. + None => target >= 4 && ends_function(target - 4), + } + && call_targets.insert(target) + { + tail_call_targets += 1; + } + addr += 4; + } + } + + tracing::debug!( + candidates = call_targets.len(), + pdata_entries = pdata.len(), + tail_call_targets, + "function candidates (bl ∪ pdata ∪ tail-call)" + ); + + // 3. For each candidate, detect prologue and walk to epilogue. Pdata + // metadata is layered on after the prologue walk so a missing prologue + // still yields an entry when pdata covers it. + let mut functions: BTreeMap = BTreeMap::new(); + + for &func_addr in &call_targets { + let pdata_entry = pdata_by_begin.get(&func_addr).copied(); + + if let Some(mut fi) = analyze_function( + pe, image_base, func_addr, &code_ranges, save_base, restore_base, + ) { + if let Some(p) = pdata_entry { + fi.pdata_validated = true; + fi.pdata_length = Some(p.function_length); + fi.pdata_prolog_length = Some(p.prolog_length); + // `flags` bit 1 mirrors packed-word bit 31 = exception handler + // registered (see `sylpheed_xex::pdata`). Bit 0 is the 32-bit-code + // flag, which is set on essentially every PPC entry. + fi.has_eh = (p.flags & 0x2) != 0; + // The linker's length is ground truth in BOTH directions: a + // prologue walk that ran past a `blr` into the next function is + // just as wrong as one that stopped early. Only a zero-length + // entry (never observed, but cheap to guard) falls back. + if p.function_length > 0 { + fi.end = p.begin_address.wrapping_add(p.function_length); + } + } + functions.insert(func_addr, fi); + } else if let Some(p) = pdata_entry { + // Orphan: pdata claims a function here but no prologue matched. + // Emit a synthetic entry so the row exists for downstream queries. + let end = p.begin_address.wrapping_add(p.function_length); + functions.insert( + func_addr, + FuncInfo { + start: func_addr, + end, + frame_size: 0, + saved_gprs: 0, + // A pdata orphan is usually a hand-written or fully inlined + // leaf; decide it from the body rather than guessing. + is_leaf: !range_has_call(pe, image_base, func_addr, end), + is_saverestore: false, + pdata_validated: true, + pdata_length: Some(p.function_length), + pdata_prolog_length: Some(p.prolog_length), + has_eh: (p.flags & 0x2) != 0, + }, + ); + } + } + + // 4. Label save/restore stubs as special functions — one entry for the whole block + if let Some(sb) = save_base { + // The save block is one cascade: entry at each rN, falls through to blr + // Treat as a single function with the first entry point + let pe_sb = pdata_by_begin.get(&sb).copied(); + functions.insert(sb, FuncInfo { + start: sb, + end: sb + 20 * 4, // 18 std + stw r12 + blr + frame_size: 0, + saved_gprs: 18, + is_leaf: true, + is_saverestore: true, + pdata_validated: pe_sb.is_some(), + pdata_length: pe_sb.map(|p| p.function_length), + pdata_prolog_length: pe_sb.map(|p| p.prolog_length), + has_eh: pe_sb.map(|p| (p.flags & 0x2) != 0).unwrap_or(false), + }); + } + if let Some(rb) = restore_base { + let pe_rb = pdata_by_begin.get(&rb).copied(); + functions.insert(rb, FuncInfo { + start: rb, + end: rb + 21 * 4, // 18 ld + lwz r12 + mtspr LR + blr + frame_size: 0, + saved_gprs: 18, + is_leaf: true, + is_saverestore: true, + pdata_validated: pe_rb.is_some(), + pdata_length: pe_rb.map(|p| p.function_length), + pdata_prolog_length: pe_rb.map(|p| p.prolog_length), + has_eh: pe_rb.map(|p| (p.flags & 0x2) != 0).unwrap_or(false), + }); + } + + // 5. Reconcile candidate starts against the linker's ground truth. + // + // 5a. A `bl` whose target lands *strictly inside* a `.pdata`-validated + // function is not a second function — it is a branch into the middle + // of one (shared epilogue, computed-goto landing pad, or a + // mis-decoded word). Left in place such a candidate would truncate + // the real function at step 5b and orphan the rest of its body. + // The ranges come straight from `.pdata`, which the linker emits + // sorted and non-overlapping — the property the binary search needs. + // (Deriving them from `functions` instead would fold in the + // save/restore stub rows, whose `end` is a fixed block length rather + // than a pdata length and can therefore overlap a neighbour.) + let pdata_ranges: Vec<(u32, u32)> = pdata + .iter() + .filter(|e| e.function_length > 0) + .map(|e| (e.begin_address, e.end_address())) + .collect(); + debug_assert!(pdata_ranges.windows(2).all(|w| w[0].1 <= w[1].0)); + let interior: Vec = functions + .iter() + .filter(|(_, f)| !f.pdata_validated) + .map(|(&addr, _)| addr) + .filter(|&addr| { + pdata_ranges + .binary_search_by(|&(s, e)| { + if addr < s { std::cmp::Ordering::Greater } + else if addr >= e { std::cmp::Ordering::Less } + else { std::cmp::Ordering::Equal } + }) + .is_ok() + }) + .collect(); + let interior_dropped = interior.len(); + for addr in interior { + functions.remove(&addr); + } + + // 5b. Trim overlaps that remain. Only prologue-only rows are trimmed — + // a `.pdata` length is authoritative and must survive intact even + // when a neighbouring heuristic row disagrees. + let starts: Vec = functions.keys().copied().collect(); + for i in 0..starts.len().saturating_sub(1) { + let cur = starts[i]; + let next = starts[i + 1]; + if let Some(fi) = functions.get_mut(&cur) + && !fi.pdata_validated + && fi.end > next + { + fi.end = next; + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("analysis.phase_ms", "phase" => "functions").record(elapsed_ms); + let pdata_validated_count = functions.values().filter(|f| f.pdata_validated).count(); + tracing::info!( + functions = functions.len(), + pdata_entries = pdata.len(), + pdata_validated = pdata_validated_count, + interior_candidates_dropped = interior_dropped, + elapsed_ms, + "function detection complete" + ); + + FuncAnalysis { + functions, + save_gpr_base: save_base, + restore_gpr_base: restore_base, + pdata_entries: pdata.to_vec(), + } +} + +/// True when `[start, end)` contains any linking branch — `bl`, `bcl`, +/// `bctrl` or `bclrl`. Used to classify pdata-only entries as leaf or not. +fn range_has_call(pe: &[u8], image_base: u32, start: u32, end: u32) -> bool { + let mut addr = start; + while addr < end { + let Some(instr) = read_instr(pe, addr, image_base) else { return false }; + let opcode = op(instr); + // I-form / B-form with LK, and XL-form bclrl / bcctrl. + if (opcode == 18 || opcode == 16) && instr & 1 == 1 { + return true; + } + if opcode == 19 && instr & 1 == 1 && matches!(bits(instr, 30, 21), 16 | 528) { + return true; + } + addr = addr.wrapping_add(4); + } + false +} + +/// Analyze a single function starting at `func_addr`. +fn analyze_function( + pe: &[u8], + image_base: u32, + func_addr: u32, + code_ranges: &[(u32, u32)], + save_base: Option, + restore_base: Option, +) -> Option { + // Verify the address is within a code section + let in_code = code_ranges.iter().any(|&(s, e)| func_addr >= s && func_addr < e); + if !in_code { return None; } + + let instr0 = read_instr(pe, func_addr, image_base)?; + + let mut frame_size: u32 = 0; + let mut saved_gprs: u32 = 0; + let mut is_leaf = false; + let mut prologue_len: u32 = 0; + + // Pattern A: mfspr rN, LR [+ bl __savegprlr_NN] + stwu r1, -N(r1) + if let Some(_lr_reg) = is_mfspr_lr(instr0) { + prologue_len = 4; + let instr1 = read_instr(pe, func_addr + 4, image_base).unwrap_or(0); + + // Check if next is bl to save stub + if let Some(target) = bl_target(instr1, func_addr + 4) + && let Some(sb) = save_base + && target >= sb && target < sb + 18 * 4 { + let idx = (target - sb) / 4; + saved_gprs = 18 - idx; + prologue_len = 8; + } + + // Next should be stwu r1, -N(r1) + let stwu_instr = read_instr(pe, func_addr + prologue_len, image_base).unwrap_or(0); + if let Some(d) = is_stwu_r1(stwu_instr) { + frame_size = (-d) as u32; + prologue_len += 4; + } + } + // Pattern B: stwu r1, -N(r1) without mfspr (rare but possible for leaf-ish functions) + else if let Some(d) = is_stwu_r1(instr0) { + frame_size = (-d) as u32; + prologue_len = 4; + is_leaf = true; // no LR save = likely leaf (or uses CTR) + } + // Pattern C: no prologue — leaf function, just code until blr + else { + is_leaf = true; + } + + // Walk forward to find the end of the function + let max_range = code_ranges.iter() + .find(|&&(s, e)| func_addr >= s && func_addr < e) + .map(|&(_, e)| e) + .unwrap_or(func_addr + 0x100000); + + let mut end_addr = func_addr + 4; + let mut addr = func_addr + prologue_len; + let scan_limit = std::cmp::min(addr + 0x100000, max_range); // 1MB max function + + while addr < scan_limit { + let instr = match read_instr(pe, addr, image_base) { + Some(i) => i, + None => break, + }; + + // Epilogue: blr + if is_blr(instr) { + end_addr = addr + 4; + // Check if the instruction after blr looks like padding or another function + // Sometimes there's trailing data after blr; we stop at the first blr + // that isn't inside a branch-over pattern + break; + } + + // Epilogue: b __restgprlr_NN (tail branch into restore stub) + if let Some(target) = b_target(instr, addr) + && let Some(rb) = restore_base + && target >= rb && target < rb + 18 * 4 { + end_addr = addr + 4; + break; + } + + // Epilogue: bctr (indirect tail call — end of function) + if is_bctr(instr) { + end_addr = addr + 4; + break; + } + + addr += 4; + } + + // If we didn't find any epilogue within a reasonable range, still emit + // the function but mark end at the scan point + if end_addr <= func_addr + 4 && prologue_len > 0 { + end_addr = addr; + } + + // Don't emit zero-size "functions" for addresses that are just data + if end_addr <= func_addr + 4 && prologue_len == 0 { + return None; + } + + Some(FuncInfo { + start: func_addr, + end: end_addr, + frame_size, + saved_gprs, + is_leaf, + is_saverestore: false, + pdata_validated: false, + pdata_length: None, + pdata_prolog_length: None, + has_eh: false, + }) +} + +// ── Label generation ─────────────────────────────────────────────────────── + +impl FuncAnalysis { + /// Generate labels for all detected functions. + /// Call targets with confirmed prologues get `sub_XXXXXXXX`. + /// Save/restore entries get `__savegprlr_NN` / `__restgprlr_NN`. + pub fn generate_labels(&self) -> HashMap { + let mut labels = HashMap::new(); + + for (&addr, fi) in &self.functions { + if fi.is_saverestore { + // Label the block start, plus individual register entry points + if let Some(sb) = self.save_gpr_base + && addr == sb { + for i in 0u32..18 { + let reg = 14 + i; + labels.insert(sb + i * 4, format!("__savegprlr_{reg}")); + } + continue; + } + if let Some(rb) = self.restore_gpr_base + && addr == rb { + for i in 0u32..18 { + let reg = 14 + i; + labels.insert(rb + i * 4, format!("__restgprlr_{reg}")); + } + continue; + } + } + labels.insert(addr, format!("sub_{addr:08X}")); + } + + labels + } + + /// Returns true if `addr` is the start of a detected function. + pub fn is_function_start(&self, addr: u32) -> bool { + self.functions.contains_key(&addr) + } + + /// Get info for the function starting at `addr`. + pub fn get(&self, addr: u32) -> Option<&FuncInfo> { + self.functions.get(&addr) + } +} diff --git a/crates/sylpheed-xexdb/src/funcptr_arrays.rs b/crates/sylpheed-xexdb/src/funcptr_arrays.rs new file mode 100644 index 00000000..b6f9e7a3 --- /dev/null +++ b/crates/sylpheed-xexdb/src/funcptr_arrays.rs @@ -0,0 +1,257 @@ +//! Generic function-pointer array detection (M8 + M11). +//! +//! M3 already detects "vtable" candidates — runs of ≥3 contiguous function +//! pointers in `.rdata` / `.data` (with COL/RTTI walk on top). This module +//! widens the net: +//! +//! - **Dispatch tables** (M8): runs of ≥2 function pointers in `.rdata` / +//! `.data` that are NOT already classified as vtables. Captures switch +//! jump tables, callback registries, command tables, gameplay state +//! machines, etc. +//! - **Static initialiser tables** (M11): function-pointer arrays in +//! `.rdata` whose entries all have classic constructor-like prologues +//! (small frame; either leaf or calling well-known runtime helpers). +//! The MSVC convention names the bracketing symbols `__xc_a` / +//! `__xc_z` (C++ ctors) and `__xi_a` / `__xi_z` (C runtime), but the +//! names are stripped from Sylpheed; we classify by structure. +//! +//! All findings are written to a single `function_pointer_arrays` table +//! with a `kind` column — `"vtable"`, `"dispatch_table"`, or `"static_init"`. +//! Vtable rows are duplicated from M3's `vtables` table for join +//! convenience (so a single query covers all classification kinds). +//! +//! ### What this module does NOT do +//! +//! - No alias-based classification — `static_init` is heuristic and may +//! include any function-pointer array near the binary's `__xc_*` region. +//! - Does not parse the bracket symbols' actual addresses — we'd need +//! debug symbols, which Sylpheed doesn't ship. +//! - Two-element runs in `.data` are common false positives (struct fields +//! that happen to alias function entries); we only emit `dispatch_table` +//! rows for `.rdata`. + +use std::collections::BTreeSet; + +use sylpheed_xex::pe::PeSection; + +use crate::vtables::Vtable; + +/// One detected function-pointer array. +#[derive(Debug, Clone)] +pub struct FuncPtrArray { + pub address: u32, + pub length: u32, + pub kind: &'static str, // "vtable" | "dispatch_table" | "static_init" + /// Array entries (function VAs). + pub entries: Vec, +} + +/// Run the pass. `vtables` is the M3 result — those addresses are skipped +/// in the dispatch-table scan to avoid duplication. `function_starts` is +/// the M1 corrected function-start set (used to validate that each array +/// entry actually points at a known function). +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + function_starts: &BTreeSet, + vtables: &[Vtable], +) -> Vec { + let started = std::time::Instant::now(); + let vtable_addrs: BTreeSet = vtables.iter().map(|v| v.address).collect(); + let mut out: Vec = Vec::new(); + + // Re-emit vtables in this table for unified-query convenience. + for v in vtables { + out.push(FuncPtrArray { + address: v.address, + length: v.length, + kind: "vtable", + entries: v.methods.clone(), + }); + } + + // Scan only .rdata for dispatch tables — .data has too many false + // positives from struct fields aliasing function VAs. + for section in sections { + if section.name != ".rdata" { continue; } + let raw_start = section.virtual_address as usize; + let raw_end = (section.virtual_address + section.virtual_size) as usize; + if raw_end > pe.len() { continue; } + let bytes = &pe[raw_start..raw_end.min(pe.len())]; + let va_base = image_base + section.virtual_address; + + let mut i = 0usize; + while i + 8 <= bytes.len() { + if !i.is_multiple_of(4) { i += 1; continue; } + let mut entries: Vec = Vec::new(); + let mut j = i; + while j + 4 <= bytes.len() { + let val = u32::from_be_bytes([bytes[j], bytes[j + 1], bytes[j + 2], bytes[j + 3]]); + if function_starts.contains(&val) { + entries.push(val); + j += 4; + } else { + break; + } + } + if entries.len() >= 2 { + let address = va_base + (i as u32); + if !vtable_addrs.contains(&address) { + let kind = classify_run(image_base, &entries, pe); + out.push(FuncPtrArray { + address, + length: entries.len() as u32, + kind, + entries, + }); + } + i += j - i; + } else { + i += 4; + } + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + let n_vt = out.iter().filter(|a| a.kind == "vtable").count(); + let n_dt = out.iter().filter(|a| a.kind == "dispatch_table").count(); + let n_si = out.iter().filter(|a| a.kind == "static_init").count(); + metrics::histogram!("analysis.phase_ms", "phase" => "funcptr_arrays").record(elapsed_ms); + tracing::info!( + total = out.len(), vtable = n_vt, dispatch_table = n_dt, static_init = n_si, + elapsed_ms, + "function-pointer array scan complete", + ); + out +} + +/// Classify a non-vtable function-pointer array. Currently distinguishes +/// only "static_init" (all entries have constructor-like prologues — a +/// brief mfspr+stwu prologue with a small frame) from "dispatch_table" +/// (anything else). +fn classify_run(image_base: u32, entries: &[u32], pe: &[u8]) -> &'static str { + // Heuristic: a static initialiser's prologue is small (frame ≤ 0x80, + // typically ≤ 0x40). If every entry's first instruction is mfspr+LR + // (opcode 31, xo 339, spr 8) followed by a small stwu, classify as + // static_init. + let mut all_ctor = true; + let mut any_ctor = false; + for &fn_va in entries { + if !is_ctor_like(pe, image_base, fn_va) { + all_ctor = false; + } else { + any_ctor = true; + } + } + if all_ctor && any_ctor && entries.len() >= 3 { + "static_init" + } else { + "dispatch_table" + } +} + +/// True if the function at `fn_va` looks like a tiny C++ static initialiser: +/// `mfspr r12, LR` immediately followed by `stwu r1, -N(r1)` with `N ≤ 0x80`. +fn is_ctor_like(pe: &[u8], image_base: u32, fn_va: u32) -> bool { + let off = fn_va.wrapping_sub(image_base) as usize; + if off + 8 > pe.len() { return false; } + let i0 = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]); + let i1 = u32::from_be_bytes([pe[off + 4], pe[off + 5], pe[off + 6], pe[off + 7]]); + // i0: mfspr rD, LR — opcode 31, xo 339, spr 8. + let op0 = i0 >> 26; + let xo0 = (i0 >> 1) & 0x3FF; + let spr0 = (((i0 >> 11) & 0x1F) << 5) | ((i0 >> 16) & 0x1F); + if !(op0 == 31 && xo0 == 339 && spr0 == 8) { return false; } + // i1 must be stwu r1, -N(r1) with N ≤ 0x80, OR a `bl __savegprlr_*` + // followed eventually by stwu (full prologue). Allow either. + let op1 = i1 >> 26; + if op1 == 37 { + // stwu D-form: rS=1, rA=1 + let rs = (i1 >> 21) & 0x1F; + let ra = (i1 >> 16) & 0x1F; + let d = ((i1 & 0xFFFF) as i16) as i32; + rs == 1 && ra == 1 && d <= 0 && (-d) <= 0x80 + } else if op1 == 18 { + // bl __savegprlr_NN — accept; ctor with frame ≤ 0x80 is the + // common case, but if the compiler emits a save-stub call we + // can't easily verify the frame size without walking further. + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sylpheed_xex::pe::PeSection; + + fn mk_section(name: &str, va: u32, size: u32) -> PeSection { + PeSection { + name: name.into(), + virtual_address: va, + virtual_size: size, + raw_offset: va, + raw_size: size, + flags: 0x4000_0040, + } + } + + fn write_be_u32(buf: &mut [u8], at: usize, val: u32) { + buf[at..at + 4].copy_from_slice(&val.to_be_bytes()); + } + + #[test] + fn detects_dispatch_table_in_rdata() { + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let mut pe = vec![0u8; 0x4000]; + + // Two consecutive function pointers, no vtable shadowing them. + let pcs = [image_base + 0x2000, image_base + 0x2010]; + for (i, p) in pcs.iter().enumerate() { + write_be_u32(&mut pe, rdata_va as usize + i * 4, *p); + } + + let sections = vec![mk_section(".rdata", rdata_va, 0x100)]; + let mut starts = BTreeSet::new(); + for &p in &pcs { starts.insert(p); } + + let arrs = analyze(&pe, image_base, §ions, &starts, &[]); + assert_eq!(arrs.len(), 1); + assert_eq!(arrs[0].kind, "dispatch_table"); + assert_eq!(arrs[0].length, 2); + } + + #[test] + fn vtable_overrides_dispatch_classification() { + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let mut pe = vec![0u8; 0x4000]; + + let pcs = [image_base + 0x2000, image_base + 0x2010, image_base + 0x2020]; + for (i, p) in pcs.iter().enumerate() { + write_be_u32(&mut pe, rdata_va as usize + i * 4, *p); + } + let sections = vec![mk_section(".rdata", rdata_va, 0x100)]; + let mut starts = BTreeSet::new(); + for &p in &pcs { starts.insert(p); } + + let vt = Vtable { + address: image_base + rdata_va, + length: 3, + col_address: None, + class_name: "ANON_test".into(), + rtti_present: false, + base_classes_json: None, + methods: pcs.to_vec(), + }; + let arrs = analyze(&pe, image_base, §ions, &starts, &[vt]); + // Vtable + (no dispatch-table dup): the M3 vtable is re-emitted, but + // the scan also skips the same address from re-classification. + assert_eq!(arrs.len(), 1); + assert_eq!(arrs[0].kind, "vtable"); + } +} diff --git a/crates/sylpheed-xexdb/src/ind_dispatch_typed.rs b/crates/sylpheed-xexdb/src/ind_dispatch_typed.rs new file mode 100644 index 00000000..d19912cf --- /dev/null +++ b/crates/sylpheed-xexdb/src/ind_dispatch_typed.rs @@ -0,0 +1,711 @@ +//! M5.5 — `this`-flow indirect-dispatch resolution. +//! +//! M5 only resolved the canonical `lis+addi → lwz off(vt) → mtctr → bcctrl` +//! pattern (vtable address materialised statically; rare in real C++). +//! This layer closes the dominant case, where the dispatch reads through +//! the object's `vptr` field: +//! +//! ```text +//! lwz rVt, vptr_off(this) ; rVt = this->vptr +//! ... ; (rVt not clobbered) +//! lwz rFn, slot*4(rVt) ; rFn = vtable[slot] +//! ... ; (rFn / ctr not clobbered) +//! mtctr rFn +//! ... +//! bcctrl +//! ``` +//! +//! Resolution strategy (class-membership inference): +//! +//! 1. **Phase 1 — vptr-write scan.** Walk every function with a tiny +//! register tracker (mirrors the lis+addi propagation in +//! `sylpheed_xexdb::xref`). Whenever a `stw rA, off(rB)` writes a +//! known M3 vtable address into `off(rB)`, record +//! `(vtable_addr, vptr_offset, writer_pc)`. These are constructor- +//! side vptr stores. +//! +//! 2. **Phase 2 — invert by offset.** Build +//! `vtables_by_offset[vptr_off] = set of vtables ever written at +//! that offset`. Most classes use offset 0 (single inheritance); +//! multiple-inheritance secondary vptrs land at non-zero offsets. +//! +//! 3. **Phase 3 — dispatch-site scan.** For each `bcctrl`, walk back +//! up to 16 instructions looking for the canonical sequence, +//! extracting `(vptr_off, slot)`. Bail on any clobber of the +//! tracked register, on any branch instruction, or on a label +//! boundary. +//! +//! 4. **Phase 4 — emit edges.** For each detected +//! `(dispatch_pc, vptr_off, slot)`: +//! - Look up all candidate vtables `V` where: +//! - `vtables_by_offset[vptr_off]` contains `V`, AND +//! - `V.length > slot` +//! - Emit one `ind_call` edge from `dispatch_pc` to +//! `V.methods[slot]` per candidate. +//! +//! Multi-candidate sites are an over-approximation: the analysis can't +//! distinguish without alias info which of the matching classes the +//! `this` register actually holds. Downstream queries can filter by +//! the exposed `candidate_count` column — single-candidate edges are +//! high-confidence, multi-candidate edges are reachability-only. +//! +//! ### What this layer does NOT do +//! +//! - No flow-sensitive analysis: register state is killed at every +//! label (basic-block boundary), and we do not propagate values +//! across calls (since the ABI's volatile/non-volatile partition is +//! unreliable for `this`-pointer chains). +//! - No alias resolution: a multi-candidate site emits one edge per +//! matching vtable, not the exact one used at runtime. +//! - Does not handle vptr writes via X-form indexed stores (`stwx`) +//! or VMX/VMX128 stores — only D-form `stw rA, off(rB)`. The MSVC +//! compiler uses D-form for all canonical vptr writes we've seen. +//! - Does not synthesise vptr writes for inlined / elided constructors. +//! If a class never has a writer at offset `vptr_off`, dispatches +//! through that offset will not find candidates. +//! +//! Reference: IBM PowerPC ABI, Itanium C++ ABI on vtable layout (the +//! same offset-from-`this` model applies on Win32 PPC). + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use crate::func::FuncAnalysis; +use crate::vtables::Vtable; + +/// Default ceiling on how many candidates a single dispatch site may +/// materialise. See [`analyze`]. +pub const DEFAULT_MAX_CANDIDATES: usize = 16; + +/// One detected dispatch site after typed resolution. +#[derive(Debug, Clone)] +pub struct TypedDispatch { + pub dispatch_pc: u32, + pub vptr_offset: u32, + pub slot: u32, + /// Set of candidate vtable addresses whose `(vptr_offset, slot)` matched. + /// Empty when [`Self::truncated`] is set. + pub candidate_vtables: Vec, + /// Set of resolved method PCs (one per candidate vtable). + /// Empty when [`Self::truncated`] is set. + pub method_pcs: Vec, + /// How many candidates matched, whether or not they were materialised. + pub total_candidates: usize, + /// True when `total_candidates` exceeded the ceiling, so the per-candidate + /// vectors were dropped. The site itself is still reported. + pub truncated: bool, +} + +/// Result of the M5.5 pass. +#[derive(Debug, Default)] +pub struct TypedIndirectResult { + pub dispatches: Vec, + /// Phase-1 raw output, exposed for diagnostics. + pub vptr_writes: Vec, +} + +/// One detected constructor-side vptr write. +#[derive(Debug, Clone, Copy)] +pub struct VptrWrite { + pub vtable_addr: u32, + pub vptr_offset: u32, + pub writer_pc: u32, + pub writer_function: u32, +} + +const OP_ADDI: u32 = 14; +const OP_ADDIS: u32 = 15; +const OP_BCCTR: u32 = 19; +const OP_LWZ: u32 = 32; +const OP_ORI: u32 = 24; +const OP_STW: u32 = 36; +const OP_X_FORM: u32 = 31; + +/// Run the full M5.5 analysis. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + func_analysis: &FuncAnalysis, + vtables: &[Vtable], + labels: &HashMap, + max_candidates: usize, +) -> TypedIndirectResult { + let started = std::time::Instant::now(); + + let vtable_addrs: BTreeSet = vtables.iter().map(|v| v.address).collect(); + let vtable_by_addr: BTreeMap = + vtables.iter().map(|v| (v.address, v)).collect(); + + let block_boundaries: HashSet = labels.keys().copied().collect(); + + // Phase 1: scan for vptr writes. + let vptr_writes = scan_vptr_writes( + pe, image_base, func_analysis, &vtable_addrs, &block_boundaries, + ); + + // Phase 2: invert by offset. + let mut vtables_by_offset: HashMap> = HashMap::new(); + for w in &vptr_writes { + vtables_by_offset.entry(w.vptr_offset).or_default().insert(w.vtable_addr); + } + + // Phase 3 + 4: scan dispatches and emit edges. + let mut dispatches = scan_dispatches_and_resolve( + pe, image_base, func_analysis, &block_boundaries, + &vtables_by_offset, &vtable_by_addr, + ); + + // Drop the per-candidate lists for sites the analysis could not narrow. + // + // A site is resolved by matching `(vptr_offset, slot)` against every class + // seen installing a vtable at that offset. When the offset is 0 — a + // single-inheritance `this->vptr` — that matches essentially every class in + // the binary, so the "resolution" degenerates into a cross product: on the + // reference title 6,556 of 6,983 sites produced 1.80M of the 1.81M + // candidate rows, one site claiming 764 different callees. Those rows are + // not evidence about the callee, and they swamped `xrefs` (84% of it) and + // dominated the database file. + // + // The site row is still emitted with a truthful `total_candidates`, so + // "this is an unresolved virtual call with N possibilities" remains + // queryable — only the meaningless enumeration is dropped. + let mut truncated = 0usize; + for d in &mut dispatches { + if d.total_candidates > max_candidates { + d.candidate_vtables.clear(); + d.method_pcs.clear(); + d.truncated = true; + truncated += 1; + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + let single_candidate = dispatches.iter().filter(|d| d.total_candidates == 1).count(); + let multi_candidate = dispatches.len() - single_candidate; + let total_edges: usize = dispatches.iter().map(|d| d.method_pcs.len()).sum(); + metrics::histogram!("analysis.phase_ms", "phase" => "ind_dispatch_typed").record(elapsed_ms); + tracing::info!( + vptr_writes = vptr_writes.len(), + offsets = vtables_by_offset.len(), + dispatches = dispatches.len(), + single = single_candidate, + multi = multi_candidate, + truncated, + max_candidates, + edges = total_edges, + elapsed_ms, + "M5.5 typed indirect-dispatch scan complete", + ); + + TypedIndirectResult { dispatches, vptr_writes } +} + +fn read_instr(pe: &[u8], image_base: u32, addr: u32) -> Option { + let off = addr.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) +} + +/// Phase 1 — find every `stw rA, off(rB)` where the lis+addi-tracked +/// value of `rA` equals a known vtable address. +fn scan_vptr_writes( + pe: &[u8], + image_base: u32, + func_analysis: &FuncAnalysis, + vtable_addrs: &BTreeSet, + block_boundaries: &HashSet, +) -> Vec { + let mut writes: Vec = Vec::new(); + for (&fn_start, fi) in &func_analysis.functions { + if fi.is_saverestore { continue; } + let mut reg: [Option; 32] = [None; 32]; + let mut pc = fn_start; + while pc < fi.end { + if pc != fn_start && block_boundaries.contains(&pc) { + reg = [None; 32]; + } + let Some(instr) = read_instr(pe, image_base, pc) else { break }; + let op = instr >> 26; + let rd = ((instr >> 21) & 0x1F) as usize; + let ra = ((instr >> 16) & 0x1F) as usize; + let simm = ((instr & 0xFFFF) as i16) as i32; + let uimm = instr & 0xFFFF; + match op { + OP_ADDIS if ra == 0 => reg[rd] = Some(uimm << 16), + OP_ADDIS => { + reg[rd] = reg[ra].map(|b| b.wrapping_add(uimm << 16)); + } + OP_ADDI if ra != 0 => { + reg[rd] = reg[ra].map(|b| b.wrapping_add(simm as u32)); + } + OP_ADDI => reg[rd] = Some(simm as u32), + OP_ORI => { + let rs = rd; + reg[ra] = reg[rs].map(|b| b | uimm); + } + OP_STW => { + // `stw rS, off(rA)` — rS in bits 21..25, rA in 16..20. + if ra != 0 + && let Some(vtable_addr) = reg[rd] + && vtable_addrs.contains(&vtable_addr) + { + // The vptr offset is the displacement; rB's value + // is irrelevant for class-membership inference. + writes.push(VptrWrite { + vtable_addr, + vptr_offset: simm as u32, + writer_pc: pc, + writer_function: fn_start, + }); + } + // stw doesn't write to rD. + } + OP_LWZ => reg[rd] = None, + 32..=35 | 40..=43 | 48..=51 => reg[rd] = None, + OP_X_FORM => { + let xo = (instr >> 1) & 0x3FF; + if xo != 444 && xo != 467 { reg[rd] = None; } + } + 18 => { + // `bl` (LK=1) clobbers volatile r0..r12 + ctr. Plain + // `b` makes the next instruction unreachable; the + // label-based reset handles join points. + if (instr & 1) != 0 { + for r in 0..=12 { reg[r] = None; } + } + } + 16 => { + if (instr & 1) != 0 { + for r in 0..=12 { reg[r] = None; } + } + } + _ => {} + } + pc = pc.wrapping_add(4); + } + } + writes +} + +/// Phase 3 + 4 — scan every `bcctrl`/`bctr` instruction; for each, walk +/// backward up to 16 instructions to find the canonical +/// `lwz vt, vptr_off(this); lwz fn, slot(vt); mtctr fn; bcctrl` sequence. +/// Emit one `TypedDispatch` per dispatch site that resolves to ≥ 1 +/// candidate vtable. +fn scan_dispatches_and_resolve( + pe: &[u8], + image_base: u32, + func_analysis: &FuncAnalysis, + block_boundaries: &HashSet, + vtables_by_offset: &HashMap>, + vtable_by_addr: &BTreeMap, +) -> Vec { + let mut out: Vec = Vec::new(); + for (&fn_start, fi) in &func_analysis.functions { + if fi.is_saverestore { continue; } + let mut pc = fn_start; + while pc < fi.end { + let Some(instr) = read_instr(pe, image_base, pc) else { break }; + let op = instr >> 26; + if op == OP_BCCTR { + let xo = (instr >> 1) & 0x3FF; + let lk = (instr & 1) != 0; + if xo == 528 && lk + && let Some(d) = try_resolve_dispatch_site( + pe, image_base, fn_start, fi.end, pc, + block_boundaries, vtables_by_offset, vtable_by_addr, + ) + { + out.push(d); + } + } + pc = pc.wrapping_add(4); + } + } + out +} + +/// Backwards scan from `bcctrl` at `pc` (looking back at most 16 instrs +/// within the same basic block). Returns `Some(_)` only when the full +/// `lwz vt, off(rA); lwz fn, slot(vt); mtctr fn` chain is present and the +/// `(vptr_off, slot)` pair has at least one candidate vtable. +fn try_resolve_dispatch_site( + pe: &[u8], + image_base: u32, + fn_start: u32, + _fn_end: u32, + bcctrl_pc: u32, + block_boundaries: &HashSet, + vtables_by_offset: &HashMap>, + vtable_by_addr: &BTreeMap, +) -> Option { + const LOOKBACK: u32 = 16; + + // Walk back 1..LOOKBACK instrs to find `mtctr rFn`. + let mut mtctr_rs: Option = None; + let mut mtctr_pc: Option = None; + for i in 1..=LOOKBACK { + let p = bcctrl_pc.wrapping_sub(i * 4); + if p < fn_start { break; } + if block_boundaries.contains(&p) { break; } + let Some(instr) = read_instr(pe, image_base, p) else { break }; + let op = instr >> 26; + if op == OP_X_FORM { + let xo = (instr >> 1) & 0x3FF; + if xo == 467 { + let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F); + if spr == 9 { + mtctr_rs = Some(((instr >> 21) & 0x1F) as usize); + mtctr_pc = Some(p); + break; + } + } + } + } + let mtctr_rs = mtctr_rs?; + let mtctr_pc = mtctr_pc?; + + // Walk back from mtctr to find `lwz rFn, slot(rVt)` defining mtctr_rs. + let mut slot: Option = None; + let mut vt_reg: Option = None; + let mut fn_lwz_pc: Option = None; + for i in 1..=LOOKBACK { + let p = mtctr_pc.wrapping_sub(i * 4); + if p < fn_start { break; } + if block_boundaries.contains(&p) { break; } + let Some(instr) = read_instr(pe, image_base, p) else { break }; + let op = instr >> 26; + let rd = ((instr >> 21) & 0x1F) as usize; + if op == OP_LWZ { + if rd == mtctr_rs { + let ra = ((instr >> 16) & 0x1F) as usize; + if ra == 0 { return None; } + let off = ((instr & 0xFFFF) as i16) as i32; + if off < 0 || (off % 4) != 0 { return None; } + slot = Some((off as u32) / 4); + vt_reg = Some(ra); + fn_lwz_pc = Some(p); + break; + } + // Other lwz; if it writes our target reg, it's a clobber, but + // the loop already keys on the lwz that produces the value, so + // no clobber check needed beyond seeing rd == mtctr_rs. + } else if writes_reg(instr, mtctr_rs as u32) { + return None; + } + } + let slot = slot?; + let vt_reg = vt_reg?; + let fn_lwz_pc = fn_lwz_pc?; + + // Walk back from the fn-lwz to find `lwz rVt, vptr_off(rThis)` defining vt_reg. + let mut vptr_off: Option = None; + for i in 1..=LOOKBACK { + let p = fn_lwz_pc.wrapping_sub(i * 4); + if p < fn_start { break; } + if block_boundaries.contains(&p) { break; } + let Some(instr) = read_instr(pe, image_base, p) else { break }; + let op = instr >> 26; + let rd = ((instr >> 21) & 0x1F) as usize; + if op == OP_LWZ && rd == vt_reg { + let ra = ((instr >> 16) & 0x1F) as usize; + if ra == 0 { return None; } + let off = ((instr & 0xFFFF) as i16) as i32; + // Negative offsets are valid in C++ (multiple inheritance casts + // can produce them in some ABIs); reinterpret as u32 wrap. + vptr_off = Some(off as u32); + break; + } + if writes_reg(instr, vt_reg as u32) { + return None; + } + } + let vptr_off = vptr_off?; + + // Phase 4 — resolve to candidate vtables. + let candidates = vtables_by_offset.get(&vptr_off)?; + let mut candidate_vtables: Vec = Vec::new(); + let mut method_pcs: Vec = Vec::new(); + for &vt_addr in candidates { + if let Some(vt) = vtable_by_addr.get(&vt_addr) + && vt.length > slot + && let Some(&method_pc) = vt.methods.get(slot as usize) + { + candidate_vtables.push(vt_addr); + method_pcs.push(method_pc); + } + } + if method_pcs.is_empty() { return None; } + let total_candidates = candidate_vtables.len(); + + Some(TypedDispatch { + dispatch_pc: bcctrl_pc, + vptr_offset: vptr_off, + slot, + candidate_vtables, + method_pcs, + total_candidates, + truncated: false, + }) +} + +/// Conservative "does this instruction write to register `r`" predicate. +/// Used to detect register clobbers between the value-producing lwz and +/// its consumer. +fn writes_reg(instr: u32, r: u32) -> bool { + let op = instr >> 26; + let rd = (instr >> 21) & 0x1F; + let _ra = (instr >> 16) & 0x1F; + match op { + // Most arithmetic / load opcodes use bits 21..25 = rD/rT. + 14 | 15 | 32..=43 | 46 | 48..=51 => rd == r, + // ori/oris/xor/etc. opcodes 24..29 — rA in bits 16..20 is the dest. + 24 | 25 | 26 | 27 | 28 | 29 => ((instr >> 16) & 0x1F) == r, + // X-form: most write rD; some write rA. Check both, conservatively. + OP_X_FORM => { + let xo = (instr >> 1) & 0x3FF; + // Logical X-form (and/or/xor/etc.): rA is the dest. + // Logical X-form ops (and/or/xor/etc.) write rA, not rD. + if matches!(xo, 26 | 28 | 60 | 124 | 284 | 316 | 444 | 476 | 536 | 539 | 922 | 954) { + ((instr >> 16) & 0x1F) == r + } else { + rd == r + } + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::func::FuncInfo; + use std::collections::BTreeMap; + + fn mk_vtable(addr: u32, methods: Vec) -> Vtable { + Vtable { + address: addr, + length: methods.len() as u32, + col_address: None, + class_name: format!("ANON_{addr:08X}"), + rtti_present: false, + base_classes_json: None, + methods, + } + } + + fn mk_func_analysis(start: u32, len: u32) -> FuncAnalysis { + let mut functions: BTreeMap = BTreeMap::new(); + functions.insert(start, FuncInfo { + start, + end: start + len, + frame_size: 0, + saved_gprs: 0, + is_leaf: false, + is_saverestore: false, + pdata_validated: false, + pdata_length: None, + pdata_prolog_length: None, + has_eh: false, + }); + FuncAnalysis { functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new() } + } + + fn write_be(pe: &mut [u8], at: usize, v: u32) { + pe[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + + /// Encode a vptr-write site: `lis rN, hi(vt); addi rN, rN, lo(vt); stw rN, off(rOther)`. + fn enc_vptr_write(pe: &mut [u8], at: usize, vt: u32, write_off: i16, dest_reg: u32) { + let hi = (vt >> 16) as u16; + let lo = (vt & 0xFFFF) as i16; + let lis = (15u32 << 26) | (3 << 21) | 0 << 16 | (hi as u32); + let addi = (14u32 << 26) | (3 << 21) | (3 << 16) | ((lo as u16) as u32); + let stw = (36u32 << 26) | (3 << 21) | (dest_reg << 16) | ((write_off as u16) as u32); + write_be(pe, at, lis); + write_be(pe, at + 4, addi); + write_be(pe, at + 8, stw); + } + + /// Encode a dispatch site: + /// lwz r4, vptr_off(r3) ; r4 = this->vptr + /// lwz r5, slot*4(r4) ; r5 = vptr[slot] + /// mtctr r5 + /// bcctrl + fn enc_dispatch(pe: &mut [u8], at: usize, vptr_off: i16, slot: u32) { + let lwz_vt = (32u32 << 26) | (4 << 21) | (3 << 16) | ((vptr_off as u16) as u32); + let lwz_fn = (32u32 << 26) | (5 << 21) | (4 << 16) | ((slot * 4) & 0xFFFF); + // mtctr r5 = mtspr CTR(=9), r5: SPR_low (=9) → bits 16..20. + let mtctr = (31u32 << 26) | (5 << 21) | (9 << 16) | (467 << 1); + let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1; + write_be(pe, at, lwz_vt); + write_be(pe, at + 4, lwz_fn); + write_be(pe, at + 8, mtctr); + write_be(pe, at + 12, bcctrl); + } + + #[test] + fn single_candidate_vtable_resolves_to_one_method() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x4000]; + + // Function A — constructor — at 0x82001000. Writes vt=0x82010000 at off=0. + let ctor_pc = 0x82001000u32; + enc_vptr_write(&mut pe, (ctor_pc - image_base) as usize, 0x82010000, 0, 31); + + // Function B — dispatcher — at 0x82002000. Calls slot 2 of vptr at off 0. + let disp_pc = 0x82002000u32; + enc_dispatch(&mut pe, (disp_pc - image_base) as usize, 0, 2); + let bcctrl_pc = disp_pc + 12; + + // Both functions in func_analysis (synthesise). + let mut fa = mk_func_analysis(ctor_pc, 0x40); + fa.functions.insert(disp_pc, FuncInfo { + start: disp_pc, end: disp_pc + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: false, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + + let vt = mk_vtable(0x82010000, vec![0xAA, 0xBB, 0xCC, 0xDD]); + let labels: HashMap = HashMap::new(); + let r = analyze(&pe, image_base, &fa, &[vt], &labels, usize::MAX); + + assert_eq!(r.vptr_writes.len(), 1); + assert_eq!(r.vptr_writes[0].vtable_addr, 0x82010000); + assert_eq!(r.vptr_writes[0].vptr_offset, 0); + + assert_eq!(r.dispatches.len(), 1); + let d = &r.dispatches[0]; + assert_eq!(d.dispatch_pc, bcctrl_pc); + assert_eq!(d.vptr_offset, 0); + assert_eq!(d.slot, 2); + assert_eq!(d.method_pcs, vec![0xCC]); + assert_eq!(d.candidate_vtables, vec![0x82010000]); + } + + /// Two classes installing different vtables at offset 0, and one dispatch + /// at slot 1 that therefore matches both. + fn multi_candidate_fixture(image_base: u32) + -> (Vec, FuncAnalysis, Vec, HashMap) + { + let mut pe = vec![0u8; 0x4000]; + + // Two ctors, each writing a different vtable at offset 0. + let ctor_a = 0x82001000u32; + enc_vptr_write(&mut pe, (ctor_a - image_base) as usize, 0x82010000, 0, 31); + let ctor_b = 0x82001100u32; + enc_vptr_write(&mut pe, (ctor_b - image_base) as usize, 0x82010040, 0, 31); + + // One dispatch at slot 1. + let disp = 0x82002000u32; + enc_dispatch(&mut pe, (disp - image_base) as usize, 0, 1); + + let mut fa = mk_func_analysis(ctor_a, 0x40); + fa.functions.insert(ctor_b, FuncInfo { + start: ctor_b, end: ctor_b + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: false, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + fa.functions.insert(disp, FuncInfo { + start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: false, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + + let vts = vec![ + mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]), + mk_vtable(0x82010040, vec![0x55, 0x66, 0x77, 0x88]), + ]; + let labels: HashMap = HashMap::new(); + (pe, fa, vts, labels) + } + + #[test] + fn multi_candidate_emits_one_edge_per_match() { + let image_base = 0x82000000u32; + let (pe, fa, vts, labels) = multi_candidate_fixture(image_base); + let r = analyze(&pe, image_base, &fa, &vts, &labels, usize::MAX); + + assert_eq!(r.vptr_writes.len(), 2); + assert_eq!(r.dispatches.len(), 1); + let d = &r.dispatches[0]; + assert_eq!(d.candidate_vtables.len(), 2); + assert!(d.method_pcs.contains(&0x22)); + assert!(d.method_pcs.contains(&0x66)); + } + + #[test] + fn out_of_bounds_slot_yields_no_dispatch() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x4000]; + + let ctor = 0x82001000u32; + enc_vptr_write(&mut pe, (ctor - image_base) as usize, 0x82010000, 0, 31); + + let disp = 0x82002000u32; + // slot 10 — vtable only has 4 methods. + enc_dispatch(&mut pe, (disp - image_base) as usize, 0, 10); + + let mut fa = mk_func_analysis(ctor, 0x40); + fa.functions.insert(disp, FuncInfo { + start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: false, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + + let vt = mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]); + let labels: HashMap = HashMap::new(); + let r = analyze(&pe, image_base, &fa, &[vt], &labels, usize::MAX); + assert_eq!(r.dispatches.len(), 0); + } + + #[test] + fn no_writer_at_offset_yields_no_dispatch() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x4000]; + + // ctor writes at offset 0 + let ctor = 0x82001000u32; + enc_vptr_write(&mut pe, (ctor - image_base) as usize, 0x82010000, 0, 31); + + // dispatch reads from offset 8 — no class writes vptr there. + let disp = 0x82002000u32; + enc_dispatch(&mut pe, (disp - image_base) as usize, 8, 1); + + let mut fa = mk_func_analysis(ctor, 0x40); + fa.functions.insert(disp, FuncInfo { + start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: false, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + + let vt = mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]); + let labels: HashMap = HashMap::new(); + let r = analyze(&pe, image_base, &fa, &[vt], &labels, usize::MAX); + assert_eq!(r.dispatches.len(), 0); + } + /// A site the resolver cannot narrow keeps its row and its true count, but + /// stops enumerating: those rows were 84% of `xrefs` and carried no + /// evidence about the callee. + #[test] + fn ceiling_truncates_unresolved_sites_without_losing_them() { + let image_base = 0x82000000u32; + let (pe, fa, vts, labels) = multi_candidate_fixture(image_base); + + let unbounded = analyze(&pe, image_base, &fa, &vts, &labels, usize::MAX); + let d = &unbounded.dispatches[0]; + assert_eq!(d.total_candidates, 2); + assert_eq!(d.method_pcs.len(), 2); + assert!(!d.truncated); + + // Same binary, ceiling of 1: the site survives, the enumeration does not. + let bounded = analyze(&pe, image_base, &fa, &vts, &labels, 1); + let d = &bounded.dispatches[0]; + assert_eq!(bounded.dispatches.len(), unbounded.dispatches.len()); + assert!(d.truncated); + assert_eq!(d.total_candidates, 2, "count stays truthful"); + assert!(d.method_pcs.is_empty(), "no speculative edges"); + assert!(d.candidate_vtables.is_empty()); + } + +} diff --git a/crates/sylpheed-xexdb/src/indirect.rs b/crates/sylpheed-xexdb/src/indirect.rs new file mode 100644 index 00000000..970156a6 --- /dev/null +++ b/crates/sylpheed-xexdb/src/indirect.rs @@ -0,0 +1,474 @@ +//! Indirect-dispatch reachability for vtable-bound `bcctrl`/`bctrl` sites. +//! +//! Walks each detected function with a tiny per-basic-block register tracker, +//! recognising the canonical MSVC PowerPC pattern that loads a slot from a +//! statically-addressed vtable into CTR and indirectly calls it: +//! +//! ```text +//! lis rA, hi +//! addi rA, rA, lo ; rA = vtable_address +//! lwz rB, slot*4(rA) ; rB = vtable[slot] +//! mtctr rB ; CTR = vtable[slot] +//! bcctrl ; indirect call → vtable[slot] +//! ``` +//! +//! Pattern hits are emitted as `(source_pc, target_pc)` pairs that callers +//! insert into the `xrefs` table with `kind='ind_call'`. +//! +//! ### What this does NOT cover +//! +//! - Vtable pointer loaded from a `this`-pointer field (`lwz rA, off(this)`) +//! is the dominant pattern in real C++ code; resolving it requires +//! alias / points-to analysis that's far beyond this layer's scope. +//! - Indirect calls via function-pointer fields (callbacks) are similarly +//! unresolvable without object-flow analysis. +//! - Register state is intentionally killed at every label (basic-block +//! boundary) — we don't try to do flow-sensitive merging across joins. +//! +//! Reference: IBM PowerPC ABI on register-save convention, plus the +//! `sylpheed_xexdb::xref` `lis+addi`/`lis+ori` tracker which we mirror +//! conceptually. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::func::FuncAnalysis; +use crate::vtables::Vtable; + +/// One detected indirect-call edge: `bcctrl` at `source` jumps to `target`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndirectEdge { + pub source: u32, + pub target: u32, + /// Vtable the source resolved through. + pub via_vtable: u32, + /// Method slot index within the vtable. + pub slot: u32, +} + +#[derive(Debug, Clone, Copy)] +enum RegVal { + /// Register holds a known constant (e.g. after `lis+addi`). + Const(u32), + /// Register holds a method pointer loaded from a known vtable slot. + MethodPtr { + vtable_addr: u32, + slot: u32, + method_pc: u32, + }, +} + +const OP_ADDI: u32 = 14; +const OP_ADDIS: u32 = 15; +const OP_BCCTR: u32 = 19; // also covers blr — distinguish via XO +const OP_LWZ: u32 = 32; +const OP_ORI: u32 = 24; +const OP_X_FORM: u32 = 31; // mtspr / mr / etc. + +/// Run the static indirect-dispatch scan. Returns one edge per resolvable +/// `bcctrl` site. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + func_analysis: &FuncAnalysis, + vtables: &[Vtable], + labels: &HashMap, +) -> Vec { + let started = std::time::Instant::now(); + // Index vtables by their start VA so the lwz handler can decide + // whether a given Const(addr) is "really" a vtable. + let vtable_by_addr: BTreeMap = + vtables.iter().map(|v| (v.address, v)).collect(); + + // Set of all "label"-bearing PCs in the analyzed binary. We treat each + // label as a basic-block boundary (anything `loc_*` is a jump target, + // so register state arriving at it is unreliable). + let mut block_boundaries: HashSet = HashSet::with_capacity(labels.len()); + for &addr in labels.keys() { + block_boundaries.insert(addr); + } + + let mut edges: Vec = Vec::new(); + + for (&fn_start, fi) in &func_analysis.functions { + if fi.is_saverestore { continue; } + let mut reg: [Option; 32] = [None; 32]; + let mut ctr: Option = None; + let mut pc = fn_start; + while pc < fi.end { + // Reset register state on basic-block entry. We don't reset on + // the function entry itself (PC == fn_start) because labels and + // function-starts coincide; the initial state is already None. + if pc != fn_start && block_boundaries.contains(&pc) { + reg = [None; 32]; + ctr = None; + } + + let instr = match read_instr(pe, image_base, pc) { + Some(i) => i, + None => break, + }; + + let op = instr >> 26; + let rd = ((instr >> 21) & 0x1F) as usize; + let ra = ((instr >> 16) & 0x1F) as usize; + let simm = ((instr & 0xFFFF) as i16) as i32; + let uimm = instr & 0xFFFF; + + match op { + // lis rD, IMM (== addis rD, r0, IMM) + OP_ADDIS if ra == 0 => { + reg[rd] = Some(RegVal::Const(uimm << 16)); + } + // addis rD, rA, IMM + OP_ADDIS => { + if let Some(RegVal::Const(b)) = reg[ra] { + reg[rd] = Some(RegVal::Const(b.wrapping_add(uimm << 16))); + } else { + reg[rd] = None; + } + } + // addi rD, rA, IMM + OP_ADDI if ra != 0 => { + if let Some(RegVal::Const(b)) = reg[ra] { + reg[rd] = Some(RegVal::Const(b.wrapping_add(simm as u32))); + } else { + reg[rd] = None; + } + } + // li rD, IMM (== addi rD, 0, IMM) + OP_ADDI => { + reg[rd] = Some(RegVal::Const(simm as u32)); + } + // ori rA, rS, IMM — note operand order: bits 21..25 = rS, 16..20 = rA + OP_ORI => { + let rs = rd; // bits 21..25 = source + if let Some(RegVal::Const(b)) = reg[rs] { + reg[ra] = Some(RegVal::Const(b | uimm)); + } else { + reg[ra] = None; + } + } + // lwz rD, off(rA) — try to resolve as vtable slot load. + OP_LWZ => { + if ra != 0 + && let Some(RegVal::Const(base)) = reg[ra] + { + let target = base.wrapping_add(simm as u32); + // Two-step lookup so we accept both: + // (a) base = exact vtable head, simm/4 = slot + // (b) base + simm = exact vtable head (rare; + // compiler hoists the slot offset into addi) + let resolved = resolve_vtable_slot(target, &vtable_by_addr) + .or_else(|| resolve_vtable_slot_via_off(base, simm, &vtable_by_addr)); + reg[rd] = resolved.map(|(vt, slot, pc)| RegVal::MethodPtr { + vtable_addr: vt, slot, method_pc: pc, + }); + } else { + reg[rd] = None; + } + } + // X-form: mtspr/mtctr, bcctrl, mr, etc. + OP_X_FORM => { + let xo = (instr >> 1) & 0x3FF; + match xo { + 467 => { + // mtspr SPR, rS — PPC SPR field is split: high 5 bits + // in PPC bits 16:20 (= Rust bits 11..15), low 5 bits + // in PPC bits 11:15 (= Rust bits 16..20). Mirrors + // the convention in `func.rs::is_mfspr_lr`. + let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F); + if spr == 9 { + ctr = reg[rd]; + } + // Otherwise no observable effect on tracked state. + } + // Anything that writes rD (most arithmetic, loads, etc.) clobbers it. + // Conservative: invalidate rD on any X-form that has rD in bits 21..25 + // and is NOT a comparison or branch. + _ => { + // Heuristic: most X-form ops with non-zero RC encode rD; we + // invalidate to avoid stale Const propagation past arithmetic. + // This is over-eager but safe (false negatives on edges, never + // false positives). + reg[rd] = None; + } + } + } + // bcctr/bcctrl — opcode 19, XO=528. LK in low bit. + OP_BCCTR => { + let xo = (instr >> 1) & 0x3FF; + if xo == 528 { + let lk = (instr & 1) != 0; + if lk + && let Some(RegVal::MethodPtr { vtable_addr, slot, method_pc }) = ctr + { + edges.push(IndirectEdge { + source: pc, + target: method_pc, + via_vtable: vtable_addr, + slot, + }); + } + // After the call, CTR is preserved but rD register + // values across the call boundary are not trustworthy. + // Don't touch reg state — most ABIs preserve only + // some regs anyway. + } + } + // op 18: b / bl / ba / bla. LK=1 is a call; LK=0 is an + // unconditional branch with no fall-through (next PC is + // reached only via a different basic block, which the + // label-based reset already handles). On a call, the + // PowerPC ABI marks r0..r12 + ctr as volatile and + // r13..r31 as non-volatile (callee-saved); preserve the + // non-volatile half so vtable pointers loaded into r30/r31 + // before a `bl` survive the call. + 18 => { + let lk = (instr & 1) != 0; + if lk { + for r in 0..=12 { reg[r] = None; } + ctr = None; + } + // LK=0 (`b`) makes fall-through unreachable; nothing to do — + // any next reachable PC will hit a label boundary. + } + // Conditional branches (op 16) fall through; preserve all reg + // state for the fall-through path. The label-based join-point + // invalidation bounds false-positive risk for jump-IN paths. + 16 => { + let lk = (instr & 1) != 0; + if lk { + for r in 0..=12 { reg[r] = None; } + ctr = None; + } + } + // Stores and loads we don't track explicitly clobber rD only + // when rD is on the destination side; the conservative rule + // is "any non-recognised opcode that may write rD invalidates it". + 36..=55 => { + // Loads write rD; stores don't. The safe pessimisation is + // to invalidate rD for the load family (32..=35, 40..=43, etc.) + // and leave it alone for stores. We've already handled lwz + // above; for the rest, invalidate rD. + if matches!(op, 32..=35 | 40..=43 | 48..=51) { + reg[rd] = None; + } + } + _ => {} + } + + pc = pc.wrapping_add(4); + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("analysis.phase_ms", "phase" => "indirect").record(elapsed_ms); + tracing::info!( + edges = edges.len(), + elapsed_ms, + "indirect-dispatch scan complete" + ); + edges +} + +fn read_instr(pe: &[u8], image_base: u32, addr: u32) -> Option { + let off = addr.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) +} + +/// `target = base + simm` where `target` is an exact vtable head (rare, +/// compiler hoists the slot offset into the addi). +fn resolve_vtable_slot_via_off( + base: u32, + simm: i32, + vtable_by_addr: &BTreeMap, +) -> Option<(u32, u32, u32)> { + let target = base.wrapping_add(simm as u32); + if let Some(v) = vtable_by_addr.get(&target) + && !v.methods.is_empty() + { + return Some((v.address, 0, v.methods[0])); + } + None +} + +/// `target` is an absolute address. If it falls inside a known vtable's +/// `[address, address + length*4)` range AND is 4-aligned to a slot, +/// return `(vtable_addr, slot, method_pc)`. +fn resolve_vtable_slot( + target: u32, + vtable_by_addr: &BTreeMap, +) -> Option<(u32, u32, u32)> { + // BTreeMap range search for the largest key ≤ target. + let (&vt_addr, vt) = vtable_by_addr.range(..=target).next_back()?; + if target < vt_addr { return None; } + let off = target - vt_addr; + if !off.is_multiple_of(4) { return None; } + let slot = off / 4; + if slot >= vt.length { return None; } + let method_pc = *vt.methods.get(slot as usize)?; + Some((vt_addr, slot, method_pc)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::func::FuncInfo; + use std::collections::BTreeMap; + + fn mk_vtable(addr: u32, methods: Vec) -> Vtable { + Vtable { + address: addr, + length: methods.len() as u32, + col_address: None, + class_name: "ANON_test".into(), + rtti_present: false, + base_classes_json: None, + methods, + } + } + + /// Encode the canonical pattern at PC `start`: + /// lis r3, hi + /// addi r3, r3, lo ; r3 = vtable_addr + /// lwz r4, slot*4(r3) ; r4 = vtable[slot] + /// mtctr r4 + /// bcctrl + fn encode_pattern(buf: &mut [u8], offset: usize, vtable_addr: u32, slot_off: i32) { + let hi = (vtable_addr >> 16) as u16; + let lo = (vtable_addr & 0xFFFF) as i16; + let lis = (15u32 << 26) | (3 << 21) | (0 << 16) | (hi as u32); + // addi r3, r3, lo (signed) — note: addi is treated as signed + let addi = (14u32 << 26) | (3 << 21) | (3 << 16) | ((lo as u16) as u32); + let lwz = (32u32 << 26) | (4 << 21) | (3 << 16) | ((slot_off as u16) as u32); + // mtctr r4 = mtspr CTR(=9), r4. SPR_low (=9) → Rust bits 16-20; + // SPR_high (=0) → Rust bits 11-15. Rc bit 0. + let mtctr = (31u32 << 26) | (4 << 21) | (9 << 16) | (0 << 11) | (467 << 1); + let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1; // bcctrl 20, 0 + let words = [lis, addi, lwz, mtctr, bcctrl]; + for (i, w) in words.iter().enumerate() { + buf[offset + i * 4..offset + i * 4 + 4].copy_from_slice(&w.to_be_bytes()); + } + } + + #[test] + fn detects_canonical_lis_addi_lwz_mtctr_bcctrl() { + let image_base = 0x82000000u32; + let text_va = 0x1000u32; + let pc_start = image_base + text_va; + let vtable_addr = 0x82010000u32; + + // PE: just the .text we'll write the pattern into. + let mut pe = vec![0u8; 0x1100]; + encode_pattern(&mut pe, text_va as usize, vtable_addr, 8); // slot 2 + + let mut functions: BTreeMap = BTreeMap::new(); + functions.insert(pc_start, FuncInfo { + start: pc_start, + end: pc_start + 5 * 4, + frame_size: 0, + saved_gprs: 0, + is_leaf: false, + is_saverestore: false, + pdata_validated: false, + pdata_length: None, + pdata_prolog_length: None, + has_eh: false, + }); + let func_analysis = FuncAnalysis { + functions, + save_gpr_base: None, + restore_gpr_base: None, + pdata_entries: Vec::new(), + }; + + let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB, 0xCC, 0xDD])]; + let labels: HashMap = HashMap::new(); + let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].source, pc_start + 4 * 4); // bcctrl at 5th instruction + assert_eq!(edges[0].target, 0xCC); // slot 2 + assert_eq!(edges[0].via_vtable, vtable_addr); + assert_eq!(edges[0].slot, 2); + } + + #[test] + fn out_of_range_slot_yields_no_edge() { + let image_base = 0x82000000u32; + let text_va = 0x1000u32; + let pc_start = image_base + text_va; + let vtable_addr = 0x82010000u32; + + let mut pe = vec![0u8; 0x1100]; + // Encode slot 12, but vtable only has 4 methods. + encode_pattern(&mut pe, text_va as usize, vtable_addr, 48); + + let mut functions: BTreeMap = BTreeMap::new(); + functions.insert(pc_start, FuncInfo { + start: pc_start, + end: pc_start + 5 * 4, + frame_size: 0, + saved_gprs: 0, + is_leaf: false, + is_saverestore: false, + pdata_validated: false, + pdata_length: None, + pdata_prolog_length: None, + has_eh: false, + }); + let func_analysis = FuncAnalysis { + functions, + save_gpr_base: None, + restore_gpr_base: None, + pdata_entries: Vec::new(), + }; + + let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB, 0xCC, 0xDD])]; + let labels: HashMap = HashMap::new(); + let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels); + assert_eq!(edges.len(), 0); + } + + #[test] + fn label_in_middle_kills_state() { + let image_base = 0x82000000u32; + let text_va = 0x1000u32; + let pc_start = image_base + text_va; + let vtable_addr = 0x82010000u32; + + let mut pe = vec![0u8; 0x1100]; + encode_pattern(&mut pe, text_va as usize, vtable_addr, 0); + + let mut functions: BTreeMap = BTreeMap::new(); + functions.insert(pc_start, FuncInfo { + start: pc_start, + end: pc_start + 5 * 4, + frame_size: 0, + saved_gprs: 0, + is_leaf: false, + is_saverestore: false, + pdata_validated: false, + pdata_length: None, + pdata_prolog_length: None, + has_eh: false, + }); + let func_analysis = FuncAnalysis { + functions, + save_gpr_base: None, + restore_gpr_base: None, + pdata_entries: Vec::new(), + }; + + let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB])]; + + // Label between addi and lwz — must kill the Const tracking. + let mut labels: HashMap = HashMap::new(); + labels.insert(pc_start + 8, "loc_mid".to_string()); + + let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels); + assert_eq!(edges.len(), 0, "label in middle of pattern must kill register state"); + } +} diff --git a/crates/sylpheed-xexdb/src/jumptables.rs b/crates/sylpheed-xexdb/src/jumptables.rs new file mode 100644 index 00000000..a9e3d381 --- /dev/null +++ b/crates/sylpheed-xexdb/src/jumptables.rs @@ -0,0 +1,758 @@ +//! Switch-statement (jump-table) recovery for MSVC PowerPC `bctr` dispatch. +//! +//! The Xbox 360 MSVC compiler lowers a dense `switch` to a table of **absolute +//! target VAs** that is emitted *inline in `.text`*, immediately after the +//! dispatching `bctr` in the common case. The canonical shape is: +//! +//! ```text +//! cmplwi rIdx, N ; bound check — N is the largest case index +//! bgt default ; out-of-range → default label +//! lis r12, tab@h ; addis r12, r0, hi +//! addi r12, r12, tab@l ; r12 = &table +//! rlwinm r0, rIdx, 2, 22, 29 ; r0 = idx * 4 +//! lwzx r0, r12, r0 ; r0 = table[idx] +//! mtctr r0 +//! bctr ; → case body +//! .long case0, case1, ... ; the table itself, inline in .text +//! ``` +//! +//! Sparse switches add a second, byte-wide *index map* read with `lbzx`: +//! `slot = map[idx]` then `target = table[slot]`, which lets several case +//! values share one body without a full-width table. Both tables are +//! recovered; for the two-level form the emitted `targets` vector is already +//! **expanded per case value** (`targets[i] = table[map[i]]`), so consumers +//! never have to redo the indirection. +//! +//! # Why this matters +//! +//! Without this pass the `bctr` is a dead end: the case bodies have no +//! incoming edge (they are unreachable in `v_reachability_from_entry`), and — +//! worse — the table words themselves are linearly disassembled as if they +//! were instructions, so `instructions` carries thousands of bogus rows in the +//! middle of otherwise correct functions. This module fixes both: it emits +//! `jump` xrefs for every case target and reports the table extents as +//! data-in-code regions so the disassembler can mark those words. +//! +//! # Validation +//! +//! A candidate table is accepted only while its entries land **inside the +//! enclosing function** (per the `.pdata`-validated boundary from +//! [`crate::func`]). That is not a heuristic softener — a `switch` always +//! branches within its own function — and on the reference title it holds for +//! 100% of recovered entries, which is what lets the scan terminate a table +//! without needing the `cmplwi` bound. When the enclosing function is unknown +//! the weaker "inside some code section" test is used instead. +//! +//! # Limits +//! +//! - Only `bctr` (tail dispatch) is considered. `bctrl` is a call through a +//! function pointer — that is [`crate::indirect`]'s job, not a switch. +//! - The constant tracker is a straight-line, single-block model over a fixed +//! backward window. Table bases materialised across a branch, or through a +//! register the model conservatively invalidates, are missed (no false +//! positives result — target validation still gates every emission). + +use std::collections::BTreeSet; + +use sylpheed_xex::pe::PeSection; + +use crate::func::FuncAnalysis; + +/// How far back from a `bctr` the constant tracker looks for the table setup. +/// MSVC emits the whole sequence within a handful of instructions; 48 is far +/// beyond what any observed switch needs and still bounds the scan cost. +const WINDOW_INSTRS: u32 = 48; + +/// Hard ceiling on entries read from a table whose extent cannot be bounded by +/// the enclosing function (only reached when the function is unknown). +const MAX_ENTRIES: u32 = 4096; + +/// Why a `bctr` did not yield a table. Counted per image and logged, so a +/// coverage regression shows up as a shift between buckets rather than as a +/// silently smaller table count. +#[derive(Debug, Default, Clone, Copy)] +pub struct RejectCounts { + /// CTR was not loaded by an `lwzx` in the window — an ordinary indirect + /// tail-call (through a vtable slot or a function-pointer field). + pub not_table_driven: u32, + /// The `lwzx` operands did not resolve to exactly one code-range constant. + pub base_unresolved: u32, + /// A table base resolved, but fewer than two entries validated. + pub too_few_entries: u32, +} + +/// One recovered switch dispatch. +#[derive(Debug, Clone)] +pub struct JumpTable { + /// VA of the dispatching `bctr`. + pub bctr_pc: u32, + /// VA of the enclosing function, when known. + pub function: Option, + /// VA of the table of absolute case targets. + pub table_address: u32, + /// Number of `targets` entries (case values, after index-map expansion). + pub entry_count: u32, + /// Number of 4-byte slots occupied by the target table itself. Equal to + /// `entry_count` for a direct table; for a two-level table it is the + /// highest map slot actually used + 1, which is what bounds the raw table. + pub table_slots: u32, + /// VA of the byte-wide index map for a two-level (sparse) switch. + pub index_map_address: Option, + /// Number of bytes read from the index map (= `bound + 1`). + pub index_map_count: Option, + /// Largest valid case index per the `cmplwi rIdx, N` bound check, when the + /// compare was found in the window. + pub bound: Option, + /// `"direct"` — `target = table[idx]`; `"indexed"` — `target = + /// table[map[idx]]`. + pub kind: &'static str, + /// Case target VA per case value, in case order. May repeat. + pub targets: Vec, +} + +impl JumpTable { + /// Byte extents of the raw tables, for marking data-in-code. + /// Returns `(address, length)` pairs. + pub fn data_regions(&self) -> Vec<(u32, u32)> { + let mut out = Vec::with_capacity(2); + out.push((self.table_address, self.table_slots.saturating_mul(4))); + if let (Some(addr), Some(n)) = (self.index_map_address, self.index_map_count) { + out.push((addr, n)); + } + out + } + + /// Distinct case bodies this dispatch can reach, sorted. + pub fn distinct_targets(&self) -> Vec { + let mut t = self.targets.clone(); + t.sort_unstable(); + t.dedup(); + t + } +} + +// ── Instruction field helpers ────────────────────────────────────────────── + +const BCTR: u32 = 0x4E80_0420; + +fn op(i: u32) -> u32 { i >> 26 } +fn rt(i: u32) -> usize { ((i >> 21) & 0x1F) as usize } +fn ra(i: u32) -> usize { ((i >> 16) & 0x1F) as usize } +fn rb(i: u32) -> usize { ((i >> 11) & 0x1F) as usize } +fn xo(i: u32) -> u32 { (i >> 1) & 0x3FF } +fn simm(i: u32) -> i32 { ((i & 0xFFFF) as i16) as i32 } +fn uimm(i: u32) -> u32 { i & 0xFFFF } + +/// `mtctr rS` — `mtspr` (op 31, xo 467) with the split SPR field naming CTR (9). +fn is_mtctr(i: u32) -> bool { + if op(i) != 31 || xo(i) != 467 { return false; } + let spr_field = (i >> 11) & 0x3FF; + (((spr_field & 0x1F) << 5) | (spr_field >> 5)) == 9 +} + +/// Which GPR (if any) an `op 31` instruction writes. +/// +/// The model errs toward *over*-invalidation: an unrecognised `op 31` form is +/// assumed to clobber its `rT` field. Losing a tracked constant only costs a +/// missed table; it cannot invent one, because every emitted target is +/// range-validated against the enclosing function. +fn op31_dest(i: u32) -> Option { + // Logical / shift / sign-extend X-forms: destination is `rA` (bits 16..20). + const WRITES_RA: &[u32] = &[ + 24, 26, 27, 28, 58, 60, 124, 284, 316, 412, 444, 476, + 536, 539, 792, 794, 824, 826, 827, 922, 954, 986, + ]; + // Stores, compares, traps, cache/sync ops and `mtspr`/`mtcrf`: no GPR write. + const NO_GPR: &[u32] = &[ + 0, 4, 32, 68, // cmp, tw, cmpl, td + 150, 151, 215, 407, 662, 918, 660, 727, 231, // stwcx./stwx/stbx/sthx/stfsx/stfdx/… + 144, 467, 512, 598, 854, 982, 1014, 86, 470, 54, // mtcrf/mtspr/mcrxr/sync/dcb*/icbi + ]; + // Store-*update* forms write back into `rA`. + if matches!(xo(i), 183 | 247 | 439 | 181 | 693 | 759) { + return Some(ra(i)); + } + let x = xo(i); + if NO_GPR.contains(&x) { return None; } + if WRITES_RA.contains(&x) { return Some(ra(i)); } + Some(rt(i)) +} + +// ── Main analysis ────────────────────────────────────────────────────────── + +/// Recover every dense/sparse switch dispatch in the image's code sections. +pub fn analyze( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + func_analysis: &FuncAnalysis, +) -> Vec { + analyze_with_stats(pe, image_base, sections, func_analysis).0 +} + +/// Like [`analyze`], but also returns the per-reason reject tally — the same +/// numbers the pass logs, for callers that want to assert on coverage. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze_with_stats( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + func_analysis: &FuncAnalysis, +) -> (Vec, RejectCounts) { + let started = std::time::Instant::now(); + + let code_ranges: Vec<(u32, u32)> = sections + .iter() + .filter(|s| s.is_code()) + .map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size)) + .collect(); + + let read = |va: u32| -> Option { + let off = va.wrapping_sub(image_base) as usize; + if off.checked_add(4)? > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) + }; + let in_code = |va: u32| code_ranges.iter().any(|&(s, e)| va >= s && va < e); + + let mut out: Vec = Vec::new(); + let mut rejects = RejectCounts::default(); + let mut sites = 0u32; + + for &(sec_start, sec_end) in &code_ranges { + let mut pc = sec_start; + while pc < sec_end { + let Some(instr) = read(pc) else { break }; + if instr == BCTR { + sites += 1; + if let Some(jt) = + recover_at(pc, sec_start, &read, &in_code, func_analysis, &mut rejects) + { + out.push(jt); + } + } + pc = pc.wrapping_add(4); + } + } + + let entries: usize = out.iter().map(|t| t.targets.len()).sum(); + let indexed = out.iter().filter(|t| t.kind == "indexed").count(); + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("analysis.phase_ms", "phase" => "jumptables").record(elapsed_ms); + tracing::info!( + bctr_sites = sites, + jump_tables = out.len(), + indexed, + case_targets = entries, + rejected_not_table_driven = rejects.not_table_driven, + rejected_base_unresolved = rejects.base_unresolved, + rejected_too_few_entries = rejects.too_few_entries, + elapsed_ms, + "jump-table scan complete", + ); + (out, rejects) +} + +/// Try to recover a jump table for the `bctr` at `bctr_pc`. +fn recover_at( + bctr_pc: u32, + sec_start: u32, + read: &impl Fn(u32) -> Option, + in_code: &impl Fn(u32) -> bool, + func_analysis: &FuncAnalysis, + rejects: &mut RejectCounts, +) -> Option { + let containing = func_analysis + .functions + .range(..=bctr_pc) + .next_back() + .filter(|(_, fi)| bctr_pc < fi.end); + + // Only a `.pdata`-validated range is usable as a table bound. A + // prologue-only row's `end` comes from an epilogue walk that stops at the + // first `blr`/`bctr` — i.e. at *this* dispatch — so every case body would + // fall "outside the function" and the table would be rejected wholesale. + let enclosing = containing + .filter(|(_, fi)| fi.pdata_validated) + .map(|(&a, fi)| (a, fi.end)); + + // The window clamp is safe with either kind of row: it only limits how far + // back the constant tracker looks. + let window_start = { + let by_window = bctr_pc.saturating_sub(WINDOW_INSTRS * 4); + let by_func = containing.map(|(&a, _)| a).unwrap_or(sec_start); + by_window.max(by_func).max(sec_start) + }; + + // Straight-line constant propagation over [window_start, bctr_pc). + let mut regs: [Option; 32] = [None; 32]; + let mut lwzx_dest: Option = None; // rT of the last lwzx + let mut lwzx_regs: Option<(Option, Option)> = None; + let mut lwzx_index_tainted = false; // did the index come from the lbzx? + let mut lbzx_regs: Option<(Option, Option)> = None; + let mut ctr_src: Option = None; // rS of the last mtctr + let mut bound: Option = None; + // Taint: "this register holds a value derived from the byte the `lbzx` + // read". Only a register carrying that taint may serve as the jump table's + // index in the two-level form — otherwise any unrelated `lbzx` in the + // window (there are plenty; games read bytes constantly) would be mistaken + // for a case index map. + let mut from_lbzx = [false; 32]; + + let mut pc = window_start; + while pc < bctr_pc { + let Some(i) = read(pc) else { return None }; + match op(i) { + // addis rT, rA, SIMM (lis when rA == 0) + 15 => { + let base = if ra(i) == 0 { Some(0) } else { regs[ra(i)] }; + regs[rt(i)] = base.map(|b| b.wrapping_add(uimm(i) << 16)); + } + // addi rT, rA, SIMM (li when rA == 0) + 14 => { + let base = if ra(i) == 0 { Some(0) } else { regs[ra(i)] }; + regs[rt(i)] = base.map(|b| b.wrapping_add(simm(i) as u32)); + } + // ori / oris rA, rS, UIMM + 24 => regs[ra(i)] = regs[rt(i)].map(|b| b | uimm(i)), + 25 => regs[ra(i)] = regs[rt(i)].map(|b| b | (uimm(i) << 16)), + // cmplwi / cmpwi rA, IMM — the switch's bound check. + 10 | 11 => bound = Some(uimm(i)), + 31 => { + match xo(i) { + 23 => { // lwzx rT, rA, rB — the table read + lwzx_dest = Some(rt(i)); + lwzx_regs = Some((regs[ra(i)], regs[rb(i)])); + lwzx_index_tainted = from_lbzx[ra(i)] || from_lbzx[rb(i)]; + regs[rt(i)] = None; + from_lbzx[rt(i)] = false; + } + 87 => { // lbzx rT, rA, rB — the sparse index-map read + lbzx_regs = Some((regs[ra(i)], regs[rb(i)])); + regs[rt(i)] = None; + from_lbzx = [false; 32]; + from_lbzx[rt(i)] = true; + } + 467 if is_mtctr(i) => ctr_src = Some(rt(i)), + // `mr rA, rS` is `or rA, rS, rS` — propagate constant + taint. + 444 if rt(i) == rb(i) => { + regs[ra(i)] = regs[rt(i)]; + from_lbzx[ra(i)] = from_lbzx[rt(i)]; + } + // `add rT, rA, rB` / `slw rA, rS, rB` also carry the index. + 266 => { + regs[rt(i)] = None; + from_lbzx[rt(i)] = from_lbzx[ra(i)] || from_lbzx[rb(i)]; + } + 24 => { + regs[ra(i)] = None; + from_lbzx[ra(i)] = from_lbzx[rt(i)]; + } + _ => { + if let Some(d) = op31_dest(i) { + regs[d] = None; + from_lbzx[d] = false; + } + } + } + } + // rlwinm / rlwnm / rlwimi write rA — and are how a byte index gets + // scaled to a word offset, so they carry the taint through. + 20 | 21 | 23 => { + regs[ra(i)] = None; + from_lbzx[ra(i)] = from_lbzx[rt(i)]; + } + // D/DS-form GPR loads write rT; the update forms also write rA. + 32 | 34 | 40 | 42 => regs[rt(i)] = None, + 33 | 35 | 41 | 43 => { regs[rt(i)] = None; regs[ra(i)] = None; } + // DS-form: bits 30..31 pick ld(0) / ldu(1) / lwa(2). + 58 => { + regs[rt(i)] = None; + if i & 3 == 1 { regs[ra(i)] = None; } + } + // lmw loads rT..r31. + 46 => for r in rt(i)..32 { regs[r] = None; }, + // FP loads touch no GPR — except the update forms, which write rA. + // Plain stores write no register at all (their `rT` field is the + // *source*), so a tracked base that merely gets spilled survives. + 37 | 39 | 45 | 49 | 51 | 53 | 55 => regs[ra(i)] = None, + // DS-form: bits 30..31 pick std(0) / stdu(1); only stdu writes rA. + 62 if i & 3 == 1 => regs[ra(i)] = None, + // Immediate ALU: 7/8/12/13 write rT, 28/29 (andi./andis.) write rA. + 7 | 8 | 12 | 13 => regs[rt(i)] = None, + 28 | 29 => regs[ra(i)] = None, + _ => {} + } + pc = pc.wrapping_add(4); + } + + // CTR must actually be loaded from the table read — otherwise the `lwzx` + // in the window belongs to unrelated code and the `bctr` is a plain + // indirect tail-call. + if ctr_src.is_none() || ctr_src != lwzx_dest { + rejects.not_table_driven += 1; + return None; + } + let Some((a_val, b_val)) = lwzx_regs else { + rejects.not_table_driven += 1; + return None; + }; + + // Exactly one operand of the table read must be a constant that lands in + // code — the other is the scaled index. Two constants is ambiguous. + let table_address = match (a_val.filter(|&v| in_code(v)), b_val.filter(|&v| in_code(v))) { + (Some(v), None) | (None, Some(v)) => v, + _ => { + rejects.base_unresolved += 1; + return None; + } + }; + + // Same test for the sparse index-map base — but only when the byte that + // `lbzx` produced actually reached the table read as its index. + let index_map_address = lbzx_regs.filter(|_| lwzx_index_tainted).and_then(|(a, b)| { + match (a.filter(|&v| in_code(v)), b.filter(|&v| in_code(v))) { + (Some(v), None) | (None, Some(v)) => Some(v), + _ => None, + } + }); + + // A recovered target is valid only inside the enclosing function; that is + // exact for a `switch`, and on the reference title it holds for 100% of + // recovered entries. Where no `.pdata` range covers the dispatch there is + // no trustworthy bound, so the compiler's own `cmplwi` bound is required + // instead and targets are only checked for being code at all. + if enclosing.is_none() && bound.is_none() { + rejects.base_unresolved += 1; + return None; + } + let valid = |t: u32| match enclosing { + Some((s, e)) => t >= s && t < e, + None => in_code(t), + }; + + if let (Some(map_addr), Some(n)) = (index_map_address, bound) { + // Two-level: expand map[0..=bound] through the table in one shot. + let count = n.saturating_add(1).min(MAX_ENTRIES); + let mut targets = Vec::with_capacity(count as usize); + let mut max_slot = 0u32; + for i in 0..count { + let byte_off = map_addr.wrapping_add(i); + let word = read(byte_off & !3)?; + let slot = (word >> (8 * (3 - (byte_off & 3)))) & 0xFF; + let t = read(table_address.wrapping_add(slot * 4))?; + if !valid(t) { break; } + max_slot = max_slot.max(slot); + targets.push(t); + } + if targets.len() < 2 { + rejects.too_few_entries += 1; + return None; + } + let n_read = targets.len() as u32; + return Some(JumpTable { + bctr_pc, + function: enclosing.map(|(s, _)| s), + table_address, + entry_count: n_read, + table_slots: max_slot + 1, + index_map_address, + index_map_count: Some(n_read), + bound, + kind: "indexed", + targets, + }); + } + + // Dense: read consecutive absolute targets until one leaves the function. + let cap = bound.map(|n| n.saturating_add(1)).unwrap_or(MAX_ENTRIES).min(MAX_ENTRIES); + let mut targets = Vec::new(); + for i in 0..cap { + let Some(t) = read(table_address.wrapping_add(i * 4)) else { break }; + if !valid(t) { break; } + targets.push(t); + } + if targets.len() < 2 { + rejects.too_few_entries += 1; + return None; + } + + Some(JumpTable { + bctr_pc, + function: enclosing.map(|(s, _)| s), + table_address, + entry_count: targets.len() as u32, + table_slots: targets.len() as u32, + index_map_address: None, + index_map_count: None, + bound, + kind: "direct", + targets, + }) +} + +/// Collapse every recovered table's raw extents into a sorted, merged interval +/// list of data-in-code byte ranges. +pub fn data_regions(tables: &[JumpTable]) -> Vec<(u32, u32)> { + let mut regions: Vec<(u32, u32)> = tables + .iter() + .flat_map(|t| t.data_regions()) + .filter(|&(_, len)| len > 0) + .collect(); + regions.sort_unstable(); + + let mut merged: Vec<(u32, u32)> = Vec::with_capacity(regions.len()); + for (addr, len) in regions { + match merged.last_mut() { + Some((p_addr, p_len)) if addr <= p_addr.wrapping_add(*p_len) => { + let end = addr.wrapping_add(len).max(p_addr.wrapping_add(*p_len)); + *p_len = end.wrapping_sub(*p_addr); + } + _ => merged.push((addr, len)), + } + } + merged +} + +/// Expand merged byte ranges into the set of 4-byte-aligned word addresses they +/// cover — the granularity at which `instructions` rows are marked. +pub fn data_word_addresses(tables: &[JumpTable]) -> BTreeSet { + let mut set = BTreeSet::new(); + for (addr, len) in data_regions(tables) { + let start = addr & !3; + let end = addr.wrapping_add(len).div_ceil(4) * 4; + let mut a = start; + while a < end { + set.insert(a); + a = a.wrapping_add(4); + } + } + set +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use sylpheed_xex::pe::PeSection; + + use crate::func::{FuncAnalysis, FuncInfo}; + + const BASE: u32 = 0x8200_0000; + const TEXT_RVA: u32 = 0x1000; + const TEXT_VA: u32 = BASE + TEXT_RVA; + + fn text_section(size: u32) -> PeSection { + PeSection { + name: ".text".into(), + virtual_address: TEXT_RVA, + virtual_size: size, + raw_offset: TEXT_RVA, + raw_size: size, + flags: 0x6000_0020, // CODE | EXECUTE | READ + } + } + + fn one_function(start: u32, end: u32) -> FuncAnalysis { + let mut functions = BTreeMap::new(); + functions.insert(start, FuncInfo { + start, + end, + frame_size: 0, + saved_gprs: 0, + is_leaf: false, + is_saverestore: false, + pdata_validated: true, + pdata_length: Some(end - start), + pdata_prolog_length: Some(0), + has_eh: false, + }); + FuncAnalysis { functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new() } + } + + /// Encode the words of the canonical MSVC dense-switch dispatch, ending at + /// the `bctr`, then the inline table. Mirrors the real sequence: + /// cmplwi r10,N / bgt / lis r12 / addi r12 / slwi r0,r10,2 / lwzx r0,r12,r0 + /// / mtctr r0 / bctr /
+ fn dense_switch(table_va: u32, n_cases: u32) -> Vec { + vec![ + 0x2800_0000 | (10 << 16) | (n_cases - 1), // cmplwi r10, N + 0x4181_0000 | 0x20, // bc (bound check, target irrelevant) + 0x3D80_0000 | (table_va >> 16), // lis r12, hi + 0x398C_0000 | (table_va & 0xFFFF), // addi r12, r12, lo + 0x5540_103A, // slwi r0, r10, 2 + 0x7C0C_002E, // lwzx r0, r12, r0 + 0x7C09_03A6, // mtctr r0 + BCTR, + ] + } + + fn assemble(words: &[u32], size: u32) -> Vec { + let mut pe = vec![0u8; (TEXT_RVA + size) as usize]; + for (i, w) in words.iter().enumerate() { + let off = TEXT_RVA as usize + i * 4; + pe[off..off + 4].copy_from_slice(&w.to_be_bytes()); + } + pe + } + + #[test] + fn recovers_dense_switch_with_inline_table() { + let table_va = TEXT_VA + 8 * 4; + let mut words = dense_switch(table_va, 4); + // Four case bodies, all inside the function. + let cases = [TEXT_VA + 0x40, TEXT_VA + 0x50, TEXT_VA + 0x60, TEXT_VA + 0x70]; + words.extend_from_slice(&cases); + + let pe = assemble(&words, 0x100); + let sections = [text_section(0x100)]; + let fa = one_function(TEXT_VA, TEXT_VA + 0x100); + + let tables = analyze(&pe, BASE, §ions, &fa); + assert_eq!(tables.len(), 1, "expected exactly one recovered table"); + let jt = &tables[0]; + assert_eq!(jt.bctr_pc, TEXT_VA + 7 * 4); + assert_eq!(jt.table_address, table_va); + assert_eq!(jt.kind, "direct"); + assert_eq!(jt.bound, Some(3)); + assert_eq!(jt.targets, cases.to_vec()); + assert_eq!(jt.function, Some(TEXT_VA)); + } + + #[test] + fn table_stops_at_a_target_outside_the_function() { + // Bound says 8 cases but only the first three land inside the function; + // the fourth word is an address in a different function, which must + // terminate the table rather than be emitted as a case. + let table_va = TEXT_VA + 8 * 4; + let mut words = dense_switch(table_va, 8); + words.extend_from_slice(&[ + TEXT_VA + 0x40, TEXT_VA + 0x50, TEXT_VA + 0x60, + 0x8300_0000, // far outside + TEXT_VA + 0x70, + ]); + + let pe = assemble(&words, 0x100); + let sections = [text_section(0x100)]; + let fa = one_function(TEXT_VA, TEXT_VA + 0x80); + + let tables = analyze(&pe, BASE, §ions, &fa); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].targets.len(), 3); + assert_eq!(tables[0].entry_count, 3); + } + + #[test] + fn plain_indirect_tail_call_is_not_a_switch() { + // `lwz r12, 0(r3); mtctr r12; bctr` — a virtual tail-call, no table. + let words = [ + 0x8183_0000, // lwz r12, 0(r3) + 0x7D89_03A6, // mtctr r12 + BCTR, + ]; + let pe = assemble(&words, 0x100); + let sections = [text_section(0x100)]; + let fa = one_function(TEXT_VA, TEXT_VA + 0x100); + assert!(analyze(&pe, BASE, §ions, &fa).is_empty()); + } + + #[test] + fn recovers_sparse_two_level_switch() { + // cmplwi r10,5 / bc / lis+addi r11 = &map / lbzx r0,r11,r10 + // / lis+addi r12 = &table / slwi r0,r0,2 / lwzx r0,r12,r0 / mtctr / bctr + let map_va = TEXT_VA + 13 * 4; // 6 bytes, then padding + let table_va = TEXT_VA + 17 * 4; // 3 distinct bodies + let words = vec![ + 0x2800_0000 | (10 << 16) | 5, // cmplwi r10, 5 + 0x4181_0000 | 0x20, // bc + 0x3D60_0000 | (map_va >> 16), // lis r11, map@h + 0x396B_0000 | (map_va & 0xFFFF), // addi r11, r11, map@l + 0x7C0B_50AE, // lbzx r0, r11, r10 + 0x3D80_0000 | (table_va >> 16), // lis r12, tab@h + 0x398C_0000 | (table_va & 0xFFFF), // addi r12, r12, tab@l + 0x5400_103A, // slwi r0, r0, 2 + 0x7C0C_002E, // lwzx r0, r12, r0 + 0x7C09_03A6, // mtctr r0 + BCTR, + 0, 0, + // map[0..6] = 0,1,2,2,1,0 packed big-endian, then padding + 0x0001_0202, 0x0100_0000, + 0, 0, + // table[0..3] + TEXT_VA + 0x80, TEXT_VA + 0x90, TEXT_VA + 0xA0, + ]; + let pe = assemble(&words, 0x100); + let sections = [text_section(0x100)]; + let fa = one_function(TEXT_VA, TEXT_VA + 0x100); + + let tables = analyze(&pe, BASE, §ions, &fa); + assert_eq!(tables.len(), 1); + let jt = &tables[0]; + assert_eq!(jt.kind, "indexed"); + assert_eq!(jt.index_map_address, Some(map_va)); + assert_eq!(jt.index_map_count, Some(6)); + assert_eq!(jt.table_slots, 3); + assert_eq!(jt.targets, vec![ + TEXT_VA + 0x80, TEXT_VA + 0x90, TEXT_VA + 0xA0, + TEXT_VA + 0xA0, TEXT_VA + 0x90, TEXT_VA + 0x80, + ]); + } + + /// An unrelated `lbzx` in the window must not be mistaken for a case index + /// map — games read bytes constantly, and a fabricated two-level mapping + /// would silently point every case at the wrong body. + #[test] + fn unrelated_lbzx_does_not_become_an_index_map() { + let table_va = TEXT_VA + 11 * 4; + let mut words = vec![ + 0x2800_0000 | (10 << 16) | 3, // cmplwi r10, 3 + 0x4181_0000 | 0x20, // bc + 0x3D60_0000 | (TEXT_VA >> 16), // lis r11, text@h (a code constant) + 0x396B_0000 | (TEXT_VA & 0xFFFF), // addi r11, r11, text@l + 0x7CEB_44AE, // lbzx r7, r11, r8 — unrelated byte load + 0x3D80_0000 | (table_va >> 16), // lis r12, tab@h + 0x398C_0000 | (table_va & 0xFFFF), // addi r12, r12, tab@l + 0x5540_103A, // slwi r0, r10, 2 — index is r10, NOT r7 + 0x7C0C_002E, // lwzx r0, r12, r0 + 0x7C09_03A6, // mtctr r0 + ]; + words.push(BCTR); + words.extend_from_slice(&[TEXT_VA + 0x80, TEXT_VA + 0x90, TEXT_VA + 0xA0, TEXT_VA + 0xB0]); + + let pe = assemble(&words, 0x100); + let sections = [text_section(0x100)]; + let fa = one_function(TEXT_VA, TEXT_VA + 0x100); + + let tables = analyze(&pe, BASE, §ions, &fa); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].kind, "direct"); + assert_eq!(tables[0].index_map_address, None); + } + + #[test] + fn data_regions_merge_adjacent_tables() { + let a = JumpTable { + bctr_pc: 0x8200_1000, function: None, table_address: 0x8200_2000, + entry_count: 4, table_slots: 4, index_map_address: None, + index_map_count: None, bound: None, kind: "direct", + targets: vec![0; 4], + }; + let b = JumpTable { table_address: 0x8200_2010, bctr_pc: 0x8200_1004, ..a.clone() }; + let merged = data_regions(&[a, b]); + assert_eq!(merged, vec![(0x8200_2000, 32)]); + } + + #[test] + fn data_word_addresses_covers_every_slot() { + let jt = JumpTable { + bctr_pc: 0x8200_1000, function: None, table_address: 0x8200_2000, + entry_count: 3, table_slots: 3, index_map_address: Some(0x8200_3000), + index_map_count: Some(5), bound: Some(4), kind: "indexed", + targets: vec![0; 5], + }; + let words = data_word_addresses(&[jt]); + // 3 table slots + ceil(5/4) = 2 words of index map. + assert_eq!(words.len(), 5); + assert!(words.contains(&0x8200_2008)); + assert!(words.contains(&0x8200_3004)); + } +} diff --git a/crates/sylpheed-xexdb/src/lib.rs b/crates/sylpheed-xexdb/src/lib.rs new file mode 100644 index 00000000..078aae07 --- /dev/null +++ b/crates/sylpheed-xexdb/src/lib.rs @@ -0,0 +1,26 @@ +pub mod ppc; +pub mod func; +pub mod xref; +pub mod db; +pub mod disasm; +pub mod formatter; +pub mod sinks; +pub mod sql_views; +pub mod demangle; +pub mod vtables; +pub mod lookup; +pub mod indirect; +pub mod ind_dispatch_typed; +pub mod strings; +pub mod funcptr_arrays; +pub mod eh_scope; +pub mod static_init; +pub mod xdbf; +pub mod jumptables; +pub mod rtti; + +mod ordinals; +pub use ordinals::resolve_ordinal; +pub use xref::{XrefKind, Xref, XrefMap, resolve_source_label}; +pub use db::{DbWriter, ExecTraceEntry, ImportCallEntry, BranchTraceEntry}; +pub use disasm::{RichDisasmItem, enrich_section}; diff --git a/crates/sylpheed-xexdb/src/lookup.rs b/crates/sylpheed-xexdb/src/lookup.rs new file mode 100644 index 00000000..df21e4e6 --- /dev/null +++ b/crates/sylpheed-xexdb/src/lookup.rs @@ -0,0 +1,222 @@ +//! Symbolic-name resolution for runtime probes (M4). +//! +//! Lets `--pc-probe` / `--branch-probe` / `--ctor-probe` accept names like +//! `xe::apu::AudioSystem::Setup` or `MyClass::*` instead of bare PC literals. +//! Resolution joins the M3-produced `classes` × `methods` × `functions` tables +//! and the M2 `demangled_names` table. +//! +//! Numeric tokens (`0x824D6640`, `2186674160`) are returned unchanged; symbolic +//! tokens require a path to an existing `sylpheed.db` (passed by the caller). +//! +//! All DB access is read-only and happens before guest execution, so the +//! lockstep digest is unaffected. + +use std::path::Path; + +use anyhow::{anyhow, Result}; +use duckdb::params; + +/// Parse one probe token into one or more PCs. +/// +/// Recognized forms: +/// - `0xADDR` / `ADDR` (decimal) → returns one PC unchanged. +/// - `Class::method` → all `methods.function_address` matching that +/// `class_name` + `method_name` pair. +/// - `Class::*` → all `methods.function_address` for that class. +/// - `func::Name` (free function) → falls back to `functions.name` lookup. +/// +/// `db_path` is consulted ONLY if the token is non-numeric. When `db_path` is +/// `None` and the token is symbolic, returns an error suggesting the user +/// either pass `--db` or use a numeric address. +pub fn resolve_probe_token(db_path: Option<&Path>, token: &str) -> Result> { + let token = token.trim(); + if token.is_empty() { + return Ok(vec![]); + } + + if let Some(pc) = parse_numeric(token) { + return Ok(vec![pc]); + } + + let db = db_path.ok_or_else(|| { + anyhow!( + "symbolic probe token {token:?} requires a sylpheed.db; \ + pass --probe-db=PATH or use a numeric 0x… address", + ) + })?; + + if !db.exists() { + return Err(anyhow!("--probe-db not found: {}", db.display())); + } + + let conn = duckdb::Connection::open_with_flags( + db, + duckdb::Config::default().access_mode(duckdb::AccessMode::ReadOnly)?, + )?; + + // Class::method or Class::* + if let Some((class, method)) = token.split_once("::") { + if method == "*" { + return resolve_class_star(&conn, class); + } + // Try Class::method first, then fall back to functions.name lookup. + let pcs = resolve_class_method(&conn, class, method)?; + if !pcs.is_empty() { + return Ok(pcs); + } + } + + // Last-resort: functions.name match (e.g. for `entry_point` or + // `__savegprlr_22`). Substring-free; user gets a clear error if missing. + resolve_function_name(&conn, token) +} + +fn parse_numeric(token: &str) -> Option { + if let Some(hex) = token.strip_prefix("0x").or_else(|| token.strip_prefix("0X")) { + return u32::from_str_radix(hex, 16).ok(); + } + token.parse::().ok() +} + +fn resolve_class_method(conn: &duckdb::Connection, class: &str, method: &str) -> Result> { + // Two-step lookup so we can give better errors: + // 1. find matching methods rows joined to classes; + // 2. surface the function_address column. + let mut stmt = conn.prepare( + "SELECT DISTINCT m.function_address FROM methods m + JOIN classes c ON c.vtable_address = m.vtable_address + JOIN demangled_names dn ON dn.address = m.function_address + WHERE c.name = ? AND dn.method_name = ?", + )?; + let pcs: Vec = stmt + .query_map(params![class, method], |r| r.get::<_, i64>(0).map(|x| x as u32))? + .filter_map(|r| r.ok()) + .collect(); + Ok(pcs) +} + +fn resolve_class_star(conn: &duckdb::Connection, class: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT DISTINCT m.function_address FROM methods m + JOIN classes c ON c.vtable_address = m.vtable_address + WHERE c.name = ?", + )?; + let pcs: Vec = stmt + .query_map(params![class], |r| r.get::<_, i64>(0).map(|x| x as u32))? + .filter_map(|r| r.ok()) + .collect(); + if pcs.is_empty() { + return Err(anyhow!( + "no class named {class:?} found in classes table — has --dis populated this DB?", + )); + } + Ok(pcs) +} + +fn resolve_function_name(conn: &duckdb::Connection, name: &str) -> Result> { + let mut stmt = conn.prepare("SELECT address FROM functions WHERE name = ?")?; + let pcs: Vec = stmt + .query_map(params![name], |r| r.get::<_, i64>(0).map(|x| x as u32))? + .filter_map(|r| r.ok()) + .collect(); + if pcs.is_empty() { + return Err(anyhow!( + "probe token {name:?} did not match any classes::methods or functions row", + )); + } + Ok(pcs) +} + +#[cfg(test)] +mod tests { + use super::*; + use duckdb::Connection; + + fn build_synthetic_db(path: &Path) { + let conn = Connection::open(path).expect("open"); + conn.execute_batch( + " + CREATE TABLE functions ( + address BIGINT PRIMARY KEY, + name VARCHAR + ); + CREATE TABLE classes ( + name VARCHAR PRIMARY KEY, + vtable_address BIGINT, + rtti_present BOOLEAN, + base_classes_json VARCHAR + ); + CREATE TABLE methods ( + vtable_address BIGINT, + slot BIGINT, + function_address BIGINT, + mangled_name VARCHAR, + demangled_name VARCHAR, + PRIMARY KEY (vtable_address, slot) + ); + CREATE TABLE demangled_names ( + address BIGINT, + mangled VARCHAR, + raw_demangled VARCHAR, + namespace_path VARCHAR, + class_name VARCHAR, + method_name VARCHAR, + params_signature VARCHAR + ); + INSERT INTO classes VALUES ('Foo', 11000, true, NULL); + INSERT INTO functions VALUES (12000, 'sub_2EE0'), (12100, 'sub_2F44'); + INSERT INTO methods VALUES (11000, 0, 12000, NULL, NULL), + (11000, 1, 12100, NULL, NULL); + INSERT INTO demangled_names (address, mangled, raw_demangled, class_name, method_name) + VALUES (12000, '?bar@Foo@@QEAAXXZ', 'void Foo::bar(void)', 'Foo', 'bar'), + (12100, '?baz@Foo@@QEAAXXZ', 'void Foo::baz(void)', 'Foo', 'baz'); + ", + ) + .expect("seed"); + } + + #[test] + fn numeric_passthrough_no_db_needed() { + let pcs = resolve_probe_token(None, "0x824D6640").unwrap(); + assert_eq!(pcs, vec![0x824D6640]); + let pcs = resolve_probe_token(None, "2186095088").unwrap(); + assert_eq!(pcs, vec![0x824D29F0]); + } + + #[test] + fn symbolic_token_without_db_errors() { + let err = resolve_probe_token(None, "Foo::bar").unwrap_err(); + assert!(format!("{err}").contains("requires a sylpheed.db")); + } + + #[test] + fn class_method_resolves() { + let tmp = std::env::temp_dir().join("sylpheed_lookup_test.duckdb"); + let _ = std::fs::remove_file(&tmp); + build_synthetic_db(&tmp); + let pcs = resolve_probe_token(Some(&tmp), "Foo::bar").unwrap(); + assert_eq!(pcs, vec![12000]); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn class_star_returns_all_methods() { + let tmp = std::env::temp_dir().join("sylpheed_lookup_star.duckdb"); + let _ = std::fs::remove_file(&tmp); + build_synthetic_db(&tmp); + let mut pcs = resolve_probe_token(Some(&tmp), "Foo::*").unwrap(); + pcs.sort(); + assert_eq!(pcs, vec![12000, 12100]); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn function_name_fallback() { + let tmp = std::env::temp_dir().join("sylpheed_lookup_fn.duckdb"); + let _ = std::fs::remove_file(&tmp); + build_synthetic_db(&tmp); + let pcs = resolve_probe_token(Some(&tmp), "sub_2EE0").unwrap(); + assert_eq!(pcs, vec![12000]); + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/crates/sylpheed-xexdb/src/ordinals.rs b/crates/sylpheed-xexdb/src/ordinals.rs new file mode 100644 index 00000000..aa6f6a13 --- /dev/null +++ b/crates/sylpheed-xexdb/src/ordinals.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/ordinals.rs")); diff --git a/crates/sylpheed-xexdb/src/ppc.rs b/crates/sylpheed-xexdb/src/ppc.rs new file mode 100644 index 00000000..34ff9d80 --- /dev/null +++ b/crates/sylpheed-xexdb/src/ppc.rs @@ -0,0 +1,28 @@ +//! Back-compat shim. The full PPC disassembler now lives in +//! [`sylpheed_ppc::disasm`] (single source of truth, sitting on top of the +//! canonical decoder). This module preserves the legacy `Decoded { base, ext }` +//! surface so existing call sites keep compiling while the analysis crate +//! migrates to `DisasmText` directly. + +use sylpheed_ppc::decoder::decode; +use sylpheed_ppc::disasm::format; + +/// Decoded instruction carrying both base and (optional) extended mnemonic forms. +pub struct Decoded { + pub base: String, + pub ext: Option, +} + +impl Decoded { + /// Returns the preferred display form (extended if available, else base). + pub fn display(&self) -> &str { + self.ext.as_deref().unwrap_or(&self.base) + } +} + +/// Disassemble one 32-bit big-endian PowerPC instruction. +pub fn disasm(instr: u32, addr: u32) -> Decoded { + let d = decode(instr, addr); + let t = format(&d); + Decoded { base: t.disasm, ext: t.ext_disasm } +} diff --git a/crates/sylpheed-xexdb/src/rtti.rs b/crates/sylpheed-xexdb/src/rtti.rs new file mode 100644 index 00000000..36bfe43d --- /dev/null +++ b/crates/sylpheed-xexdb/src/rtti.rs @@ -0,0 +1,453 @@ +//! MSVC RTTI recovery — the authoritative source of C++ class identity. +//! +//! [`crate::vtables`] finds vtables *bottom-up*, by looking for runs of words +//! that happen to be function entries, and only then tries the RTTI walk. That +//! misses every table whose head holds a null / pure-virtual / thunk slot, and +//! it cannot see a class that has no such run at all. This module works +//! *top-down* from the RTTI structures the linker emitted, which is exact: +//! a `CompleteObjectLocator` names its class, and the word that points at a +//! COL is by definition `vftable[-1]`. +//! +//! ## Structure layout (32-bit MSVC, big-endian on Xbox 360) +//! +//! ```text +//! TypeDescriptor (in .data — it is written at startup) +//! +0 void* pVFTable -> type_info's own vftable (identical for all TDs) +//! +4 void* spare +//! +8 char name[] -> ".?AVFoo@Bar@@", NUL-terminated +//! +//! RTTICompleteObjectLocator (in .rdata) +//! +0 u32 signature -> 0 for 32-bit images +//! +4 u32 offset -> this-offset of the subobject this vftable serves +//! +8 u32 cdOffset -> constructor-displacement offset +//! +12 TypeDescriptor* +//! +16 RTTIClassHierarchyDescriptor* +//! +//! RTTIClassHierarchyDescriptor (in .rdata) +//! +0 u32 signature +//! +4 u32 attributes -> bit 0 = multiple inheritance, bit 1 = virtual +//! +8 u32 numBaseClasses +//! +12 RTTIBaseClassDescriptor** pBaseClassArray +//! +//! RTTIBaseClassDescriptor (in .rdata) +//! +0 TypeDescriptor* +//! +4 u32 numContainedBases +//! +8 i32 PMD.mdisp -> member displacement +//! +12 i32 PMD.pdisp -> vbtable displacement (-1 = not virtual) +//! +16 i32 PMD.vdisp -> displacement inside the vbtable +//! +20 u32 attributes +//! ``` +//! +//! A vtable is located at `col_ref + 4` for every word `col_ref` whose value is +//! a validated COL address. `offset` distinguishes the primary vftable +//! (`offset == 0`) from the extra vftables a multiply-inheriting class emits +//! for its secondary base subobjects — those are linked to the same class +//! rather than being reported as unrelated tables. +//! +//! ## Limits +//! +//! - Only statically-emitted RTTI is seen; a class whose RTTI the linker +//! stripped stays anonymous and is left to [`crate::vtables`]. +//! - Vtable *length* is measured by walking forward from `vftable[0]` while the +//! words are plausible method pointers, stopping at the next COL reference or +//! at a known label — the linker does not record it. + +use std::collections::{BTreeMap, BTreeSet}; + +use sylpheed_xex::pe::PeSection; + +use crate::demangle; + +/// One `TypeDescriptor`: the mangled class name the compiler emitted. +#[derive(Debug, Clone)] +pub struct TypeDescriptor { + /// VA of the descriptor (i.e. of its `pVFTable` word). + pub address: u32, + /// Raw decorated name, e.g. `.?AVSilph@silph@@`. + pub mangled_name: String, + /// Readable form, e.g. `silph::Silph`. Falls back to `mangled_name`. + pub demangled_name: String, +} + +/// One `RTTICompleteObjectLocator` and the vtable it labels. +#[derive(Debug, Clone)] +pub struct CompleteObjectLocator { + pub address: u32, + /// `this`-offset of the subobject whose vftable this is. 0 = primary. + pub offset: u32, + pub cd_offset: u32, + pub type_descriptor: u32, + pub class_hierarchy: u32, + /// VA of `vftable[0]`, when a word pointing at this COL was found. + pub vtable_address: Option, +} + +/// One entry of a class's `RTTIBaseClassArray`, in linearised order. +#[derive(Debug, Clone)] +pub struct BaseClass { + /// VA of the deriving class's `RTTIClassHierarchyDescriptor`. + pub class_hierarchy: u32, + /// Position in the base-class array (index 0 is the class itself). + pub index: u32, + pub type_descriptor: u32, + pub name: String, + pub num_contained_bases: u32, + pub mdisp: i32, + pub pdisp: i32, + pub vdisp: i32, + pub attributes: u32, +} + +/// Everything the RTTI walk recovered. +#[derive(Debug, Default)] +pub struct RttiResult { + pub type_descriptors: Vec, + pub locators: Vec, + pub base_classes: Vec, + /// `vftable[0]` VA → the COL that labels it. + pub vtable_to_locator: BTreeMap, +} + +impl RttiResult { + /// Vtable base VAs the walk proved exist — the anchor set + /// [`crate::vtables`] should treat as authoritative. + pub fn vtable_anchors(&self) -> BTreeSet { + self.vtable_to_locator.keys().copied().collect() + } + + /// `vftable[0]` VA → `(demangled class name, subobject offset)`. + pub fn vtable_class_names(&self) -> BTreeMap { + let td: BTreeMap = + self.type_descriptors.iter().map(|t| (t.address, t)).collect(); + let mut out = BTreeMap::new(); + for col in &self.locators { + if let (Some(vt), Some(t)) = (col.vtable_address, td.get(&col.type_descriptor)) { + out.insert(vt, (t.demangled_name.clone(), col.offset)); + } + } + out + } +} + +// ── Scan ─────────────────────────────────────────────────────────────────── + +/// Walk the image's RTTI. `sections` must be the full PE section list. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze(pe: &[u8], image_base: u32, sections: &[PeSection]) -> RttiResult { + let started = std::time::Instant::now(); + + let read = |va: u32| -> Option { + let off = va.wrapping_sub(image_base) as usize; + if off.checked_add(4)? > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) + }; + + // Byte ranges actually backed by file data (a section's tail beyond + // `raw_size` is BSS: reading it yields zeros, never a real structure). + let backed = |s: &PeSection| -> (u32, u32) { + let start = image_base + s.virtual_address; + let len = s.virtual_size.min(s.raw_size); + (start, start + len) + }; + let ranges: Vec<(String, u32, u32)> = sections + .iter() + .map(|s| { let (a, b) = backed(s); (s.name.clone(), a, b) }) + .collect(); + let range_of = |name: &str| -> Option<(u32, u32)> { + ranges.iter().find(|(n, _, _)| n == name).map(|&(_, a, b)| (a, b)) + }; + + // 1. TypeDescriptors. The decorated name lives at descriptor+8 and always + // starts with ".?A". MSVC places these in writable data. + let mut type_descriptors: Vec = Vec::new(); + let mut td_addrs: BTreeSet = BTreeSet::new(); + for (name, start, end) in &ranges { + if !matches!(name.as_str(), ".data" | ".rdata") { continue; } + let s = (*start).wrapping_sub(image_base) as usize; + let e = (*end).wrapping_sub(image_base) as usize; + if e > pe.len() || s >= e { continue; } + let bytes = &pe[s..e]; + let mut i = 0usize; + while i + 3 < bytes.len() { + if &bytes[i..i + 3] != b".?A" { i += 1; continue; } + let name_va = start.wrapping_add(i as u32); + // The descriptor head sits 8 bytes before the name. + let Some(td_va) = name_va.checked_sub(8) else { i += 1; continue }; + if td_va < *start { i += 1; continue; } + let Some(decorated) = read_cstr(bytes, i, 512) else { i += 1; continue }; + i += decorated.len() + 1; + if td_addrs.insert(td_va) { + type_descriptors.push(TypeDescriptor { + address: td_va, + demangled_name: demangle::demangle_type_descriptor(&decorated) + .unwrap_or_else(|| decorated.clone()), + mangled_name: decorated, + }); + } + } + } + + // 2. CompleteObjectLocators. Scan read-only data on a 4-byte grid for the + // 5-word shape whose `pTypeDescriptor` hits a descriptor we just found + // and whose `pClassDescriptor` points back into read-only data. + let rdata = range_of(".rdata"); + let mut locators: Vec = Vec::new(); + let mut col_addrs: BTreeSet = BTreeSet::new(); + if let Some((rd_start, rd_end)) = rdata { + let mut va = rd_start; + while va + 20 <= rd_end { + let (Some(sig), Some(off), Some(cd), Some(ptd), Some(pchd)) = ( + read(va), read(va + 4), read(va + 8), read(va + 12), read(va + 16), + ) else { break }; + if sig == 0 && td_addrs.contains(&ptd) && pchd >= rd_start && pchd < rd_end { + col_addrs.insert(va); + locators.push(CompleteObjectLocator { + address: va, + offset: off, + cd_offset: cd, + type_descriptor: ptd, + class_hierarchy: pchd, + vtable_address: None, + }); + } + va += 4; + } + } + + // 3. `vftable[-1]` sites: any word in initialised data whose value is a COL. + let mut vtable_to_locator: BTreeMap = BTreeMap::new(); + for (name, start, end) in &ranges { + if !matches!(name.as_str(), ".rdata" | ".data") { continue; } + let mut va = *start; + while va + 4 <= *end { + if let Some(w) = read(va) + && col_addrs.contains(&w) + { + vtable_to_locator.insert(va + 4, w); + } + va += 4; + } + } + let locator_to_vtable: BTreeMap = + vtable_to_locator.iter().map(|(&vt, &col)| (col, vt)).collect(); + for col in &mut locators { + col.vtable_address = locator_to_vtable.get(&col.address).copied(); + } + + // 4. Class hierarchies: for each distinct CHD, read its base-class array. + let td_by_addr: BTreeMap = + type_descriptors.iter().map(|t| (t.address, t)).collect(); + let mut base_classes: Vec = Vec::new(); + let chds: BTreeSet = locators.iter().map(|c| c.class_hierarchy).collect(); + if let Some((rd_start, rd_end)) = rdata { + for chd in chds { + let (Some(n_bases), Some(p_array)) = (read(chd + 8), read(chd + 12)) else { continue }; + // A malformed or misidentified descriptor would blow the scan up; + // real hierarchies are small. + if n_bases == 0 || n_bases > 64 { continue; } + if p_array < rd_start || p_array >= rd_end { continue; } + for i in 0..n_bases { + let Some(bcd) = read(p_array + i * 4) else { break }; + if bcd < rd_start || bcd >= rd_end { break; } + let (Some(ptd), Some(ncb), Some(md), Some(pd), Some(vd), Some(attr)) = ( + read(bcd), read(bcd + 4), read(bcd + 8), + read(bcd + 12), read(bcd + 16), read(bcd + 20), + ) else { break }; + let Some(td) = td_by_addr.get(&ptd) else { break }; + base_classes.push(BaseClass { + class_hierarchy: chd, + index: i, + type_descriptor: ptd, + name: td.demangled_name.clone(), + num_contained_bases: ncb, + mdisp: md as i32, + pdisp: pd as i32, + vdisp: vd as i32, + attributes: attr, + }); + } + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("analysis.phase_ms", "phase" => "rtti").record(elapsed_ms); + tracing::info!( + type_descriptors = type_descriptors.len(), + locators = locators.len(), + vtables = vtable_to_locator.len(), + base_class_records = base_classes.len(), + elapsed_ms, + "RTTI walk complete", + ); + + RttiResult { type_descriptors, locators, base_classes, vtable_to_locator } +} + +/// Read a NUL-terminated ASCII string starting at `off` in `bytes`. +fn read_cstr(bytes: &[u8], off: usize, max: usize) -> Option { + let end = (off + max).min(bytes.len()); + let slice = &bytes[off..end]; + let nul = slice.iter().position(|&b| b == 0)?; + let s = &slice[..nul]; + if s.is_empty() || !s.iter().all(|&b| (0x20..0x7F).contains(&b)) { + return None; + } + Some(String::from_utf8_lossy(s).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: u32 = 0x8200_0000; + const RDATA_RVA: u32 = 0x1000; + const DATA_RVA: u32 = 0x2000; + const SEC_SIZE: u32 = 0x1000; + + fn sections() -> Vec { + vec![ + PeSection { + name: ".rdata".into(), + virtual_address: RDATA_RVA, virtual_size: SEC_SIZE, + raw_offset: RDATA_RVA, raw_size: SEC_SIZE, + flags: 0x4000_0040, + }, + PeSection { + name: ".data".into(), + virtual_address: DATA_RVA, virtual_size: SEC_SIZE, + raw_offset: DATA_RVA, raw_size: SEC_SIZE, + flags: 0xC000_0040, + }, + ] + } + + struct Image(Vec); + impl Image { + fn new() -> Self { Image(vec![0u8; (DATA_RVA + SEC_SIZE) as usize]) } + fn put_u32(&mut self, va: u32, v: u32) { + let o = (va - BASE) as usize; + self.0[o..o + 4].copy_from_slice(&v.to_be_bytes()); + } + fn put_str(&mut self, va: u32, s: &str) { + let o = (va - BASE) as usize; + self.0[o..o + s.len()].copy_from_slice(s.as_bytes()); + self.0[o + s.len()] = 0; + } + } + + /// Lay down one class: TypeDescriptor in .data, COL + CHD + BCD in .rdata, + /// and the `vftable[-1]` word that points at the COL. + #[allow(clippy::too_many_arguments)] + fn emit_class( + img: &mut Image, td: u32, name: &str, + col: u32, offset: u32, chd: u32, bcd_array: u32, bcd: u32, base_name_td: Option, + vtable_minus_one: u32, + ) { + img.put_u32(td, 0xDEAD_BEEF); // type_info vftable — value is irrelevant + img.put_str(td + 8, name); + + img.put_u32(col, 0); // signature + img.put_u32(col + 4, offset); + img.put_u32(col + 8, 0); // cdOffset + img.put_u32(col + 12, td); + img.put_u32(col + 16, chd); + + let n_bases = if base_name_td.is_some() { 2 } else { 1 }; + img.put_u32(chd, 0); + img.put_u32(chd + 4, 0); + img.put_u32(chd + 8, n_bases); + img.put_u32(chd + 12, bcd_array); + + // Base-class array: entry 0 is the class itself. + img.put_u32(bcd_array, bcd); + img.put_u32(bcd, td); + img.put_u32(bcd + 4, n_bases - 1); + img.put_u32(bcd + 8, 0); // mdisp + img.put_u32(bcd + 12, u32::MAX); // pdisp = -1 + img.put_u32(bcd + 16, 0); // vdisp + img.put_u32(bcd + 20, 0x40); // attributes + if let Some(base_td) = base_name_td { + let bcd2 = bcd + 24; + img.put_u32(bcd_array + 4, bcd2); + img.put_u32(bcd2, base_td); + img.put_u32(bcd2 + 4, 0); + img.put_u32(bcd2 + 8, 4); // mdisp = 4 + img.put_u32(bcd2 + 12, u32::MAX); + img.put_u32(bcd2 + 16, 0); + img.put_u32(bcd2 + 20, 0); + } + + img.put_u32(vtable_minus_one, col); + } + + #[test] + fn recovers_class_name_vtable_and_bases() { + let mut img = Image::new(); + let rd = BASE + RDATA_RVA; + let da = BASE + DATA_RVA; + + // Base class Foo, then Derived : Foo. + emit_class(&mut img, da + 0x100, ".?AVFoo@ns@@", + rd + 0x100, 0, rd + 0x200, rd + 0x280, rd + 0x300, None, + rd + 0x000); + emit_class(&mut img, da + 0x200, ".?AVDerived@ns@@", + rd + 0x400, 0, rd + 0x500, rd + 0x580, rd + 0x600, Some(da + 0x100), + rd + 0x040); + + let r = analyze(&img.0, BASE, §ions()); + + assert_eq!(r.type_descriptors.len(), 2); + let derived = r.type_descriptors.iter() + .find(|t| t.mangled_name.contains("Derived")).unwrap(); + assert_eq!(derived.demangled_name, "ns::Derived"); + + assert_eq!(r.locators.len(), 2); + // vftable[0] is one word past the word holding the COL pointer. + assert_eq!(r.vtable_to_locator.get(&(rd + 0x044)), Some(&(rd + 0x400))); + assert!(r.vtable_anchors().contains(&(rd + 0x004))); + + let names = r.vtable_class_names(); + assert_eq!(names.get(&(rd + 0x044)), Some(&("ns::Derived".to_string(), 0))); + + // Derived's hierarchy lists itself at index 0 and Foo at index 1. + let mut bases: Vec<_> = r.base_classes.iter() + .filter(|b| b.class_hierarchy == rd + 0x500) + .collect(); + bases.sort_by_key(|b| b.index); + assert_eq!(bases.len(), 2); + assert_eq!(bases[1].name, "ns::Foo"); + assert_eq!(bases[1].mdisp, 4); + assert_eq!(bases[1].pdisp, -1); + } + + #[test] + fn secondary_base_vftable_keeps_its_subobject_offset() { + let mut img = Image::new(); + let rd = BASE + RDATA_RVA; + let da = BASE + DATA_RVA; + emit_class(&mut img, da + 0x100, ".?AVMulti@@", + rd + 0x100, 0x8, rd + 0x200, rd + 0x280, rd + 0x300, None, + rd + 0x000); + + let r = analyze(&img.0, BASE, §ions()); + let names = r.vtable_class_names(); + assert_eq!(names.get(&(rd + 0x004)), Some(&("Multi".to_string(), 0x8))); + } + + #[test] + fn ignores_data_that_merely_looks_like_a_locator() { + // A 5-word run with signature 0 but a `pTypeDescriptor` that hits no + // descriptor must not be reported. + let mut img = Image::new(); + let rd = BASE + RDATA_RVA; + img.put_u32(rd + 0x100, 0); + img.put_u32(rd + 0x104, 0); + img.put_u32(rd + 0x108, 0); + img.put_u32(rd + 0x10C, BASE + DATA_RVA + 0x900); // no TD there + img.put_u32(rd + 0x110, rd + 0x200); + + let r = analyze(&img.0, BASE, §ions()); + assert!(r.locators.is_empty()); + assert!(r.type_descriptors.is_empty()); + } +} diff --git a/crates/sylpheed-xexdb/src/sinks/duckdb.rs b/crates/sylpheed-xexdb/src/sinks/duckdb.rs new file mode 100644 index 00000000..40db1117 --- /dev/null +++ b/crates/sylpheed-xexdb/src/sinks/duckdb.rs @@ -0,0 +1,39 @@ +//! DuckDB sink — appends rich disasm items to the `instructions` table. +//! +//! Column layout matches [`crate::db`]: address, raw, mnemonic, operands, +//! disasm, ext_mnemonic, ext_operands, ext_disasm, target_hex, section, +//! function, label, is_data. + +use duckdb::{Appender, params}; + +use crate::disasm::RichDisasmItem; + +/// Append every item to the appender. Returns the number of rows written. +/// Does NOT flush — the caller decides when to flush, since multiple +/// section iterators typically share one appender. +pub fn append_instructions<'a>( + appender: &mut Appender<'_>, + items: impl IntoIterator>, +) -> duckdb::Result { + let mut count: u64 = 0; + for ri in items { + let t = &ri.item.text; + appender.append_row(params![ + ri.item.addr as i64, + ri.item.raw as i64, + t.mnemonic.as_str(), + t.operands.as_str(), + t.disasm.as_str(), + t.ext_mnemonic.as_deref(), + t.ext_operands.as_deref(), + t.ext_disasm.as_deref(), + t.branch_target.map(|t| t as i64), + ri.section, + ri.function.map(|f| f as i64), + ri.label, + ri.is_data, + ])?; + count += 1; + } + Ok(count) +} diff --git a/crates/sylpheed-xexdb/src/sinks/json.rs b/crates/sylpheed-xexdb/src/sinks/json.rs new file mode 100644 index 00000000..ea56da1d --- /dev/null +++ b/crates/sylpheed-xexdb/src/sinks/json.rs @@ -0,0 +1,65 @@ +//! JSON Lines sink — one structured row per line, constant memory. +//! +//! Suited for piping into `jq`, importing into pandas / DuckDB's +//! `read_json_auto`, or feeding downstream tooling that expects a +//! line-delimited stream rather than a single megaobject. + +use std::io::{self, Write}; + +use serde::Serialize; + +use crate::disasm::RichDisasmItem; + +#[derive(Serialize)] +struct JsonRow<'a> { + addr: u32, + raw: u32, + mnemonic: &'a str, + operands: &'a str, + disasm: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + ext_mnemonic: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + ext_operands: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + ext_disasm: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + branch_target: Option, + section: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + function: Option, + #[serde(skip_serializing_if = "Option::is_none")] + label: Option<&'a str>, + is_data: bool, +} + +/// Write each item as a single JSON object on its own line. Returns the +/// number of rows written. +pub fn write_jsonl<'a, W: Write>( + out: &mut W, + items: impl IntoIterator>, +) -> io::Result { + let mut count: u64 = 0; + for ri in items { + let t = &ri.item.text; + let row = JsonRow { + addr: ri.item.addr, + raw: ri.item.raw, + mnemonic: &t.mnemonic, + operands: &t.operands, + disasm: &t.disasm, + ext_mnemonic: t.ext_mnemonic.as_deref(), + ext_operands: t.ext_operands.as_deref(), + ext_disasm: t.ext_disasm.as_deref(), + branch_target: t.branch_target, + section: ri.section, + function: ri.function, + label: ri.label, + is_data: ri.is_data, + }; + serde_json::to_writer(&mut *out, &row)?; + out.write_all(b"\n")?; + count += 1; + } + Ok(count) +} diff --git a/crates/sylpheed-xexdb/src/sinks/mod.rs b/crates/sylpheed-xexdb/src/sinks/mod.rs new file mode 100644 index 00000000..fdc7afe8 --- /dev/null +++ b/crates/sylpheed-xexdb/src/sinks/mod.rs @@ -0,0 +1,8 @@ +//! Output sinks for [`crate::disasm::RichDisasmItem`] streams. +//! +//! Each sink consumes the same iterator shape and writes to a different +//! medium: human-readable .asm text, JSON Lines, or DuckDB rows. + +pub mod duckdb; +pub mod json; +pub mod text; diff --git a/crates/sylpheed-xexdb/src/sinks/text.rs b/crates/sylpheed-xexdb/src/sinks/text.rs new file mode 100644 index 00000000..2b7de344 --- /dev/null +++ b/crates/sylpheed-xexdb/src/sinks/text.rs @@ -0,0 +1,71 @@ +//! Text sink — renders one .asm instruction line with optional +//! branch-target / data-ref annotations. +//! +//! The full `write_asm` orchestration (section headers, function prologue +//! info, xref comment blocks, hex-dump of data sections) stays in +//! [`crate::formatter`]; this sink only owns the per-instruction line. + +use std::collections::HashMap; +use std::io::{self, Write}; + +use xenia_xex::pe::PeSection; + +use crate::disasm::RichDisasmItem; +use crate::xref::{XrefKind, section_for_addr}; + +/// Render one instruction line: +/// ` 82000000: 60000000 nop` +/// ` 82000004: 4800FFFC bl 0x82000000 ; -> entry_point` +/// ` 82000010: 812A0000 lwz r9, 0(r10) ; [R] 0x828A0000 (.rdata) = dat_…` +pub fn write_instr_line( + out: &mut W, + item: &RichDisasmItem<'_>, + labels: &HashMap, + sections: &[PeSection], + image_base: u32, + data_annotation: Option<(u32, XrefKind)>, +) -> io::Result<()> { + // A word the analysis proved is data (a recovered jump table or its index + // map) must not be printed as if it decoded to something meaningful. + if item.is_data { + let lbl = labels.get(&item.item.raw) + .map(|s| format!(" ; -> {s}")) + .unwrap_or_default(); + return writeln!( + out, + " {:08X}: {:08X} .long 0x{:08X}{}", + item.item.addr, item.item.raw, item.item.raw, lbl, + ); + } + + let disasm_text = item.item.text.display(); + + // Branch-target → label annotation. Uses the structured `branch_target` + // field (cleaner than the legacy "find 0x in disasm string" regex). + let mut annotated = match item.item.text.branch_target { + Some(target) => match labels.get(&target) { + Some(lbl) => format!("{disasm_text:<40} ; -> {lbl}"), + None => disasm_text.to_string(), + }, + None => disasm_text.to_string(), + }; + + if let Some((data_addr, kind)) = data_annotation { + let tag = match kind { + XrefKind::DataRead => "[R]", + XrefKind::DataWrite => "[W]", + _ => "[&]", + }; + let sec = section_for_addr(data_addr, sections, image_base).unwrap_or("?"); + let data_lbl = labels.get(&data_addr) + .map(|s| format!(" = {s}")) + .unwrap_or_default(); + if !annotated.contains("; ->") { + annotated = format!("{annotated:<40} ; {tag} 0x{data_addr:08X} ({sec}){data_lbl}"); + } else { + annotated = format!("{annotated} {tag} 0x{data_addr:08X} ({sec}){data_lbl}"); + } + } + + writeln!(out, " {:08X}: {:08X} {}", item.item.addr, item.item.raw, annotated) +} diff --git a/crates/sylpheed-xexdb/src/sql_views.rs b/crates/sylpheed-xexdb/src/sql_views.rs new file mode 100644 index 00000000..ddcef4e7 --- /dev/null +++ b/crates/sylpheed-xexdb/src/sql_views.rs @@ -0,0 +1,285 @@ +//! Additive SQL views over the Phase-3 ingest tables. +//! +//! These views are created when `--analyze=sql` or `--analyze=both` is set. +//! They are *not* a replacement for the Rust passes ([`crate::xref`], +//! [`crate::func`]) — those still own data-ref resolution and prologue +//! pattern matching. The views cover the cleanly-relational parts: +//! +//! - branch xrefs (self-join on `instructions.target_hex`) +//! - call graph + reachability (recursive CTE over `xrefs`) +//! - convenience joins (function-first-instruction, imports-called) +//! +//! All views are read-only and stable across re-creation: dropping and +//! recreating the database via [`crate::db::DbWriter::open_fresh`] re-runs +//! these definitions. +//! +//! ## Cross-check semantics +//! +//! `v_branch_xrefs` is intended to produce *exactly* the same `(source, +//! target, kind)` tuples as the Rust `xref.rs` first pass — given the same +//! input image. [`crate::db::DbWriter::cross_check_branch_xrefs`] queries +//! the symmetric difference and returns the row counts; both should be +//! zero. A non-zero count means the formatter's `mnemonic` column or the +//! kind-classification CASE drifted out of agreement with `xref.rs`, and +//! is worth a one-line warning at log time. + + +/// Every XDBF string side-by-side across the languages the title ships, so a +/// piece of UI text can be looked up once and read in all locales. +const V_XDBF_TEXT: &str = " +CREATE OR REPLACE VIEW v_xdbf_text AS +SELECT + s.string_id, + MAX(CASE WHEN s.language = 1 THEN s.value END) AS english, + MAX(CASE WHEN s.language = 2 THEN s.value END) AS japanese, + MAX(CASE WHEN s.language = 3 THEN s.value END) AS german, + MAX(CASE WHEN s.language = 4 THEN s.value END) AS french, + MAX(CASE WHEN s.language = 5 THEN s.value END) AS spanish, + MAX(CASE WHEN s.language = 6 THEN s.value END) AS italian +FROM xdbf_strings s +GROUP BY s.string_id; +"; + +/// Achievements joined to their three strings in every shipped language. +const V_XDBF_ACHIEVEMENTS: &str = " +CREATE OR REPLACE VIEW v_xdbf_achievements AS +SELECT + a.id, + a.gamerscore, + s.language, + s.language_name, + n.value AS name, + u.value AS unlocked_desc, + l.value AS locked_desc, + a.image_id +FROM xdbf_achievements a +JOIN (SELECT DISTINCT language, language_name FROM xdbf_strings) s ON TRUE +LEFT JOIN xdbf_strings n ON n.language = s.language AND n.string_id = a.label_id +LEFT JOIN xdbf_strings u ON u.language = s.language AND u.string_id = a.description_id +LEFT JOIN xdbf_strings l ON l.language = s.language AND l.string_id = a.unachieved_id; +"; + +/// `(view_name, CREATE VIEW … SQL)` pairs in the order they must run. +/// Later views may depend on earlier ones (e.g. `v_call_graph` reads +/// `xrefs`, which is the Rust-pass table; `v_branch_xrefs` is independent). +pub const ALL_VIEWS: &[(&str, &str)] = &[ + ("v_branch_xrefs", V_BRANCH_XREFS), + ("v_call_graph", V_CALL_GRAPH), + ("v_reachability_from_entry", V_REACHABILITY_FROM_ENTRY), + ("v_indirect_reachability_from_entry", V_INDIRECT_REACHABILITY_FROM_ENTRY), + ("v_function_first_instruction", V_FUNCTION_FIRST_INSTRUCTION), + ("v_imports_called", V_IMPORTS_CALLED), + ("v_xdbf_text", V_XDBF_TEXT), + ("v_xdbf_achievements", V_XDBF_ACHIEVEMENTS), + ("v_switch_cases", V_SWITCH_CASES), + ("v_class_hierarchy", V_CLASS_HIERARCHY), + ("v_class_methods", V_CLASS_METHODS), + ("v_function_strings", V_FUNCTION_STRINGS), +]; + +/// Branch cross-references derived purely from `instructions.target_hex`. +/// +/// Mirrors the kind classification in [`crate::xref::collect_branch_target`] +/// and the short tags returned by [`crate::xref::XrefKind::tag`] (which are +/// what `xrefs.kind` actually stores): +/// - I-form (`b`/`bl`/`ba`/`bla`): `bl`/`bla` → `"call"`, `b`/`ba` → `"j"` +/// - B-form (`bc`/`bcl`/`bca`/`bcla`): always → `"br"` +/// +/// Indirect branches (`bclr`/`bcctr`) leave `target_hex` NULL and are +/// excluded from this view by design. +const V_BRANCH_XREFS: &str = " +CREATE OR REPLACE VIEW v_branch_xrefs AS +SELECT + address AS source, + target_hex AS target, + CASE + WHEN mnemonic IN ('bl', 'bla') THEN 'call' + WHEN mnemonic IN ('b', 'ba') THEN 'j' + WHEN mnemonic IN ('bc', 'bcl', 'bca', 'bcla') THEN 'br' + ELSE 'br' + END AS kind, + mnemonic AS instruction, + function AS source_func +FROM instructions +WHERE target_hex IS NOT NULL; +"; + +/// Call-graph edges resolved against function names. +/// +/// Reads from `xrefs` (the Rust-pass table) — this is the canonical source +/// for *all* edge kinds, including indirect/data; SQL can't reconstruct the +/// data-ref edges cleanly because they require register tracking. For pure +/// branch edges, `v_branch_xrefs` produces equivalent rows directly from +/// `instructions`. +const V_CALL_GRAPH: &str = " +CREATE OR REPLACE VIEW v_call_graph AS +SELECT + x.source AS caller_addr, + cf.name AS caller_name, + x.target AS callee_addr, + tf.name AS callee_name, + x.kind AS edge_kind +FROM xrefs x +LEFT JOIN functions cf ON cf.address = x.source_func +LEFT JOIN functions tf ON tf.address = x.target +WHERE x.kind = 'call'; +"; + +/// Transitive function-level reachability from the entry point over +/// call/jump/branch edges. Useful for finding dead code +/// (`SELECT address FROM functions +/// WHERE address NOT IN (SELECT addr FROM v_reachability_from_entry)`) +/// and for scoping analysis to the live subset. +/// +/// Seeds from the function containing the `entry_point` label and walks +/// the recursive closure: a reachable function's instructions branch into +/// the functions enclosing the branch targets, which are then reachable +/// in turn. `UNION` (not `UNION ALL`) deduplicates to handle call-graph +/// cycles (recursive functions, mutually-recursive pairs). +const V_REACHABILITY_FROM_ENTRY: &str = " +CREATE OR REPLACE VIEW v_reachability_from_entry AS +WITH RECURSIVE reach(fn) AS ( + SELECT i.function FROM instructions i + JOIN labels l ON l.address = i.address + WHERE l.name = 'entry_point' AND i.function IS NOT NULL + UNION + SELECT tgt.function FROM xrefs x + JOIN instructions src ON src.address = x.source + JOIN instructions tgt ON tgt.address = x.target + JOIN reach r ON src.function = r.fn + WHERE x.kind IN ('call', 'j', 'br', 'jt') + AND tgt.function IS NOT NULL +) +SELECT fn AS addr FROM reach; +"; + +/// Reachability extended over `kind='ind_call'` edges from M5. Strict +/// superset of `v_reachability_from_entry` — every fn there is also here, +/// plus any function reached only via a vtable bcctrl whose vtable+slot +/// the M5 dataflow could resolve. Sample 5 newly-reachable PCs in canary +/// before trusting widely; the analysis intentionally leaves out alias- +/// dependent indirect calls (vtable loaded from a `this` field). +const V_INDIRECT_REACHABILITY_FROM_ENTRY: &str = " +CREATE OR REPLACE VIEW v_indirect_reachability_from_entry AS +WITH RECURSIVE reach(fn) AS ( + SELECT i.function FROM instructions i + JOIN labels l ON l.address = i.address + WHERE l.name = 'entry_point' AND i.function IS NOT NULL + UNION + SELECT tgt.function FROM xrefs x + JOIN instructions src ON src.address = x.source + JOIN instructions tgt ON tgt.address = x.target + JOIN reach r ON src.function = r.fn + WHERE x.kind IN ('call', 'ind_call', 'j', 'br', 'jt') + AND tgt.function IS NOT NULL +) +SELECT fn AS addr FROM reach; +"; + +/// Convenience join: each function's first decoded instruction. Useful for +/// quickly inspecting prologue patterns without computing offsets manually. +const V_FUNCTION_FIRST_INSTRUCTION: &str = " +CREATE OR REPLACE VIEW v_function_first_instruction AS +SELECT + f.address AS function_addr, + f.name AS function_name, + i.raw AS first_raw, + i.disasm AS first_disasm, + i.ext_disasm AS first_ext_disasm +FROM functions f +JOIN instructions i ON i.address = f.address; +"; + +/// Per-function summary of which kernel/library imports it calls. Joins +/// xrefs (call edges) against the labels table to surface import names. +const V_IMPORTS_CALLED: &str = " +CREATE OR REPLACE VIEW v_imports_called AS +SELECT + x.source_func AS function_addr, + f.name AS function_name, + x.target AS import_addr, + l.name AS import_name +FROM xrefs x +JOIN labels l ON l.address = x.target +LEFT JOIN functions f ON f.address = x.source_func +WHERE x.kind = 'call' + AND l.kind = 'import'; +"; + +/// Every recovered `switch` case, joined to the dispatching function and to +/// the label on the case body. One row per case *value* — several rows can +/// share a `target_address` when case values fall through to one body. +const V_SWITCH_CASES: &str = " +CREATE OR REPLACE VIEW v_switch_cases AS +SELECT + jt.bctr_pc AS dispatch_pc, + jt.function AS function_addr, + f.name AS function_name, + jt.kind AS table_kind, + jt.table_address AS table_address, + e.case_index AS case_index, + e.target_address AS target_address, + l.name AS target_label +FROM jump_tables jt +JOIN jump_table_entries e ON e.bctr_pc = jt.bctr_pc +LEFT JOIN functions f ON f.address = jt.function +LEFT JOIN labels l ON l.address = e.target_address; +"; + +/// The C++ inheritance graph as recovered from RTTI. Index 0 of a base-class +/// array is the class itself and is excluded, so every row is a genuine +/// `derived -> base` edge carrying the displacement triple needed to find the +/// base subobject inside an instance. +const V_CLASS_HIERARCHY: &str = " +CREATE OR REPLACE VIEW v_class_hierarchy AS +SELECT DISTINCT + dtd.demangled_name AS derived_class, + b.name AS base_class, + b.base_index AS base_index, + b.mdisp AS mdisp, + b.pdisp AS pdisp, + b.vdisp AS vdisp, + c.vtable_address AS derived_vtable +FROM rtti_base_classes b +JOIN rtti_locators c ON c.class_hierarchy = b.class_hierarchy +JOIN rtti_type_descriptors dtd ON dtd.address = c.type_descriptor +WHERE b.base_index > 0; +"; + +/// Virtual methods per class, resolved through the RTTI-named vtable. The +/// authoritative counterpart to querying `methods` by an `ANON_Class_*` name. +const V_CLASS_METHODS: &str = " +CREATE OR REPLACE VIEW v_class_methods AS +SELECT + td.demangled_name AS class_name, + c.subobject_offset AS subobject_offset, + v.address AS vtable_address, + m.slot AS slot, + m.function_address AS method_addr, + f.name AS method_name, + f.has_eh AS method_has_eh +FROM rtti_locators c +JOIN rtti_type_descriptors td ON td.address = c.type_descriptor +JOIN vtables v ON v.address = c.vtable_address +JOIN methods m ON m.vtable_address = v.address +LEFT JOIN functions f ON f.address = m.function_address; +"; + +/// Which function references which string literal. The single most useful +/// orientation query in a stripped binary: it is how you find the code behind +/// a message you can see on screen. +const V_FUNCTION_STRINGS: &str = " +CREATE OR REPLACE VIEW v_function_strings AS +SELECT + x.source_func AS function_addr, + f.name AS function_name, + x.source AS reference_pc, + x.kind AS reference_kind, + s.address AS string_addr, + s.encoding AS encoding, + s.content AS content +FROM xrefs x +JOIN strings s ON s.address = x.target +LEFT JOIN functions f ON f.address = x.source_func +WHERE x.kind IN ('ref', 'read'); +"; diff --git a/crates/sylpheed-xexdb/src/static_init.rs b/crates/sylpheed-xexdb/src/static_init.rs new file mode 100644 index 00000000..3d3876a4 --- /dev/null +++ b/crates/sylpheed-xexdb/src/static_init.rs @@ -0,0 +1,399 @@ +//! M11.5 — static-initialiser driver detection. +//! +//! MSVC's CRT static-init driver (`_initterm` / `_initterm_e` style) +//! is a tight loop that walks a function-pointer array between two +//! addresses, calling each non-null entry: +//! +//! ```text +//! loop_top: +//! cmpw[l] rA, rB ; compare cursor vs end +//! beq done +//! lwz rN, 0(rA) ; load fn ptr +//! cmpwi rN, 0 ; null-skip (optional) +//! beq skip +//! mtctr rN +//! bcctrl +//! skip: +//! addi rA, rA, 4 +//! b loop_top +//! done: +//! ``` +//! +//! Two static addresses (`rA` and `rB` at loop start) bracket the +//! function-pointer array. Detection strategy: scan every function for +//! the canonical pattern; when found, extract the array bounds and +//! emit one row in `function_pointer_arrays` with `kind='static_init'`. +//! +//! ### What this layer does +//! +//! - Walks each function looking for an `lwz; mtctr; bcctrl` sequence +//! inside a loop bounded by a comparison against another constant. +//! - When the loop's cursor register is observed to be incremented by +//! exactly 4 per iteration, classifies it as a static-init driver +//! and records the (start, end) array bounds. +//! +//! ### What this layer does NOT do +//! +//! - No support for back-to-back drivers sharing a common loop trampoline. +//! - No detection of the M11 prologue-style heuristic; M11.5 is +//! structure-grounded and replaces the prior heuristic where it fires. +//! - Does not handle CRT-style `_initterm_e` (the `_e` variant returns +//! a status); detection works for both as long as the loop shape +//! matches. +//! +//! Reference: Microsoft CRT `crt0.c::_initterm` source pattern. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use crate::func::FuncAnalysis; +use crate::funcptr_arrays::FuncPtrArray; +use sylpheed_xex::pe::PeSection; + +#[derive(Debug, Clone, Copy)] +pub struct StaticInitDriver { + /// VA of the driver function (the one containing the loop). + pub driver_function: u32, + /// VA of the array start. + pub array_start: u32, + /// VA one-past-end of the array. + pub array_end: u32, + /// Detected length in slots. + pub length: u32, +} + +#[derive(Debug, Default)] +pub struct StaticInitResult { + pub drivers: Vec, + /// Newly-detected static-init arrays, ready to be merged into the + /// `function_pointer_arrays` table with `kind='static_init'`. + pub arrays: Vec, +} + +const OP_ADDI: u32 = 14; +const OP_ADDIS: u32 = 15; +const OP_BCCTR: u32 = 19; +const OP_LWZ: u32 = 32; +const OP_X_FORM: u32 = 31; + +#[derive(Debug, Clone, Copy)] +enum RegVal { + Const(u32), +} + +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + func_analysis: &FuncAnalysis, + function_starts: &BTreeSet, + labels: &HashMap, +) -> StaticInitResult { + let started = std::time::Instant::now(); + let block_boundaries: HashSet = labels.keys().copied().collect(); + + let mut drivers: Vec = Vec::new(); + + for (&fn_start, fi) in &func_analysis.functions { + if fi.is_saverestore { continue; } + if let Some(d) = scan_function_for_driver( + pe, image_base, fn_start, fi.end, &block_boundaries, + ) { + drivers.push(d); + } + } + + // Build arrays from the discovered drivers + section data. + let mut arrays: Vec = Vec::new(); + for d in &drivers { + if let Some(entries) = read_array(pe, image_base, sections, d.array_start, d.array_end, function_starts) { + arrays.push(FuncPtrArray { + address: d.array_start, + length: entries.len() as u32, + kind: "static_init", + entries, + }); + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("analysis.phase_ms", "phase" => "static_init").record(elapsed_ms); + tracing::info!( + drivers = drivers.len(), + arrays = arrays.len(), + elapsed_ms, + "M11.5 static-init driver scan complete", + ); + + StaticInitResult { drivers, arrays } +} + +/// Read the function-pointer array between [start, end) from .rdata/.data. +/// NULL entries are skipped (CRT _initterm explicitly tolerates them). +/// Non-function-start entries cause us to bail (the driver bounds were +/// likely misidentified). +fn read_array( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + start: u32, + end: u32, + function_starts: &BTreeSet, +) -> Option> { + if end <= start || (end - start) > 4096 { return None; } + let _section = sections.iter().find(|s| { + let lo = image_base + s.virtual_address; + let hi = lo + s.virtual_size; + start >= lo && end <= hi && (s.name == ".rdata" || s.name == ".data") + })?; + let mut entries = Vec::new(); + let mut p = start; + while p < end { + let off = p.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + let v = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]); + if v != 0 { + if !function_starts.contains(&v) { return None; } + entries.push(v); + } + p = p.wrapping_add(4); + } + if entries.is_empty() { return None; } + Some(entries) +} + +/// Walk one function looking for the canonical static-init driver shape. +/// Returns Some when the loop's cursor register starts at a known constant +/// `rA`, terminates at another known constant `rB` via a compare, and +/// increments by 4 per iteration with an `lwz; mtctr; bcctrl` body. +fn scan_function_for_driver( + pe: &[u8], + image_base: u32, + fn_start: u32, + fn_end: u32, + block_boundaries: &HashSet, +) -> Option { + let mut reg: [Option; 32] = [None; 32]; + // Pattern features observed during the walk. + let mut cursor_reg: Option = None; + let mut cursor_init: Option = None; + let mut end_reg: Option = None; + let mut end_init: Option = None; + let mut saw_lwz_through_cursor = false; + let mut saw_mtctr = false; + let mut saw_bcctrl = false; + let mut saw_addi_4 = false; + + let mut pc = fn_start; + while pc < fn_end { + if pc != fn_start && block_boundaries.contains(&pc) { + // Heuristic: when we cross a basic-block boundary that + // is not the loop-top, accumulated state remains valid for + // pattern-matching purposes — but we drop register Const + // tracking to be safe. + reg = [None; 32]; + } + let off = pc.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { break; } + let instr = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]); + let op = instr >> 26; + let rd = ((instr >> 21) & 0x1F) as usize; + let ra = ((instr >> 16) & 0x1F) as usize; + let simm = ((instr & 0xFFFF) as i16) as i32; + let uimm = instr & 0xFFFF; + + match op { + OP_ADDIS if ra == 0 => reg[rd] = Some(RegVal::Const(uimm << 16)), + OP_ADDIS => { + if let Some(RegVal::Const(b)) = reg[ra] { + reg[rd] = Some(RegVal::Const(b.wrapping_add(uimm << 16))); + } else { reg[rd] = None; } + } + OP_ADDI if ra != 0 => { + let prev = reg[ra]; + if let Some(RegVal::Const(b)) = prev { + let v = b.wrapping_add(simm as u32); + reg[rd] = Some(RegVal::Const(v)); + // Was this an `addi r, r, 4`? Mark cursor-increment. + if rd == ra && simm == 4 { + if Some(rd) == cursor_reg { + saw_addi_4 = true; + } + } else if cursor_reg.is_none() { + // First time we see a known-constant register that + // *could* be the cursor — defer the choice until we + // see a load through it. + cursor_init = Some(v); + cursor_reg = Some(rd); + } else if end_reg.is_none() && Some(rd) != cursor_reg { + end_init = Some(v); + end_reg = Some(rd); + } + } else { reg[rd] = None; } + } + OP_LWZ => { + if ra != 0 && Some(ra) == cursor_reg { + saw_lwz_through_cursor = true; + } + reg[rd] = None; + } + OP_X_FORM => { + let xo = (instr >> 1) & 0x3FF; + if xo == 467 { + let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F); + if spr == 9 && saw_lwz_through_cursor { saw_mtctr = true; } + } + if xo != 444 && xo != 467 { reg[rd] = None; } + } + OP_BCCTR => { + let xo = (instr >> 1) & 0x3FF; + let lk = (instr & 1) != 0; + if xo == 528 && lk && saw_mtctr { + saw_bcctrl = true; + } + } + 18 => { + if (instr & 1) != 0 { + for r in 0..=12 { reg[r] = None; } + } + } + 16 => { + if (instr & 1) != 0 { + for r in 0..=12 { reg[r] = None; } + } + } + _ => {} + } + pc = pc.wrapping_add(4); + } + + // Validate that all four pattern features fired. + if !(saw_lwz_through_cursor && saw_mtctr && saw_bcctrl && saw_addi_4) { + return None; + } + let cursor_init = cursor_init?; + let end_init = end_init?; + if end_init <= cursor_init { return None; } + if end_init - cursor_init > 4096 { return None; } + + Some(StaticInitDriver { + driver_function: fn_start, + array_start: cursor_init, + array_end: end_init, + length: (end_init - cursor_init) / 4, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::func::FuncInfo; + use std::collections::BTreeMap; + use sylpheed_xex::pe::PeSection; + + fn mk_section(name: &str, va: u32, size: u32) -> PeSection { + PeSection { + name: name.into(), + virtual_address: va, virtual_size: size, + raw_offset: va, raw_size: size, + flags: 0x4000_0040, + } + } + fn write_be(pe: &mut [u8], at: usize, v: u32) { + pe[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + + #[test] + fn detects_canonical_initterm_loop() { + // Build a tiny driver that loops over a 3-entry array. + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x4000]; + + // Array at .rdata + 0x800: 3 function pointers. + let arr_va_lo = 0x800u32; + let fns = [image_base + 0x2000, image_base + 0x2010, image_base + 0x2020]; + for (i, p) in fns.iter().enumerate() { + write_be(&mut pe, arr_va_lo as usize + i * 4, *p); + } + let array_start = image_base + arr_va_lo; + let array_end = array_start + 12; + + // Driver function at 0x82001000: + // lis r3, hi(array_start) + // addi r3, r3, lo(array_start) + // lis r4, hi(array_end) + // addi r4, r4, lo(array_end) + // lwz r5, 0(r3) + // mtctr r5 + // bcctrl + // addi r3, r3, 4 + // blr + let driver = 0x82001000u32; + let off = (driver - image_base) as usize; + let lis_r3 = (15u32 << 26) | (3 << 21) | ((array_start >> 16) as u32); + let addi_r3 = (14u32 << 26) | (3 << 21) | (3 << 16) | ((array_start as u16) as u32); + let lis_r4 = (15u32 << 26) | (4 << 21) | ((array_end >> 16) as u32); + let addi_r4 = (14u32 << 26) | (4 << 21) | (4 << 16) | ((array_end as u16) as u32); + let lwz = (32u32 << 26) | (5 << 21) | (3 << 16); + let mtctr = (31u32 << 26) | (5 << 21) | (9 << 16) | (467 << 1); + let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1; + let addi_inc = (14u32 << 26) | (3 << 21) | (3 << 16) | 4; + let blr = (19u32 << 26) | (20 << 21) | (16 << 1); + for (i, w) in [lis_r3, addi_r3, lis_r4, addi_r4, lwz, mtctr, bcctrl, addi_inc, blr].iter().enumerate() { + write_be(&mut pe, off + i * 4, *w); + } + + let mut functions: BTreeMap = BTreeMap::new(); + functions.insert(driver, FuncInfo { + start: driver, end: driver + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: false, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + let fa = FuncAnalysis { + functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new(), + }; + + let sections = vec![mk_section(".rdata", 0x800, 0x100)]; + let mut starts = BTreeSet::new(); + for &p in &fns { starts.insert(p); } + let labels: HashMap = HashMap::new(); + + let r = analyze(&pe, image_base, §ions, &fa, &starts, &labels); + + assert_eq!(r.drivers.len(), 1, "should detect one driver"); + let d = &r.drivers[0]; + assert_eq!(d.driver_function, driver); + assert_eq!(d.array_start, array_start); + assert_eq!(d.array_end, array_end); + assert_eq!(d.length, 3); + + assert_eq!(r.arrays.len(), 1); + assert_eq!(r.arrays[0].kind, "static_init"); + assert_eq!(r.arrays[0].entries.len(), 3); + } + + #[test] + fn rejects_function_without_pattern() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x4000]; + let driver = 0x82001000u32; + // Just a blr — no driver pattern. + let blr = (19u32 << 26) | (20 << 21) | (16 << 1); + write_be(&mut pe, (driver - image_base) as usize, blr); + + let mut functions: BTreeMap = BTreeMap::new(); + functions.insert(driver, FuncInfo { + start: driver, end: driver + 0x40, frame_size: 0, saved_gprs: 0, + is_leaf: true, is_saverestore: false, + pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false, + }); + let fa = FuncAnalysis { + functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new(), + }; + let sections = vec![mk_section(".rdata", 0x800, 0x100)]; + let starts: BTreeSet = BTreeSet::new(); + let labels: HashMap = HashMap::new(); + let r = analyze(&pe, image_base, §ions, &fa, &starts, &labels); + assert_eq!(r.drivers.len(), 0); + } +} diff --git a/crates/sylpheed-xexdb/src/strings.rs b/crates/sylpheed-xexdb/src/strings.rs new file mode 100644 index 00000000..1d7266ef --- /dev/null +++ b/crates/sylpheed-xexdb/src/strings.rs @@ -0,0 +1,479 @@ +//! String / constant-pool detection in the initialised data sections. +//! +//! Scans the `.rdata` section for runs of printable ASCII or null-terminated +//! UTF-16LE characters of length ≥ 6, emitting one row per discovered string. +//! Cross-references against `xrefs.target` are computed by the caller — +//! this module only finds the strings; downstream queries can join. +//! +//! ### What this layer does NOT do +//! +//! - No UTF-8 multibyte detection — Xbox 360 game binaries reliably use +//! ASCII for debug strings and UTF-16LE for localised text. +//! - Only the file-backed part of a section is scanned: the tail of `.data` +//! past `raw_size` is BSS and contains nothing but zeros at rest. +//! - Wide strings on Xbox 360 are little-endian (compiler convention even +//! on this big-endian platform); we do NOT try big-endian UTF-16. +//! - No language detection / classification beyond encoding. +//! +//! Extends the original ASCII / UTF-16LE pass with Shift_JIS detection +//! (Sylpheed is originally Japanese — likely yields mission/UI text +//! invisible to ASCII-only) and UTF-8 multi-byte detection. +//! +//! Reference: `objdump -s` `.rdata` walks rely on the same heuristic; +//! Shift_JIS lead/trail byte ranges per JIS X 0208. + +use sylpheed_xex::pe::PeSection; + +/// One detected string. +#[derive(Debug, Clone)] +pub struct DetectedString { + /// Absolute VA of the first byte. + pub address: u32, + /// `"ascii"` | `"utf16le"` | `"shift_jis"` | `"utf8"`. + pub encoding: &'static str, + /// Length in bytes (excluding the NUL terminator). + pub length: u32, + /// UTF-8 representation of the string content. + pub content: String, + /// Name of the PE section the string lives in (`.rdata` / `.data`). + pub section: String, +} + +/// Scan the initialised data sections for ASCII / UTF-16LE / Shift_JIS / UTF-8 +/// strings. +/// +/// `.data` is scanned as well as `.rdata`: a lot of a game's string material — +/// mutable tables, and every RTTI type-descriptor name — lives there, and +/// leaving it out is why this table comes back nearly empty on real titles. +/// The `section` column lets a consumer separate the two again. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Vec { + let started = std::time::Instant::now(); + let mut out: Vec = Vec::new(); + + for section in sections { + if !matches!(section.name.as_str(), ".rdata" | ".data") { continue; } + let raw_start = section.virtual_address as usize; + // Clamp to the file-backed extent — everything past `raw_size` is BSS. + let backed = section.virtual_size.min(section.raw_size) as usize; + let raw_end = (raw_start + backed).min(pe.len()); + if raw_start >= raw_end { continue; } + let bytes = &pe[raw_start..raw_end]; + let va_base = image_base + section.virtual_address; + + let before = out.len(); + scan_ascii(bytes, va_base, &mut out); + scan_utf16le(bytes, va_base, &mut out); + scan_shift_jis(bytes, va_base, &mut out); + scan_utf8(bytes, va_base, &mut out); + for s in &mut out[before..] { + s.section = section.name.clone(); + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + let n_ascii = out.iter().filter(|s| s.encoding == "ascii").count(); + let n_utf16 = out.iter().filter(|s| s.encoding == "utf16le").count(); + let n_sjis = out.iter().filter(|s| s.encoding == "shift_jis").count(); + let n_utf8 = out.iter().filter(|s| s.encoding == "utf8").count(); + metrics::histogram!("analysis.phase_ms", "phase" => "strings").record(elapsed_ms); + tracing::info!( + ascii = n_ascii, + utf16le = n_utf16, + shift_jis = n_sjis, + utf8 = n_utf8, + total = out.len(), + elapsed_ms, + "string scan complete" + ); + out +} + +const MIN_LEN: usize = 6; + +fn is_printable_ascii(b: u8) -> bool { + // Printable + the common whitespace characters used in real strings. + matches!(b, 0x20..=0x7E | b'\t' | b'\n' | b'\r') +} + +fn scan_ascii(bytes: &[u8], va_base: u32, out: &mut Vec) { + let mut i = 0; + while i < bytes.len() { + if !is_printable_ascii(bytes[i]) { + i += 1; + continue; + } + let start = i; + while i < bytes.len() && is_printable_ascii(bytes[i]) { i += 1; } + let run_len = i - start; + // Require NUL termination and minimum length. + if run_len >= MIN_LEN && i < bytes.len() && bytes[i] == 0 { + let s = std::str::from_utf8(&bytes[start..i]).unwrap_or(""); + out.push(DetectedString { + address: va_base + start as u32, + encoding: "ascii", + length: run_len as u32, + content: s.to_string(), + section: String::new(), + }); + } + // Skip the NUL (if any) before continuing. + if i < bytes.len() && bytes[i] == 0 { i += 1; } + } +} + +fn scan_utf16le(bytes: &[u8], va_base: u32, out: &mut Vec) { + // UTF-16LE strings are 2-byte aligned in MSVC output. Walk on even + // offsets to avoid misaligned hits. + let mut i = 0; + while i + 2 <= bytes.len() { + if !i.is_multiple_of(2) { i += 1; continue; } + let lo = bytes[i]; + let hi = bytes[i + 1]; + // Restrict scan-start to printable ASCII range with a zero high byte — + // this is what real Xbox 360 wide strings look like. + if hi != 0 || !is_printable_ascii(lo) { + i += 2; + continue; + } + let start = i; + let mut codeunits: Vec = Vec::new(); + while i + 2 <= bytes.len() { + let l = bytes[i]; + let h = bytes[i + 1]; + if h != 0 || !is_printable_ascii(l) { break; } + codeunits.push((h as u16) << 8 | l as u16); + i += 2; + } + // Require NUL u16 terminator. + let nul_terminated = i + 2 <= bytes.len() && bytes[i] == 0 && bytes[i + 1] == 0; + if codeunits.len() >= MIN_LEN && nul_terminated { + let s: String = String::from_utf16_lossy(&codeunits); + out.push(DetectedString { + address: va_base + start as u32, + encoding: "utf16le", + length: ((i - start) as u32), + content: s, + section: String::new(), + }); + } + // Skip past the terminator. + if nul_terminated { i += 2; } + } +} + +/// Per JIS X 0208: Shift_JIS lead byte is [0x81, 0x9F] u [0xE0, 0xEF]; +/// trail byte is [0x40, 0x7E] u [0x80, 0xFC]. +/// +/// Half-width katakana (0xA1..=0xDF) is deliberately *not* accepted as string +/// content. It is legal Shift_JIS, but this binary's Japanese text never uses +/// it, while 0xA1..=0xDF is extremely common in the float and pointer tables +/// that share `.rdata` — admitting it turned the scan into a noise generator +/// (837 detections, of which the overwhelming majority were IEEE-754 arrays: +/// `3f 66 66 66` = 0.9f reads as "fff"). +fn is_sjis_lead(b: u8) -> bool { + (0x81..=0x9F).contains(&b) || (0xE0..=0xEF).contains(&b) +} +fn is_sjis_trail(b: u8) -> bool { + (0x40..=0x7E).contains(&b) || (0x80..=0xFC).contains(&b) +} + +/// A character that can plausibly appear in a Japanese debug/UI string: +/// printable ASCII, CJK punctuation and kana, CJK ideographs, or full-width +/// ASCII. +fn is_text_like(ch: char) -> bool { + let o = ch as u32; + matches!(o, 0x20..=0x7E) + || matches!(ch, '\t' | '\n' | '\r') + || is_wide(ch) +} + +/// A full-width character — kana, CJK punctuation, ideograph, or full-width +/// ASCII. Used to tell "real text" from a lucky byte pair. +fn is_wide(ch: char) -> bool { + let o = ch as u32; + (0x3000..=0x30FF).contains(&o) || (0x4E00..=0x9FFF).contains(&o) || (0xFF01..=0xFF5E).contains(&o) +} + +/// True when `t` contains a lone ASCII character with a full-width character +/// on *both* sides. +/// +/// This is the Shift_JIS resynchronisation signal. A scan that starts one byte +/// early pairs the wrong lead with the wrong trail and typically produces a +/// stray kanji plus an orphaned ASCII letter before the real text resumes: +/// the run at 0x820a4b9f decodes as `帥Vステムマネージャ開始` when the actual +/// string is `システムマネージャ開始` at 0x820a4ba0. Genuine text mixes ASCII in +/// *runs* (`render_stateスタックオーバーフロー`, `size=%d`), never as a single +/// character wedged between two wide ones. +fn has_isolated_ascii(t: &str) -> bool { + let chars: Vec = t.chars().collect(); + (1..chars.len().saturating_sub(1)).any(|k| { + !is_wide(chars[k]) && is_wide(chars[k - 1]) && is_wide(chars[k + 1]) + }) +} + +/// Decode `raw` as Shift_JIS, rejecting anything that is not convincingly +/// Japanese text. Returns the UTF-8 form on success. +fn decode_sjis(raw: &[u8]) -> Option { + let (text, _, had_errors) = encoding_rs::SHIFT_JIS.decode(raw); + if had_errors { + return None; + } + let t = text.into_owned(); + // Require real kana somewhere. Arbitrary binary readily decodes to + // obscure kanji, but hiragana/katakana (U+3040..U+30FF) essentially never + // appear by accident and are ubiquitous in genuine Japanese. + let has_kana = t.chars().any(|c| ('\u{3040}'..='\u{30FF}').contains(&c)); + if t.chars().count() >= 4 && has_kana && t.chars().all(is_text_like) && !has_isolated_ascii(&t) { + Some(t) + } else { + None + } +} + +/// Scan for Shift_JIS strings — NUL-terminated runs of >= `MIN_LEN` bytes made +/// of printable ASCII and valid lead+trail pairs, with at least one pair. +/// +/// Each accepted run is *resynchronised*: the emitted string starts at the +/// earliest offset within the run whose full decode passes [`decode_sjis`], so +/// a run that begins mid-character reports the true string address rather than +/// a mangled one. +fn scan_shift_jis(bytes: &[u8], va_base: u32, out: &mut Vec) { + let mut i = 0; + while i < bytes.len() { + let start = i; + let mut has_multibyte = false; + let mut nbytes = 0; + while i < bytes.len() { + let b = bytes[i]; + if is_sjis_lead(b) && i + 1 < bytes.len() && is_sjis_trail(bytes[i + 1]) { + has_multibyte = true; + nbytes += 2; + i += 2; + } else if is_printable_ascii(b) { + nbytes += 1; + i += 1; + } else { + break; + } + } + let end = i; + if has_multibyte && nbytes >= MIN_LEN && end < bytes.len() && bytes[end] == 0 { + for s in start..end { + if let Some(text) = decode_sjis(&bytes[s..end]) { + out.push(DetectedString { + address: va_base + s as u32, + encoding: "shift_jis", + length: (end - s) as u32, + content: text, + section: String::new(), + }); + break; + } + } + i = end + 1; // skip NUL + } else { + i = start + 1; + if i < bytes.len() && bytes[i] == 0 { i += 1; } + } + } +} + +/// Scan for UTF-8 strings carrying multi-byte sequences (we already +/// catch pure-ASCII via `scan_ascii`). Validates 2/3-byte sequences; +/// 4-byte (supplementary plane) is uncommon in game text and skipped. +fn scan_utf8(bytes: &[u8], va_base: u32, out: &mut Vec) { + let mut i = 0; + while i < bytes.len() { + let start = i; + let mut has_multibyte = false; + let mut nbytes = 0; + while i < bytes.len() { + let b = bytes[i]; + if b < 0x80 { + if !is_printable_ascii(b) { break; } + nbytes += 1; + i += 1; + } else if (b & 0xE0) == 0xC0 { + // 2-byte: 110xxxxx 10xxxxxx + if i + 1 >= bytes.len() || (bytes[i + 1] & 0xC0) != 0x80 { break; } + has_multibyte = true; + nbytes += 2; + i += 2; + } else if (b & 0xF0) == 0xE0 { + // 3-byte: 1110xxxx 10xxxxxx 10xxxxxx + if i + 2 >= bytes.len() + || (bytes[i + 1] & 0xC0) != 0x80 + || (bytes[i + 2] & 0xC0) != 0x80 { break; } + has_multibyte = true; + nbytes += 3; + i += 3; + } else { + break; + } + } + if has_multibyte + && nbytes >= MIN_LEN + && i < bytes.len() && bytes[i] == 0 + && let Ok(s) = std::str::from_utf8(&bytes[start..i]) + { + out.push(DetectedString { + address: va_base + start as u32, + encoding: "utf8", + length: nbytes as u32, + content: s.to_string(), + section: String::new(), + }); + i += 1; // skip NUL + } else { + i = start + 1; + if i < bytes.len() && bytes[i] == 0 { i += 1; } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mk_section(name: &str, va: u32, size: u32) -> PeSection { + PeSection { + name: name.into(), + virtual_address: va, + virtual_size: size, + raw_offset: va, + raw_size: size, + flags: 0x4000_0040, + } + } + + #[test] + fn detects_ascii_string() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + let s = b"Hello, world!\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + assert_eq!(strings.len(), 1); + assert_eq!(strings[0].encoding, "ascii"); + assert_eq!(strings[0].content, "Hello, world!"); + assert_eq!(strings[0].address, image_base + 0x1000); + } + + #[test] + fn rejects_short_runs() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + let s = b"Hi\0longer string here\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + assert_eq!(strings.len(), 1); + assert_eq!(strings[0].content, "longer string here"); + } + + #[test] + fn detects_utf16le_string() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + // "Hello!" in UTF-16LE + NUL u16 + let s: &[u8] = b"H\0e\0l\0l\0o\0!\0\0\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + // Both ASCII and UTF-16 may detect — UTF-16 should find it as wide; + // ASCII pass scans bytes and won't see this as a contiguous run + // because of the interleaved 0 bytes (non-printable). + let utf16: Vec<_> = strings.iter().filter(|s| s.encoding == "utf16le").collect(); + assert!(utf16.iter().any(|s| s.content == "Hello!")); + } + + #[test] + fn detects_shift_jis_string() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + // "ABC" + SJIS hiragana あ (0x82 0xA0) + い (0x82 0xA2) + NUL. + let s: &[u8] = b"ABC\x82\xA0\x82\xA2\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + let sjis: Vec<_> = strings.iter().filter(|s| s.encoding == "shift_jis").collect(); + assert_eq!(sjis.len(), 1); + // Decoded to real UTF-8, not rendered as escaped bytes. + assert_eq!(sjis[0].content, "ABCあい"); + assert_eq!(sjis[0].address, image_base + 0x1000); + } + + #[test] + fn shift_jis_rejects_float_table_noise() { + // Four IEEE-754 floats (0.85, 0.9, 0.8, 0.7). Every byte satisfies the + // Shift_JIS lead/trail ranges, so the byte-range test alone accepts it. + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + let s: &[u8] = b"\x3f\x59\x99\x9a\x3f\x66\x66\x66\x3f\x4c\xcc\xcd\x3f\x33\x33\x33\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + assert!(strings.iter().all(|s| s.encoding != "shift_jis"), + "float table must not be reported as Japanese text"); + } + + #[test] + fn shift_jis_resynchronises_to_true_start() { + // Mirrors 0x820a4b9f in the reference title: binary data runs straight + // into a real string, and a naive forward scan mis-pairs the boundary + // byte, yielding `帥Vステム…` one byte early instead of `システム…`. + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + // Exact bytes from that site: a trailing 0x90 from the preceding + // float pairs with the string's first byte (0x83) to form 帥, which + // orphans the 0x56 as an ASCII 'V' before the text resumes. + // 0x90 シ ス テ ム + let s: &[u8] = b"\x90\x83\x56\x83\x58\x83\x65\x83\x80\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + let sjis: Vec<_> = strings.iter().filter(|s| s.encoding == "shift_jis").collect(); + assert_eq!(sjis.len(), 1); + assert_eq!(sjis[0].content, "システム"); + // Reported at the true start, one byte past the run's beginning. + assert_eq!(sjis[0].address, image_base + 0x1000 + 1); + } + + #[test] + fn detects_utf8_multibyte_string() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + let off = 0x1000usize; + // "Café" = 'C', 'a', 'f', 0xC3 0xA9 (é), then more ASCII to reach min length + let s: &[u8] = b"Caf\xC3\xA9eteria\0"; + pe[off..off + s.len()].copy_from_slice(s); + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + let u8s: Vec<_> = strings.iter().filter(|s| s.encoding == "utf8").collect(); + assert_eq!(u8s.len(), 1); + assert_eq!(u8s[0].content, "Café".to_string() + "eteria"); + } + + #[test] + fn requires_nul_terminator() { + let image_base = 0x82000000u32; + let mut pe = vec![0u8; 0x1100]; + // No trailing NUL — should NOT be detected. + let off = 0x1000usize; + let s = b"abcdefghij"; + pe[off..off + s.len()].copy_from_slice(s); + // Fill rest of section with 0xFF so the run terminates cleanly without NUL. + for j in off + s.len()..off + 0x100 { pe[j] = 0xFF; } + let sections = vec![mk_section(".rdata", 0x1000, 0x100)]; + let strings = analyze(&pe, image_base, §ions); + assert_eq!(strings.len(), 0); + } +} diff --git a/crates/sylpheed-xexdb/src/vtables.rs b/crates/sylpheed-xexdb/src/vtables.rs new file mode 100644 index 00000000..3d4d5d02 --- /dev/null +++ b/crates/sylpheed-xexdb/src/vtables.rs @@ -0,0 +1,841 @@ +//! MSVC vtable + RTTI detection. +//! +//! Heuristic two-pass scan over the binary's read-only data sections. Pass 1 +//! finds candidate vtables — runs of ≥3 contiguous big-endian u32 values that +//! all land on known function entries. Pass 2 attempts the MSVC RTTI walk +//! `vtable[-1] → CompleteObjectLocator → TypeDescriptor → mangled name`. When +//! RTTI is stripped (typical for shipped game binaries), each anonymous vtable +//! gets a deterministic name `ANON_Class_` keyed by a hash of its +//! sorted method PCs (so identical vtables across multiple class instances +//! collapse to one entry). +//! +//! What this module does NOT do: +//! - Vtables in heap-allocated memory (built at runtime by ctors) are out of +//! scope — only vtables present statically in `.rdata` / `.data`. +//! - RTTI inheritance (`BaseClassDescriptor` walk) is best-effort; we record +//! the first-level base list when present and leave it NULL otherwise. +//! - Multiple-inheritance "extra" vftables (one per base subobject) are +//! detected as independent vtables; we don't link them. +//! +//! Reference: openrce.org "Reversing Microsoft Visual C++" RTTI articles +//! (CompleteObjectLocator / TypeDescriptor / BaseClassDescriptor layout). + +use std::collections::BTreeMap; + +use sylpheed_xex::pe::PeSection; + +use crate::demangle; + +/// Maximum number of consecutive non-function slots tolerated inside an +/// anchor-recovered vtable before the run is considered terminated. MSVC +/// vtables can carry null / pure-virtual / unrecognised-thunk slots in their +/// head or interior; a small budget lets those through without merging two +/// physically-adjacent vtables. Kept small to avoid bridging the gap between +/// distinct tables. +const MAX_ANCHOR_GAP: usize = 2; + +/// One detected vtable. +#[derive(Debug, Clone)] +pub struct Vtable { + /// Absolute VA of `vtable[0]` (first method slot). + pub address: u32, + /// Number of methods in the vtable. + pub length: u32, + /// Absolute VA of the `CompleteObjectLocator` from `vtable[-1]`, if it + /// looked like a valid pointer into `.rdata`. NULL when no RTTI / stripped. + pub col_address: Option, + /// Class name. Demangled from RTTI when available, otherwise the synthetic + /// `ANON_Class_` form. + pub class_name: String, + /// True when the COL → TypeDescriptor walk succeeded. + pub rtti_present: bool, + /// First-level base class names from `RTTIClassHierarchyDescriptor`, JSON-encoded. + /// `None` when not parseable. + pub base_classes_json: Option, + /// One entry per slot: function VA in `.text`. + pub methods: Vec, +} + +/// Run the vtable scan + RTTI walk. `function_starts` is the set of valid +/// `.text` function entry VAs from M1's corrected `functions` table. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + function_starts: &std::collections::BTreeSet, +) -> Vec { + analyze_with_anchors(pe, image_base, sections, function_starts, &std::collections::BTreeSet::new()) +} + +/// Like [`analyze`], but additionally recovers vtables whose base address is +/// known a-priori from a constructor vptr-write store (an "anchor"). The +/// contiguity heuristic in pass 1 fragments any vtable whose head region +/// contains words that don't resolve to recognised function entries (null / +/// pure-virtual / unrecognised thunk slots); those vtables are never emitted +/// and the downstream typed-dispatch resolver can't type objects of that +/// class. An anchor is a *content-independent* vtable signal — the ctor +/// literally installs `vtable_base` into `this+0` via +/// `addis/addi (or lis/ori) → stw rX, 0(rThis)` — so for every anchor not +/// already covered by a pass-1 run we synthesise a vtable starting at that +/// base, reading the fnptr-array run while *tolerating* up to +/// [`MAX_ANCHOR_GAP`] consecutive non-function slots before terminating. +/// +/// `anchors` are absolute VAs of vtable bases (from +/// [`scan_vptr_write_constants`]). Existing pass-1 vtables are kept unchanged +/// (no regression): an anchor that already coincides with a detected vtable +/// base is skipped, and an anchor that lands *inside* an existing run is also +/// skipped (it's a sub-object pointer, not a fresh table). +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] +pub fn analyze_with_anchors( + pe: &[u8], + image_base: u32, + sections: &[PeSection], + function_starts: &std::collections::BTreeSet, + anchors: &std::collections::BTreeSet, +) -> Vec { + let started = std::time::Instant::now(); + // Sections we'll scan for vtable bodies. + let scan_targets: Vec<&PeSection> = sections + .iter() + .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) + .collect(); + + // Range table for "is this VA in .rdata?" — where COLs and class-hierarchy + // descriptors live. + let rdata_ranges: Vec<(u32, u32)> = sections + .iter() + .filter(|s| s.name == ".rdata") + .map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size)) + .collect(); + // TypeDescriptors are *written at startup* (their first word is + // `type_info`'s vftable), so MSVC emits them into writable `.data`, not + // `.rdata`. Range-checking a TypeDescriptor pointer against `.rdata` alone + // rejects every one of them and leaves the whole inline walk dead. + let typedesc_ranges: Vec<(u32, u32)> = sections + .iter() + .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) + .map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size)) + .collect(); + + let mut candidates: Vec = Vec::new(); + + for section in scan_targets { + let va_start = image_base + section.virtual_address; + let va_end = va_start + section.virtual_size; + let raw_start = section.virtual_address as usize; + let raw_end = (section.virtual_address + section.virtual_size) as usize; + if raw_end > pe.len() { continue; } + let bytes = &pe[raw_start..raw_end.min(pe.len())]; + + let mut i = 0usize; + while i + 12 <= bytes.len() { + // Try to start a run at this 4-aligned offset. + if !i.is_multiple_of(4) { i += 1; continue; } + let mut run_len = 0usize; + let mut methods: Vec = Vec::new(); + let mut j = i; + while j + 4 <= bytes.len() { + let val = u32::from_be_bytes([bytes[j], bytes[j + 1], bytes[j + 2], bytes[j + 3]]); + if function_starts.contains(&val) { + methods.push(val); + run_len += 1; + j += 4; + } else { + break; + } + } + if run_len >= 3 { + let address = va_start + (i as u32); + candidates.push(Vtable { + address, + length: run_len as u32, + col_address: None, + class_name: synth_anon_name(&methods), + rtti_present: false, + base_classes_json: None, + methods, + }); + i += run_len * 4; + } else { + i += 4; + } + } + let _ = (va_start, va_end); + } + + // --- Anchor-driven recovery (vptr-write-anchored vtables) --- + // + // Build a coverage interval set from pass-1 runs so we don't re-emit a + // table for an anchor that already lies within an extracted vtable. + let mut covered: Vec<(u32, u32)> = candidates + .iter() + .map(|v| (v.address, v.address + v.length * 4)) + .collect(); + covered.sort_unstable(); + + let is_covered = |addr: u32, covered: &[(u32, u32)]| -> bool { + covered.iter().any(|&(s, e)| addr >= s && addr < e) + }; + + // Section lookup for "which scan target contains this VA?" + let scan_targets_va: Vec<(u32, u32, usize, usize)> = sections + .iter() + .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) + .map(|s| { + let va = image_base + s.virtual_address; + ( + va, + va + s.virtual_size, + s.virtual_address as usize, + (s.virtual_address + s.virtual_size) as usize, + ) + }) + .collect(); + + // Cap a recovered run at the *next anchor* so two physically-adjacent + // anchored vtables don't merge. We deliberately do NOT cap at pass-1 + // fragments: a fragment is a sub-run the contiguity scan carved out of a + // larger table, and the anchor legitimately re-absorbs it (subsumed + // fragments are removed afterwards). + let anchor_bases: std::collections::BTreeSet = anchors.iter().copied().collect(); + + let mut recovered = 0usize; + let mut newly: Vec = Vec::new(); + for &anchor in anchors { + if is_covered(anchor, &covered) { continue; } + // Locate the containing .rdata/.data section. + let Some(&(va_lo, va_hi, raw_lo, raw_hi)) = + scan_targets_va.iter().find(|&&(lo, hi, _, _)| anchor >= lo && anchor < hi) + else { continue }; + if anchor % 4 != 0 { continue; } + let raw_hi = raw_hi.min(pe.len()); + // Read the fnptr-array run starting at the anchor. Tolerate small + // gaps of non-function slots (null / pure-virtual / unrecognised), + // but require the run to actually contain at least one real function + // (otherwise it's just data, not a vtable). + let next_base = anchor_bases.range((anchor + 4)..).next().copied(); + let mut methods: Vec = Vec::new(); + let mut gap = 0usize; + let mut real_fns = 0usize; + let mut off = (anchor - va_lo) as usize + raw_lo; + let mut va = anchor; + while off + 4 <= raw_hi && va < va_hi { + if let Some(nb) = next_base && va >= nb { break; } + let val = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]); + if function_starts.contains(&val) { + methods.push(val); + real_fns += 1; + gap = 0; + } else { + // A non-function slot. Keep the slot (so downstream slot + // indexing stays aligned) but count toward the gap budget. + gap += 1; + if gap > MAX_ANCHOR_GAP { + // Drop the trailing gap slots — they belong past the + // table's end. + methods.truncate(methods.len().saturating_sub(gap - 1)); + break; + } + methods.push(val); + } + off += 4; + va += 4; + } + // Trim any trailing non-function slots (the table ends at its last + // real method). + while methods.last().is_some_and(|&m| !function_starts.contains(&m)) { + methods.pop(); + } + if real_fns == 0 || methods.is_empty() { continue; } + let length = methods.len() as u32; + newly.push(Vtable { + address: anchor, + length, + col_address: None, + class_name: synth_anon_name(&methods), + rtti_present: false, + base_classes_json: None, + methods, + }); + recovered += 1; + } + if recovered > 0 { + // Drop pass-1 fragments fully subsumed by a recovered (anchored) + // vtable — the anchor base is authoritative and the fragment was a + // contiguity-scan artifact of the same table. Keep fragments that + // only partially overlap (defensive; shouldn't happen for true + // sub-runs) so we never lose method coverage. + let recovered_spans: Vec<(u32, u32)> = + newly.iter().map(|v| (v.address, v.address + v.length * 4)).collect(); + candidates.retain(|v| { + !recovered_spans + .iter() + .any(|&(s, e)| v.address >= s && v.address + v.length * 4 <= e) + }); + candidates.extend(newly); + tracing::info!(recovered, "vtables recovered from vptr-write anchors"); + } + let _ = &covered; + + // RTTI walk: for each candidate, look at vtable[-1]. + let pe_image_base = image_base; + for v in &mut candidates { + if v.address < 4 { continue; } + let col_off = (v.address - pe_image_base - 4) as usize; + if col_off + 4 > pe.len() { continue; } + let col_ptr = u32::from_be_bytes([pe[col_off], pe[col_off + 1], pe[col_off + 2], pe[col_off + 3]]); + if col_ptr == 0 { continue; } + if !is_in_ranges(col_ptr, &rdata_ranges) { continue; } + + // Try to extract the TypeDescriptor mangled-name string. + if let Some((td_ptr, hierarchy_ptr)) = read_col(pe, image_base, col_ptr) + && let Some(mangled) = read_typedescriptor_name(pe, image_base, td_ptr, &typedesc_ranges) + && let Some(class) = demangle_rtti_typename(&mangled) + { + v.col_address = Some(col_ptr); + v.class_name = class; + v.rtti_present = true; + v.base_classes_json = read_class_hierarchy(pe, image_base, hierarchy_ptr, &rdata_ranges); + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + let rtti_count = candidates.iter().filter(|v| v.rtti_present).count(); + metrics::histogram!("analysis.phase_ms", "phase" => "vtables").record(elapsed_ms); + tracing::info!( + vtables = candidates.len(), + rtti = rtti_count, + anon = candidates.len() - rtti_count, + elapsed_ms, + "vtable scan complete" + ); + candidates +} + +fn is_in_ranges(addr: u32, ranges: &[(u32, u32)]) -> bool { + ranges.iter().any(|&(s, e)| addr >= s && addr < e) +} + +/// Read 4 big-endian bytes at absolute VA `addr` from the PE image. +fn read_be_u32(pe: &[u8], image_base: u32, addr: u32) -> Option { + let off = addr.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) +} + +/// Parse a `CompleteObjectLocator` at VA `col`. Returns +/// `(type_descriptor_ptr, class_hierarchy_descriptor_ptr)` on success. +/// +/// Layout (32-bit MSVC): +/// ```text +/// +0x00 signature (0 for x86 without /GR-, can be 1) +/// +0x04 offset within complete object +/// +0x08 cdOffset (this-pointer adjuster) +/// +0x0C TypeDescriptor * +/// +0x10 RTTIClassHierarchyDescriptor * +/// ``` +fn read_col(pe: &[u8], image_base: u32, col: u32) -> Option<(u32, u32)> { + let td = read_be_u32(pe, image_base, col + 0x0C)?; + let chd = read_be_u32(pe, image_base, col + 0x10)?; + if td == 0 { return None; } + Some((td, chd)) +} + +/// Read a TypeDescriptor's mangled-name string at VA `td`. +/// +/// Layout: `+0x00` vftable ptr, `+0x04` "spare", `+0x08` zero-terminated +/// mangled name (e.g. `.?AVClassName@@`). +fn read_typedescriptor_name( + pe: &[u8], + image_base: u32, + td: u32, + rdata_ranges: &[(u32, u32)], +) -> Option { + if !is_in_ranges(td, rdata_ranges) { return None; } + let name_va = td + 0x08; + let off = name_va.wrapping_sub(image_base) as usize; + if off + 1 > pe.len() { return None; } + // Read up to 256 bytes or until NUL. + let mut end = off; + while end < pe.len().min(off + 256) && pe[end] != 0 { end += 1; } + if end == off { return None; } + let s = std::str::from_utf8(&pe[off..end]).ok()?; + // Sanity: MSVC RTTI names always start with `.?A`. + if !s.starts_with(".?A") { return None; } + Some(s.to_string()) +} + +/// Demangle an RTTI type-name string of the form `.?AVClassName@ns@@`. +/// MSVC convention: leading `.` is the marker for an RTTI string; strip it +/// before passing to the demangler. +fn demangle_rtti_typename(rtti_name: &str) -> Option { + let stripped = rtti_name.strip_prefix('.')?; + let raw = msvc_demangler::demangle(stripped, msvc_demangler::DemangleFlags::llvm()).ok()?; + // Output looks like `class xe::apu::AudioSystem` or `struct foo::Bar`. + let cls = raw + .strip_prefix("class ") + .or_else(|| raw.strip_prefix("struct ")) + .or_else(|| raw.strip_prefix("union ")) + .unwrap_or(&raw); + Some(cls.to_string()) +} + +/// Best-effort `RTTIClassHierarchyDescriptor` walk: read the +/// `BaseClassArray` entries and demangle each base's TypeDescriptor name. +/// Returns a JSON array string on success. +/// +/// Layout: +/// ```text +/// RTTIClassHierarchyDescriptor: +/// +0x00 signature +/// +0x04 attributes +/// +0x08 numBaseClasses +/// +0x0C BaseClassArray * (-> array of BaseClassDescriptor *) +/// BaseClassDescriptor: +/// +0x00 TypeDescriptor * +/// +0x04 numContainedBases +/// ... +/// ``` +fn read_class_hierarchy( + pe: &[u8], + image_base: u32, + chd: u32, + rdata_ranges: &[(u32, u32)], +) -> Option { + if !is_in_ranges(chd, rdata_ranges) { return None; } + let num_bases = read_be_u32(pe, image_base, chd + 0x08)?; + if num_bases == 0 || num_bases > 256 { return None; } // sanity cap + let bca_ptr = read_be_u32(pe, image_base, chd + 0x0C)?; + if !is_in_ranges(bca_ptr, rdata_ranges) { return None; } + + let mut names: Vec = Vec::new(); + for i in 0..num_bases { + let bcd_ptr = match read_be_u32(pe, image_base, bca_ptr + i * 4) { + Some(p) if is_in_ranges(p, rdata_ranges) => p, + _ => return None, + }; + let td_ptr = match read_be_u32(pe, image_base, bcd_ptr) { + Some(p) if is_in_ranges(p, rdata_ranges) => p, + _ => return None, + }; + let mangled = match read_typedescriptor_name(pe, image_base, td_ptr, rdata_ranges) { + Some(s) => s, + None => return None, + }; + let cls = demangle_rtti_typename(&mangled).unwrap_or(mangled); + names.push(cls); + } + serde_json::to_string(&names).ok() +} + +/// Pre-pass: discover candidate vtable *bases* from constructor vptr-write +/// stores, independent of the static contiguity heuristic. A vptr install is +/// the canonical `addis/addi` (or `lis/ori`) immediate build of a constant +/// pointing into `.rdata` / `.data`, followed by `stw rX, 0(rThis)` — i.e. the +/// ctor writing the vtable pointer to `this+0`. We return the set of such +/// constants; these are fed to [`analyze_with_anchors`] so a vtable with +/// non-function head words isn't lost. +/// +/// We only consider stores at displacement 0 (the primary vptr; secondary +/// MI vptrs land at non-zero offsets and are handled by the existing +/// contiguity scan / typed-dispatch resolver well enough). The register +/// tracker mirrors the lis+addi propagation used elsewhere and is reset at +/// every basic-block boundary (`block_boundaries`). +pub fn scan_vptr_write_constants( + pe: &[u8], + image_base: u32, + functions: &std::collections::BTreeMap, // start -> (end, is_saverestore) + sections: &[PeSection], + block_boundaries: &std::collections::HashSet, +) -> std::collections::BTreeSet { + // Ranges that a vtable base may legitimately live in. + let data_ranges: Vec<(u32, u32)> = sections + .iter() + .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) + .map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size)) + .collect(); + let in_data = |a: u32| data_ranges.iter().any(|&(s, e)| a >= s && a < e); + + const OP_ADDI: u32 = 14; + const OP_ADDIS: u32 = 15; + const OP_ORI: u32 = 24; + const OP_STW: u32 = 36; + const OP_X_FORM: u32 = 31; + + let read = |addr: u32| -> Option { + let off = addr.wrapping_sub(image_base) as usize; + if off + 4 > pe.len() { return None; } + Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) + }; + + let mut anchors: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for (&fn_start, &(fn_end, is_saverestore)) in functions { + if is_saverestore { continue; } + let mut reg: [Option; 32] = [None; 32]; + let mut pc = fn_start; + while pc < fn_end { + if pc != fn_start && block_boundaries.contains(&pc) { + reg = [None; 32]; + } + let Some(instr) = read(pc) else { break }; + let op = instr >> 26; + let rd = ((instr >> 21) & 0x1F) as usize; + let ra = ((instr >> 16) & 0x1F) as usize; + let simm = ((instr & 0xFFFF) as i16) as i32; + let uimm = instr & 0xFFFF; + match op { + OP_ADDIS if ra == 0 => reg[rd] = Some(uimm << 16), + OP_ADDIS => reg[rd] = reg[ra].map(|b| b.wrapping_add(uimm << 16)), + OP_ADDI if ra != 0 => reg[rd] = reg[ra].map(|b| b.wrapping_add(simm as u32)), + OP_ADDI => reg[rd] = Some(simm as u32), + OP_ORI => { + let rs = rd; + reg[ra] = reg[rs].map(|b| b | uimm); + } + OP_STW => { + // `stw rS, off(rA)` with displacement 0 = primary vptr install. + if ra != 0 + && simm == 0 + && let Some(val) = reg[rd] + && in_data(val) + { + anchors.insert(val); + } + } + 32..=35 | 40..=43 | 48..=51 => reg[rd] = None, + OP_X_FORM => { + let xo = (instr >> 1) & 0x3FF; + if xo != 444 && xo != 467 { reg[rd] = None; } // keep `or`(444=mr)/`mtspr`-ish + } + 18 | 16 => { + if (instr & 1) != 0 { + for r in 0..=12 { reg[r] = None; } + } + } + _ => {} + } + pc = pc.wrapping_add(4); + } + } + anchors +} + +/// Synthetic name for an RTTI-stripped vtable, derived from a stable hash of +/// the sorted method-PC list. Two vtables with identical method ordering +/// collapse to the same anonymous name. +fn synth_anon_name(methods: &[u32]) -> String { + // FNV-1a 64-bit on the sorted PC list; we only use 32 bits for brevity. + let mut sorted = methods.to_vec(); + sorted.sort_unstable(); + let mut h: u64 = 0xcbf29ce484222325; + for pc in &sorted { + for b in pc.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + } + format!("ANON_Class_{:08X}", (h as u32)) +} + +/// Build the per-method `(vtable_address, slot, function_address)` list for +/// DB insertion, with optional demangled-name lookup for any function that +/// has a matching `?…` label. Skips slots whose function isn't in the +/// supplied label map. +pub fn methods_table( + vtables: &[Vtable], + labels: &std::collections::HashMap, +) -> Vec<(u32, u32, u32, Option, Option)> { + let mut out = Vec::new(); + for v in vtables { + for (slot, &fn_va) in v.methods.iter().enumerate() { + let label = labels.get(&fn_va).cloned(); + let demangled = label.as_ref() + .and_then(|l| demangle::demangle(l).map(|d| d.raw_demangled)); + out.push((v.address, slot as u32, fn_va, label, demangled)); + } + } + out +} + +/// Build a `class_name → Vtable` summary for the `classes` table. Multiple +/// vtables sharing the same class name (multiple instances at link time) +/// collapse via `BTreeMap` — the first detected vtable wins. +pub fn classes_table(vtables: &[Vtable]) -> Vec<(String, u32, bool, Option)> { + let mut by_name: BTreeMap = BTreeMap::new(); + for v in vtables { + by_name.entry(v.class_name.clone()).or_insert(v); + } + by_name + .into_iter() + .map(|(name, v)| (name, v.address, v.rtti_present, v.base_classes_json.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synth_anon_name_is_stable() { + let a = synth_anon_name(&[0x82001000, 0x82001100, 0x82001200]); + let b = synth_anon_name(&[0x82001200, 0x82001000, 0x82001100]); + assert_eq!(a, b, "anon name must be order-independent"); + } + + #[test] + fn synth_anon_name_differs_for_different_methods() { + let a = synth_anon_name(&[0x82001000, 0x82001100]); + let b = synth_anon_name(&[0x82002000, 0x82002100]); + assert_ne!(a, b); + } + + #[test] + fn detects_3_method_vtable_in_rdata() { + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let text_va = 0x2000u32; + let rdata_size = 16u32; + let text_size = 0x100u32; + + // PE buffer big enough for both sections. + let total = (text_va + text_size) as usize; + let mut pe = vec![0u8; total]; + + // Vtable: 3 method PCs at .rdata start, all valid function entries. + let m: [u32; 3] = [image_base + text_va, image_base + text_va + 0x10, image_base + text_va + 0x20]; + for (i, val) in m.iter().enumerate() { + pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4] + .copy_from_slice(&val.to_be_bytes()); + } + + let sections = vec![ + PeSection { + name: ".rdata".into(), + virtual_address: rdata_va, + virtual_size: rdata_size, + raw_offset: rdata_va, + raw_size: rdata_size, + flags: 0x4000_0040, + }, + PeSection { + name: ".text".into(), + virtual_address: text_va, + virtual_size: text_size, + raw_offset: text_va, + raw_size: text_size, + flags: 0x6000_0020, + }, + ]; + let mut function_starts = std::collections::BTreeSet::new(); + for &pc in &m { function_starts.insert(pc); } + + let vtables = analyze(&pe, image_base, §ions, &function_starts); + assert_eq!(vtables.len(), 1); + assert_eq!(vtables[0].length, 3); + assert_eq!(vtables[0].address, image_base + rdata_va); + assert!(vtables[0].class_name.starts_with("ANON_Class_")); + assert!(!vtables[0].rtti_present); + } + + #[test] + fn anchor_recovers_vtable_with_nonfn_head() { + // A vtable whose head has a null + an unrecognised word, so the + // contiguity scan (≥3 contiguous known fns) fragments it. The anchor + // (from a ctor vptr-write) must recover the whole table from its base. + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let text_va = 0x2000u32; + let rdata_size = 0x40u32; + let text_size = 0x100u32; + let total = (text_va + text_size) as usize; + let mut pe = vec![0u8; total]; + + let f0 = image_base + text_va; + let f1 = image_base + text_va + 0x10; + let f2 = image_base + text_va + 0x20; + // Slots: [null, NONFN(0xDEAD), f0, f1, f2] + let slots: [u32; 5] = [0, 0xDEADBEEF, f0, f1, f2]; + for (i, val) in slots.iter().enumerate() { + pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4] + .copy_from_slice(&val.to_be_bytes()); + } + + let sections = vec![ + PeSection { + name: ".rdata".into(), + virtual_address: rdata_va, + virtual_size: rdata_size, + raw_offset: rdata_va, + raw_size: rdata_size, + flags: 0x4000_0040, + }, + PeSection { + name: ".text".into(), + virtual_address: text_va, + virtual_size: text_size, + raw_offset: text_va, + raw_size: text_size, + flags: 0x6000_0020, + }, + ]; + let mut function_starts = std::collections::BTreeSet::new(); + for &pc in &[f0, f1, f2] { function_starts.insert(pc); } + + // Without an anchor: the head gap (null + nonfn = 2 slots) means the + // contiguous run is only [f0,f1,f2]=3 starting at +0x08, so pass-1 + // still finds it but at the WRONG base (0x...1008), not the true base. + let no_anchor = analyze(&pe, image_base, §ions, &function_starts); + assert!( + !no_anchor.iter().any(|v| v.address == image_base + rdata_va), + "without anchor the table is not recovered at its true base" + ); + + // With the anchor at the true base: + let mut anchors = std::collections::BTreeSet::new(); + anchors.insert(image_base + rdata_va); + let with_anchor = + analyze_with_anchors(&pe, image_base, §ions, &function_starts, &anchors); + let v = with_anchor + .iter() + .find(|v| v.address == image_base + rdata_va) + .expect("anchor must recover vtable at its true base"); + // length spans through f2 (slot 4): 5 slots. + assert_eq!(v.length, 5, "table spans null/nonfn head through last fn"); + assert_eq!(v.methods[2], f0); + assert_eq!(v.methods[4], f2); + } + + #[test] + fn scan_vptr_write_constants_finds_ctor_store() { + // Encode a ctor: addis r11,r0,0x8201; addi r11,r11,lo; stw r11,0(r31) + // installing vtable base 0x8200A908 into this+0. + let image_base = 0x82000000u32; + let ctor = 0x82001000u32; + let mut pe = vec![0u8; 0x4000]; + // Lay out a tiny .rdata at 0x...A900 so the constant lands in-range. + let vt_base = 0x8200A908u32; // 0x82010000 - 22264 + let addis = (15u32 << 26) | (11 << 21) | (0 << 16) | 0x8201; + let lo = (vt_base & 0xFFFF) as i16; // -22264 + let addi = (14u32 << 26) | (11 << 21) | (0 << 16) | ((lo as u16) as u32); + // addi r11,r0,lo would set r11=lo (sign-extended); we need addis+addi + // chained. Re-encode addis into r11 from r0, then addi r11,r11,lo. + let addi2 = (14u32 << 26) | (11 << 21) | (11 << 16) | ((lo as u16) as u32); + let stw = (36u32 << 26) | (11 << 21) | (31 << 16) | 0; // stw r11,0(r31) + let at = (ctor - image_base) as usize; + pe[at..at + 4].copy_from_slice(&addis.to_be_bytes()); + pe[at + 4..at + 8].copy_from_slice(&addi2.to_be_bytes()); + pe[at + 8..at + 12].copy_from_slice(&stw.to_be_bytes()); + let _ = addi; + + let sections = vec![PeSection { + name: ".rdata".into(), + virtual_address: 0xA900, + virtual_size: 0x200, + raw_offset: 0xA900, + raw_size: 0x200, + flags: 0x4000_0040, + }]; + let mut funcs: std::collections::BTreeMap = std::collections::BTreeMap::new(); + funcs.insert(ctor, (ctor + 0x40, false)); + let anchors = scan_vptr_write_constants( + &pe, image_base, &funcs, §ions, &std::collections::HashSet::new(), + ); + assert!(anchors.contains(&vt_base), "ctor vptr store must yield anchor {vt_base:#x}, got {anchors:?}"); + } + + #[test] + fn rejects_2_method_run() { + let image_base = 0x82000000u32; + let rdata_va = 0x1000u32; + let text_va = 0x2000u32; + + let total = (text_va + 0x100) as usize; + let mut pe = vec![0u8; total]; + let m: [u32; 2] = [image_base + text_va, image_base + text_va + 0x10]; + for (i, val) in m.iter().enumerate() { + pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4] + .copy_from_slice(&val.to_be_bytes()); + } + let sections = vec![ + PeSection { + name: ".rdata".into(), + virtual_address: rdata_va, + virtual_size: 8, + raw_offset: rdata_va, + raw_size: 8, + flags: 0x4000_0040, + }, + PeSection { + name: ".text".into(), + virtual_address: text_va, + virtual_size: 0x100, + raw_offset: text_va, + raw_size: 0x100, + flags: 0x6000_0020, + }, + ]; + let mut function_starts = std::collections::BTreeSet::new(); + for &pc in &m { function_starts.insert(pc); } + let vtables = analyze(&pe, image_base, §ions, &function_starts); + assert_eq!(vtables.len(), 0, "runs of 2 must be rejected to keep false-positive rate down"); + } +} + +// ── RTTI relabelling ─────────────────────────────────────────────────────── + +/// Overwrite heuristic vtable identity with the authoritative RTTI walk. +/// +/// [`analyze_with_anchors`] names a table either from its own inline COL walk +/// or, failing that, with a synthetic `ANON_Class_`. [`crate::rtti`] +/// resolves the same question top-down from the structures the linker emitted, +/// which is exact — so wherever the two disagree, RTTI wins. Rows RTTI knows +/// nothing about keep their heuristic name. +/// +/// `base_classes_json` is rebuilt here as the class's full linearised base list +/// (excluding index 0, which is the class itself), which is strictly more than +/// the first-level list the inline walk produced. +/// +/// Returns the number of vtables that gained a real class name. +pub fn apply_rtti_names(vtables: &mut [Vtable], rtti: &crate::rtti::RttiResult) -> usize { + use std::collections::BTreeMap; + + let names = rtti.vtable_class_names(); + let locator_by_vtable: BTreeMap = rtti + .locators + .iter() + .filter_map(|c| c.vtable_address.map(|v| (v, c))) + .collect(); + + // class-hierarchy VA → base class names, in the linker's order. + let mut bases_by_chd: BTreeMap> = BTreeMap::new(); + for b in &rtti.base_classes { + if b.index == 0 { continue; } // index 0 is the class itself + bases_by_chd.entry(b.class_hierarchy).or_default().push(b.name.as_str()); + } + + let mut named = 0usize; + for vt in vtables.iter_mut() { + let Some((class_name, offset)) = names.get(&vt.address) else { continue }; + // A secondary-base vftable belongs to the same class but is a distinct + // table; keep them apart by suffixing the subobject offset. + vt.class_name = if *offset == 0 { + class_name.clone() + } else { + format!("{class_name}#base+0x{offset:X}") + }; + vt.rtti_present = true; + if let Some(col) = locator_by_vtable.get(&vt.address) { + vt.col_address = Some(col.address); + vt.base_classes_json = bases_by_chd.get(&col.class_hierarchy).map(|names| { + let items: Vec = names + .iter() + .map(|n| format!("\"{}\"", n.replace('\\', "\\\\").replace('"', "\\\""))) + .collect(); + format!("[{}]", items.join(",")) + }); + } + named += 1; + } + named +} diff --git a/crates/sylpheed-xexdb/src/xdbf.rs b/crates/sylpheed-xexdb/src/xdbf.rs new file mode 100644 index 00000000..9b2be0b0 --- /dev/null +++ b/crates/sylpheed-xexdb/src/xdbf.rs @@ -0,0 +1,450 @@ +//! XDBF / SPA — the title metadata package embedded in the XEX. +//! +//! A title's `XEX_HEADER_RESOURCE_INFO` names one resource whose body is an +//! **XDBF** ("Xbox DataBase File") container, in its SPA flavour: achievement +//! definitions, one string table per shipped language, PNG images, and the +//! matchmaking / leaderboard / presence schema. +//! +//! ```text +//! XdbfHeader 24 bytes magic 'XDBF', version, entry_count, entry_used, +//! free_count, free_used +//! XdbfEntry[] 18 each namespace u16, id u64, offset u32, size u32 +//! XdbfFileLoc[] 8 each the free-space table +//! data entry offsets are relative to the end of the two tables +//! ``` +//! +//! Each entry's body starts with a section header — `magic, version, size`, +//! plus a `u16 count` for the table-shaped ones. +//! +//! Entries are enumerated from the **entry table**, not by scanning for section +//! magics. Scanning is what the project's earlier `tools/xach_dump.py` does, and +//! on this title it finds a phantom seventh `XSTR` (the byte pattern occurs +//! outside any declared entry) where the entry table declares six — which shifts +//! every language index derived from the scan order. +//! +//! Layouts follow the reference implementation in xenia-canary +//! (`src/xenia/kernel/xam/xdbf/{xdbf_io,spa_info}.h`), which in turn cites +//! freestyledash `Tools/XEX/SPA.{h,cpp}`. + +/// `XDBF` big-endian. +const XDBF_MAGIC: u32 = 0x5844_4246; + +/// The well-known entry id carrying the title's own name (in the string-table +/// namespace) and its icon (in the image namespace) — canary's `kXdbfIdTitle`. +pub const ID_TITLE: u64 = 0x8000; + +const NS_METADATA: u16 = 1; +const NS_IMAGE: u16 = 2; +const NS_STRING_TABLE: u16 = 3; + +/// One row of the container's entry table. +#[derive(Debug, Clone)] +pub struct XdbfEntry { + /// 1 = metadata, 2 = image, 3 = string table. + pub namespace: u16, + /// Entry id. For metadata entries this is the section fourcc as an integer; + /// for string tables it is the [`XLanguage`] value; for images, the image id. + pub id: u64, + /// Absolute offset of the entry body within the image buffer. + pub offset: usize, + /// Entry body length in bytes. + pub size: usize, + /// The body's leading fourcc, when it has one (`XACH`, `XSTR`, …). + pub magic: Option, +} + +/// One achievement definition (`XACH`, 36-byte records). +#[derive(Debug, Clone)] +pub struct Achievement { + pub id: u16, + /// String id of the achievement's name. + pub label_id: u16, + /// String id of the description shown once unlocked. + pub description_id: u16, + /// String id of the description shown while locked. + pub unachieved_id: u16, + pub image_id: u32, + pub gamerscore: u16, + pub flags: u32, +} + +/// One localized string table (`XSTR`). +#[derive(Debug, Clone)] +pub struct StringTable { + /// `XLanguage` value; the entry id. + pub language: u32, + /// `(string id, value)` in table order. + pub strings: Vec<(u16, String)>, +} + +/// `XTHD` — the title header. +#[derive(Debug, Clone, Copy)] +pub struct TitleHeader { + pub title_id: u32, + pub title_type: u32, + pub major: u16, + pub minor: u16, + pub build: u16, + pub revision: u16, + pub flags: u32, +} + +/// An embedded image (namespace 2). Bodies are raw files, in practice PNG. +#[derive(Debug, Clone)] +pub struct Image { + pub id: u64, + pub offset: usize, + pub size: usize, + /// `"png"` when the body carries the PNG signature, else `"unknown"`. + pub format: &'static str, +} + +/// Everything recovered from one XDBF package. +#[derive(Debug, Default)] +pub struct Xdbf { + /// Offset of the container within the image buffer. + pub base: usize, + pub version: u32, + pub entries: Vec, + pub achievements: Vec, + pub string_tables: Vec, + pub images: Vec, + pub title: Option, + /// `XSTC` default language (an `XLanguage` value). + pub default_language: Option, +} + +fn be16(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?])) +} +fn be32(b: &[u8], o: usize) -> Option { + Some(u32::from_be_bytes([ + *b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?, + ])) +} +fn be64(b: &[u8], o: usize) -> Option { + let hi = be32(b, o)? as u64; + let lo = be32(b, o + 4)? as u64; + Some((hi << 32) | lo) +} + +/// Render a fourcc as text when all four bytes are printable ASCII. +fn fourcc(v: u32) -> Option { + let b = v.to_be_bytes(); + b.iter() + .all(|c| (0x20..0x7F).contains(c)) + .then(|| String::from_utf8_lossy(&b).into_owned()) +} + +/// Human-readable name for an `XLanguage` value. +pub fn language_name(v: u32) -> &'static str { + match v { + 1 => "English", + 2 => "Japanese", + 3 => "German", + 4 => "French", + 5 => "Spanish", + 6 => "Italian", + 7 => "Korean", + 8 => "Chinese (Traditional)", + 9 => "Portuguese", + 10 => "Chinese (Simplified)", + 11 => "Polish", + 12 => "Russian", + _ => "unknown", + } +} + +/// Parse the XDBF package at `base` within `image`. +/// +/// Returns `None` when there is no XDBF magic there — callers locate the +/// package via `sylpheed_xex::resources`, and a title without one is normal. +#[tracing::instrument(skip_all, fields(base = format_args!("{base:#x}")))] +pub fn analyze(image: &[u8], base: usize) -> Option { + let started = std::time::Instant::now(); + if be32(image, base)? != XDBF_MAGIC { + return None; + } + let version = be32(image, base + 4)?; + let entry_count = be32(image, base + 8)? as usize; + let entry_used = be32(image, base + 12)? as usize; + let free_count = be32(image, base + 16)? as usize; + + // Guard against a corrupt header pointing the data region off the end. + if entry_used > entry_count || entry_count > 0x10000 || free_count > 0x10000 { + return None; + } + let entry_table = base + 24; + let data_start = entry_table + entry_count * 18 + free_count * 8; + if data_start > image.len() { + return None; + } + + let mut out = Xdbf { + base, + version, + ..Default::default() + }; + + for i in 0..entry_used { + let p = entry_table + i * 18; + let (Some(namespace), Some(id), Some(off), Some(size)) = + (be16(image, p), be64(image, p + 2), be32(image, p + 10), be32(image, p + 14)) + else { + continue; + }; + let body = data_start + off as usize; + let size = size as usize; + if body + size > image.len() { + continue; + } + let magic = be32(image, body).and_then(fourcc); + out.entries.push(XdbfEntry { + namespace, + id, + offset: body, + size, + magic: magic.clone(), + }); + + match namespace { + NS_IMAGE => out.images.push(Image { + id, + offset: body, + size, + format: if image[body..].starts_with(b"\x89PNG") { "png" } else { "unknown" }, + }), + NS_STRING_TABLE => { + if let Some(t) = parse_string_table(image, body, size, id as u32) { + out.string_tables.push(t); + } + } + NS_METADATA => match magic.as_deref() { + Some("XACH") => out.achievements.extend(parse_achievements(image, body, size)), + Some("XTHD") => out.title = parse_title_header(image, body), + Some("XSTC") => out.default_language = be32(image, body + 12), + _ => {} + }, + _ => {} + } + } + + metrics::histogram!("analysis.phase_ms", "phase" => "xdbf") + .record(started.elapsed().as_millis() as f64); + tracing::info!( + entries = out.entries.len(), + achievements = out.achievements.len(), + string_tables = out.string_tables.len(), + images = out.images.len(), + default_language = out.default_language, + "XDBF package parsed", + ); + Some(out) +} + +/// `XACH`: `magic, version, size, count u16`, then 36-byte records. +fn parse_achievements(image: &[u8], body: usize, size: usize) -> Vec { + let Some(count) = be16(image, body + 12) else { return Vec::new() }; + let mut out = Vec::with_capacity(count as usize); + for i in 0..count as usize { + let p = body + 14 + i * 36; + if p + 36 > body + size { + break; + } + let (Some(id), Some(label_id), Some(description_id), Some(unachieved_id)) = + (be16(image, p), be16(image, p + 2), be16(image, p + 4), be16(image, p + 6)) + else { + break; + }; + out.push(Achievement { + id, + label_id, + description_id, + unachieved_id, + image_id: be32(image, p + 8).unwrap_or(0), + gamerscore: be16(image, p + 12).unwrap_or(0), + flags: be32(image, p + 16).unwrap_or(0), + }); + } + out +} + +/// `XSTR`: `magic, version, size, count u16`, then `id u16, len u16, bytes`. +/// +/// Bodies are UTF-8 (the ASCII subset for most locales; Japanese uses the full +/// range), decoded lossily so one bad table cannot drop a whole language. +fn parse_string_table(image: &[u8], body: usize, size: usize, language: u32) -> Option { + if fourcc(be32(image, body)?)? != "XSTR" { + return None; + } + let count = be16(image, body + 12)?; + let end = body + size; + let mut p = body + 14; + let mut strings = Vec::with_capacity(count as usize); + for _ in 0..count { + let (Some(id), Some(len)) = (be16(image, p), be16(image, p + 2)) else { break }; + let s = p + 4; + let e = s + len as usize; + if e > end || e > image.len() { + break; + } + strings.push((id, String::from_utf8_lossy(&image[s..e]).into_owned())); + p = e; + } + Some(StringTable { language, strings }) +} + +/// `XTHD`: section header then the 32-byte `TitleHeaderData`. +fn parse_title_header(image: &[u8], body: usize) -> Option { + let p = body + 12; + Some(TitleHeader { + title_id: be32(image, p)?, + title_type: be32(image, p + 4)?, + major: be16(image, p + 8)?, + minor: be16(image, p + 10)?, + build: be16(image, p + 12)?, + revision: be16(image, p + 14)?, + flags: be32(image, p + 16)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal XDBF: one XACH with a single achievement, one XSTR, one + /// PNG, an XTHD and an XSTC. + fn mk_xdbf() -> (Vec, usize) { + let base = 0x100usize; + let entry_count = 5usize; + let free_count = 1usize; + let data_start = base + 24 + entry_count * 18 + free_count * 8; + + let mut bodies: Vec<(u16, u64, Vec)> = Vec::new(); + + let mut xach = Vec::new(); + xach.extend(b"XACH"); + xach.extend(1u32.to_be_bytes()); + xach.extend(0u32.to_be_bytes()); + xach.extend(1u16.to_be_bytes()); // count + let mut rec = Vec::new(); + rec.extend(7u16.to_be_bytes()); // id + rec.extend(100u16.to_be_bytes()); // label + rec.extend(101u16.to_be_bytes()); // description + rec.extend(102u16.to_be_bytes()); // unachieved + rec.extend(9u32.to_be_bytes()); // image id + rec.extend(20u16.to_be_bytes()); // gamerscore + rec.extend(0u16.to_be_bytes()); + rec.extend(0x0Cu32.to_be_bytes()); // flags + rec.extend([0u8; 16]); + assert_eq!(rec.len(), 36); + xach.extend(rec); + bodies.push((NS_METADATA, u32::from_be_bytes(*b"XACH") as u64, xach)); + + let mut xstr = Vec::new(); + xstr.extend(b"XSTR"); + xstr.extend(1u32.to_be_bytes()); + xstr.extend(0u32.to_be_bytes()); + xstr.extend(2u16.to_be_bytes()); + for (id, s) in [(100u16, "Space Combat Award"), (101u16, "Well done")] { + xstr.extend(id.to_be_bytes()); + xstr.extend((s.len() as u16).to_be_bytes()); + xstr.extend(s.as_bytes()); + } + bodies.push((NS_STRING_TABLE, 1, xstr)); // language 1 = English + + let mut xthd = Vec::new(); + xthd.extend(b"XTHD"); + xthd.extend(1u32.to_be_bytes()); + xthd.extend(0u32.to_be_bytes()); + xthd.extend(0x5351_07D4u32.to_be_bytes()); // title id + xthd.extend(1u32.to_be_bytes()); // type = full + xthd.extend(1u16.to_be_bytes()); + xthd.extend(2u16.to_be_bytes()); + xthd.extend(3u16.to_be_bytes()); + xthd.extend(4u16.to_be_bytes()); + xthd.extend(0u32.to_be_bytes()); + bodies.push((NS_METADATA, u32::from_be_bytes(*b"XTHD") as u64, xthd)); + + let mut xstc = Vec::new(); + xstc.extend(b"XSTC"); + xstc.extend(1u32.to_be_bytes()); + xstc.extend(16u32.to_be_bytes()); + xstc.extend(1u32.to_be_bytes()); // default language = English + bodies.push((NS_METADATA, u32::from_be_bytes(*b"XSTC") as u64, xstc)); + + let png = b"\x89PNG\r\n\x1a\n----".to_vec(); + bodies.push((NS_IMAGE, 9, png)); + + let total: usize = bodies.iter().map(|(_, _, b)| b.len()).sum(); + let mut img = vec![0u8; data_start + total + 0x10]; + img[base..base + 4].copy_from_slice(&XDBF_MAGIC.to_be_bytes()); + img[base + 4..base + 8].copy_from_slice(&0x10000u32.to_be_bytes()); + img[base + 8..base + 12].copy_from_slice(&(entry_count as u32).to_be_bytes()); + img[base + 12..base + 16].copy_from_slice(&(bodies.len() as u32).to_be_bytes()); + img[base + 16..base + 20].copy_from_slice(&(free_count as u32).to_be_bytes()); + + let mut off = 0usize; + for (i, (ns, id, b)) in bodies.iter().enumerate() { + let p = base + 24 + i * 18; + img[p..p + 2].copy_from_slice(&ns.to_be_bytes()); + img[p + 2..p + 10].copy_from_slice(&id.to_be_bytes()); + img[p + 10..p + 14].copy_from_slice(&(off as u32).to_be_bytes()); + img[p + 14..p + 18].copy_from_slice(&(b.len() as u32).to_be_bytes()); + img[data_start + off..data_start + off + b.len()].copy_from_slice(b); + off += b.len(); + } + (img, base) + } + + #[test] + fn parses_container_via_entry_table() { + let (img, base) = mk_xdbf(); + let x = analyze(&img, base).expect("parses"); + assert_eq!(x.entries.len(), 5); + assert_eq!(x.achievements.len(), 1); + assert_eq!(x.string_tables.len(), 1); + assert_eq!(x.images.len(), 1); + assert_eq!(x.default_language, Some(1)); + } + + #[test] + fn achievement_fields_and_string_ids_line_up() { + let (img, base) = mk_xdbf(); + let x = analyze(&img, base).unwrap(); + let a = &x.achievements[0]; + assert_eq!((a.id, a.gamerscore, a.image_id, a.flags), (7, 20, 9, 0x0C)); + let t = &x.string_tables[0]; + assert_eq!(t.language, 1); + assert_eq!(t.strings[0], (100, "Space Combat Award".to_string())); + // The achievement's label resolves through the table. + let name = t.strings.iter().find(|(i, _)| *i == a.label_id).map(|(_, s)| s.as_str()); + assert_eq!(name, Some("Space Combat Award")); + } + + #[test] + fn title_header_and_image_format() { + let (img, base) = mk_xdbf(); + let x = analyze(&img, base).unwrap(); + let t = x.title.expect("XTHD"); + assert_eq!(t.title_id, 0x5351_07D4); + assert_eq!((t.major, t.minor, t.build, t.revision), (1, 2, 3, 4)); + assert_eq!(x.images[0].format, "png"); + assert_eq!(x.images[0].id, 9); + } + + #[test] + fn rejects_non_xdbf() { + let img = vec![0u8; 0x200]; + assert!(analyze(&img, 0x100).is_none()); + } + + #[test] + fn rejects_header_pointing_past_the_buffer() { + let mut img = vec![0u8; 0x200]; + img[0..4].copy_from_slice(&XDBF_MAGIC.to_be_bytes()); + img[8..12].copy_from_slice(&0xFFFFu32.to_be_bytes()); // entry_count + img[12..16].copy_from_slice(&0xFFFFu32.to_be_bytes()); // entry_used + assert!(analyze(&img, 0).is_none()); + } +} diff --git a/crates/sylpheed-xexdb/src/xref.rs b/crates/sylpheed-xexdb/src/xref.rs new file mode 100644 index 00000000..9bd4bfe5 --- /dev/null +++ b/crates/sylpheed-xexdb/src/xref.rs @@ -0,0 +1,563 @@ +//! Cross-reference analysis for Xbox 360 PE images. + +use std::collections::HashMap; +use sylpheed_xex::pe::PeSection; +use crate::func::FuncAnalysis; + +// ── Cross-reference types ──────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum XrefKind { + Call, // bl + IndirectCall, // bcctrl through a statically-resolvable vtable slot (M5) + JumpTable, // bctr through a recovered switch jump table (M12) + Jump, // b (unconditional) + Branch, // bc / bXX (conditional) + DataRead, // lwz, lbz, lhz, lha, lfs, lfd, etc. from resolved address + DataWrite, // stw, stb, sth, stfs, stfd, etc. to resolved address + DataRef, // address computed via lis+addi/ori but not directly loaded/stored +} + +impl XrefKind { + pub fn tag(self) -> &'static str { + match self { + XrefKind::Call => "call", + XrefKind::IndirectCall => "ind_call", + XrefKind::JumpTable => "jt", + XrefKind::Jump => "j", + XrefKind::Branch => "br", + XrefKind::DataRead => "read", + XrefKind::DataWrite => "write", + XrefKind::DataRef => "ref", + } + } + + pub fn is_data(self) -> bool { + matches!(self, XrefKind::DataRead | XrefKind::DataWrite | XrefKind::DataRef) + } + + pub fn db_tag(self) -> &'static str { + self.tag() + } +} + +/// Sub-classification of how `source`'s instruction computes its target +/// address. Only meaningful for data xrefs (`read` / `write` / `ref`); call +/// / jump / branch / ind_call rows store `None`. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum AddrMode { + /// Standard signed-16 displacement: `lwz rD, simm(rA)`, `stw rS, simm(rA)`, + /// FP D-forms (`lfs/lfd/stfs/stfd`), update variants. The dominant case. + DForm, + /// Address materialised via `lis + addi` register tracking — no + /// load/store yet at this site. + LisAddi, + /// Address materialised via `lis + ori` register tracking. + LisOri, + /// Multi-word D-form: `lmw / stmw rS, simm(rA)` — emits one xref per + /// register slot (32-rS slots starting at the resolved base). + Multiword, + /// X-form indexed: `stwx / stbx / sthx / stwux / stbux / sthux / stdx / + /// stdux` plus AltiVec/VMX vector stores `stvx / stvxl / stvebx / + /// stvehx / stvewx`. Static resolution requires both rA and rB + /// constant. (M6 + VMX follow-up.) + XFormIndexed, + /// X-form byte-reverse: `stwbrx / sthbrx / lwbrx / lhbrx`. + XFormByteRev, + /// Reservation/atomic store-conditional: `stwcx. / stdcx.`. + Atomic, + /// Cache-line clear: `dcbz rA, rB` — clears 32 bytes at rA+rB. + DCBZ, +} + +impl AddrMode { + pub fn tag(self) -> &'static str { + match self { + AddrMode::DForm => "d_form", + AddrMode::LisAddi => "lis_addi", + AddrMode::LisOri => "lis_ori", + AddrMode::Multiword => "multiword", + AddrMode::XFormIndexed => "x_form_indexed", + AddrMode::XFormByteRev => "x_form_byterev", + AddrMode::Atomic => "atomic", + AddrMode::DCBZ => "dcbz", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Xref { + pub source: u32, + pub kind: XrefKind, + /// `None` for control-flow edges; `Some(...)` for data edges. + pub addr_mode: Option, +} + +pub type XrefMap = HashMap>; + +/// Result of cross-reference analysis. +pub struct XrefResult { + pub labels: HashMap, + pub xrefs: XrefMap, + pub data_annotations: HashMap, +} + +/// Perform full cross-reference analysis on a PE image. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), entry_point = format_args!("{:#010x}", entry_point)))] +pub fn analyze_xrefs( + pe: &[u8], + image_base: u32, + entry_point: u32, + sections: &[PeSection], + func_analysis: &FuncAnalysis, + import_map: &HashMap, +) -> XrefResult { + analyze_xrefs_skipping( + pe, image_base, entry_point, sections, func_analysis, import_map, + &std::collections::BTreeSet::new(), + ) +} + +/// Like [`analyze_xrefs`], but skips the word addresses in `data_words`. +/// +/// Those are data embedded in a code section — recovered jump tables and their +/// index maps (see [`crate::jumptables`]). Decoding them yields whatever +/// instruction their bit pattern happens to spell, and any reference that +/// "instruction" appears to make is fiction. On the reference title every case +/// target begins `0x82…`, which decodes as a `lwz`, so the damage is bogus data +/// reads rather than bogus control flow — but it is damage either way, and it +/// also invents `dat_…` labels in the middle of `.rdata`. +#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), data_words = data_words.len()))] +pub fn analyze_xrefs_skipping( + pe: &[u8], + image_base: u32, + entry_point: u32, + sections: &[PeSection], + func_analysis: &FuncAnalysis, + import_map: &HashMap, + data_words: &std::collections::BTreeSet, +) -> XrefResult { + let started = std::time::Instant::now(); + let func_labels = func_analysis.generate_labels(); + let mut labels: HashMap = func_labels; + labels.insert(entry_point, "entry_point".to_string()); + + // Add import thunks as labels + for (addr, name) in import_map { + labels.insert(*addr, format!("__imp_{}", name.replace("::", "_"))); + } + + // First pass: collect branch targets + cross-references from code sections + let mut xrefs: XrefMap = HashMap::new(); + + for section in sections { + if !section.is_code() { continue; } + let va_start = section.virtual_address; + let va_end = va_start + section.virtual_size; + let file_start = section.virtual_address as usize; + + let mut addr = va_start; + while addr < va_end { + let abs_addr = image_base + addr; + let off = (addr - va_start) as usize + file_start; + if off + 4 > pe.len() { break; } + let instr = u32::from_be_bytes([ + pe[off], pe[off+1], pe[off+2], pe[off+3] + ]); + + if !data_words.contains(&abs_addr) { + collect_branch_target(instr, abs_addr, &mut labels, &mut xrefs); + } + addr += 4; + } + } + + // Second pass: resolve data references via lis+load/store pattern matching + let mut data_annotations: HashMap = HashMap::new(); + + // Build set of valid data address ranges for filtering false positives + let data_ranges: Vec<(u32, u32)> = sections.iter() + .map(|s| (image_base + s.virtual_address, + image_base + s.virtual_address + s.virtual_size)) + .collect(); + + for section in sections { + if !section.is_code() { continue; } + let va_start = section.virtual_address; + let va_end = va_start + section.virtual_size; + let file_start = section.virtual_address as usize; + + // Register state: track lis results. reg_hi[r] = Some(high_16_bits << 16) + let mut reg_hi: [Option; 32] = [None; 32]; + + let mut addr = va_start; + while addr < va_end { + let abs_addr = image_base + addr; + let off = (addr - va_start) as usize + file_start; + if off + 4 > pe.len() { break; } + let instr = u32::from_be_bytes([ + pe[off], pe[off+1], pe[off+2], pe[off+3] + ]); + + // A jump-table word is not an instruction. Skip it, and drop the + // tracked constants with it: the words around it belong to + // different basic blocks, so nothing carries across. + if data_words.contains(&abs_addr) { + reg_hi = [None; 32]; + addr += 4; + continue; + } + + let opcode = (instr >> 26) & 0x3F; + let rd = ((instr >> 21) & 0x1F) as usize; + let ra = ((instr >> 16) & 0x1F) as usize; + let simm = ((instr & 0xFFFF) as i16) as i32; + let uimm = instr & 0xFFFF; + + // Reset tracking on function boundaries (prologue = mfspr rN, LR) + if opcode == 31 { + let xo = (instr >> 1) & 0x3FF; + if xo == 339 { // mfspr + let spr = (((instr >> 16) & 0x1F) << 5) | ((instr >> 11) & 0x1F); + if spr == 8 { // LR + reg_hi = [None; 32]; + } + } + } + + match opcode { + // lis rD, IMM (encoded as addis rD, r0, IMM) + 15 if ra == 0 => { + reg_hi[rd] = Some(uimm << 16); + } + // addis rD, rA, IMM (rA != 0) — if rA has known lis, update + 15 if ra != 0 => { + if let Some(base) = reg_hi[ra] { + reg_hi[rd] = Some(base.wrapping_add(uimm << 16)); + } else { + reg_hi[rd] = None; + } + } + // addi rD, rA, IMM — compute full address if rA has known lis + 14 if ra != 0 => { + if let Some(base) = reg_hi[ra] { + let data_addr = base.wrapping_add(simm as u32); + if is_in_ranges(data_addr, &data_ranges) { + data_annotations.insert(abs_addr, (data_addr, XrefKind::DataRef)); + xrefs.entry(data_addr).or_default().push(Xref { + source: abs_addr, kind: XrefKind::DataRef, + addr_mode: Some(AddrMode::LisAddi), + }); + labels.entry(data_addr).or_insert_with(|| format!("dat_{data_addr:08X}")); + } + reg_hi[rd] = Some(data_addr); // propagate for chained access + } else { + reg_hi[rd] = None; + } + } + // ori rA, rS, UIMM — compute full address + 24 => { + let rs = rd; // source is bits 21-25 for ori + if let Some(base) = reg_hi[rs] { + let data_addr = base | uimm; + if is_in_ranges(data_addr, &data_ranges) { + data_annotations.insert(abs_addr, (data_addr, XrefKind::DataRef)); + xrefs.entry(data_addr).or_default().push(Xref { + source: abs_addr, kind: XrefKind::DataRef, + addr_mode: Some(AddrMode::LisOri), + }); + labels.entry(data_addr).or_insert_with(|| format!("dat_{data_addr:08X}")); + } + reg_hi[ra] = Some(data_addr); + } else { + reg_hi[ra] = None; + } + } + // Load instructions: lwz, lbz, lhz, lha, lfs, lfd, lwzu, etc. + 32 | 33 | 34 | 35 | 40 | 41 | 42 | 43 | 48 | 49 | 50 | 51 => { + if ra != 0 + && let Some(base) = reg_hi[ra] { + let data_addr = base.wrapping_add(simm as u32); + if is_in_ranges(data_addr, &data_ranges) { + data_annotations.insert(abs_addr, (data_addr, XrefKind::DataRead)); + xrefs.entry(data_addr).or_default().push(Xref { + source: abs_addr, kind: XrefKind::DataRead, + addr_mode: Some(AddrMode::DForm), + }); + labels.entry(data_addr).or_insert_with(|| format!("dat_{data_addr:08X}")); + } + } + // Load into rD may clobber the tracked value + reg_hi[rd] = None; + } + // lmw rD, simm(rA) — D-form multi-word load. Reads (32-rD) + // consecutive 4-byte words starting at base+simm into + // rD..r31. Emits one DataRead per slot. + 46 => { + if ra != 0 + && let Some(base) = reg_hi[ra] + { + let mut addr_w = base.wrapping_add(simm as u32); + for _slot in (rd as u32)..32 { + if is_in_ranges(addr_w, &data_ranges) { + data_annotations.insert(abs_addr, (addr_w, XrefKind::DataRead)); + xrefs.entry(addr_w).or_default().push(Xref { + source: abs_addr, kind: XrefKind::DataRead, + addr_mode: Some(AddrMode::Multiword), + }); + labels.entry(addr_w).or_insert_with(|| format!("dat_{addr_w:08X}")); + } + addr_w = addr_w.wrapping_add(4); + } + } + reg_hi[rd] = None; + } + // Store instructions: stw, stb, sth, stfs, stfd, stwu, etc. + 36 | 37 | 38 | 39 | 44 | 45 | 52 | 53 | 54 | 55 => { + if ra != 0 + && let Some(base) = reg_hi[ra] { + let data_addr = base.wrapping_add(simm as u32); + if is_in_ranges(data_addr, &data_ranges) { + data_annotations.insert(abs_addr, (data_addr, XrefKind::DataWrite)); + xrefs.entry(data_addr).or_default().push(Xref { + source: abs_addr, kind: XrefKind::DataWrite, + addr_mode: Some(AddrMode::DForm), + }); + labels.entry(data_addr).or_insert_with(|| format!("dat_{data_addr:08X}")); + } + } + } + // stmw rS, simm(rA) — D-form multi-word store. Writes + // (32-rS) consecutive 4-byte words from rS..r31 to + // base+simm onward. Emits one DataWrite per slot. + 47 => { + if ra != 0 + && let Some(base) = reg_hi[ra] + { + let mut addr_w = base.wrapping_add(simm as u32); + for _slot in (rd as u32)..32 { + if is_in_ranges(addr_w, &data_ranges) { + data_annotations.insert(abs_addr, (addr_w, XrefKind::DataWrite)); + xrefs.entry(addr_w).or_default().push(Xref { + source: abs_addr, kind: XrefKind::DataWrite, + addr_mode: Some(AddrMode::Multiword), + }); + labels.entry(addr_w).or_insert_with(|| format!("dat_{addr_w:08X}")); + } + addr_w = addr_w.wrapping_add(4); + } + } + } + // X-form: opcode 31 — indexed loads/stores, atomic ops, dcbz. + // We can't statically resolve `rA + rB` without tracking rB + // too; we record an xref ONLY when rB is also a known + // constant (rare) OR when rB is r0 (which encodes as zero). + // Falls through to the generic-clobber arm afterwards via + // the explicit reg_hi update. + 31 => { + let xo = (instr >> 1) & 0x3FF; + let rb = ((instr >> 11) & 0x1F) as usize; + let resolve_rab = |reg_hi: &[Option; 32]| -> Option { + let a = if ra == 0 { Some(0u32) } else { reg_hi[ra] }; + let b = if rb == 0 { Some(0u32) } else { reg_hi[rb] }; + match (a, b) { + (Some(av), Some(bv)) => Some(av.wrapping_add(bv)), + _ => None, + } + }; + let mode_for_xo = |xo: u32| -> Option<(AddrMode, XrefKind)> { + match xo { + // Atomic store-conditional + 150 => Some((AddrMode::Atomic, XrefKind::DataWrite)), // stwcx. + 214 => Some((AddrMode::Atomic, XrefKind::DataWrite)), // stdcx. + // Byte-reverse stores + 662 => Some((AddrMode::XFormByteRev, XrefKind::DataWrite)), // stwbrx + 918 => Some((AddrMode::XFormByteRev, XrefKind::DataWrite)), // sthbrx + // Byte-reverse loads + 534 => Some((AddrMode::XFormByteRev, XrefKind::DataRead)), // lwbrx + 790 => Some((AddrMode::XFormByteRev, XrefKind::DataRead)), // lhbrx + // dcbz — cache-line zero (32-byte clear). Treat as a write. + 1014 => Some((AddrMode::DCBZ, XrefKind::DataWrite)), + // Plain X-form indexed stores (the common ones) + 151 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stwx + 215 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stbx + 407 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // sthx + 183 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stwux + 247 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stbux + 439 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // sthux + 149 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stdx + 181 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stdux + // Plain X-form indexed loads + 23 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lwzx + 87 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lbzx + 279 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhzx + 343 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhax + 55 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lwzux + 119 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lbzux + 311 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhzux + 375 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhaux + 21 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // ldx + 53 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // ldux + // AltiVec/VMX (opcode 31) loads & stores. Element + // variants store one byte/halfword/word; full + // `stvx` stores 16 bytes. Address resolution still + // requires both rA and rB constant — common only + // in static-table setup loops. + 231 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvx + 487 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvxl + 135 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvebx + 167 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvehx + 199 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvewx + // AltiVec/VMX loads — same XO range, kind=read. + 103 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvx + 359 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvxl + 7 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvebx + 39 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvehx + 71 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvewx + _ => None, + } + }; + if let Some((addr_mode, kind)) = mode_for_xo(xo) + && let Some(data_addr) = resolve_rab(®_hi) + && is_in_ranges(data_addr, &data_ranges) + { + data_annotations.insert(abs_addr, (data_addr, kind)); + xrefs.entry(data_addr).or_default().push(Xref { + source: abs_addr, kind, + addr_mode: Some(addr_mode), + }); + labels.entry(data_addr).or_insert_with(|| format!("dat_{data_addr:08X}")); + } + // Fall through: any X-form op may write rD; invalidate. + reg_hi[rd] = None; + } + // Any other instruction writing to rD: invalidate + _ => { + // Conservatively invalidate for instructions that modify rD + // (most ALU ops, loads, etc.) + if opcode != 18 && opcode != 16 && opcode != 17 { // skip branch/sc + reg_hi[rd] = None; + } + } + } + + addr += 4; + } + } + + let elapsed_ms = started.elapsed().as_millis() as f64; + metrics::histogram!("analysis.phase_ms", "phase" => "xrefs").record(elapsed_ms); + let total_xrefs: usize = xrefs.values().map(|v| v.len()).sum(); + tracing::info!( + labels = labels.len(), + xrefs = total_xrefs, + data_annotations = data_annotations.len(), + elapsed_ms, + "xref analysis complete" + ); + + XrefResult { labels, xrefs, data_annotations } +} + +fn collect_branch_target(instr: u32, addr: u32, labels: &mut HashMap, xrefs: &mut XrefMap) { + let op = (instr >> 26) & 0x3F; + match op { + 18 => { + // I-form: b/bl/ba/bla + let li = sign_ext26(instr & 0x03FFFFFC); + let aa = instr & 2 != 0; + let lk = instr & 1 != 0; + let target = if aa { li as u32 } else { addr.wrapping_add(li as u32) }; + labels.entry(target).or_insert_with(|| format!("loc_{target:08X}")); + let kind = if lk { XrefKind::Call } else { XrefKind::Jump }; + xrefs.entry(target).or_default().push(Xref { source: addr, kind, addr_mode: None }); + } + 16 => { + // B-form: bc/bcl + let bd = sign_ext16(instr & 0xFFFC); + let aa = instr & 2 != 0; + let target = if aa { bd as u32 } else { addr.wrapping_add(bd as u32) }; + labels.entry(target).or_insert_with(|| format!("loc_{target:08X}")); + xrefs.entry(target).or_default().push(Xref { source: addr, kind: XrefKind::Branch, addr_mode: None }); + } + _ => {} + } +} + +fn sign_ext16(val: u32) -> i32 { + ((val << 16) as i32) >> 16 +} + +fn sign_ext26(val: u32) -> i32 { + ((val << 6) as i32) >> 6 +} + +fn is_in_ranges(addr: u32, ranges: &[(u32, u32)]) -> bool { + ranges.iter().any(|&(start, end)| addr >= start && addr < end) +} + +/// Find which section a data address falls in. +pub fn section_for_addr(addr: u32, sections: &[PeSection], image_base: u32) -> Option<&str> { + for s in sections { + let start = image_base + s.virtual_address; + let end = start + s.virtual_size; + if addr >= start && addr < end { + return Some(&s.name); + } + } + None +} + +/// Resolve a source address to "function_name+0xNN" or just "0xADDR". +pub fn resolve_source_label( + addr: u32, + func_analysis: &FuncAnalysis, + labels: &HashMap, +) -> String { + // Direct label hit? + if let Some(lbl) = labels.get(&addr) { + return lbl.clone(); + } + + // Find the containing function (largest start <= addr) + if let Some((&func_start, _fi)) = func_analysis.functions.range(..=addr).next_back() + && let Some(func_label) = labels.get(&func_start) { + let offset = addr - func_start; + return format!("{func_label}+0x{offset:X}"); + } + + format!("0x{addr:08X}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addr_mode_tags_are_distinct() { + let modes = [ + AddrMode::DForm, + AddrMode::LisAddi, + AddrMode::LisOri, + AddrMode::Multiword, + AddrMode::XFormIndexed, + AddrMode::XFormByteRev, + AddrMode::Atomic, + AddrMode::DCBZ, + ]; + let tags: std::collections::HashSet<&str> = modes.iter().map(|m| m.tag()).collect(); + assert_eq!(tags.len(), modes.len(), "every AddrMode variant must have a unique tag"); + } + + #[test] + fn xref_struct_carries_addr_mode_for_data_edges() { + let x = Xref { source: 0x1234, kind: XrefKind::DataWrite, addr_mode: Some(AddrMode::DForm) }; + assert_eq!(x.addr_mode.unwrap().tag(), "d_form"); + } + + #[test] + fn xref_struct_addr_mode_is_none_for_call_edges() { + let x = Xref { source: 0x1234, kind: XrefKind::Call, addr_mode: None }; + assert!(x.addr_mode.is_none()); + } +} diff --git a/crates/sylpheed-xexdb/tests/db_schema_golden.rs b/crates/sylpheed-xexdb/tests/db_schema_golden.rs new file mode 100644 index 00000000..ecad1782 --- /dev/null +++ b/crates/sylpheed-xexdb/tests/db_schema_golden.rs @@ -0,0 +1,450 @@ +//! DB schema golden — locks the column layout (names + types) of every +//! table written by `DbWriter`. A schema change here without a fixture +//! update fails the test, forcing a conscious decision before downstream +//! query consumers break. +//! +//! The fixture is constructed in-process (no XEX/ISO needed): a small +//! synthetic PE-shaped byte slice with one `.text` section of 4 +//! instructions, plus an empty import-library list and one detected +//! function. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::io::Write; + +use duckdb::Connection; + +use sylpheed_xexdb::DbWriter; +use sylpheed_xexdb::formatter::DisasmInfo; +use sylpheed_xexdb::func::{FuncAnalysis, FuncInfo}; +use sylpheed_xexdb::rtti::RttiResult; +use sylpheed_xexdb::xref::XrefMap; +use sylpheed_xex::pe::PeSection; + +/// Build a 16-byte `.text` section: 4 instructions (mflr / nop / blr / nop). +fn synthetic_pe() -> (Vec, Vec, Vec) { + // VA layout: image_base + 0x1000 = .text start (so RVA = 0x1000). + // The DB writer expects pe[rva] to hold the byte at that RVA, so the + // buffer must be at least 0x1000 + section_size bytes long. + const RVA: usize = 0x1000; + const TEXT: [u32; 4] = [ + // mfspr r12, LR (a.k.a. mflr r12) — opcode 31, xo 339, spr 8 (LR). + // Encoded with spr halves swapped per the ISA: spr_field = (8<<5). + (31u32 << 26) | (12 << 21) | ((8 << 5) << 11) | (339 << 1), + 0x60000000, // nop (ori r0, r0, 0) + (19u32 << 26) | (20 << 21) | (16 << 1), // blr (bclr 20, 0) + 0x60000000, // nop + ]; + + let mut pe = vec![0u8; RVA + 16]; + for (i, &word) in TEXT.iter().enumerate() { + pe[RVA + i * 4..RVA + i * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + + let sections = vec![PeSection { + name: ".text".to_string(), + virtual_address: 0x1000, + virtual_size: 16, + raw_offset: 0x1000, + raw_size: 16, + flags: 0x60000020, // CODE | EXECUTE | READ + }]; + + let import_libraries = vec![]; // No imports in the fixture. + (pe, sections, import_libraries) +} + +fn synthetic_func_analysis(image_base: u32) -> FuncAnalysis { + // Single function covering all four .text instructions. + let entry = image_base + 0x1000; + let mut functions = BTreeMap::new(); + functions.insert( + entry, + FuncInfo { + start: entry, + end: entry + 16, + frame_size: 0, + saved_gprs: 0, + is_leaf: true, + is_saverestore: false, + pdata_validated: false, + pdata_length: None, + pdata_prolog_length: None, + has_eh: false, + }, + ); + FuncAnalysis { + functions, + save_gpr_base: None, + restore_gpr_base: None, + pdata_entries: Vec::new(), + } +} + +#[test] +fn db_schema_matches_expected_columns() { + let (pe, sections, libs) = synthetic_pe(); + let image_base = 0x82000000u32; + let entry = image_base + 0x1000; + + let info = DisasmInfo { + image_base, + entry_point: entry, + original_pe_name: Some("synthetic.exe"), + title_id: Some(0xDEADBEEF), + media_id: Some(0xCAFEF00D), + sections: §ions, + import_libraries: &libs, + xex_header: None, + }; + + let func_analysis = synthetic_func_analysis(image_base); + let mut labels: HashMap = HashMap::new(); + labels.insert(entry, "entry_point".to_string()); + let xrefs: XrefMap = XrefMap::new(); + + let tmp = std::env::temp_dir().join("sylpheed_xexdb_schema_golden.duckdb"); + let _ = std::fs::remove_file(&tmp); + + { + let mut w = DbWriter::open_fresh(&tmp).expect("open fresh DB"); + w.write_base(&info).expect("write_base"); + w.ingest_instructions(&pe, &info, &func_analysis, &labels, &BTreeSet::new()) + .expect("ingest_instructions"); + w.write_analysis_results( + &pe, &info, &func_analysis, &labels, &xrefs, + &[], &[], &[], None, &[], &[], &RttiResult::default(), None, + ) + .expect("write_analysis_results"); + w.create_sql_views().expect("create_sql_views"); + } + + let conn = Connection::open(&tmp).expect("reopen DB"); + + // Lock the column layout per table. Pairs are (name, type). + let expected: &[(&str, &[(&str, &str)])] = &[ + ("metadata", &[ + ("key", "VARCHAR"), + ("value", "VARCHAR"), + ]), + ("sections", &[ + ("name", "VARCHAR"), + ("virtual_address", "BIGINT"), + ("virtual_size", "BIGINT"), + ("raw_offset", "BIGINT"), + ("raw_size", "BIGINT"), + ("flags", "BIGINT"), + ("is_code", "BOOLEAN"), + ]), + ("imports", &[ + ("library", "VARCHAR"), + ("ordinal", "BIGINT"), + ("name", "VARCHAR"), + ("record_type", "BIGINT"), + ("address", "BIGINT"), + ]), + ("instructions", &[ + ("address", "BIGINT"), + ("raw", "BIGINT"), + ("mnemonic", "VARCHAR"), + ("operands", "VARCHAR"), + ("disasm", "VARCHAR"), + ("ext_mnemonic", "VARCHAR"), + ("ext_operands", "VARCHAR"), + ("ext_disasm", "VARCHAR"), + ("target_hex", "BIGINT"), + ("section", "VARCHAR"), + ("function", "BIGINT"), + ("label", "VARCHAR"), + ("is_data", "BOOLEAN"), + ]), + ("functions", &[ + ("address", "BIGINT"), + ("name", "VARCHAR"), + ("end_address", "BIGINT"), + ("frame_size", "BIGINT"), + ("saved_gprs", "BIGINT"), + ("is_leaf", "BOOLEAN"), + ("is_saverestore", "BOOLEAN"), + ("pdata_validated", "BOOLEAN"), + ("pdata_length", "BIGINT"), + ("prolog_length", "BIGINT"), + ("has_eh", "BOOLEAN"), + ]), + ("jump_tables", &[ + ("bctr_pc", "BIGINT"), + ("function", "BIGINT"), + ("table_address", "BIGINT"), + ("entry_count", "BIGINT"), + ("table_slots", "BIGINT"), + ("index_map_address", "BIGINT"), + ("index_map_count", "BIGINT"), + ("case_bound", "BIGINT"), + ("kind", "VARCHAR"), + ]), + ("jump_table_entries", &[ + ("bctr_pc", "BIGINT"), + ("case_index", "BIGINT"), + ("target_address", "BIGINT"), + ]), + ("data_in_code", &[ + ("address", "BIGINT"), + ("length", "BIGINT"), + ("kind", "VARCHAR"), + ]), + ("rtti_type_descriptors", &[ + ("address", "BIGINT"), + ("mangled_name", "VARCHAR"), + ("demangled_name", "VARCHAR"), + ]), + ("rtti_locators", &[ + ("address", "BIGINT"), + ("subobject_offset", "BIGINT"), + ("cd_offset", "BIGINT"), + ("type_descriptor", "BIGINT"), + ("class_hierarchy", "BIGINT"), + ("vtable_address", "BIGINT"), + ]), + ("rtti_base_classes", &[ + ("class_hierarchy", "BIGINT"), + ("base_index", "BIGINT"), + ("type_descriptor", "BIGINT"), + ("name", "VARCHAR"), + ("num_contained_bases", "BIGINT"), + ("mdisp", "BIGINT"), + ("pdisp", "BIGINT"), + ("vdisp", "BIGINT"), + ("attributes", "BIGINT"), + ]), + ("pdata_entries", &[ + ("begin_address", "BIGINT"), + ("end_address", "BIGINT"), + ("function_length", "BIGINT"), + ("prolog_length", "BIGINT"), + ("flags", "BIGINT"), + ]), + ("labels", &[ + ("address", "BIGINT"), + ("name", "VARCHAR"), + ("kind", "VARCHAR"), + ]), + ("xdbf_entries", &[ + ("namespace", "BIGINT"), + ("namespace_name", "VARCHAR"), + ("id", "BIGINT"), + ("body_offset", "BIGINT"), + ("size", "BIGINT"), + ("magic", "VARCHAR"), + ]), + ("xdbf_achievements", &[ + ("id", "BIGINT"), + ("name", "VARCHAR"), + ("unlocked_desc", "VARCHAR"), + ("locked_desc", "VARCHAR"), + ("label_id", "BIGINT"), + ("description_id", "BIGINT"), + ("unachieved_id", "BIGINT"), + ("image_id", "BIGINT"), + ("gamerscore", "BIGINT"), + ("flags", "BIGINT"), + ]), + ("xdbf_strings", &[ + ("language", "BIGINT"), + ("language_name", "VARCHAR"), + ("string_id", "BIGINT"), + ("value", "VARCHAR"), + ]), + ("xdbf_images", &[ + ("id", "BIGINT"), + ("is_title_icon", "BOOLEAN"), + ("body_offset", "BIGINT"), + ("size", "BIGINT"), + ("format", "VARCHAR"), + ]), + ("demangled_names", &[ + ("address", "BIGINT"), + ("mangled", "VARCHAR"), + ("raw_demangled", "VARCHAR"), + ("namespace_path", "VARCHAR"), + ("class_name", "VARCHAR"), + ("method_name", "VARCHAR"), + ("params_signature", "VARCHAR"), + ]), + ("vtables", &[ + ("address", "BIGINT"), + ("length", "BIGINT"), + ("col_address", "BIGINT"), + ("class_name", "VARCHAR"), + ("rtti_present", "BOOLEAN"), + ("base_classes_json", "VARCHAR"), + ]), + ("methods", &[ + ("vtable_address", "BIGINT"), + ("slot", "BIGINT"), + ("function_address", "BIGINT"), + ("mangled_name", "VARCHAR"), + ("demangled_name", "VARCHAR"), + ]), + ("classes", &[ + ("name", "VARCHAR"), + ("vtable_address", "BIGINT"), + ("rtti_present", "BOOLEAN"), + ("base_classes_json", "VARCHAR"), + ]), + ("strings", &[ + ("address", "BIGINT"), + ("encoding", "VARCHAR"), + ("length", "BIGINT"), + ("content", "VARCHAR"), + ("section", "VARCHAR"), + ]), + ("tls_info", &[ + ("raw_data_start", "BIGINT"), + ("raw_data_end", "BIGINT"), + ("index_address", "BIGINT"), + ("callback_array", "BIGINT"), + ("zero_fill_size", "BIGINT"), + ("characteristics", "BIGINT"), + ]), + ("tls_callbacks", &[ + ("slot", "BIGINT"), + ("address", "BIGINT"), + ]), + ("function_pointer_arrays", &[ + ("address", "BIGINT"), + ("length", "BIGINT"), + ("kind", "VARCHAR"), + ]), + ("function_pointer_array_entries", &[ + ("array_address", "BIGINT"), + ("slot", "BIGINT"), + ("function_address", "BIGINT"), + ]), + ("indirect_dispatch_sites", &[ + ("dispatch_pc", "BIGINT"), + ("vptr_offset", "BIGINT"), + ("slot", "BIGINT"), + ("candidate_count", "BIGINT"), + ("truncated", "BOOLEAN"), + ]), + ("indirect_dispatch_candidates", &[ + ("dispatch_pc", "BIGINT"), + ("vtable_address", "BIGINT"), + ("method_address", "BIGINT"), + ]), + ("vptr_writes", &[ + ("writer_pc", "BIGINT"), + ("vtable_address", "BIGINT"), + ("vptr_offset", "BIGINT"), + ("writer_function", "BIGINT"), + ]), + ("eh_funcinfo", &[ + ("address", "BIGINT"), + ("magic", "BIGINT"), + ("max_state", "BIGINT"), + ("p_unwind_map", "BIGINT"), + ("n_try_blocks", "BIGINT"), + ("p_try_block_map", "BIGINT"), + ("n_ip_map_entries", "BIGINT"), + ("p_ip_to_state_map", "BIGINT"), + ("p_es_type_list", "BIGINT"), + ("eh_flags", "BIGINT"), + ]), + ("eh_unwind_map", &[ + ("funcinfo_address", "BIGINT"), + ("state_index", "BIGINT"), + ("to_state", "BIGINT"), + ("action_pc", "BIGINT"), + ]), + ("eh_try_blocks", &[ + ("funcinfo_address", "BIGINT"), + ("try_index", "BIGINT"), + ("try_low", "BIGINT"), + ("try_high", "BIGINT"), + ("catch_high", "BIGINT"), + ("n_catches", "BIGINT"), + ("p_handler_array", "BIGINT"), + ]), + ("xrefs", &[ + ("source", "BIGINT"), + ("target", "BIGINT"), + ("kind", "VARCHAR"), + ("addr_mode", "VARCHAR"), + ("instruction", "VARCHAR"), + ("source_func", "BIGINT"), + ("source_label", "VARCHAR"), + ("target_label", "VARCHAR"), + ]), + ]; + + let mut errs: Vec = Vec::new(); + for (table, cols) in expected { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info('{}')", table)) + .unwrap_or_else(|e| panic!("prepare PRAGMA for {table}: {e}")); + let rows: Vec<(String, String)> = stmt + .query_map([], |row| { + let name: String = row.get(1)?; + let ty: String = row.get(2)?; + Ok((name, ty)) + }) + .expect("query") + .map(|r| r.unwrap()) + .collect(); + + if rows.len() != cols.len() { + writeln!( + std::io::stderr(), + "{table}: column count mismatch (got {}, expected {})", + rows.len(), + cols.len() + ).ok(); + errs.push(format!("{table}: count {} vs {}", rows.len(), cols.len())); + } + for (i, (got, expected_col)) in rows.iter().zip(cols.iter()).enumerate() { + if got.0 != expected_col.0 || got.1 != expected_col.1 { + errs.push(format!( + "{table} col {i}: got ({}, {}) expected ({}, {})", + got.0, got.1, expected_col.0, expected_col.1 + )); + } + } + } + + assert!(errs.is_empty(), "schema drift detected:\n {}", errs.join("\n ")); + + // Verify row counts in the populated tables. + let n_instr: i64 = conn + .query_row("SELECT COUNT(*) FROM instructions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n_instr, 4, "expected 4 instruction rows from the synthetic PE"); + + // The synthetic mflr should produce target_hex = NULL, blr likewise (indirect). + let n_with_target: i64 = conn + .query_row("SELECT COUNT(target_hex) FROM instructions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n_with_target, 0, "indirect-only fixture should have no direct branch targets"); + + // SQL views must be queryable. The `_` in SQL LIKE is a single-char + // wildcard, so we list the names explicitly rather than `LIKE 'v_%'` + // (which also matches DuckDB's built-in `views` system view). + let expected_views = [ + "v_branch_xrefs", + "v_call_graph", + "v_function_first_instruction", + "v_imports_called", + "v_indirect_reachability_from_entry", + "v_reachability_from_entry", + ]; + for v in expected_views { + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM duckdb_views() WHERE view_name = ?", + [v], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(exists, 1, "missing SQL view: {v}"); + } + + let _ = std::fs::remove_file(&tmp); +} diff --git a/crates/sylpheed-xexdb/tests/disasm_goldens.rs b/crates/sylpheed-xexdb/tests/disasm_goldens.rs new file mode 100644 index 00000000..60df0082 --- /dev/null +++ b/crates/sylpheed-xexdb/tests/disasm_goldens.rs @@ -0,0 +1,123 @@ +//! Analysis-side goldens: every row in the xenia-cpu fixtures must +//! round-trip cleanly through the [`sylpheed_xexdb::ppc`] shim. This +//! pins the shim's behaviour to the canonical `sylpheed_ppc::disasm::format` +//! output so that any future refactor of the shim layer surfaces here. +//! +//! Loads the same JSON fixtures committed under +//! `crates/xenia-cpu/tests/golden/`. No separate analysis-side fixture +//! files — the cpu canon is the source of truth. + +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct GoldenRow { + label: String, + raw: String, + addr: String, + mnemonic: String, + operands: String, + #[serde(default)] + ext_mnemonic: Option, + #[serde(default)] + ext_operands: Option, + #[serde(default)] + branch_target: Option, +} + +#[derive(Debug, Deserialize)] +struct GoldenFile { + rows: Vec, +} + +fn cpu_fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("xenia-cpu") + .join("tests") + .join("golden") + .join(name) +} + +fn parse_hex(s: &str) -> u32 { + let trimmed = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + u32::from_str_radix(trimmed, 16).expect("hex u32") +} + +/// Verify the shim's `Decoded { base, ext }` mirrors the canonical fields +/// from `sylpheed_ppc::disasm::format` for every fixture row. +fn check_fixture(fixture_name: &str) { + let path = cpu_fixture(fixture_name); + assert!( + path.exists(), + "missing fixture {} — run `cargo test -p xenia-cpu --test disasm_goldens` to (re)generate it", + path.display() + ); + let src = std::fs::read_to_string(&path).unwrap(); + let golden: GoldenFile = serde_json::from_str(&src).unwrap(); + + for row in &golden.rows { + let raw = parse_hex(&row.raw); + let addr = parse_hex(&row.addr); + + let canonical = + sylpheed_ppc::disasm::format(&sylpheed_ppc::decode(raw, addr)); + let shim = sylpheed_xexdb::ppc::disasm(raw, addr); + + assert_eq!( + shim.base, canonical.disasm, + "shim.base drifted for {} (raw={})", + row.label, row.raw, + ); + assert_eq!( + shim.ext, canonical.ext_disasm, + "shim.ext drifted for {} (raw={})", + row.label, row.raw, + ); + + // Also pin against the fixture's structured fields — guards against + // someone changing the cpu canon without regenerating the fixture. + assert_eq!(canonical.mnemonic, row.mnemonic, "mnemonic drift: {}", row.label); + assert_eq!(canonical.operands, row.operands, "operands drift: {}", row.label); + assert_eq!(canonical.ext_mnemonic, row.ext_mnemonic, "ext_mnemonic drift: {}", row.label); + assert_eq!(canonical.ext_operands, row.ext_operands, "ext_operands drift: {}", row.label); + + let target_str = canonical.branch_target.map(|t| format!("0x{t:08X}")); + assert_eq!(target_str, row.branch_target, "branch_target drift: {}", row.label); + } +} + +#[test] +fn analysis_shim_matches_base_mnemonics() { + check_fixture("base_mnemonics.json"); +} + +#[test] +fn analysis_shim_matches_extended_mnemonics() { + check_fixture("extended_mnemonics.json"); +} + +#[test] +fn analysis_shim_matches_vmx128_registers() { + check_fixture("vmx128_registers.json"); +} + +/// Spot-check that the shim's `display()` returns the extended form when +/// present and falls back to the base otherwise. This is the contract +/// `formatter.rs` and the .asm output rely on. +#[test] +fn shim_display_prefers_extended() { + // ori r0, r0, 0 → base "ori r0, r0, 0x0", ext "nop" + let d = sylpheed_xexdb::ppc::disasm(0x60000000, 0); + assert_eq!(d.display(), "nop"); + + // addi r3, r1, 16 → no extended form, display falls back to base + let raw = (14u32 << 26) | (3 << 21) | (1 << 16) | 16; + let d = sylpheed_xexdb::ppc::disasm(raw, 0); + assert!( + d.ext.is_none(), + "addi r3, r1, 16 has no extended form (only addi r3, r0, … → li)" + ); + assert_eq!(d.display(), d.base); +} diff --git a/tools/zq.py b/tools/zq.py new file mode 100755 index 00000000..934914d0 --- /dev/null +++ b/tools/zq.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Sylpheed static-analysis helper over DuckDB `sylpheed.db`. + +Hides the gotchas: DECIMAL bounds (DuckDB rejects 0x literals), read-only connect, +and the fact that the engine vtable / rdata is NOT in the DB (read it from guest +memory with `xenia-rs exec ... --dump-addr=0x` instead). + +Usage: + zq.py dis # disassemble [lo,hi) (jump-table words shown as .long) + zq.py fn # function containing pc (address,name,end) + zq.py xref # xrefs whose target == addr (callers) + zq.py callers # call-sites of vtable slot at byte offset N + # (finds `lwz r11, N(r11)` + reports the fn) + zq.py grep # instructions whose operands LIKE %substr% + zq.py find # instructions whose raw word == value (e.g. a ptr) + + zq.py switch # recovered switch cases for the bctr at/near pc + zq.py switches [fn_hex] # every recovered switch (optionally in one function) + zq.py classes [substr] # RTTI class names (+ vtable, method count) + zq.py class # one class: bases, vtable, virtual methods + zq.py str # string literals matching, with referencing functions + + zq.py xdbf [substr] # XDBF title text (all locales); substr filters + zq.py ach # XDBF achievements (id, gamerscore, name, descriptions) + +A command that needs a table the current DB predates prints what to regenerate +rather than a SQL error. +""" +import duckdb, sys + +DB = '/home/fabi/RE - Project Sylpheed/xenia-rs/sylpheed.db' +c = duckdb.connect(DB, read_only=True) +H = lambda x: '0x%08x' % x + +REGEN = ("xenia-rs dis --db sylpheed.db --analyze sql") + + +def _need(*tables): + """Exit with a regeneration hint if any table is missing from this DB.""" + have = {r[0] for r in c.execute( + "SELECT table_name FROM information_schema.tables").fetchall()} + missing = [t for t in tables if t not in have] + if missing: + sys.exit(f"this db predates {', '.join(missing)} — regenerate with:\n {REGEN}") + + +def _has_col(table, col): + return any(r[0] == col for r in c.execute( + "SELECT column_name FROM information_schema.columns WHERE table_name=?", + [table]).fetchall()) + + +def _fn(pc): + r = c.execute('SELECT address,name,end_address FROM functions WHERE address<=? AND end_address>? ' + 'ORDER BY address DESC LIMIT 1', [pc, pc]).fetchall() + return f'{r[0][1]}({H(r[0][0])})' if r else '?' + + +def cmd_dis(lo, hi): + data_col = 'is_data' if _has_col('instructions', 'is_data') else 'false' + rows = c.execute(f'SELECT address,mnemonic,operands,raw,{data_col} FROM instructions ' + 'WHERE address>=? AND address=? ORDER BY bctr_pc LIMIT 1', [pc]).fetchall() + if not r: + sys.exit('no recovered switch at or after %s' % H(pc)) + bctr, fn, tbl, kind, n = r[0] + print(f'bctr {H(bctr)} in {_fn(bctr)} table={H(tbl)} kind={kind} cases={n}') + for ci, tgt in c.execute('SELECT case_index,target_address FROM jump_table_entries ' + 'WHERE bctr_pc=? ORDER BY case_index', [bctr]).fetchall(): + print(f' case {ci:>3} -> {H(tgt)}') + + +def cmd_switches(fn): + _need('jump_tables') + q = ('SELECT bctr_pc,function,kind,entry_count,table_address FROM jump_tables ' + + ('WHERE function=? ' if fn is not None else '') + 'ORDER BY bctr_pc') + for bctr, f, kind, n, tbl in c.execute(q, [fn] if fn is not None else []).fetchall(): + print(H(bctr), f'{kind:<8}', f'cases={n:<4}', 'table=' + H(tbl), 'in', _fn(bctr)) + + +def cmd_classes(sub): + _need('rtti_type_descriptors', 'rtti_locators') + q = """SELECT td.demangled_name, c.vtable_address, c.subobject_offset, + (SELECT count(*) FROM methods m WHERE m.vtable_address = c.vtable_address) + FROM rtti_locators c + JOIN rtti_type_descriptors td ON td.address = c.type_descriptor + {} ORDER BY td.demangled_name, c.subobject_offset""" + q = q.format('WHERE td.demangled_name ILIKE ?' if sub else '') + for name, vt, off, nm in c.execute(q, [f'%{sub}%'] if sub else []).fetchall(): + loc = H(vt) if vt is not None else '-' + print(f'{name:<60} vtable={loc} +0x{off:x} methods={nm}') + + +def cmd_class(name): + _need('rtti_type_descriptors', 'rtti_locators', 'rtti_base_classes') + rows = c.execute("""SELECT c.address, c.vtable_address, c.class_hierarchy, c.subobject_offset + FROM rtti_locators c + JOIN rtti_type_descriptors td ON td.address = c.type_descriptor + WHERE td.demangled_name = ?""", [name]).fetchall() + if not rows: + sys.exit(f'no RTTI class named {name!r} (try: zq.py classes {name})') + for col, vt, chd, off in rows: + print(f'== {name} (COL {H(col)}, subobject +0x{off:x})') + bases = c.execute('SELECT base_index,name,mdisp,pdisp,vdisp FROM rtti_base_classes ' + 'WHERE class_hierarchy=? AND base_index>0 ORDER BY base_index', + [chd]).fetchall() + for _, bn, md, pd, vd in bases: + print(f' base {bn} mdisp={md} pdisp={pd} vdisp={vd}') + if vt is None: + print(' (no vtable located)') + continue + for slot, fa in c.execute('SELECT slot,function_address FROM methods ' + 'WHERE vtable_address=? ORDER BY slot', [vt]).fetchall(): + print(f' vf{slot:<3} {H(fa)} {_fn(fa)}') + + +def cmd_str(sub): + sec = ', section' if _has_col('strings', 'section') else ", ''" + rows = c.execute(f'SELECT address, encoding, content{sec} FROM strings ' + 'WHERE content ILIKE ? ORDER BY address', [f'%{sub}%']).fetchall() + for a, enc, content, section in rows: + refs = c.execute("SELECT DISTINCT source_func FROM xrefs WHERE target=? AND source_func IS NOT NULL", + [a]).fetchall() + where = ', '.join(_fn(r[0]) for r in refs[:4]) or '(no xref)' + print(f'{H(a)} [{enc}{"/" + section if section else ""}] {content!r}\n <- {where}') + + +def cmd_xdbf(args): + """XDBF title text across every shipped locale.""" + sub = args[0] if args else "" + rows = c.execute( + "SELECT string_id, english, japanese FROM v_xdbf_text " + "WHERE (? = '' OR english ILIKE '%' || ? || '%' OR japanese ILIKE '%' || ? || '%') " + "ORDER BY string_id", + [sub, sub, sub], + ).fetchall() + for sid, en, ja in rows: + print(f"{sid:6} {en or ''}") + if ja and ja != en: + print(f" ja: {ja}") + print(f"({len(rows)} strings)") + + +def cmd_ach(_args): + """XDBF achievements in the title's default language.""" + rows = c.execute( + "SELECT id, gamerscore, name, unlocked_desc, locked_desc " + "FROM xdbf_achievements ORDER BY id" + ).fetchall() + total = 0 + for aid, gs, name, unlocked, locked in rows: + total += gs or 0 + print(f"{aid:3} | {gs:3}G | {name}") + print(f" unlocked: {unlocked}") + print(f" locked : {locked}") + print(f"\n{len(rows)} achievements, {total}G") + + +def main(): + if len(sys.argv) < 2: + print(__doc__); return + cmd, args = sys.argv[1], sys.argv[2:] + if cmd == 'dis': + cmd_dis(int(args[0], 16), int(args[1], 16)) + elif cmd == 'fn': + print(_fn(int(args[0], 16))) + elif cmd == 'xref': + t = int(args[0], 16) + for s, k, i, sf in c.execute('SELECT source,kind,instruction,source_func FROM xrefs ' + 'WHERE target=? ORDER BY source', [t]).fetchall(): + print(H(s), k, 'in', _fn(s), ':', i) + elif cmd == 'callers': + off = int(args[0]) # decimal byte offset, e.g. 196 for vtable[49] + pat = f'r11, {off}(r11)' + for (a,) in c.execute("SELECT address FROM instructions WHERE mnemonic='lwz' AND operands=? " + 'ORDER BY address', [pat]).fetchall(): + print(H(a), 'in', _fn(a)) + elif cmd == 'grep': + for a, m, o in c.execute("SELECT address,mnemonic,operands FROM instructions " + "WHERE operands LIKE ? ORDER BY address", [f'%{args[0]}%']).fetchall(): + print(H(a), m, o, ' in', _fn(a)) + elif cmd == 'find': + for (a,) in c.execute('SELECT address FROM instructions WHERE raw=? ORDER BY address', + [int(args[0], 16)]).fetchall(): + print(H(a)) + elif cmd == 'switch': + cmd_switch(int(args[0], 16)) + elif cmd == 'switches': + cmd_switches(int(args[0], 16) if args else None) + elif cmd == 'classes': + cmd_classes(args[0] if args else None) + elif cmd == 'class': + cmd_class(args[0]) + elif cmd == 'str': + cmd_str(args[0]) + elif cmd == 'xdbf': + cmd_xdbf(args) + elif cmd == 'ach': + cmd_ach(args) + else: + print(__doc__) + + + +if __name__ == '__main__': + main()