Files
PS2Recomp/ps2xTest/src/ps2_runtime_expansion_tests.cpp
T
Shane Michael Mathews (Personal Account) 1176609890 test(sema): make contended semaphore poll/signal test deterministic (#176)
The "Semaphore poll/signal remains stable under host-thread contention"
test created the semaphore full (init == max == 1), so SignalSema could
only succeed after PollSema had already freed a slot. With no start
barrier, under host-thread contention the signaler thread could run all
64 of its iterations before the poller's first timeslice, making every
SignalSema legitimately return KE_SEMA_OVF and leaving signalOkCount at
0 — a false failure of "contended SignalSema should observe successful
releases". An earlier investigation of the unfixed test observed this
twice in 24 contended (8-way-parallel) full-suite runs and never in 20
serial runs; CI has independently hit the same assertion on the
unrelated draft PR #174, confirming the trigger is host scheduling, not
the code under review.

Seed the semaphore with headroom (init=1, max=2) so the first PollSema
and the first SignalSema each succeed regardless of scheduling order,
and add a start barrier so both workers start together, maximizing the
opportunity to interleave. Widen the final-count range check to the new
max. The semaphore implementation is unchanged; both threads still
contend concurrently on the same per-semaphore mutex.
2026-07-21 11:09:38 -03:00

2115 lines
92 KiB
C++

#include "MiniTest.h"
#include "ps2recomp/code_generator.h"
#include "ps2recomp/instructions.h"
#include "ps2recomp/r5900_decoder.h"
#include "ps2recomp/types.h"
#include "ps2_runtime.h"
#include "runtime/ps2_memory.h"
#include "ps2_syscalls.h"
#include "ps2_stubs.h"
#include "runtime/ps2_gs_gpu.h"
#include "runtime/ps2_gs_psmct32.h"
#include "ps2_runtime_macros.h"
#include "Stubs/MPEG.h"
#include "Stubs/CD.h"
#include "Stubs/Audio.h"
#include "Stubs/GS.h"
#include "Stubs/VU.h"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <exception>
#include <string>
#include <thread>
#include <vector>
using namespace ps2recomp;
using namespace ps2_syscalls;
namespace
{
constexpr uint32_t COP0_CAUSE_BD = 0x80000000u;
constexpr uint32_t COP0_CAUSE_EXCCODE_MASK = 0x0000007Cu;
constexpr uint32_t COP0_STATUS_EXL = 0x00000002u;
constexpr uint32_t COP0_STATUS_BEV = 0x00400000u;
constexpr uint32_t EXCEPTION_VECTOR_GENERAL = 0x80000080u;
constexpr uint32_t EXCEPTION_VECTOR_BOOT = 0xBFC00200u;
constexpr int KE_OK = 0;
void setRegU32(R5900Context &ctx, int reg, uint32_t value)
{
ctx.r[reg] = _mm_set_epi64x(0, static_cast<int64_t>(value));
}
int32_t getRegS32(const R5900Context &ctx, int reg)
{
return static_cast<int32_t>(::getRegU32(&ctx, reg));
}
uint32_t makeVifCmd(uint8_t opcode, uint8_t num, uint16_t imm)
{
return (static_cast<uint32_t>(opcode) << 24) |
(static_cast<uint32_t>(num) << 16) |
static_cast<uint32_t>(imm);
}
uint32_t makeVuLq(uint8_t dest, uint8_t targetVf, uint8_t baseVi, int16_t imm)
{
return (static_cast<uint32_t>(dest & 0xFu) << 21) |
(static_cast<uint32_t>(targetVf & 0x1Fu) << 16) |
(static_cast<uint32_t>(baseVi & 0x1Fu) << 11) |
(static_cast<uint32_t>(imm) & 0x7FFu);
}
uint32_t makeVuSq(uint8_t dest, uint8_t sourceVf, uint8_t baseVi, int16_t imm)
{
return (0x01u << 25) |
(static_cast<uint32_t>(dest & 0xFu) << 21) |
(static_cast<uint32_t>(baseVi & 0x1Fu) << 16) |
(static_cast<uint32_t>(sourceVf & 0x1Fu) << 11) |
(static_cast<uint32_t>(imm) & 0x7FFu);
}
uint32_t makeVuAdd(uint8_t dest, uint8_t fd, uint8_t fs, uint8_t ft)
{
return (static_cast<uint32_t>(dest & 0xFu) << 21) |
(static_cast<uint32_t>(ft & 0x1Fu) << 16) |
(static_cast<uint32_t>(fs & 0x1Fu) << 11) |
(static_cast<uint32_t>(fd & 0x1Fu) << 6) |
0x28u;
}
void writeVuInstructionPair(uint8_t *code, uint32_t pc, uint32_t lower, uint32_t upper)
{
std::memcpy(code + pc, &lower, sizeof(lower));
std::memcpy(code + pc + sizeof(lower), &upper, sizeof(upper));
}
bool hasSignedRdWrite(const std::string &generated, uint8_t rd)
{
if (rd == 0u)
{
return false;
}
const std::string needle = "SET_GPR_S32(ctx, " + std::to_string(rd) + ",";
return generated.find(needle) != std::string::npos;
}
template <typename Predicate>
bool waitUntil(Predicate pred, std::chrono::milliseconds timeout)
{
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline)
{
if (pred())
{
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return pred();
}
uint32_t frameOffsetBytes(uint32_t x, uint32_t y, uint32_t fbw)
{
return GSPSMCT32::addrPSMCT32(0u, (fbw != 0u) ? fbw : 1u, x, y);
}
void testRuntimeWorkerLoop(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime)
{
if (!ctx || !runtime)
{
return;
}
// Keep touching guest memory so teardown races are easier to catch.
(void)Ps2FastRead64(rdram, static_cast<uint32_t>(0x01FFFFF8u + (ctx->insn_count & 0x7u)));
++ctx->insn_count;
if (runtime->isStopRequested())
{
ctx->pc = 0u;
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
std::atomic<int32_t> gSerializedGuestActive{0};
std::atomic<int32_t> gSerializedGuestMaxActive{0};
std::atomic<int32_t> gPreemptionPolicyEntryCount{0};
std::atomic<bool> gPreemptionPolicyAllowFirstProbe{false};
std::atomic<bool> gPreemptionPolicyPeerRan{false};
void testSerializedGuestStep(uint8_t *, R5900Context *ctx, PS2Runtime *)
{
const int32_t active = gSerializedGuestActive.fetch_add(1, std::memory_order_acq_rel) + 1;
int32_t observedMax = gSerializedGuestMaxActive.load(std::memory_order_relaxed);
while (observedMax < active &&
!gSerializedGuestMaxActive.compare_exchange_weak(
observedMax,
active,
std::memory_order_release,
std::memory_order_relaxed))
{
}
std::this_thread::sleep_for(std::chrono::milliseconds(25));
gSerializedGuestActive.fetch_sub(1, std::memory_order_acq_rel);
if (ctx)
{
ctx->pc = 0u;
}
}
void testPreemptionPolicyStep(uint8_t *, R5900Context *ctx, PS2Runtime *runtime)
{
if (!ctx || !runtime)
{
return;
}
const int32_t entryIndex = gPreemptionPolicyEntryCount.fetch_add(1, std::memory_order_acq_rel) + 1;
if (entryIndex == 1)
{
while (!gPreemptionPolicyAllowFirstProbe.load(std::memory_order_acquire))
{
std::this_thread::yield();
}
bool shouldPreempt = false;
for (int attempt = 0; attempt < 256 &&
!shouldPreempt;
++attempt)
{
shouldPreempt = runtime->shouldPreemptGuestExecution();
}
setRegU32(*ctx, 2, shouldPreempt ? 1u : 0u);
}
else
{
gPreemptionPolicyPeerRan.store(true, std::memory_order_release);
setRegU32(*ctx, 2, 2u);
}
ctx->pc = 0u;
}
void testResumeOwnerFallbackHandler(uint8_t *, R5900Context *ctx, PS2Runtime *)
{
if (ctx)
{
setRegU32(*ctx, 2, 0x00ABC123u);
ctx->pc = 0u;
}
}
void testResumeNextFunctionHandler(uint8_t *, R5900Context *ctx, PS2Runtime *)
{
if (ctx)
{
setRegU32(*ctx, 2, 0x00555555u);
ctx->pc = 0u;
}
}
void testGuestBranchImplicitReturnHandler(uint8_t *, R5900Context *ctx, PS2Runtime *)
{
if (ctx)
{
setRegU32(*ctx, 2, 0x00FACE42u);
// Leave ctx->pc at the entry point. dispatchGuestBranch should convert
// unchanged call PC into the supplied fallthrough PC for call-like edges.
}
}
void testGuestBranchTransferHandler(uint8_t *, R5900Context *ctx, PS2Runtime *)
{
if (ctx)
{
setRegU32(*ctx, 2, 0x00BEEFu);
ctx->pc = 0x33330000u;
}
}
constexpr uint32_t kAsyncCounterAddr = 0x2400u;
void testWaitForAsyncCounter(uint8_t *rdram, R5900Context *ctx, PS2Runtime *)
{
if (!rdram || !ctx)
{
return;
}
uint32_t counter = 0u;
do
{
std::memcpy(&counter, rdram + kAsyncCounterAddr, sizeof(counter));
if (counter == 0u)
{
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
} while (counter == 0u);
ctx->pc = 0u;
}
void testSignalAsyncCounter(uint8_t *rdram, R5900Context *ctx, PS2Runtime *)
{
if (rdram)
{
const uint32_t counter = 1u;
std::memcpy(rdram + kAsyncCounterAddr, &counter, sizeof(counter));
}
if (ctx)
{
ctx->pc = 0u;
}
}
std::atomic<uint32_t> gAsyncCallbackObservedSp{0u};
std::atomic<uint32_t> gAsyncCallbackObservedGp{0u};
void testRecordAsyncCallbackStack(uint8_t *, R5900Context *ctx, PS2Runtime *)
{
if (!ctx)
{
return;
}
gAsyncCallbackObservedSp.store(::getRegU32(ctx, 29), std::memory_order_release);
gAsyncCallbackObservedGp.store(::getRegU32(ctx, 28), std::memory_order_release);
ctx->pc = 0u;
}
std::atomic<uint32_t> gMpegStreamCallbackCount{0u};
std::atomic<uint32_t> gMpegStreamCallbackMpeg{0u};
std::atomic<uint32_t> gMpegStreamCallbackType{0u};
std::atomic<uint32_t> gMpegStreamCallbackDataAddr{0u};
std::atomic<uint32_t> gMpegStreamCallbackLen{0u};
std::atomic<uint32_t> gMpegStreamCallbackUserData{0u};
void testRecordMpegStreamCallback(uint8_t *rdram, R5900Context *ctx, PS2Runtime *)
{
if (!rdram || !ctx)
{
return;
}
const uint32_t cbData = ::getRegU32(ctx, 5);
uint32_t type = 0u;
uint32_t dataAddr = 0u;
uint32_t len = 0u;
std::memcpy(&type, rdram + cbData + 0x00u, sizeof(type));
std::memcpy(&dataAddr, rdram + cbData + 0x08u, sizeof(dataAddr));
std::memcpy(&len, rdram + cbData + 0x0Cu, sizeof(len));
gMpegStreamCallbackMpeg.store(::getRegU32(ctx, 4), std::memory_order_release);
gMpegStreamCallbackType.store(type, std::memory_order_release);
gMpegStreamCallbackDataAddr.store(dataAddr, std::memory_order_release);
gMpegStreamCallbackLen.store(len, std::memory_order_release);
gMpegStreamCallbackUserData.store(::getRegU32(ctx, 6), std::memory_order_release);
gMpegStreamCallbackCount.fetch_add(1u, std::memory_order_acq_rel);
ctx->pc = 0u;
}
}
void register_ps2_runtime_expansion_tests()
{
MiniTest::Case("PS2RuntimeExpansion", [](TestCase &tc)
{
tc.Run("differential decoder/codegen gpr-write contract for MULT and DIV families", [](TestCase &t)
{
R5900Decoder decoder;
CodeGenerator generator({}, {});
const struct
{
const char *name;
uint32_t raw;
} cases[] = {
{"MULT rd!=0", (OPCODE_SPECIAL << 26) | (4u << 21) | (5u << 16) | (3u << 11) | SPECIAL_MULT},
{"MULT rd==0", (OPCODE_SPECIAL << 26) | (4u << 21) | (5u << 16) | (0u << 11) | SPECIAL_MULT},
{"DIV rd!=0", (OPCODE_SPECIAL << 26) | (6u << 21) | (7u << 16) | (9u << 11) | SPECIAL_DIV},
{"MMI MULT1 rd!=0", (OPCODE_MMI << 26) | (8u << 21) | (9u << 16) | (10u << 11) | MMI_MULT1},
{"MMI DIV1 rd!=0", (OPCODE_MMI << 26) | (8u << 21) | (9u << 16) | (10u << 11) | MMI_DIV1},
};
for (size_t i = 0; i < std::size(cases); ++i)
{
const Instruction inst = decoder.decodeInstruction(0x1000u + static_cast<uint32_t>(i * 4u), cases[i].raw);
const std::string generated = generator.translateInstruction(inst);
const bool emittedRdWrite = hasSignedRdWrite(generated, inst.rd);
t.Equals(emittedRdWrite, inst.modificationInfo.modifiesGPR,
std::string("decoder/codegen mismatch for ") + cases[i].name);
t.IsTrue(inst.modificationInfo.modifiesControl,
std::string("HI/LO control side-effect missing for ") + cases[i].name);
}
});
tc.Run("guest execution is serialized per runtime", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
gSerializedGuestActive.store(0, std::memory_order_release);
gSerializedGuestMaxActive.store(0, std::memory_order_release);
constexpr uint32_t kEntries[] = {
0x120000u,
0x130000u,
0x140000u,
0x150000u,
};
constexpr size_t kEntryCount = sizeof(kEntries) / sizeof(kEntries[0]);
R5900Context contexts[kEntryCount]{};
std::vector<std::thread> workers;
workers.reserve(kEntryCount);
for (size_t i = 0; i < kEntryCount; ++i)
{
runtime.registerFunction(kEntries[i], &testSerializedGuestStep);
contexts[i].pc = kEntries[i];
}
for (size_t i = 0; i < kEntryCount; ++i)
{
workers.emplace_back([&, i]()
{
runtime.dispatchLoop(rdram.data(), &contexts[i]);
});
}
for (std::thread &worker : workers)
{
if (worker.joinable())
{
worker.join();
}
}
t.Equals(gSerializedGuestActive.load(std::memory_order_acquire), 0,
"serialized guest dispatch should leave no active workers");
t.Equals(gSerializedGuestMaxActive.load(std::memory_order_acquire), 1,
"dispatchLoop should not execute guest code concurrently on one runtime");
});
tc.Run("wake handoff lets a contending guest thread acquire before returning", [](TestCase &t)
{
PS2Runtime runtime;
std::atomic<bool> peerRan{false};
std::thread peer;
bool peerWaiting = false;
bool peerRanWhileMainHeld = false;
bool peerRanAfterHandoff = false;
{
PS2Runtime::GuestExecutionScope mainScope(&runtime);
peer = std::thread([&]()
{
PS2Runtime::GuestExecutionScope peerScope(&runtime);
peerRan.store(true, std::memory_order_release);
});
peerWaiting = waitUntil([&]()
{
return runtime.guestExecutionWaiterCountForTesting() > 0u;
}, std::chrono::milliseconds(100));
peerRanWhileMainHeld = peerRan.load(std::memory_order_acquire);
runtime.yieldGuestExecutionAfterWake();
peerRanAfterHandoff = peerRan.load(std::memory_order_acquire);
}
if (peer.joinable())
{
peer.join();
}
t.IsTrue(peerWaiting, "peer guest thread should contend while the waker owns guest execution");
t.IsFalse(peerRanWhileMainHeld, "peer guest thread should not run before the waker yields execution");
t.IsTrue(peerRanAfterHandoff, "wake handoff should let the peer acquire guest execution before returning");
});
tc.Run("guest preemption policy requests a dispatcher handoff when another guest thread contends", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kFirstEntry = 0x190000u;
constexpr uint32_t kSecondEntry = 0x1A0000u;
gPreemptionPolicyEntryCount.store(0, std::memory_order_release);
gPreemptionPolicyAllowFirstProbe.store(false, std::memory_order_release);
gPreemptionPolicyPeerRan.store(false, std::memory_order_release);
runtime.registerFunction(kFirstEntry, &testPreemptionPolicyStep);
runtime.registerFunction(kSecondEntry, &testPreemptionPolicyStep);
R5900Context firstCtx{};
R5900Context secondCtx{};
firstCtx.pc = kFirstEntry;
secondCtx.pc = kSecondEntry;
std::thread firstWorker([&]()
{
runtime.dispatchLoop(rdram.data(), &firstCtx);
});
const bool firstEntered = waitUntil([&]()
{
return gPreemptionPolicyEntryCount.load(std::memory_order_acquire) >= 1;
}, std::chrono::milliseconds(100));
std::thread secondWorker([&]()
{
runtime.dispatchLoop(rdram.data(), &secondCtx);
});
const bool secondContending = waitUntil([&]()
{
return runtime.guestExecutionWaiterCountForTesting() > 0u;
}, std::chrono::milliseconds(100));
gPreemptionPolicyAllowFirstProbe.store(true, std::memory_order_release);
if (firstWorker.joinable())
{
firstWorker.join();
}
if (secondWorker.joinable())
{
secondWorker.join();
}
t.IsTrue(firstEntered, "first guest worker should enter before probing for preemption");
t.IsTrue(secondContending, "second guest worker should contend for guest execution before the first returns");
t.IsTrue(gPreemptionPolicyPeerRan.load(std::memory_order_acquire),
"second guest worker should run after the first returns to the dispatcher");
t.Equals(getRegU32(&firstCtx, 2), 1u,
"first guest worker should observe that the runtime requested preemption under contention");
});
tc.Run("lookupFunction rejects internal resume PCs without exact registration", [](TestCase &t)
{
PS2Runtime runtime;
runtime.setMissingFunctionPolicy(PS2Runtime::MissingFunctionPolicy::Stop);
runtime.registerFunction(0x1000u, &testResumeOwnerFallbackHandler);
runtime.registerFunction(0x1100u, &testResumeNextFunctionHandler);
R5900Context ctx{};
ctx.pc = 0x1010u;
auto fn = runtime.lookupFunction(ctx.pc);
fn(nullptr, &ctx, &runtime);
t.Equals(::getRegU32(&ctx, 2), 0u,
"unregistered resume PC should not alias to the nearest owner");
t.IsTrue(runtime.isStopRequested(),
"missing exact dispatch target should request runtime stop");
});
tc.Run("lookupFunction rejects final-function PCs inside code regions without exact registration", [](TestCase &t)
{
PS2Runtime runtime;
runtime.setMissingFunctionPolicy(PS2Runtime::MissingFunctionPolicy::Stop);
runtime.memory().registerCodeRegion(0x2000u, 0x2100u);
runtime.registerFunction(0x2000u, &testResumeOwnerFallbackHandler);
R5900Context ctx{};
ctx.pc = 0x2010u;
auto fn = runtime.lookupFunction(ctx.pc);
fn(nullptr, &ctx, &runtime);
t.Equals(::getRegU32(&ctx, 2), 0u,
"code-region membership alone should not alias to the previous function");
t.IsTrue(runtime.isStopRequested(),
"missing exact final-function target should request runtime stop");
});
tc.Run("dispatchGuestBranch call normalizes unchanged callee PC to fallthrough", [](TestCase &t)
{
PS2Runtime runtime;
runtime.registerFunction(0x3000u, &testGuestBranchImplicitReturnHandler);
R5900Context ctx{};
ctx.pc = 0x2000u;
const bool returnedToFallthrough = runtime.dispatchGuestBranch(
nullptr,
&ctx,
0x3000u,
0x2000u,
0x2008u,
PS2Runtime::GuestBranchKind::IndirectCall,
"test-jalr");
t.IsTrue(returnedToFallthrough,
"call-like dispatch should report true when it resumes at fallthrough");
t.Equals(ctx.pc, 0x2008u,
"unchanged callee PC should be converted to call fallthrough");
t.Equals(::getRegU32(&ctx, 2), 0x00FACE42u,
"callee should still execute normally");
});
tc.Run("dispatchGuestBranch call returns false when callee transfers elsewhere", [](TestCase &t)
{
PS2Runtime runtime;
runtime.registerFunction(0x3100u, &testGuestBranchTransferHandler);
R5900Context ctx{};
ctx.pc = 0x2000u;
const bool returnedToFallthrough = runtime.dispatchGuestBranch(
nullptr,
&ctx,
0x3100u,
0x2000u,
0x2008u,
PS2Runtime::GuestBranchKind::IndirectCall,
"test-jalr-transfer");
t.IsFalse(returnedToFallthrough,
"call-like dispatch should stop caller flow when callee transfers elsewhere");
t.Equals(ctx.pc, 0x33330000u,
"callee transfer PC should be preserved");
});
tc.Run("dispatchGuestBranch rejects missing exact targets", [](TestCase &t)
{
PS2Runtime runtime;
runtime.setMissingFunctionPolicy(PS2Runtime::MissingFunctionPolicy::Stop);
runtime.registerFunction(0x3200u, &testGuestBranchImplicitReturnHandler);
R5900Context ctx{};
ctx.pc = 0x2000u;
const bool returnedToFallthrough = runtime.dispatchGuestBranch(
nullptr,
&ctx,
0x3210u,
0x2000u,
0x2008u,
PS2Runtime::GuestBranchKind::IndirectCall,
"test-missing");
t.IsFalse(returnedToFallthrough,
"missing target should not resume caller flow");
t.IsTrue(runtime.isStopRequested(),
"missing exact target should request runtime stop");
t.Equals(ctx.pc, 0x3210u,
"missing target should remain visible in ctx->pc for diagnostics");
});
tc.Run("vblank intc handlers can preempt serialized guest execution", [](TestCase &t)
{
notifyRuntimeStop();
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kBusyEntry = 0x160000u;
constexpr uint32_t kIntcHandlerEntry = 0x170000u;
runtime.registerFunction(kBusyEntry, &testWaitForAsyncCounter);
runtime.registerFunction(kIntcHandlerEntry, &testSignalAsyncCounter);
R5900Context addCtx{};
setRegU32(addCtx, 4, 2u);
setRegU32(addCtx, 5, kIntcHandlerEntry);
setRegU32(addCtx, 6, 0u);
setRegU32(addCtx, 7, 0u);
AddIntcHandler(rdram.data(), &addCtx, &runtime);
t.IsTrue(getRegS32(addCtx, 2) > 0, "AddIntcHandler should register a VBlank handler");
R5900Context busyCtx{};
busyCtx.pc = kBusyEntry;
std::atomic<bool> workerDone{false};
std::atomic<bool> workerThrew{false};
std::thread worker([&]()
{
try
{
runtime.dispatchLoop(rdram.data(), &busyCtx);
}
catch (...)
{
workerThrew.store(true, std::memory_order_release);
}
workerDone.store(true, std::memory_order_release);
});
ps2_syscalls::EnsureVSyncWorkerRunning(rdram.data(), &runtime);
const bool finished = waitUntil([&]()
{
return workerDone.load(std::memory_order_acquire);
}, std::chrono::milliseconds(250));
if (!finished)
{
const uint32_t counter = 1u;
std::memcpy(rdram.data() + kAsyncCounterAddr, &counter, sizeof(counter));
}
if (worker.joinable())
{
worker.join();
}
runtime.requestStop();
notifyRuntimeStop();
uint32_t counter = 0u;
std::memcpy(&counter, rdram.data() + kAsyncCounterAddr, sizeof(counter));
t.IsFalse(workerThrew.load(std::memory_order_acquire),
"busy dispatch worker should not throw while VBlank handlers fire");
t.IsTrue(finished,
"VBlank interrupt handlers should run even while a guest thread is spinning");
t.Equals(counter, 1u, "VBlank handler should publish the awaited counter value");
});
tc.Run("GS async callbacks keep a dedicated stack when guest heap is exhausted", [](TestCase &t)
{
notifyRuntimeStop();
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kCallbackEntry = 0x180000u;
constexpr uint32_t kCallerGp = 0x0036A7F0u;
constexpr uint32_t kCallerSp = 0x00123450u;
constexpr uint32_t kAsyncStackFloor = 0x01F00000u;
runtime.configureGuestHeap(kAsyncStackFloor, kAsyncStackFloor);
runtime.registerFunction(kCallbackEntry, &testRecordAsyncCallbackStack);
ps2_stubs::resetGsSyncVCallbackState();
gAsyncCallbackObservedSp.store(0u, std::memory_order_release);
gAsyncCallbackObservedGp.store(0u, std::memory_order_release);
R5900Context registerCtx{};
registerCtx.pc = 0x00101900u;
setRegU32(registerCtx, 4, kCallbackEntry);
setRegU32(registerCtx, 28, kCallerGp);
setRegU32(registerCtx, 29, kCallerSp);
ps2_stubs::sceGsSyncVCallback(rdram.data(), &registerCtx, &runtime);
ps2_stubs::dispatchGsSyncVCallback(rdram.data(), &runtime, 1u);
const uint32_t observedSp = gAsyncCallbackObservedSp.load(std::memory_order_acquire);
const uint32_t observedGp = gAsyncCallbackObservedGp.load(std::memory_order_acquire);
t.IsTrue(observedSp != 0u, "callback should execute");
t.Equals(observedGp, kCallerGp, "callback should preserve the registered GP");
t.IsTrue(observedSp != kCallerSp, "callback should not reuse the registering thread stack");
t.IsTrue(observedSp >= kAsyncStackFloor,
"callback should switch to the reserved async stack pool");
runtime.requestStop();
notifyRuntimeStop();
});
tc.Run("MPEG init and callback stubs return success instead of TODO errors", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
ps2_stubs::resetMpegStubState();
R5900Context initCtx{};
ps2_stubs::sceMpegInit(rdram.data(), &initCtx, nullptr);
t.Equals(getRegS32(initCtx, 2), 0,
"sceMpegInit should succeed so games can continue through movie setup");
R5900Context addCtx0{};
setRegU32(addCtx0, 4, 0x00123000u);
setRegU32(addCtx0, 5, 1u);
setRegU32(addCtx0, 6, 0x00124000u);
setRegU32(addCtx0, 7, 0u);
ps2_stubs::sceMpegAddCallback(rdram.data(), &addCtx0, nullptr);
t.Equals(getRegS32(addCtx0, 2), 1,
"first sceMpegAddCallback should hand back a non-error callback handle");
R5900Context addCtx1{};
setRegU32(addCtx1, 4, 0x00123000u);
setRegU32(addCtx1, 5, 2u);
setRegU32(addCtx1, 6, 0x00124010u);
setRegU32(addCtx1, 7, 0u);
ps2_stubs::sceMpegAddCallback(rdram.data(), &addCtx1, nullptr);
t.Equals(getRegS32(addCtx1, 2), 2,
"subsequent sceMpegAddCallback calls should keep succeeding");
R5900Context reinitCtx{};
ps2_stubs::sceMpegInit(rdram.data(), &reinitCtx, nullptr);
R5900Context addAfterReinit{};
setRegU32(addAfterReinit, 4, 0x00123000u);
setRegU32(addAfterReinit, 5, 3u);
setRegU32(addAfterReinit, 6, 0x00124020u);
setRegU32(addAfterReinit, 7, 0u);
ps2_stubs::sceMpegAddCallback(rdram.data(), &addAfterReinit, nullptr);
t.Equals(getRegS32(addAfterReinit, 2), 1,
"sceMpegInit should reset MPEG callback bookkeeping between runs");
});
tc.Run("sceMpegDemuxPssRing dispatches registered video and audio stream callbacks", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
ps2_stubs::resetMpegStubState();
constexpr uint32_t kMpegAddr = 0x00123000u;
constexpr uint32_t kCallbackEntry = 0x00124000u;
constexpr uint32_t kVideoUserData = 0x11223344u;
constexpr uint32_t kAudioUserData = 0x55667788u;
constexpr uint32_t kVideoPacketAddr = 0x00128000u;
constexpr uint32_t kAudioPacketAddr = 0x00129000u;
runtime.registerFunction(kCallbackEntry, &testRecordMpegStreamCallback);
auto registerGenericCallback = [&](uint32_t callbackType, uint32_t userData)
{
R5900Context addCtx{};
setRegU32(addCtx, 4, kMpegAddr);
setRegU32(addCtx, 5, callbackType);
setRegU32(addCtx, 6, kCallbackEntry);
setRegU32(addCtx, 7, userData);
ps2_stubs::sceMpegAddCallback(rdram.data(), &addCtx, &runtime);
};
auto registerStreamCallback = [&](uint32_t streamType, uint32_t userData)
{
R5900Context addCtx{};
setRegU32(addCtx, 4, kMpegAddr);
setRegU32(addCtx, 5, streamType);
setRegU32(addCtx, 6, 0u);
setRegU32(addCtx, 7, kCallbackEntry);
setRegU32(addCtx, 8, userData);
ps2_stubs::sceMpegAddStrCallback(rdram.data(), &addCtx, &runtime);
};
auto writePesPacket = [&](uint32_t addr, uint8_t streamId, const std::vector<uint8_t> &payload)
{
const uint16_t packetLen = static_cast<uint16_t>(payload.size() + 3u);
std::vector<uint8_t> packet = {
0x00u, 0x00u, 0x01u, streamId,
static_cast<uint8_t>(packetLen >> 8u),
static_cast<uint8_t>(packetLen & 0xFFu),
0x80u, 0x00u, 0x00u};
packet.insert(packet.end(), payload.begin(), payload.end());
std::memcpy(rdram.data() + addr, packet.data(), packet.size());
return static_cast<uint32_t>(packet.size());
};
registerGenericCallback(0u, 0xDEAD0000u);
registerGenericCallback(2u, 0xDEAD0002u);
registerStreamCallback(0u, kVideoUserData);
registerStreamCallback(2u, kAudioUserData);
const std::vector<uint8_t> videoPayload = {
0x00u, 0x00u, 0x01u, 0xB3u, 0x14u, 0x00u, 0xF0u, 0x13u};
const uint32_t videoPacketSize = writePesPacket(kVideoPacketAddr, 0xE0u, videoPayload);
gMpegStreamCallbackCount.store(0u, std::memory_order_release);
R5900Context videoDemuxCtx{};
setRegU32(videoDemuxCtx, 4, kMpegAddr);
setRegU32(videoDemuxCtx, 5, kVideoPacketAddr);
setRegU32(videoDemuxCtx, 6, videoPacketSize);
setRegU32(videoDemuxCtx, 7, kVideoPacketAddr);
setRegU32(videoDemuxCtx, 8, videoPacketSize);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &videoDemuxCtx, &runtime);
t.Equals(getRegS32(videoDemuxCtx, 2), static_cast<int32_t>(videoPacketSize),
"sceMpegDemuxPssRing should consume the video PES packet");
t.Equals(gMpegStreamCallbackCount.load(std::memory_order_acquire), 1u,
"registered video stream callback should be invoked");
t.Equals(gMpegStreamCallbackMpeg.load(std::memory_order_acquire), kMpegAddr,
"video callback should receive the MPEG handle");
t.Equals(gMpegStreamCallbackType.load(std::memory_order_acquire), 0u,
"video callback data should report M2V stream type");
t.Equals(gMpegStreamCallbackDataAddr.load(std::memory_order_acquire), kVideoPacketAddr + 9u,
"video callback data should point at PES payload");
t.Equals(gMpegStreamCallbackLen.load(std::memory_order_acquire),
static_cast<uint32_t>(videoPayload.size()),
"video callback data should report PES payload length");
t.Equals(gMpegStreamCallbackUserData.load(std::memory_order_acquire), kVideoUserData,
"video callback should receive registered user data");
const std::vector<uint8_t> audioPayload = {0x80u, 0x01u, 0x02u, 0x03u, 0x04u, 0x05u};
const uint32_t audioPacketSize = writePesPacket(kAudioPacketAddr, 0xBDu, audioPayload);
gMpegStreamCallbackCount.store(0u, std::memory_order_release);
R5900Context audioDemuxCtx{};
setRegU32(audioDemuxCtx, 4, kMpegAddr);
setRegU32(audioDemuxCtx, 5, kAudioPacketAddr);
setRegU32(audioDemuxCtx, 6, audioPacketSize);
setRegU32(audioDemuxCtx, 7, kAudioPacketAddr);
setRegU32(audioDemuxCtx, 8, audioPacketSize);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &audioDemuxCtx, &runtime);
t.Equals(getRegS32(audioDemuxCtx, 2), static_cast<int32_t>(audioPacketSize),
"sceMpegDemuxPssRing should consume the audio PES packet");
t.Equals(gMpegStreamCallbackCount.load(std::memory_order_acquire), 1u,
"registered audio stream callback should be invoked");
t.Equals(gMpegStreamCallbackType.load(std::memory_order_acquire), 2u,
"audio callback data should report PCM stream type");
t.Equals(gMpegStreamCallbackDataAddr.load(std::memory_order_acquire), kAudioPacketAddr + 9u,
"audio callback data should point at PES payload");
t.Equals(gMpegStreamCallbackLen.load(std::memory_order_acquire),
static_cast<uint32_t>(audioPayload.size()),
"audio callback data should report PES payload length");
t.Equals(gMpegStreamCallbackUserData.load(std::memory_order_acquire), kAudioUserData,
"audio callback should receive registered user data");
ps2_stubs::notifyMpegCdStreamEof();
gMpegStreamCallbackCount.store(0u, std::memory_order_release);
R5900Context afterEofDemuxCtx{};
setRegU32(afterEofDemuxCtx, 4, kMpegAddr);
setRegU32(afterEofDemuxCtx, 5, kVideoPacketAddr);
setRegU32(afterEofDemuxCtx, 6, videoPacketSize);
setRegU32(afterEofDemuxCtx, 7, kVideoPacketAddr);
setRegU32(afterEofDemuxCtx, 8, videoPacketSize);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &afterEofDemuxCtx, &runtime);
t.Equals(getRegS32(afterEofDemuxCtx, 2), static_cast<int32_t>(videoPacketSize),
"post-EOF demux should continue consuming caller data");
t.Equals(gMpegStreamCallbackCount.load(std::memory_order_acquire), 0u,
"post-EOF demux should not feed callbacks again");
R5900Context resetCtx{};
setRegU32(resetCtx, 4, kMpegAddr);
ps2_stubs::sceMpegReset(rdram.data(), &resetCtx, &runtime);
gMpegStreamCallbackCount.store(0u, std::memory_order_release);
R5900Context afterResetDemuxCtx{};
setRegU32(afterResetDemuxCtx, 4, kMpegAddr);
setRegU32(afterResetDemuxCtx, 5, kVideoPacketAddr);
setRegU32(afterResetDemuxCtx, 6, videoPacketSize);
setRegU32(afterResetDemuxCtx, 7, kVideoPacketAddr);
setRegU32(afterResetDemuxCtx, 8, videoPacketSize);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &afterResetDemuxCtx, &runtime);
t.Equals(getRegS32(afterResetDemuxCtx, 2), static_cast<int32_t>(videoPacketSize),
"post-EOF reset demux should still drain caller data");
t.Equals(gMpegStreamCallbackCount.load(std::memory_order_acquire), 0u,
"post-EOF reset demux should not restart callbacks on stale data");
ps2_stubs::notifyMpegCdStreamStart();
gMpegStreamCallbackCount.store(0u, std::memory_order_release);
R5900Context afterNewStreamDemuxCtx{};
setRegU32(afterNewStreamDemuxCtx, 4, kMpegAddr);
setRegU32(afterNewStreamDemuxCtx, 5, kVideoPacketAddr);
setRegU32(afterNewStreamDemuxCtx, 6, videoPacketSize);
setRegU32(afterNewStreamDemuxCtx, 7, kVideoPacketAddr);
setRegU32(afterNewStreamDemuxCtx, 8, videoPacketSize);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &afterNewStreamDemuxCtx, &runtime);
t.Equals(getRegS32(afterNewStreamDemuxCtx, 2), static_cast<int32_t>(videoPacketSize),
"new CD stream demux should reopen an ended MPEG handle");
t.Equals(gMpegStreamCallbackCount.load(std::memory_order_acquire), 1u,
"new CD stream demux should allow callbacks on a reused MPEG handle");
constexpr uint32_t kMpegWorkAddr = 0x00130000u;
R5900Context createCtx{};
setRegU32(createCtx, 4, kMpegAddr);
setRegU32(createCtx, 5, kMpegWorkAddr);
setRegU32(createCtx, 6, 0x2000u);
ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, &runtime);
t.IsTrue(::getRegU32(&createCtx, 2) != 0u,
"sceMpegCreate should reopen the MPEG handle after an ended reset");
gMpegStreamCallbackCount.store(0u, std::memory_order_release);
R5900Context afterCreateDemuxCtx{};
setRegU32(afterCreateDemuxCtx, 4, kMpegAddr);
setRegU32(afterCreateDemuxCtx, 5, kVideoPacketAddr);
setRegU32(afterCreateDemuxCtx, 6, videoPacketSize);
setRegU32(afterCreateDemuxCtx, 7, kVideoPacketAddr);
setRegU32(afterCreateDemuxCtx, 8, videoPacketSize);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &afterCreateDemuxCtx, &runtime);
t.Equals(gMpegStreamCallbackCount.load(std::memory_order_acquire), 1u,
"new MPEG create should allow callbacks for the next stream");
runtime.requestStop();
});
tc.Run("MPEG playback stays active during a temporary demux pause before CD EOF", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
ps2_stubs::resetMpegStubState();
constexpr uint32_t kMpegAddr = 0x00123000u;
constexpr uint32_t kPacketAddr = 0x00128000u;
constexpr uint32_t kImageAddr = 0x00130000u;
const std::vector<uint8_t> es = {
0x00u, 0x00u, 0x01u, 0xB3u, 0x01u, 0x00u, 0x10u, 0x12u, 0xFFu, 0xFFu, 0xE0u, 0x18u,
0x00u, 0x00u, 0x01u, 0xB5u, 0x14u, 0x8Au, 0x00u, 0x01u, 0x00u, 0x17u, 0x00u, 0x00u,
0x01u, 0xB8u, 0x00u, 0x08u, 0x00u, 0x40u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x0Fu,
0xFFu, 0xF8u, 0x00u, 0x00u, 0x01u, 0xB5u, 0x8Fu, 0xFFu, 0xF3u, 0x41u, 0x80u, 0x00u,
0x00u, 0x01u, 0x01u, 0x13u, 0xF8u, 0x7Du, 0x29u, 0x48u, 0x88u, 0x00u, 0x00u, 0x01u,
0xB3u, 0x01u, 0x00u, 0x10u, 0x12u, 0xFFu, 0xFFu, 0xE0u, 0x18u, 0x00u, 0x00u, 0x01u,
0xB5u, 0x14u, 0x8Au, 0x00u, 0x01u, 0x00u, 0x17u, 0x00u, 0x00u, 0x01u, 0xB8u, 0x00u,
0x08u, 0x00u, 0xC0u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x0Fu, 0xFFu, 0xF8u, 0x00u,
0x00u, 0x01u, 0xB5u, 0x8Fu, 0xFFu, 0xF3u, 0x41u, 0x80u, 0x00u, 0x00u, 0x01u, 0x01u,
0x13u, 0xF8u, 0x7Du, 0x29u, 0x48u, 0x88u, 0x00u, 0x00u, 0x01u, 0xB3u, 0x01u, 0x00u,
0x10u, 0x12u, 0xFFu, 0xFFu, 0xE0u, 0x18u, 0x00u, 0x00u, 0x01u, 0xB5u, 0x14u, 0x8Au,
0x00u, 0x01u, 0x00u, 0x17u, 0x00u, 0x00u, 0x01u, 0xB8u, 0x00u, 0x08u, 0x01u, 0x40u,
0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x0Fu, 0xFFu, 0xF8u, 0x00u, 0x00u, 0x01u, 0xB5u,
0x8Fu, 0xFFu, 0xF3u, 0x41u, 0x80u, 0x00u, 0x00u, 0x01u, 0x01u, 0x13u, 0xF8u, 0x7Du,
0x29u, 0x48u, 0x88u};
std::vector<uint8_t> packet = {
0x00u, 0x00u, 0x01u, 0xE0u,
0x00u, static_cast<uint8_t>(es.size() + 3u),
0x80u, 0x00u, 0x00u};
packet.insert(packet.end(), es.begin(), es.end());
std::memcpy(rdram.data() + kPacketAddr, packet.data(), packet.size());
R5900Context demuxCtx{};
setRegU32(demuxCtx, 4, kMpegAddr);
setRegU32(demuxCtx, 5, kPacketAddr);
setRegU32(demuxCtx, 6, static_cast<uint32_t>(packet.size()));
setRegU32(demuxCtx, 7, kPacketAddr);
setRegU32(demuxCtx, 8, static_cast<uint32_t>(packet.size()));
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &demuxCtx, nullptr);
R5900Context pictureCtx{};
setRegU32(pictureCtx, 4, kMpegAddr);
setRegU32(pictureCtx, 5, kImageAddr);
ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr);
t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u,
"test stream should decode one frame before the pause");
t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x08u), 0u,
"first decoded frame should report frame index zero");
std::this_thread::sleep_for(std::chrono::milliseconds(650));
R5900Context isEndCtx{};
setRegU32(isEndCtx, 4, kMpegAddr);
ps2_stubs::sceMpegIsEnd(rdram.data(), &isEndCtx, nullptr);
t.Equals(getRegS32(isEndCtx, 2), 0,
"a temporary demux pause must not end an active stream before CD EOF");
ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr);
t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x08u), 1u,
"temporary non-EOF starvation should keep movie frame progress moving");
ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr);
t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x08u), 2u,
"repeated temporary starvation should continue advancing from the held frame");
});
tc.Run("sceMpegGetPicture releases an old waiter when the CD stream restarts", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
ps2_stubs::resetMpegStubState();
ps2_stubs::notifyMpegCdStreamStart();
constexpr uint32_t kMpegAddr = 0x00123000u;
constexpr uint32_t kImageAddr = 0x00130000u;
R5900Context pictureCtx{};
setRegU32(pictureCtx, 4, kMpegAddr);
setRegU32(pictureCtx, 5, kImageAddr);
std::atomic<bool> returned{false};
std::thread waiter([&]()
{
ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, &runtime);
returned.store(true, std::memory_order_release);
});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
t.IsFalse(returned.load(std::memory_order_acquire),
"sceMpegGetPicture should wait while the current stream still has no frame");
ps2_stubs::notifyMpegCdStreamStart();
const bool released = waitUntil(
[&]() { return returned.load(std::memory_order_acquire); },
std::chrono::milliseconds(30));
runtime.requestStop();
waiter.join();
t.IsTrue(released,
"a new sceCdStStart generation should release a waiter owned by the previous movie");
});
tc.Run("sceMpegGetPicture yields during active stream starvation before CD EOF", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
ps2_stubs::resetMpegStubState();
ps2_stubs::notifyMpegCdStreamStart();
constexpr uint32_t kMpegAddr = 0x00123000u;
constexpr uint32_t kPssAddr = 0x0012C000u;
constexpr uint32_t kImageAddr = 0x00130000u;
const uint8_t incompletePssStart[] = {0x00u, 0x00u, 0x01u};
std::memcpy(rdram.data() + kPssAddr, incompletePssStart, sizeof(incompletePssStart));
R5900Context demuxCtx{};
setRegU32(demuxCtx, 4, kMpegAddr);
setRegU32(demuxCtx, 5, kPssAddr);
setRegU32(demuxCtx, 6, sizeof(incompletePssStart));
setRegU32(demuxCtx, 7, kPssAddr);
setRegU32(demuxCtx, 8, sizeof(incompletePssStart));
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &demuxCtx, &runtime);
R5900Context pictureCtx{};
setRegU32(pictureCtx, 4, kMpegAddr);
setRegU32(pictureCtx, 5, kImageAddr);
std::atomic<bool> returned{false};
std::thread waiter([&]()
{
ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, &runtime);
returned.store(true, std::memory_order_release);
});
const bool yielded = waitUntil(
[&]() { return returned.load(std::memory_order_acquire); },
std::chrono::milliseconds(200));
runtime.requestStop();
waiter.join();
t.IsTrue(yielded,
"active non-EOF streams must return control when no decoded frame is currently available");
R5900Context isEndCtx{};
setRegU32(isEndCtx, 4, kMpegAddr);
ps2_stubs::sceMpegIsEnd(rdram.data(), &isEndCtx, nullptr);
t.Equals(getRegS32(isEndCtx, 2), 0,
"yielding without a frame must not mark the active stream ended");
});
tc.Run("movie startup MPEG and audio stubs return safe progress values", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
ps2_stubs::resetMpegStubState();
ps2_stubs::resetAudioStubState();
R5900Context firstIsEndCtx{};
setRegU32(firstIsEndCtx, 4, 0x00123000u);
ps2_stubs::sceMpegIsEnd(rdram.data(), &firstIsEndCtx, nullptr);
t.Equals(getRegS32(firstIsEndCtx, 2), 0,
"sceMpegIsEnd should allow one synthetic frame before reporting end");
R5900Context demuxCtx{};
setRegU32(demuxCtx, 4, 0x00123000u);
setRegU32(demuxCtx, 5, 0x00400000u);
setRegU32(demuxCtx, 6, 0x00004000u);
setRegU32(demuxCtx, 7, 0x00410000u);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &demuxCtx, nullptr);
t.Equals(getRegS32(demuxCtx, 2), 0x4000,
"sceMpegDemuxPssRing should consume the provided input instead of trapping");
R5900Context getPictureCtx{};
setRegU32(getPictureCtx, 4, 0x00123000u);
setRegU32(getPictureCtx, 5, 0x00124000u);
setRegU32(getPictureCtx, 6, 440u);
ps2_stubs::sceMpegGetPicture(rdram.data(), &getPictureCtx, nullptr);
t.Equals(Ps2FastRead32(rdram.data(), 0x00123000u + 0x00u), 320u,
"sceMpegGetPicture should seed a safe movie width");
t.Equals(Ps2FastRead32(rdram.data(), 0x00123000u + 0x04u), 240u,
"sceMpegGetPicture should seed a safe movie height");
t.Equals(Ps2FastRead32(rdram.data(), 0x00123000u + 0x08u), 0u,
"first synthetic picture should preserve frameCount==0 for guest setup");
R5900Context secondIsEndCtx{};
setRegU32(secondIsEndCtx, 4, 0x00123000u);
ps2_stubs::sceMpegIsEnd(rdram.data(), &secondIsEndCtx, nullptr);
t.Equals(getRegS32(secondIsEndCtx, 2), 0,
"sceMpegIsEnd should keep the decode thread alive and let the guest stop playback");
constexpr uint32_t pssEndAddr = 0x00128000u;
constexpr uint32_t stackAddr = 0x00129000u;
const uint8_t programEnd[] = {0x00u, 0x00u, 0x01u, 0xB9u};
std::memcpy(rdram.data() + pssEndAddr, programEnd, sizeof(programEnd));
std::memcpy(rdram.data() + stackAddr + 0x10u, "\x04\x00\x00\x00", 4u);
R5900Context endDemuxCtx{};
setRegU32(endDemuxCtx, 29, stackAddr);
setRegU32(endDemuxCtx, 4, 0x00123000u);
setRegU32(endDemuxCtx, 5, pssEndAddr);
setRegU32(endDemuxCtx, 6, sizeof(programEnd));
setRegU32(endDemuxCtx, 7, pssEndAddr);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &endDemuxCtx, nullptr);
R5900Context endIsEndCtx{};
setRegU32(endIsEndCtx, 4, 0x00123000u);
ps2_stubs::sceMpegIsEnd(rdram.data(), &endIsEndCtx, nullptr);
t.Equals(getRegS32(endIsEndCtx, 2), 1,
"sceMpegIsEnd should report end after a demuxed MPEG program end code");
ps2_stubs::resetMpegStubState();
constexpr uint32_t wrappedEndBase = 0x0012A000u;
rdram[wrappedEndBase + 0u] = 0x00u;
rdram[wrappedEndBase + 1u] = 0x01u;
rdram[wrappedEndBase + 2u] = 0xB9u;
rdram[wrappedEndBase + 3u] = 0x00u;
R5900Context wrappedEndDemuxCtx{};
setRegU32(wrappedEndDemuxCtx, 4, 0x00123000u);
setRegU32(wrappedEndDemuxCtx, 5, wrappedEndBase + 3u);
setRegU32(wrappedEndDemuxCtx, 6, 4u);
setRegU32(wrappedEndDemuxCtx, 7, wrappedEndBase);
setRegU32(wrappedEndDemuxCtx, 8, 4u);
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &wrappedEndDemuxCtx, nullptr);
R5900Context wrappedEndIsEndCtx{};
setRegU32(wrappedEndIsEndCtx, 4, 0x00123000u);
ps2_stubs::sceMpegIsEnd(rdram.data(), &wrappedEndIsEndCtx, nullptr);
t.Equals(getRegS32(wrappedEndIsEndCtx, 2), 1,
"sceMpegDemuxPssRing should use the ABI fifth argument in t0 for wrapped rings");
ps2_stubs::resetMpegStubState();
constexpr uint32_t eofMpegAddr = 0x0012B000u;
constexpr uint32_t eofPssAddr = 0x0012C000u;
const uint8_t incompletePssStart[] = {0x00u, 0x00u, 0x01u};
std::memcpy(rdram.data() + eofPssAddr, incompletePssStart, sizeof(incompletePssStart));
R5900Context eofDemuxCtx{};
setRegU32(eofDemuxCtx, 4, eofMpegAddr);
setRegU32(eofDemuxCtx, 5, eofPssAddr);
setRegU32(eofDemuxCtx, 6, sizeof(incompletePssStart));
setRegU32(eofDemuxCtx, 7, eofPssAddr);
setRegU32(eofDemuxCtx, 8, sizeof(incompletePssStart));
ps2_stubs::sceMpegDemuxPssRing(rdram.data(), &eofDemuxCtx, nullptr);
t.Equals(getRegS32(eofDemuxCtx, 2), static_cast<int32_t>(sizeof(incompletePssStart)),
"sceMpegDemuxPssRing should accept partial trailing stream data");
R5900Context eofBeforeStopCtx{};
setRegU32(eofBeforeStopCtx, 4, eofMpegAddr);
ps2_stubs::sceMpegIsEnd(rdram.data(), &eofBeforeStopCtx, nullptr);
t.Equals(getRegS32(eofBeforeStopCtx, 2), 0,
"sceMpegIsEnd should not report end until the CD stream terminates");
R5900Context cdStopCtx{};
ps2_stubs::sceCdStStop(rdram.data(), &cdStopCtx, nullptr);
R5900Context eofAfterStopCtx{};
setRegU32(eofAfterStopCtx, 4, eofMpegAddr);
ps2_stubs::sceMpegIsEnd(rdram.data(), &eofAfterStopCtx, nullptr);
t.Equals(getRegS32(eofAfterStopCtx, 2), 1,
"sceCdStStop should finalize active MPEG playback so movie threads can advance");
R5900Context remoteInitCtx{};
ps2_stubs::sceSdRemoteInit(rdram.data(), &remoteInitCtx, nullptr);
t.Equals(getRegS32(remoteInitCtx, 2), 0,
"sceSdRemoteInit should succeed so Veronica can set up movie audio");
R5900Context blockTransCtx{};
const uint32_t blockTransSp = 0x00100000u;
setRegU32(blockTransCtx, 29, blockTransSp);
setRegU32(blockTransCtx, 4, 1u);
setRegU32(blockTransCtx, 5, 0x80E0u);
setRegU32(blockTransCtx, 6, 1u);
setRegU32(blockTransCtx, 7, 0x13u);
std::memcpy(rdram.data() + blockTransSp + 0x10u, "\x40\x23\x01\x00", 4u);
std::memcpy(rdram.data() + blockTransSp + 0x14u, "\x00\x30\x00\x00", 4u);
std::memcpy(rdram.data() + blockTransSp + 0x18u, "\x40\x27\x01\x00", 4u);
ps2_stubs::sceSdRemote(rdram.data(), &blockTransCtx, nullptr);
t.Equals(getRegU32(&blockTransCtx, 2), 0u,
"sceSdRemote block-transfer start should report libsd success");
R5900Context statusCtx{};
setRegU32(statusCtx, 29, blockTransSp);
setRegU32(statusCtx, 4, 1u);
setRegU32(statusCtx, 5, 0x8100u);
setRegU32(statusCtx, 6, 1u);
setRegU32(statusCtx, 7, 0u);
std::memset(rdram.data() + blockTransSp + 0x10u, 0, 12u);
ps2_stubs::sceSdRemote(rdram.data(), &statusCtx, nullptr);
t.Equals(getRegU32(&statusCtx, 2), 0x00012B40u,
"sceSdRemote status polling should advance the emulated SPU transfer head");
for (uint32_t i = 0u; i < 11u; ++i)
{
ps2_stubs::sceSdRemote(rdram.data(), &statusCtx, nullptr);
}
t.Equals(getRegU32(&statusCtx, 2), 0x00012740u,
"sceSdRemote status polling should wrap inside the configured IOP ring");
R5900Context setParamCtx{};
setRegU32(setParamCtx, 29, blockTransSp);
setRegU32(setParamCtx, 4, 1u);
setRegU32(setParamCtx, 5, 0x8010u);
setRegU32(setParamCtx, 6, 0x0F81u);
setRegU32(setParamCtx, 7, 0u);
ps2_stubs::sceSdRemote(rdram.data(), &setParamCtx, nullptr);
t.Equals(getRegU32(&setParamCtx, 2), 0u,
"sceSdRemote set-param calls should not trap or disturb the movie audio state");
});
tc.Run("sceSdRemote isolates voice transfers from block streaming state", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kStackAddr = 0x00100000u;
constexpr uint32_t kBlockBase = 0x00012340u;
constexpr uint32_t kBlockSize = 0x00003000u;
constexpr uint32_t kBlockPause = 0x00012740u;
R5900Context initCtx{};
ps2_stubs::sceSdRemoteInit(rdram.data(), &initCtx, nullptr);
R5900Context blockCtx{};
setRegU32(blockCtx, 29, kStackAddr);
setRegU32(blockCtx, 4, 1u);
setRegU32(blockCtx, 5, 0x80E0u);
setRegU32(blockCtx, 6, 1u);
setRegU32(blockCtx, 7, 0x13u);
setRegU32(blockCtx, 8, kBlockBase);
setRegU32(blockCtx, 9, kBlockSize);
setRegU32(blockCtx, 10, kBlockPause);
ps2_stubs::sceSdRemote(rdram.data(), &blockCtx, nullptr);
R5900Context blockStatusCtx{};
setRegU32(blockStatusCtx, 29, kStackAddr);
setRegU32(blockStatusCtx, 4, 1u);
setRegU32(blockStatusCtx, 5, 0x8100u);
setRegU32(blockStatusCtx, 6, 1u);
setRegU32(blockStatusCtx, 7, 0u);
ps2_stubs::sceSdRemote(rdram.data(), &blockStatusCtx, nullptr);
t.Equals(getRegU32(&blockStatusCtx, 2), 0x00012B40u,
"initial block-status poll should advance the streaming ring");
R5900Context voiceCtx{};
setRegU32(voiceCtx, 29, kStackAddr);
setRegU32(voiceCtx, 4, 1u);
setRegU32(voiceCtx, 5, 0x80D0u);
setRegU32(voiceCtx, 6, 0u);
setRegU32(voiceCtx, 7, 0u);
setRegU32(voiceCtx, 8, 0x00022000u);
setRegU32(voiceCtx, 9, 0x00004000u);
setRegU32(voiceCtx, 10, 0x00000800u);
ps2_stubs::sceSdRemote(rdram.data(), &voiceCtx, nullptr);
t.Equals(getRegU32(&voiceCtx, 2), 0x00000800u,
"DMA voice transfer should report its transferred byte count");
R5900Context voiceStatusCtx{};
setRegU32(voiceStatusCtx, 29, kStackAddr);
setRegU32(voiceStatusCtx, 4, 1u);
setRegU32(voiceStatusCtx, 5, 0x80F0u);
setRegU32(voiceStatusCtx, 6, 0u);
setRegU32(voiceStatusCtx, 7, 1u);
ps2_stubs::sceSdRemote(rdram.data(), &voiceStatusCtx, nullptr);
t.Equals(getRegU32(&voiceStatusCtx, 2), 1u,
"voice-transfer status should complete independently from block position");
ps2_stubs::sceSdRemote(rdram.data(), &blockStatusCtx, nullptr);
t.Equals(getRegU32(&blockStatusCtx, 2), 0x00012F40u,
"voice transfer should not replace or advance the block-streaming ring");
});
tc.Run("sceSdRemote keeps block cursors and loop banks isolated per core", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kStackAddr = 0x00100000u;
R5900Context initCtx{};
ps2_stubs::sceSdRemoteInit(rdram.data(), &initCtx, nullptr);
auto remote = [&](uint32_t command,
uint32_t core,
uint32_t mode,
uint32_t arg4 = 0u,
uint32_t arg5 = 0u,
uint32_t arg6 = 0u)
{
R5900Context ctx{};
setRegU32(ctx, 29, kStackAddr);
setRegU32(ctx, 4, 1u);
setRegU32(ctx, 5, command);
setRegU32(ctx, 6, core);
setRegU32(ctx, 7, mode);
setRegU32(ctx, 8, arg4);
setRegU32(ctx, 9, arg5);
setRegU32(ctx, 10, arg6);
ps2_stubs::sceSdRemote(rdram.data(), &ctx, nullptr);
return getRegU32(&ctx, 2);
};
t.Equals(remote(0x80E0u, 0u, 0x10u, 0x00010000u, 0x00001000u, 0x00010000u), 0u,
"core 0 block stream should start successfully");
t.Equals(remote(0x80E0u, 1u, 0x13u, 0x00020000u, 0x00002000u, 0x00020800u), 0u,
"core 1 block stream should start independently");
t.Equals(remote(0x8100u, 0u, 0u), 0x00010400u,
"core 0 status should advance only the core 0 cursor");
t.Equals(remote(0x8100u, 1u, 0u), 0x00020C00u,
"core 1 status should retain its independent pause position");
t.Equals(remote(0x8100u, 0u, 0u), 0x01010800u,
"loop status should expose the second buffer in the high byte");
t.Equals(remote(0x80E0u, 0u, 0x02u), 0x01010800u,
"block STOP should return the final core 0 cursor");
t.Equals(remote(0x8100u, 0u, 0u), 0u,
"stopped block status should no longer expose a live cursor");
t.Equals(remote(0x8100u, 1u, 0u), 0x01021000u,
"stopping core 0 should not stop or advance core 1");
ps2_stubs::sceSdRemoteInit(rdram.data(), &initCtx, nullptr);
t.Equals(remote(0x8100u, 1u, 0u), 0u,
"sceSdRemoteInit should reset block state for both cores");
t.Equals(remote(0x80F0u, 1u, 0u), 1u,
"sceSdRemoteInit should restore idle voice status to complete");
});
tc.Run("IPU init skips missing optional helper instead of dispatching the default trap", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
ctx.pc = 0x0010B470u;
ps2_stubs::sceIpuInit(rdram.data(), &ctx, &runtime);
t.IsFalse(runtime.isStopRequested(),
"sceIpuInit should tolerate the missing optional SetD4 helper");
t.Equals(runtime.memory().read32(0x10002010u), 0x40000000u,
"sceIpuInit should still program IPU_CTRL");
t.Equals(runtime.memory().read32(0x10002000u), 0u,
"sceIpuInit should leave IPU_CMD reset after initialization");
});
tc.Run("sprintf consumes EE varargs from a2 a3 t0 and preserves width formatting", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
constexpr uint32_t kDestAddr = 0x00002000u;
constexpr uint32_t kFormatAddr = 0x00002100u;
constexpr char kFormat[] = "rm_%1d%02d%1d.rdx";
std::memcpy(rdram.data() + kFormatAddr, kFormat, sizeof(kFormat));
setRegU32(ctx, 4, kDestAddr);
setRegU32(ctx, 5, kFormatAddr);
setRegU32(ctx, 6, 0u); // a2
setRegU32(ctx, 7, 3u); // a3
setRegU32(ctx, 8, 1u); // t0
ps2_stubs::sprintf(rdram.data(), &ctx, &runtime);
const std::string rendered(reinterpret_cast<const char *>(rdram.data() + kDestAddr));
t.Equals(rendered, std::string("rm_0031.rdx"),
"sprintf should read the third variadic integer from t0 and honor %02d");
t.Equals(getRegS32(ctx, 2), static_cast<int32_t>(rendered.size()),
"sprintf should return the rendered length");
});
tc.Run("multiply-add matrix writes rd only when R5900 requires it", [](TestCase &t)
{
R5900Decoder decoder;
CodeGenerator generator({}, {});
const struct
{
const char *name;
uint32_t raw;
bool expectedRdWrite;
} cases[] = {
{"MULTU rd!=0", (OPCODE_SPECIAL << 26) | (2u << 21) | (3u << 16) | (11u << 11) | SPECIAL_MULTU, true},
{"MMI MADD rd!=0", (OPCODE_MMI << 26) | (2u << 21) | (3u << 16) | (12u << 11) | MMI_MADD, true},
{"MMI MADDU rd!=0", (OPCODE_MMI << 26) | (2u << 21) | (3u << 16) | (13u << 11) | MMI_MADDU, true},
{"MMI MADD1 rd!=0", (OPCODE_MMI << 26) | (2u << 21) | (3u << 16) | (14u << 11) | MMI_MADD1, true},
{"MMI MADDU1 rd!=0", (OPCODE_MMI << 26) | (2u << 21) | (3u << 16) | (15u << 11) | MMI_MADDU1, true},
{"MMI DIVU1 rd!=0", (OPCODE_MMI << 26) | (2u << 21) | (3u << 16) | (16u << 11) | MMI_DIVU1, false},
};
for (size_t i = 0; i < std::size(cases); ++i)
{
const Instruction inst = decoder.decodeInstruction(0x2000u + static_cast<uint32_t>(i * 4u), cases[i].raw);
const std::string generated = generator.translateInstruction(inst);
const bool emittedRdWrite = hasSignedRdWrite(generated, inst.rd);
t.Equals(inst.modificationInfo.modifiesGPR, cases[i].expectedRdWrite,
std::string("decoder rd-write metadata mismatch for ") + cases[i].name);
t.Equals(emittedRdWrite, cases[i].expectedRdWrite,
std::string("codegen rd-write mismatch for ") + cases[i].name);
}
});
tc.Run("SignalException marks EPC and BD for delay-slot exceptions", [](TestCase &t)
{
PS2Runtime runtime;
R5900Context ctx{};
ctx.pc = 0x2000u;
ctx.branch_pc = 0x1FFCu;
ctx.in_delay_slot = true;
ctx.cop0_status = 0u;
ctx.cop0_cause = 0u;
runtime.SignalException(&ctx, EXCEPTION_ADDRESS_ERROR_LOAD);
t.Equals(ctx.cop0_epc, 0x1FFCu, "delay-slot exception should capture branch_pc in EPC");
t.IsTrue((ctx.cop0_cause & COP0_CAUSE_BD) != 0u, "delay-slot exception should set CAUSE.BD");
t.Equals(ctx.cop0_cause & COP0_CAUSE_EXCCODE_MASK,
(static_cast<uint32_t>(EXCEPTION_ADDRESS_ERROR_LOAD) << 2) & COP0_CAUSE_EXCCODE_MASK,
"CAUSE.EXCCODE should match exception");
t.IsTrue((ctx.cop0_status & COP0_STATUS_EXL) != 0u, "exception should set STATUS.EXL");
t.Equals(ctx.pc, EXCEPTION_VECTOR_GENERAL, "exception should jump to general vector when BEV=0");
t.IsFalse(ctx.in_delay_slot, "exception delivery should clear delay-slot state");
});
tc.Run("SignalException uses current pc without BD and honors BEV vector", [](TestCase &t)
{
PS2Runtime runtime;
R5900Context ctx{};
ctx.pc = 0x3000u;
ctx.in_delay_slot = false;
ctx.cop0_status = COP0_STATUS_BEV;
ctx.cop0_cause = COP0_CAUSE_BD;
runtime.SignalException(&ctx, EXCEPTION_ADDRESS_ERROR_STORE);
t.Equals(ctx.cop0_epc, 0x3000u, "non-delay exception should capture current pc in EPC");
t.IsTrue((ctx.cop0_cause & COP0_CAUSE_BD) == 0u, "non-delay exception should clear CAUSE.BD");
t.Equals(ctx.pc, EXCEPTION_VECTOR_BOOT, "BEV=1 should route exception to boot vector");
});
tc.Run("handleSyscall rejects invocation in delay slot", [](TestCase &t)
{
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
ctx.in_delay_slot = true;
bool threw = false;
try
{
runtime.handleSyscall(rdram.data(), &ctx, 0x3Cu);
}
catch (const std::runtime_error &)
{
threw = true;
}
t.IsTrue(threw, "syscall from delay slot should throw to preserve block atomicity");
});
tc.Run("VIF MSCAL and MSCNT toggle DBF and keep TOPS/ITOPS coherent", [](TestCase &t)
{
PS2Memory mem;
t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed");
mem.vif1_regs.base = 4u;
mem.vif1_regs.ofst = 2u;
mem.vif1_regs.tops = 4u;
mem.vif1_regs.itops = 0x21u;
mem.vif1_regs.stat &= ~(1u << 7); // DBF = 0
uint32_t callbackPc = 0xFFFFFFFFu;
uint32_t callbackTop = 0xFFFFFFFFu;
uint32_t callbackItop = 0xFFFFFFFFu;
uint32_t callbackCount = 0u;
mem.setVu1MscalCallback([&](uint32_t startPC, uint32_t top, uint32_t itop)
{
callbackPc = startPC;
callbackTop = top;
callbackItop = itop;
++callbackCount;
});
const uint32_t mscal = makeVifCmd(0x14u, 0u, 3u); // start PC = 3 * 8
mem.processVIF1Data(reinterpret_cast<const uint8_t *>(&mscal), sizeof(mscal));
t.Equals(callbackCount, 1u, "MSCAL should invoke VU1 callback exactly once");
t.Equals(callbackPc, 24u, "MSCAL should pass startPC=imm*8");
t.Equals(callbackTop, 4u, "MSCAL callback should receive current TOPS");
t.Equals(callbackItop, 0x21u, "MSCAL callback should receive pending ITOPS");
t.Equals(mem.vif1_regs.top, 4u, "MSCAL should latch TOP from TOPS");
t.Equals(mem.vif1_regs.itop, 0x21u, "MSCAL should latch ITOP from ITOPS");
t.IsTrue((mem.vif1_regs.stat & (1u << 7)) != 0u, "MSCAL should toggle DBF on");
t.Equals(mem.vif1_regs.tops, 6u, "DBF=1 should make TOPS=BASE+OFST");
const uint32_t mscnt = makeVifCmd(0x17u, 0u, 0u);
mem.processVIF1Data(reinterpret_cast<const uint8_t *>(&mscnt), sizeof(mscnt));
t.Equals(callbackCount, 1u, "MSCNT should not invoke MSCAL callback");
t.IsTrue((mem.vif1_regs.stat & (1u << 7)) == 0u, "MSCNT should toggle DBF back off");
t.Equals(mem.vif1_regs.tops, 4u, "DBF=0 should make TOPS=BASE");
t.Equals(mem.vif1_regs.top, 6u, "MSCNT should latch TOP from current TOPS before toggling");
t.Equals(mem.vif1_regs.itop, 0x21u, "MSCNT should keep latching ITOP from ITOPS");
});
tc.Run("VU0 microprogram executes against VU0 code and data memory", [](TestCase &t)
{
PS2Runtime runtime;
t.IsTrue(runtime.memory().initialize(), "PS2Memory initialize should succeed");
t.IsTrue(runtime.syncCoreSubsystems(), "runtime core subsystems should bind");
uint8_t *const code = runtime.memory().getVU0Code();
uint8_t *const data = runtime.memory().getVU0Data();
std::memset(code, 0, PS2_VU0_CODE_SIZE);
std::memset(data, 0, PS2_VU0_DATA_SIZE);
const float input[4] = {1.0f, 2.0f, 3.0f, 4.0f};
std::memcpy(data, input, sizeof(input));
constexpr uint32_t kVuNop = 0x0000003Fu;
constexpr uint32_t kVuEndNop = 0x4000003Fu;
writeVuInstructionPair(code, 0u, makeVuLq(0xFu, 1u, 0u, 0), kVuNop);
writeVuInstructionPair(code, 8u, 0u, makeVuAdd(0xFu, 2u, 1u, 1u));
writeVuInstructionPair(code, 16u, makeVuSq(0xFu, 2u, 0u, 1), kVuEndNop);
R5900Context ctx;
runtime.executeVU0Microprogram(runtime.memory().getRDRAM(), &ctx, 0u);
float output[4]{};
std::memcpy(output, data + 16u, sizeof(output));
t.Equals(output[0], 2.0f, "VU0 output x should be doubled");
t.Equals(output[1], 4.0f, "VU0 output y should be doubled");
t.Equals(output[2], 6.0f, "VU0 output z should be doubled");
t.Equals(output[3], 8.0f, "VU0 output w should be doubled");
alignas(16) float vf2[4]{};
_mm_storeu_ps(vf2, ctx.vu0_vf[2]);
t.Equals(vf2[0], 2.0f, "VU0 VF2.x should copy back to CPU context");
t.Equals(static_cast<uint32_t>(ctx.vi[0]), 0u, "VU0 VI0 should remain zero");
});
tc.Run("GS sprite draw applies XYOFFSET and fully-outside scissor should not render", [](TestCase &t)
{
std::vector<uint8_t> vram(PS2_GS_VRAM_SIZE, 0u);
GS gs;
gs.init(vram.data(), static_cast<uint32_t>(vram.size()), nullptr);
const uint64_t frame1 =
(0ull << 0) | // FBP
(1ull << 16) | // FBW
(0ull << 24) | // PSM CT32
(0ull << 32); // FBMSK
const uint64_t zbuf1 = (1ull << 32);
gs.writeRegister(GS_REG_FRAME_1, frame1);
gs.writeRegister(GS_REG_ZBUF_1, zbuf1);
gs.writeRegister(GS_REG_TEST_1, 0x30000ull);
// XYOFFSET=1,1 pixels (16.4 fixed point).
const uint64_t xyoffset = (16ull) | (16ull << 32);
gs.writeRegister(GS_REG_XYOFFSET_1, xyoffset);
// Scissor initially includes pixel (1,1).
const uint64_t scissorInside = (0ull) | (3ull << 16) | (0ull << 32) | (3ull << 48);
gs.writeRegister(GS_REG_SCISSOR_1, scissorInside);
gs.writeRegister(GS_REG_PRIM, static_cast<uint64_t>(GS_PRIM_SPRITE));
gs.writeRegister(GS_REG_RGBAQ, 0xFF3214C8ull); // RGBA=(200,20,50,255)
// With XYOFFSET=(1,1), vertex at (2,2) draws to pixel (1,1).
const uint64_t xyz = (32ull) | (32ull << 16) | (0ull << 32);
gs.writeRegister(GS_REG_XYZ2, xyz);
gs.writeRegister(GS_REG_XYZ2, xyz);
const uint32_t insideOff = frameOffsetBytes(1u, 1u, 1u);
t.Equals(vram[insideOff + 0u], static_cast<uint8_t>(200u), "inside draw should write R");
t.Equals(vram[insideOff + 1u], static_cast<uint8_t>(20u), "inside draw should write G");
t.Equals(vram[insideOff + 2u], static_cast<uint8_t>(50u), "inside draw should write B");
t.Equals(vram[insideOff + 3u], static_cast<uint8_t>(255u), "inside draw should write A");
std::memset(vram.data(), 0, 1024u);
// Move scissor so target pixel is fully outside.
const uint64_t scissorOutside = (3ull) | (4ull << 16) | (3ull << 32) | (4ull << 48);
gs.writeRegister(GS_REG_SCISSOR_1, scissorOutside);
gs.writeRegister(GS_REG_XYZ2, xyz);
gs.writeRegister(GS_REG_XYZ2, xyz);
bool anyWrite = false;
for (size_t i = 0; i < 1024u; ++i)
{
if (vram[i] != 0u)
{
anyWrite = true;
break;
}
}
t.IsFalse(anyWrite, "fully-outside sprite should not render any pixel");
});
tc.Run("GS alpha blend uses ALPHA register FIX factor", [](TestCase &t)
{
std::vector<uint8_t> vram(PS2_GS_VRAM_SIZE, 0u);
GS gs;
gs.init(vram.data(), static_cast<uint32_t>(vram.size()), nullptr);
const uint64_t frame1 =
(0ull << 0) | // FBP
(1ull << 16) | // FBW
(0ull << 24) | // PSM CT32
(0ull << 32); // FBMSK
const uint64_t zbuf1 = (1ull << 32);
gs.writeRegister(GS_REG_FRAME_1, frame1);
gs.writeRegister(GS_REG_ZBUF_1, zbuf1);
gs.writeRegister(GS_REG_SCISSOR_1, (0ull) | (4ull << 16) | (0ull << 32) | (4ull << 48));
gs.writeRegister(GS_REG_XYOFFSET_1, 0ull);
gs.writeRegister(GS_REG_TEST_1, 0x30000ull);
const uint32_t pxOff = frameOffsetBytes(1u, 1u, 1u);
vram[pxOff + 0u] = 40u;
vram[pxOff + 1u] = 40u;
vram[pxOff + 2u] = 40u;
vram[pxOff + 3u] = 255u;
// ABE on sprite prim.
gs.writeRegister(GS_REG_PRIM, static_cast<uint64_t>(GS_PRIM_SPRITE) | (1ull << 6));
// ALPHA: (A-B)*FIX/128 + D
// A=Cs(0), B=Cd(1), C=FIX(2), D=Cd(1), FIX=64.
const uint64_t alpha = (0ull << 0) | (1ull << 2) | (2ull << 4) | (1ull << 6) | (64ull << 32);
gs.writeRegister(GS_REG_ALPHA_1, alpha);
gs.writeRegister(GS_REG_RGBAQ, 0xFFC8C8C8ull); // src RGB = 200
const uint64_t xyz = (16ull) | (16ull << 16) | (0ull << 32); // pixel (1,1)
gs.writeRegister(GS_REG_XYZ2, xyz);
gs.writeRegister(GS_REG_XYZ2, xyz);
// ((200 - 40) * 64 >> 7) + 40 = 120
t.Equals(vram[pxOff + 0u], static_cast<uint8_t>(120u), "alpha blend should update R with FIX factor");
t.Equals(vram[pxOff + 1u], static_cast<uint8_t>(120u), "alpha blend should update G with FIX factor");
t.Equals(vram[pxOff + 2u], static_cast<uint8_t>(120u), "alpha blend should update B with FIX factor");
});
tc.Run("notifyRuntimeStop joins guest worker threads before teardown", [](TestCase &t)
{
notifyRuntimeStop();
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kEntry = 0x250000u;
constexpr uint32_t kThreadParamAddr = 0x2600u;
const uint32_t threadParam[7] = {
0u, // attr
kEntry, // entry
0x00100000u, // stack
0x00000400u, // stack size
0x00110000u, // gp
8u, // priority
0u // option
};
runtime.registerFunction(kEntry, &testRuntimeWorkerLoop);
std::memcpy(rdram.data() + kThreadParamAddr, threadParam, sizeof(threadParam));
R5900Context createCtx{};
setRegU32(createCtx, 4, kThreadParamAddr);
CreateThread(rdram.data(), &createCtx, &runtime);
const int32_t tid = getRegS32(createCtx, 2);
t.IsTrue(tid > 0, "CreateThread should succeed for teardown-join test");
R5900Context startCtx{};
setRegU32(startCtx, 4, static_cast<uint32_t>(tid));
setRegU32(startCtx, 5, 0u);
StartThread(rdram.data(), &startCtx, &runtime);
t.Equals(getRegS32(startCtx, 2), KE_OK, "StartThread should launch worker");
const bool started = waitUntil([&]()
{
return g_activeThreads.load(std::memory_order_relaxed) > 0;
}, std::chrono::milliseconds(500));
t.IsTrue(started, "worker thread should become active");
runtime.requestStop();
const bool drained = waitUntil([&]()
{
return g_activeThreads.load(std::memory_order_relaxed) == 0;
}, std::chrono::milliseconds(2000));
t.IsTrue(drained, "requestStop should drain all guest worker threads");
notifyRuntimeStop();
});
tc.Run("Semaphore poll/signal remains stable under host-thread contention", [](TestCase &t)
{
notifyRuntimeStop();
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kParamAddr = 0x2000u;
// init=1 < max=2 headroom makes both first calls succeed
// regardless of scheduling: poller is the sole decrementer
// (count>=1 at first poll), signaler the sole incrementer
// (count<max at first signal). Keep init<max.
const uint32_t semaParam[6] = {
0u, // count
2u, // max_count
1u, // init_count
0u, // wait_threads
0u, // attr
0u // option
};
std::memcpy(rdram.data() + kParamAddr, semaParam, sizeof(semaParam));
R5900Context createCtx{};
setRegU32(createCtx, 4, kParamAddr);
CreateSema(rdram.data(), &createCtx, &runtime);
const int32_t sid = getRegS32(createCtx, 2);
t.IsTrue(sid > 0, "CreateSema should return a valid sid");
std::atomic<int32_t> pollOkCount{0};
std::atomic<int32_t> signalOkCount{0};
std::atomic<bool> pollerThrew{false};
std::atomic<bool> signalerThrew{false};
std::atomic<int32_t> readyCount{0};
// Release both workers together so their 64-iteration loops start at
// the same instant, maximizing the opportunity to interleave instead
// of one thread running to completion before the other is scheduled.
const auto waitForStart = [&]()
{
readyCount.fetch_add(1, std::memory_order_acq_rel);
while (readyCount.load(std::memory_order_acquire) < 2)
{
std::this_thread::yield();
}
};
std::thread poller([&]()
{
try
{
waitForStart();
for (int i = 0; i < 64; ++i)
{
R5900Context pollCtx{};
setRegU32(pollCtx, 4, static_cast<uint32_t>(sid));
PollSema(rdram.data(), &pollCtx, &runtime);
if (getRegS32(pollCtx, 2) == sid)
{
pollOkCount.fetch_add(1, std::memory_order_relaxed);
}
}
}
catch (...)
{
pollerThrew.store(true, std::memory_order_release);
}
});
std::thread signaler([&]()
{
try
{
waitForStart();
for (int i = 0; i < 64; ++i)
{
R5900Context signalCtx{};
setRegU32(signalCtx, 4, static_cast<uint32_t>(sid));
SignalSema(rdram.data(), &signalCtx, &runtime);
if (getRegS32(signalCtx, 2) == sid)
{
signalOkCount.fetch_add(1, std::memory_order_relaxed);
}
}
}
catch (...)
{
signalerThrew.store(true, std::memory_order_release);
}
});
if (poller.joinable())
{
poller.join();
}
if (signaler.joinable())
{
signaler.join();
}
t.IsFalse(pollerThrew.load(std::memory_order_acquire),
"PollSema worker thread should not throw");
t.IsFalse(signalerThrew.load(std::memory_order_acquire),
"SignalSema worker thread should not throw");
t.IsTrue(pollOkCount.load(std::memory_order_relaxed) > 0,
"contended PollSema should observe at least one successful acquire");
t.IsTrue(signalOkCount.load(std::memory_order_relaxed) > 0,
"contended SignalSema should observe successful releases");
constexpr uint32_t kStatusAddr = 0x2100u;
R5900Context referCtx{};
setRegU32(referCtx, 4, static_cast<uint32_t>(sid));
setRegU32(referCtx, 5, kStatusAddr);
ReferSemaStatus(rdram.data(), &referCtx, &runtime);
t.Equals(getRegS32(referCtx, 2), KE_OK, "ReferSemaStatus should succeed after contention");
int32_t finalCount = 0;
std::memcpy(&finalCount, rdram.data() + kStatusAddr + 0u, sizeof(finalCount));
t.IsTrue(finalCount >= 0 && finalCount <= 2, "semaphore count should remain within [0, max_count]");
runtime.requestStop();
notifyRuntimeStop();
});
tc.Run("WaitEventFlag AND-mode is stable under concurrent setters", [](TestCase &t)
{
notifyRuntimeStop();
PS2Runtime runtime;
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
constexpr uint32_t kEventParamAddr = 0x2400u;
constexpr uint32_t kResBitsAddr = 0x2410u;
const uint32_t eventParam[3] = {0u, 0u, 0u};
std::memcpy(rdram.data() + kEventParamAddr, eventParam, sizeof(eventParam));
R5900Context createCtx{};
setRegU32(createCtx, 4, kEventParamAddr);
CreateEventFlag(rdram.data(), &createCtx, &runtime);
const int32_t eid = getRegS32(createCtx, 2);
t.IsTrue(eid > 0, "CreateEventFlag should return a valid id");
std::atomic<bool> waiterDone{false};
std::atomic<int32_t> waiterRet{-9999};
std::atomic<uint32_t> waiterBits{0u};
std::atomic<bool> waiterThrew{false};
std::atomic<bool> setterAThrew{false};
std::atomic<bool> setterBThrew{false};
std::thread waiter([&]()
{
try
{
R5900Context waitCtx{};
setRegU32(waitCtx, 4, static_cast<uint32_t>(eid));
setRegU32(waitCtx, 5, 0x3u); // wait for bit0 and bit1 (AND mode)
setRegU32(waitCtx, 6, 0u); // AND, no clear
setRegU32(waitCtx, 7, kResBitsAddr);
WaitEventFlag(rdram.data(), &waitCtx, &runtime);
waiterRet.store(getRegS32(waitCtx, 2), std::memory_order_relaxed);
uint32_t bits = 0u;
std::memcpy(&bits, rdram.data() + kResBitsAddr, sizeof(bits));
waiterBits.store(bits, std::memory_order_relaxed);
}
catch (...)
{
waiterThrew.store(true, std::memory_order_release);
}
waiterDone.store(true, std::memory_order_release);
});
std::thread setterA([&]()
{
try
{
std::this_thread::sleep_for(std::chrono::milliseconds(10));
R5900Context setCtx{};
setRegU32(setCtx, 4, static_cast<uint32_t>(eid));
setRegU32(setCtx, 5, 0x1u);
SetEventFlag(rdram.data(), &setCtx, &runtime);
}
catch (...)
{
setterAThrew.store(true, std::memory_order_release);
}
});
std::thread setterB([&]()
{
try
{
std::this_thread::sleep_for(std::chrono::milliseconds(15));
R5900Context setCtx{};
setRegU32(setCtx, 4, static_cast<uint32_t>(eid));
setRegU32(setCtx, 5, 0x2u);
SetEventFlag(rdram.data(), &setCtx, &runtime);
}
catch (...)
{
setterBThrew.store(true, std::memory_order_release);
}
});
const bool woke = waitUntil([&]()
{
return waiterDone.load(std::memory_order_acquire);
}, std::chrono::milliseconds(500));
if (setterA.joinable())
{
setterA.join();
}
if (setterB.joinable())
{
setterB.join();
}
if (waiter.joinable())
{
waiter.join();
}
t.IsFalse(waiterThrew.load(std::memory_order_acquire),
"WaitEventFlag waiter thread should not throw");
t.IsFalse(setterAThrew.load(std::memory_order_acquire),
"SetEventFlag setterA thread should not throw");
t.IsFalse(setterBThrew.load(std::memory_order_acquire),
"SetEventFlag setterB thread should not throw");
t.IsTrue(woke, "WaitEventFlag AND waiter should wake after both bits are published");
t.Equals(waiterRet.load(std::memory_order_relaxed), KE_OK, "WaitEventFlag should return KE_OK");
t.IsTrue((waiterBits.load(std::memory_order_relaxed) & 0x3u) == 0x3u,
"WaitEventFlag result bits should include both concurrently-set bits");
R5900Context deleteCtx{};
setRegU32(deleteCtx, 4, static_cast<uint32_t>(eid));
DeleteEventFlag(rdram.data(), &deleteCtx, &runtime);
runtime.requestStop();
notifyRuntimeStop();
});
tc.Run("sceVu0ApplyMatrix uses libvux matrix math with the imported EE ABI", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
constexpr uint32_t kOutAddr = 0x00100000u;
constexpr uint32_t kMatrixAddr = 0x00100040u;
constexpr uint32_t kSrcAddr = 0x00100080u;
const float matrix[16] = {
1.0f, 2.0f, 3.0f, 4.0f,
5.0f, 6.0f, 7.0f, 8.0f,
9.0f, 10.0f, 11.0f, 12.0f,
13.0f, 14.0f, 15.0f, 16.0f,
};
const float src[4] = {1.0f, 2.0f, 3.0f, 1.0f};
std::memcpy(rdram.data() + kMatrixAddr, matrix, sizeof(matrix));
std::memcpy(rdram.data() + kSrcAddr, src, sizeof(src));
setRegU32(ctx, 4, kOutAddr);
setRegU32(ctx, 5, kMatrixAddr);
setRegU32(ctx, 6, kSrcAddr);
ps2_stubs::sceVu0ApplyMatrix(rdram.data(), &ctx, nullptr);
float out[4]{};
std::memcpy(out, rdram.data() + kOutAddr, sizeof(out));
t.Equals(out[0], 51.0f, "sceVu0ApplyMatrix should compute X with libvux layout");
t.Equals(out[1], 58.0f, "sceVu0ApplyMatrix should compute Y with libvux layout");
t.Equals(out[2], 65.0f, "sceVu0ApplyMatrix should compute Z with libvux layout");
t.Equals(out[3], 72.0f, "sceVu0ApplyMatrix should compute W with libvux layout");
t.Equals(getRegS32(ctx, 2), 0, "sceVu0ApplyMatrix should report success");
});
tc.Run("sceVu0TransposeMatrix transposes a 4x4 matrix with dst/src ABI", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
constexpr uint32_t kDstAddr = 0x00100100u;
constexpr uint32_t kSrcAddr = 0x00100140u;
const float src[16] = {
1.0f, 2.0f, 3.0f, 4.0f,
5.0f, 6.0f, 7.0f, 8.0f,
9.0f, 10.0f, 11.0f, 12.0f,
13.0f, 14.0f, 15.0f, 16.0f,
};
std::memcpy(rdram.data() + kSrcAddr, src, sizeof(src));
setRegU32(ctx, 4, kDstAddr);
setRegU32(ctx, 5, kSrcAddr);
ps2_stubs::sceVu0TransposeMatrix(rdram.data(), &ctx, nullptr);
float out[16]{};
std::memcpy(out, rdram.data() + kDstAddr, sizeof(out));
t.Equals(out[0], 1.0f, "transpose should preserve [0][0]");
t.Equals(out[1], 5.0f, "transpose should swap row 0 col 1");
t.Equals(out[2], 9.0f, "transpose should swap row 0 col 2");
t.Equals(out[3], 13.0f, "transpose should swap row 0 col 3");
t.Equals(out[4], 2.0f, "transpose should swap row 1 col 0");
t.Equals(out[5], 6.0f, "transpose should preserve [1][1]");
t.Equals(out[10], 11.0f, "transpose should preserve [2][2]");
t.Equals(out[12], 4.0f, "transpose should swap row 3 col 0");
t.Equals(out[15], 16.0f, "transpose should preserve [3][3]");
t.Equals(getRegS32(ctx, 2), 0, "sceVu0TransposeMatrix should report success");
});
tc.Run("sceVif1PkReset preserves the packet base pointer and clears open tag state", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
constexpr uint32_t kStateAddr = 0x00100200u;
constexpr uint32_t kBaseAddr = 0x00101000u;
setRegU32(ctx, 4, kStateAddr);
setRegU32(ctx, 5, kBaseAddr);
ps2_stubs::sceVif1PkInit(rdram.data(), &ctx, nullptr);
const uint32_t dirtyCurrent = kBaseAddr + 0x40u;
const uint32_t dirtyPending = 0x12345678u;
const uint32_t dirtyDirectOpen = 0x00ABCDEFu;
const uint32_t dirtyGifOpen = 0x00112233u;
std::memcpy(rdram.data() + kStateAddr + 0u, &dirtyCurrent, sizeof(dirtyCurrent));
std::memcpy(rdram.data() + kStateAddr + 8u, &dirtyPending, sizeof(dirtyPending));
std::memcpy(rdram.data() + kStateAddr + 12u, &dirtyDirectOpen, sizeof(dirtyDirectOpen));
std::memcpy(rdram.data() + kStateAddr + 20u, &dirtyGifOpen, sizeof(dirtyGifOpen));
std::memset(&ctx, 0, sizeof(ctx));
setRegU32(ctx, 4, kStateAddr);
ps2_stubs::sceVif1PkReset(rdram.data(), &ctx, nullptr);
uint32_t current = 0u;
uint32_t base = 0u;
uint32_t pending = 0u;
uint32_t directOpen = 0u;
uint32_t gifOpen = 0u;
std::memcpy(&current, rdram.data() + kStateAddr + 0u, sizeof(current));
std::memcpy(&base, rdram.data() + kStateAddr + 4u, sizeof(base));
std::memcpy(&pending, rdram.data() + kStateAddr + 8u, sizeof(pending));
std::memcpy(&directOpen, rdram.data() + kStateAddr + 12u, sizeof(directOpen));
std::memcpy(&gifOpen, rdram.data() + kStateAddr + 20u, sizeof(gifOpen));
t.Equals(current, kBaseAddr, "sceVif1PkReset should restore current pointer to the packet base");
t.Equals(base, kBaseAddr, "sceVif1PkReset should preserve the packet base pointer");
t.Equals(pending, 0u, "sceVif1PkReset should clear pending count tracking");
t.Equals(directOpen, 0u, "sceVif1PkReset should clear direct-code open state");
t.Equals(gifOpen, 0u, "sceVif1PkReset should clear GIF-tag open state");
t.Equals(::getRegU32(&ctx, 2), kBaseAddr, "sceVif1PkReset should return the packet base pointer");
});
tc.Run("sceVif1PkCloseDirectCode encodes DIRECT length in qwords", [](TestCase &t)
{
std::vector<uint8_t> rdram(PS2_RAM_SIZE, 0u);
R5900Context ctx{};
constexpr uint32_t kStateAddr = 0x00100400u;
constexpr uint32_t kBaseAddr = 0x00102000u;
setRegU32(ctx, 4, kStateAddr);
setRegU32(ctx, 5, kBaseAddr);
ps2_stubs::sceVif1PkInit(rdram.data(), &ctx, nullptr);
std::memset(&ctx, 0, sizeof(ctx));
setRegU32(ctx, 4, kStateAddr);
setRegU32(ctx, 5, 0u);
ps2_stubs::sceVif1PkCnt(rdram.data(), &ctx, nullptr);
std::memset(&ctx, 0, sizeof(ctx));
setRegU32(ctx, 4, kStateAddr);
setRegU32(ctx, 5, 0u);
ps2_stubs::sceVif1PkOpenDirectCode(rdram.data(), &ctx, nullptr);
std::memset(&ctx, 0, sizeof(ctx));
setRegU32(ctx, 4, kStateAddr);
setRegU32(ctx, 5, 4u); // reserve one qword worth of GIF payload
ps2_stubs::sceVif1PkReserve(rdram.data(), &ctx, nullptr);
std::memset(&ctx, 0, sizeof(ctx));
setRegU32(ctx, 4, kStateAddr);
ps2_stubs::sceVif1PkCloseDirectCode(rdram.data(), &ctx, nullptr);
uint32_t directCmd = 0u;
std::memcpy(&directCmd, rdram.data() + kBaseAddr + 12u, sizeof(directCmd));
t.Equals(directCmd, 0x50000001u, "sceVif1PkCloseDirectCode should store a 1-QW DIRECT length");
});
});
}