#include "MiniTest.h" #include "ps2x/iop/iop_subsystem.h" #include #include #include #include #include #include #include #include #include #include #include #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #elif defined(__linux__) #include #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(nextGuestAddress) + effectiveAlignment - 1u) / effectiveAlignment) * effectiveAlignment; if (aligned + size > memory.size()) { return 0u; } nextGuestAddress = static_cast(aligned + size); guestAllocations.push_back(static_cast(aligned)); return static_cast(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, file->second.size() - static_cast(offset)); if (bytesRead != 0u) { std::memcpy(destination, file->second.data() + static_cast(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 memory; uint32_t nextHandle = 0x8000u; uint32_t nextGuestAddress = 0x4000u; std::vector guestAllocations; std::vector 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 lastGuestArguments; std::vector> logs; std::unordered_map> hostFileContents; std::unordered_map openHostFiles; std::vector closedHostFileHandles; uint64_t nextHostFileHandle = 1u; private: bool contains(uint32_t address, size_t size) const { const uint64_t end = static_cast(address) + static_cast(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::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("LotR sound update completes queued PlayStream slots", [](TestCase &t) { FakeIopHost host; ps2x::iop::IopSubsystem subsystem(host); std::string error; t.IsTrue(subsystem.configure({"SLUS_205.78", 0u, 0u}, &error), "LotR profile should configure"); constexpr uint32_t kSendAddress = 0x0800u; constexpr uint32_t kReceiveAddress = 0x1000u; constexpr uint16_t kStreamSlot = 7u; const std::array playStreamPacket = { 1u, // command count 1u, // PlayStream 7u, // argument count 0u, static_cast(kStreamSlot << 8u), 0u, 0u, 0u, 0u, 0u, }; t.IsTrue(host.writeGuest(kSendAddress, playStreamPacket.data(), sizeof(playStreamPacket)), "PlayStream command packet should fit in guest memory"); ps2x::iop::RpcRequest request{}; request.sid = 0x00012345u; request.send = {kSendAddress, sizeof(playStreamPacket)}; request.receive = {kReceiveAddress, 0x100u}; t.IsTrue(subsystem.handleRpc(request).handled, "LotR sound service should handle PlayStream"); t.Equals(host.readWord(kReceiveAddress), 1u, "PlayStream response should expose one active record"); const uint32_t packedStream = host.readWord(kReceiveAddress + 4u); t.Equals((packedStream >> 4u) & 0x3Fu, static_cast(kStreamSlot), "active record should identify the queued EE stream slot"); t.Equals(host.readWord(kReceiveAddress + 0x24u), 1u, "response counter should follow the active record"); const std::array statusPacket = { 1u, // command count 9u, // GetStatus 2u, // argument count kStreamSlot, 0u, }; t.IsTrue(host.writeGuest(kSendAddress, statusPacket.data(), sizeof(statusPacket)), "GetStatus command packet should fit in guest memory"); request.send.size = sizeof(statusPacket); t.IsTrue(subsystem.handleRpc(request).handled, "LotR sound service should handle the following status update"); t.Equals(host.readWord(kReceiveAddress), 0u, "the update after PlayStream should report no active records"); t.Equals(host.readWord(kReceiveAddress + 4u), 2u, "empty response counter should return to the base offset"); }); 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 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(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 }); }