feat(kernel): KRNBUG-IO-001 — NtReadFile on synth-empty file returns SUCCESS+0, not EOF

AUDIT-005's static attribution to sub_824ABA98 was wrong. The 0xC0000011
(STATUS_END_OF_FILE) at lr=0x824a97e4 traces to the NtReadFile call at
0x824a9810 inside sub_824A9710 — the cache-loader reads 1024 B from
offset 2048 of `\Device\Harddisk0\partition0`. Our synth-empty fallback
returned EOF (start_pos 2048 > size 0), so the function bailed via
RtlNtStatusToDosError before sub_824ABA98 was ever called.

Canary mounts partition0 to a NullDevice; `NullFile::ReadSync`
([null_file.cc:24-31](xenia-canary/src/xenia/vfs/devices/null_file.cc))
returns X_STATUS_SUCCESS with bytes_read=0 and never touches the
buffer. Sylpheed's caller pre-zeroes the 1024-byte stack buffer
(`memset(sp+208, 0, 1024)` at sub_824A9710 prologue), validates a
"Josh" magic on the first read, and falls back to the cache-recreate
path when the magic doesn't match.

The fix mirrors NullFile semantics: when the open synthesized a
zero-length file (`data.is_empty() && size == 0`), NtReadFile returns
SUCCESS with information=0 and the buffer untouched.

Effects (chain-of-effects verification at -n 500M):
  - tests: 590 → 591 (added regression covering NullDevice semantics)
  - lockstep: deterministic across 3 reruns (same instructions=100000010,
    swaps=2)
  - sylpheed_n50m golden re-baselined: instructions 50000004→50000000,
    imports 407416→407362
  - canary kernel-call diff: 10 → 7 missing exports
    (XeCryptSha + XeKeysConsolePrivateKeySign + NtDeviceIoControlFile
    now run; the cache-recreate path executes through to NtWriteFile)
  - boot reaches silph::Silph::Impl::OnInit: 19 worker threads spawn
    (was 6 before the fix)
  - parked-handle 0x1004 still signal_attempts=0; the original 0x100c
    and 0x15e0 are now <UNCREATED> because cascade walked past them and
    the handle assignments shifted; new parked sites: 0x12fc/0x1600/
    0x1040/0x10b8/0x15e8/0x1014/0x101c/0x10bc/0x1044
  - draws=0 plateau persists; renderer is multi-causal blocked

Next blocker: per the canary-only diff, XamTaskSchedule + the cluster
of XAM exports (XamTaskCloseHandle, XamUserReadProfileSettings,
ObCreateSymbolicLink) and the post-thread-exit chain (ExTerminateThread,
KeReleaseSemaphore, KeResetEvent) are the next-up frontier.
This commit is contained in:
MechaCat02
2026-05-04 20:20:10 +02:00
parent a6208a1249
commit bef9793aec
2 changed files with 57 additions and 2 deletions

View File

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

View File

@@ -943,6 +943,23 @@ fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState
}; };
let total = *size; let total = *size;
// Synthesized empty files (system partition opens like
// `\Device\Harddisk0\partition0` that miss the disc-VFS) act as
// NullDevice handles. Canary's `NullFile::ReadSync` returns
// `X_STATUS_SUCCESS` with `bytes_read=0` and never touches the buffer
// ([null_file.cc:24-31](xenia-canary/src/xenia/vfs/devices/null_file.cc));
// Sylpheed's cache loader at `sub_824A9710` reads 1024 B from offset
// 2048, expects success, then validates a `"Josh"` magic — falling back
// to the recreate path if the buffer (already zeroed by the caller via
// `memset(sp+208, 0, 1024)`) doesn't match.
if data.is_empty() && total == 0 {
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
ctx.gpr[3] = STATUS_SUCCESS;
signal_io_completion_event(state, event_handle);
return;
}
if start_pos >= total { if start_pos >= total {
write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0); write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0);
ctx.gpr[3] = STATUS_END_OF_FILE; ctx.gpr[3] = STATUS_END_OF_FILE;
@@ -3980,6 +3997,44 @@ mod tests {
assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected with null event"); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected with null event");
} }
/// Synthesized empty files (system-partition opens like
/// `\Device\Harddisk0\partition0` that miss the disc-VFS) act as
/// canary's `NullDevice`: any `NtReadFile` returns `STATUS_SUCCESS`
/// with `information=0` and the buffer untouched. Sylpheed's
/// cache-loader at `sub_824A9710` reads 1024 B from offset 2048 then
/// validates a `"Josh"` magic — falling back to the recreate path
/// when the (caller-zeroed) buffer doesn't match.
#[test]
fn nt_read_file_synth_empty_file_returns_success_with_zero_bytes() {
let (mut ctx, mut mem, mut state) = fresh();
let synth = make_file(&mut state, Vec::new());
// Pre-fill the buffer with a sentinel; canary's NullDevice never
// touches it, so the post-read bytes must be unchanged.
let buf: u32 = 0x4000_0100;
for i in 0..16u32 {
mem.write_u8(buf + i, 0xAB);
}
let evt = make_event(&mut state);
// Read 1024 B from offset 2048 — exactly the cache-catalog read.
let offset_ptr: u32 = 0x4000_0080;
mem.write_u64(offset_ptr, 2048);
ctx.gpr[3] = synth as u64;
ctx.gpr[4] = evt as u64;
ctx.gpr[7] = 0x4000_0000;
ctx.gpr[8] = buf as u64;
ctx.gpr[9] = 1024;
ctx.gpr[10] = offset_ptr as u64;
nt_read_file(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS for synth-empty read");
for i in 0..16u32 {
assert_eq!(mem.read_u8(buf + i), 0xAB, "buffer at +{} must be untouched", i);
}
// IOSB.information must be 0 (matches NullFile bytes_read).
assert_eq!(mem.read_u32(0x4000_0000), 0, "iosb.status = 0");
assert_eq!(mem.read_u32(0x4000_0004), 0, "iosb.information = 0");
assert!(event_signaled(&state, evt), "synth-empty read must signal completion");
}
#[test] #[test]
fn nt_write_file_signals_completion_event() { fn nt_write_file_signals_completion_event() {
let (mut ctx, mut mem, mut state) = fresh(); let (mut ctx, mut mem, mut state) = fresh();