fix(kernel): KRNBUG-IO-003 — NtDeviceIoControlFile real impl mirroring NullDevice::IoControl

Replace the stub_success registration of NtDeviceIoControlFile at
exports.rs:90 with a real handler for FsCtlCodes 0x70000 (drive
geometry) and 0x74004 (partition info), mirroring xenia-canary
xboxkrnl_io.cc:645-678 + null_device.{h,cc}. The 16-byte 0x74004
response with cache_size=0xFF000 at OUT+8 is the gate that lets
sub_824ABD88 return SUCCESS and sub_824A9710 reach the priv-11
XexCheckExecutablePrivilege site identified by KRNBUG-AUDIT-007.

Stack args 9-10 (OutputBuffer, OutputBufferLength) read from the
caller's parameter save area at [sp+0x54] / [sp+0x5C] per the Xbox
360 PowerPC EABI (linkage area sp+0..sp+8, 8-quadword spill area
sp+0x14..sp+0x54, then stack args every 8 bytes). First HLE export
in the codebase to need 9+ args.

Cascade vs. KRNBUG-AUDIT-007 prediction (5/8 held):
- XexCheckExecutablePrivilege count 1 → 2 (priv=0xA + priv=0xB) ✓
- XamTaskSchedule count 0 → 1 ✓
- canary-only exports 7 → 3 (audit predicted ≤3) ✓
- 0x15e0 semaphore signal_attempts 0 → 1 (bonus)
- 0x100c worker spawn DID NOT fire (still UNCREATED) ✗
- 0x1004 signal_attempts unchanged ✗
- Worker spawn count unchanged at 19 ✗

Tests: 592 → 594. Lockstep deterministic at -n 100M (run1 ≡ run2 ≡
run3, byte-identical). instructions=100000010 → 100000019, imports
407417 → 987524 (+2.4×). swaps=2 draws=0 plateau persists.

sylpheed_n50m golden re-baselined instructions=50000004→50000003,
imports=407362→407255. sylpheed_n2m unchanged.

Still canary-only after this fix: ExTerminateThread,
KeReleaseSemaphore, XamUserReadProfileSettings. The next downstream
gate is somewhere past XamTaskSchedule's completion path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-04 22:00:12 +02:00
parent 58f416c284
commit a1a7265f29
4 changed files with 429 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
{
"instructions": 50000000,
"imports": 407362,
"instructions": 50000003,
"imports": 407255,
"unimpl": 0,
"draws": 0,
"swaps": 2,

View File

@@ -87,7 +87,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0xD2, "NtCreateFile", nt_create_file);
state.register_export(Xboxkrnl, 0xD5, "NtCreateSemaphore", nt_create_semaphore);
state.register_export(Xboxkrnl, 0xD7, "NtCreateTimer", nt_create_timer);
state.register_export(Xboxkrnl, 0xD9, "NtDeviceIoControlFile", stub_success);
state.register_export(Xboxkrnl, 0xD9, "NtDeviceIoControlFile", nt_device_io_control_file);
state.register_export(Xboxkrnl, 0xDA, "NtDuplicateObject", nt_duplicate_object);
state.register_export(Xboxkrnl, 0xDB, "NtFlushBuffersFile", stub_success);
state.register_export(Xboxkrnl, 0xDC, "NtFreeVirtualMemory", stub_success);
@@ -1001,6 +1001,64 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
signal_io_completion_event(state, event_handle);
}
/// Mirrors canary `NullDevice::IoControl` via
/// [xboxkrnl_io.cc:645-678](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc).
/// Used by `XMountUtilityDrive` cache-mount probes; Sylpheed issues both
/// `0x70000` (drive geometry) and `0x74004` (partition info) inside
/// `sub_824ABD88`. The OUT-buffer fields it writes are the gate that
/// keeps `sub_824A9710` from synthesizing `STATUS_OBJECT_NAME_NOT_FOUND`.
fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
const X_IOCTL_DISK_GET_DRIVE_GEOMETRY: u32 = 0x70000;
const X_IOCTL_DISK_GET_PARTITION_INFO: u32 = 0x74004;
const STATUS_BUFFER_TOO_SMALL: u64 = 0xC000_0023;
const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D;
const CACHE_SIZE: u64 = 0xFF000;
let event_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;
let output_buffer = mem.read_u32(sp + 0x54);
let output_buffer_len = mem.read_u32(sp + 0x5C);
let status: u64 = match io_control_code {
X_IOCTL_DISK_GET_DRIVE_GEOMETRY => {
if output_buffer_len < 0x8 {
STATUS_BUFFER_TOO_SMALL
} else {
mem.write_u32(output_buffer, (CACHE_SIZE / 512) as u32);
mem.write_u32(output_buffer + 4, 512);
STATUS_SUCCESS
}
}
X_IOCTL_DISK_GET_PARTITION_INFO => {
if output_buffer_len < 0x10 {
STATUS_BUFFER_TOO_SMALL
} else {
mem.write_u64(output_buffer, 0);
mem.write_u64(output_buffer + 8, CACHE_SIZE);
STATUS_SUCCESS
}
}
_ => {
tracing::warn!(
io_control_code = format!("0x{:X}", io_control_code),
"NtDeviceIoControlFile: unhandled IOCTL"
);
STATUS_INVALID_PARAMETER
}
};
let info = if status == STATUS_SUCCESS {
output_buffer_len
} else {
0
};
write_io_status_block(mem, io_status_block, status as u32, info);
ctx.gpr[3] = status;
signal_io_completion_event(state, event_handle);
}
/// Minimal `NtQueryInformationFile`. The only classes Sylpheed (and most
/// games) use are `FileStandardInformation` (5) and `FilePositionInformation`
/// (14). Anything else gets zeros + success.
@@ -5461,6 +5519,55 @@ mod tests {
assert!(state.xaudio.get(index).is_none());
}
/// FsCtlCode 0x70000 (drive geometry): canary writes
/// `cache_size/512` at OUT+0 and `512` at OUT+4 (both u32 BE).
#[test]
fn nt_device_io_control_file_drive_geometry() {
let (mut ctx, mem, mut state) = fresh();
let sp = SCRATCH_BASE + 0x800;
let iosb = SCRATCH_BASE + 0x100;
let out_buf = SCRATCH_BASE + 0x200;
ctx.gpr[1] = sp as u64;
ctx.gpr[3] = 0xF800_0010;
ctx.gpr[4] = 0;
ctx.gpr[7] = iosb as u64;
ctx.gpr[8] = 0x70000;
mem.write_u32(sp + 0x54, out_buf);
mem.write_u32(sp + 0x5C, 0x8);
nt_device_io_control_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
assert_eq!(mem.read_u32(out_buf), 0xFF000 / 512);
assert_eq!(mem.read_u32(out_buf + 4), 512);
assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32);
assert_eq!(mem.read_u32(iosb + 4), 0x8);
}
/// FsCtlCode 0x74004 (partition info): canary writes `0` at OUT+0 and
/// `cache_size = 0xFF000` at OUT+8 (both u64 BE). Sub_824ABD88 at
/// `0x824abe9c` reads OUT+8 and synthesizes `0xC0000034` if zero — a
/// non-zero value at OUT+8 is the entire fix.
#[test]
fn nt_device_io_control_file_partition_info_unblocks_gate() {
let (mut ctx, mem, mut state) = fresh();
let sp = SCRATCH_BASE + 0x800;
let iosb = SCRATCH_BASE + 0x100;
let out_buf = SCRATCH_BASE + 0x200;
ctx.gpr[1] = sp as u64;
ctx.gpr[3] = 0xF800_0010;
ctx.gpr[4] = 0;
ctx.gpr[7] = iosb as u64;
ctx.gpr[8] = 0x74004;
mem.write_u32(sp + 0x54, out_buf);
mem.write_u32(sp + 0x5C, 0x10);
nt_device_io_control_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
assert_eq!(mem.read_u64(out_buf), 0);
assert_eq!(mem.read_u64(out_buf + 8), 0xFF000);
assert_ne!(mem.read_u64(out_buf + 8), 0, "OUT+8 must be non-zero");
assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32);
assert_eq!(mem.read_u32(iosb + 4), 0x10);
}
/// Once a client is registered, the lockstep ticker eventually queues
/// a fire — proves the producer pipeline is wired end-to-end through
/// the kernel state.