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>
4278 lines
196 KiB
Diff
4278 lines
196 KiB
Diff
diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs
|
||
index a4dfa7d..4ff6238 100644
|
||
--- a/crates/xenia-kernel/src/exports.rs
|
||
+++ b/crates/xenia-kernel/src/exports.rs
|
||
@@ -16,7 +16,12 @@ pub fn register_exports(state: &mut KernelState) {
|
||
|
||
// Debug
|
||
state.register_export(Xboxkrnl, 0x01, "DbgBreakPoint", dbg_break_point);
|
||
- state.register_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print);
|
||
+ // Phase C+6½: `DbgPrint` (ord 0x03) is table-entry-only in canary
|
||
+ // (`xboxkrnl_table.inc:17`, no `DECLARE_XBOXKRNL_EXPORT(DbgPrint)`).
|
||
+ // Canary routes through the syscall thunk, which emits NO Phase A
|
||
+ // events. Mirror that — body still logs the string (harmless side
|
||
+ // effect) but the Phase A emitter stays silent.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print);
|
||
|
||
// ExCreateThread and friends
|
||
state.register_export(Xboxkrnl, 0x0D, "ExCreateThread", ex_create_thread);
|
||
@@ -28,7 +33,17 @@ pub fn register_exports(state: &mut KernelState) {
|
||
state.register_export(Xboxkrnl, 0x28, "HalReturnToFirmware", hal_return_to_firmware);
|
||
|
||
// I/O
|
||
- state.register_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success);
|
||
+ // Phase C+6: `IoDismountVolumeByFileHandle` has a table entry in
|
||
+ // canary's `xboxkrnl_table.inc:74` but NO `DECLARE_XBOXKRNL_EXPORT`
|
||
+ // shim, so canary routes calls through the syscall thunk
|
||
+ // (`xex_module.cc:1310-1335`) which emits NO Phase A events.
|
||
+ // Mirror that by registering as unimplemented — ours still runs
|
||
+ // `stub_success` for guest-visible semantics, but the Phase A
|
||
+ // emitter stays silent. Before this fix, ours's tid=1 main chain
|
||
+ // injected 3 spurious events (`import.call`/`kernel.call`/
|
||
+ // `kernel.return`) at idx=102132 ahead of `NtClose`, becoming the
|
||
+ // first divergence vs canary which jumps straight to `NtClose`.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success);
|
||
|
||
// Ke* Threading/Sync
|
||
state.register_export(Xboxkrnl, 0x4D, "KeAcquireSpinLockAtRaisedIrql", stub_return_zero);
|
||
@@ -44,16 +59,36 @@ pub fn register_exports(state: &mut KernelState) {
|
||
state.register_export(Xboxkrnl, 0x7D, "KeLeaveCriticalRegion", stub_success);
|
||
state.register_export(Xboxkrnl, 0x7F, "KePulseEvent", ke_pulse_event);
|
||
state.register_export(Xboxkrnl, 0x81, "KeQueryBasePriorityThread", ke_query_base_priority_thread);
|
||
- state.register_export(Xboxkrnl, 0x82, "KeQueryIdealProcessor", ke_query_ideal_processor);
|
||
+ // Phase C+6½ hallucination fix: ord 0x82 = `KeQueryInterruptTime`
|
||
+ // per canary's `xboxkrnl_table.inc:130`. Canary DECLAREs this export
|
||
+ // (`xboxkrnl_misc.cc:127`) — both engines emit Phase A events.
|
||
+ // Previously mis-labeled `KeQueryIdealProcessor` in ours; the body
|
||
+ // returned a wrong value (processor index instead of interrupt-time
|
||
+ // counter). Fixed body returns a synthetic monotonic u64.
|
||
+ state.register_export(Xboxkrnl, 0x82, "KeQueryInterruptTime", ke_query_interrupt_time);
|
||
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);
|
||
state.register_export(Xboxkrnl, 0x92, "KeResumeThread", ke_resume_thread);
|
||
state.register_export(Xboxkrnl, 0x97, "KeSetAffinityThread", ke_set_affinity_thread);
|
||
- state.register_export(Xboxkrnl, 0x98, "KeSetIdealProcessor", ke_set_ideal_processor);
|
||
+ // Phase C+6½ hallucination fix: ord 0x98 = `KeSetBackgroundProcessors`
|
||
+ // per canary's `xboxkrnl_table.inc:166`. Table-entry-only (no
|
||
+ // `DECLARE_XBOXKRNL_EXPORT` shim), so canary routes via the syscall
|
||
+ // thunk and emits NO Phase A events. Previously mis-labeled
|
||
+ // `KeSetIdealProcessor` in ours; the body wrote
|
||
+ // `GuestThread::ideal_processor` — wrong state mutation under the
|
||
+ // wrong name. Replaced with `stub_success` and registered as
|
||
+ // unimplemented to mirror canary's silence.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x98, "KeSetBackgroundProcessors", stub_success);
|
||
state.register_export(Xboxkrnl, 0x99, "KeSetBasePriorityThread", ke_set_base_priority_thread);
|
||
state.register_export(Xboxkrnl, 0x9B, "KeSetCurrentStackPointers", stub_success);
|
||
state.register_export(Xboxkrnl, 0x9D, "KeSetEvent", ke_set_event);
|
||
@@ -61,7 +96,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);
|
||
@@ -126,13 +161,16 @@ pub fn register_exports(state: &mut KernelState) {
|
||
state.register_export(Xboxkrnl, 0x0110, "ObReferenceObjectByHandle", ob_reference_object_by_handle);
|
||
|
||
// RTL
|
||
- state.register_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context);
|
||
+ // Phase C+6½: `RtlCaptureContext` (ord 0x119) is table-entry-only
|
||
+ // in canary — no `DECLARE_XBOXKRNL_EXPORT(RtlCaptureContext)`.
|
||
+ // Mirror canary's silence so the Phase A emitter doesn't drift.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context);
|
||
state.register_export(Xboxkrnl, 0x011B, "RtlCompareMemoryUlong", rtl_compare_memory_ulong);
|
||
state.register_export(Xboxkrnl, 0x0125, "RtlEnterCriticalSection", rtl_enter_critical_section);
|
||
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);
|
||
@@ -140,18 +178,27 @@ pub fn register_exports(state: &mut KernelState) {
|
||
state.register_export(Xboxkrnl, 0x0133, "RtlMultiByteToUnicodeN", rtl_multi_byte_to_unicode_n);
|
||
state.register_export(Xboxkrnl, 0x0135, "RtlNtStatusToDosError", rtl_nt_status_to_dos_error);
|
||
state.register_export(Xboxkrnl, 0x0136, "RtlRaiseException", rtl_raise_exception);
|
||
- state.register_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf);
|
||
+ // Phase C+6½: `sprintf` (ord 0x13B) is table-entry-only in canary
|
||
+ // — no `DECLARE_XBOXKRNL_EXPORT(sprintf)`. Mirror canary's silence.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf);
|
||
state.register_export(Xboxkrnl, 0x013F, "RtlTimeFieldsToTime", stub_success);
|
||
state.register_export(Xboxkrnl, 0x0140, "RtlTimeToTimeFields", stub_success);
|
||
state.register_export(Xboxkrnl, 0x0141, "RtlTryEnterCriticalSection", rtl_try_enter_critical_section);
|
||
state.register_export(Xboxkrnl, 0x0142, "RtlUnicodeStringToAnsiString", stub_success);
|
||
state.register_export(Xboxkrnl, 0x0143, "RtlUnicodeToMultiByteN", stub_success);
|
||
- state.register_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind);
|
||
- state.register_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf);
|
||
+ // Phase C+6½: `RtlUnwind` (ord 0x147) is table-entry-only in canary
|
||
+ // — no `DECLARE_XBOXKRNL_EXPORT(RtlUnwind)`. Mirror canary's silence.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind);
|
||
+ // Phase C+6½: `_vsnprintf` (ord 0x14D) is table-entry-only in
|
||
+ // canary — no `DECLARE_XBOXKRNL_EXPORT(_vsnprintf)`. Mirror silence.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf);
|
||
|
||
// Stfs
|
||
- state.register_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success);
|
||
- state.register_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success);
|
||
+ // Phase C+6½: `StfsCreateDevice` (ord 0x259) and `StfsControlDevice`
|
||
+ // (ord 0x25A) are table-entry-only in canary. `StfsCreateDevice` is
|
||
+ // the C+6-noted driver of tid=7→tid=2 divergence at idx=15.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success);
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success);
|
||
|
||
// Video
|
||
state.register_export(Xboxkrnl, 0x01B1, "VdCallGraphicsNotificationRoutines", stub_success);
|
||
@@ -160,7 +207,7 @@ pub fn register_exports(state: &mut KernelState) {
|
||
state.register_export(Xboxkrnl, 0x01B9, "VdGetCurrentDisplayGamma", vd_get_current_display_gamma);
|
||
state.register_export(Xboxkrnl, 0x01BA, "VdGetCurrentDisplayInformation", stub_success);
|
||
state.register_export(Xboxkrnl, 0x01BD, "VdGetSystemCommandBuffer", vd_get_system_command_buffer);
|
||
- state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_success);
|
||
+ state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_return_one);
|
||
state.register_export(Xboxkrnl, 0x01C3, "VdInitializeRingBuffer", vd_initialize_ring_buffer);
|
||
state.register_export(Xboxkrnl, 0x01C5, "VdInitializeScalerCommandBuffer", stub_success);
|
||
state.register_export(Xboxkrnl, 0x01C6, "VdIsHSIOTrainingSucceeded", vd_is_hsio_training_succeeded);
|
||
@@ -185,9 +232,11 @@ 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_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success);
|
||
+ state.register_void_export(Xboxkrnl, 0x0192, "XeCryptSha", xe_crypt_sha);
|
||
+ state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", xe_keys_console_private_key_sign);
|
||
+ // Phase C+6½: `XeKeysConsoleSignatureVerification` (ord 0x257) is
|
||
+ // table-entry-only in canary. Mirror silence.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success);
|
||
|
||
// Xex module
|
||
state.register_export(Xboxkrnl, 0x0194, "XexCheckExecutablePrivilege", xex_check_executable_privilege);
|
||
@@ -195,7 +244,9 @@ pub fn register_exports(state: &mut KernelState) {
|
||
state.register_export(Xboxkrnl, 0x0197, "XexGetProcedureAddress", xex_get_procedure_address);
|
||
|
||
// Exception handling
|
||
- state.register_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler);
|
||
+ // Phase C+6½: `__C_specific_handler` (ord 0x1A5) is table-entry-only
|
||
+ // in canary. Mirror silence.
|
||
+ state.register_unimplemented_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler);
|
||
}
|
||
|
||
// ===== Generic stubs =====
|
||
@@ -208,6 +259,16 @@ fn stub_return_zero(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut Kerne
|
||
ctx.gpr[3] = 0;
|
||
}
|
||
|
||
+/// Phase W: a literal `return 1`. Matches canary's
|
||
+/// `VdInitializeEngines_entry` in `xboxkrnl_video.cc:271-279` which
|
||
+/// returns `1` (truthy success token) rather than STATUS_SUCCESS=0.
|
||
+/// Sylpheed-side guest code branches on this non-zero, so returning
|
||
+/// 0 made the game skip the VdInitializeRingBuffer-and-after init
|
||
+/// sequence and never set up the post-init render-target state.
|
||
+fn stub_return_one(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
|
||
+ ctx.gpr[3] = 1;
|
||
+}
|
||
+
|
||
// ===== Debug =====
|
||
|
||
fn dbg_break_point(_ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
|
||
@@ -280,6 +341,16 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
|
||
if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
|
||
*slot = Some(hw_id);
|
||
}
|
||
+ // Phase C+16: install the "thread owns itself until exited"
|
||
+ // self-reference. Mirrors canary's `XThread::Create` line 414
|
||
+ // `RetainHandle()`. Released by `ex_terminate_thread` and the
|
||
+ // main-loop LR-sentinel implicit-exit path. Without this, a
|
||
+ // subsequent NtClose on the thread handle (e.g. via
|
||
+ // `XamTaskCloseHandle`) drops the only ref and prematurely
|
||
+ // destroys the thread handle while the spawned thread is
|
||
+ // still live — the original C+16 divergence at Phase A
|
||
+ // idx=102168 on the main chain (canary tid=6 ↔ ours tid=1).
|
||
+ state.retain_handle(handle);
|
||
if handle_ptr != 0 {
|
||
mem.write_u32(handle_ptr, handle);
|
||
}
|
||
@@ -296,6 +367,33 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
|
||
create_suspended,
|
||
affinity,
|
||
);
|
||
+ // Phase C+15-α: schema-v1 `thread.create` event emitted by
|
||
+ // the **parent** thread at the kernel call that created the
|
||
+ // new guest thread. The handle.create for the thread-handle
|
||
+ // itself was already emitted inside `alloc_handle_for`
|
||
+ // above; here we surface the spawn-specific metadata
|
||
+ // (entry_pc, ctx_ptr, priority, affinity, stack, suspended).
|
||
+ // Canary's symmetric emit is at `XThread::Create` after
|
||
+ // CreationParameters are populated.
|
||
+ if crate::event_log::is_enabled() {
|
||
+ let (parent_tid, cycle) = {
|
||
+ let r = state.scheduler.current_ref();
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ };
|
||
+ let sid = crate::event_log::lookup_handle_semantic_id(handle);
|
||
+ crate::event_log::emit_thread_create(
|
||
+ parent_tid,
|
||
+ cycle,
|
||
+ sid,
|
||
+ start_address,
|
||
+ start_context,
|
||
+ /* priority */ 0,
|
||
+ affinity,
|
||
+ stack_size,
|
||
+ create_suspended,
|
||
+ );
|
||
+ }
|
||
ctx.gpr[3] = STATUS_SUCCESS;
|
||
}
|
||
Err(_) => {
|
||
@@ -311,6 +409,19 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
|
||
/// waiting on the thread handle are woken with STATUS_SUCCESS.
|
||
fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
|
||
let exit_code = ctx.gpr[3] as u32;
|
||
+ // Phase C+15-α: schema-v1 `thread.exit` event. Must emit BEFORE the
|
||
+ // scheduler unwinds the current thread, because `tid_event_idx` is
|
||
+ // per-tid and the exiting thread's counter is what gets the event.
|
||
+ // Canary symmetric emit at `XThread::Execute` exit (xthread.cc:540
|
||
+ // ff., after `kernel_state()->processor()->Execute` returns).
|
||
+ if crate::event_log::is_enabled() {
|
||
+ let (tid, cycle) = {
|
||
+ let r = state.scheduler.current_ref();
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ };
|
||
+ crate::event_log::emit_thread_exit(tid, cycle, exit_code);
|
||
+ }
|
||
let (hw_id, tid, handle_opt) = state.scheduler.exit_current(exit_code);
|
||
tracing::info!(
|
||
"ExTerminateThread: tid={:?} hw={} exit_code={}",
|
||
@@ -318,8 +429,8 @@ fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Ker
|
||
hw_id,
|
||
exit_code
|
||
);
|
||
- if let Some(handle) = handle_opt
|
||
- && let Some(KernelObject::Thread {
|
||
+ if let Some(handle) = handle_opt {
|
||
+ if let Some(KernelObject::Thread {
|
||
exit_code: ec,
|
||
waiters,
|
||
..
|
||
@@ -331,6 +442,16 @@ fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Ker
|
||
state.scheduler.wake_ref(w);
|
||
}
|
||
}
|
||
+ // Phase C+16: release the thread's self-reference installed at
|
||
+ // spawn time (`ex_create_thread` / `xam_task_schedule` via
|
||
+ // `state.retain_handle`). Mirrors canary's `XThread::Exit`
|
||
+ // `ReleaseHandle()` at xthread.cc:524. After this release, the
|
||
+ // refcount equals only the user-visible refs (1 if guest hasn't
|
||
+ // closed the handle, 0 if guest already called NtClose during
|
||
+ // the thread's lifetime — in which case the handle is destroyed
|
||
+ // here, emitting `handle.destroy`).
|
||
+ state.release_handle(handle);
|
||
+ }
|
||
tracing::debug!("ExTerminateThread: exit_status={:#x}", ctx.gpr[3]);
|
||
ctx.gpr[3] = 0;
|
||
}
|
||
@@ -375,38 +496,51 @@ fn ke_query_base_priority_thread(
|
||
ctx.gpr[3] = pri as u32 as u64;
|
||
}
|
||
|
||
-/// `KeSetIdealProcessor(thread_handle, proc_number) -> u8 old_ideal` —
|
||
-/// Axis 5. Stores the hint on the `GuestThread` for future spawn-sibling
|
||
-/// placement; does NOT migrate a live thread (use `KeSetAffinityThread`
|
||
-/// for that).
|
||
-fn ke_set_ideal_processor(
|
||
+/// Phase C+6½ hallucination fix: ord 0x82 maps to `KeQueryInterruptTime`
|
||
+/// in canary's `xboxkrnl_table.inc:130`, with a `DECLARE_XBOXKRNL_EXPORT`
|
||
+/// shim in `xboxkrnl_misc.cc:119-127`. Ours previously mis-labeled this
|
||
+/// ord as `KeQueryIdealProcessor` (a real NT function, but at a different
|
||
+/// position on Xbox 360 — not at 0x82). The hallucinated body returned
|
||
+/// the calling thread's `ideal_processor` byte; guests calling
|
||
+/// `KeQueryInterruptTime` to read the system interrupt-time counter were
|
||
+/// receiving a 1-byte processor index instead.
|
||
+///
|
||
+/// Canary returns `bundle->interrupt_time` (u64) — the monotonic system
|
||
+/// interrupt-time counter maintained by the kernel timer ISR. Ours has
|
||
+/// no `X_TIME_STAMP_BUNDLE` infrastructure, so we mirror the
|
||
+/// `KeQuerySystemTime` approach: return a fixed synthetic value that
|
||
+/// gives a plausible monotonic-looking u64. Determinism per `KernelState`
|
||
+/// requires this be reproducible — a constant satisfies both.
|
||
+fn ke_query_interrupt_time(
|
||
ctx: &mut PpcContext,
|
||
_mem: &GuestMemory,
|
||
- state: &mut KernelState,
|
||
+ _state: &mut KernelState,
|
||
) {
|
||
- let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
|
||
- let ideal = ctx.gpr[4] as u8;
|
||
- let prev = state
|
||
- .scheduler
|
||
- .find_by_handle(handle)
|
||
- .map(|r| state.scheduler.set_ideal_ref(r, ideal))
|
||
- .unwrap_or(0xFF);
|
||
- ctx.gpr[3] = prev as u64;
|
||
+ // Synthetic interrupt-time count. Units are 100ns ticks since boot;
|
||
+ // value chosen large enough to look post-boot but small enough that
|
||
+ // any timer-arithmetic stays in u32 range when masked. Matches the
|
||
+ // determinism pattern used by `ke_query_system_time` above.
|
||
+ const FAKE_INTERRUPT_TIME: u64 = 0x0000_0001_0000_0000;
|
||
+ ctx.gpr[3] = FAKE_INTERRUPT_TIME;
|
||
}
|
||
|
||
-fn ke_query_ideal_processor(
|
||
- ctx: &mut PpcContext,
|
||
- _mem: &GuestMemory,
|
||
- state: &mut KernelState,
|
||
-) {
|
||
- let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
|
||
- let ideal = state
|
||
- .scheduler
|
||
- .find_by_handle(handle)
|
||
- .and_then(|r| state.scheduler.ideal_ref(r))
|
||
- .unwrap_or(0);
|
||
- ctx.gpr[3] = ideal as u64;
|
||
-}
|
||
+/// Phase C+6½ hallucination fix: ord 0x98 maps to
|
||
+/// `KeSetBackgroundProcessors` in canary's `xboxkrnl_table.inc:166`.
|
||
+/// Canary has NO `DECLARE_XBOXKRNL_EXPORT` shim for this name — it's a
|
||
+/// table-entry-only export, routed through the syscall thunk
|
||
+/// (`xex_module.cc:1310-1335`) which is a no-op. Ours previously
|
||
+/// mis-labeled this ord as `KeSetIdealProcessor` (a real NT function but
|
||
+/// at a different position on Xbox 360) and the hallucinated body wrote
|
||
+/// to `GuestThread::ideal_processor` — a state mutation under the wrong
|
||
+/// semantic name. Guests calling `KeSetBackgroundProcessors` to mask off
|
||
+/// CPUs for background work were instead pinning the thread's ideal
|
||
+/// processor hint.
|
||
+///
|
||
+/// Replaced with a no-op (`stub_success`) registered via
|
||
+/// `register_unimplemented_export` so the Phase A emitter stays silent
|
||
+/// (matching canary's syscall-thunk path). The underlying
|
||
+/// `Scheduler::set_ideal_ref`/`ideal_ref` methods remain available for
|
||
+/// `NtSetInformationThread` info-class `ThreadIdealProcessor`.
|
||
|
||
/// `NtSetInformationThread(handle, info_class, info_ptr, info_len)` —
|
||
/// minimal Axis 5 wiring for priority / affinity / ideal-processor
|
||
@@ -453,18 +587,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 +644,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 +784,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 +1016,34 @@ 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;
|
||
+const STATUS_OBJECT_NAME_INVALID: u64 = 0xC000_0033;
|
||
+const STATUS_ACCESS_DENIED: u64 = 0xC000_0022;
|
||
+// Phase C+11 — canary's `NtQueryFullAttributesFile_entry` returns
|
||
+// `STATUS_NO_SUCH_FILE` (0xC000000F) on resolve-miss, not
|
||
+// `STATUS_OBJECT_NAME_NOT_FOUND` (0xC0000034). Both are negative NTSTATUS
|
||
+// values; Sylpheed treats them equivalently at the call site, but the
|
||
+// Phase A diff compares return values byte-exact, so the codes must
|
||
+// match.
|
||
+const STATUS_NO_SUCH_FILE: u64 = 0xC000_000F;
|
||
+/// 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 +1051,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 {
|
||
@@ -793,32 +1118,96 @@ fn open_cache_file(
|
||
// `cache:\d4ea4615` which then blocked subsequent hierarchical
|
||
// creates of `cache:\d4ea4615\e\46ee8ca` with NAME_COLLISION).
|
||
const FILE_DIRECTORY_FILE: u32 = 0x0000_0001;
|
||
+ const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040;
|
||
let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0;
|
||
-
|
||
- // Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the
|
||
- // cache root directory itself. Mirror canary's HostPathDevice.Open
|
||
- // which returns a directory handle (success, attributes = DIR).
|
||
- // Empty `path.file_name()` after our resolve_cache_path strip means
|
||
- // the guest asked for the mount root.
|
||
- let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path)
|
||
- || host_path.is_dir()
|
||
- || want_dir;
|
||
+ let want_non_dir = (create_options & FILE_NON_DIRECTORY_FILE) != 0;
|
||
+
|
||
+ // Phase C+11 — when the host path already exists, its actual on-disk
|
||
+ // type wins over the guest's `FILE_DIRECTORY_FILE` bit. Mirrors
|
||
+ // canary's `VirtualFileSystem::OpenFile` which routes to the existing
|
||
+ // entry's device-specific open without re-checking the bit. Sylpheed
|
||
+ // sets `FILE_DIRECTORY_FILE` on `NtOpenFile cache:\<H1><H2>.tmp`
|
||
+ // re-opens (the `.tmp` was already a file from a prior FILE_CREATE),
|
||
+ // which under the AUDIT-054 logic mis-routed to the directory branch
|
||
+ // and dropped `host_path` — blocking the subsequent class-10 rename
|
||
+ // with `STATUS_ACCESS_DENIED`. Also resolves Phase C+11's bug #2:
|
||
+ // `cache:\access`/`ignore`/`recent` end up as files on cold creation
|
||
+ // because `want_non_dir` (FILE_NON_DIRECTORY_FILE bit 0x40) takes
|
||
+ // precedence when set, even with FILE_DIRECTORY_FILE.
|
||
+ //
|
||
+ // Resolution order (mirrors canary):
|
||
+ // 1. Existing host entry: actual type wins (file ↔ dir).
|
||
+ // 2. `want_non_dir` set → file path (NON_DIRECTORY_FILE overrides).
|
||
+ // 3. `want_dir` set → directory path.
|
||
+ // 4. Default → file path.
|
||
+ //
|
||
+ // Root-of-mount case is captured by the existing-dir branch: the
|
||
+ // cache root always exists as a directory, so `host_path.is_dir()`
|
||
+ // is true.
|
||
+ let host_exists_as_dir = host_path.is_dir();
|
||
+ let host_exists_as_file = host_path.is_file();
|
||
+ let is_dir_open = host_exists_as_dir
|
||
+ || (!host_exists_as_file && !want_non_dir && want_dir);
|
||
if is_dir_open {
|
||
- // For non-existent paths the guest wants us to create as a
|
||
- // directory, mkdir-p; canary's HostPathDevice does the same
|
||
- // when FILE_DIRECTORY_FILE is set on a kCreate disposition.
|
||
- if want_dir && !host_path.exists() {
|
||
- if let Err(e) = std::fs::create_dir_all(host_path) {
|
||
- tracing::warn!(
|
||
- "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
|
||
- host_path,
|
||
- e
|
||
- );
|
||
+ // Phase C+11.1 — only create the host directory when the
|
||
+ // disposition is *create-capable*. Mirrors canary's
|
||
+ // `VirtualFileSystem::OpenFile` (virtual_file_system.cc:265-273):
|
||
+ // for `FileDisposition::kOpen`/`kOverwrite` on a non-existent
|
||
+ // path the function returns `X_STATUS_OBJECT_NAME_NOT_FOUND`
|
||
+ // *before* any `CreatePath` call — i.e. mkdir is never invoked
|
||
+ // on these dispositions. The pre-fix code (Phase C+11) called
|
||
+ // `create_dir_all` whenever `want_dir && !host_path.exists()`,
|
||
+ // so Sylpheed's cold-boot probes for `cache:/access`,
|
||
+ // `cache:/ignore`, `cache:/recent` (disp=1, opts=0x7) succeeded
|
||
+ // and produced spurious host directories. Canary instead
|
||
+ // returns NOT_FOUND, after which Sylpheed re-creates these as
|
||
+ // FILES via `disp=5` + `FILE_NON_DIRECTORY_FILE`.
|
||
+ //
|
||
+ // Create-capable dispositions (mkdir OK):
|
||
+ // 0 FILE_SUPERSEDE
|
||
+ // 2 FILE_CREATE
|
||
+ // 3 FILE_OPEN_IF
|
||
+ // 5 FILE_OVERWRITE_IF
|
||
+ // Non-create dispositions (must miss when path is absent):
|
||
+ // 1 FILE_OPEN
|
||
+ // 4 FILE_OVERWRITE
|
||
+ let disp_is_create_capable = matches!(
|
||
+ create_disposition,
|
||
+ FILE_SUPERSEDE | FILE_CREATE | FILE_OPEN_IF | FILE_OVERWRITE_IF
|
||
+ );
|
||
+ if !host_path.exists() {
|
||
+ if !disp_is_create_capable {
|
||
if handle_out != 0 {
|
||
mem.write_u32(handle_out, 0);
|
||
}
|
||
- write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
|
||
- return STATUS_UNSUCCESSFUL;
|
||
+ write_io_status_block(
|
||
+ mem,
|
||
+ io_status_block,
|
||
+ STATUS_OBJECT_NAME_NOT_FOUND as u32,
|
||
+ 0,
|
||
+ );
|
||
+ tracing::info!(
|
||
+ "cache open (dir) MISS path={:?} disp={} opts={:#x} -> NOT_FOUND",
|
||
+ guest_path,
|
||
+ create_disposition,
|
||
+ create_options
|
||
+ );
|
||
+ return STATUS_OBJECT_NAME_NOT_FOUND;
|
||
+ }
|
||
+ // create-capable + want_dir → mkdir-p the directory.
|
||
+ if want_dir {
|
||
+ if let Err(e) = std::fs::create_dir_all(host_path) {
|
||
+ tracing::warn!(
|
||
+ "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
|
||
+ host_path,
|
||
+ e
|
||
+ );
|
||
+ if handle_out != 0 {
|
||
+ mem.write_u32(handle_out, 0);
|
||
+ }
|
||
+ write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
|
||
+ return STATUS_UNSUCCESSFUL;
|
||
+ }
|
||
}
|
||
}
|
||
// Stored path ends with '/' so nt_query_information_file's
|
||
@@ -828,6 +1217,10 @@ fn open_cache_file(
|
||
} else {
|
||
format!("{}/", guest_path)
|
||
};
|
||
+ // Phase C+12 — register / refresh directory entry mirror.
|
||
+ if let Ok(md) = host_path.metadata() {
|
||
+ state.register_cache_entry(guest_path, &md);
|
||
+ }
|
||
let handle = state.alloc_handle_for(KernelObject::File {
|
||
path: dir_path,
|
||
size: 0,
|
||
@@ -836,6 +1229,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);
|
||
}
|
||
@@ -918,10 +1312,16 @@ fn open_cache_file(
|
||
return STATUS_UNSUCCESSFUL;
|
||
}
|
||
}
|
||
- let size = host_path
|
||
- .metadata()
|
||
- .map(|m| m.len())
|
||
- .unwrap_or(0);
|
||
+ let metadata = host_path.metadata().ok();
|
||
+ let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||
+ // Phase C+12 — register / refresh the in-memory entry mirror so
|
||
+ // subsequent `NtQueryFullAttributesFile` probes for this path
|
||
+ // resolve without re-stating the host FS (parity with canary's
|
||
+ // `Entry::CreateEntry`,
|
||
+ // `xenia-canary/src/xenia/vfs/entry.cc:88-104`).
|
||
+ if let Some(md) = metadata.as_ref() {
|
||
+ state.register_cache_entry(guest_path, md);
|
||
+ }
|
||
let handle = state.alloc_handle_for(KernelObject::File {
|
||
path: guest_path.to_string(),
|
||
size,
|
||
@@ -931,6 +1331,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);
|
||
}
|
||
@@ -950,6 +1351,32 @@ fn open_cache_file(
|
||
/// AUDIT-038 — additional NTSTATUS used by the cache-backed open path.
|
||
const STATUS_OBJECT_NAME_COLLISION: u64 = 0xC000_0035;
|
||
|
||
+/// Phase C+13 — does `raw_path` start with a prefix that aliases the
|
||
+/// (read-only) game disc? Used to scope the synth-empty fallback in
|
||
+/// `open_vfs_file`: missing disc files report `STATUS_OBJECT_NAME_NOT_FOUND`
|
||
+/// (matching canary's `NtCreateFile_entry` for game-data lookups), while
|
||
+/// missing writable-partition paths keep the legacy zero-byte synth.
|
||
+///
|
||
+/// Mirrors the disc-mapped subset of `crate::path::DEVICE_PREFIXES`:
|
||
+/// - `game:\` — canary's symbolic-link alias for the disc
|
||
+/// (xenia-canary/src/xenia/kernel/kernel_state.cc registrations).
|
||
+/// - `d:\` / `D:\` — drive-letter alias for the disc.
|
||
+/// - `\Device\Cdrom0\` — NT device path for the disc.
|
||
+///
|
||
+/// Compares case-insensitively to match canary's path resolver.
|
||
+fn is_disc_prefix(raw_path: &str) -> bool {
|
||
+ let lowered = raw_path.trim_start().to_ascii_lowercase();
|
||
+ const DISC_PREFIXES: &[&str] = &[
|
||
+ "game:\\",
|
||
+ "game:/",
|
||
+ "d:\\",
|
||
+ "d:/",
|
||
+ "\\device\\cdrom0\\",
|
||
+ "\\device\\cdrom0/",
|
||
+ ];
|
||
+ DISC_PREFIXES.iter().any(|p| lowered.starts_with(p))
|
||
+}
|
||
+
|
||
/// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the
|
||
/// create/open distinction only matters for writable volumes (cache:/),
|
||
/// which we now back with a host directory (audit-038). The disc image
|
||
@@ -980,6 +1407,17 @@ fn open_vfs_file(
|
||
// see a null handle later and trigger `XamShowDirtyDiscErrorUI`.
|
||
let path = crate::path::object_attributes_to_vfs_path(mem, obj_attrs_ptr)
|
||
.unwrap_or_default();
|
||
+ // Phase C+13 — recover the raw (un-stripped) path so we can tell a
|
||
+ // disc-aliased prefix (`game:\`, `d:\`, `\Device\Cdrom0\`) apart from a
|
||
+ // writable-partition prefix (`\Device\Harddisk0\…`, `\??\`, raw "no
|
||
+ // prefix" cases). The synth-empty fallback below covers both today but
|
||
+ // canary's `NtCreateFile_entry` (xboxkrnl_io.cc:83-110) returns the
|
||
+ // VFS lookup status verbatim, which is `STATUS_OBJECT_NAME_NOT_FOUND`
|
||
+ // for any disc path that isn't in the ISO. Scoping the synth to
|
||
+ // non-disc prefixes makes us match canary's behaviour for missing
|
||
+ // game-data files (e.g. `game:\dat\files.tbl` at Phase C+13 idx 103862).
|
||
+ let raw_path = crate::path::object_attributes_raw_name(mem, obj_attrs_ptr)
|
||
+ .unwrap_or_default();
|
||
if path.is_empty() && obj_attrs_ptr == 0 {
|
||
if handle_out != 0 {
|
||
mem.write_u32(handle_out, 0);
|
||
@@ -1004,6 +1442,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 +1486,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);
|
||
}
|
||
@@ -1055,28 +1495,43 @@ fn open_vfs_file(
|
||
STATUS_SUCCESS
|
||
}
|
||
Err(e) => {
|
||
- // When the VFS can't resolve a path we synthesize a zero-byte
|
||
- // virtual file rather than returning NOT_FOUND. Two rationales:
|
||
- //
|
||
- // 1. **Writable system partitions** (`cache:/`, `cache0:`,
|
||
- // `cache1:`, `partition0:`, `partition1:`) aren't backed by
|
||
- // the disc — Canary mounts them on host directories
|
||
- // ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc)).
|
||
- // We skip the host mount for now, so opens there always miss
|
||
- // without this fallback.
|
||
+ // Phase C+13 — scope the synth-empty fallback to non-disc
|
||
+ // prefixes only. Canary's `NtCreateFile_entry` returns the VFS
|
||
+ // result verbatim (xboxkrnl_io.cc:83-110); for a missing disc
|
||
+ // file like `game:\dat\files.tbl` that's
|
||
+ // `STATUS_OBJECT_NAME_NOT_FOUND`. Sylpheed handles NOT_FOUND
|
||
+ // cleanly (next event in canary's trace at idx 103862 is
|
||
+ // `RtlNtStatusToDosError(0xc0000034) -> 2`, then the boot
|
||
+ // validator continues), so the synth was masking the
|
||
+ // correct branch.
|
||
//
|
||
- // 2. **Disc files that didn't make it into the ISO rip** (e.g.,
|
||
- // Sylpheed's `dat/files.tbl`, which the retail disc shipped
|
||
- // but our dump doesn't contain). Returning NOT_FOUND makes
|
||
- // Sylpheed's boot validator call `XamShowDirtyDiscErrorUI`
|
||
- // → dashboard exit; see Canary's `XamShowDirtyDiscErrorUI`
|
||
- // at xam_ui.cc:562 for the "bad or unimplemented file IO
|
||
- // calls" framing.
|
||
- //
|
||
- // A zero-byte file lets the game's existence probe succeed, its
|
||
- // read return EOF, and its "is the content here" sanity checks
|
||
- // pass. If the game actually needs the bytes for gameplay we'll
|
||
- // see a fresh failure downstream and can decide what to stub next.
|
||
+ // Synth-empty is still kept for writable system partitions
|
||
+ // (`\Device\Harddisk0\…`, `\Device\Mass*`, `\??\`, raw paths)
|
||
+ // because those aren't backed by the disc — Canary mounts
|
||
+ // them on host directories
|
||
+ // ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc));
|
||
+ // ours skips the host mount for those and falls back to the
|
||
+ // legacy stub to avoid regressing audit-006 / audit-018
|
||
+ // disc-validation probes. `cache:/` was already routed to
|
||
+ // `open_cache_file` upstream of this branch (AUDIT-038).
|
||
+ if is_disc_prefix(&raw_path) {
|
||
+ if handle_out != 0 {
|
||
+ mem.write_u32(handle_out, 0);
|
||
+ }
|
||
+ write_io_status_block(
|
||
+ mem,
|
||
+ io_status_block,
|
||
+ STATUS_OBJECT_NAME_NOT_FOUND as u32,
|
||
+ 0,
|
||
+ );
|
||
+ tracing::info!(
|
||
+ "Disc path missing: raw={:?} norm={:?} err={} -> NOT_FOUND",
|
||
+ raw_path,
|
||
+ path,
|
||
+ e
|
||
+ );
|
||
+ return STATUS_OBJECT_NAME_NOT_FOUND;
|
||
+ }
|
||
let handle = state.alloc_handle_for(KernelObject::File {
|
||
path: path.clone(),
|
||
size: 0,
|
||
@@ -1085,6 +1540,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 +1578,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,
|
||
@@ -1171,8 +1637,10 @@ fn signal_io_completion_event(state: &mut KernelState, event_handle: u32) {
|
||
fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
// r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx,
|
||
// r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr
|
||
- let handle = ctx.gpr[3] as u32;
|
||
- let event_handle = ctx.gpr[4] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source so file/event lookups
|
||
+ // hit the canonical `state.objects` slot.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
|
||
let io_status_block = ctx.gpr[7] as u32;
|
||
let buffer = ctx.gpr[8] as u32;
|
||
let length = ctx.gpr[9] as u32;
|
||
@@ -1293,8 +1761,9 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
|
||
// r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr.
|
||
// For cache:/* (host_path Some) writes go to disk; everything else
|
||
// is still discarded (matches legacy read-only behaviour for game:/).
|
||
- let handle = ctx.gpr[3] as u32;
|
||
- let event_handle = ctx.gpr[4] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
|
||
let io_status_block = ctx.gpr[7] as u32;
|
||
let buffer = ctx.gpr[8] as u32;
|
||
let length = ctx.gpr[9] as u32;
|
||
@@ -1320,6 +1789,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 +1811,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 +1827,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);
|
||
}
|
||
@@ -1373,7 +1857,8 @@ fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
|
||
const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D;
|
||
const CACHE_SIZE: u64 = 0xFF000;
|
||
|
||
- let event_handle = ctx.gpr[4] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
|
||
let io_status_block = ctx.gpr[7] as u32;
|
||
let io_control_code = ctx.gpr[8] as u32;
|
||
let sp = ctx.gpr[1] as u32;
|
||
@@ -1423,7 +1908,8 @@ fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
|
||
/// (14). Anything else gets zeros + success.
|
||
fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
// r3 = handle, r4 = io_status_block, r5 = file_info, r6 = length, r7 = class
|
||
- let handle = ctx.gpr[3] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
let io_status_block = ctx.gpr[4] as u32;
|
||
let file_info = ctx.gpr[5] as u32;
|
||
let length = ctx.gpr[6] as u32;
|
||
@@ -1517,6 +2003,123 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
|
||
ctx.gpr[3] = STATUS_SUCCESS;
|
||
}
|
||
|
||
+/// Phase C+11 — XFileRenameInformation (class 10) body. Mirrors canary
|
||
+/// `xboxkrnl_io_info.cc:226-243` `file->Rename(target_path)`. Sylpheed's
|
||
+/// cache-build path writes `cache:\<H1><H2>.tmp` flat journal files, then
|
||
+/// renames them to the hierarchical leaf `cache:\<H1>\<X>\<H2>` via this
|
||
+/// info-class. Before this body landed, ours silently fell through to the
|
||
+/// `_ => STATUS_SUCCESS` catch-all and the `.tmp` never became a leaf —
|
||
+/// blocking `NtQueryFullAttributesFile` at idx 102404 in the Phase A diff.
|
||
+///
|
||
+/// Layout per canary `info/file.h:79-83` (16 bytes total):
|
||
+/// offset 0 be<u32> replace_existing
|
||
+/// offset 4 be<u32> root_dir_handle
|
||
+/// offset 8 X_ANSI_STRING (u16 Length, u16 MaximumLength, u32 Buffer)
|
||
+///
|
||
+/// Pulled out of `nt_set_information_file`'s main `match` because it
|
||
+/// needs an immutable read of `state.cache_root` (via
|
||
+/// `resolve_cache_path`) BEFORE the mutable destructure of the file
|
||
+/// handle — Rust's borrow checker can't see through `state.method()`
|
||
+/// across both kinds of access.
|
||
+fn handle_set_info_rename(
|
||
+ mem: &GuestMemory,
|
||
+ state: &mut KernelState,
|
||
+ handle: u32,
|
||
+ info_ptr: u32,
|
||
+ info_length: u32,
|
||
+) -> (u64, u32) {
|
||
+ // Read the rename target ANSI_STRING. The raw-form helper trims
|
||
+ // whitespace but does NOT prefix-strip — we want the original
|
||
+ // `cache:\...` form so the path resolver sees it.
|
||
+ let target_raw =
|
||
+ match crate::path::file_rename_information_raw_target(mem, info_ptr, info_length) {
|
||
+ Some(s) if !s.is_empty() => s,
|
||
+ _ => return (STATUS_OBJECT_NAME_INVALID, 16),
|
||
+ };
|
||
+
|
||
+ // Translate target path. Sylpheed only renames inside `cache:\`; any
|
||
+ // other prefix is not in scope (canary's `IsValidPath` rejects
|
||
+ // anything that doesn't resolve to a writable mount).
|
||
+ let target_host_path = match state.resolve_cache_path(&target_raw) {
|
||
+ Some(p) => p,
|
||
+ None => return (STATUS_OBJECT_NAME_INVALID, 16),
|
||
+ };
|
||
+
|
||
+ // Look up the source handle. Note: ANY non-File handle (event,
|
||
+ // semaphore, etc.) is INVALID_HANDLE; a File without a
|
||
+ // `host_path` is VFS-backed (read-only) and can't be renamed.
|
||
+ let Some(KernelObject::File { path, size, host_path, .. }) = state.objects.get_mut(&handle)
|
||
+ else {
|
||
+ return (STATUS_INVALID_HANDLE, 16);
|
||
+ };
|
||
+ let Some(src_host_path) = host_path.clone() else {
|
||
+ // VFS-backed read-only handle (disc / synth stub). Canary's
|
||
+ // HostPathDevice mount is the only Rename-capable backend on
|
||
+ // Sylpheed; Disc/SVOD throws `kReadOnly`.
|
||
+ return (STATUS_ACCESS_DENIED, 16);
|
||
+ };
|
||
+
|
||
+ // Create parent directories for the destination (matches canary's
|
||
+ // `HostPathEntry::CreateEntryInternal` which calls
|
||
+ // `create_directories` before writing the file). Without this, the
|
||
+ // rename to `<root>/d4ea4615/e/46ee8ca` fails when `<root>/d4ea4615/e`
|
||
+ // doesn't yet exist (a common cold-cache scenario).
|
||
+ if let Some(parent) = target_host_path.parent() {
|
||
+ if let Err(e) = std::fs::create_dir_all(parent) {
|
||
+ tracing::warn!(
|
||
+ "NtSetInformationFile rename: create_dir_all({:?}): {}",
|
||
+ parent,
|
||
+ e
|
||
+ );
|
||
+ return (STATUS_UNSUCCESSFUL, 16);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Perform the rename. `std::fs::rename` is atomic within a single
|
||
+ // filesystem on POSIX; cross-filesystem is the only failure path
|
||
+ // worth worrying about, and the entire cache lives under one root.
|
||
+ let old_path = path.clone();
|
||
+ let rename_outcome = match std::fs::rename(&src_host_path, &target_host_path) {
|
||
+ Ok(()) => {
|
||
+ // Update the in-engine handle to point at the new location.
|
||
+ // The handle stays valid (mirrors canary's `XFile::Rename`
|
||
+ // which keeps the file handle open at the new path).
|
||
+ *path = crate::path::normalize_path(&target_raw);
|
||
+ *host_path = Some(target_host_path.clone());
|
||
+ let new_size = std::fs::metadata(&target_host_path)
|
||
+ .map(|m| m.len())
|
||
+ .unwrap_or(*size);
|
||
+ *size = new_size;
|
||
+ Ok(())
|
||
+ }
|
||
+ Err(e) => {
|
||
+ tracing::warn!(
|
||
+ "NtSetInformationFile rename: rename({:?} -> {:?}): {}",
|
||
+ src_host_path,
|
||
+ target_host_path,
|
||
+ e
|
||
+ );
|
||
+ Err(())
|
||
+ }
|
||
+ };
|
||
+ // Drop the mutable borrow on `state.objects` before touching
|
||
+ // `state.cache_entries` via the helper methods. The `let
|
||
+ // Some(KernelObject::File { .. }) = state.objects.get_mut(...)`
|
||
+ // binding above holds it until the function returns otherwise.
|
||
+ match rename_outcome {
|
||
+ Ok(()) => {
|
||
+ // Phase C+12 — refresh the in-memory entry tree: drop the
|
||
+ // source mirror, install / refresh the target mirror.
|
||
+ state.forget_cache_entry(&old_path);
|
||
+ if let Ok(md) = std::fs::metadata(&target_host_path) {
|
||
+ state.register_cache_entry(&target_raw, &md);
|
||
+ }
|
||
+ (STATUS_SUCCESS, 16)
|
||
+ }
|
||
+ Err(()) => (STATUS_UNSUCCESSFUL, 16),
|
||
+ }
|
||
+}
|
||
+
|
||
/// `NtSetInformationFile(FileHandle, IoStatusBlock*, FileInformation,
|
||
/// Length, FileInformationClass)`. Mirrors Canary
|
||
/// [xboxkrnl_io_info.cc:180-304](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io_info.cc).
|
||
@@ -1524,16 +2127,17 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
|
||
/// Validates `info_class` (must have a defined minimum size) and
|
||
/// `info_length` (must meet that minimum); returns
|
||
/// `STATUS_INVALID_INFO_CLASS` / `STATUS_INFO_LENGTH_MISMATCH` in those
|
||
-/// cases. The only class with real side-effects in xenia-rs is
|
||
-/// `XFilePositionInformation` (14) — seek updates the file's cursor.
|
||
-/// Read-only VFS means `XFileEndOfFileInformation` (20, truncate) can
|
||
-/// only succeed if the new length equals the current size, otherwise
|
||
-/// returns `STATUS_UNSUCCESSFUL`. Other classes acknowledge the write
|
||
-/// but have no backing store.
|
||
+/// cases. Side-effect classes:
|
||
+/// * `XFileRenameInformation` (10) — rename a cache:-backed handle.
|
||
+/// * `XFilePositionInformation` (14) — seek updates the file's cursor.
|
||
+/// * `XFileEndOfFileInformation` (20) — truncate (cache: only; disc-VFS
|
||
+/// rejects non-identity truncates with `STATUS_UNSUCCESSFUL`).
|
||
+/// Other classes acknowledge the write but have no backing store.
|
||
fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
// r3 = handle, r4 = io_status_block, r5 = info_ptr,
|
||
// r6 = info_length, r7 = info_class.
|
||
- let handle = ctx.gpr[3] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
let iosb_ptr = ctx.gpr[4] as u32;
|
||
let info_ptr = ctx.gpr[5] as u32;
|
||
let info_length = ctx.gpr[6] as u32;
|
||
@@ -1562,6 +2166,21 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
|
||
return;
|
||
}
|
||
|
||
+ // Phase C+11 — class 10 (`XFileRenameInformation`) needs both a
|
||
+ // read of `state.cache_root` (via `resolve_cache_path`) AND a mutable
|
||
+ // borrow of the target file handle. Rust's borrow checker can't see
|
||
+ // through `&self.method()` calls, so split it out before the shared
|
||
+ // `get_mut` destructure below.
|
||
+ if info_class == 10 {
|
||
+ let (status, out_length) =
|
||
+ handle_set_info_rename(mem, state, handle, info_ptr, info_length);
|
||
+ if iosb_ptr != 0 {
|
||
+ write_io_status_block(mem, iosb_ptr, status as u32, out_length);
|
||
+ }
|
||
+ ctx.gpr[3] = status;
|
||
+ return;
|
||
+ }
|
||
+
|
||
// Handle lookup.
|
||
let Some(KernelObject::File { size, position, host_path, .. }) = state.objects.get_mut(&handle) else {
|
||
ctx.gpr[3] = STATUS_INVALID_HANDLE;
|
||
@@ -1634,6 +2253,48 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
|
||
ctx.gpr[3] = status;
|
||
}
|
||
|
||
+/// Phase C+12 — write the 56-byte `X_FILE_NETWORK_OPEN_INFORMATION`
|
||
+/// (`xenia-canary/src/xenia/kernel/info/file.h:117-127`) at `out` from
|
||
+/// the entry's metadata. All multibyte fields are stored big-endian
|
||
+/// (`be<uint64_t>` / `be<uint32_t>` in the canary struct); our
|
||
+/// `GuestMemory::write_u{32,64}` already byte-swaps via `to_be_bytes`,
|
||
+/// so the writes naturally produce the BE layout the Xbox 360 expects.
|
||
+///
|
||
+/// Layout (offset / size / type / canary field):
|
||
+/// ```text
|
||
+/// 0 u64 CreationTime (FILETIME)
|
||
+/// 8 u64 LastAccessTime
|
||
+/// 16 u64 LastWriteTime
|
||
+/// 24 u64 ChangeTime (= LastWriteTime per xboxkrnl_io.cc:504)
|
||
+/// 32 u64 AllocationSize
|
||
+/// 40 u64 EndOfFile
|
||
+/// 48 u32 Attributes (FILE_ATTRIBUTE_*)
|
||
+/// 52 u32 Reserved (= 0)
|
||
+/// ```
|
||
+fn write_file_network_open_information(
|
||
+ mem: &GuestMemory,
|
||
+ out: u32,
|
||
+ meta: &crate::state::CacheEntryMeta,
|
||
+) {
|
||
+ if out == 0 {
|
||
+ return;
|
||
+ }
|
||
+ mem.write_u64(out, meta.create_time);
|
||
+ mem.write_u64(out + 8, meta.access_time);
|
||
+ mem.write_u64(out + 16, meta.write_time);
|
||
+ // change_time = write_time per canary `xboxkrnl_io.cc:504`.
|
||
+ mem.write_u64(out + 24, meta.write_time);
|
||
+ mem.write_u64(out + 32, meta.allocation_size);
|
||
+ mem.write_u64(out + 40, meta.size);
|
||
+ let attrs = if meta.is_directory {
|
||
+ crate::state::X_FILE_ATTRIBUTE_DIRECTORY
|
||
+ } else {
|
||
+ crate::state::X_FILE_ATTRIBUTE_NORMAL
|
||
+ };
|
||
+ mem.write_u32(out + 48, attrs);
|
||
+ mem.write_u32(out + 52, 0);
|
||
+}
|
||
+
|
||
fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
// r3 = obj_attrs, r4 = network_open_info
|
||
let obj_attrs_ptr = ctx.gpr[3] as u32;
|
||
@@ -1647,37 +2308,41 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state:
|
||
}
|
||
};
|
||
|
||
- // AUDIT-038 — cache:/* short-circuit: stat the host-FS file directly
|
||
- // so existence probes (Sylpheed's pre-open `NtQueryFullAttributesFile`)
|
||
- // see real attributes for files we just created and miss for files we
|
||
- // haven't.
|
||
- if let Some(hp) = state.resolve_cache_path(&path) {
|
||
- let entry = std::fs::metadata(&hp);
|
||
- match entry {
|
||
- Ok(md) => {
|
||
- let filetime: u64 = 132_500_000_000_000_000;
|
||
- if out != 0 {
|
||
- for off in (0..32).step_by(4) {
|
||
- mem.write_u32(out + off, if off & 4 == 0 {
|
||
- (filetime >> 32) as u32
|
||
- } else {
|
||
- filetime as u32
|
||
- });
|
||
- }
|
||
- mem.write_u64(out + 32, md.len());
|
||
- mem.write_u64(out + 40, md.len());
|
||
- let attrs: u32 = if md.is_dir() { 0x10 } else { 0x80 };
|
||
- mem.write_u32(out + 48, attrs);
|
||
- mem.write_u32(out + 52, 0);
|
||
+ // Phase C+12 — `cache:*` paths consult the in-memory entry mirror
|
||
+ // first, mirroring canary's `NtQueryFullAttributesFile_entry` which
|
||
+ // walks the in-memory entry tree via `VirtualFileSystem::ResolvePath`
|
||
+ // and never re-stats the host
|
||
+ // (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:498-512`).
|
||
+ //
|
||
+ // The entry tree is seeded at mount time by
|
||
+ // `populate_cache_entries_from_host` (mirrors canary's eager
|
||
+ // `HostPathDevice::PopulateEntry`) and refreshed per-NtCreateFile
|
||
+ // by `register_cache_entry` (mirrors canary's `Entry::CreateEntry`).
|
||
+ // A second-line host-FS fallback handles the rare case where the
|
||
+ // entry tree lost track but the host file is present (defensive;
|
||
+ // canary returns NO_SUCH_FILE in that case so we keep this fallback
|
||
+ // narrow).
|
||
+ if path.to_ascii_lowercase().starts_with("cache:") {
|
||
+ if let Some(meta) = state.lookup_cache_entry(&path) {
|
||
+ write_file_network_open_information(mem, out, meta);
|
||
+ ctx.gpr[3] = STATUS_SUCCESS;
|
||
+ return;
|
||
+ }
|
||
+ // Host-FS defensive fallback — only fires when the in-memory
|
||
+ // tree missed but the file is on disk. Refreshes the tree as a
|
||
+ // side-effect so subsequent probes hit the fast path.
|
||
+ if let Some(hp) = state.resolve_cache_path(&path) {
|
||
+ if let Ok(md) = std::fs::metadata(&hp) {
|
||
+ state.register_cache_entry(&path, &md);
|
||
+ if let Some(meta) = state.lookup_cache_entry(&path) {
|
||
+ write_file_network_open_information(mem, out, meta);
|
||
+ ctx.gpr[3] = STATUS_SUCCESS;
|
||
+ return;
|
||
}
|
||
- ctx.gpr[3] = STATUS_SUCCESS;
|
||
- return;
|
||
- }
|
||
- Err(_) => {
|
||
- ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
|
||
- return;
|
||
}
|
||
}
|
||
+ ctx.gpr[3] = STATUS_NO_SUCH_FILE;
|
||
+ return;
|
||
}
|
||
|
||
let Some(vfs) = state.vfs.as_ref() else {
|
||
@@ -1687,24 +2352,23 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state:
|
||
|
||
match vfs.stat(&path) {
|
||
Ok(entry) => {
|
||
- // FILE_NETWORK_OPEN_INFORMATION (56 bytes): 4 × FILETIME,
|
||
- // AllocationSize(i64), EndOfFile(i64), FileAttributes(u32), pad(u32)
|
||
- let filetime: u64 = 132_500_000_000_000_000;
|
||
- if out != 0 {
|
||
- mem.write_u32(out, (filetime >> 32) as u32);
|
||
- mem.write_u32(out + 4, filetime as u32);
|
||
- mem.write_u32(out + 8, (filetime >> 32) as u32);
|
||
- mem.write_u32(out + 12, filetime as u32);
|
||
- mem.write_u32(out + 16, (filetime >> 32) as u32);
|
||
- mem.write_u32(out + 20, filetime as u32);
|
||
- mem.write_u32(out + 24, (filetime >> 32) as u32);
|
||
- mem.write_u32(out + 28, filetime as u32);
|
||
- mem.write_u64(out + 32, entry.size);
|
||
- mem.write_u64(out + 40, entry.size);
|
||
- let attrs: u32 = if entry.is_directory { 0x10 } else { 0x80 };
|
||
- mem.write_u32(out + 48, attrs);
|
||
- mem.write_u32(out + 52, 0);
|
||
- }
|
||
+ let meta = crate::state::CacheEntryMeta {
|
||
+ is_directory: entry.is_directory,
|
||
+ size: entry.size,
|
||
+ // Disc/VFS entries have no host metadata; use the same
|
||
+ // 4 KiB alignment canary derives from
|
||
+ // `device->bytes_per_sector()`. Disc devices default
|
||
+ // to 2048 in canary
|
||
+ // (`xenia-canary/src/xenia/vfs/devices/disc_image_device.cc`)
|
||
+ // but for the existence-probe consumers we hit on
|
||
+ // Sylpheed boot the exact alignment doesn't matter —
|
||
+ // they only branch on the SUCCESS/NOT_FOUND status.
|
||
+ allocation_size: (entry.size + 2047) & !2047,
|
||
+ create_time: 0,
|
||
+ access_time: 0,
|
||
+ write_time: 0,
|
||
+ };
|
||
+ write_file_network_open_information(mem, out, &meta);
|
||
ctx.gpr[3] = STATUS_SUCCESS;
|
||
}
|
||
Err(_) => {
|
||
@@ -1759,8 +2423,9 @@ fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
|
||
// r3=file_handle, r4=event_handle, r5=apc_routine, r6=apc_context,
|
||
// r7=io_status_block, r8=file_info_ptr, r9=length, r10=file_name,
|
||
// sp+... = restart_scan.
|
||
- let handle = ctx.gpr[3] as u32;
|
||
- let event_handle = ctx.gpr[4] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
|
||
let iosb_ptr = ctx.gpr[7] as u32;
|
||
let info_ptr = ctx.gpr[8] as u32;
|
||
let length = ctx.gpr[9] as u32;
|
||
@@ -1916,15 +2581,31 @@ fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
|
||
|
||
fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
|
||
let handle = ctx.gpr[3] as u32;
|
||
- // Aliased refcount: `NtDuplicateObject` returns the *source* handle as the
|
||
- // "new" handle (we don't mint fresh values), so the game commonly holds
|
||
- // two logical references to the same handle value. Without refcount, the
|
||
- // first `NtClose` wipes the object while the second reference is still
|
||
- // live, which traps any later wait on that handle (Sylpheed's
|
||
- // create→dup(SAME_ACCESS)→set→close pattern at 0x8246079c manifests this
|
||
- // — main thread then parks forever on the closed handle). Mirror Canary's
|
||
- // `ObjectTable::ReleaseHandle` (object_table.cc:189): decrement the
|
||
- // per-handle refcount and only drop the object when it reaches zero.
|
||
+ close_handle_internal(state, handle);
|
||
+ ctx.gpr[3] = 0;
|
||
+}
|
||
+
|
||
+/// Phase C+19: shared close path used by `nt_close`,
|
||
+/// `nt_duplicate_object`'s `DUPLICATE_CLOSE_SOURCE` branch, and
|
||
+/// `xam::xam_task_close_handle` (which canary defers to NtClose).
|
||
+///
|
||
+/// Mirrors canary's `ObjectTable::ReleaseHandle` (object_table.cc:237-256):
|
||
+/// decrement the slot's local refcount; on zero, emit `handle.destroy` for
|
||
+/// the slot AND release the canonical kernel object — the canonical entry
|
||
+/// (and its `KernelObject`) is removed only when `canonical_slot_count`
|
||
+/// reaches zero (all dup siblings are gone). This preserves canary's
|
||
+/// observable lifecycle:
|
||
+///
|
||
+/// - Each `NtClose` of a slot with `handle_refcount==1` emits exactly one
|
||
+/// `handle.destroy` event for that slot.
|
||
+/// - The underlying object survives until the last slot closes; only then
|
||
+/// are `state.objects`/`async_file_handles`/`pending_timer_fires` pruned.
|
||
+pub(crate) fn close_handle_internal(state: &mut KernelState, handle: u32) {
|
||
+ let prior_rc = state
|
||
+ .handle_refcount
|
||
+ .get(&handle)
|
||
+ .copied()
|
||
+ .unwrap_or(0);
|
||
let remaining = state
|
||
.handle_refcount
|
||
.get_mut(&handle)
|
||
@@ -1934,14 +2615,51 @@ fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
|
||
})
|
||
.unwrap_or(0);
|
||
if remaining == 0 {
|
||
- state.objects.remove(&handle);
|
||
state.handle_refcount.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.
|
||
- state.disarm_timer(handle);
|
||
+
|
||
+ // Resolve the canonical id before we discard the alias entry —
|
||
+ // we need it to decrement the slot-count and possibly drop the
|
||
+ // backing object.
|
||
+ let canonical = state.resolve_handle(handle);
|
||
+ state.handle_aliases.remove(&handle);
|
||
+
|
||
+ // Decrement the canonical's live-slot count. If this slot was the
|
||
+ // last one referring to the canonical, drop the underlying object.
|
||
+ let slots_left = match state.canonical_slot_count.get_mut(&canonical) {
|
||
+ Some(c) => {
|
||
+ *c = c.saturating_sub(1);
|
||
+ *c
|
||
+ }
|
||
+ None => 0,
|
||
+ };
|
||
+
|
||
+ if slots_left == 0 {
|
||
+ state.canonical_slot_count.remove(&canonical);
|
||
+ state.objects.remove(&canonical);
|
||
+ // 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(&canonical);
|
||
+ // 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.
|
||
+ state.disarm_timer(canonical);
|
||
+ }
|
||
+
|
||
+ // Phase C+15-α: schema-v1 `handle.destroy` event for the SLOT being
|
||
+ // closed (which is `handle`, not the canonical). Canary emits at
|
||
+ // `ObjectTable::RemoveHandle` (object_table.cc:294-296) per-slot,
|
||
+ // regardless of whether the underlying object still has sibling
|
||
+ // slots — so we match that.
|
||
+ if crate::event_log::is_enabled() {
|
||
+ let (tid, cycle) = {
|
||
+ let r = state.scheduler.current_ref();
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ };
|
||
+ crate::event_log::emit_handle_destroy_auto(tid, cycle, handle, prior_rc);
|
||
+ }
|
||
}
|
||
- ctx.gpr[3] = 0;
|
||
}
|
||
|
||
fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
@@ -2186,6 +2904,139 @@ fn rtl_enter_critical_section(
|
||
return;
|
||
}
|
||
let current_tid = ctx.thread_id;
|
||
+
|
||
+ // Phase D Stage 3 — contention-replay manifest. When installed (via
|
||
+ // `XENIA_CONTENTION_MANIFEST_PATH`), the manifest tells us at which
|
||
+ // (tid, tid_event_idx) canary saw real contention on which CS. We
|
||
+ // peek the next per-tid ordinal, look up the manifest, and if it hits
|
||
+ // (with a matching cs_ptr) we emit a parity `contention.observed`
|
||
+ // event and force a park via the existing `cs_waiters` path.
|
||
+ //
|
||
+ // Wake comes when some other guest thread calls
|
||
+ // `RtlLeaveCriticalSection` on this CS naturally — the existing path
|
||
+ // at lines 2972-2980 handles the lock handoff. If no peer touches
|
||
+ // the CS, `Scheduler::unblock_on_deadlock` recovers with the existing
|
||
+ // CriticalSection-blocked wake at scheduler.rs:1208 (STATUS_TIMEOUT
|
||
+ // style — owner field will read 0 post-recovery, surfaceable as a
|
||
+ // downstream trace divergence rather than a silent hang).
|
||
+ //
|
||
+ // Default mode (no manifest installed): zero overhead, byte-identical
|
||
+ // to pre-Stage-3 behavior — `state.contention_manifest.as_ref()`
|
||
+ // short-circuits before peek_tid_idx.
|
||
+ // `consume_at_peek` translates ours's `peek_tid_idx` back to
|
||
+ // canary's idx space by subtracting the count of prior
|
||
+ // `contention.observed` emits on this tid (each emit shifts
|
||
+ // ours's per-tid idx by +1 relative to canary's stream). The
|
||
+ // bookkeeping is internal to the manifest; the caller just hands
|
||
+ // it the current peek value.
|
||
+ let manifest_hit = state
|
||
+ .contention_manifest
|
||
+ .as_ref()
|
||
+ .and_then(|m| {
|
||
+ let peek = crate::event_log::peek_tid_idx(current_tid);
|
||
+ m.consume_at_peek(current_tid, peek)
|
||
+ });
|
||
+ if let Some(entry) = manifest_hit {
|
||
+ // Per-tid ordinal alignment with canary: ALWAYS emit
|
||
+ // `contention.observed` when the manifest fires, even if we end
|
||
+ // up not parking. Canary emits one here too (Stage 1) so
|
||
+ // consuming one per-tid idx slot on this side keeps the
|
||
+ // downstream events aligned. Stage 4 marks the kind
|
||
+ // engine-local in the diff tool, so the diff tool advances past
|
||
+ // these events on either side without comparison.
|
||
+ //
|
||
+ // We do NOT verify `entry.cs_ptr == cs_ptr` because canary and
|
||
+ // ours route guest-heap allocations to different VA regions
|
||
+ // (AUDIT-043 ε host-allocator divergence). Trust the
|
||
+ // `(tid, tid_event_idx)` alignment instead; if we got here, the
|
||
+ // manifest hit at the same per-tid call-site as canary's
|
||
+ // contention.observed.
|
||
+ let guest_cycle = ctx.cycle_count;
|
||
+ crate::event_log::emit_contention_observed(
|
||
+ current_tid,
|
||
+ guest_cycle,
|
||
+ cs_ptr,
|
||
+ true,
|
||
+ );
|
||
+ if entry.cs_ptr != cs_ptr {
|
||
+ tracing::debug!(
|
||
+ "manifest cs_ptr cross-engine divergence at tid={} idx={}: manifest {:#010x}, ours {:#010x} (allocator ε)",
|
||
+ current_tid,
|
||
+ entry.tid_event_idx,
|
||
+ entry.cs_ptr,
|
||
+ cs_ptr,
|
||
+ );
|
||
+ }
|
||
+ // Stage 3 aggressive mode: force-park even when CS is free in
|
||
+ // guest memory. The bet is that some other guest tid will
|
||
+ // naturally acquire+release this CS during ours's park window,
|
||
+ // triggering the natural wake at lines 2972-2980. If no peer
|
||
+ // touches the CS, `Scheduler::unblock_on_deadlock` recovers via
|
||
+ // its existing CriticalSection-blocked wake path (returning
|
||
+ // with owner=0 and any state divergence surfacing as a
|
||
+ // downstream trace mismatch rather than a silent hang).
|
||
+ //
|
||
+ // The conservative skip-when-free variant (the plan's "deadlock
|
||
+ // safe" branch) keeps the prefix at 104,607 because it doesn't
|
||
+ // actually shift behavior at the contention point. Aggressive
|
||
+ // mode tests whether driving the contention path is enough to
|
||
+ // advance past the cap. Gate via `XENIA_CONTENTION_AGGRESSIVE=1`
|
||
+ // so we can flip without rebuilding.
|
||
+ let aggressive = std::env::var("XENIA_CONTENTION_AGGRESSIVE")
|
||
+ .ok()
|
||
+ .is_some_and(|v| {
|
||
+ let v = v.trim().to_ascii_lowercase();
|
||
+ v == "1" || v == "true" || v == "yes"
|
||
+ });
|
||
+ let pre_owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD);
|
||
+ let pre_owner_live = pre_owner != 0
|
||
+ && state.scheduler.find_by_tid(pre_owner).is_some();
|
||
+ let natural_contention = pre_owner_live && pre_owner != current_tid;
|
||
+ if aggressive && !natural_contention {
|
||
+ // Synthesize a forced-park via the same path as the natural
|
||
+ // contention branch below: bump lock_count, push self onto
|
||
+ // cs_waiters, then park. Note: we set owning_thread to a
|
||
+ // SENTINEL (current_tid) so that re-entries by the same tid
|
||
+ // see "self owns it" and recursion paths work; the natural
|
||
+ // wake path will overwrite owning_thread when it transfers
|
||
+ // the lock. (NB: this is a hack; only enabled by an env-var
|
||
+ // gate so the conservative default stays deadlock-safe.)
|
||
+ let lc = mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32;
|
||
+ mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, (lc + 1) as u32);
|
||
+ let current_ref = state.scheduler.current_ref();
|
||
+ state
|
||
+ .cs_waiters
|
||
+ .entry(cs_ptr)
|
||
+ .or_default()
|
||
+ .push(current_ref);
|
||
+ tracing::debug!(
|
||
+ "manifest AGGRESSIVE force-park: hw={} cs={:#010x} tid={} idx={} (owner was {})",
|
||
+ current_ref.hw_id,
|
||
+ cs_ptr,
|
||
+ current_tid,
|
||
+ entry.tid_event_idx,
|
||
+ pre_owner,
|
||
+ );
|
||
+ ctx.gpr[3] = 0;
|
||
+ state
|
||
+ .scheduler
|
||
+ .park_current(BlockReason::CriticalSection(cs_ptr));
|
||
+ return;
|
||
+ }
|
||
+ if !natural_contention {
|
||
+ tracing::debug!(
|
||
+ "manifest hit at tid={} idx={} cs={:#010x} but CS is free/self-owned (owner={}); replay skipped (state-divergence, not schedule-divergence)",
|
||
+ current_tid,
|
||
+ entry.tid_event_idx,
|
||
+ cs_ptr,
|
||
+ pre_owner,
|
||
+ );
|
||
+ // Fall through to natural fast-path.
|
||
+ }
|
||
+ // If natural contention conditions ARE met, fall through to the
|
||
+ // existing park path below.
|
||
+ }
|
||
+
|
||
let owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD);
|
||
|
||
// "Effective owner" — if the stored tid doesn't correspond to any live HW
|
||
@@ -2382,10 +3233,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) {
|
||
@@ -2410,13 +3330,19 @@ fn rtl_multi_byte_to_unicode_n(ctx: &mut PpcContext, mem: &GuestMemory, _state:
|
||
}
|
||
|
||
fn rtl_nt_status_to_dos_error(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
|
||
- // Simple mapping for common cases
|
||
+ // NTSTATUS → Win32 ERROR_* translation. Canary's
|
||
+ // `RtlNtStatusToDosError` mirrors the documented Windows
|
||
+ // implementation; the subset below covers the codes Sylpheed
|
||
+ // surfaces in the Phase A diff window. Add new mappings as new
|
||
+ // divergences appear rather than synthesising a giant table up-front.
|
||
let status = ctx.gpr[3] as u32;
|
||
ctx.gpr[3] = match status {
|
||
- 0 => 0, // ERROR_SUCCESS
|
||
- 0xC000_0034 => 2, // ERROR_FILE_NOT_FOUND
|
||
- 0xC000_0011 => 38, // ERROR_HANDLE_EOF
|
||
- _ => status as u64, // Pass through
|
||
+ 0x0000_0000 => 0, // STATUS_SUCCESS → ERROR_SUCCESS
|
||
+ 0xC000_000F => 2, // STATUS_NO_SUCH_FILE → ERROR_FILE_NOT_FOUND
|
||
+ 0xC000_0011 => 38, // STATUS_END_OF_FILE → ERROR_HANDLE_EOF
|
||
+ 0xC000_0034 => 2, // STATUS_OBJECT_NAME_NOT_FOUND → ERROR_FILE_NOT_FOUND
|
||
+ 0xC000_0035 => 183, // STATUS_OBJECT_NAME_COLLISION → ERROR_ALREADY_EXISTS
|
||
+ _ => status as u64, // Pass through
|
||
};
|
||
}
|
||
|
||
@@ -3233,10 +4159,24 @@ fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state:
|
||
state.xaudio.worker_refs[index] = Some(r);
|
||
}
|
||
|
||
+ // Phase HostAudioEager (2026-05-19): mirror canary's
|
||
+ // `client_semaphore->Release(queued_frames_=8)` at
|
||
+ // `audio_system.cc:210` — seed the audio fire queue immediately so
|
||
+ // the round prologue's `try_inject_audio_callback` delivers the
|
||
+ // first callback within a few rounds of register-return, BEFORE
|
||
+ // tid=1 reaches `ExCreateThread` for the XAudio worker threads
|
||
+ // (tid=14/15 in canary, tid=9/10 in ours). Pre-fix, the 48k-
|
||
+ // instruction ticker delay let those threads spawn and enter their
|
||
+ // spin loop on the uninitialized voice struct before any callback
|
||
+ // fired. See `audit-runs/phase-host-audio-eager/investigation.md`.
|
||
+ let seeded = state
|
||
+ .xaudio
|
||
+ .seed_fires_for(index, crate::xaudio::XAUDIO_REGISTER_SEED_FIRES);
|
||
+
|
||
tracing::info!(
|
||
- "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?}",
|
||
+ "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?} seeded_fires={}",
|
||
index, callback_pc, callback_arg, wrapped, driver_id,
|
||
- state.xaudio.worker_handles[index],
|
||
+ state.xaudio.worker_handles[index], seeded,
|
||
);
|
||
ctx.gpr[3] = 0;
|
||
}
|
||
@@ -3266,6 +4206,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`
|
||
@@ -3503,14 +4515,20 @@ pub(crate) fn parse_timeout(state: &KernelState, timeout_ptr: u32, mem: &GuestMe
|
||
/// running its real body, leaving the main thread parked forever on the
|
||
/// completion event.
|
||
fn resolve_pseudo_handle(state: &KernelState, handle: u32) -> u32 {
|
||
- match handle {
|
||
+ let raw = match handle {
|
||
0xFFFF_FFFF => 0,
|
||
0xFFFF_FFFE => {
|
||
let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
|
||
state.scheduler.thread_handle(hw_id).unwrap_or(0)
|
||
}
|
||
h => h,
|
||
- }
|
||
+ };
|
||
+ // Phase C+19: canonicalize through the dup-alias map so every Nt*/Ke*
|
||
+ // call site that funnels through `resolve_pseudo_handle` (18 sites at
|
||
+ // C+19 landing) automatically routes dup ids back to their source
|
||
+ // slot before indexing `state.objects`. Preserves AUDIT-062's
|
||
+ // signal-on-dup-wakes-wait-on-source invariant.
|
||
+ state.resolve_handle(raw)
|
||
}
|
||
|
||
/// Lazily register a shadow kernel object for a guest `PKEVENT` / `PKSEMAPHORE`
|
||
@@ -3590,13 +4608,62 @@ fn ensure_dispatcher_object(state: &mut KernelState, mem: &GuestMemory, ptr: u32
|
||
},
|
||
_ => return,
|
||
};
|
||
+ // Phase C+17: object_type for the schema-v1 `handle.create` emit
|
||
+ // below. Must match `KernelObject::schema_object_type` exactly so
|
||
+ // re-entrant lookups via `lookup_handle_semantic_id` resolve a SID
|
||
+ // computed from the same tuple `(create_site_pc=0, tid, idx, type)`.
|
||
+ let object_type = obj.schema_object_type();
|
||
state.objects.insert(ptr, obj);
|
||
+ // Phase C+17: each fresh shadow gets a baseline refcount of 1 so
|
||
+ // the lifecycle bookkeeping is symmetric with `alloc_handle_for`.
|
||
+ // No `handle.destroy` is currently emitted on shadow removal —
|
||
+ // canary's `GetNativeObject` lazy-wrap likewise survives for the
|
||
+ // session — but the entry's presence guards against
|
||
+ // accidental-underflow when future code wires the symmetric destroy.
|
||
+ state.handle_refcount.entry(ptr).or_insert(1);
|
||
// Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first
|
||
// adoption, stamp the X_DISPATCH_HEADER's wait_list with the kXObjSignature
|
||
// fourcc 'X','E','N','\0' (flink_ptr) and the stash handle (blink_ptr).
|
||
// Game code reads these to recognize already-adopted dispatchers.
|
||
mem.write_u32(ptr + 0x08, 0x58454E00);
|
||
mem.write_u32(ptr + 0x0C, ptr);
|
||
+ // Phase C+17: schema-v1 `handle.create` event for the synthesized
|
||
+ // wrapper. Mirrors canary's `ObjectTable::AddHandle` emit
|
||
+ // (util/object_table.cc:191-198) inside `XObject::GetNativeObject`
|
||
+ // (xobject.cc:436-449). The `raw_handle_id` is the guest dispatcher
|
||
+ // pointer itself — ours uses it as the shadow's handle key, and
|
||
+ // canary's `StashHandle` likewise round-trips through the same
|
||
+ // dispatcher slot, so cross-engine SID identity is independent of
|
||
+ // the concrete value. Cvar-gated default-off via
|
||
+ // `event_log::is_enabled()`. Registers the SID in the global
|
||
+ // registry so the immediately-following `wait.begin` resolves a
|
||
+ // non-zero `handles_semantic_ids` element.
|
||
+ //
|
||
+ // Phase C+18: use `emit_handle_create_shared_global` so the SID is
|
||
+ // **scheduling-invariant** — depends only on `(pointer, object_type)`.
|
||
+ // The dispatcher at this pointer is process-global; whichever guest
|
||
+ // thread happens to be the first toucher synthesizes the wrapper, but
|
||
+ // which thread wins is timing-dependent. Per-thread `(tid, idx)`-keyed
|
||
+ // SIDs would diverge between canary and ours at the SID level; the
|
||
+ // diff tool also uses SID equality to cross-tid match the floating
|
||
+ // `handle.create` event when the first-toucher is a different tid in
|
||
+ // each engine. See `event_log::semantic_id_shared_global` and the
|
||
+ // C+18 memory entry / schema-v1.md §"Shared-global SIDs".
|
||
+ if crate::event_log::is_enabled() {
|
||
+ let (tid, cycle) = if let Some(r) = state.scheduler.current {
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ } else {
|
||
+ (0u32, 0u64)
|
||
+ };
|
||
+ crate::event_log::emit_handle_create_shared_global(
|
||
+ tid,
|
||
+ cycle,
|
||
+ object_type,
|
||
+ ptr,
|
||
+ /* object_name */ None,
|
||
+ );
|
||
+ }
|
||
}
|
||
|
||
/// Set `gpr[3]` on a just-woken HW thread to reflect which handle in its
|
||
@@ -3717,54 +4784,76 @@ fn ke_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState
|
||
// for why we need the lazy-shadow step here.
|
||
let h = ctx.gpr[3] as u32;
|
||
ensure_dispatcher_object(state, mem, h);
|
||
- let previous = match state.objects.get_mut(&h) {
|
||
+ // Canary parity (xevent.cc:60-64): `XEvent::Set` returns constant `1`
|
||
+ // on success, NOT the prior signaled state as the NT contract claims.
|
||
+ // We compute `previous` for internal bookkeeping (audit_signal,
|
||
+ // wake_eligible_waiters honor the prior-state read), but report
|
||
+ // `1` for success / `0` for "no dispatcher found" to match the
|
||
+ // canary Phase A oracle. See Phase C+7 investigation.md.
|
||
+ let (previous, found) = match state.objects.get_mut(&h) {
|
||
Some(KernelObject::Event { signaled, .. }) => {
|
||
let prev = *signaled;
|
||
*signaled = true;
|
||
- prev as u32
|
||
+ (prev as u32, true)
|
||
}
|
||
- _ => 0,
|
||
+ _ => (0u32, false),
|
||
};
|
||
state.audit_signal(h, ctx.lr as u32, "KeSetEvent", previous as u64);
|
||
wake_eligible_waiters(state, h);
|
||
- ctx.gpr[3] = previous as u64;
|
||
+ ctx.gpr[3] = if found { 1 } else { 0 };
|
||
}
|
||
|
||
fn ke_reset_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
+ // r3 = PKEVENT on Ke* (guest pointer). See `ensure_dispatcher_object`
|
||
+ // for the lazy-shadow step.
|
||
let h = ctx.gpr[3] as u32;
|
||
ensure_dispatcher_object(state, mem, h);
|
||
- let previous = match state.objects.get_mut(&h) {
|
||
+ // Canary parity (xevent.cc:72-75): `XEvent::Reset` returns constant `1`
|
||
+ // on success — exact sibling of `XEvent::Set`. The NT contract claims
|
||
+ // the prior signaled state, but canary hardcodes `1` and the game
|
||
+ // observes that value via Phase A oracle at idx=102164. Sibling fix
|
||
+ // of Phase C+7 KeSetEvent (xevent.cc:60-64). The `assert_always;
|
||
+ // return 0` arm is preserved (no shadow → 0).
|
||
+ let (previous, found) = match state.objects.get_mut(&h) {
|
||
Some(KernelObject::Event { signaled, .. }) => {
|
||
let prev = *signaled;
|
||
*signaled = false;
|
||
- prev as u32
|
||
+ (prev as u32, true)
|
||
}
|
||
- _ => 0,
|
||
+ _ => (0u32, false),
|
||
};
|
||
- ctx.gpr[3] = previous as u64;
|
||
+ state.audit_signal(h, ctx.lr as u32, "KeResetEvent", previous as u64);
|
||
+ ctx.gpr[3] = if found { 1 } else { 0 };
|
||
}
|
||
|
||
fn nt_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
- let handle = ctx.gpr[3] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source so signal-on-dup wakes
|
||
+ // wait-on-source (AUDIT-062 invariant).
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
let prev_ptr = ctx.gpr[4] as u32;
|
||
- let previous = match state.objects.get_mut(&handle) {
|
||
+ // Canary parity (xboxkrnl_threading.cc:610-628): the optional out-pointer
|
||
+ // is filled with `was_signalled` = `ev->Set()` = constant 1 (see
|
||
+ // xevent.cc:60-64), NOT the prior signaled state. r3 carries
|
||
+ // STATUS_SUCCESS. We retain `previous` for internal audit/wake plumbing.
|
||
+ let (previous, found) = match state.objects.get_mut(&handle) {
|
||
Some(KernelObject::Event { signaled, .. }) => {
|
||
let prev = *signaled;
|
||
*signaled = true;
|
||
- prev as u32
|
||
+ (prev as u32, true)
|
||
}
|
||
- _ => 0,
|
||
+ _ => (0u32, false),
|
||
};
|
||
state.audit_signal(handle, ctx.lr as u32, "NtSetEvent", previous as u64);
|
||
wake_eligible_waiters(state, handle);
|
||
- if prev_ptr != 0 {
|
||
- mem.write_u32(prev_ptr, previous);
|
||
+ if prev_ptr != 0 && found {
|
||
+ mem.write_u32(prev_ptr, 1);
|
||
}
|
||
ctx.gpr[3] = STATUS_SUCCESS;
|
||
}
|
||
|
||
fn nt_clear_event(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
|
||
- let handle = ctx.gpr[3] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&handle) {
|
||
*signaled = false;
|
||
}
|
||
@@ -4015,10 +5104,53 @@ fn nt_wait_for_single_object_ex(
|
||
) {
|
||
// r3 = handle, r4 = wait_mode, r5 = alertable, r6 = timeout_ptr
|
||
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
|
||
+ let alertable = ctx.gpr[5] != 0;
|
||
let timeout_ptr = ctx.gpr[6] as u32;
|
||
+ // Phase C+15-α: schema-v1 `wait.begin` event. Emitted BEFORE
|
||
+ // `do_wait_single` to surface the wait initiation regardless of
|
||
+ // synchronous vs. parked outcome. `wait.end` is deferred (the
|
||
+ // synchronous status is already captured in the
|
||
+ // immediately-following `kernel.return`). Canary's symmetric emit
|
||
+ // is at `NtWaitForSingleObjectEx_entry` body.
|
||
+ if crate::event_log::is_enabled() {
|
||
+ let timeout_ns = decode_timeout_ns(mem, timeout_ptr);
|
||
+ let sid = crate::event_log::lookup_handle_semantic_id(handle);
|
||
+ let (tid, cycle) = {
|
||
+ let r = state.scheduler.current_ref();
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ };
|
||
+ crate::event_log::emit_wait_begin(
|
||
+ tid,
|
||
+ cycle,
|
||
+ &[sid],
|
||
+ timeout_ns,
|
||
+ alertable,
|
||
+ /* wait_all */ false,
|
||
+ );
|
||
+ }
|
||
do_wait_single(ctx, state, handle, timeout_ptr, mem);
|
||
}
|
||
|
||
+/// Phase C+15-α helper: decode a TIMEOUT* big-endian i64 to ns for the
|
||
+/// schema-v1 `wait.begin` payload. `timeout_ptr == 0` → INFINITE
|
||
+/// (encoded as -1 per schema). NT TIMEOUT units are 100ns. Negative
|
||
+/// values are relative (timeout from now); positive values are
|
||
+/// absolute deadlines. For simplicity (and to mirror canary's
|
||
+/// emission), we report the **raw** ticks unscaled; the diff tool
|
||
+/// only compares values, not their meaning. Encoding into ns matches
|
||
+/// schema-v1 field name; precise unit-conversion isn't required for
|
||
+/// cross-engine equality.
|
||
+fn decode_timeout_ns(mem: &GuestMemory, timeout_ptr: u32) -> i64 {
|
||
+ if timeout_ptr == 0 {
|
||
+ return -1;
|
||
+ }
|
||
+ let raw = mem.read_u64(timeout_ptr) as i64;
|
||
+ // NT TIMEOUT is 100ns ticks. Convert to ns; saturating to avoid
|
||
+ // wraparound on extreme values.
|
||
+ raw.saturating_mul(100)
|
||
+}
|
||
+
|
||
/// `NtSignalAndWaitForSingleObjectEx(signal_handle, wait_handle, wait_mode,
|
||
/// alertable, timeout_ptr)` — atomically signal one kernel object and wait on
|
||
/// another. Matches Canary's `NtSignalAndWaitForSingleObjectEx_entry`
|
||
@@ -4083,7 +5215,27 @@ fn ke_wait_for_single_object(
|
||
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
|
||
ensure_dispatcher_object(state, mem, handle);
|
||
refresh_pkevent_shadow_from_guest(state, mem, handle);
|
||
+ let alertable = ctx.gpr[6] != 0;
|
||
let timeout_ptr = ctx.gpr[7] as u32;
|
||
+ // Phase C+15-α: schema-v1 `wait.begin` event. Symmetric counterpart
|
||
+ // in canary at `xeKeWaitForSingleObject`.
|
||
+ if crate::event_log::is_enabled() {
|
||
+ let timeout_ns = decode_timeout_ns(mem, timeout_ptr);
|
||
+ let sid = crate::event_log::lookup_handle_semantic_id(handle);
|
||
+ let (tid, cycle) = {
|
||
+ let r = state.scheduler.current_ref();
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ };
|
||
+ crate::event_log::emit_wait_begin(
|
||
+ tid,
|
||
+ cycle,
|
||
+ &[sid],
|
||
+ timeout_ns,
|
||
+ alertable,
|
||
+ /* wait_all */ false,
|
||
+ );
|
||
+ }
|
||
do_wait_single(ctx, state, handle, timeout_ptr, mem);
|
||
}
|
||
|
||
@@ -4173,7 +5325,9 @@ fn ke_resume_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kernel
|
||
|
||
fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
// r3 = handle, r4 = prev_suspend_count_ptr
|
||
- let handle = ctx.gpr[3] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source so a duplicated thread
|
||
+ // handle (rare but legal) still resolves to the scheduler entry.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
let prev_ptr = ctx.gpr[4] as u32;
|
||
let prev = state
|
||
.scheduler
|
||
@@ -4188,7 +5342,8 @@ fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
|
||
|
||
fn nt_suspend_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
// r3 = handle, r4 = prev_suspend_count_ptr
|
||
- let handle = ctx.gpr[3] as u32;
|
||
+ // Phase C+19: canonicalize dup ids → source.
|
||
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||
let prev_ptr = ctx.gpr[4] as u32;
|
||
let prev = state
|
||
.scheduler
|
||
@@ -4250,10 +5405,22 @@ fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Ke
|
||
/// * r4 = new_handle_ptr (if zero, the call is actually a close)
|
||
/// * r5 = options (bit 0 = DUPLICATE_CLOSE_SOURCE)
|
||
///
|
||
-/// Canary allocates a fresh handle id that refcounts the same underlying
|
||
-/// `XObject`. We don't refcount, so we alias: write the *source* handle back
|
||
-/// as the "new" handle. The game then uses it interchangeably, and both ids
|
||
-/// resolve to the same `KernelObject` entry.
|
||
+/// Canary's `ObjectTable::DuplicateHandle` (object_table.cc:210-223) allocates
|
||
+/// a fresh slot via `AddHandle` (which retains the underlying `XObject` and
|
||
+/// emits `handle.create`), returning the new slot id. Both source and dup
|
||
+/// slots independently refcount the same `XObject`; closing one decrements
|
||
+/// the slot's local count and, when zero, removes that slot. The underlying
|
||
+/// object dies only when the last slot is gone.
|
||
+///
|
||
+/// Phase C+19: ours mirrors this. Pre-C+19 we aliased `dup_id == source_id`
|
||
+/// to avoid maintaining a separate refcount across distinct ids; AUDIT-062
|
||
+/// verified the wedge-case (signal-on-dup wakes wait-on-source) worked
|
||
+/// because the ids collided into the same `state.objects` entry. Allocating
|
||
+/// a fresh id surfaces the canary-symmetric `handle.create` Phase A event
|
||
+/// at main idx=102553; the AUDIT-062 invariant is preserved by routing
|
||
+/// every Nt*/Ke* lookup through `state.resolve_handle` which canonicalizes
|
||
+/// the dup id back to the source — both ids still hit the same `KernelObject`
|
||
+/// with the same `waiters` list and `signaled` flag.
|
||
///
|
||
/// A prior `stub_success` left `*new_handle_ptr` uninitialized — Sylpheed's
|
||
/// thread-dispatch prologue does `NtDuplicateObject(event, &dup)` then passes
|
||
@@ -4261,34 +5428,70 @@ fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Ke
|
||
/// completion. With the stub, `dup` was stack garbage → set-event lookup
|
||
/// failed silently → main thread blocked forever on the source event.
|
||
fn nt_duplicate_object(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||
- let source = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
|
||
+ let raw_source = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
|
||
+ // The guest may itself pass a dup id — canonicalize before validation
|
||
+ // so we always alias against the live `state.objects` entry.
|
||
+ let canonical = state.resolve_handle(raw_source);
|
||
let out_ptr = ctx.gpr[4] as u32;
|
||
let options = ctx.gpr[5] as u32;
|
||
+ const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001;
|
||
|
||
- if !state.objects.contains_key(&source) {
|
||
+ if !state.objects.contains_key(&canonical) {
|
||
if out_ptr != 0 {
|
||
mem.write_u32(out_ptr, 0);
|
||
}
|
||
ctx.gpr[3] = STATUS_INVALID_HANDLE;
|
||
return;
|
||
}
|
||
- if out_ptr != 0 {
|
||
- mem.write_u32(out_ptr, source);
|
||
- }
|
||
- // Aliased-handle refcount: since we return the source handle as the "new"
|
||
- // handle (no fresh id), every duplicate must bump the per-handle refcount
|
||
- // so the later `NtClose` pair (one for source, one for dup) doesn't
|
||
- // destroy the object mid-flight. `DUPLICATE_CLOSE_SOURCE` (bit 0) closes
|
||
- // the source in Canary (xboxkrnl_ob.cc:389), so in our aliased model the
|
||
- // source-close cancels the dup-gain: net refcount is unchanged. Without
|
||
- // `CLOSE_SOURCE`, both the source and the dup are separately live and we
|
||
- // need +1.
|
||
- const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001;
|
||
- if options & DUPLICATE_CLOSE_SOURCE == 0
|
||
- && let Some(c) = state.handle_refcount.get_mut(&source)
|
||
+
|
||
+ // Allocate a fresh slot id. Canonical refcount bumps by one slot;
|
||
+ // the dup slot starts with a single local NtClose owed to it.
|
||
+ let dup_id = state.alloc_handle();
|
||
+ state.handle_aliases.insert(dup_id, canonical);
|
||
+ state.handle_refcount.insert(dup_id, 1);
|
||
+ *state.canonical_slot_count.entry(canonical).or_insert(0) += 1;
|
||
+
|
||
+ // Phase C+15-α schema-v1 `handle.create` event. Canary's symmetric path
|
||
+ // is `ObjectTable::AddHandle` (object_table.cc:198-204) which emits
|
||
+ // when called from inside `DuplicateHandle`. SID recipe = per-tid
|
||
+ // `(creating_tid, idx_at_creation, object_type)` — matches canary's
|
||
+ // `EmitHandleCreateAuto` exactly (event_log.cc), so the same logical
|
||
+ // dup pair produces the same SID across engines.
|
||
+ if crate::event_log::is_enabled()
|
||
+ && let Some(obj) = state.objects.get(&canonical)
|
||
{
|
||
- *c += 1;
|
||
+ let object_type = obj.schema_object_type();
|
||
+ let (tid, cycle) = {
|
||
+ let r = state.scheduler.current_ref();
|
||
+ let t = state.scheduler.thread(r);
|
||
+ (t.tid, t.ctx.timebase)
|
||
+ };
|
||
+ crate::event_log::emit_handle_create_auto(
|
||
+ tid,
|
||
+ cycle,
|
||
+ /* create_site_pc */ 0,
|
||
+ object_type,
|
||
+ dup_id,
|
||
+ /* object_name */ None,
|
||
+ );
|
||
+ }
|
||
+
|
||
+ if out_ptr != 0 {
|
||
+ mem.write_u32(out_ptr, dup_id);
|
||
}
|
||
+
|
||
+ // DUPLICATE_CLOSE_SOURCE: canary additionally calls `RemoveHandle(handle)`
|
||
+ // (xboxkrnl_ob.cc:405-408) which decrements the source slot's refcount
|
||
+ // and — if zero — destroys the source slot (but leaves the underlying
|
||
+ // object alive through the dup). We mirror by routing the source through
|
||
+ // `close_handle_internal` so the symmetric `handle.destroy(source)` event
|
||
+ // fires at the canary-equivalent boundary. Note: the source value here
|
||
+ // is `raw_source` (the id the guest passed, post pseudo-handle resolve),
|
||
+ // NOT `canonical` — the close targets the *slot* the guest named.
|
||
+ if options & DUPLICATE_CLOSE_SOURCE != 0 {
|
||
+ close_handle_internal(state, raw_source);
|
||
+ }
|
||
+
|
||
ctx.gpr[3] = STATUS_SUCCESS;
|
||
}
|
||
|
||
@@ -4344,6 +5547,28 @@ mod tests {
|
||
mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
|
||
.expect("scratch page must commit");
|
||
let mut state = KernelState::new();
|
||
+ // Phase C+11 — the default cache root is now persistent, but
|
||
+ // tests must NOT share state. Override with a per-test tmpdir
|
||
+ // (unique by PID + monotonic counter + nanos) and wipe on
|
||
+ // entry. Mirrors the pre-flip AUDIT-038 behaviour for the
|
||
+ // test harness specifically.
|
||
+ static TEST_CACHE_ID: std::sync::atomic::AtomicU64 =
|
||
+ std::sync::atomic::AtomicU64::new(0);
|
||
+ let test_id = TEST_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
+ let nanos = std::time::SystemTime::now()
|
||
+ .duration_since(std::time::UNIX_EPOCH)
|
||
+ .unwrap()
|
||
+ .subsec_nanos();
|
||
+ let test_cache = std::env::temp_dir().join(format!(
|
||
+ "xenia-rs-test-cache-{}-{}-{}",
|
||
+ std::process::id(),
|
||
+ test_id,
|
||
+ nanos
|
||
+ ));
|
||
+ // Wipe any leftover, then install.
|
||
+ let _ = std::fs::remove_dir_all(&test_cache);
|
||
+ std::fs::create_dir_all(&test_cache).expect("test cache mkdir");
|
||
+ state.set_cache_root(test_cache);
|
||
// Under per-slot runqueues, most kernel exports reach through
|
||
// `scheduler.current` — tests that exercise those paths need a
|
||
// live thread installed on slot 0 first. Older tests (file I/O
|
||
@@ -4423,12 +5648,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,20 +5670,95 @@ mod tests {
|
||
assert_eq!(r.hw_id, 5);
|
||
}
|
||
|
||
- /// Axis 5: `KeSetIdealProcessor` stores a hint on the thread
|
||
- /// without migrating it; query round-trips.
|
||
+ /// 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_ideal_processor_round_trips() {
|
||
+ fn ke_set_affinity_thread_null_out_ptr_still_succeeds() {
|
||
let (mut ctx, mut mem, mut state) = fresh();
|
||
- // Main thread handle is 0x1000.
|
||
- ctx.gpr[3] = 0x1000;
|
||
- ctx.gpr[4] = 3;
|
||
- ke_set_ideal_processor(&mut ctx, &mut mem, &mut state);
|
||
+ 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: scheduler-level ideal-processor hint round-trip via
|
||
+ /// `Scheduler::set_ideal_ref` / `ideal_ref`. The previous test
|
||
+ /// exercised `ke_set_ideal_processor` / `ke_query_ideal_processor`
|
||
+ /// which were hallucinated functions at the wrong ordinals — those
|
||
+ /// bodies were removed in Phase C+6½. The underlying scheduler
|
||
+ /// state still backs `NtSetInformationThread` info-class
|
||
+ /// `ThreadIdealProcessor`.
|
||
+ #[test]
|
||
+ fn scheduler_ideal_processor_round_trips() {
|
||
+ let (_, _, mut state) = fresh();
|
||
+ let r = state.scheduler.find_by_handle(0x1000).expect("main alive");
|
||
// Prior was 0xFF (unset sentinel).
|
||
- assert_eq!(ctx.gpr[3], 0xFF);
|
||
- ctx.gpr[3] = 0x1000;
|
||
- ke_query_ideal_processor(&mut ctx, &mut mem, &mut state);
|
||
- assert_eq!(ctx.gpr[3], 3);
|
||
+ let prev = state.scheduler.set_ideal_ref(r, 3);
|
||
+ assert_eq!(prev, 0xFF);
|
||
+ let queried = state.scheduler.ideal_ref(r);
|
||
+ assert_eq!(queried, Some(3));
|
||
+ }
|
||
+
|
||
+ /// Phase C+6½: `KeQueryInterruptTime` (ord 0x82) returns a
|
||
+ /// non-zero monotonic u64 in gpr[3]. Previously this ord was
|
||
+ /// mis-labeled `KeQueryIdealProcessor` and returned a 1-byte
|
||
+ /// processor index — guests querying the system interrupt-time
|
||
+ /// counter received the wrong value.
|
||
+ #[test]
|
||
+ fn ke_query_interrupt_time_returns_synthetic_u64() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ // Pre-clear gpr[3] so we know the function wrote it.
|
||
+ ctx.gpr[3] = 0;
|
||
+ ke_query_interrupt_time(&mut ctx, &mut mem, &mut state);
|
||
+ assert_ne!(ctx.gpr[3], 0, "interrupt time must be non-zero");
|
||
+ // Should be 64-bit (above u32::MAX) to ensure it's not
|
||
+ // truncated to a processor-index byte.
|
||
+ assert!(
|
||
+ ctx.gpr[3] > 0xFFFF_FFFF,
|
||
+ "interrupt time must occupy 64 bits, got {:#x}",
|
||
+ ctx.gpr[3]
|
||
+ );
|
||
}
|
||
|
||
/// Axis 5: `NtSetInformationThread` class `ThreadAffinityMask`
|
||
@@ -4660,6 +5969,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
|
||
@@ -5023,8 +6420,13 @@ mod tests {
|
||
write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // notification
|
||
ctx.gpr[3] = kevent_ptr as u64;
|
||
ke_reset_event(&mut ctx, &mut mem, &mut state);
|
||
- // After reset, shadow exists and is unsignaled; gpr[3] reports previous=1.
|
||
- assert_eq!(ctx.gpr[3], 1, "previous state must be reported");
|
||
+ // After reset, shadow exists and is unsignaled. Post-C+8: gpr[3]
|
||
+ // reports canary-constant `1` on hit (xevent.cc:72-75 hardcodes
|
||
+ // `return 1`), NOT the prior signaled state — same value here by
|
||
+ // coincidence (prior state happens to be 1). The
|
||
+ // `ke_reset_event_returns_constant_one_on_unsignaled_*` tests below
|
||
+ // distinguish constant-return from prior-state-return.
|
||
+ assert_eq!(ctx.gpr[3], 1, "canary parity: KeResetEvent returns constant 1 on hit");
|
||
match state.objects.get(&kevent_ptr) {
|
||
Some(KernelObject::Event { manual_reset, signaled, .. }) => {
|
||
assert!(*manual_reset, "type=0 must be manual-reset");
|
||
@@ -5117,6 +6519,95 @@ mod tests {
|
||
assert_eq!(mem.read_u32(ptr + 0x0C), 0);
|
||
}
|
||
|
||
+ /// Phase C+17: first adoption of a guest dispatcher pointer via
|
||
+ /// `ensure_dispatcher_object` must seed `handle_refcount[ptr] = 1`,
|
||
+ /// mirroring canary's `ObjectTable::AddHandle` baseline
|
||
+ /// (object_table.cc:164). Symmetric to `alloc_handle_for` which
|
||
+ /// already does this for handle-based objects.
|
||
+ #[test]
|
||
+ fn ensure_dispatcher_object_initializes_handle_refcount_for_event() {
|
||
+ let (mut _ctx, mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0x800;
|
||
+ write_dispatcher_header(&mem, kevent_ptr, 1, 0); // synchronization
|
||
+ assert!(!state.handle_refcount.contains_key(&kevent_ptr));
|
||
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
|
||
+ assert!(state.objects.contains_key(&kevent_ptr));
|
||
+ assert_eq!(
|
||
+ state.handle_refcount.get(&kevent_ptr).copied(),
|
||
+ Some(1),
|
||
+ "fresh shadow must start with refcount 1"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Same baseline for semaphores. Header type=5 picks the
|
||
+ /// Semaphore branch; refcount is independent of count/max.
|
||
+ #[test]
|
||
+ fn ensure_dispatcher_object_initializes_handle_refcount_for_semaphore() {
|
||
+ let (mut _ctx, mem, mut state) = fresh();
|
||
+ let sem_ptr = SCRATCH_BASE + 0x820;
|
||
+ write_dispatcher_header(&mem, sem_ptr, 5, 0);
|
||
+ mem.write_u32(sem_ptr + 0x10, 4); // Limit=4
|
||
+ ensure_dispatcher_object(&mut state, &mem, sem_ptr);
|
||
+ assert!(matches!(state.objects.get(&sem_ptr), Some(KernelObject::Semaphore { .. })));
|
||
+ assert_eq!(state.handle_refcount.get(&sem_ptr).copied(), Some(1));
|
||
+ }
|
||
+
|
||
+ /// Re-entry on the same pointer is a no-op: the early-return guard
|
||
+ /// at the top of `ensure_dispatcher_object` (contains_key check)
|
||
+ /// must NOT double-bump the refcount. Mirrors canary's
|
||
+ /// `kXObjSignature` short-circuit (xobject.cc:421-427).
|
||
+ #[test]
|
||
+ fn ensure_dispatcher_object_is_idempotent_on_repeated_touch() {
|
||
+ let (mut _ctx, mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0x840;
|
||
+ write_dispatcher_header(&mem, kevent_ptr, 0, 0); // notification
|
||
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
|
||
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
|
||
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
|
||
+ assert_eq!(
|
||
+ state.handle_refcount.get(&kevent_ptr).copied(),
|
||
+ Some(1),
|
||
+ "repeated ensure must not bump refcount"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Two distinct native pointers each get their own shadow and
|
||
+ /// their own refcount entry. Canary's `GetNativeObject` lazy-wraps
|
||
+ /// each dispatcher independently — there's no shared XObject for
|
||
+ /// distinct guest pointers.
|
||
+ #[test]
|
||
+ fn ensure_dispatcher_object_distinct_ptrs_get_distinct_refcount_entries() {
|
||
+ let (mut _ctx, mem, mut state) = fresh();
|
||
+ let a = SCRATCH_BASE + 0x860;
|
||
+ let b = SCRATCH_BASE + 0x880;
|
||
+ write_dispatcher_header(&mem, a, 1, 0);
|
||
+ write_dispatcher_header(&mem, b, 5, 0);
|
||
+ mem.write_u32(b + 0x10, 2);
|
||
+ ensure_dispatcher_object(&mut state, &mem, a);
|
||
+ ensure_dispatcher_object(&mut state, &mem, b);
|
||
+ assert_eq!(state.handle_refcount.get(&a).copied(), Some(1));
|
||
+ assert_eq!(state.handle_refcount.get(&b).copied(), Some(1));
|
||
+ assert!(matches!(state.objects.get(&a), Some(KernelObject::Event { .. })));
|
||
+ assert!(matches!(state.objects.get(&b), Some(KernelObject::Semaphore { .. })));
|
||
+ }
|
||
+
|
||
+ /// Unsupported dispatcher types (e.g., Mutant type=2 — canary's
|
||
+ /// `GetNativeObject` `assert_always`s on them) must leave both
|
||
+ /// `state.objects` AND `state.handle_refcount` untouched. The
|
||
+ /// early-return after the match guard prevents both insertions.
|
||
+ #[test]
|
||
+ fn ensure_dispatcher_object_unknown_type_does_not_touch_refcount() {
|
||
+ let (mut _ctx, mem, mut state) = fresh();
|
||
+ let ptr = SCRATCH_BASE + 0x8A0;
|
||
+ write_dispatcher_header(&mem, ptr, 2, 0); // Mutant — unsupported
|
||
+ ensure_dispatcher_object(&mut state, &mem, ptr);
|
||
+ assert!(!state.objects.contains_key(&ptr));
|
||
+ assert!(
|
||
+ !state.handle_refcount.contains_key(&ptr),
|
||
+ "no refcount entry for unsupported dispatcher type"
|
||
+ );
|
||
+ }
|
||
+
|
||
/// Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first
|
||
/// adoption of a guest dispatcher, +0x08 must hold the 'X','E','N','\0'
|
||
/// fourcc and +0x0C must hold the stash handle.
|
||
@@ -6215,6 +7706,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;
|
||
@@ -6335,22 +7834,1537 @@ mod tests {
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
- /// `resolve_cache_path` rejects path-traversal attempts so a guest
|
||
- /// can't escape the cache directory by passing `cache:\..\..\etc\foo`.
|
||
+ /// Phase C+11 Stage 2 — when a `cache:\<name>` file already exists
|
||
+ /// on disk as a regular file, re-opening it with the
|
||
+ /// `FILE_DIRECTORY_FILE` bit set MUST still route through the file
|
||
+ /// branch (host_path = Some) — the on-disk type wins. Pre-fix:
|
||
+ /// `is_dir_open = want_dir || host_path.is_dir()` would force
|
||
+ /// re-opens with bit 0x1 set into the dir branch, dropping
|
||
+ /// host_path and blocking subsequent class-10 renames.
|
||
#[test]
|
||
- fn cache_resolve_strips_path_traversal() {
|
||
- let dir = std::env::temp_dir().join(format!(
|
||
- "xenia-rs-cache-test-trav-{}",
|
||
- std::process::id()
|
||
- ));
|
||
- std::fs::create_dir_all(&dir).unwrap();
|
||
- let mut state = KernelState::new();
|
||
- state.init_cache_root(dir.clone()).unwrap();
|
||
- let resolved = state
|
||
- .resolve_cache_path("cache:\\..\\..\\etc\\foo")
|
||
- .expect("must resolve");
|
||
- assert!(resolved.starts_with(&dir), "must stay inside cache root");
|
||
- assert!(resolved.ends_with("etc/foo"));
|
||
- std::fs::remove_dir_all(&dir).ok();
|
||
+ fn cache_existing_file_wins_over_directory_bit() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ let cache_root = state.cache_root.clone().unwrap();
|
||
+
|
||
+ // 1. FILE_CREATE without DIRECTORY bit → produces a real file.
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp");
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ 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;
|
||
+ ctx.gpr[10] = FILE_CREATE as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ assert!(cache_root.join("foo.tmp").is_file());
|
||
+
|
||
+ // 2. Re-open with FILE_DIRECTORY_FILE bit set in r7.
|
||
+ // open_options bit 0x1 = FILE_DIRECTORY_FILE.
|
||
+ // open_options bit 0x20 = FILE_SYNCHRONOUS_IO_NONALERT (keeps
|
||
+ // the handle synchronous so NtWriteFile returns STATUS_SUCCESS).
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[7] = (0x1 | FILE_SYNCHRONOUS_IO_NONALERT) as u64;
|
||
+ nt_open_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ let handle = mem.read_u32(handle_out);
|
||
+
|
||
+ // 3. The re-opened handle MUST be a file handle with a real
|
||
+ // host_path, not a directory handle with host_path=None.
|
||
+ let obj = state.objects.get(&handle).expect("handle must exist");
|
||
+ match obj {
|
||
+ KernelObject::File { host_path, path, .. } => {
|
||
+ assert!(
|
||
+ host_path.is_some(),
|
||
+ "existing file re-open must keep host_path (got None) — bug #2 regression"
|
||
+ );
|
||
+ assert!(
|
||
+ !path.ends_with('/'),
|
||
+ "existing file re-open path must NOT have trailing '/' (got dir-shape) — bug #2 regression"
|
||
+ );
|
||
+ }
|
||
+ _ => panic!("expected File kernel object"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Phase C+11 Stage 2 — `cache:\access`, `cache:\ignore`, and
|
||
+ /// `cache:\recent` are TOP-LEVEL files in canary's cache (per
|
||
+ /// the canary-cache-listing.csv enumeration). Cold creation
|
||
+ /// through ours should produce files, not directories.
|
||
+ #[test]
|
||
+ fn cache_top_level_manifests_create_as_files() {
|
||
+ for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ let cache_root = state.cache_root.clone().unwrap();
|
||
+ let leaf_name = path_str.strip_prefix("cache:\\").unwrap();
|
||
+
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str);
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
|
||
+ // Set FILE_NON_DIRECTORY_FILE explicitly so Sylpheed-style
|
||
+ // create paths produce host files. (If Sylpheed sets the
|
||
+ // DIRECTORY bit but no NON_DIRECTORY bit, the pre-fix code
|
||
+ // would mis-create as dirs; this test pins the
|
||
+ // bit-conflict-resolution policy.)
|
||
+ mem.write_u32(
|
||
+ SCRATCH_BASE + 0x700 + 0x54,
|
||
+ FILE_SYNCHRONOUS_IO_NONALERT | 0x40, // | FILE_NON_DIRECTORY_FILE
|
||
+ );
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_CREATE as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_SUCCESS,
|
||
+ "FILE_CREATE on {} must succeed",
|
||
+ path_str
|
||
+ );
|
||
+ assert!(
|
||
+ cache_root.join(leaf_name).is_file(),
|
||
+ "cache:\\{} must be a host file (got: dir or absent)",
|
||
+ leaf_name
|
||
+ );
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Phase C+11.1 — Sylpheed's cold-boot probe pattern: open
|
||
+ /// `cache:\access` / `cache:\ignore` / `cache:\recent` with
|
||
+ /// disp=1 (FILE_OPEN) + opts=0x7 (DIRECTORY_FILE | WRITE_THROUGH
|
||
+ /// | SEQUENTIAL_ONLY) MUST return `STATUS_OBJECT_NAME_NOT_FOUND`
|
||
+ /// and MUST NOT create a host directory. Pre-fix the
|
||
+ /// `is_dir_open` branch unconditionally mkdir-p'd whenever
|
||
+ /// `want_dir`, which produced spurious `access`/`ignore`/`recent`
|
||
+ /// directories that then occluded later `disp=5 NON_DIRECTORY`
|
||
+ /// re-creates Sylpheed uses to populate the manifests.
|
||
+ /// Mirrors canary's `VirtualFileSystem::OpenFile`
|
||
+ /// (virtual_file_system.cc:265-273) which returns
|
||
+ /// `X_STATUS_OBJECT_NAME_NOT_FOUND` for `kOpen` on missing path,
|
||
+ /// regardless of `is_directory`.
|
||
+ #[test]
|
||
+ fn cache_open_directory_on_missing_path_returns_not_found() {
|
||
+ for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ let cache_root = state.cache_root.clone().unwrap();
|
||
+ let leaf_name = path_str.strip_prefix("cache:\\").unwrap();
|
||
+
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str);
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
|
||
+ // Sylpheed's exact cold-boot bit pattern: FILE_DIRECTORY_FILE
|
||
+ // (0x1) | FILE_WRITE_THROUGH (0x2) | FILE_SEQUENTIAL_ONLY (0x4)
|
||
+ // = 0x7. Slot offset 0x54 per the `nt_create_file`
|
||
+ // arg-marshalling.
|
||
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7);
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_OPEN as u64;
|
||
+ // Clear any pre-existing handle slot so the assert is honest.
|
||
+ mem.write_u32(handle_out, 0xDEAD_BEEF);
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND,
|
||
+ "FILE_OPEN+DIR on missing {} must return NOT_FOUND",
|
||
+ path_str
|
||
+ );
|
||
+ assert_eq!(
|
||
+ mem.read_u32(handle_out),
|
||
+ 0,
|
||
+ "no handle on cold-boot dir-open miss for {}",
|
||
+ path_str
|
||
+ );
|
||
+ assert!(
|
||
+ !cache_root.join(leaf_name).exists(),
|
||
+ "{} must NOT be created on disk by a non-create disp",
|
||
+ leaf_name
|
||
+ );
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Phase C+11.1 — after the cold-boot NOT_FOUND probe (see
|
||
+ /// `cache_open_directory_on_missing_path_returns_not_found`),
|
||
+ /// Sylpheed re-issues `disp=FILE_OVERWRITE_IF (5)` with
|
||
+ /// `FILE_NON_DIRECTORY_FILE` set. That second call MUST produce
|
||
+ /// a regular file, not a directory. This pins the two-call
|
||
+ /// sequence canary actually executes on cold boot.
|
||
+ #[test]
|
||
+ fn cache_disp5_after_disp1_miss_creates_file() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ let cache_root = state.cache_root.clone().unwrap();
|
||
+
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\access");
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
|
||
+
|
||
+ // 1) Cold disp=1 + opts=0x7 → NOT_FOUND, no host-side entry.
|
||
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7);
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_OPEN as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND);
|
||
+ assert!(!cache_root.join("access").exists());
|
||
+
|
||
+ // 2) disp=5 + opts=0x60 (FILE_NON_DIRECTORY_FILE |
|
||
+ // FILE_SYNCHRONOUS_IO_NONALERT) → FILE created.
|
||
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x60);
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ assert!(
|
||
+ cache_root.join("access").is_file(),
|
||
+ "disp=5 with NON_DIRECTORY on cache:\\access must produce a host FILE"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Phase C+11 — write a `cache:\<H1><H2>.tmp` flat journal, then
|
||
+ /// rename it to the hierarchical leaf `cache:\<H1>\<X>\<H2>` via
|
||
+ /// NtSetInformationFile class 10 (XFileRenameInformation). After the
|
||
+ /// rename, the flat file must be gone and the leaf must contain the
|
||
+ /// original bytes. This is the .tmp-to-leaf promotion that Sylpheed
|
||
+ /// relies on for cache build.
|
||
+ #[test]
|
||
+ fn cache_rename_information_promotes_tmp_to_leaf() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+
|
||
+ // Create cache:\foo.tmp with FILE_CREATE.
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp");
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ 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;
|
||
+ ctx.gpr[10] = FILE_CREATE as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ let handle = mem.read_u32(handle_out);
|
||
+
|
||
+ // Write 4 bytes.
|
||
+ let write_buf = SCRATCH_BASE + 0x400;
|
||
+ for (i, b) in b"abcd".iter().enumerate() {
|
||
+ mem.write_u8(write_buf + i as u32, *b);
|
||
+ }
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ ctx.gpr[4] = 0;
|
||
+ ctx.gpr[7] = iosb as u64;
|
||
+ ctx.gpr[8] = write_buf as u64;
|
||
+ ctx.gpr[9] = 4;
|
||
+ ctx.gpr[10] = 0;
|
||
+ nt_write_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+
|
||
+ // Confirm the flat .tmp exists.
|
||
+ let cache_root = state.cache_root.clone().expect("must have cache root");
|
||
+ assert!(cache_root.join("foo.tmp").exists(), ".tmp must exist pre-rename");
|
||
+ assert!(!cache_root.join("bar").exists(), "leaf must NOT exist yet");
|
||
+
|
||
+ // Build XFileRenameInformation buffer at SCRATCH_BASE+0x500:
|
||
+ // offset 0: be<u32> replace_existing = 1
|
||
+ // offset 4: be<u32> root_dir_handle = 0
|
||
+ // offset 8: ANSI_STRING { Length, MaxLength, BufferPtr }
|
||
+ // offset 16: path bytes
|
||
+ let info_buf = SCRATCH_BASE + 0x500;
|
||
+ let target = "cache:\\bar";
|
||
+ mem.write_u32(info_buf, 1); // replace_existing
|
||
+ mem.write_u32(info_buf + 4, 0); // root_dir_handle
|
||
+ mem.write_u16(info_buf + 8, target.len() as u16); // ANSI_STRING.Length
|
||
+ mem.write_u16(info_buf + 10, target.len() as u16); // ANSI_STRING.MaxLength
|
||
+ mem.write_u32(info_buf + 12, info_buf + 16); // ANSI_STRING.Buffer
|
||
+ for (i, b) in target.bytes().enumerate() {
|
||
+ mem.write_u8(info_buf + 16 + i as u32, b);
|
||
+ }
|
||
+
|
||
+ // NtSetInformationFile class 10 (rename).
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ ctx.gpr[4] = iosb as u64;
|
||
+ ctx.gpr[5] = info_buf as u64;
|
||
+ ctx.gpr[6] = 16 + target.len() as u64; // info_length
|
||
+ ctx.gpr[7] = 10; // info_class = XFileRenameInformation
|
||
+ nt_set_information_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "rename must succeed");
|
||
+
|
||
+ // After rename: .tmp gone, leaf present with the original bytes.
|
||
+ assert!(!cache_root.join("foo.tmp").exists(), ".tmp must be gone");
|
||
+ assert!(cache_root.join("bar").exists(), "leaf must exist");
|
||
+ assert_eq!(
|
||
+ std::fs::read(cache_root.join("bar")).unwrap(),
|
||
+ b"abcd",
|
||
+ "leaf must have the original bytes"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Phase C+11 — rename also creates intermediate parent directories
|
||
+ /// (Sylpheed's leaf paths are `cache:\<H1>\<X>\<H2>` form; a
|
||
+ /// host-fs `rename` would fail without `create_dir_all` on parent).
|
||
+ #[test]
|
||
+ fn cache_rename_creates_parent_directories() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+
|
||
+ // Create cache:\src.tmp.
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\src.tmp");
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ 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;
|
||
+ ctx.gpr[10] = FILE_CREATE as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ let handle = mem.read_u32(handle_out);
|
||
+
|
||
+ // Rename to cache:\d4ea4615\e\46ee8ca (depth-3 hierarchical leaf).
|
||
+ let info_buf = SCRATCH_BASE + 0x500;
|
||
+ let target = "cache:\\d4ea4615\\e\\46ee8ca";
|
||
+ mem.write_u32(info_buf, 1);
|
||
+ mem.write_u32(info_buf + 4, 0);
|
||
+ mem.write_u16(info_buf + 8, target.len() as u16);
|
||
+ mem.write_u16(info_buf + 10, target.len() as u16);
|
||
+ mem.write_u32(info_buf + 12, info_buf + 16);
|
||
+ for (i, b) in target.bytes().enumerate() {
|
||
+ mem.write_u8(info_buf + 16 + i as u32, b);
|
||
+ }
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ ctx.gpr[4] = iosb as u64;
|
||
+ ctx.gpr[5] = info_buf as u64;
|
||
+ ctx.gpr[6] = 16 + target.len() as u64;
|
||
+ ctx.gpr[7] = 10;
|
||
+ nt_set_information_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+
|
||
+ let cache_root = state.cache_root.clone().unwrap();
|
||
+ assert!(cache_root.join("d4ea4615/e/46ee8ca").exists());
|
||
+ }
|
||
+
|
||
+ /// Phase C+11 — rename of a non-existent / closed handle returns
|
||
+ /// STATUS_INVALID_HANDLE (canary parity).
|
||
+ #[test]
|
||
+ fn cache_rename_invalid_handle_returns_status() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ let info_buf = SCRATCH_BASE + 0x500;
|
||
+ let target = "cache:\\target";
|
||
+ mem.write_u32(info_buf, 1);
|
||
+ mem.write_u32(info_buf + 4, 0);
|
||
+ mem.write_u16(info_buf + 8, target.len() as u16);
|
||
+ mem.write_u16(info_buf + 10, target.len() as u16);
|
||
+ mem.write_u32(info_buf + 12, info_buf + 16);
|
||
+ for (i, b) in target.bytes().enumerate() {
|
||
+ mem.write_u8(info_buf + 16 + i as u32, b);
|
||
+ }
|
||
+ ctx.gpr[3] = 0xDEADBEEF; // bogus handle
|
||
+ ctx.gpr[4] = 0;
|
||
+ ctx.gpr[5] = info_buf as u64;
|
||
+ ctx.gpr[6] = 16 + target.len() as u64;
|
||
+ ctx.gpr[7] = 10;
|
||
+ nt_set_information_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
|
||
+ }
|
||
+
|
||
+ /// Phase C+12 — helper. Pins the wire-format of
|
||
+ /// `X_FILE_NETWORK_OPEN_INFORMATION` produced by
|
||
+ /// `nt_query_full_attributes_file`. Issues the query for `path` and
|
||
+ /// asserts the 8-DWord OUT struct fields (all big-endian).
|
||
+ fn assert_query_attrs_struct(
|
||
+ state: &mut KernelState,
|
||
+ mem: &GuestMemory,
|
||
+ path: &str,
|
||
+ expected_attrs: u32,
|
||
+ expected_size: u64,
|
||
+ ) -> u64 {
|
||
+ let mut ctx = PpcContext::default();
|
||
+ let obj_attrs = write_obj_attrs(mem, SCRATCH_BASE + 0x100, path);
|
||
+ let out = SCRATCH_BASE + 0x300;
|
||
+ for off in (0..56).step_by(4) {
|
||
+ mem.write_u32(out + off as u32, 0xCDCD_CDCD);
|
||
+ }
|
||
+ ctx.gpr[3] = obj_attrs as u64;
|
||
+ ctx.gpr[4] = out as u64;
|
||
+ nt_query_full_attributes_file(&mut ctx, mem, state);
|
||
+ let status = ctx.gpr[3];
|
||
+ if status == STATUS_SUCCESS {
|
||
+ assert_eq!(
|
||
+ mem.read_u32(out + 48),
|
||
+ expected_attrs,
|
||
+ "FileAttributes mismatch at {}",
|
||
+ path
|
||
+ );
|
||
+ assert_eq!(
|
||
+ mem.read_u64(out + 40),
|
||
+ expected_size,
|
||
+ "EndOfFile mismatch at {}",
|
||
+ path
|
||
+ );
|
||
+ assert_eq!(
|
||
+ mem.read_u32(out + 52),
|
||
+ 0,
|
||
+ "Reserved field must be zero at {}",
|
||
+ path
|
||
+ );
|
||
+ // AllocationSize == round_up(size, 512)
|
||
+ let expected_alloc = (expected_size + 511) & !511;
|
||
+ assert_eq!(
|
||
+ mem.read_u64(out + 32),
|
||
+ expected_alloc,
|
||
+ "AllocationSize mismatch at {}",
|
||
+ path
|
||
+ );
|
||
+ }
|
||
+ status
|
||
+ }
|
||
+
|
||
+ /// Phase C+12 — `nt_query_full_attributes_file` returns
|
||
+ /// `STATUS_NO_SUCH_FILE` for a path that's never been created.
|
||
+ /// Mirrors canary's `NtQueryFullAttributesFile_entry` returning
|
||
+ /// `X_STATUS_NO_SUCH_FILE` when `ResolvePath` returns null
|
||
+ /// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:512`).
|
||
+ #[test]
|
||
+ fn nt_query_full_attributes_file_missing_returns_no_such_file() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let status =
|
||
+ assert_query_attrs_struct(&mut state, &mem, "cache:\\never_existed", 0, 0);
|
||
+ assert_eq!(status, STATUS_NO_SUCH_FILE);
|
||
+ }
|
||
+
|
||
+ /// Phase C+12 — after `NtCreateFile cache:\foo` succeeds (which
|
||
+ /// canary's `Entry::CreateEntry` populates the in-memory tree),
|
||
+ /// a follow-up `NtQueryFullAttributesFile` MUST resolve from the
|
||
+ /// in-memory mirror and return SUCCESS with
|
||
+ /// `FILE_ATTRIBUTE_NORMAL` (0x80) for a regular file.
|
||
+ #[test]
|
||
+ fn nt_query_full_attributes_file_after_create_returns_normal() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ // Create cache:\foo with FILE_OVERWRITE_IF (creates if missing).
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo");
|
||
+ let handle_out = SCRATCH_BASE + 0x400;
|
||
+ let iosb = SCRATCH_BASE + 0x410;
|
||
+ 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;
|
||
+ ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ // Now query.
|
||
+ let status = assert_query_attrs_struct(
|
||
+ &mut state,
|
||
+ &mem,
|
||
+ "cache:\\foo",
|
||
+ crate::state::X_FILE_ATTRIBUTE_NORMAL,
|
||
+ 0,
|
||
+ );
|
||
+ assert_eq!(status, STATUS_SUCCESS);
|
||
+ }
|
||
+
|
||
+ /// Phase C+12 — mount-time scan picks up files that already exist
|
||
+ /// on disk under the cache root (canary's `HostPathDevice::
|
||
+ /// PopulateEntry` analogue). The probe MUST succeed even though
|
||
+ /// no `NtCreateFile` ran this boot — this is exactly the canary
|
||
+ /// behaviour ours was missing at idx 102404.
|
||
+ #[test]
|
||
+ fn nt_query_full_attributes_file_resolves_preexisting_host_entry() {
|
||
+ let mut state = KernelState::new();
|
||
+ let dir = std::env::temp_dir().join(format!(
|
||
+ "xenia-rs-cache-test-c12pre-{}-{}",
|
||
+ std::process::id(),
|
||
+ std::time::SystemTime::now()
|
||
+ .duration_since(std::time::UNIX_EPOCH)
|
||
+ .unwrap()
|
||
+ .subsec_nanos()
|
||
+ ));
|
||
+ std::fs::create_dir_all(dir.join("d4ea4615").join("e")).unwrap();
|
||
+ std::fs::write(dir.join("d4ea4615").join("e").join("46ee8ca"), b"oracle").unwrap();
|
||
+ // `set_cache_root` performs the eager scan.
|
||
+ state.set_cache_root(dir.clone());
|
||
+
|
||
+ // Wire up scratch + initial thread (mirrors `fresh()`).
|
||
+ let mut mem = GuestMemory::new().expect("memory init");
|
||
+ mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
|
||
+ .expect("scratch page must commit");
|
||
+ state.install_initial_thread(
|
||
+ PpcContext::default(),
|
||
+ 0x7000_0000,
|
||
+ 0x10_0000,
|
||
+ SCRATCH_BASE + 0x800,
|
||
+ SCRATCH_BASE + 0xC00,
|
||
+ 0x1000,
|
||
+ &mut mem,
|
||
+ );
|
||
+ state.scheduler.begin_slot_visit(0);
|
||
+
|
||
+ let status = assert_query_attrs_struct(
|
||
+ &mut state,
|
||
+ &mem,
|
||
+ "cache:\\d4ea4615\\e\\46ee8ca",
|
||
+ crate::state::X_FILE_ATTRIBUTE_NORMAL,
|
||
+ 6, // strlen("oracle")
|
||
+ );
|
||
+ assert_eq!(status, STATUS_SUCCESS);
|
||
+ // Directory probe must also resolve (mount-time scan inserts
|
||
+ // both files and dirs).
|
||
+ let status_dir = assert_query_attrs_struct(
|
||
+ &mut state,
|
||
+ &mem,
|
||
+ "cache:\\d4ea4615",
|
||
+ crate::state::X_FILE_ATTRIBUTE_DIRECTORY,
|
||
+ 0,
|
||
+ );
|
||
+ assert_eq!(status_dir, STATUS_SUCCESS);
|
||
+
|
||
+ std::fs::remove_dir_all(&dir).ok();
|
||
+ }
|
||
+
|
||
+ /// Phase C+12 — pin the FILETIME conversion: a known Unix epoch
|
||
+ /// value (`1_700_000_000` seconds = 2023-11-14 22:13:20 UTC)
|
||
+ /// converts to the expected Windows FILETIME tick count.
|
||
+ #[test]
|
||
+ fn unix_to_filetime_known_value() {
|
||
+ let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
|
||
+ let ft = crate::state::unix_to_filetime(t);
|
||
+ // (1_700_000_000 + 11_644_473_600) * 10_000_000 = 133_444_736_000_000_000
|
||
+ assert_eq!(ft, 133_444_736_000_000_000);
|
||
+ }
|
||
+
|
||
+ /// Phase C+12 — `change_time` slot (offset 24) MUST equal
|
||
+ /// `last_write_time` (offset 16), mirroring canary's
|
||
+ /// `xboxkrnl_io.cc:504` line `file_info->change_time =
|
||
+ /// entry->write_timestamp();`. This is the only field where the
|
||
+ /// brief's "4 distinct FILETIMEs" framing differs from canary's
|
||
+ /// actual semantics.
|
||
+ #[test]
|
||
+ fn nt_query_full_attributes_file_change_time_equals_write_time() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\writeme");
|
||
+ let handle_out = SCRATCH_BASE + 0x400;
|
||
+ let iosb = SCRATCH_BASE + 0x410;
|
||
+ 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;
|
||
+ ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+
|
||
+ let out = SCRATCH_BASE + 0x300;
|
||
+ ctx.gpr[3] = obj_attrs as u64;
|
||
+ ctx.gpr[4] = out as u64;
|
||
+ nt_query_full_attributes_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ let last_write = mem.read_u64(out + 16);
|
||
+ let change = mem.read_u64(out + 24);
|
||
+ assert_eq!(
|
||
+ change, last_write,
|
||
+ "change_time must equal last_write_time per canary xboxkrnl_io.cc:504"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Phase C+13 — `is_disc_prefix` recognises every alias canary maps
|
||
+ /// to the read-only disc partition: `game:\`, `d:\`/`D:\`, and the
|
||
+ /// raw NT device path `\Device\Cdrom0\`. Anything else (writable
|
||
+ /// partitions, raw paths) must return false so the synth-empty
|
||
+ /// fallback still fires.
|
||
+ #[test]
|
||
+ fn is_disc_prefix_recognises_disc_aliases() {
|
||
+ assert!(is_disc_prefix("game:\\dat\\files.tbl"));
|
||
+ assert!(is_disc_prefix("GAME:\\dat\\files.tbl"));
|
||
+ assert!(is_disc_prefix("d:\\default.xex"));
|
||
+ assert!(is_disc_prefix("D:\\default.xex"));
|
||
+ assert!(is_disc_prefix("\\Device\\Cdrom0\\dat\\files.tbl"));
|
||
+ assert!(is_disc_prefix("\\DEVICE\\CDROM0\\foo"));
|
||
+ // Non-disc prefixes must NOT count.
|
||
+ assert!(!is_disc_prefix("cache:\\d4ea4615\\e\\46ee8ca"));
|
||
+ assert!(!is_disc_prefix("\\Device\\Harddisk0\\Partition1\\x"));
|
||
+ assert!(!is_disc_prefix("\\??\\foo"));
|
||
+ assert!(!is_disc_prefix("\\Device\\Mass0\\foo"));
|
||
+ assert!(!is_disc_prefix("scripts/init.lua"));
|
||
+ assert!(!is_disc_prefix(""));
|
||
+ }
|
||
+
|
||
+ /// Phase C+13 — `NtCreateFile` on a disc-prefixed path that the VFS
|
||
+ /// can't resolve returns `STATUS_OBJECT_NAME_NOT_FOUND` (mirrors
|
||
+ /// canary `xboxkrnl_io.cc:83-110` which forwards the lookup
|
||
+ /// status verbatim, idx 103862 first divergence). Sylpheed
|
||
+ /// handles NOT_FOUND via `RtlNtStatusToDosError` then continues
|
||
+ /// its boot validator.
|
||
+ #[test]
|
||
+ fn nt_create_file_game_prefix_missing_returns_not_found() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ // Install a stub VFS that doesn't resolve anything — mirrors a
|
||
+ // disc image that doesn't contain `dat/files.tbl`.
|
||
+ state.vfs = Some(Box::new(StubVfs { entries: vec![] }));
|
||
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "game:\\dat\\files.tbl");
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
|
||
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0);
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_OPEN as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND,
|
||
+ "missing disc file must return STATUS_OBJECT_NAME_NOT_FOUND"
|
||
+ );
|
||
+ assert_eq!(
|
||
+ mem.read_u32(handle_out),
|
||
+ 0,
|
||
+ "no handle returned on NOT_FOUND"
|
||
+ );
|
||
+ assert_eq!(
|
||
+ mem.read_u32(iosb),
|
||
+ STATUS_OBJECT_NAME_NOT_FOUND as u32,
|
||
+ "IOSB.status records NOT_FOUND"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Phase C+13 — same as above for the `\Device\Cdrom0\` NT-device
|
||
+ /// alias of the disc.
|
||
+ #[test]
|
||
+ fn nt_create_file_cdrom_prefix_missing_returns_not_found() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ state.vfs = Some(Box::new(StubVfs { entries: vec![] }));
|
||
+ let obj_attrs = write_obj_attrs(
|
||
+ &mem,
|
||
+ SCRATCH_BASE + 0x100,
|
||
+ "\\Device\\Cdrom0\\dat\\files.tbl",
|
||
+ );
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
|
||
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0);
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_OPEN as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND);
|
||
+ }
|
||
+
|
||
+ /// Phase C+13 — a non-disc prefix that misses the VFS still gets
|
||
+ /// the legacy zero-byte synth (preserves audit-006 / audit-018
|
||
+ /// behaviour for writable system-partition opens that ours
|
||
+ /// doesn't host-mount). `\Device\Harddisk0\Partition1\` is the
|
||
+ /// canonical writable mount.
|
||
+ #[test]
|
||
+ fn nt_create_file_non_disc_prefix_missing_still_synthesizes() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ state.vfs = Some(Box::new(StubVfs { entries: vec![] }));
|
||
+ let obj_attrs = write_obj_attrs(
|
||
+ &mem,
|
||
+ SCRATCH_BASE + 0x100,
|
||
+ "\\Device\\Harddisk0\\Partition1\\sys.bin",
|
||
+ );
|
||
+ let handle_out = SCRATCH_BASE + 0x300;
|
||
+ let iosb = SCRATCH_BASE + 0x310;
|
||
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
|
||
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0);
|
||
+ ctx.gpr[3] = handle_out as u64;
|
||
+ ctx.gpr[5] = obj_attrs as u64;
|
||
+ ctx.gpr[6] = iosb as u64;
|
||
+ ctx.gpr[10] = FILE_OPEN as u64;
|
||
+ nt_create_file(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_SUCCESS,
|
||
+ "non-disc missing path keeps synth-empty"
|
||
+ );
|
||
+ let handle = mem.read_u32(handle_out);
|
||
+ assert!(handle >= 0x1000, "synth handle must be allocated");
|
||
+ assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32);
|
||
+ }
|
||
+
|
||
+ /// `resolve_cache_path` rejects path-traversal attempts so a guest
|
||
+ /// can't escape the cache directory by passing `cache:\..\..\etc\foo`.
|
||
+ #[test]
|
||
+ fn cache_resolve_strips_path_traversal() {
|
||
+ let dir = std::env::temp_dir().join(format!(
|
||
+ "xenia-rs-cache-test-trav-{}",
|
||
+ std::process::id()
|
||
+ ));
|
||
+ std::fs::create_dir_all(&dir).unwrap();
|
||
+ let mut state = KernelState::new();
|
||
+ state.init_cache_root(dir.clone()).unwrap();
|
||
+ let resolved = state
|
||
+ .resolve_cache_path("cache:\\..\\..\\etc\\foo")
|
||
+ .expect("must resolve");
|
||
+ assert!(resolved.starts_with(&dir), "must stay inside cache root");
|
||
+ 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");
|
||
+ }
|
||
+
|
||
+ // ---------------------------------------------------------------
|
||
+ // Phase C+7 — KeSetEvent / NtSetEvent canary-parity return value
|
||
+ // ---------------------------------------------------------------
|
||
+
|
||
+ /// Canary parity: `KeSetEvent` on an unsignaled auto-reset event
|
||
+ /// must return constant `1` (NOT prior state). See investigation
|
||
+ /// for the `XEvent::Set` reference path.
|
||
+ #[test]
|
||
+ fn ke_set_event_returns_constant_one_on_unsignaled_auto_reset() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0x900;
|
||
+ write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // auto-reset, unsignaled
|
||
+ ctx.gpr[3] = kevent_ptr as u64;
|
||
+ ke_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], 1,
|
||
+ "KeSetEvent must return constant 1 on success (canary parity, xevent.cc:60-64)"
|
||
+ );
|
||
+ // Shadow must be signaled even though the return value is constant.
|
||
+ match state.objects.get(&kevent_ptr) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
|
||
+ _ => panic!("shadow not minted"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Canary parity: `KeSetEvent` on an already-signaled manual-reset
|
||
+ /// event also returns constant `1` (not prior `1`). Same constant.
|
||
+ #[test]
|
||
+ fn ke_set_event_returns_constant_one_on_already_signaled_manual_reset() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0xA00;
|
||
+ write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // manual-reset, signaled
|
||
+ ctx.gpr[3] = kevent_ptr as u64;
|
||
+ ke_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], 1,
|
||
+ "KeSetEvent returns 1 regardless of prior state (canary parity)"
|
||
+ );
|
||
+ match state.objects.get(&kevent_ptr) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
|
||
+ _ => panic!("shadow vanished"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Canary parity: `NtSetEvent` with null `PreviousState` ptr returns
|
||
+ /// STATUS_SUCCESS and performs no out-pointer write.
|
||
+ #[test]
|
||
+ fn nt_set_event_null_prev_ptr_returns_status_success_no_write() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle = state.alloc_handle_for(KernelObject::Event {
|
||
+ manual_reset: false,
|
||
+ signaled: false,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ ctx.gpr[4] = 0; // null out-pointer
|
||
+ nt_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_SUCCESS,
|
||
+ "NtSetEvent must return STATUS_SUCCESS"
|
||
+ );
|
||
+ // Event must be signaled.
|
||
+ match state.objects.get(&handle) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
|
||
+ _ => panic!("handle lookup broken"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Canary parity: `NtSetEvent` with a valid out-pointer writes
|
||
+ /// **constant 1** (canary's `was_signalled = ev->Set()` always 1),
|
||
+ /// NOT the prior signaled state. See xboxkrnl_threading.cc:610-628.
|
||
+ #[test]
|
||
+ fn nt_set_event_valid_prev_ptr_writes_constant_one_and_returns_success() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle = state.alloc_handle_for(KernelObject::Event {
|
||
+ manual_reset: false,
|
||
+ signaled: false,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+ let prev_ptr = SCRATCH_BASE + 0xB00;
|
||
+ mem.write_u32(prev_ptr, 0xDEAD_BEEF); // sentinel — overwrite expected
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ ctx.gpr[4] = prev_ptr as u64;
|
||
+ nt_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_SUCCESS,
|
||
+ "NtSetEvent must return STATUS_SUCCESS"
|
||
+ );
|
||
+ assert_eq!(
|
||
+ mem.read_u32(prev_ptr),
|
||
+ 1,
|
||
+ "PreviousState out-ptr must receive constant 1 (canary parity)"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Canary parity: `NtSetEvent` on an already-signaled event still
|
||
+ /// writes constant `1` to the out-pointer (not the prior `1`,
|
||
+ /// though they happen to match here — distinguished from the
|
||
+ /// prior-state-write bug by the auto-reset/un-signaled case above).
|
||
+ #[test]
|
||
+ fn nt_set_event_on_signaled_event_writes_one() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle = state.alloc_handle_for(KernelObject::Event {
|
||
+ manual_reset: true,
|
||
+ signaled: true,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+ let prev_ptr = SCRATCH_BASE + 0xC00;
|
||
+ mem.write_u32(prev_ptr, 0);
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ ctx.gpr[4] = prev_ptr as u64;
|
||
+ nt_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(mem.read_u32(prev_ptr), 1);
|
||
+ // Event stays signaled (manual-reset).
|
||
+ match state.objects.get(&handle) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
|
||
+ _ => panic!("handle lookup broken"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Wake-cascade regression: KeSetEvent on a manual-reset event with
|
||
+ /// a parked waiter still wakes the waiter post-fix. The return-value
|
||
+ /// change is observation-only — internal wake plumbing uses the
|
||
+ /// `previous` read, not the return value.
|
||
+ #[test]
|
||
+ fn ke_set_event_post_fix_still_wakes_waiter() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0xD00;
|
||
+ write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled
|
||
+ // Mint the shadow first by calling reset_event (no waiter yet).
|
||
+ ctx.gpr[3] = kevent_ptr as u64;
|
||
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
|
||
+ // Park a fake waiter.
|
||
+ match state.objects.get_mut(&kevent_ptr) {
|
||
+ Some(KernelObject::Event { waiters, .. }) => {
|
||
+ waiters.push(ThreadRef { hw_id: 4, idx: 0, generation: 0 });
|
||
+ }
|
||
+ _ => panic!("shadow not minted"),
|
||
+ }
|
||
+ // Signal.
|
||
+ ctx.gpr[3] = kevent_ptr as u64;
|
||
+ ke_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], 1, "constant 1 return preserved");
|
||
+ // Manual-reset: waiter list drained after wake.
|
||
+ match state.objects.get(&kevent_ptr) {
|
||
+ Some(KernelObject::Event { signaled, waiters, .. }) => {
|
||
+ assert!(*signaled, "manual-reset stays signaled");
|
||
+ assert!(waiters.is_empty(), "manual-reset wake drains all waiters");
|
||
+ }
|
||
+ _ => panic!("shadow vanished"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // ---------------------------------------------------------------
|
||
+ // Phase C+8 — KeResetEvent canary-parity return value (sibling of C+7)
|
||
+ // ---------------------------------------------------------------
|
||
+
|
||
+ /// Canary parity: `KeResetEvent` on an unsignaled manual-reset event
|
||
+ /// must return constant `1` on shadow hit (NOT prior `0`). Canary's
|
||
+ /// `XEvent::Reset` hardcodes `return 1` regardless of prior state
|
||
+ /// (xevent.cc:72-75), exactly mirroring `XEvent::Set`. This is the
|
||
+ /// case that triggered the Phase A divergence at idx=102164: prior
|
||
+ /// state was unsignaled (`0`) and the prior-state-return bug gave
|
||
+ /// `0` while canary returns `1`.
|
||
+ #[test]
|
||
+ fn ke_reset_event_returns_constant_one_on_unsignaled_manual_reset() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0xE00;
|
||
+ write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled
|
||
+ ctx.gpr[3] = kevent_ptr as u64;
|
||
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], 1,
|
||
+ "KeResetEvent must return constant 1 on success (canary parity, xevent.cc:72-75)"
|
||
+ );
|
||
+ // Shadow stays unsignaled (was already 0, reset is idempotent).
|
||
+ match state.objects.get(&kevent_ptr) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => assert!(!*signaled),
|
||
+ _ => panic!("shadow not minted"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Canary parity: `KeResetEvent` on a signaled auto-reset event also
|
||
+ /// returns constant `1`. Distinguished from the prior-state-return
|
||
+ /// bug by the unsignaled case above (where they would differ: bug=0
|
||
+ /// vs canary=1).
|
||
+ #[test]
|
||
+ fn ke_reset_event_returns_constant_one_on_signaled_auto_reset() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let kevent_ptr = SCRATCH_BASE + 0xF00;
|
||
+ write_dispatcher_header(&mut mem, kevent_ptr, 1, 1); // auto-reset, signaled
|
||
+ ctx.gpr[3] = kevent_ptr as u64;
|
||
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], 1,
|
||
+ "KeResetEvent returns 1 regardless of prior state (canary parity)"
|
||
+ );
|
||
+ match state.objects.get(&kevent_ptr) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => {
|
||
+ assert!(!*signaled, "ke_reset_event must clear the shadow");
|
||
+ }
|
||
+ _ => panic!("shadow vanished"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Canary parity: `KeResetEvent` on a non-existent shadow (and a
|
||
+ /// PKEVENT that doesn't match a dispatcher type the lazy-shadow can
|
||
+ /// mint) must return `0` — canary's `assert_always(); return 0` arm
|
||
+ /// for the no-XEvent-bound case (xboxkrnl_threading.cc:566-574).
|
||
+ /// We model this via a pointer below the dispatcher-shim threshold
|
||
+ /// (handle range, no kevent header pre-written).
|
||
+ #[test]
|
||
+ fn ke_reset_event_returns_zero_on_missing_object() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ // Use a low handle-range value with no allocated object — no
|
||
+ // shadow mint (handle path), no dispatcher header to lazy-mint
|
||
+ // from (ptr below 0x10000 means ensure_dispatcher_object skips).
|
||
+ ctx.gpr[3] = 0x4242; // arbitrary handle that doesn't exist
|
||
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], 0,
|
||
+ "KeResetEvent must return 0 when no event object is bound (canary's assert_always arm)"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// `NtClearEvent` parity: returns `STATUS_SUCCESS` and resets the
|
||
+ /// shadow signaled flag. Unlike NtSetEvent, NtClearEvent has NO
|
||
+ /// PreviousState out-pointer (xboxkrnl_threading.cc:685-687 →
|
||
+ /// xeNtClearEvent calls XEvent::Clear which is void-returning).
|
||
+ /// Verified canary-parity; included for symmetry coverage.
|
||
+ #[test]
|
||
+ fn nt_clear_event_resets_shadow_and_returns_status_success() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle = state.alloc_handle_for(KernelObject::Event {
|
||
+ manual_reset: true,
|
||
+ signaled: true,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+ ctx.gpr[3] = handle as u64;
|
||
+ nt_clear_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(
|
||
+ ctx.gpr[3], STATUS_SUCCESS,
|
||
+ "NtClearEvent must return STATUS_SUCCESS on hit"
|
||
+ );
|
||
+ match state.objects.get(&handle) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => {
|
||
+ assert!(!*signaled, "nt_clear_event must clear the shadow");
|
||
+ }
|
||
+ _ => panic!("handle lookup broken"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Phase C+16: `ExCreateThread` must install a thread self-reference
|
||
+ /// (handle refcount = 2 post-spawn). Mirrors canary's
|
||
+ /// `XThread::Create::RetainHandle()` at xthread.cc:414. Without
|
||
+ /// this, a guest `NtClose` on the thread handle destroys it
|
||
+ /// prematurely while the spawned thread is still live — the
|
||
+ /// original C+16 divergence at Phase A idx=102168.
|
||
+ #[test]
|
||
+ fn ex_create_thread_installs_self_reference() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle_ptr = SCRATCH_BASE + 0x100;
|
||
+ let thread_id_ptr = SCRATCH_BASE + 0x108;
|
||
+ ctx.gpr[3] = handle_ptr as u64;
|
||
+ ctx.gpr[4] = 0x10000; // stack_size
|
||
+ ctx.gpr[5] = thread_id_ptr as u64;
|
||
+ ctx.gpr[6] = 0; // xapi_startup
|
||
+ ctx.gpr[7] = 0x8200_1000; // start_address
|
||
+ ctx.gpr[8] = 0; // start_context
|
||
+ ctx.gpr[9] = 0; // creation_flags (not suspended, affinity = 0)
|
||
+ ex_create_thread(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "ExCreateThread must succeed");
|
||
+ let handle = mem.read_u32(handle_ptr);
|
||
+ assert_eq!(
|
||
+ state.handle_refcount.get(&handle).copied(),
|
||
+ Some(2),
|
||
+ "ExCreateThread must install self-ref (refcount = creator + self = 2)"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Phase C+16: `ExTerminateThread` releases the self-reference. The
|
||
+ /// thread terminates from inside its own context, so we spawn a
|
||
+ /// worker via `ex_create_thread`, switch to its slot, and then
|
||
+ /// terminate. Post-terminate: refcount = 1 (creator-only, handle
|
||
+ /// still alive). Mirrors canary's `XThread::Exit::ReleaseHandle()`
|
||
+ /// at xthread.cc:524.
|
||
+ #[test]
|
||
+ fn ex_terminate_thread_releases_self_reference() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle_ptr = SCRATCH_BASE + 0x100;
|
||
+ let thread_id_ptr = SCRATCH_BASE + 0x108;
|
||
+ ctx.gpr[3] = handle_ptr as u64;
|
||
+ ctx.gpr[4] = 0x10000;
|
||
+ ctx.gpr[5] = thread_id_ptr as u64;
|
||
+ ctx.gpr[6] = 0;
|
||
+ ctx.gpr[7] = 0x8200_1000;
|
||
+ ctx.gpr[8] = 0;
|
||
+ ctx.gpr[9] = 0;
|
||
+ ex_create_thread(&mut ctx, &mut mem, &mut state);
|
||
+ let handle = mem.read_u32(handle_ptr);
|
||
+ assert_eq!(state.handle_refcount.get(&handle).copied(), Some(2));
|
||
+
|
||
+ // Switch to the spawned thread's slot so `exit_current` sees it.
|
||
+ let r = state
|
||
+ .scheduler
|
||
+ .find_by_handle(handle)
|
||
+ .expect("spawned thread must be findable");
|
||
+ state.scheduler.current = Some(r);
|
||
+
|
||
+ let mut term_ctx = PpcContext::default();
|
||
+ term_ctx.gpr[3] = 0; // exit_code
|
||
+ ex_terminate_thread(&mut term_ctx, &mem, &mut state);
|
||
+
|
||
+ // self-ref dropped → refcount = 1 (creator still holds).
|
||
+ assert_eq!(
|
||
+ state.handle_refcount.get(&handle).copied(),
|
||
+ Some(1),
|
||
+ "ex_terminate_thread must release the self-ref"
|
||
+ );
|
||
+ assert!(
|
||
+ state.objects.contains_key(&handle),
|
||
+ "object must survive (creator-ref still held)"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /// Phase C+16: end-to-end refcount lifecycle balance. Spawn →
|
||
+ /// user closes → thread exits → object destroyed. No leak.
|
||
+ #[test]
|
||
+ fn ex_create_then_close_then_exit_balances_refcount() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let handle_ptr = SCRATCH_BASE + 0x100;
|
||
+ let thread_id_ptr = SCRATCH_BASE + 0x108;
|
||
+ ctx.gpr[3] = handle_ptr as u64;
|
||
+ ctx.gpr[4] = 0x10000;
|
||
+ ctx.gpr[5] = thread_id_ptr as u64;
|
||
+ ctx.gpr[6] = 0;
|
||
+ ctx.gpr[7] = 0x8200_1000;
|
||
+ ctx.gpr[8] = 0;
|
||
+ ctx.gpr[9] = 0;
|
||
+ ex_create_thread(&mut ctx, &mut mem, &mut state);
|
||
+ let handle = mem.read_u32(handle_ptr);
|
||
+
|
||
+ // User NtClose: refcount 2 → 1, object survives.
|
||
+ let mut close_ctx = PpcContext::default();
|
||
+ close_ctx.gpr[3] = handle as u64;
|
||
+ nt_close(&mut close_ctx, &mem, &mut state);
|
||
+ assert!(state.objects.contains_key(&handle));
|
||
+ assert_eq!(state.handle_refcount.get(&handle).copied(), Some(1));
|
||
+
|
||
+ // Thread exits: refcount 1 → 0, object destroyed.
|
||
+ let r = state
|
||
+ .scheduler
|
||
+ .find_by_handle(handle)
|
||
+ .expect("must still be findable");
|
||
+ state.scheduler.current = Some(r);
|
||
+ let mut term_ctx = PpcContext::default();
|
||
+ term_ctx.gpr[3] = 0;
|
||
+ ex_terminate_thread(&mut term_ctx, &mem, &mut state);
|
||
+
|
||
+ assert!(
|
||
+ !state.objects.contains_key(&handle),
|
||
+ "object must be destroyed at zero refcount"
|
||
+ );
|
||
+ assert!(
|
||
+ !state.handle_refcount.contains_key(&handle),
|
||
+ "refcount entry must be scrubbed"
|
||
+ );
|
||
+ }
|
||
+
|
||
+ // ===== Phase C+19: NtDuplicateObject fresh-slot semantics =====
|
||
+
|
||
+ /// Helper: create an Event and duplicate it; return (source, dup, state).
|
||
+ fn create_event_and_dup(
|
||
+ mem: &GuestMemory,
|
||
+ state: &mut KernelState,
|
||
+ ) -> (u32, u32) {
|
||
+ let source = state.alloc_handle_for(KernelObject::Event {
|
||
+ manual_reset: false,
|
||
+ signaled: false,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+ let mut ctx = PpcContext::default();
|
||
+ ctx.gpr[3] = source as u64;
|
||
+ let out_ptr = SCRATCH_BASE + 0x100;
|
||
+ mem.write_u32(out_ptr, 0xDEAD_BEEF);
|
||
+ ctx.gpr[4] = out_ptr as u64;
|
||
+ ctx.gpr[5] = 0; // no DUPLICATE_CLOSE_SOURCE
|
||
+ nt_duplicate_object(&mut ctx, mem, state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ let dup = mem.read_u32(out_ptr);
|
||
+ (source, dup)
|
||
+ }
|
||
+
|
||
+ /// Phase C+19: dup id is a *fresh* slot, NOT aliased to source. Mirrors
|
||
+ /// canary's `ObjectTable::DuplicateHandle` → `AddHandle` (object_table.cc:210).
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_allocates_fresh_handle_id() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+ assert_ne!(dup, source, "dup id must be distinct from source");
|
||
+ assert_ne!(dup, 0, "dup id must be non-zero");
|
||
+ }
|
||
+
|
||
+ /// AUDIT-062 INVARIANT (signal-on-dup wakes wait-on-source): the dup
|
||
+ /// alias canonicalizes back to the source `state.objects` entry, so
|
||
+ /// signaling the dup mutates the same `KernelObject::Event` that the
|
||
+ /// source slot points at. This is THE load-bearing test — if it fails
|
||
+ /// the C+19 fix has broken the AUDIT-062 worker-cluster wedge.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_signal_on_dup_wakes_wait_on_source() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ // Signal via dup.
|
||
+ ctx.gpr[3] = dup as u64;
|
||
+ ctx.gpr[4] = 0;
|
||
+ nt_set_event(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+
|
||
+ // Source's event entry must show signaled=true (shared underlying).
|
||
+ match state.objects.get(&source) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => {
|
||
+ assert!(*signaled, "source event must be signaled by dup signal");
|
||
+ }
|
||
+ _ => panic!("source lookup must hit the canonical Event"),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Symmetric: signal-on-source wakes wait-on-dup. Both lookup paths
|
||
+ /// canonicalize to the same entry.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_signal_on_source_visible_via_dup() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ ctx.gpr[3] = source as u64;
|
||
+ ctx.gpr[4] = 0;
|
||
+ nt_set_event(&mut ctx, &mut mem, &mut state);
|
||
+
|
||
+ // Resolve dup → source and check signaled.
|
||
+ let canonical = state.resolve_handle(dup);
|
||
+ assert_eq!(canonical, source);
|
||
+ match state.objects.get(&canonical) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => {
|
||
+ assert!(*signaled);
|
||
+ }
|
||
+ _ => panic!(),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Refcount: both source and dup slots independently get
|
||
+ /// `handle_refcount = 1`. The canonical's `canonical_slot_count` rises
|
||
+ /// to 2 (one per slot). Mirrors canary AddHandle (one Retain per slot).
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_refcount_lifecycle() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ assert_eq!(state.handle_refcount.get(&source).copied(), Some(1));
|
||
+ assert_eq!(state.handle_refcount.get(&dup).copied(), Some(1));
|
||
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(2));
|
||
+ assert_eq!(state.handle_aliases.get(&dup).copied(), Some(source));
|
||
+ }
|
||
+
|
||
+ /// Close the dup first: dup slot is gone, source slot remains, underlying
|
||
+ /// object remains. Symmetric to canary's per-slot `RemoveHandle` (the
|
||
+ /// underlying XObject survives until the last slot is gone).
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_then_close_dup_keeps_source_live() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ let mut close_ctx = PpcContext::default();
|
||
+ close_ctx.gpr[3] = dup as u64;
|
||
+ nt_close(&mut close_ctx, &mem, &mut state);
|
||
+
|
||
+ assert!(!state.handle_refcount.contains_key(&dup));
|
||
+ assert!(!state.handle_aliases.contains_key(&dup));
|
||
+ assert!(state.objects.contains_key(&source));
|
||
+ assert_eq!(state.handle_refcount.get(&source).copied(), Some(1));
|
||
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1));
|
||
+ }
|
||
+
|
||
+ /// Close source first: source slot is gone, dup slot remains, and
|
||
+ /// crucially the underlying object remains so the dup can still be
|
||
+ /// used. Sister of the above.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_then_close_source_keeps_dup_live() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ let mut close_ctx = PpcContext::default();
|
||
+ close_ctx.gpr[3] = source as u64;
|
||
+ nt_close(&mut close_ctx, &mem, &mut state);
|
||
+
|
||
+ assert!(!state.handle_refcount.contains_key(&source));
|
||
+ // Underlying object survives (canonical entry alive through dup slot).
|
||
+ assert!(state.objects.contains_key(&source));
|
||
+ // Dup still points at it.
|
||
+ assert_eq!(state.resolve_handle(dup), source);
|
||
+ // Slot count down to 1 (just the dup).
|
||
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1));
|
||
+
|
||
+ // Signal through dup still works.
|
||
+ let mut set_ctx = PpcContext::default();
|
||
+ let mut mem = mem;
|
||
+ set_ctx.gpr[3] = dup as u64;
|
||
+ set_ctx.gpr[4] = 0;
|
||
+ nt_set_event(&mut set_ctx, &mut mem, &mut state);
|
||
+ match state.objects.get(&source) {
|
||
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
|
||
+ _ => panic!(),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Final close on the last surviving slot drops the canonical object.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_close_both_destroys_underlying() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ let mut close_dup = PpcContext::default();
|
||
+ close_dup.gpr[3] = dup as u64;
|
||
+ nt_close(&mut close_dup, &mem, &mut state);
|
||
+
|
||
+ let mut close_src = PpcContext::default();
|
||
+ close_src.gpr[3] = source as u64;
|
||
+ nt_close(&mut close_src, &mem, &mut state);
|
||
+
|
||
+ assert!(!state.objects.contains_key(&source));
|
||
+ assert!(!state.handle_refcount.contains_key(&source));
|
||
+ assert!(!state.canonical_slot_count.contains_key(&source));
|
||
+ }
|
||
+
|
||
+ /// DUPLICATE_CLOSE_SOURCE: dup happens AND source is closed atomically.
|
||
+ /// Net result: dup is live, source is gone.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_with_close_source_flag() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let source = state.alloc_handle_for(KernelObject::Event {
|
||
+ manual_reset: false,
|
||
+ signaled: false,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+
|
||
+ let out_ptr = SCRATCH_BASE + 0x200;
|
||
+ mem.write_u32(out_ptr, 0);
|
||
+ ctx.gpr[3] = source as u64;
|
||
+ ctx.gpr[4] = out_ptr as u64;
|
||
+ ctx.gpr[5] = 0x1; // DUPLICATE_CLOSE_SOURCE
|
||
+ nt_duplicate_object(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+
|
||
+ let dup = mem.read_u32(out_ptr);
|
||
+ assert_ne!(dup, source);
|
||
+
|
||
+ // Source slot scrubbed.
|
||
+ assert!(!state.handle_refcount.contains_key(&source));
|
||
+ // But the canonical object is still alive through dup.
|
||
+ assert!(state.objects.contains_key(&source));
|
||
+ // Slot count is exactly 1 (the dup).
|
||
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1));
|
||
+ // Dup alias points at canonical.
|
||
+ assert_eq!(state.resolve_handle(dup), source);
|
||
+ }
|
||
+
|
||
+ /// Invalid source handle: STATUS_INVALID_HANDLE + zero write to out_ptr.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_invalid_handle_returns_invalid_handle() {
|
||
+ let (mut ctx, mut mem, mut state) = fresh();
|
||
+ let out_ptr = SCRATCH_BASE + 0x300;
|
||
+ mem.write_u32(out_ptr, 0xCAFE_BABE);
|
||
+ ctx.gpr[3] = 0x9999 as u64; // bogus
|
||
+ ctx.gpr[4] = out_ptr as u64;
|
||
+ ctx.gpr[5] = 0;
|
||
+ nt_duplicate_object(&mut ctx, &mut mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
|
||
+ assert_eq!(mem.read_u32(out_ptr), 0);
|
||
+ }
|
||
+
|
||
+ /// Double-dup: dup of a dup canonicalizes to the original source.
|
||
+ /// Mirrors canary's `LookupObject(TranslateHandle(handle), false)` which
|
||
+ /// resolves through nested dups by hitting the same `XObject*`.
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_dup_of_dup_canonicalizes() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let (source, dup1) = create_event_and_dup(&mem, &mut state);
|
||
+
|
||
+ // Now dup the dup.
|
||
+ let mut ctx = PpcContext::default();
|
||
+ ctx.gpr[3] = dup1 as u64;
|
||
+ let out_ptr = SCRATCH_BASE + 0x400;
|
||
+ mem.write_u32(out_ptr, 0);
|
||
+ ctx.gpr[4] = out_ptr as u64;
|
||
+ ctx.gpr[5] = 0;
|
||
+ nt_duplicate_object(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
|
||
+ let dup2 = mem.read_u32(out_ptr);
|
||
+
|
||
+ assert_ne!(dup2, source);
|
||
+ assert_ne!(dup2, dup1);
|
||
+ // All three resolve to the same canonical source.
|
||
+ assert_eq!(state.resolve_handle(dup1), source);
|
||
+ assert_eq!(state.resolve_handle(dup2), source);
|
||
+ // Slot count reflects 3 live slots.
|
||
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(3));
|
||
+ }
|
||
+
|
||
+ /// Aliased dup with non-Event kernel objects also works. Mirrors
|
||
+ /// canary's `XObject::Type` codes (Event/Mutant/Semaphore/...).
|
||
+ #[test]
|
||
+ fn nt_duplicate_object_works_for_semaphore() {
|
||
+ let (_ctx, mem, mut state) = fresh();
|
||
+ let source = state.alloc_handle_for(KernelObject::Semaphore {
|
||
+ count: 3,
|
||
+ max: 10,
|
||
+ waiters: Vec::new(),
|
||
+ });
|
||
+ let mut ctx = PpcContext::default();
|
||
+ ctx.gpr[3] = source as u64;
|
||
+ let out_ptr = SCRATCH_BASE + 0x600;
|
||
+ mem.write_u32(out_ptr, 0);
|
||
+ ctx.gpr[4] = out_ptr as u64;
|
||
+ ctx.gpr[5] = 0;
|
||
+ nt_duplicate_object(&mut ctx, &mem, &mut state);
|
||
+
|
||
+ let dup = mem.read_u32(out_ptr);
|
||
+ assert_ne!(dup, source);
|
||
+ assert_eq!(state.resolve_handle(dup), source);
|
||
+ // Underlying count unchanged.
|
||
+ match state.objects.get(&source) {
|
||
+ Some(KernelObject::Semaphore { count, max, .. }) => {
|
||
+ assert_eq!(*count, 3);
|
||
+ assert_eq!(*max, 10);
|
||
+ }
|
||
+ _ => panic!(),
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Phase W: ensure `VdInitializeEngines` writes `r3=1` (canary's
|
||
+ /// literal return value, not `STATUS_SUCCESS=0`). Anchored on the
|
||
+ /// helper directly so the registration is exercised end-to-end via
|
||
+ /// a separate code-path check (no need to actually issue the import
|
||
+ /// call). The `// canary returns 1` invariant is the entirety of
|
||
+ /// the fix.
|
||
+ #[test]
|
||
+ fn vd_initialize_engines_returns_one() {
|
||
+ let (mut ctx, mem, mut state) = fresh();
|
||
+ ctx.gpr[3] = 0xDEAD_BEEF; // sentinel — must be overwritten
|
||
+ stub_return_one(&mut ctx, &mem, &mut state);
|
||
+ assert_eq!(ctx.gpr[3], 1, "stub_return_one must put 1 in r3");
|
||
}
|
||
}
|
||
diff --git a/crates/xenia-kernel/src/xaudio.rs b/crates/xenia-kernel/src/xaudio.rs
|
||
index c20fe94..cb09261 100644
|
||
--- a/crates/xenia-kernel/src/xaudio.rs
|
||
+++ b/crates/xenia-kernel/src/xaudio.rs
|
||
@@ -58,6 +58,24 @@ pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333);
|
||
/// queueing unbounded callbacks while injection is starved.
|
||
pub const XAUDIO_QUEUE_CAP: usize = 16;
|
||
|
||
+/// Phase HostAudioEager (2026-05-19): initial seeded fire count at
|
||
+/// `XAudioRegisterRenderDriverClient` time. Mirrors xenia-canary
|
||
+/// [`audio_system.cc:210`](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L210)
|
||
+/// `client_semaphore->Release(queued_frames_=8, nullptr)` — the moment
|
||
+/// canary's `RegisterClient` returns, its already-running host worker
|
||
+/// thread has 8 buffer-complete fires queued to drain.
|
||
+///
|
||
+/// In ours, the dedicated guest audio worker (spawned at the same
|
||
+/// register call) can't be HOST-threaded; instead we seed the pending
|
||
+/// FIFO so the round prologue's `try_inject_audio_callback` injects
|
||
+/// the first callback on the very next round — well before tid=1
|
||
+/// reaches `ExCreateThread` for the XAudio worker threads (tid=14/15
|
||
+/// in canary, tid=9/10 in ours). This fixes the ordering issue where
|
||
+/// the 48k-instruction ticker delay let tid=9/10 spawn and enter
|
||
+/// their spin loop on the uninitialized voice struct before the
|
||
+/// callback could modify it.
|
||
+pub const XAUDIO_REGISTER_SEED_FIRES: usize = 8;
|
||
+
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct XAudioClient {
|
||
pub callback_pc: u32,
|
||
@@ -155,6 +173,28 @@ impl XAudioState {
|
||
}
|
||
}
|
||
|
||
+ /// Phase HostAudioEager: enqueue `n` buffer-complete fires for a
|
||
+ /// specific client slot. Used by `XAudioRegisterRenderDriverClient`
|
||
+ /// to mirror canary's `client_semaphore->Release(queued_frames_)`
|
||
+ /// at register time. Capped by [`XAUDIO_QUEUE_CAP`] to avoid
|
||
+ /// unbounded growth if the caller seeds aggressively. Returns the
|
||
+ /// actual number of fires enqueued.
|
||
+ pub fn seed_fires_for(&mut self, index: usize, n: usize) -> usize {
|
||
+ if index >= XAUDIO_MAX_CLIENTS || self.clients[index].is_none() {
|
||
+ return 0;
|
||
+ }
|
||
+ let mut queued = 0;
|
||
+ for _ in 0..n {
|
||
+ if self.pending.len() >= XAUDIO_QUEUE_CAP {
|
||
+ self.dropped += 1;
|
||
+ break;
|
||
+ }
|
||
+ self.pending.push_back(index);
|
||
+ queued += 1;
|
||
+ }
|
||
+ queued
|
||
+ }
|
||
+
|
||
pub fn peek_next(&self) -> Option<usize> {
|
||
self.pending.front().copied()
|
||
}
|
||
@@ -320,6 +360,51 @@ mod tests {
|
||
assert!(s.last_instant.is_some());
|
||
}
|
||
|
||
+ #[test]
|
||
+ fn seed_fires_for_registered_slot_enqueues_n() {
|
||
+ let mut s = XAudioState::default();
|
||
+ let i = s.register(dummy_client(1)).unwrap();
|
||
+ let queued = s.seed_fires_for(i, XAUDIO_REGISTER_SEED_FIRES);
|
||
+ assert_eq!(queued, XAUDIO_REGISTER_SEED_FIRES);
|
||
+ assert_eq!(s.pending.len(), XAUDIO_REGISTER_SEED_FIRES);
|
||
+ // All enqueued fires reference our slot.
|
||
+ for _ in 0..XAUDIO_REGISTER_SEED_FIRES {
|
||
+ assert_eq!(s.take_next(), Some(i));
|
||
+ }
|
||
+ assert!(s.pending.is_empty());
|
||
+ }
|
||
+
|
||
+ #[test]
|
||
+ fn seed_fires_for_unregistered_slot_is_noop() {
|
||
+ let mut s = XAudioState::default();
|
||
+ // Slot 3 is empty.
|
||
+ let queued = s.seed_fires_for(3, 8);
|
||
+ assert_eq!(queued, 0);
|
||
+ assert!(s.pending.is_empty());
|
||
+ assert_eq!(s.dropped, 0);
|
||
+ }
|
||
+
|
||
+ #[test]
|
||
+ fn seed_fires_for_caps_at_queue_cap_and_counts_drops() {
|
||
+ let mut s = XAudioState::default();
|
||
+ let i = s.register(dummy_client(1)).unwrap();
|
||
+ let queued = s.seed_fires_for(i, XAUDIO_QUEUE_CAP * 4);
|
||
+ assert_eq!(queued, XAUDIO_QUEUE_CAP);
|
||
+ assert_eq!(s.pending.len(), XAUDIO_QUEUE_CAP);
|
||
+ // Excess fires are counted as dropped (per
|
||
+ // existing `enqueue_all_active` discipline).
|
||
+ assert!(s.dropped >= 1);
|
||
+ }
|
||
+
|
||
+ #[test]
|
||
+ fn seed_fires_for_out_of_range_index_is_noop() {
|
||
+ let mut s = XAudioState::default();
|
||
+ s.register(dummy_client(1)).unwrap();
|
||
+ let queued = s.seed_fires_for(XAUDIO_MAX_CLIENTS + 5, 4);
|
||
+ assert_eq!(queued, 0);
|
||
+ assert!(s.pending.is_empty());
|
||
+ }
|
||
+
|
||
#[test]
|
||
fn tick_wallclock_fires_after_period() {
|
||
let mut s = XAudioState::default();
|