Files
PS2Recomp/ps2xTest/src/ps2_iop_tests.cpp
T
Ranieri ee149581aa Refactor IOP system (#170)
* feat: implement memory card IOP

* feat: IOP trace

* feat: move IOP logic to ps2xIOP
refactor: small refactor on audio api on runtime
2026-07-15 01:44:06 -03:00

817 lines
36 KiB
C++

#include "MiniTest.h"
#include "ps2x/iop/iop_subsystem.h"
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <limits>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#elif defined(__linux__)
#include <dlfcn.h>
#endif
namespace
{
using namespace ps2x::iop;
constexpr uint32_t kSyntheticSid = 0xF00DCAFEu;
constexpr uint32_t kCoreCollisionSid = 0x80001300u;
constexpr uint32_t kSyntheticFunction = 0x42u;
constexpr uint32_t kCoreCollisionFunction = 0x99u;
constexpr uint32_t kSyntheticEntryPoint = 0x00123456u;
constexpr uint32_t kSpecificRecvXEntryPoint = kSyntheticEntryPoint + 0x100u;
constexpr uint32_t kSyntheticCrc32 = 0xA1B2C3D4u;
constexpr uint32_t kResponseXor = 0xA5A55A5Au;
constexpr uint32_t kCoreCollisionResponse = 0xC0DEF00Du;
class FakeIopHost final : public IopHost
{
public:
explicit FakeIopHost(size_t memorySize = 0x10000u)
: memory(memorySize, 0u)
{
}
bool readGuest(uint32_t address, void *destination, size_t size) const override
{
if ((!destination && size != 0u) || !contains(address, size))
{
return false;
}
if (size != 0u)
{
std::memcpy(destination, memory.data() + address, size);
}
return true;
}
bool writeGuest(uint32_t address, const void *source, size_t size) override
{
if ((!source && size != 0u) || !contains(address, size))
{
return false;
}
if (size != 0u)
{
std::memcpy(memory.data() + address, source, size);
}
return true;
}
bool zeroGuest(uint32_t address, size_t size) override
{
if (!contains(address, size))
{
return false;
}
std::fill(memory.begin() + address, memory.begin() + address + size, 0u);
return true;
}
bool normalizeGuestAddress(uint32_t address, uint32_t &normalized) const override
{
normalized = address & 0x1FFFFFFFu;
return normalized < memory.size();
}
uint32_t allocateIopHandle(IopHandleKind kind) override
{
const uint32_t value = nextHandle;
nextHandle += (kind == IopHandleKind::RpcPacket) ? 0x40u : 0x80u;
return value;
}
uint32_t allocateGuest(uint32_t size, uint32_t alignment) override
{
if (size == 0u)
{
return 0u;
}
const uint64_t effectiveAlignment = alignment == 0u ? 1u : alignment;
const uint64_t aligned = ((static_cast<uint64_t>(nextGuestAddress) + effectiveAlignment - 1u) /
effectiveAlignment) *
effectiveAlignment;
if (aligned + size > memory.size())
{
return 0u;
}
nextGuestAddress = static_cast<uint32_t>(aligned + size);
guestAllocations.push_back(static_cast<uint32_t>(aligned));
return static_cast<uint32_t>(aligned);
}
void freeGuest(uint32_t address) override
{
freedGuestAddresses.push_back(address);
}
void audioCommand(uint32_t sid,
uint32_t function,
GuestBuffer send,
GuestBuffer receive) override
{
lastAudioSid = sid;
lastAudioFunction = function;
lastAudioSend = send;
lastAudioReceive = receive;
++audioCalls;
}
std::string hostPath(HostPathKind kind) const override
{
switch (kind)
{
case HostPathKind::CdRoot:
return "fake/cd";
case HostPathKind::CdImage:
return "fake/disc.iso";
case HostPathKind::HostRoot:
return "fake/host";
case HostPathKind::MemoryCardRoot:
return "fake/mc0";
default:
return "fake/elf";
}
}
std::string translateGuestPath(std::string_view path) const override
{
return "translated/" + std::string(path);
}
uint64_t openHostFile(std::string_view path) override
{
const auto file = hostFileContents.find(std::string(path));
if (file == hostFileContents.end())
{
return 0u;
}
const uint64_t handle = nextHostFileHandle++;
openHostFiles.emplace(handle, file->first);
return handle;
}
bool hostFileSize(uint64_t handle, uint64_t &size) const override
{
size = 0u;
const auto open = openHostFiles.find(handle);
if (open == openHostFiles.end())
{
return false;
}
const auto file = hostFileContents.find(open->second);
if (file == hostFileContents.end())
{
return false;
}
size = file->second.size();
return true;
}
bool readHostFile(uint64_t handle,
uint64_t offset,
void *destination,
size_t size,
size_t &bytesRead) override
{
bytesRead = 0u;
if (!destination && size != 0u)
{
return false;
}
const auto open = openHostFiles.find(handle);
if (open == openHostFiles.end())
{
return false;
}
const auto file = hostFileContents.find(open->second);
if (file == hostFileContents.end() || offset > file->second.size())
{
return false;
}
bytesRead = std::min<size_t>(size, file->second.size() - static_cast<size_t>(offset));
if (bytesRead != 0u)
{
std::memcpy(destination,
file->second.data() + static_cast<size_t>(offset),
bytesRead);
}
return true;
}
void closeHostFile(uint64_t handle) override
{
if (openHostFiles.erase(handle) != 0u)
{
closedHostFileHandles.push_back(handle);
}
}
int32_t memoryCard(const MemoryCardRequest &request) override
{
lastMemoryCardRequest = request;
++memoryCardCalls;
return 0;
}
bool hasGuestFunction(uint32_t address) const override
{
return address == guestFunctionAddress;
}
bool invokeGuestFunction(uint64_t callToken,
uint32_t address,
uint32_t a0,
uint32_t a1,
uint32_t a2,
uint32_t a3,
uint32_t *resultAddress) override
{
if (!hasGuestFunction(address))
{
return false;
}
lastCallToken = callToken;
lastGuestArguments = {a0, a1, a2, a3};
if (resultAddress)
{
*resultAddress = guestFunctionResult;
}
return true;
}
void log(LogLevel level, std::string_view message) override
{
logs.emplace_back(level, std::string(message));
}
bool writeWord(uint32_t address, uint32_t value)
{
return writeGuest(address, &value, sizeof(value));
}
uint32_t readWord(uint32_t address) const
{
uint32_t value = 0u;
(void)readGuest(address, &value, sizeof(value));
return value;
}
bool hasLog(std::string_view expected) const
{
return std::any_of(logs.begin(), logs.end(), [&](const auto &entry)
{ return entry.second == expected; });
}
std::vector<uint8_t> memory;
uint32_t nextHandle = 0x8000u;
uint32_t nextGuestAddress = 0x4000u;
std::vector<uint32_t> guestAllocations;
std::vector<uint32_t> freedGuestAddresses;
uint32_t audioCalls = 0u;
uint32_t lastAudioSid = 0u;
uint32_t lastAudioFunction = 0u;
GuestBuffer lastAudioSend{};
GuestBuffer lastAudioReceive{};
uint32_t memoryCardCalls = 0u;
MemoryCardRequest lastMemoryCardRequest{};
uint32_t guestFunctionAddress = 0x2000u;
uint32_t guestFunctionResult = 0x3000u;
uint64_t lastCallToken = 0u;
std::vector<uint32_t> lastGuestArguments;
std::vector<std::pair<LogLevel, std::string>> logs;
std::unordered_map<std::string, std::vector<uint8_t>> hostFileContents;
std::unordered_map<uint64_t, std::string> openHostFiles;
std::vector<uint64_t> closedHostFileHandles;
uint64_t nextHostFileHandle = 1u;
private:
bool contains(uint32_t address, size_t size) const
{
const uint64_t end = static_cast<uint64_t>(address) + static_cast<uint64_t>(size);
return end <= memory.size();
}
};
bool containsDiagnostic(const DebugSnapshot &snapshot, std::string_view text)
{
return std::any_of(snapshot.diagnostics.begin(), snapshot.diagnostics.end(), [&](const std::string &diagnostic)
{ return diagnostic.find(text) != std::string::npos; });
}
const DebugService *findService(const DebugSnapshot &snapshot, std::string_view name)
{
const auto it = std::find_if(snapshot.services.begin(), snapshot.services.end(), [&](const DebugService &service)
{ return service.name == name; });
return it == snapshot.services.end() ? nullptr : &*it;
}
uint64_t metricValue(const DebugService &service, std::string_view name)
{
const auto it = std::find_if(service.metrics.begin(), service.metrics.end(), [&](const DebugMetric &metric)
{ return metric.name == name; });
return it == service.metrics.end() ? std::numeric_limits<uint64_t>::max() : it->value;
}
bool pluginModuleIsLoaded(const std::filesystem::path &path)
{
#if defined(_WIN32)
return GetModuleHandleW(path.c_str()) != nullptr;
#elif defined(__linux__)
void *handle = dlopen(path.c_str(), RTLD_NOW | RTLD_NOLOAD);
if (!handle)
{
return false;
}
dlclose(handle);
return true;
#else
(void)path;
return false;
#endif
}
}
void register_ps2_iop_tests()
{
MiniTest::Case("PS2IopSubsystem", [](TestCase &tc)
{
tc.Run("unknown SID remains unhandled without a matching profile", [](TestCase &t)
{
FakeIopHost host;
ps2x::iop::IopSubsystem subsystem(host);
std::string error;
const bool configured = subsystem.configure({"unmatched.elf", 0x100000u, 0x12345678u}, &error);
t.IsTrue(configured, "configuring an unmatched game should keep core-only IOP services available");
ps2x::iop::RpcRequest request{};
request.sid = 0xDEADC0DEu;
request.function = 0x99u;
const ps2x::iop::RpcResult result = subsystem.handleRpc(request);
t.IsFalse(result.handled, "an unknown SID should not be claimed by the IOP subsystem");
t.Equals(result.resultAddress, 0u, "an unknown SID should not return a guest result address");
t.IsFalse(result.signalNowaitCompletion, "an unknown SID should not signal nowait completion");
t.Equals(result.callbackPolicy, ps2x::iop::CallbackPolicy::RuntimeDefault,
"an unknown SID should preserve runtime callback handling");
const ps2x::iop::DebugSnapshot snapshot = subsystem.debugSnapshot();
t.IsTrue(snapshot.activeProfile.empty(), "an unmatched game should not activate a profile");
t.IsTrue(snapshot.activeProvider.empty(), "an unmatched game should not report a profile provider");
});
tc.Run("built-in profiles select by ELF basename and keep core services active", [](TestCase &t)
{
FakeIopHost host;
ps2x::iop::IopSubsystem subsystem(host);
std::string error;
t.IsTrue(subsystem.configure({"SLUS_201.84", 0u, 0u}, &error),
"RECVX profile should match case-insensitively by basename");
ps2x::iop::DebugSnapshot snapshot = subsystem.debugSnapshot();
t.Equals(snapshot.activeProfile, std::string("recvx-us"),
"RECVX ELF should select its built-in profile");
t.IsNotNull(findService(snapshot, "TSNDDRV"),
"RECVX profile should register TSNDDRV");
t.IsNotNull(findService(snapshot, "CRI DTX"),
"RECVX profile should register CRI DTX");
t.IsNotNull(findService(snapshot, "dbcman"),
"core DBCMAN should remain active with a game profile");
t.IsNotNull(findService(snapshot, "libsd"),
"core LIBSD should remain active with a game profile");
t.IsNotNull(findService(snapshot, "MCSERV"),
"core MCSERV should remain active with a game profile");
error.clear();
t.IsTrue(subsystem.configure({"slus_203.88", 0u, 0u}, &error),
"Fatal Frame profile should configure after a different game");
snapshot = subsystem.debugSnapshot();
t.Equals(snapshot.activeProfile, std::string("fatal-frame-us"),
"reload should replace the active profile");
t.IsNull(findService(snapshot, "CRI DTX"),
"reload should destroy services from the previous profile");
t.IsNotNull(findService(snapshot, "SDRDRV"),
"Fatal Frame profile should expose SDRDRV");
});
tc.Run("two subsystem instances isolate profile state and reset deterministically", [](TestCase &t)
{
FakeIopHost hostA;
FakeIopHost hostB;
ps2x::iop::IopSubsystem subsystemA(hostA);
ps2x::iop::IopSubsystem subsystemB(hostB);
std::string error;
t.IsTrue(subsystemA.configure({"SLUS_205.78", 0u, 0u}, &error),
"first LotR instance should configure");
t.IsTrue(subsystemB.configure({"SLUS_205.78", 0u, 0u}, &error),
"second LotR instance should configure");
ps2x::iop::RpcRequest request{};
request.sid = 0x00012345u;
request.receive = {0x1000u, 8u};
t.IsTrue(subsystemA.handleRpc(request).handled,
"first instance should handle LotR sound RPC");
t.Equals(hostA.readWord(0x1004u), 1u,
"first instance should start its counter at one");
(void)subsystemA.handleRpc(request);
t.Equals(hostA.readWord(0x1004u), 2u,
"first instance should advance independently");
t.IsTrue(subsystemB.handleRpc(request).handled,
"second instance should handle LotR sound RPC");
t.Equals(hostB.readWord(0x1004u), 1u,
"second instance must not inherit the first counter");
subsystemA.reset();
(void)subsystemA.handleRpc(request);
t.Equals(hostA.readWord(0x1004u), 1u,
"reset should restore per-instance service state");
});
tc.Run("TSNDDRV uses profile checksum bindings without writing invalid ports", [](TestCase &t)
{
FakeIopHost host(0x02000000u);
ps2x::iop::IopSubsystem subsystem(host);
std::string error;
t.IsTrue(subsystem.configure({"slus_201.84", 0u, 0u}, &error),
"RECVX profile should configure for TSNDDRV command testing");
constexpr uint32_t kResponseAddress = 0x1000u;
ps2x::iop::RpcRequest stateRequest{};
stateRequest.sid = 1u;
stateRequest.function = 0x12u;
stateRequest.receive = {kResponseAddress, sizeof(uint32_t)};
t.IsTrue(subsystem.handleRpc(stateRequest).handled,
"TSNDDRV should return its configured status buffer");
const uint32_t statusAddress = host.readWord(kResponseAddress);
t.IsTrue(statusAddress != 0u, "TSNDDRV status buffer should be allocated");
constexpr int16_t kChecksum = 0x1234;
t.IsTrue(host.writeGuest(0x01E0EF10u, &kChecksum, sizeof(kChecksum)),
"RECVX primary checksum binding should be writable in the fake guest");
constexpr uint32_t kCommandAddress = 0x2000u;
std::array<uint8_t, 8> command{};
command[0] = 0x29u;
command[1] = 0u;
t.IsTrue(host.writeGuest(kCommandAddress, command.data(), command.size()),
"valid TSNDDRV command should be writable");
ps2x::iop::RpcRequest commandRequest{};
commandRequest.sid = 0u;
commandRequest.function = 0u;
commandRequest.send = {kCommandAddress, static_cast<uint32_t>(command.size())};
t.IsTrue(subsystem.handleRpc(commandRequest).handled,
"TSNDDRV should handle the characterized command queue");
int16_t writtenChecksum = 0;
t.IsTrue(host.readGuest(statusAddress + 0x26u,
&writtenChecksum,
sizeof(writtenChecksum)),
"TSNDDRV SE checksum slot should be readable");
t.Equals(writtenChecksum, kChecksum,
"valid port should mirror the profile-bound checksum table");
constexpr uint32_t kPastStatusAddress = 0x44u;
constexpr uint16_t kSentinel = 0xBEEFu;
t.IsTrue(host.writeGuest(statusAddress + kPastStatusAddress,
&kSentinel,
sizeof(kSentinel)),
"sentinel after the status structure should be writable");
command[1] = 0x0Fu;
(void)host.writeGuest(kCommandAddress, command.data(), command.size());
(void)subsystem.handleRpc(commandRequest);
uint16_t sentinelAfter = 0u;
(void)host.readGuest(statusAddress + kPastStatusAddress,
&sentinelAfter,
sizeof(sentinelAfter));
t.Equals(sentinelAfter, kSentinel,
"invalid port must not overwrite memory past the 0x42-byte status structure");
});
tc.Run("RECVX reset clears CRI object maps without global state", [](TestCase &t)
{
FakeIopHost host(0x02000000u);
ps2x::iop::IopSubsystem subsystem(host);
std::string error;
t.IsTrue(subsystem.configure({"slus_201.84", 0u, 0u}, &error),
"RECVX profile should configure");
constexpr uint32_t kSendAddress = 0x2000u;
constexpr uint32_t kReceiveAddress = 0x2100u;
host.writeWord(kSendAddress + 0u, 0u);
host.writeWord(kSendAddress + 4u, 0x4000u);
host.writeWord(kSendAddress + 8u, 0x100u);
ps2x::iop::RpcRequest request{};
request.sid = 0x7D000000u;
request.function = 0x422u;
request.send = {kSendAddress, 12u};
request.receive = {kReceiveAddress, 4u};
t.IsTrue(subsystem.handleRpc(request).handled,
"SJRMT create should be emulated by the RECVX profile");
ps2x::iop::DebugSnapshot snapshot = subsystem.debugSnapshot();
const ps2x::iop::DebugService *service =
findService(snapshot, "CRI DTX");
if (!service)
{
t.Fail("CRI DTX service should be visible in the debug snapshot");
return;
}
t.Equals(metricValue(*service, "sjrmt_objects"), uint64_t{1},
"created CRI object should be tracked by this instance");
subsystem.reset();
snapshot = subsystem.debugSnapshot();
service = findService(snapshot, "CRI DTX");
if (!service)
{
t.Fail("CRI DTX service should survive reset");
return;
}
t.Equals(metricValue(*service, "sjrmt_objects"), uint64_t{0},
"reset should clear CRI object maps");
});
tc.Run("reset closes profile-owned host file handles", [](TestCase &t)
{
FakeIopHost host;
host.hostFileContents["translated/test.bin"] = {0x10u, 0x20u, 0x30u};
ps2x::iop::IopSubsystem subsystem(host);
std::string error;
t.IsTrue(subsystem.configure({"SLUS_205.78", 0u, 0u}, &error),
"LotR profile should configure for file lifecycle testing");
constexpr uint32_t kPathAddress = 0x1000u;
constexpr uint32_t kReceiveAddress = 0x1100u;
constexpr char kPath[] = "test.bin";
t.IsTrue(host.writeGuest(kPathAddress, kPath, sizeof(kPath)),
"fake guest path should be writable");
ps2x::iop::RpcRequest request{};
request.sid = 0x0000FF01u;
request.function = 0x08u;
request.send = {kPathAddress, sizeof(kPath)};
request.receive = {kReceiveAddress, 8u};
t.IsTrue(subsystem.handleRpc(request).handled,
"LotR CLFILE open should be handled");
t.Equals(host.openHostFiles.size(), size_t{1},
"open RPC should retain one opaque host file handle");
ps2x::iop::DebugSnapshot snapshot = subsystem.debugSnapshot();
const ps2x::iop::DebugService *service =
findService(snapshot, "CLFILE");
if (!service)
{
t.Fail("LotR CLFILE service should be visible before reset");
return;
}
t.Equals(metricValue(*service, "open_files"), uint64_t{1},
"debug state should report the open file");
subsystem.reset();
t.IsTrue(host.openHostFiles.empty(),
"reset should release every retained host file handle");
t.Equals(host.closedHostFileHandles.size(), size_t{1},
"host close callback should run exactly once");
snapshot = subsystem.debugSnapshot();
service = findService(snapshot, "CLFILE");
if (!service)
{
t.Fail("LotR CLFILE service should survive reset");
return;
}
t.Equals(metricValue(*service, "open_files"), uint64_t{0},
"reset should clear the CLFILE handle registry");
});
#if defined(PS2X_TEST_IOP_PLUGIN_DIR)
tc.Run("plugin module remains loaded through instances and unloads after subsystem destruction", [](TestCase &t)
{
const std::filesystem::path pluginDirectory(PS2X_TEST_IOP_PLUGIN_DIR);
#if defined(_WIN32)
const std::filesystem::path pluginPath =
pluginDirectory / "ps2_iop_fake_plugin.dll";
#else
const std::filesystem::path pluginPath =
pluginDirectory / "ps2_iop_fake_plugin.so";
#endif
t.IsFalse(pluginModuleIsLoaded(pluginPath),
"synthetic plugin should not be loaded before discovery");
{
FakeIopHost host;
ps2x::iop::IopSubsystem subsystem(host);
subsystem.setPluginSearchPaths({pluginDirectory});
std::string error;
t.IsTrue(subsystem.loadPlugins(&error),
"synthetic plugins should load for lifetime testing");
t.IsTrue(subsystem.configure({"synthetic_iop_test.elf",
kSyntheticEntryPoint,
kSyntheticCrc32},
&error),
"synthetic plugin instance should be created");
t.IsTrue(pluginModuleIsLoaded(pluginPath),
"module must stay loaded while a profile instance exists");
}
t.IsFalse(pluginModuleIsLoaded(pluginPath),
"module should unload after profile destruction and catalog teardown");
});
tc.Run("plugin discovery matches all identity fields and dispatches through the host bridge", [](TestCase &t)
{
FakeIopHost host;
ps2x::iop::IopSubsystem subsystem(host);
const std::filesystem::path pluginDirectory(PS2X_TEST_IOP_PLUGIN_DIR);
t.IsTrue(std::filesystem::is_directory(pluginDirectory),
"the synthetic IOP plugin directory should be staged by the test build");
subsystem.setPluginSearchPaths({pluginDirectory});
std::string error;
t.IsTrue(subsystem.loadPlugins(&error), "synthetic IOP plugin discovery should succeed");
ps2x::iop::DebugSnapshot discoverySnapshot = subsystem.debugSnapshot();
t.IsTrue(containsDiagnostic(discoverySnapshot, "loaded 4 profile(s)"),
"plugin discovery diagnostics should report all accepted synthetic profiles");
t.IsTrue(containsDiagnostic(discoverySnapshot, "too many SIDs"),
"an invalid profile descriptor should be ignored with a diagnostic");
t.IsTrue(containsDiagnostic(discoverySnapshot, "bad_abi"),
"an ABI-incompatible plugin should be ignored with a diagnostic");
t.IsTrue(containsDiagnostic(discoverySnapshot, "incompatible ABI"),
"the incompatible-plugin diagnostic should explain the ABI failure");
t.IsTrue(containsDiagnostic(discoverySnapshot, "missing_symbol"),
"a plugin without the query symbol should be ignored with a diagnostic");
t.IsTrue(containsDiagnostic(discoverySnapshot, "missing ps2x_iop_query_v1"),
"the missing-symbol diagnostic should name the required entry point");
auto expectNoProfile = [&](const ps2x::iop::GameIdentity &identity, const std::string &reason) {
error.clear();
t.IsTrue(subsystem.configure(identity, &error), "mismatching plugin identity should configure core-only services");
const ps2x::iop::DebugSnapshot snapshot = subsystem.debugSnapshot();
t.IsTrue(snapshot.activeProfile.empty(), reason);
ps2x::iop::RpcRequest request{};
request.sid = kSyntheticSid;
request.function = kSyntheticFunction;
t.IsFalse(subsystem.handleRpc(request).handled,
"a mismatching profile must not expose its synthetic SID");
};
expectNoProfile({"different.elf", kSyntheticEntryPoint, kSyntheticCrc32},
"a different ELF basename should not match the plugin profile");
expectNoProfile({"synthetic_iop_test.elf", kSyntheticEntryPoint + 4u, kSyntheticCrc32},
"a different entry point should not match the plugin profile");
expectNoProfile({"synthetic_iop_test.elf", kSyntheticEntryPoint, kSyntheticCrc32 ^ 1u},
"a different CRC32 should not match the plugin profile");
error.clear();
t.IsTrue(subsystem.configure({"synthetic_iop_test.elf", kSyntheticEntryPoint, kSyntheticCrc32}, &error),
"the synthetic ELF identity should activate the plugin profile");
ps2x::iop::DebugSnapshot snapshot = subsystem.debugSnapshot();
t.Equals(snapshot.activeProfile, std::string("synthetic-test-profile"),
"debug snapshot should expose the active plugin profile id");
t.Equals(snapshot.activeProvider, std::string("ps2x-test-plugin"),
"debug snapshot should expose the plugin provider name");
const ps2x::iop::DebugService *service = findService(snapshot, "synthetic-test-profile");
if (!service)
{
t.Fail("debug snapshot should include the synthetic profile service");
return;
}
t.IsTrue(service->profileSpecific, "plugin service should be marked profile-specific");
t.IsTrue(std::find(service->sids.begin(), service->sids.end(), kSyntheticSid) != service->sids.end(),
"plugin service should advertise its synthetic SID");
t.Equals(metricValue(*service, "reset_generation"), uint64_t{1},
"profile configuration should reset a new plugin instance once");
ps2x::iop::RpcAbiRequest abiRequest{};
abiRequest.boundSid = kSyntheticSid;
abiRequest.function = kSyntheticFunction;
abiRequest.registers.plausible = true;
abiRequest.stack.plausible = true;
t.Equals(subsystem.selectRpcAbi(abiRequest), ps2x::iop::RpcAbi::Stack,
"plugin should be able to select the stack RPC ABI");
abiRequest.function = kSyntheticFunction + 1u;
t.Equals(subsystem.selectRpcAbi(abiRequest), ps2x::iop::RpcAbi::RuntimeDefault,
"plugin ABI selection should fall back for unrelated functions");
constexpr uint32_t kSendAddress = 0x1000u;
constexpr uint32_t kReceiveAddress = 0x1100u;
constexpr uint32_t kInput = 0x1234ABCDu;
t.IsTrue(host.writeWord(kSendAddress, kInput), "fake host should seed the plugin send buffer");
t.IsTrue(host.writeWord(kReceiveAddress, 0u), "fake host should clear the plugin receive buffer");
ps2x::iop::RpcRequest request{};
request.callToken = 0x1122334455667788ull;
request.sid = kSyntheticSid;
request.function = kSyntheticFunction;
request.send = {kSendAddress, sizeof(uint32_t)};
request.receive = {kReceiveAddress, sizeof(uint32_t)};
const ps2x::iop::RpcResult result = subsystem.handleRpc(request);
t.IsTrue(result.handled, "matching synthetic SID/function should dispatch to the plugin");
t.Equals(result.resultAddress, kReceiveAddress, "plugin should return its receive-buffer address");
t.IsTrue(result.signalNowaitCompletion, "plugin should request nowait completion signaling");
t.Equals(result.callbackPolicy, ps2x::iop::CallbackPolicy::Suppress,
"plugin should be able to suppress the runtime callback");
t.Equals(host.readWord(kReceiveAddress), kInput ^ kResponseXor,
"plugin should read and write guest memory through the IopHost bridge");
ps2x::iop::RpcRequest unknownRequest{};
unknownRequest.sid = 0xDEADC0DEu;
unknownRequest.function = kSyntheticFunction;
t.IsFalse(subsystem.handleRpc(unknownRequest).handled,
"unknown SID should remain unhandled while a plugin profile is active");
constexpr uint32_t kCoreCollisionReceiveAddress = 0x1200u;
ps2x::iop::RpcRequest collisionRequest{};
collisionRequest.sid = kCoreCollisionSid;
collisionRequest.function = kCoreCollisionFunction;
collisionRequest.receive = {kCoreCollisionReceiveAddress, sizeof(uint32_t)};
const ps2x::iop::RpcResult collisionResult = subsystem.handleRpc(collisionRequest);
t.IsTrue(collisionResult.handled,
"a profile service should take precedence over a core service for the same SID");
t.Equals(host.readWord(kCoreCollisionReceiveAddress), kCoreCollisionResponse,
"the profile collision route should reach the plugin implementation");
subsystem.onSifTransfer({ps2x::iop::SifTransferKind::SetDma,
ps2x::iop::SifTransferPhase::AfterCopy,
kSendAddress,
kReceiveAddress,
sizeof(uint32_t)});
snapshot = subsystem.debugSnapshot();
service = findService(snapshot, "synthetic-test-profile");
if (!service)
{
t.Fail("synthetic profile service should remain visible after dispatch");
return;
}
t.Equals(metricValue(*service, "rpc_calls"), uint64_t{2},
"plugin debug metrics should count dispatched RPCs");
t.Equals(metricValue(*service, "sif_transfers"), uint64_t{1},
"plugin debug metrics should count SIF transfer hooks");
subsystem.reset();
snapshot = subsystem.debugSnapshot();
service = findService(snapshot, "synthetic-test-profile");
if (!service)
{
t.Fail("synthetic profile service should remain visible after reset");
return;
}
t.Equals(metricValue(*service, "reset_generation"), uint64_t{2},
"explicit subsystem reset should reach the plugin instance");
t.Equals(metricValue(*service, "rpc_calls"), uint64_t{0},
"plugin reset should clear per-instance RPC state");
t.Equals(metricValue(*service, "sif_transfers"), uint64_t{0},
"plugin reset should clear per-instance transfer state");
error.clear();
t.IsFalse(subsystem.configure({"synthetic_duplicate.elf", kSyntheticEntryPoint, kSyntheticCrc32}, &error),
"duplicate SIDs inside one profile layer should reject configuration");
t.IsTrue(error.find("duplicate IOP SID") != std::string::npos,
"duplicate-SID failure should clearly identify the registry conflict");
error.clear();
t.IsFalse(subsystem.configure({"slus_201.84", kSyntheticEntryPoint, kSyntheticCrc32}, &error),
"equally specific built-in and plugin matchers should be ambiguous");
t.IsTrue(error.find("ambiguous IOP profiles") != std::string::npos,
"ambiguous profile selection should fail clearly");
error.clear();
t.IsTrue(subsystem.configure({"slus_201.84",
kSpecificRecvXEntryPoint,
kSyntheticCrc32},
&error),
"a more-specific matcher should win over a lower-specificity tie");
t.Equals(subsystem.debugSnapshot().activeProfile,
std::string("synthetic-specific-recvx-profile"),
"the most specific plugin profile should be selected");
error.clear();
t.IsTrue(subsystem.configure({"different.elf", kSyntheticEntryPoint, kSyntheticCrc32}, &error),
"switching to an unmatched ELF should destroy the active plugin profile");
t.IsTrue(host.hasLog("fake-plugin-destroy"),
"plugin profile destroy callback should run when the active profile is replaced");
t.IsTrue(subsystem.debugSnapshot().activeProfile.empty(),
"switching to an unmatched ELF should leave no active profile");
});
#endif
});
}