+//! ```
+//! 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