fix(audio): harden AudioOut2 stack out-buffer writes against canary smash (#532)

Titles that stack-allocate AudioOut2 outs next to the frame canary were
corrupted by oversized or mistyped HLE writes; keep ContextPush pacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MarcelMediaDev
2026-07-23 13:34:40 +01:00
committed by GitHub
parent 956da769a3
commit e13cb28267
5 changed files with 657 additions and 72 deletions
+389 -71
View File
@@ -15,16 +15,40 @@ public static class AudioOut2Exports
// Clearing 0x80 bytes here overwrote the caller's stack canary immediately // Clearing 0x80 bytes here overwrote the caller's stack canary immediately
// following the 0x40-byte parameter block. // following the 0x40-byte parameter block.
private const int AudioOut2ContextParamSize = 0x40; private const int AudioOut2ContextParamSize = 0x40;
private const int AudioOut2ContextMemorySize = 0x10000; // Keep these modest. Some Prospero titles stack-allocate QueryMemory results
private const int AudioOut2ContextMemoryAlignment = 0x10000; // next to the frame canary: a 16-byte {size,align} write to [rbp-0x38] plants
// align at [rbp-0x30] (observed canary=0x100). Size-only (8 bytes) on stack.
private const int AudioOut2ContextMemorySize = 0x4000;
private const int AudioOut2ContextMemoryAlignment = 0x100;
// Exact object body size. Do not page-align to 64K — callers that
// stack-allocate from this size planted 0x10000 on the canary with a 64K VLA.
private const int SpeakerArrayHeaderSize = 0x40;
private const int SpeakerArrayEntrySize = 0x100;
// Extra scratch the title writes after the per-channel entries (coefficients).
private const int SpeakerArrayScratchBytes = 0x400;
private const uint SpeakerArrayDefaultChannels = 8;
private const uint SpeakerArrayMaxChannels = 32;
// Field read by titles at object+0x34 (mov eax,[rbx+0x34]).
private const int SpeakerArrayDivisorFieldOffset = 0x34;
private const int SpeakerArrayResultFieldOffset = 0x3C;
private const uint SpeakerArrayDefaultDivisor = 1;
private const int SpeakerArrayCoefficientBytes = 0x400;
// OrbisAudioOutPortState is 0x20 bytes. Never grow this from r8/r9 — those
// regs arrive polluted with GetSize leftovers (0x840/0x10C/0x180) and caused
// PortGetState/GetSpeakerInfo to overwrite the speaker-array param block
// (param+0x18 == first PortGetState out) and smash the Main Thread canary
// with ContextMemoryAlignment (0x100).
private const int PortStateSize = 0x20;
private const int SpeakerInfoSize = 0x20;
private const ushort PortStateOutputConnectedPrimary = 0x01;
private static long _nextContextHandle = 1; private static long _nextContextHandle = 1;
private static long _nextUserHandle = 1; private static long _nextUserHandle = 1;
private static int _nextPortId; private static int _nextPortId;
private static long _pushTraceCount; private static long _pushTraceCount;
// Per-context audio parameters captured at ContextCreate so ContextAdvance private static readonly ConcurrentDictionary<ulong, byte> SpeakerArrays = new();
// can pace to the real playback cadence (grain samples at the sample rate).
private static readonly ConcurrentDictionary<ulong, ContextState> Contexts = new(); private static readonly ConcurrentDictionary<ulong, ContextState> Contexts = new();
private static readonly ConcurrentDictionary<ulong, int> Ports = new();
private sealed class ContextState private sealed class ContextState
{ {
@@ -42,9 +66,6 @@ public static class AudioOut2Exports
public uint Channels { get; } public uint Channels { get; }
public uint GrainSamples { get; } public uint GrainSamples { get; }
// Blocks the advancing thread until one grain worth of wall-clock time
// has elapsed since the previous advance, matching hardware timing so
// audio-gated titles neither spin nor drift ahead.
public void PaceAdvance() public void PaceAdvance()
{ {
long delay; long delay;
@@ -112,19 +133,36 @@ public static class AudioOut2Exports
public static int AudioOut2ContextQueryMemory(CpuContext ctx) public static int AudioOut2ContextQueryMemory(CpuContext ctx)
{ {
var paramAddress = ctx[CpuRegister.Rdi]; var paramAddress = ctx[CpuRegister.Rdi];
var memoryInfoAddress = ctx[CpuRegister.Rsi]; var memoryInfoAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rsi], ctx[CpuRegister.Rdx]);
if (paramAddress == 0 || memoryInfoAddress == 0) if (paramAddress == 0 || memoryInfoAddress == 0)
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
Span<byte> memoryInfo = stackalloc byte[0x20]; // Heap: {size, alignment} (16 bytes), matching sceAudioPropagationSystemQueryMemory.
// Stack: SIZE ONLY as a full ulong (8 bytes). Writing alignment at +8 is how
// [rbp-0x30] became 0x100 on GTA V Enhanced. Do NOT shrink this to uint32 —
// Main reads the out as a 64-bit size; a 4-byte write leaves a garbage high
// dword (observed 0x7<<32|0x4000) and the allocator aborts with int 0x41.
if (IsGuestStackAddress(memoryInfoAddress))
{
Span<byte> sizeOnly = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(sizeOnly, AudioOut2ContextMemorySize);
TraceAudioOut2(
$"context-query-memory stack-size-only out=0x{memoryInfoAddress:X} " +
$"size=0x{AudioOut2ContextMemorySize:X}");
return ctx.Memory.TryWrite(memoryInfoAddress, sizeOnly)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Span<byte> memoryInfo = stackalloc byte[0x10];
memoryInfo.Clear(); memoryInfo.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x00..], AudioOut2ContextMemorySize); BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x00..], AudioOut2ContextMemorySize);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x08..], AudioOut2ContextMemoryAlignment); BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x08..], AudioOut2ContextMemoryAlignment);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x10..], AudioOut2ContextMemorySize); TraceAudioOut2(
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x18..], AudioOut2ContextMemoryAlignment); $"context-query-memory out=0x{memoryInfoAddress:X} " +
$"size=0x{AudioOut2ContextMemorySize:X} align=0x{AudioOut2ContextMemoryAlignment:X}");
return ctx.Memory.TryWrite(memoryInfoAddress, memoryInfo) return ctx.Memory.TryWrite(memoryInfoAddress, memoryInfo)
? SetReturn(ctx, 0) ? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -146,8 +184,6 @@ public static class AudioOut2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
// Read channels/frequency/grain from the reset-param blob so the
// context can pace advances to the real audio cadence.
uint channels = 2; uint channels = 2;
uint frequency = 48000; uint frequency = 48000;
uint grain = 256; uint grain = 256;
@@ -159,8 +195,6 @@ public static class AudioOut2Exports
var pg = BinaryPrimitives.ReadUInt32LittleEndian(param[0x0C..]); var pg = BinaryPrimitives.ReadUInt32LittleEndian(param[0x0C..]);
if (pc is > 0 and <= 8) channels = pc; if (pc is > 0 and <= 8) channels = pc;
if (pf is >= 8000 and <= 192000) frequency = pf; if (pf is >= 8000 and <= 192000) frequency = pf;
// Values below one cache line are flags/counts in observed PS5
// callers, not audio grains. Keep the hardware-sized default.
if (pg is >= 64 and <= 0x4000) grain = pg; if (pg is >= 64 and <= 0x4000) grain = pg;
TraceAudioOut2($"context-param address=0x{paramAddress:X} bytes={Convert.ToHexString(param)}"); TraceAudioOut2($"context-param address=0x{paramAddress:X} bytes={Convert.ToHexString(param)}");
} }
@@ -199,17 +233,16 @@ public static class AudioOut2Exports
public static int AudioOut2ContextPush(CpuContext ctx) public static int AudioOut2ContextPush(CpuContext ctx)
{ {
var handle = ctx[CpuRegister.Rdi]; var handle = ctx[CpuRegister.Rdi];
var traceCount = Interlocked.Increment(ref _pushTraceCount); if (Interlocked.Increment(ref _pushTraceCount) <= 8)
if (traceCount <= 16)
{ {
TraceAudioOut2($"context-push count={traceCount} rdi=0x{handle:X} rsi=0x{ctx[CpuRegister.Rsi]:X} rdx=0x{ctx[CpuRegister.Rdx]:X} rcx=0x{ctx[CpuRegister.Rcx]:X}"); TraceAudioOut2($"context-push handle=0x{handle:X} data=0x{ctx[CpuRegister.Rsi]:X}");
} }
// FMOD's PS5 output path uses ContextPush as the submission clock and
// does not call ContextAdvance. Pace pushes to one hardware grain so
// the feeder cannot outrun playback and starve the title.
if (Contexts.TryGetValue(handle, out var context)) if (Contexts.TryGetValue(handle, out var context))
{ {
// FMOD's PS5 output path uses ContextPush as the submission clock
// and does not call ContextAdvance. Pace pushes to one hardware
// grain so the feeder cannot outrun playback and starve the game.
context.PaceAdvance(); context.PaceAdvance();
} }
@@ -223,11 +256,9 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextAdvance(CpuContext ctx) public static int AudioOut2ContextAdvance(CpuContext ctx)
{ {
// Advancing renders one grain of audio on hardware; pace it to the same if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var state))
// wall-clock cadence so the guest audio thread runs at the right speed.
if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var context))
{ {
context.PaceAdvance(); state.PaceAdvance();
} }
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
@@ -240,38 +271,35 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextGetQueueLevel(CpuContext ctx) public static int AudioOut2ContextGetQueueLevel(CpuContext ctx)
{ {
// The advance path paces synchronously, so the queue is always drained. // ABI out is a 32-bit queue depth (callers compare dword [out]). A
var levelAddress = ctx[CpuRegister.Rsi]; // uint64 write into a stack slot at [rbp-0x14] next to the canary at
if (levelAddress != 0) // [rbp-0x10] zeroed the canary low half and aborted the audio thread.
var outLevelAddress = ctx[CpuRegister.Rsi];
if (outLevelAddress == 0)
{ {
_ = TryWriteUInt64(ctx, levelAddress, 0); outLevelAddress = ctx[CpuRegister.Rdx];
}
if (outLevelAddress != 0)
{
Span<byte> level = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(level, 0);
if (!ctx.Memory.TryWrite(outLevelAddress, level))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
} }
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
[SysAbiExport( [SysAbiExport(
Nid = "JK2wamZPzwM", Nid = "Q8DZkKQ-SYc",
ExportName = "sceAudioOut2PortCreate", ExportName = "sceAudioOut2LoContextGetQueueLevel",
Target = Generation.Gen5, Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortCreate(CpuContext ctx) public static int AudioOut2LoContextGetQueueLevel(CpuContext ctx) =>
{ AudioOut2ContextGetQueueLevel(ctx);
var type = unchecked((int)ctx[CpuRegister.Rdi]);
var paramAddress = ctx[CpuRegister.Rsi];
var outPortAddress = ctx[CpuRegister.Rdx];
var contextAddress = ctx[CpuRegister.Rcx];
if (type < 0 || type > 255 || paramAddress == 0 || outPortAddress == 0 || contextAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var portId = unchecked((uint)Interlocked.Increment(ref _nextPortId)) & 0xFF;
var handle = 0x2000_0000UL | ((ulong)(uint)type << 16) | portId;
return TryWriteUInt64(ctx, outPortAddress, handle)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport( [SysAbiExport(
Nid = "8XTArSPyWHk", Nid = "8XTArSPyWHk",
@@ -280,6 +308,39 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortSetAttributes(CpuContext ctx) => SetReturn(ctx, 0); public static int AudioOut2PortSetAttributes(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(
Nid = "JK2wamZPzwM",
ExportName = "sceAudioOut2PortCreate",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortCreate(CpuContext ctx)
{
// rdi=user/context, rsi=type, rdx=outPort* (fallback rcx if rdx unusable).
var outPortAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rdx], ctx[CpuRegister.Rcx]);
if (outPortAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var type = unchecked((int)ctx[CpuRegister.Rsi]);
if (type is < 0 or > 0x100)
{
type = 0;
}
var portId = (uint)Interlocked.Increment(ref _nextPortId);
var handle = 0x2000_0000UL | ((ulong)(uint)type << 16) | portId;
Ports[handle] = type;
if (!TryWriteUInt64(ctx, outPortAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAudioOut2($"port-create handle=0x{handle:X} type={type} out=0x{outPortAddress:X}");
return SetReturn(ctx, 0);
}
// Fixed-size connected stereo state. Do not trust r8/r9 for byte counts.
[SysAbiExport( [SysAbiExport(
Nid = "gatEUKG+Ea4", Nid = "gatEUKG+Ea4",
ExportName = "sceAudioOut2PortGetState", ExportName = "sceAudioOut2PortGetState",
@@ -287,27 +348,44 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortGetState(CpuContext ctx) public static int AudioOut2PortGetState(CpuContext ctx)
{ {
var handle = ctx[CpuRegister.Rdi]; var portHandle = ctx[CpuRegister.Rdi];
var stateAddress = ctx[CpuRegister.Rsi]; var stateAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rsi], ctx[CpuRegister.Rdx]);
if (handle == 0 || stateAddress == 0) if (stateAddress == 0)
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
var type = (int)((handle >> 16) & 0xFF); // Stack out-buffers with garbage handles were writing 0x20 bytes over
Span<byte> state = stackalloc byte[0x20]; // caller frames / canaries (state=0x7FFFDE1FF688 right before fail).
// Heap outs still get a real state blob even when the handle wasn't
// minted by PortCreate — some titles synthesize port ids themselves.
if (IsGuestStackAddress(stateAddress))
{
TraceAudioOut2(
$"port-get-state skip-stack handle=0x{portHandle:X} state=0x{stateAddress:X}");
return SetReturn(ctx, 0);
}
Span<byte> state = stackalloc byte[PortStateSize];
state.Clear(); state.Clear();
var output = type == 2 ? 0x40 : 0x01; // +0x00 u16 output = CONNECTED_PRIMARY (1)
var channels = type == 2 ? 1 : 2; // +0x02 u8 channels = 2
BinaryPrimitives.WriteUInt16LittleEndian(state[0x00..], unchecked((ushort)output)); // +0x04 s16 volume = -1 (N/A for main)
state[0x02] = unchecked((byte)channels); BinaryPrimitives.WriteUInt16LittleEndian(state[0x00..], PortStateOutputConnectedPrimary);
state[0x02] = 2;
BinaryPrimitives.WriteInt16LittleEndian(state[0x04..], -1); BinaryPrimitives.WriteInt16LittleEndian(state[0x04..], -1);
return ctx.Memory.TryWrite(stateAddress, state) if (!ctx.Memory.TryWrite(stateAddress, state))
? SetReturn(ctx, 0) {
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAudioOut2(
$"port-get-state handle=0x{portHandle:X} state=0x{stateAddress:X} bytes=0x{PortStateSize:X}");
return SetReturn(ctx, 0);
} }
// rdi=out buffer, rsi=type/flag (not a pointer). Fixed-size write only.
[SysAbiExport( [SysAbiExport(
Nid = "DImz2Ft9E2g", Nid = "DImz2Ft9E2g",
ExportName = "sceAudioOut2GetSpeakerInfo", ExportName = "sceAudioOut2GetSpeakerInfo",
@@ -315,21 +393,134 @@ public static class AudioOut2Exports
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2GetSpeakerInfo(CpuContext ctx) public static int AudioOut2GetSpeakerInfo(CpuContext ctx)
{ {
var infoAddress = ctx[CpuRegister.Rdi]; var infoAddress = ResolveGuestOutBuffer(ctx[CpuRegister.Rdi], ctx[CpuRegister.Rdx]);
if (infoAddress == 0) if (infoAddress == 0)
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
Span<byte> info = stackalloc byte[0x40]; // Same rule as PortGetState — never bulk-write speaker info onto the stack.
info.Clear(); if (IsGuestStackAddress(infoAddress))
BinaryPrimitives.WriteUInt32LittleEndian(info[0x00..], 1); {
BinaryPrimitives.WriteUInt32LittleEndian(info[0x04..], 2); TraceAudioOut2($"get-speaker-info skip-stack out=0x{infoAddress:X}");
BinaryPrimitives.WriteUInt32LittleEndian(info[0x08..], 48000); return SetReturn(ctx, 0);
}
return ctx.Memory.TryWrite(infoAddress, info) Span<byte> info = stackalloc byte[SpeakerInfoSize];
? SetReturn(ctx, 0) info.Clear();
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); BinaryPrimitives.WriteUInt32LittleEndian(info[0x00..], 2);
BinaryPrimitives.WriteUInt32LittleEndian(info[0x04..], 48000);
BinaryPrimitives.WriteUInt16LittleEndian(info[0x08..], PortStateOutputConnectedPrimary);
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAudioOut2(
$"get-speaker-info out=0x{infoAddress:X} type=0x{ctx[CpuRegister.Rsi]:X} bytes=0x{SpeakerInfoSize:X}");
return SetReturn(ctx, 0);
}
// Matches sceAudio3dGetSpeakerArrayMemorySize(uiNumSpeakers, bIs3d): size is
// returned directly in rax. Exact channel-scaled body — never a 64K slab.
[SysAbiExport(
Nid = "G1YOKDJYX2Y",
ExportName = "sceAudioOut2GetSpeakerArrayMemorySize",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2GetSpeakerArrayMemorySize(CpuContext ctx)
{
var numChannels = (uint)ctx[CpuRegister.Rdi];
if (numChannels == 0 || numChannels > SpeakerArrayMaxChannels)
{
numChannels = SpeakerArrayDefaultChannels;
}
var size = ComputeSpeakerArrayBytes(numChannels);
TraceAudioOut2(
$"speaker-array-get-size rdi=0x{ctx[CpuRegister.Rdi]:X} " +
$"rsi=0x{ctx[CpuRegister.Rsi]:X} rdx=0x{ctx[CpuRegister.Rdx]:X} -> 0x{size:X}");
ctx[CpuRegister.Rax] = unchecked((ulong)size);
return size;
}
[SysAbiExport(
Nid = "4BlZurolOAo",
ExportName = "sceAudioOut2GetSpeakerArrayCoefficients",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2GetSpeakerArrayCoefficients(CpuContext ctx) =>
WriteZeroSpeakerArrayCoefficients(ctx, "coefficients");
[SysAbiExport(
Nid = "28QqMnuuJ9Y",
ExportName = "sceAudioOut2GetSpeakerArrayAmbisonicsCoefficients",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2GetSpeakerArrayAmbisonicsCoefficients(CpuContext ctx) =>
WriteZeroSpeakerArrayCoefficients(ctx, "ambisonics-coefficients");
// rdi = param (may share a heap slab with PortGetState/GetSpeakerInfo outs —
// do NOT read buffer*/size* from it). rsi = &outHandle, rdx = reserved/size
// slot (leave alone), rcx = channels. Always heap-allocate a fresh object.
[SysAbiExport(
Nid = "+k91hoTuoA8",
ExportName = "sceAudioOut2SpeakerArrayCreate",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2SpeakerArrayCreate(CpuContext ctx)
{
var param = ctx[CpuRegister.Rdi];
var outHandleAddress = ctx[CpuRegister.Rsi];
var outReservedAddress = ctx[CpuRegister.Rdx];
var channels = (uint)ctx[CpuRegister.Rcx];
if (channels == 0 || channels > SpeakerArrayMaxChannels)
{
channels = SpeakerArrayDefaultChannels;
}
if (outHandleAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var bytes = ComputeSpeakerArrayBytes(channels);
if (!TryAllocateSpeakerArrayMemory(ctx, (ulong)bytes, out var memory) ||
!InitializeSpeakerArrayObject(ctx, memory, channels))
{
TraceAudioOut2(
$"speaker-array-create alloc-failed bytes=0x{bytes:X} channels={channels}");
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
SpeakerArrays[memory] = 0;
// Publish ONLY the out-handle slot. rdx is often an adjacent size /
// reserved local on the caller stack — writing it previously fed canary
// corruption.
if (!TryWriteUInt64(ctx, outHandleAddress, memory))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAudioOut2(
$"speaker-array-create object=0x{memory:X} bytes=0x{bytes:X} " +
$"channels={channels} param=0x{param:X} out=0x{outHandleAddress:X} " +
$"reserved=0x{outReservedAddress:X} (untouched)");
ctx[CpuRegister.Rax] = memory;
return 0;
}
[SysAbiExport(
Nid = "erCWQR5eKiQ",
ExportName = "sceAudioOut2SpeakerArrayDestroy",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2SpeakerArrayDestroy(CpuContext ctx)
{
SpeakerArrays.TryRemove(ctx[CpuRegister.Rdi], out _);
return SetReturn(ctx, 0);
} }
[SysAbiExport( [SysAbiExport(
@@ -337,7 +528,11 @@ public static class AudioOut2Exports
ExportName = "sceAudioOut2PortDestroy", ExportName = "sceAudioOut2PortDestroy",
Target = Generation.Gen5, Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")] LibraryName = "libSceAudioOut2")]
public static int AudioOut2PortDestroy(CpuContext ctx) => SetReturn(ctx, 0); public static int AudioOut2PortDestroy(CpuContext ctx)
{
Ports.TryRemove(ctx[CpuRegister.Rdi], out _);
return SetReturn(ctx, 0);
}
[SysAbiExport( [SysAbiExport(
Nid = "IaZXJ9M79uo", Nid = "IaZXJ9M79uo",
@@ -367,6 +562,129 @@ public static class AudioOut2Exports
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
private static int ComputeSpeakerArrayBytes(uint channels) =>
SpeakerArrayHeaderSize + (int)(channels * SpeakerArrayEntrySize) + SpeakerArrayScratchBytes;
private static bool InitializeSpeakerArrayObject(CpuContext ctx, ulong memory, uint channels)
{
// Header only — never wipe the full GetSize slab (and never touch stack).
Span<byte> body = stackalloc byte[SpeakerArrayHeaderSize];
body.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(body[0x00..], (uint)SpeakerArrayHeaderSize);
BinaryPrimitives.WriteUInt32LittleEndian(body[0x04..], channels);
BinaryPrimitives.WriteUInt32LittleEndian(body[SpeakerArrayDivisorFieldOffset..], SpeakerArrayDefaultDivisor);
BinaryPrimitives.WriteUInt32LittleEndian(body[SpeakerArrayResultFieldOffset..], 0);
return ctx.Memory.TryWrite(memory, body);
}
// Prefer the high guest arena (0x6000_xxxx). TryAllocateHleData advances
// _nextVirtualAddress into the title's direct-memory window (~0x1559_xxxx);
// publishing an object there made sceKernelBatchMap(fixed, 0x1559C80000,
// 0x20000) return NOT_FOUND and abort RenderThread with int 0x41.
// Never mint the old 0x1559C0xxxx "cookie" pointers — they are unmapped and
// collide with dmem VAs.
private static bool TryAllocateSpeakerArrayMemory(CpuContext ctx, ulong bytes, out ulong memory)
{
memory = 0;
var length = Math.Max(bytes, 0x1000UL);
if (TryAllocateViaGuestAllocator(ctx, length, 0x1000, out memory) &&
IsSafeSpeakerArrayAddress(memory))
{
return true;
}
if (Kernel.KernelMemoryCompatExports.TryAllocateHleData(ctx, length, 0x1000, out memory) &&
IsSafeSpeakerArrayAddress(memory))
{
return true;
}
memory = 0;
return false;
}
private static bool TryAllocateViaGuestAllocator(CpuContext ctx, ulong length, ulong alignment, out ulong memory)
{
memory = 0;
var allocator = ctx.Memory as IGuestMemoryAllocator;
if (allocator is null && ctx.Memory is ICpuMemoryWrapper { Inner: IGuestMemoryAllocator inner })
{
allocator = inner;
}
return allocator is not null && allocator.TryAllocateGuestMemory(length, alignment, out memory);
}
private static bool IsSafeSpeakerArrayAddress(ulong value) =>
IsPlausibleGuestObjectPointer(value) &&
!IsGuestStackAddress(value) &&
!IsDirectMemoryWindowAddress(value);
// BatchMap fixed dmem VAs have been observed around 0x1559_xxxx_xxxx.
// Keep HLE speaker-array objects out of that window.
private static bool IsDirectMemoryWindowAddress(ulong value) =>
value >= 0x0000_1400_0000_0000UL && value < 0x0000_1800_0000_0000UL;
private static bool IsPlausibleGuestObjectPointer(ulong value) =>
value >= 0x1000_0000UL &&
value != 0x10000UL &&
value < 0x0000_8000_0000_0000UL;
// Windows user stacks sit in 0x00007FFFxxxxxxxx. Never treat those as
// heap objects we can bulk-initialize.
private static bool IsGuestStackAddress(ulong value) =>
value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
private static ulong ResolveGuestOutBuffer(ulong primary, ulong secondary)
{
// Accept heap or stack out-buffers (PortGetState legitimately uses both),
// but never small integers / size constants.
if (IsWritableOutBuffer(primary))
{
return primary;
}
if (IsWritableOutBuffer(secondary))
{
return secondary;
}
return 0;
}
private static bool IsWritableOutBuffer(ulong value) =>
value != 0 &&
value != 0x10000UL &&
value >= 0x1000UL &&
(IsPlausibleGuestObjectPointer(value) || IsGuestStackAddress(value));
private static int WriteZeroSpeakerArrayCoefficients(CpuContext ctx, string label)
{
var destination = ctx[CpuRegister.Rsi];
if (destination == 0)
{
destination = ctx[CpuRegister.Rdx];
}
// Coefficients are large — only wipe real heap objects, never stack.
if (destination != 0 &&
IsPlausibleGuestObjectPointer(destination) &&
!IsGuestStackAddress(destination))
{
Span<byte> zeros = stackalloc byte[SpeakerArrayCoefficientBytes];
zeros.Clear();
if (!ctx.Memory.TryWrite(destination, zeros))
{
TraceAudioOut2($"{label} write-failed dest=0x{destination:X}");
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
TraceAudioOut2($"{label} ok dest=0x{destination:X}");
return SetReturn(ctx, 0);
}
private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value) private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value)
{ {
Span<byte> buffer = stackalloc byte[sizeof(ulong)]; Span<byte> buffer = stackalloc byte[sizeof(ulong)];
@@ -175,6 +175,14 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
// Same rule as AudioOut2 PortGetState: never bulk-write onto the caller
// stack. Some titles place small locals next to the canary; a full
// SceAudioOutPortState write smashes it.
if (IsGuestStackAddress(stateAddress))
{
return ctx.SetReturn(0);
}
// SceAudioOutPortState: report a connected primary output at full volume // SceAudioOutPortState: report a connected primary output at full volume
// so pacing/mixing code sees a live port. We do no host rerouting, so // so pacing/mixing code sees a live port. We do no host rerouting, so
// rerouteCounter and flag stay zero. // rerouteCounter and flag stay zero.
@@ -192,6 +200,9 @@ public static class AudioOutExports
return ctx.SetReturn(0); return ctx.SetReturn(0);
} }
private static bool IsGuestStackAddress(ulong value) =>
value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
[SysAbiExport( [SysAbiExport(
Nid = "QOQtbeDqsT4", Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput", ExportName = "sceAudioOutOutput",
@@ -0,0 +1,86 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
public sealed class AudioOut2PortGetStateExportsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong StateAddress = MemoryBase + 0x100;
private static CpuContext CreateContext(out FakeCpuMemory memory)
{
memory = new FakeCpuMemory(MemoryBase, 0x1000);
return new CpuContext(memory, Generation.Gen5);
}
[Fact]
public void PortGetState_WritesFixedSizeIgnoringPollutedR9()
{
var ctx = CreateContext(out var memory);
// Paint the buffer so we can see the write footprint.
Span<byte> paint = stackalloc byte[0x100];
paint.Fill(0xAB);
Assert.True(memory.TryWrite(StateAddress, paint));
ctx[CpuRegister.Rdi] = 0xDE1FF6800001UL;
ctx[CpuRegister.Rsi] = StateAddress;
ctx[CpuRegister.Rdx] = StateAddress + 0x200;
// Polluted GetSize leftover — must NOT enlarge the write.
ctx[CpuRegister.R9] = 0x180;
var result = AudioOut2Exports.AudioOut2PortGetState(ctx);
Assert.Equal(0, result);
Span<byte> state = stackalloc byte[0x100];
Assert.True(memory.TryRead(StateAddress, state));
Assert.Equal(1, BinaryPrimitives.ReadUInt16LittleEndian(state));
Assert.Equal(2, state[2]);
// Bytes past the fixed 0x20 header must remain untouched.
Assert.Equal(0xAB, state[0x20]);
Assert.Equal(0xAB, state[0x7F]);
}
[Fact]
public void PortGetState_SkipsGuestStackOutBuffer()
{
var ctx = CreateContext(out _);
const ulong stackOut = 0x00007FFFDE1FF688UL;
ctx[CpuRegister.Rdi] = 0xDE1FF688004DUL;
ctx[CpuRegister.Rsi] = stackOut;
ctx[CpuRegister.Rdx] = 0;
var result = AudioOut2Exports.AudioOut2PortGetState(ctx);
Assert.Equal(0, result);
}
[Fact]
public void GetSpeakerInfo_WritesFixedSizeToRdiNotRsiTypeFlag()
{
var ctx = CreateContext(out var memory);
Span<byte> paint = stackalloc byte[0x80];
paint.Fill(0xCD);
Assert.True(memory.TryWrite(StateAddress, paint));
ctx[CpuRegister.Rdi] = StateAddress;
ctx[CpuRegister.Rsi] = 1;
ctx[CpuRegister.Rdx] = StateAddress + 0x200;
ctx[CpuRegister.R8] = 0x840;
ctx[CpuRegister.R9] = 0x10C;
var result = AudioOut2Exports.AudioOut2GetSpeakerInfo(ctx);
Assert.Equal(0, result);
Span<byte> info = stackalloc byte[0x80];
Assert.True(memory.TryRead(StateAddress, info));
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(info));
Assert.Equal(48000u, BinaryPrimitives.ReadUInt32LittleEndian(info[4..]));
Assert.Equal(0xCD, info[0x20]);
}
}
@@ -0,0 +1,140 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
public sealed class AudioOut2SpeakerArrayExportsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong OutHandleAddress = MemoryBase + 0x100;
private const ulong ReservedAddress = MemoryBase + 0x120;
private const ulong ParamAddress = MemoryBase + 0x200;
private const ulong SpeakerMemoryAddress = MemoryBase + 0x400;
private static CpuContext CreateContext(out FakeCpuMemory memory)
{
memory = new FakeCpuMemory(MemoryBase, 0x2000);
return new CpuContext(memory, Generation.Gen5);
}
private static void WriteU64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> bytes = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
Assert.True(memory.TryWrite(address, bytes));
}
private static ulong ReadU64(FakeCpuMemory memory, ulong address)
{
Span<byte> bytes = stackalloc byte[8];
Assert.True(memory.TryRead(address, bytes));
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
}
private static uint ReadU32(FakeCpuMemory memory, ulong address)
{
Span<byte> bytes = stackalloc byte[4];
Assert.True(memory.TryRead(address, bytes));
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
}
[Fact]
public void GetSpeakerArrayMemorySize_NeverReturnsTheNotFoundSentinel()
{
var ctx = CreateContext(out _);
ctx[CpuRegister.Rdi] = 8;
var result = AudioOut2Exports.AudioOut2GetSpeakerArrayMemorySize(ctx);
Assert.NotEqual((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
Assert.Equal(0x40 + 8 * 0x100 + 0x400, result);
Assert.Equal((ulong)result, ctx[CpuRegister.Rax]);
Assert.True(result < 0x10000);
}
[Fact]
public void GetSpeakerArrayMemorySize_TwoChannelsIsExactChannelScaledSize()
{
var ctx = CreateContext(out _);
ctx[CpuRegister.Rdi] = 2;
var result = AudioOut2Exports.AudioOut2GetSpeakerArrayMemorySize(ctx);
Assert.Equal(0x40 + 2 * 0x100 + 0x400, result);
Assert.Equal(0x640UL, ctx[CpuRegister.Rax]);
}
[Fact]
public void SpeakerArrayCreate_PublishesObjectPointerAndLeavesReservedSizeAlone()
{
var ctx = CreateContext(out var memory);
// Stage a size in the reserved slot the way callers do before Create.
WriteU64(memory, ReservedAddress, 0x100);
ctx[CpuRegister.Rdi] = ParamAddress;
ctx[CpuRegister.Rsi] = OutHandleAddress;
ctx[CpuRegister.Rdx] = ReservedAddress;
ctx[CpuRegister.Rcx] = 2;
var result = AudioOut2Exports.AudioOut2SpeakerArrayCreate(ctx);
Assert.Equal(0, result);
Assert.NotEqual(0UL, ctx[CpuRegister.Rax]);
Assert.NotEqual(0x100UL, ctx[CpuRegister.Rax]);
Assert.Equal(ctx[CpuRegister.Rax], ReadU64(memory, OutHandleAddress));
// Reserved/size slot must remain untouched — writing it corrupted canaries.
Assert.Equal(0x100UL, ReadU64(memory, ReservedAddress));
}
[Fact]
public void SpeakerArrayCreate_PublishesHandleForTypicalCallShape()
{
var ctx = CreateContext(out _);
ctx[CpuRegister.Rdi] = ParamAddress;
ctx[CpuRegister.Rsi] = OutHandleAddress;
ctx[CpuRegister.Rdx] = ReservedAddress;
ctx[CpuRegister.Rcx] = 2;
var result = AudioOut2Exports.AudioOut2SpeakerArrayCreate(ctx);
Assert.Equal(0, result);
Assert.NotEqual(0UL, ctx[CpuRegister.Rax]);
Assert.NotEqual(0x10000UL, ctx[CpuRegister.Rax]);
Assert.NotEqual((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
}
[Fact]
public void SpeakerArrayCreate_IgnoresCorruptedParamBufferFields()
{
var ctx = CreateContext(out var memory);
// Simulate PortGetState having overwritten param+0x18 (size) with a
// state blob — Create must NOT adopt that as an in-place buffer.
WriteU64(memory, ParamAddress + 0x10, SpeakerMemoryAddress);
WriteU64(memory, ParamAddress + 0x18, 0x100);
ctx[CpuRegister.Rdi] = ParamAddress;
ctx[CpuRegister.Rsi] = OutHandleAddress;
ctx[CpuRegister.Rdx] = ReservedAddress;
ctx[CpuRegister.Rcx] = 2;
var result = AudioOut2Exports.AudioOut2SpeakerArrayCreate(ctx);
Assert.Equal(0, result);
Assert.NotEqual(SpeakerMemoryAddress, ctx[CpuRegister.Rax]);
Assert.NotEqual(0x100UL, ctx[CpuRegister.Rax]);
}
[Fact]
public void SpeakerArrayDestroy_UnknownHandleStillSucceeds()
{
var ctx = CreateContext(out _);
ctx[CpuRegister.Rdi] = 0xDEAD_BEEF;
var result = AudioOut2Exports.AudioOut2SpeakerArrayDestroy(ctx);
Assert.Equal(0, result);
}
}
+31 -1
View File
@@ -7,15 +7,18 @@ namespace SharpEmu.Libs.Tests;
// A single contiguous guest region backed by a byte[]. Enough to hand C strings and small // A single contiguous guest region backed by a byte[]. Enough to hand C strings and small
// structures to HLE exports under test without a live guest. // structures to HLE exports under test without a live guest.
internal sealed class FakeCpuMemory : ICpuMemory internal sealed class FakeCpuMemory : ICpuMemory, IGuestMemoryAllocator
{ {
private readonly ulong _base; private readonly ulong _base;
private readonly byte[] _storage; private readonly byte[] _storage;
private ulong _allocBump;
public FakeCpuMemory(ulong baseAddress, int size) public FakeCpuMemory(ulong baseAddress, int size)
{ {
_base = baseAddress; _base = baseAddress;
_storage = new byte[size]; _storage = new byte[size];
// Bump from the top so test fixtures at low offsets stay intact.
_allocBump = baseAddress + (ulong)size;
} }
public bool TryRead(ulong virtualAddress, Span<byte> destination) public bool TryRead(ulong virtualAddress, Span<byte> destination)
@@ -40,6 +43,33 @@ internal sealed class FakeCpuMemory : ICpuMemory
return true; return true;
} }
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
address = 0;
if (size == 0 || alignment == 0 || (alignment & (alignment - 1)) != 0)
{
return false;
}
var alignedSize = (size + alignment - 1) & ~(alignment - 1);
if (alignedSize > _allocBump - _base)
{
return false;
}
var next = (_allocBump - alignedSize) & ~(alignment - 1);
if (next < _base)
{
return false;
}
_allocBump = next;
address = next;
return true;
}
public bool TryFreeGuestMemory(ulong address) => false;
public ulong WriteCString(ulong virtualAddress, string text) public ulong WriteCString(ulong virtualAddress, string text)
{ {
var bytes = System.Text.Encoding.UTF8.GetBytes(text); var bytes = System.Text.Encoding.UTF8.GetBytes(text);