From the 2026-06-12 5-subsystem differential audit. All verified against canary as oracle; 660/660 workspace tests green (655 + 5 new). 1. nt_create_event polarity (exports.rs) — `manual_reset = gpr[5] != 0` was INVERTED. Canary xboxkrnl_threading.cc:668 `Initialize(!event_type,..)` + xevent.cc:41 (type 0 = NotificationEvent = manual, type 1 = Sync = auto). Now `== 0`. Was the dormant 2.AI fix on chore/portable-snapshot, never merged. The Ke-path was already correct; only the Nt-path was wrong. 2. 2.AF deadline drain (main.rs coord_pre_round) — expired KeWait/KeDelay deadlines never fired under load because advance_to_next_wake_if_due was only called in coord_idle_advance (no-Ready-threads path). Added a per-round drain loop; covers BOTH lockstep and parallel outer loops since both call coord_pre_round. Was the dormant 2.AF fix, never merged. 3. handle slab-recycle ABA guard (state.rs + scheduler.rs) — release_handle_slot (my round-34 regression) recycled a closed slot even with a thread still parked on it, risking a stale-waiter wake when the slot is re-minted. Added Scheduler::any_thread_waiting_on; decline to recycle a still-waited slot. 4. vpkpx pixel-pack (vmx.rs) — wrong field mapping (~100% mismatch). Now exact canary ppc_emit_altivec.cc:1795 shift/mask (red 6b out[15:10] from w[24:19], green out[9:5] from w[14:10], blue out[4:0] from w[7:3]; no fabricated alpha bit). +unit test. 5. VFS GDFX attribute plumbing (vfs/*, exports.rs query fns) — VfsEntry now carries the real on-disc attribute byte (GDFX dirent +12, canary disc_image_device.cc:136/154) instead of inferring directory-ness from path shape. Query exports report the real FILE_ATTRIBUTE_* bits. Candidate driver of the XamShowDirtyDiscErrorUI gate. +tests. 6. MmGetPhysicalAddress region-aware mirror (exports.rs) — flat 0x1FFFFFFF mask missed canary's +0x1000 host_address_offset for 0xE0000000+ mirror (memory.cc:2317). Read-only query; proven byte-identical 50M digest. +test. Investigated and intentionally NOT changed: - zero-on-recommit: no-op; ours has no region-reuse path (bump allocators, free is a stub). - 32-bit ALU writeback truncation (PPCBUG-020): documented-deliberate; premise (MSR.SF=0) is questionable but flipping it is out of scope here. - KeSetEvent/NtSetEvent return value: ours returns true previous state (hardware-faithful); canary returns constant 1 — NOT an ours bug. sylpheed_n50m golden will need re-baselining (legit behavior change). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
344 lines
13 KiB
Rust
344 lines
13 KiB
Rust
use crate::{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<VfsEntry>,
|
|
}
|
|
|
|
/// 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<String>, path: &std::path::Path) -> Result<Self, VfsError> {
|
|
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 `<prefix>/<name>` 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<Vec<VfsEntry>, 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<Vec<u8>, 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<VfsEntry, VfsError> {
|
|
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");
|
|
}
|
|
}
|