[HLE] Fix AGC OOB, kevent coalescing, and VideoOut flip/buffer-registration bugs (#808)

sceAgcAddPrimStateRegisters (NID unconfirmed, resolved from the decrypted
eboot's call site) reads 32 register pairs from a buffer that
sceAgcCreatePrimState only partially fills, treating the unfilled part as
guest-stack garbage -- an out-of-bounds probe index sourced from that
garbage was the AV. Zero the unfilled part instead.

GPU interrupt kevents were being coalesced when several arrived close
together, but the AGC driver's interrupt thread expects exactly one
completion per delivered kevent -- coalescing silently dropped completions
and wedged its dependency counters. Deliver one kevent per trigger instead,
with a cap so an undrained queue can't grow unbounded.

sceVideoOutGetFlipStatus left an extended region of its output struct
un-zeroed; some titles poll a flag past the classic struct layout and spin
forever on garbage that never clears. Zero the whole region -- flips
already complete synchronously in this emulator, so it should read as
not-pending anyway.

sceVideoOutRegisterBuffers2 rejected any nonzero category/option, which
some titles use, making them unable to ever register display buffers or
flip. Treat unknown categories as the standard layout instead of failing
the registration.

Tested on Ghost of Yotei (PPSA26344): combined with the separate
ajm-acm-audio change, gets past its audio-init stall and renders/presents a
real GPU frame; neither change alone reaches that point.

Tested on Cult of the Lamb: calls sceAgcAddPrimStateRegisters 39 times, 0
crashes (previously an unresolved import on this exact NID).

Regression-checked on Demon's Souls (both with and without the separate
native-tls-fix change, to reach further into boot), Astro Bot, Outer Wilds,
Ghost of Tsushima, GTA, Minecraft, Quake: no change in behavior on any of
them.
This commit is contained in:
Foued Attar
2026-08-09 17:52:23 +02:00
committed by GitHub
parent 4b8d45520e
commit 498d402577
3 changed files with 93 additions and 10 deletions
+36
View File
@@ -2079,6 +2079,42 @@ public static partial class AgcExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Symbol name unconfirmed (not in ps5_names.txt); resolved from the
// decrypted eboot's call site only. On Ghost of Yotei, the caller scans
// this same buffer right after sceAgcCreatePrimState for 32 (offset,value)
// pairs (a hardcoded size, not read from any header) and open-address-
// probes them as a register hash table -- an out-of-bounds probe index
// sourced from an unwritten pair was the AV. CreatePrimState only
// populates the first 3 pairs; zero the rest of the scanned window so
// every unpopulated slot is a harmless failed probe instead of
// guest-stack garbage.
[SysAbiExport(
Nid = "dbOlWdppb4o",
ExportName = "sceAgcAddPrimStateRegisters",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int AddPrimStateRegisters(CpuContext ctx)
{
var ucRegistersAddress = ctx[CpuRegister.Rdi];
if (ucRegistersAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
const int prefilledPairBytes = 3 * 8; // sceAgcCreatePrimState's 3 (offset,value) pairs
const int scannedTableBytes = 0x20 * 8; // caller's hardcoded probe-window size
Span<byte> zero = stackalloc byte[scannedTableBytes - prefilledPairBytes];
zero.Clear();
if (!ctx.Memory.TryWrite(ucRegistersAddress + prefilledPairBytes, zero))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAgc($"agc.add_prim_state_registers uc=0x{ucRegistersAddress:X16}");
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// NID captured from shipped titles; the friendly name collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation.
#pragma warning disable SHEM004
[SysAbiExport(
@@ -1070,15 +1070,29 @@ public static class KernelEventQueueCompatExports
_pendingEvents[handle] = queue;
}
QueueOrUpdateEvent(
queue,
new KernelQueuedEvent(
registration.Ident,
registration.Filter,
registration.Flags,
1,
data,
registration.UserData));
// GPU interrupt events must not coalesce: the AGC driver's
// interrupt thread accounts exactly one completion per
// delivered kevent (it never reads the kevent payload), so
// merging N triggers into one pending entry silently drops
// N-1 completions and wedges its dependency counters. Queue
// a distinct entry per trigger, with a defensive cap so an
// undrained queue cannot grow without bound.
var queuedEvent = new KernelQueuedEvent(
registration.Ident,
registration.Filter,
registration.Flags,
1,
data,
registration.UserData);
if (CountPendingEvents(queue, registration.Ident, registration.Filter) < 256)
{
queue.AddLast(queuedEvent);
}
else
{
QueueOrUpdateEvent(queue, queuedEvent);
}
(wakeQueues ??= []).Add(state);
triggeredCount++;
@@ -1304,6 +1318,24 @@ public static class KernelEventQueueCompatExports
}
}
private static int CountPendingEvents(
KernelEventDeque queue,
ulong ident,
short filter)
{
var count = 0;
for (var i = 0; i < queue.Count; i++)
{
var pending = queue[i];
if (pending.Ident == ident && pending.Filter == filter)
{
count++;
}
}
return count;
}
private static void QueueOrUpdateEvent(
KernelEventDeque queue,
KernelQueuedEvent queuedEvent)
+16 -1
View File
@@ -729,6 +729,14 @@ public static class VideoOutExports
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x10, 0);
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x18, 0);
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x20, currentBuffer);
// Ghost of Yotei polls a flag past the classic 0x28-byte struct and
// spins on sceKernelUsleep(1) while it's nonzero; the caller never
// pre-zeroes that stack buffer, so an untouched field reads back as
// garbage. Flips complete synchronously in this emulator (see
// SubmitFlip/sceVideoOutIsFlipPending, always not-pending), so the
// extended region must read zero here too.
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x28, 0);
KernelMemoryCompatExports.TryWriteUInt64Compat(ctx, statusAddress + 0x30, 0);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1114,7 +1122,14 @@ public static class VideoOutExports
if (category > 1 || option != 0)
{
return OrbisVideoOutErrorInvalidValue;
// Ghost of Yotei registers its display buffers with a nonzero
// category/option pair; rejecting the registration guarantees the
// title can never flip. Treat unknown categories as the standard
// uncompressed layout instead of failing the whole registration.
TraceVideoOut(
$"register_buffers2 nonstandard category=0x{categoryRaw:X} " +
$"option=0x{option:X} handle={handle} set={setIndex} " +
$"start={bufferIndexStart} count={bufferNum}");
}
if (!TryReadBufferAttribute(ctx, attributeAddress, true, out var attribute))