Files
xenia-rs/audit-runs/phase-c5-NtWriteFile/fix.diff
MechaCat02 ef93a4fa14 handoff: VSync/event-wedge fixes + iterate 2.A–2.BC research notes
Source changes (dormant parity infra, retained from iterate 2.AI/2.AO):
- xenia-kernel/exports.rs: nt_create_event manual_reset polarity +
  related event wiring
- xenia-gpu/mmio_region.rs: D1MODE_VBLANK_VLINE_STATUS hardcode parity

Also lands the audit-runs/ analysis notes (.md/.txt/.json digests) for the
iterate 2.x VSync/0x10e8/0x1004 wedge investigation. Raw trace dumps
(.jsonl/.gz/.csv/.stdout) and agent worktrees (.claude/) are gitignored as
regenerable local artifacts — see memory + HANDOFF for the running findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:19:08 +02:00

1168 lines
52 KiB
Diff

diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs
index a4dfa7d..96d23bf 100644
--- a/crates/xenia-kernel/src/exports.rs
+++ b/crates/xenia-kernel/src/exports.rs
@@ -46,8 +46,14 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x81, "KeQueryBasePriorityThread", ke_query_base_priority_thread);
state.register_export(Xboxkrnl, 0x82, "KeQueryIdealProcessor", ke_query_ideal_processor);
state.register_export(Xboxkrnl, 0x83, "KeQueryPerformanceFrequency", ke_query_performance_frequency);
- state.register_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time);
- state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", stub_return_zero);
+ // Canary declares `void KeQuerySystemTime_entry(lpqword_t time_ptr, ...)`
+ // (xboxkrnl_threading.cc:459); the time is delivered via the OUT
+ // pointer, not via gpr[3]. Phase A's `kernel.return.return_value`
+ // must be 0 (canary literal) — not r3 (which for ours is the input
+ // arg `time_ptr` left untouched). See `register_void_export` doc in
+ // state.rs.
+ state.register_void_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time);
+ state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", ke_raise_irql_to_dpc_level);
state.register_export(Xboxkrnl, 0x88, "KeReleaseSemaphore", ke_release_semaphore);
state.register_export(Xboxkrnl, 0x89, "KeReleaseSpinLockFromRaisedIrql", ke_release_spinlock_from_raised_irql);
state.register_export(Xboxkrnl, 0x8F, "KeResetEvent", ke_reset_event);
@@ -61,7 +67,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0xAF, "KeWaitForMultipleObjects", ke_wait_for_multiple_objects);
state.register_export(Xboxkrnl, 0xB0, "KeWaitForSingleObject", ke_wait_for_single_object);
state.register_export(Xboxkrnl, 0xB1, "KfAcquireSpinLock", kf_acquire_spin_lock);
- state.register_export(Xboxkrnl, 0xB3, "KfLowerIrql", stub_success);
+ state.register_void_export(Xboxkrnl, 0xB3, "KfLowerIrql", kf_lower_irql);
state.register_export(Xboxkrnl, 0xB4, "KfReleaseSpinLock", kf_release_spin_lock);
state.register_export(Xboxkrnl, 0x0152, "KeTlsAlloc", ke_tls_alloc);
state.register_export(Xboxkrnl, 0x0153, "KeTlsFree", stub_success);
@@ -132,7 +138,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x0126, "RtlFillMemoryUlong", rtl_fill_memory_ulong);
state.register_export(Xboxkrnl, 0x0127, "RtlFreeAnsiString", stub_success);
state.register_export(Xboxkrnl, 0x012B, "RtlImageXexHeaderField", rtl_image_xex_header_field);
- state.register_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string);
+ state.register_void_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string);
state.register_export(Xboxkrnl, 0x012D, "RtlInitUnicodeString", rtl_init_unicode_string);
state.register_export(Xboxkrnl, 0x012E, "RtlInitializeCriticalSection", rtl_initialize_critical_section);
state.register_export(Xboxkrnl, 0x012F, "RtlInitializeCriticalSectionAndSpinCount", rtl_initialize_critical_section);
@@ -185,8 +191,8 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", stub_success);
// Crypto
- state.register_export(Xboxkrnl, 0x0192, "XeCryptSha", stub_success);
- state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", stub_success);
+ state.register_void_export(Xboxkrnl, 0x0192, "XeCryptSha", xe_crypt_sha);
+ state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", xe_keys_console_private_key_sign);
state.register_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success);
// Xex module
@@ -453,18 +459,33 @@ fn nt_set_information_thread(
}
}
-/// `KeSetAffinityThread(thread_handle, new_mask) -> old_mask` — Axis 4.
-/// Drives `KernelState::set_affinity` which delegates to the scheduler
-/// and then fixes up every outstanding `ThreadRef` held in waiter lists.
+/// `KeSetAffinityThread(thread_ptr, affinity, prev_affinity_ptr)` — Axis 4.
+/// Mirrors xenia-canary `KeSetAffinityThread_entry`
+/// (xboxkrnl_threading.cc:323-346): returns `X_STATUS_SUCCESS` (0) in r3
+/// and writes the previous affinity to `*prev_affinity_ptr` (r5) when
+/// non-NULL. Validates `affinity != 0` (else `X_STATUS_INVALID_PARAMETER`)
+/// and that the thread handle resolves (else `X_STATUS_INVALID_HANDLE`).
+///
+/// Stage 2 Batch 3 fix (2026-05-14): pre-fix, ours returned `old_mask` in
+/// r3 with no OUT-pointer write — guest code expecting `STATUS_SUCCESS`
+/// in r3 was reading a small bitmask as an NTSTATUS.
fn ke_set_affinity_thread(
ctx: &mut PpcContext,
mem: &GuestMemory,
state: &mut KernelState,
) {
- let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let new_mask = (ctx.gpr[4] as u32) as u8;
+ let prev_ptr = ctx.gpr[5] as u32;
+ if new_mask == 0 {
+ ctx.gpr[3] = 0xC000_000D; // X_STATUS_INVALID_PARAMETER
+ return;
+ }
+ let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let old = state.set_affinity(handle, new_mask, mem);
- ctx.gpr[3] = old as u64;
+ if prev_ptr != 0 {
+ mem.write_u32(prev_ptr, old as u32);
+ }
+ ctx.gpr[3] = 0; // X_STATUS_SUCCESS
}
fn ke_bug_check(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
@@ -495,6 +516,49 @@ fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut Ke
}
}
+/// Offset of `current_irql` (u8) within PCR. Mirrors xenia-canary's
+/// `X_KPCR.current_irql` at offset 0x18 (xthread.h:189). PCR base is in
+/// `ctx.gpr[13]` per scheduler setup.
+const PCR_CURRENT_IRQL_OFFSET: u32 = 0x18;
+
+/// Mirrors xenia-canary `KeRaiseIrqlToDpcLevel_entry`
+/// (xboxkrnl_threading.cc:1253-1264): reads PCR's `current_irql`,
+/// returns the old value in r3, writes `DISPATCH_LEVEL` (2) back.
+fn ke_raise_irql_to_dpc_level(
+ ctx: &mut PpcContext,
+ mem: &GuestMemory,
+ _state: &mut KernelState,
+) {
+ let pcr = ctx.gpr[13] as u32;
+ let old_irql = mem.read_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET));
+ if old_irql > 2 {
+ tracing::warn!(
+ old_irql = old_irql,
+ "KeRaiseIrqlToDpcLevel: old_irql > 2 (DISPATCH_LEVEL)"
+ );
+ }
+ mem.write_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET), 2);
+ ctx.gpr[3] = old_irql as u64;
+}
+
+/// Mirrors xenia-canary `KfLowerIrql_entry`
+/// (xboxkrnl_threading.cc:1280-1282 calling `xeKfLowerIrql`): writes
+/// `new_irql` (r3) to PCR's `current_irql`. Void return (registered via
+/// `register_void_export`).
+fn kf_lower_irql(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
+ let new_irql = (ctx.gpr[3] as u32) as u8;
+ let pcr = ctx.gpr[13] as u32;
+ let current = mem.read_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET));
+ if new_irql > current {
+ tracing::warn!(
+ new_irql = new_irql,
+ current = current,
+ "KfLowerIrql: new_irql > current_irql"
+ );
+ }
+ mem.write_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET), new_irql);
+}
+
fn ke_initialize_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = PKSEMAPHORE, r4 = initial count, r5 = limit.
// Mirrors xenia-canary KeInitializeSemaphore_entry
@@ -592,8 +656,102 @@ fn ke_tls_set_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kernel
ctx.gpr[3] = 1; // TRUE
}
-fn ex_get_xconfig_setting(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
- ctx.gpr[3] = 0; // STATUS_SUCCESS (writes nothing)
+/// Mirrors xenia-canary `ExGetXConfigSetting_entry` + `xeExGetXConfigSetting`
+/// (xboxkrnl_xconfig.cc:303-319 calling :65-302). Returns a small value
+/// describing one of the Xbox 360's `XCONFIG_*` settings.
+///
+/// Stage 2 Batch 6 (2026-05-14): pre-fix returned STATUS_SUCCESS with no
+/// buffer write — game saw uninitialized buffer data. We implement the
+/// most commonly queried (category, setting) pairs as constants matching
+/// canary's defaults. Unknown pairs return `STATUS_INVALID_PARAMETER_2`.
+fn ex_get_xconfig_setting(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
+ let category = (ctx.gpr[3] as u32) & 0xFFFF;
+ let setting = (ctx.gpr[4] as u32) & 0xFFFF;
+ let buffer_ptr = ctx.gpr[5] as u32;
+ let buffer_size = (ctx.gpr[6] as u32) & 0xFFFF;
+ let required_size_ptr = ctx.gpr[7] as u32;
+
+ // Per-setting value encoded as big-endian bytes (canary uses
+ // `xe::store_and_swap<T>`; we hand-roll the BE bytes since values
+ // are constant).
+ #[derive(Clone, Copy)]
+ enum SettingValue {
+ U8(u8),
+ U16Be(u16),
+ U32Be(u32),
+ }
+ impl SettingValue {
+ fn size(&self) -> u16 {
+ match self {
+ SettingValue::U8(_) => 1,
+ SettingValue::U16Be(_) => 2,
+ SettingValue::U32Be(_) => 4,
+ }
+ }
+ fn write(&self, mem: &GuestMemory, addr: u32) {
+ match self {
+ SettingValue::U8(v) => mem.write_u8(addr, *v),
+ SettingValue::U16Be(v) => mem.write_u16(addr, *v),
+ SettingValue::U32Be(v) => mem.write_u32(addr, *v),
+ }
+ }
+ }
+
+ let value: Option<SettingValue> = match (category, setting) {
+ // XCONFIG_SECURED_CATEGORY = 0x02
+ (0x02, 0x02) => Some(SettingValue::U32Be(1)), // SECURED_AV_REGION = NTSCM
+ // XCONFIG_USER_CATEGORY = 0x03
+ (0x03, 0x01) // TIME_ZONE_BIAS
+ | (0x03, 0x02) // TIME_ZONE_STD_NAME
+ | (0x03, 0x03) // TIME_ZONE_DLT_NAME
+ | (0x03, 0x04) // TIME_ZONE_STD_DATE
+ | (0x03, 0x05) // TIME_ZONE_DLT_DATE
+ | (0x03, 0x06) // TIME_ZONE_STD_BIAS
+ | (0x03, 0x07) // TIME_ZONE_DLT_BIAS
+ => Some(SettingValue::U32Be(0)),
+ (0x03, 0x09) => Some(SettingValue::U32Be(1)), // USER_LANGUAGE = en
+ (0x03, 0x0A) => Some(SettingValue::U32Be(0)), // USER_VIDEO_FLAGS = RatioNormal
+ (0x03, 0x0B) => Some(SettingValue::U32Be(0x00010001)), // USER_AUDIO_FLAGS
+ (0x03, 0x0C) => Some(SettingValue::U32Be(0x40)), // USER_RETAIL_FLAGS
+ (0x03, 0x0E) => Some(SettingValue::U8(103)), // USER_COUNTRY = US
+ (0x03, 0x0F) => Some(SettingValue::U8(0x03)), // USER_PC_FLAGS = XBL allowed
+ // XCONFIG_CONSOLE_CATEGORY = 0x07
+ (0x07, 0x02) => Some(SettingValue::U16Be(0)), // SCREEN_SAVER = Off
+ (0x07, 0x03) => Some(SettingValue::U16Be(0)), // AUTO_SHUT_OFF = Off
+ _ => None,
+ };
+
+ let v = match value {
+ Some(v) => v,
+ None => {
+ // Unknown category or setting. Match canary's per-category
+ // return code: invalid category vs invalid setting both
+ // surface as STATUS_INVALID_PARAMETER_x in canary; we use
+ // STATUS_INVALID_PARAMETER_2 as a single sentinel since the
+ // distinction is rarely consulted by guest code.
+ ctx.gpr[3] = 0xC000_00F0; // X_STATUS_INVALID_PARAMETER_2
+ return;
+ }
+ };
+
+ let setting_size = v.size();
+
+ if buffer_ptr != 0 {
+ if buffer_size < setting_size as u32 {
+ ctx.gpr[3] = 0xC000_0023; // X_STATUS_BUFFER_TOO_SMALL
+ return;
+ }
+ v.write(mem, buffer_ptr);
+ } else if buffer_size != 0 {
+ ctx.gpr[3] = 0xC000_00F1; // X_STATUS_INVALID_PARAMETER_3
+ return;
+ }
+
+ if required_size_ptr != 0 {
+ mem.write_u16(required_size_ptr, setting_size);
+ }
+
+ ctx.gpr[3] = 0; // STATUS_SUCCESS
}
// ===== Memory =====
@@ -730,6 +888,25 @@ const STATUS_SEMAPHORE_LIMIT_EXCEEDED: u64 = 0xC000_0047;
const STATUS_UNSUCCESSFUL: u64 = 0xC000_0001;
const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003;
const STATUS_INFO_LENGTH_MISMATCH: u64 = 0xC000_0004;
+/// Phase C+5 — canary's `NtWriteFile_entry`
+/// (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353) returns
+/// this NT-style status code when the underlying `XFile::is_synchronous_`
+/// is false (i.e. the file was opened without `FILE_SYNCHRONOUS_IO_ALERT`
+/// or `FILE_SYNCHRONOUS_IO_NONALERT`). The write itself still completes
+/// synchronously and the IO_STATUS_BLOCK still records STATUS_SUCCESS;
+/// only the function return value flips. Real NT uses STATUS_PENDING here
+/// as a "the caller may now wait on the event" convention.
+const STATUS_PENDING: u64 = 0x0000_0103;
+
+/// `CreateOptions` bits we care about for is-synchronous tracking
+/// (canary's `CreateOptions::FILE_SYNCHRONOUS_IO_ALERT` /
+/// `CreateOptions::FILE_SYNCHRONOUS_IO_NONALERT` in xboxkrnl_io.cc:32-33).
+/// `NtOpenFile` forwards the same options dword through its `open_options`
+/// argument, so this bitmask applies to both paths.
+const FILE_SYNCHRONOUS_IO_ALERT: u32 = 0x0000_0010;
+const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020;
+const FILE_SYNCHRONOUS_IO_MASK: u32 =
+ FILE_SYNCHRONOUS_IO_ALERT | FILE_SYNCHRONOUS_IO_NONALERT;
/// `X_ERROR_NOT_FOUND` from xenia-canary `xenia/xbox.h`. Returned by
/// `XexGetModuleHandle` for unknown module names.
const X_ERROR_NOT_FOUND: u64 = 0x0000_048B;
@@ -737,6 +914,17 @@ const X_ERROR_NOT_FOUND: u64 = 0x0000_048B;
/// A sentinel byte-offset value meaning "read at current file position".
const FILE_USE_FILE_POINTER_POSITION: u64 = 0xFFFF_FFFF_FFFF_FFFE;
+/// Phase C+5 — register `handle` in `state.async_file_handles` iff the
+/// caller did NOT request synchronous IO (mirrors canary's
+/// `XFile::is_synchronous_` derivation in xboxkrnl_io.cc:94-97). Subsequent
+/// `nt_write_file` returns flip from `STATUS_SUCCESS` to `STATUS_PENDING`
+/// for async-opened files only.
+fn maybe_mark_async_file(state: &mut KernelState, handle: u32, create_options: u32) {
+ if (create_options & FILE_SYNCHRONOUS_IO_MASK) == 0 {
+ state.async_file_handles.insert(handle);
+ }
+}
+
/// Write an `IO_STATUS_BLOCK { status, information }` if the pointer is non-null.
fn write_io_status_block(mem: &GuestMemory, ptr: u32, status: u32, information: u32) {
if ptr == 0 {
@@ -836,6 +1024,7 @@ fn open_cache_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -931,6 +1120,7 @@ fn open_cache_file(
dir_enum_pos: None,
host_path: Some(host_path.to_path_buf()),
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1004,6 +1194,7 @@ fn open_vfs_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1047,6 +1238,7 @@ fn open_vfs_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1085,6 +1277,7 @@ fn open_vfs_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1122,16 +1315,26 @@ fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSta
}
fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
- // r3 = handle_out, r4 = desired_access, r5 = obj_attrs,
- // r6 = io_status_block, r7 = share_access, r8 = open_options.
- // `NtOpenFile` is FILE_OPEN-only (no create) — file must exist.
- // Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options`
+ // Phase C+5 — canary `NtOpenFile_entry`
+ // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:114-122) has
+ // FIVE args: (handle_out, desired_access, object_attributes,
+ // io_status_block, open_options). Per Xenia's shim_utils LoadValue
+ // (util/shim_utils.h:158-167), the 5th dword arg arrives in r7. Ours
+ // previously read r8 — the bit 0x01 (FILE_DIRECTORY_FILE) check still
+ // happened to pass because the game also left bit 0x01 set in r8 for
+ // dir opens (AUDIT-054 enabling condition), but the
+ // FILE_SYNCHRONOUS_IO_NONALERT bit (0x20) was wrongly set in r8 for
+ // device opens, making every file appear synchronous and causing the
+ // Phase C+5 NtWriteFile divergence at idx=102068
+ // (canary=STATUS_PENDING / ours=STATUS_SUCCESS).
+ //
+ // Per xboxkrnl_io.cc:118-122, NtOpenFile forwards `open_options`
// straight into NtCreateFile's `create_options` slot, so the
- // FILE_DIRECTORY_FILE bit applies the same way.
+ // FILE_DIRECTORY_FILE bit + sync bits apply the same way.
let handle_out = ctx.gpr[3] as u32;
let obj_attrs_ptr = ctx.gpr[5] as u32;
let io_status_block = ctx.gpr[6] as u32;
- let open_options = ctx.gpr[8] as u32;
+ let open_options = ctx.gpr[7] as u32;
ctx.gpr[3] = open_vfs_file(
mem,
state,
@@ -1320,6 +1523,7 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
*position
};
+ let mut wrote_ok = false;
if let Some(hp) = host_path.clone() {
use std::io::{Seek, SeekFrom, Write};
let mut buf = vec![0u8; length as usize];
@@ -1341,6 +1545,7 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
*size = live_size;
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
ctx.gpr[3] = STATUS_SUCCESS;
+ wrote_ok = true;
tracing::info!(
"NtWriteFile cache: {} bytes to {:?} @ {} (handle={:#x})",
length, path, start_pos, handle
@@ -1356,6 +1561,19 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
// Legacy: discard but report full-length-written so caller proceeds.
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
ctx.gpr[3] = STATUS_SUCCESS;
+ wrote_ok = true;
+ }
+ // Phase C+5 — canary `NtWriteFile_entry`
+ // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353) flips
+ // the function return value to `STATUS_PENDING` after the synchronous
+ // write completes when the underlying `XFile::is_synchronous_` is
+ // false. The IO_STATUS_BLOCK already stores STATUS_SUCCESS above; only
+ // the r3 return changes. Mirroring this here closes the
+ // `tid_event_idx=102068` divergence (canary=0x103 / ours=0) on the
+ // main thread without touching `NtReadFile` / `NtReadFileScatter`
+ // (scoped to one divergence per Phase C session, per project plan).
+ if wrote_ok && state.async_file_handles.contains(&handle) {
+ ctx.gpr[3] = STATUS_PENDING;
}
signal_io_completion_event(state, event_handle);
}
@@ -1936,6 +2154,10 @@ fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
if remaining == 0 {
state.objects.remove(&handle);
state.handle_refcount.remove(&handle);
+ // Phase C+5 — prune the async-file side-table when the underlying
+ // handle is finally released. Mirrors the canary `XFile` dtor
+ // releasing `is_synchronous_`. No-op for non-file handles.
+ state.async_file_handles.remove(&handle);
// If the object was an armed Timer, strip its pending-fire entry
// so a later scheduler round doesn't try to signal a dead handle.
// `disarm_timer` is a no-op for non-timer handles.
@@ -2382,10 +2604,79 @@ fn rtl_fill_memory_ulong(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut K
}
}
-fn rtl_image_xex_header_field(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
- // r3 = xex_header_ptr, r4 = field_id
- // Return 0 for all fields
- ctx.gpr[3] = 0;
+fn rtl_image_xex_header_field(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
+ // r3 = xex_header_guest_ptr (may be NULL — game's CRT often passes 0
+ // because ours's `*XexExecutableModuleHandle = image_base` doesn't
+ // resolve to a real LDR_DATA_TABLE_ENTRY, so its `*(hmodule + 0x58)`
+ // deref yields PE OptionalHeader bytes instead of a header pointer;
+ // those bytes fail the game's validation and the call goes through
+ // with ptr=NULL). When NULL, fall back to KernelState's recorded
+ // `xex_header_guest_ptr` (the guest-VA of the raw XEX header copy
+ // set up in `xenia-app::cmd_exec`'s Phase 3, mirroring canary's
+ // `user_module.cc:223-227` `guest_xex_header_`).
+ // r4 = field_key (xex2_header_keys).
+ //
+ // Mirror of canary's `xboxkrnl_rtl.cc:501-514` →
+ // `UserModule::GetOptHeader(memory, header, key, &field_value)`
+ // (`user_module.cc:335-369`). Iterates `header->headers[]` (flat
+ // array of (key:u32, value:u32) pairs, both BE), and for the first
+ // entry where `opt_header.key == key` returns one of:
+ // * key & 0xFF == 0x00 → `opt_header.value` (inline value).
+ // * key & 0xFF == 0x01 → guest VA of `opt_header.value` itself.
+ // * else → `header_base + opt_header.offset`
+ // i.e. guest VA inside the header of the referenced data block.
+ // Returns 0 if the resolved header pointer is NULL or the key is
+ // not found.
+ let mut xex_header_ptr = ctx.gpr[3] as u32;
+ let field_key = ctx.gpr[4] as u32;
+ if xex_header_ptr == 0 {
+ xex_header_ptr = state.xex_header_guest_ptr;
+ }
+ if xex_header_ptr == 0 {
+ ctx.gpr[3] = 0;
+ return;
+ }
+ // xex2_header layout (raw, BE; see xenia-canary `xex2_info.h`):
+ // +0x00 magic ("XEX2"), +0x04 module_flags, +0x08 header_size,
+ // +0x0C reserved, +0x10 security_offset, +0x14 header_count,
+ // +0x18.. array of (key:u32, value:u32) pairs.
+ let header_count = mem.read_u32(xex_header_ptr.wrapping_add(0x14));
+ let entries_base = xex_header_ptr.wrapping_add(0x18);
+ let mut field_value: u32 = 0;
+ let mut found = false;
+ for i in 0..header_count {
+ let entry_addr = entries_base.wrapping_add(i.wrapping_mul(8));
+ let entry_key = mem.read_u32(entry_addr);
+ if entry_key != field_key {
+ continue;
+ }
+ found = true;
+ let entry_value_addr = entry_addr.wrapping_add(4);
+ match entry_key & 0xFF {
+ 0x00 => {
+ // Inline value.
+ field_value = mem.read_u32(entry_value_addr);
+ }
+ 0x01 => {
+ // Pointer to the inline value slot itself.
+ field_value = entry_value_addr;
+ }
+ _ => {
+ // Offset within the header. `opt_header.value` here is the
+ // file offset of the optional data block, which canary
+ // copied verbatim into guest memory at `xex_header_ptr`,
+ // so `xex_header_ptr + offset` is the in-guest VA.
+ let offset = mem.read_u32(entry_value_addr);
+ field_value = xex_header_ptr.wrapping_add(offset);
+ }
+ }
+ break;
+ }
+ if !found {
+ ctx.gpr[3] = 0;
+ return;
+ }
+ ctx.gpr[3] = field_value as u64;
}
fn rtl_multi_byte_to_unicode_n(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
@@ -3266,6 +3557,78 @@ fn xma_create_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kern
ctx.gpr[3] = handle as u64;
}
+// ===== Crypto =====
+
+/// Mirrors xenia-canary `XeCryptSha_entry` (xboxkrnl_crypt.cc:469-489):
+/// 3-input SHA-1 accumulator. Each of the three (ptr, size) pairs is
+/// processed only when both ptr and size are non-zero. The resulting
+/// 20-byte digest is copied to `output`, truncated to `output_size`.
+/// Void return (registered via `register_void_export`).
+fn xe_crypt_sha(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
+ use sha1::{Digest, Sha1};
+ let input_1 = ctx.gpr[3] as u32;
+ let input_1_size = ctx.gpr[4] as u32;
+ let input_2 = ctx.gpr[5] as u32;
+ let input_2_size = ctx.gpr[6] as u32;
+ let input_3 = ctx.gpr[7] as u32;
+ let input_3_size = ctx.gpr[8] as u32;
+ let output = ctx.gpr[9] as u32;
+ let output_size = ctx.gpr[10] as u32;
+ let mut hasher = Sha1::new();
+ for (ptr, size) in [
+ (input_1, input_1_size),
+ (input_2, input_2_size),
+ (input_3, input_3_size),
+ ] {
+ if ptr != 0 && size != 0 {
+ let mut buf = vec![0u8; size as usize];
+ mem.read_bytes(ptr, &mut buf);
+ hasher.update(&buf);
+ }
+ }
+ let digest = hasher.finalize();
+ let n = std::cmp::min(20, output_size as usize);
+ if output != 0 && n != 0 {
+ mem.write_bytes(output, &digest[..n]);
+ }
+}
+
+/// Mirrors xenia-canary `XeKeysConsolePrivateKeySign_entry`
+/// (xboxkrnl_crypt.cc:1111-1138): writes a hardcoded fake
+/// `XE_CONSOLE_CERTIFICATE` (0x1A8 bytes) to `output` and returns 1
+/// (success). Returns 0 if either pointer is null. The 5-byte
+/// `XE_CONSOLE_ID` bit-field at offset 0x02 is laid out per MSVC
+/// `#pragma pack(1)` semantics; we write the precomputed bytes
+/// directly to avoid bit-fiddling ambiguity.
+fn xe_keys_console_private_key_sign(
+ ctx: &mut PpcContext,
+ mem: &GuestMemory,
+ _state: &mut KernelState,
+) {
+ let hash = ctx.gpr[3] as u32;
+ let output = ctx.gpr[4] as u32;
+ if hash == 0 || output == 0 {
+ ctx.gpr[3] = 0;
+ return;
+ }
+ // Zero the 0x1A8-byte struct first (canary calls `output.Zero()`).
+ let zeros = [0u8; 0x1A8];
+ mem.write_bytes(output, &zeros);
+ // XE_CONSOLE_ID at offset 0x02 (5 bytes, MSVC pack(1) bit-fields).
+ // RefurbBits = 0b0011, ManufactureMonth = 0b1001 → byte 0 = 0x93
+ // ManufactureYear = 1, MacIndex3 = 0x40, MacIndex4 = 0x66,
+ // MacIndex5 = 0x7E, Crc = 0 → bytes 1..5 = 0x01,0x64,0xE6,0x07
+ // (LSB-first packing of the 32-bit storage unit at offset 1.)
+ let console_id = [0x93u8, 0x01, 0x64, 0xE6, 0x07];
+ mem.write_bytes(output + 0x02, &console_id);
+ // console_type (u32 BE) at 0x18 → Retail = 2
+ mem.write_u32(output + 0x18, 2);
+ // manufacture_date[8] at 0x1C
+ let mfg_date = [2u8, 0, 0, 5, 1, 1, 2, 2];
+ mem.write_bytes(output + 0x1C, &mfg_date);
+ ctx.gpr[3] = 1;
+}
+
// ===== Xex =====
/// Mirrors xenia-canary `XexCheckExecutablePrivilege_entry`
@@ -4423,12 +4786,21 @@ mod tests {
// Confirm PCR was written by the spawn (sanity).
assert_eq!(mem.read_u32(pcr_base + 0x2C), 1);
- // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20).
+ // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20,
+ // prev_mask_ptr=scratch). Post Stage 2 Batch 3: r3=STATUS_SUCCESS,
+ // previous mask delivered via OUT-pointer.
+ let prev_ptr = SCRATCH_BASE + 0xA0;
+ mem.write_u32(prev_ptr, 0xFFFF_FFFF); // sentinel
ctx.gpr[3] = 0x2000;
ctx.gpr[4] = 0x20; // slot 5 only
+ ctx.gpr[5] = prev_ptr as u64;
ke_set_affinity_thread(&mut ctx, &mut mem, &mut state);
- // Return value = previous mask = 0x02.
- assert_eq!(ctx.gpr[3], 0x02);
+ assert_eq!(ctx.gpr[3], 0, "must return STATUS_SUCCESS in r3");
+ assert_eq!(
+ mem.read_u32(prev_ptr),
+ 0x02,
+ "previous affinity mask must be written to OUT-pointer"
+ );
// PCR rewritten to 5.
assert_eq!(mem.read_u32(pcr_base + 0x2C), 5);
// Thread now on slot 5.
@@ -4436,6 +4808,58 @@ mod tests {
assert_eq!(r.hw_id, 5);
}
+ /// Stage 2 Batch 3: zero affinity must return STATUS_INVALID_PARAMETER
+ /// and not touch the OUT-pointer.
+ #[test]
+ fn ke_set_affinity_thread_zero_affinity_returns_invalid_parameter() {
+ let (mut ctx, mem, mut state) = fresh();
+ let prev_ptr = SCRATCH_BASE + 0xA0;
+ mem.write_u32(prev_ptr, 0xDEAD_BEEF);
+ ctx.gpr[3] = 0x1000; // main handle
+ ctx.gpr[4] = 0; // zero affinity
+ ctx.gpr[5] = prev_ptr as u64;
+ ke_set_affinity_thread(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0xC000_000D, "STATUS_INVALID_PARAMETER");
+ assert_eq!(mem.read_u32(prev_ptr), 0xDEAD_BEEF, "OUT-ptr untouched");
+ }
+
+ /// Stage 2 Batch 3: NULL OUT-pointer is valid (mirrors canary's
+ /// `if (previous_affinity_ptr)` guard); still returns SUCCESS and
+ /// migrates the thread.
+ #[test]
+ fn ke_set_affinity_thread_null_out_ptr_still_succeeds() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ use xenia_cpu::scheduler::SpawnParams;
+ let pcr_base = SCRATCH_BASE + 0x500;
+ let params = SpawnParams {
+ entry: 0x8200_0000,
+ start_context: 0,
+ stack_base: 0x7200_0000,
+ stack_size: 0x10000,
+ pcr_base,
+ tls_base: 0,
+ thread_handle: 0x2100,
+ guest_tid: 43,
+ create_suspended: false,
+ is_initial: false,
+ tls_slot_count: 0,
+ affinity_mask: 0b0000_0010,
+ priority: 0,
+ ideal_processor: None,
+ };
+ state
+ .scheduler
+ .spawn(params, &mut crate::state::GuestMemoryPcr(&mut mem))
+ .unwrap();
+ ctx.gpr[3] = 0x2100;
+ ctx.gpr[4] = 0x10; // slot 4
+ ctx.gpr[5] = 0; // NULL OUT-ptr
+ ke_set_affinity_thread(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS even with NULL OUT-ptr");
+ let r = state.scheduler.find_by_handle(0x2100).expect("alive");
+ assert_eq!(r.hw_id, 4);
+ }
+
/// Axis 5: `KeSetIdealProcessor` stores a hint on the thread
/// without migrating it; query round-trips.
#[test]
@@ -4660,6 +5084,94 @@ mod tests {
assert!(event_signaled(&state, evt), "write must signal too");
}
+ /// Phase C+5 — async-opened files (no `FILE_SYNCHRONOUS_IO_*` bit in
+ /// `create_options`) return `STATUS_PENDING` (0x103) from
+ /// `NtWriteFile`. The synchronous write still completes and
+ /// IO_STATUS_BLOCK still records STATUS_SUCCESS — only the function
+ /// return value flips. Mirrors canary
+ /// `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353`.
+ #[test]
+ fn nt_write_file_async_handle_returns_status_pending() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ // Pre-register an "async" file handle the same way `open_vfs_file`
+ // does for a file whose `create_options` omits sync bits.
+ let handle = state.alloc_handle_for(KernelObject::File {
+ path: "async.tmp".to_string(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ state.async_file_handles.insert(handle);
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = 0; // no event
+ ctx.gpr[7] = SCRATCH_BASE as u64; // iosb at scratch base
+ ctx.gpr[9] = 8; // length
+ nt_write_file(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_PENDING,
+ "async-opened file: r3 must return STATUS_PENDING (0x103)"
+ );
+ assert_eq!(
+ mem.read_u32(SCRATCH_BASE),
+ STATUS_SUCCESS as u32,
+ "IO_STATUS_BLOCK.status still records STATUS_SUCCESS"
+ );
+ assert_eq!(
+ mem.read_u32(SCRATCH_BASE + 4),
+ 8,
+ "IO_STATUS_BLOCK.information records bytes written"
+ );
+ }
+
+ /// Sync-opened files (one of `FILE_SYNCHRONOUS_IO_*` bits set in
+ /// `create_options`) retain the legacy `STATUS_SUCCESS` return.
+ #[test]
+ fn nt_write_file_sync_handle_returns_status_success() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::File {
+ path: "sync.tmp".to_string(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ // Not inserted into `async_file_handles` — sync handle by default.
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = 0;
+ ctx.gpr[7] = SCRATCH_BASE as u64;
+ ctx.gpr[9] = 8;
+ nt_write_file(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "sync-opened file: r3 must return STATUS_SUCCESS"
+ );
+ }
+
+ /// `nt_close` must prune the async-file side-table when the final
+ /// refcount drops to zero so a recycled handle isn't mis-classified.
+ #[test]
+ fn nt_close_prunes_async_file_set() {
+ let (mut ctx, mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::File {
+ path: "x.tmp".to_string(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ state.async_file_handles.insert(handle);
+ ctx.gpr[3] = handle as u64;
+ nt_close(&mut ctx, &mem, &mut state);
+ assert!(
+ !state.async_file_handles.contains(&handle),
+ "nt_close must remove from async_file_handles"
+ );
+ }
+
/// Verify `FileStandardInformation` reports `Directory=1` for empty-path
/// (device-root) synthesized file handles. Sylpheed calls
/// `NtCreateFile("game:\\")` then `NtQueryInformationFile` on the returned
@@ -6215,6 +6727,14 @@ mod tests {
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\rt.tmp");
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
+ // Phase C+5 — set sp so nt_create_file reads create_options from a
+ // committed scratch slot, and set the FILE_SYNCHRONOUS_IO_NONALERT
+ // bit so `NtWriteFile` returns `STATUS_SUCCESS` (legacy assertion).
+ // Files opened WITHOUT this bit return `STATUS_PENDING` after
+ // canary's xboxkrnl_io.cc:351-353 — covered by
+ // `nt_write_file_async_handle_returns_status_pending`.
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
@@ -6353,4 +6873,214 @@ mod tests {
assert!(resolved.ends_with("etc/foo"));
std::fs::remove_dir_all(&dir).ok();
}
+
+ // ===== Stage 2 Batch 2: Crypto handlers =====
+
+ #[test]
+ fn xe_crypt_sha_empty_input_writes_canonical_digest() {
+ let (mut ctx, mem, mut state) = fresh();
+ let input_ptr = SCRATCH_BASE;
+ let output_ptr = SCRATCH_BASE + 0x100;
+ ctx.gpr[3] = input_ptr as u64;
+ ctx.gpr[4] = 0; // input_1_size = 0 (skips this buffer)
+ ctx.gpr[5] = 0;
+ ctx.gpr[6] = 0;
+ ctx.gpr[7] = 0;
+ ctx.gpr[8] = 0;
+ ctx.gpr[9] = output_ptr as u64;
+ ctx.gpr[10] = 20;
+ xe_crypt_sha(&mut ctx, &mem, &mut state);
+ let mut got = [0u8; 20];
+ mem.read_bytes(output_ptr, &mut got);
+ // SHA-1 of empty input
+ let expected: [u8; 20] = [
+ 0xDA, 0x39, 0xA3, 0xEE, 0x5E, 0x6B, 0x4B, 0x0D, 0x32, 0x55, 0xBF, 0xEF, 0x95, 0x60,
+ 0x18, 0x90, 0xAF, 0xD8, 0x07, 0x09,
+ ];
+ assert_eq!(got, expected);
+ }
+
+ #[test]
+ fn xe_crypt_sha_three_inputs_concatenate() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf_a = SCRATCH_BASE;
+ let buf_b = SCRATCH_BASE + 0x10;
+ let buf_c = SCRATCH_BASE + 0x20;
+ let output_ptr = SCRATCH_BASE + 0x100;
+ mem.write_bytes(buf_a, b"abc");
+ mem.write_bytes(buf_b, b"def");
+ mem.write_bytes(buf_c, b"ghi");
+ ctx.gpr[3] = buf_a as u64;
+ ctx.gpr[4] = 3;
+ ctx.gpr[5] = buf_b as u64;
+ ctx.gpr[6] = 3;
+ ctx.gpr[7] = buf_c as u64;
+ ctx.gpr[8] = 3;
+ ctx.gpr[9] = output_ptr as u64;
+ ctx.gpr[10] = 20;
+ xe_crypt_sha(&mut ctx, &mem, &mut state);
+ let mut got = [0u8; 20];
+ mem.read_bytes(output_ptr, &mut got);
+ // SHA-1("abcdefghi") = c63b19f1e4c8b5f76b25c49b8b87f57d8e4872a1
+ let expected: [u8; 20] = [
+ 0xC6, 0x3B, 0x19, 0xF1, 0xE4, 0xC8, 0xB5, 0xF7, 0x6B, 0x25, 0xC4, 0x9B, 0x8B, 0x87,
+ 0xF5, 0x7D, 0x8E, 0x48, 0x72, 0xA1,
+ ];
+ assert_eq!(got, expected);
+ }
+
+ #[test]
+ fn xe_crypt_sha_truncates_output() {
+ let (mut ctx, mem, mut state) = fresh();
+ let output_ptr = SCRATCH_BASE + 0x100;
+ // Pre-fill 0xFF so we can verify only 4 bytes were written.
+ mem.write_bytes(output_ptr, &[0xFFu8; 20]);
+ ctx.gpr[3] = 0;
+ ctx.gpr[4] = 0;
+ ctx.gpr[5] = 0;
+ ctx.gpr[6] = 0;
+ ctx.gpr[7] = 0;
+ ctx.gpr[8] = 0;
+ ctx.gpr[9] = output_ptr as u64;
+ ctx.gpr[10] = 4; // truncate to 4 bytes
+ xe_crypt_sha(&mut ctx, &mem, &mut state);
+ // First 4 bytes match SHA-1 of empty; next 16 stay 0xFF.
+ let mut got = [0u8; 20];
+ mem.read_bytes(output_ptr, &mut got);
+ assert_eq!(&got[..4], &[0xDA, 0x39, 0xA3, 0xEE]);
+ assert_eq!(&got[4..], &[0xFFu8; 16]);
+ }
+
+ #[test]
+ fn xe_keys_console_private_key_sign_writes_certificate_and_returns_one() {
+ let (mut ctx, mem, mut state) = fresh();
+ let hash_ptr = SCRATCH_BASE;
+ let output_ptr = SCRATCH_BASE + 0x100;
+ ctx.gpr[3] = hash_ptr as u64;
+ ctx.gpr[4] = output_ptr as u64;
+ xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 1, "must return success");
+ // console_type at 0x18 (u32 BE) = Retail (2)
+ assert_eq!(mem.read_u32(output_ptr + 0x18), 2);
+ // manufacture_date at 0x1C
+ let mut mfg = [0u8; 8];
+ mem.read_bytes(output_ptr + 0x1C, &mut mfg);
+ assert_eq!(mfg, [2, 0, 0, 5, 1, 1, 2, 2]);
+ // XE_CONSOLE_ID byte 0 at offset 0x02
+ assert_eq!(mem.read_u8(output_ptr + 0x02), 0x93);
+ // cert_size and console_part_number must remain zero (Zero() output)
+ assert_eq!(mem.read_u16(output_ptr), 0);
+ assert_eq!(mem.read_u8(output_ptr + 0x07), 0);
+ }
+
+ // ===== Stage 2 Batch 6: ExGetXConfigSetting =====
+
+ #[test]
+ fn ex_get_xconfig_setting_user_language_returns_one() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf = SCRATCH_BASE + 0x200;
+ let req = SCRATCH_BASE + 0x208;
+ mem.write_u32(buf, 0xDEAD_BEEF);
+ mem.write_u16(req, 0xFFFF);
+ ctx.gpr[3] = 0x03; // USER_CATEGORY
+ ctx.gpr[4] = 0x09; // USER_LANGUAGE
+ ctx.gpr[5] = buf as u64;
+ ctx.gpr[6] = 4;
+ ctx.gpr[7] = req as u64;
+ ex_get_xconfig_setting(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS");
+ assert_eq!(mem.read_u32(buf), 1, "USER_LANGUAGE = en");
+ assert_eq!(mem.read_u16(req), 4, "required_size = 4 bytes");
+ }
+
+ #[test]
+ fn ex_get_xconfig_setting_unknown_returns_invalid_parameter() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf = SCRATCH_BASE + 0x200;
+ ctx.gpr[3] = 0xDEAD;
+ ctx.gpr[4] = 0xBEEF;
+ ctx.gpr[5] = buf as u64;
+ ctx.gpr[6] = 4;
+ ctx.gpr[7] = 0;
+ ex_get_xconfig_setting(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0xC000_00F0, "STATUS_INVALID_PARAMETER_2");
+ }
+
+ #[test]
+ fn ex_get_xconfig_setting_buffer_too_small_returns_error() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf = SCRATCH_BASE + 0x200;
+ mem.write_u32(buf, 0xDEAD_BEEF);
+ ctx.gpr[3] = 0x03; // USER_CATEGORY
+ ctx.gpr[4] = 0x09; // USER_LANGUAGE (4 bytes)
+ ctx.gpr[5] = buf as u64;
+ ctx.gpr[6] = 2; // too small
+ ctx.gpr[7] = 0;
+ ex_get_xconfig_setting(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0xC000_0023, "STATUS_BUFFER_TOO_SMALL");
+ // Buffer untouched
+ assert_eq!(mem.read_u32(buf), 0xDEAD_BEEF);
+ }
+
+ // ===== Stage 2 Batch 5: IRQL pair =====
+
+ /// Stage 2 Batch 5: `KeRaiseIrqlToDpcLevel` reads PCR's current_irql,
+ /// returns it in r3, and writes DISPATCH_LEVEL=2 back.
+ #[test]
+ fn ke_raise_irql_to_dpc_level_returns_old_writes_dispatch_level() {
+ let (mut ctx, mem, mut state) = fresh();
+ let pcr = SCRATCH_BASE + 0x500;
+ // Initial IRQL = PASSIVE_LEVEL (0).
+ mem.write_u8(pcr + PCR_CURRENT_IRQL_OFFSET, 0);
+ ctx.gpr[13] = pcr as u64;
+ ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "old IRQL = PASSIVE_LEVEL");
+ assert_eq!(
+ mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET),
+ 2,
+ "PCR.current_irql = DISPATCH_LEVEL"
+ );
+ // Second Raise returns 2 (already at DPC).
+ ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 2);
+ assert_eq!(mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), 2);
+ }
+
+ /// Stage 2 Batch 5: Raise → Lower round-trip leaves PCR at the value
+ /// passed to Lower. Demonstrates the IRQL nesting invariant.
+ #[test]
+ fn ke_irql_raise_lower_round_trip() {
+ let (mut ctx, mem, mut state) = fresh();
+ let pcr = SCRATCH_BASE + 0x500;
+ mem.write_u8(pcr + PCR_CURRENT_IRQL_OFFSET, 0);
+ ctx.gpr[13] = pcr as u64;
+ ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state);
+ let prev = ctx.gpr[3] as u8;
+ assert_eq!(prev, 0);
+ assert_eq!(mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), 2);
+ // Restore.
+ ctx.gpr[3] = prev as u64;
+ kf_lower_irql(&mut ctx, &mem, &mut state);
+ assert_eq!(
+ mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET),
+ 0,
+ "PCR.current_irql restored to PASSIVE_LEVEL"
+ );
+ }
+
+ #[test]
+ fn xe_keys_console_private_key_sign_rejects_null_inputs() {
+ let (mut ctx, mem, mut state) = fresh();
+ let output_ptr = SCRATCH_BASE + 0x100;
+ // null hash
+ ctx.gpr[3] = 0;
+ ctx.gpr[4] = output_ptr as u64;
+ xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "must return failure on null hash");
+ // null output
+ ctx.gpr[3] = 0x1234_5678;
+ ctx.gpr[4] = 0;
+ xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "must return failure on null output");
+ }
}
diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs
index b256fe7..32c7218 100644
--- a/crates/xenia-kernel/src/state.rs
+++ b/crates/xenia-kernel/src/state.rs
@@ -50,6 +50,17 @@ pub const HMODULE_XAM: u32 = 0xFFFE_0002;
/// Central kernel state tracking all guest OS state.
pub struct KernelState {
exports: HashMap<(ModuleId, u32), (&'static str, KernelExportFn)>,
+ /// Phase A: kernel exports whose canary signature is `void` (no
+ /// dword_result_t / pointer_result_t). For symmetry with canary's
+ /// `if constexpr (std::is_void<R>::value)` trampoline branch
+ /// (see `xenia-canary/src/xenia/kernel/util/shim_utils.h`), the
+ /// Phase A `kernel.return` event for these exports emits
+ /// `return_value=0` instead of `gpr[3]` (which for void fns is
+ /// just the input arg pointer left untouched). Without this,
+ /// e.g. `KeQuerySystemTime` — declared `void` in canary, taking a
+ /// `lpqword_t time_ptr` — would report ours's r3=time_ptr but
+ /// canary's literal 0, producing a spurious diff. Cvar-OFF inert.
+ void_exports: std::collections::HashSet<(ModuleId, u32)>,
/// M2.4: bump allocator for kernel handles. `AtomicU32` so concurrent
/// HLE calls under M3 can `fetch_add` without a lock. `Relaxed` is
/// fine — the allocated value is a fresh ID with no prior payload to
@@ -70,6 +81,16 @@ pub struct KernelState {
pub cs_waiters: HashMap<u32, Vec<ThreadRef>>,
/// Kernel object table: handle → object
pub objects: HashMap<u32, KernelObject>,
+ /// Phase C+5 — set of file handles opened WITHOUT
+ /// `FILE_SYNCHRONOUS_IO_ALERT` (0x10) or `FILE_SYNCHRONOUS_IO_NONALERT`
+ /// (0x20). Canary's `NtWriteFile_entry`
+ /// (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353)
+ /// completes such writes synchronously but returns `STATUS_PENDING`
+ /// (0x103) instead of `STATUS_SUCCESS`. Mirrors `xfile.is_synchronous_`
+ /// in canary (xfile.h:177, xfile.cc:22). Populated by `open_vfs_file`
+ /// and `open_cache_file`; pruned by `nt_close` when the handle's
+ /// refcount drops to zero.
+ pub async_file_handles: std::collections::HashSet<u32>,
/// Bump allocator for guest heap (NtAllocateVirtualMemory etc.).
/// M2.4: `AtomicU32` for lock-free concurrent allocation.
pub heap_cursor: std::sync::atomic::AtomicU32,
@@ -91,6 +112,17 @@ pub struct KernelState {
pub last_input_bytes: u128,
/// Image base of the loaded XEX (for XexExecutableModuleHandle etc.)
pub image_base: u32,
+ /// Guest VA of the raw XEX header bytes copied into guest memory at
+ /// startup (mirrors canary's `UserModule::guest_xex_header_`,
+ /// allocated in `user_module.cc:224`). Used by `RtlImageXexHeaderField`
+ /// to compute return values that are offsets into the in-guest header
+ /// copy (canary's `xboxkrnl_rtl.cc:501-514` calls `UserModule::Get
+ /// OptHeader(memory, header, key, &field_value)` which iterates
+ /// `header->headers[]` and returns `HostToGuestVirtual(header) +
+ /// opt_header.offset` for "else"-class keys, key low byte != 0/1). Zero
+ /// when the executable hasn't been installed yet. Set once by
+ /// `xenia-app` after `mem.write_bulk(base, &image_data)`.
+ pub xex_header_guest_ptr: u32,
/// `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) parsed from the loaded
/// XEX header. Queried by `XexCheckExecutablePrivilege`: privilege bit
/// `n` is set iff `(xex_system_flags & (1 << n)) != 0`. Zero before the
@@ -264,6 +296,23 @@ pub struct KernelState {
pub dump_addrs: Vec<u32>,
/// `--dump-section=BASE:LEN:PATH` end-of-run snapshot, page-gated by `is_mapped`.
pub dump_section: Option<(u32, u32, std::path::PathBuf)>,
+ /// Phase B initial-state snapshot — directory under which a
+ /// `ours/{cpu_state,memory,kernel,vfs,config}.json` + `manifest.json`
+ /// snapshot is written at the moment immediately before the first
+ /// guest PPC instruction of the XEX entry_point. `None` (default) =
+ /// disabled, zero overhead. See
+ /// `xenia-rs/audit-runs/phase-b-state-equivalence/`.
+ pub phase_b_snapshot_dir: Option<std::path::PathBuf>,
+ /// Phase B: after writing the snapshot, exit the process immediately
+ /// so re-runs are byte-deterministic. Default false.
+ pub phase_b_snapshot_and_exit: bool,
+ /// Phase B: include raw bytes in `memory.json`'s `section_contents`.
+ /// Default false — per-region SHA-256 is enough for the routine diff.
+ pub phase_b_dump_section_content: bool,
+ /// Phase B: the XEX entry_point address — captured by the app at
+ /// `install_initial_thread` time and consulted by the snapshot hook
+ /// to validate the firing thread is the entry thread.
+ pub entry_pc: u32,
}
impl KernelState {
@@ -288,11 +337,13 @@ impl KernelState {
scheduler.set_reservation_table(Some(reservations.clone()));
let mut state = Self {
exports: HashMap::new(),
+ void_exports: std::collections::HashSet::new(),
next_handle: AtomicU32::new(0x1000),
scheduler,
next_tls_index: AtomicU32::new(0),
cs_waiters: HashMap::new(),
objects: HashMap::new(),
+ async_file_handles: std::collections::HashSet::new(),
heap_cursor: AtomicU32::new(0x4000_0000), // Start of user heap region
stack_cursor: AtomicU32::new(0x7100_0000), // Above main stack
gpu_command_buffer: 0,
@@ -300,6 +351,7 @@ impl KernelState {
input_packet_number: 0,
last_input_bytes: 0,
image_base: 0,
+ xex_header_guest_ptr: 0,
xex_system_flags: 0,
xex_priv_logged: std::collections::HashSet::new(),
has_notified_startup: false,
@@ -331,6 +383,10 @@ impl KernelState {
lr_trace_writer: None,
dump_addrs: Vec::new(),
dump_section: None,
+ phase_b_snapshot_dir: None,
+ phase_b_snapshot_and_exit: false,
+ phase_b_dump_section_content: false,
+ entry_pc: 0,
};
crate::exports::register_exports(&mut state);
crate::xam::register_exports(&mut state);
@@ -377,6 +433,22 @@ impl KernelState {
self.exports.insert((module, ordinal), (name, func));
}
+ /// Register a kernel export whose canary signature is `void`.
+ /// See `KernelState::void_exports` doc. Identical semantics to
+ /// `register_export` except the Phase A `kernel.return` payload's
+ /// `return_value` field is emitted as 0 instead of `gpr[3]`,
+ /// matching canary's `EmitReturn(name, 0)` branch.
+ pub fn register_void_export(
+ &mut self,
+ module: ModuleId,
+ ordinal: u32,
+ name: &'static str,
+ func: KernelExportFn,
+ ) {
+ self.exports.insert((module, ordinal), (name, func));
+ self.void_exports.insert((module, ordinal));
+ }
+
/// AUDIT-038 — install a host directory as the backing store for the
/// `cache:` mount. The directory is unconditionally cleared (and then
/// re-created) on entry so two consecutive runs see byte-identical
@@ -514,7 +586,49 @@ impl KernelState {
metrics::counter!("kernel.calls", "name" => name).increment(1);
tracing::trace!(target: "probe_calls", "hw={} call={} r3={:#x} r4={:#x} r5={:#x} lr={:#x}",
r.hw_id, name, ctx.gpr[3], ctx.gpr[4], ctx.gpr[5], ctx.lr);
+ // Phase A event log — see crates/xenia-kernel/src/event_log.rs.
+ // Hot path: `is_enabled` is a relaxed atomic-bool load.
+ let phase_a_on = crate::event_log::is_enabled();
+ let (phase_a_tid, phase_a_cycle) = if phase_a_on {
+ let tid = self.scheduler.thread(r).tid;
+ let cycle = ctx.cycle_count;
+ (tid, cycle)
+ } else {
+ (0u32, 0u64)
+ };
+ if phase_a_on {
+ let module_name = match module {
+ ModuleId::Xboxkrnl => "xboxkrnl.exe",
+ ModuleId::Xam => "xam.xex",
+ ModuleId::Xbdm => "xbdm.xex",
+ };
+ crate::event_log::emit_import_call(
+ phase_a_tid,
+ phase_a_cycle,
+ module_name,
+ ordinal as u16,
+ name,
+ );
+ crate::event_log::emit_kernel_call(phase_a_tid, phase_a_cycle, name);
+ }
+ let is_void = self.void_exports.contains(&(module, ordinal));
func(&mut ctx, mem, self);
+ if phase_a_on {
+ // Mirror canary's `if constexpr (std::is_void<R>::value)`
+ // trampoline branch: void exports emit literal 0; non-void
+ // emit post-call gpr[3]. Without this, void exports that
+ // take a pointer arg (e.g. `KeQuerySystemTime`) would
+ // report ours=r3=arg_ptr vs canary=0 — a Phase A diff
+ // that is purely an emitter-framing asymmetry, not an
+ // engine semantic divergence.
+ let return_value = if is_void { 0 } else { ctx.gpr[3] };
+ crate::event_log::emit_kernel_return(
+ phase_a_tid,
+ ctx.cycle_count,
+ name,
+ return_value,
+ );
+ }
true
} else {
metrics::counter!("kernel.unimplemented").increment(1);