diff --git a/ps2xIOP/CMakeLists.txt b/ps2xIOP/CMakeLists.txt index 5fbbc45..1e93ca0 100644 --- a/ps2xIOP/CMakeLists.txt +++ b/ps2xIOP/CMakeLists.txt @@ -68,6 +68,16 @@ if(PS2X_IOP_BUILD_TESTS) target_link_libraries(ps2_iop_import_tests PRIVATE ps2_iop) target_include_directories(ps2_iop_import_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) add_test(NAME ps2_iop_import_tests COMMAND ps2_iop_import_tests) + + add_executable(ps2_iop_compatibility_tests tests/iop_compatibility_tests.cpp) + target_link_libraries(ps2_iop_compatibility_tests PRIVATE ps2_iop) + add_test(NAME ps2_iop_compatibility_tests COMMAND ps2_iop_compatibility_tests) + + add_executable(ps2_iop_import_version_tests tests/iop_import_version_tests.cpp) + target_link_libraries(ps2_iop_import_version_tests PRIVATE ps2_iop) + target_include_directories(ps2_iop_import_version_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + add_test(NAME ps2_iop_import_version_tests COMMAND ps2_iop_import_version_tests) + endif() install(TARGETS ps2_iop diff --git a/ps2xIOP/src/emulator/imports/iop_imports.cpp b/ps2xIOP/src/emulator/imports/iop_imports.cpp index 6afcf5d..f13267f 100644 --- a/ps2xIOP/src/emulator/imports/iop_imports.cpp +++ b/ps2xIOP/src/emulator/imports/iop_imports.cpp @@ -92,6 +92,7 @@ namespace ps2x::iop::detail return IopImportCall{ trimLibraryName(name), static_cast(delay & 0xFFFFu), + m_memory.read16(table + 8u), }; } } @@ -131,20 +132,34 @@ namespace ps2x::iop::detail return m_libraries.erase(IopMemory::physicalAddress(address)) != 0u; } - uint32_t IopImportRegistry::findTable(std::string_view library) const + const IopImportRegistry::ExportLibrary *IopImportRegistry::findLibrary(std::string_view name, std::optional version) const { - const auto found = std::find_if(m_libraries.begin(), m_libraries.end(), [&](const auto &entry) - { return equalsIgnoreCase(entry.second.name, library); }); - return found != m_libraries.end() ? found->second.tableAddress : 0u; + const ExportLibrary *selected = nullptr; + for (const auto &[address, library] : m_libraries) + { + (void)address; + if (!equalsIgnoreCase(library.name, name) || + (version && (library.version >> 8u) != (*version >> 8u))) + continue; + // LOADCORE links by major version; a newer minor supersedes older exports. + if (!selected || library.version > selected->version) + selected = &library; + } + return selected; } - uint32_t IopImportRegistry::resolve(std::string_view library, uint16_t ordinal) const + uint32_t IopImportRegistry::findTable(std::string_view library, std::optional version) const { - const auto found = std::find_if(m_libraries.begin(), m_libraries.end(), [&](const auto &entry) - { return equalsIgnoreCase(entry.second.name, library); }); - if (found == m_libraries.end() || ordinal >= found->second.functions.size()) + const ExportLibrary *found = findLibrary(library, version); + return found ? found->tableAddress : 0u; + } + + uint32_t IopImportRegistry::resolve(std::string_view library, uint16_t ordinal, std::optional version) const + { + const ExportLibrary *found = findLibrary(library, version); + if (!found || ordinal >= found->functions.size()) return 0u; - return found->second.functions[ordinal]; + return found->functions[ordinal]; } int32_t IopImportRegistry::setRebootTimeLibraryHandlingMode(uint32_t address, uint32_t mode) diff --git a/ps2xIOP/src/emulator/imports/iop_imports.h b/ps2xIOP/src/emulator/imports/iop_imports.h index 66a9162..cae8d91 100644 --- a/ps2xIOP/src/emulator/imports/iop_imports.h +++ b/ps2xIOP/src/emulator/imports/iop_imports.h @@ -16,6 +16,7 @@ namespace ps2x::iop::detail { std::string library; uint16_t ordinal = 0; + uint16_t version = 0; }; class IopImportRegistry @@ -27,8 +28,8 @@ namespace ps2x::iop::detail [[nodiscard]] std::optional decode(uint32_t pc) const; [[nodiscard]] bool registerExportTable(uint32_t address); [[nodiscard]] bool releaseExportTable(uint32_t address); - [[nodiscard]] uint32_t findTable(std::string_view library) const; - [[nodiscard]] uint32_t resolve(std::string_view library, uint16_t ordinal) const; + [[nodiscard]] uint32_t findTable(std::string_view library, std::optional version = std::nullopt) const; + [[nodiscard]] uint32_t resolve(std::string_view library, uint16_t ordinal, std::optional version = std::nullopt) const; [[nodiscard]] int32_t setRebootTimeLibraryHandlingMode(uint32_t address, uint32_t mode); void eraseRange(uint32_t base, uint32_t size); @@ -41,6 +42,8 @@ namespace ps2x::iop::detail std::vector functions; }; + [[nodiscard]] const ExportLibrary *findLibrary(std::string_view name, std::optional version) const; + IopMemory &m_memory; std::map m_libraries; }; diff --git a/ps2xIOP/src/emulator/imports/iop_loadcore.cpp b/ps2xIOP/src/emulator/imports/iop_loadcore.cpp index 272e493..600bff8 100644 --- a/ps2xIOP/src/emulator/imports/iop_loadcore.cpp +++ b/ps2xIOP/src/emulator/imports/iop_loadcore.cpp @@ -43,9 +43,18 @@ namespace ps2x::iop::detail case 7: setV0(m_imports.releaseExportTable(a0) ? 0u : 0xFFFFFFFFu); return true; - case 11: - setV0(m_imports.findTable(m_memory.readString(a0 + 12u, 8u))); + case 11: // QueryLibraryEntryTable returns the function array, not the export header. + { + const uint32_t address = IopMemory::physicalAddress(a0); + if (a0 == 0u || address > IopMemory::RamSize - 20u) + { + setV0(0u); + return true; + } + const uint32_t table = m_imports.findTable(m_memory.readString(address + 12u, 8u), m_memory.read16(address + 8u)); + setV0(table != 0u ? table + 20u : 0u); return true; + } case 27: // SetRebootTimeLibraryHandlingMode setV0(static_cast(m_imports.setRebootTimeLibraryHandlingMode(a0, cpu.gpr[5]))); return true; diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index ccb27bb..e5f63a9 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -342,7 +342,7 @@ namespace ps2x::iop::detail if (iequals(call.library, "heaplib") && heaplib.dispatchImport(call.ordinal, cpu)) return ImportDisposition::Handled; - const uint32_t target = imports.resolve(call.library, call.ordinal); + const uint32_t target = imports.resolve(call.library, call.ordinal, call.version); if (target != 0u) { cpu.pc = target; @@ -351,7 +351,8 @@ namespace ps2x::iop::detail } std::ostringstream out; - out << "[IOP] unhandled import " << call.library << ':' << call.ordinal << " pc=0x" << std::hex << cpu.pc; + out << "[IOP] unhandled import " << call.library << ':' << call.ordinal + << " version=0x" << std::hex << call.version << " pc=0x" << cpu.pc; log(LogLevel::Warning, out.str()); setV0(0); return ImportDisposition::Missing; diff --git a/ps2xIOP/src/iop_module_manager.cpp b/ps2xIOP/src/iop_module_manager.cpp index 5576d20..56e4a8f 100644 --- a/ps2xIOP/src/iop_module_manager.cpp +++ b/ps2xIOP/src/iop_module_manager.cpp @@ -30,9 +30,12 @@ namespace ps2x::iop::detail "vblank", "secrman", "sio2man", + "xsio2man", "sio2d", "padman", + "xpadman", "mcman", + "xmcman", "mcserv", "libsd", "cdvdman", diff --git a/ps2xIOP/src/iop_subsystem.cpp b/ps2xIOP/src/iop_subsystem.cpp index fbfe7e1..af0d891 100644 --- a/ps2xIOP/src/iop_subsystem.cpp +++ b/ps2xIOP/src/iop_subsystem.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace ps2x::iop @@ -137,6 +138,19 @@ namespace ps2x::iop routesValid = addLayer(coreServices, false) && addLayer(profileServices, true); } + void recordLoadOutcome(std::string_view path, bool hle) + { + constexpr size_t maxOutcomes = 32u; + if (loadOutcomes.size() >= maxOutcomes || !loggedLoadPaths.emplace(path).second) + return; + std::string message = hle ? "[IOP:HLE] fallback module='" : "[IOP:load-failed] module='"; + message.append(path); + message += hle ? "' physical IRX unavailable; using registered HLE provider" + : "' no HLE provider accepted the module; physical IRX was not loaded"; + loadOutcomes.push_back(message); + host.log(hle ? LogLevel::Info : LogLevel::Warning, message); + } + IopHost &host; detail::PluginCatalog pluginCatalog; detail::ServiceList coreServices; @@ -145,6 +159,8 @@ namespace ps2x::iop std::unordered_map routes; std::vector pluginSearchPaths; std::vector diagnostics; + std::vector loadOutcomes; + std::unordered_set loggedLoadPaths; std::string activeProfile; std::string activeProvider; std::string lastError; @@ -174,6 +190,8 @@ namespace ps2x::iop bool IopSubsystem::configure(const GameIdentity &identity, std::string *error) { + if (error) + error->clear(); m_impl->profileServices.clear(); m_impl->activeProfile.clear(); m_impl->activeProvider.clear(); @@ -273,6 +291,8 @@ namespace ps2x::iop void IopSubsystem::reset() { m_impl->moduleManager.reset(); + m_impl->loadOutcomes.clear(); + m_impl->loggedLoadPaths.clear(); for (auto &service : m_impl->coreServices) { if (service) @@ -311,7 +331,15 @@ namespace ps2x::iop ModuleLoadResult hle = m_impl->moduleManager.loadHle(path); if (hle.moduleId > 0) + { m_impl->rebuildRoutes(); + if (parsed.device != Ps2PathDevice::Rom0) + m_impl->recordLoadOutcome(path, true); + } + else + { + m_impl->recordLoadOutcome(path, false); + } return hle; } @@ -463,6 +491,7 @@ namespace ps2x::iop snapshot.activeProfile = m_impl->activeProfile; snapshot.activeProvider = m_impl->activeProvider; snapshot.diagnostics = m_impl->diagnostics; + snapshot.diagnostics.insert(snapshot.diagnostics.end(), m_impl->loadOutcomes.begin(), m_impl->loadOutcomes.end()); if (!m_impl->lastError.empty()) { snapshot.diagnostics.push_back(m_impl->lastError); diff --git a/ps2xIOP/src/modules/dbcman.cpp b/ps2xIOP/src/modules/dbcman.cpp index 5fc356b..c698a34 100644 --- a/ps2xIOP/src/modules/dbcman.cpp +++ b/ps2xIOP/src/modules/dbcman.cpp @@ -1,4 +1,5 @@ #include "module_factories.h" +#include "rpc_reply.h" #include #include @@ -12,9 +13,11 @@ namespace ps2x::iop::detail { constexpr uint32_t kDbcManSid = 0x80001300u; constexpr uint32_t kRpcCheckVersion = 0x80001363u; - constexpr uint32_t kDbcManVersion = 0x0320u; constexpr uint32_t kMaxUnknownRpcLogs = 32u; + constexpr std::array kSupportedVersions{0x0310u, 0x0320u}; + constexpr uint16_t kReportedVersion = kSupportedVersions.front(); + class DbcmanService final : public IopService { public: @@ -42,6 +45,8 @@ namespace ps2x::iop::detail { std::lock_guard lock(m_mutex); m_unknownRpcLogCount = 0u; + m_versionQueryCount = 0u; + m_failedVersionReplies = 0u; } [[nodiscard]] RpcResult handleRpc(const RpcRequest &request) override @@ -61,12 +66,21 @@ namespace ps2x::iop::detail if (request.function == kRpcCheckVersion) { - const uint32_t wordCount = request.receive.size / sizeof(uint32_t); - const uint32_t count = wordCount < 4u ? wordCount : 4u; - for (uint32_t index = 0u; index < count; ++index) + const uint32_t version = kReportedVersion; + const std::array reply{version, version, version, version}; + const bool written = writeRpcWords(m_host, request.receive, reply); + bool firstQuery = false; { - const uint32_t address = request.receive.address + index * sizeof(uint32_t); - (void)m_host.writeGuest(address, &kDbcManVersion, sizeof(kDbcManVersion)); + std::lock_guard lock(m_mutex); + firstQuery = m_versionQueryCount++ == 0u; + if (!written) + ++m_failedVersionReplies; + } + if (firstQuery) + { + std::ostringstream message; + message << "[DBCMAN:HLE] check-version reply=0x" << std::hex << version; + m_host.log(LogLevel::Info, message.str()); } return result; } @@ -99,6 +113,9 @@ namespace ps2x::iop::detail void appendDebugMetrics(std::vector &metrics) const override { std::lock_guard lock(m_mutex); + metrics.push_back({"reported_version", kReportedVersion, true}); + metrics.push_back({"version_queries", m_versionQueryCount, false}); + metrics.push_back({"failed_version_replies", m_failedVersionReplies, false}); metrics.push_back({"unknown_rpc_logs", m_unknownRpcLogCount, false}); } @@ -109,6 +126,8 @@ namespace ps2x::iop::detail IopHost &m_host; mutable std::mutex m_mutex; uint32_t m_unknownRpcLogCount = 0u; + uint64_t m_versionQueryCount = 0u; + uint64_t m_failedVersionReplies = 0u; }; } diff --git a/ps2xIOP/src/modules/mcserv.cpp b/ps2xIOP/src/modules/mcserv.cpp index 86db416..ce23759 100644 --- a/ps2xIOP/src/modules/mcserv.cpp +++ b/ps2xIOP/src/modules/mcserv.cpp @@ -1,4 +1,5 @@ #include "../iop_service.h" +#include "../rpc_reply.h" #include #include @@ -189,8 +190,8 @@ namespace ps2x::iop::detail const Operation operation = decodeOperation(request.function, flavor); if (operation == Operation::Init) { - (void)call(MemoryCardOperation::Init); - writeInitResult(request.receive); + const int32_t result = call(MemoryCardOperation::Init); + writeInitResult(request.receive, flavor, result); return response; } @@ -206,7 +207,7 @@ namespace ps2x::iop::detail { NameParameter parameter{}; if (request.send.address != 0u && - request.send.size >= offsetof(NameParameter, name) && + request.send.size >= sizeof(parameter) && m_host.readGuest(request.send.address, ¶meter, sizeof(parameter))) { result = handleNameOperation(operation, request.send.address, parameter); @@ -222,22 +223,15 @@ namespace ps2x::iop::detail if (operation == Operation::Write && parameter.origin > 0 && parameter.origin <= static_cast(sizeof(parameter.data))) { - const uint32_t inlineAddress = - request.send.address + static_cast(offsetof(DescriptorParameter, data)); - const int32_t prefix = call(MemoryCardOperation::Write, - static_cast(parameter.fd), - inlineAddress, - static_cast(parameter.origin)); + const uint32_t inlineAddress = request.send.address + static_cast(offsetof(DescriptorParameter, data)); + const int32_t prefix = call(MemoryCardOperation::Write, static_cast(parameter.fd), inlineAddress, static_cast(parameter.origin)); if (prefix < 0) { result = prefix; } else { - const int32_t body = call(MemoryCardOperation::Write, - static_cast(parameter.fd), - parameter.buffer, - static_cast(std::max(parameter.size, 0))); + const int32_t body = call(MemoryCardOperation::Write, static_cast(parameter.fd), parameter.buffer, static_cast(std::max(parameter.size, 0))); result = body < 0 ? body : prefix + body; } } @@ -271,36 +265,18 @@ namespace ps2x::iop::detail void writeResult(GuestBuffer receive, int32_t result) { - if (receive.address == 0u || receive.size < sizeof(result)) - { - return; - } - (void)m_host.writeGuest(receive.address, &result, sizeof(result)); - if (receive.size > sizeof(result)) - { - (void)m_host.zeroGuest(receive.address + sizeof(result), receive.size - sizeof(result)); - } + const std::array values{static_cast(result)}; + (void)writeRpcWords(m_host, receive, values); } - void writeInitResult(GuestBuffer receive) + void writeInitResult(GuestBuffer receive, Flavor flavor, int32_t result) { - if (receive.address == 0u || receive.size < sizeof(int32_t)) - { - return; - } - const std::array values = { - static_cast(kSucceeded), kMcservVersion, kMcmanVersion}; - const uint32_t bytes = std::min(receive.size, sizeof(values)); - (void)m_host.writeGuest(receive.address, values.data(), bytes); - if (receive.size > bytes) - { - (void)m_host.zeroGuest(receive.address + bytes, receive.size - bytes); - } + const std::array values = {static_cast(result), kMcservVersion, kMcmanVersion}; + const size_t count = flavor == Flavor::NewXmcserv ? values.size() : 1u; + (void)writeRpcWords(m_host, receive, std::span(values.data(), count)); } - int32_t handleNameOperation(Operation operation, - uint32_t sendAddress, - const NameParameter ¶meter) + int32_t handleNameOperation(Operation operation, uint32_t sendAddress, const NameParameter ¶meter) { const uint32_t nameAddress = sendAddress + static_cast(offsetof(NameParameter, name)); const uint32_t port = static_cast(parameter.port); @@ -367,8 +343,7 @@ namespace ps2x::iop::detail case Operation::Read: if (parameter.parameter != 0u) { - (void)m_host.zeroGuest(parameter.parameter, - flavor == Flavor::NewXmcserv ? 192u : 64u); + (void)m_host.zeroGuest(parameter.parameter, flavor == Flavor::NewXmcserv ? 192u : 64u); } return call(MemoryCardOperation::Read, static_cast(parameter.fd), diff --git a/ps2xIOP/src/rpc_reply.h b/ps2xIOP/src/rpc_reply.h new file mode 100644 index 0000000..62aae90 --- /dev/null +++ b/ps2xIOP/src/rpc_reply.h @@ -0,0 +1,22 @@ +#pragma once + +#include "ps2x/iop/iop_host.h" + +#include +#include +#include +#include + +namespace ps2x::iop::detail +{ + [[nodiscard]] inline bool writeRpcWords(IopHost &host, GuestBuffer receive, std::span words) + { + const size_t count = std::min(receive.size / sizeof(uint32_t), words.size()); + const size_t bytes = count * sizeof(uint32_t); + if (receive.address == 0u || bytes == 0u) + return false; + if (bytes - 1u > std::numeric_limits::max() - receive.address) + return false; + return host.writeGuest(receive.address, words.data(), bytes); + } +} diff --git a/ps2xIOP/tests/iop_compat_test_support.h b/ps2xIOP/tests/iop_compat_test_support.h new file mode 100644 index 0000000..21831fe --- /dev/null +++ b/ps2xIOP/tests/iop_compat_test_support.h @@ -0,0 +1,237 @@ +#pragma once + +#include "ps2x/iop/iop_subsystem.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace iop_test +{ + using namespace ps2x::iop; + + inline void require(bool condition, const char *message) + { + if (!condition) + throw std::runtime_error(message); + } + + class Host final : public IopHost + { + public: + explicit Host(size_t bytes = 0x20000u) : guest(bytes, 0xCCu) {} + + bool readGuest(uint32_t address, void *destination, size_t size) const override + { + if ((size != 0u && !destination) || address > guest.size() || size > guest.size() - address) + return false; + if (size != 0u) + std::memcpy(destination, guest.data() + address, size); + ++guestReads; + return true; + } + bool writeGuest(uint32_t address, const void *source, size_t size) override + { + if ((size != 0u && !source) || address > guest.size() || size > guest.size() - address) + return false; + if (size != 0u) + std::memcpy(guest.data() + address, source, size); + ++guestWrites; + return true; + } + bool zeroGuest(uint32_t address, size_t size) override + { + if (address > guest.size() || size > guest.size() - address) + return false; + std::fill_n(guest.begin() + address, size, uint8_t{0}); + ++guestWrites; + return true; + } + bool normalizeGuestAddress(uint32_t address, uint32_t &normalized) const override + { + normalized = address; + return address < guest.size(); + } + uint32_t allocateIopHandle(IopHandleKind) override { return nextHandle += 0x80u; } + uint32_t allocateGuest(uint32_t, uint32_t) override { return 0u; } + void freeGuest(uint32_t) override {} + void audioCommand(uint32_t, uint32_t, GuestBuffer, GuestBuffer) override { ++audioCalls; } + std::string hostPath(HostPathKind) const override { return {}; } + std::string translateGuestPath(std::string_view path) const override { return std::string(path); } + uint64_t openHostFile(std::string_view) override { return file.empty() ? 0u : 1u; } + bool hostFileSize(uint64_t handle, uint64_t &size) const override + { + size = file.size(); + return handle == 1u && !file.empty(); + } + bool readHostFile(uint64_t handle, uint64_t offset, void *destination, size_t size, + size_t &bytesRead) override + { + bytesRead = 0u; + if (handle != 1u || offset > file.size()) + return false; + bytesRead = std::min(size, file.size() - static_cast(offset)); + if (bytesRead != 0u) + std::memcpy(destination, file.data() + offset, bytesRead); + return true; + } + void closeHostFile(uint64_t) override {} + int32_t memoryCard(const MemoryCardRequest &request) override + { + cardCalls.push_back(request); + return request.operation == MemoryCardOperation::Init ? initResult : 0; + } + bool hasGuestFunction(uint32_t) const override { return false; } + bool invokeGuestFunction(uint64_t, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t *) override + { + return false; + } + void log(LogLevel, std::string_view message) override { logs.emplace_back(message); } + + uint32_t word(uint32_t address) const + { + uint32_t value = 0u; + require(readGuest(address, &value, sizeof(value)), "test read outside guest RAM"); + return value; + } + void fill(uint32_t address, size_t size, uint8_t value = 0xCCu) + { + require(address <= guest.size() && size <= guest.size() - address, "test fill outside RAM"); + std::fill_n(guest.begin() + address, size, value); + } + + std::vector guest; + std::vector file; + std::vector logs; + std::vector cardCalls; + mutable size_t guestReads = 0u; + size_t guestWrites = 0u; + size_t audioCalls = 0u; + int32_t initResult = 0; + uint32_t nextHandle = 0x1000u; + }; + + inline RpcRequest request(uint32_t sid, uint32_t function, uint32_t size = 16u) + { + RpcRequest result{}; + result.sid = sid; + result.function = function; + result.receive = {0x800u, size}; + return result; + } + + inline uint64_t metric(const IopSubsystem &iop, std::string_view service, std::string_view name) + { + for (const auto &row : iop.debugSnapshot().services) + if (row.name == service) + for (const auto &entry : row.metrics) + if (entry.name == name) + return entry.value; + throw std::runtime_error("missing debug metric"); + } + + class Irx + { + public: + explicit Irx(uint32_t base = 0x10000u, uint32_t imageBytes = 0x500u) + : bytes(0x100u + imageBytes, 0u) + { + put32(0u, 0x464C457Fu); + bytes[4] = bytes[5] = bytes[6] = 1u; + put16(16u, 2u); + put16(18u, 8u); + put32(20u, 1u); + put32(24u, base); + put32(28u, 52u); + put16(40u, 52u); + put16(42u, 32u); + put16(44u, 1u); + put32(52u, 1u); + put32(56u, 0x100u); + put32(60u, base); + put32(64u, base); + put32(68u, imageBytes); + put32(72u, imageBytes); + put32(76u, 7u); + put32(80u, 4u); + } + void words(uint32_t offset, std::initializer_list values) + { + for (uint32_t value : values) + { + put32(0x100u + offset, value); + offset += 4u; + } + } + void install(Host &host, uint32_t address = 0x1000u) const + { + require(host.writeGuest(address, bytes.data(), bytes.size()), "synthetic IRX does not fit"); + } + std::vector bytes; + + private: + void put16(uint32_t offset, uint16_t value) + { + require(offset + 2u <= bytes.size(), "IRX builder overflow"); + bytes[offset] = static_cast(value); + bytes[offset + 1u] = static_cast(value >> 8u); + } + void put32(uint32_t offset, uint32_t value) + { + put16(offset, static_cast(value)); + put16(offset + 2u, static_cast(value >> 16u)); + } + }; + + inline Irx rpcServer(uint32_t sid, uint32_t reply) + { + Irx image; + image.words(0u, { + 0x27BDFFE0u, 0xAFBF001Cu, // save ra + 0x3C040001u, 0x34840200u, + 0x3C050000u | (sid >> 16u), 0x34A50000u | (sid & 0xFFFFu), + 0x3C060001u, 0x34C60300u, + 0x3C070001u, 0x34E70400u, + 0xAFA00010u, 0xAFA00014u, 0xAFA00018u, + 0x0C00401Du, 0u, // jal 0x10074: sceSifRegisterRpc + 0x8FBF001Cu, 0x00001021u, 0x27BD0020u, 0x03E00008u, 0u, + }); + image.words(0x60u, {0x41E00000u, 0u, 0x0101u, 0x63666973u, 0x0000646Du, + 0x03E00008u, 0x24000011u, 0u, 0u}); + image.words(0x300u, {0x3C020001u, 0x34420400u, 0x03E00008u, 0u}); + image.words(0x400u, {reply, reply, reply, reply}); + return image; + } + + struct Test + { + const char *name; + void (*function)(); + }; + + inline int run(std::span tests) + { + size_t failures = 0u; + for (const Test &test : tests) + { + try + { + test.function(); + std::cout << "PASS " << test.name << '\n'; + } + catch (const std::exception &error) + { + ++failures; + std::cerr << "FAIL " << test.name << ": " << error.what() << '\n'; + } + } + std::cout << tests.size() - failures << '/' << tests.size() << " cases passed\n"; + return failures == 0u ? 0 : 1; + } +} diff --git a/ps2xIOP/tests/iop_compatibility_tests.cpp b/ps2xIOP/tests/iop_compatibility_tests.cpp new file mode 100644 index 0000000..5c3bf40 --- /dev/null +++ b/ps2xIOP/tests/iop_compatibility_tests.cpp @@ -0,0 +1,315 @@ +#include "iop_compat_test_support.h" + +#include + +namespace +{ + using namespace iop_test; + constexpr uint32_t dbcSid = 0x80001300u; + constexpr uint32_t dbcVersion = 0x80001363u; + constexpr uint32_t mcSid = 0x80000400u; + + void dbcDefault() + { + Host host; + IopSubsystem iop(host); + require(!iop.canBindRpc(dbcSid), "unloaded DBCMAN must stay dormant"); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "DBCMAN load failed"); + auto query = request(dbcSid, dbcVersion); + require(iop.handleRpc(query).handled, "version RPC not handled"); + for (uint32_t i = 0u; i < 4u; ++i) + require(host.word(0x800u + i * 4u) == 0x0310u, "DBCMAN target version changed"); + } + + + + void dbcResetAndReconfigure() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "load failed"); + require(iop.handleRpc(request(dbcSid, dbcVersion)).handled, "RPC failed"); + require(host.word(0x800u) == 0x0310u, "unexpected DBCMAN version"); + iop.reset(); + require(!iop.canBindRpc(dbcSid), "reset retained a module route"); + require(metric(iop, "dbcman", "version_queries") == 0u, "query counter not reset"); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "reload failed"); + require(iop.handleRpc(request(dbcSid, dbcVersion)).handled, "RPC failed"); + require(host.word(0x800u) == 0x0310u, "IOP reboot changed target version"); + } + + + void dbcReplyBounds() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "load failed"); + for (uint32_t size = 0u; size <= 24u; ++size) + { + host.fill(0x7FCu, 40u); + require(iop.handleRpc(request(dbcSid, dbcVersion, size)).handled, "RPC failed"); + const uint32_t written = std::min(size / 4u, 4u) * 4u; + for (uint32_t offset = written; offset < 32u; ++offset) + require(host.guest[0x800u + offset] == 0xCCu, "reply wrote past whole-word payload"); + require(host.word(0x7FCu) == 0xCCCCCCCCu, "reply underflow"); + } + auto query = request(dbcSid, dbcVersion); + query.receive.address = 0u; + const size_t writes = host.guestWrites; + require(iop.handleRpc(query).handled && host.guestWrites == writes, "null reply was written"); + } + + void dbcNoAddressWrap() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "load failed"); + auto query = request(dbcSid, dbcVersion); + query.receive.address = 0xFFFFFFF8u; + require(iop.handleRpc(query).handled, "RPC failed"); + require(host.word(0u) == 0xCCCCCCCCu && host.word(4u) == 0xCCCCCCCCu, + "overflowed reply corrupted low guest addresses"); + require(metric(iop, "dbcman", "failed_version_replies") == 1u, "invalid reply not recorded"); + } + + void dbcNoRequestGuessing() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "load failed"); + auto query = request(dbcSid, dbcVersion); + const std::array randomArguments{0x0310u, 0x00010000u, 0u, 0xFFFFu}; + require(host.writeGuest(0x600u, randomArguments.data(), sizeof(randomArguments)), "write failed"); + query.send = {0x600u, sizeof(randomArguments)}; + require(iop.handleRpc(query).handled, "RPC failed"); + require(host.word(0x800u) == 0x0310u, "send buffer was guessed to be a requested version"); + } + + void dbcPhysicalServerWins() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:DBCMAN").moduleId > 0, "HLE load failed"); + auto image = rpcServer(dbcSid, 0xDEADBEEFu); + image.install(host); + auto physical = iop.loadModuleBuffer(0x1000u); + require(physical.moduleId > 0 && physical.startResult == 0, "physical IRX failed"); + require(iop.handleRpc(request(dbcSid, dbcVersion)).handled, "physical RPC not handled"); + require(host.word(0x800u) == 0xDEADBEEFu, "HLE overwrote physical server version"); + require(metric(iop, "dbcman", "version_queries") == 0u, "HLE ran after physical service"); + require(iop.stopModule(physical.moduleId), "physical stop failed"); + require(iop.handleRpc(request(dbcSid, dbcVersion)).handled, "HLE fallback not restored"); + require(host.word(0x800u) == 0x0310u, "wrong HLE version after physical stop"); + } + + void mcNewInit() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:XMCSERV").moduleId > 0, "XMCSERV load failed"); + require(iop.handleRpc(request(mcSid, 0xFEu, 16u)).handled, "init RPC failed"); + require(host.word(0x800u) == 0u && host.word(0x804u) == 0x0205u && host.word(0x808u) == 0x0206u, + "new memory-card init layout changed"); + require(host.word(0x80Cu) == 0xCCCCCCCCu, "new init wrote beyond 12-byte response"); + } + + void mcOldInit() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:MCSERV").moduleId > 0, "MCSERV load failed"); + require(iop.handleRpc(request(mcSid, 0x70u, 16u)).handled, "init RPC failed"); + require(host.word(0x800u) == 0u && host.word(0x804u) == 0xCCCCCCCCu, + "old init leaked extended protocol versions"); + } + + void mcInitFailure() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:MCSERV").moduleId > 0, "MCSERV load failed"); + host.initResult = -5; + for (uint32_t operation : {0x70u, 0xFEu}) + { + require(iop.handleRpc(request(mcSid, operation)).handled, "init RPC failed"); + require(static_cast(host.word(0x800u)) == -5, "init failure reported as success"); + } + } + + void mcReplyBounds() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:MCSERV").moduleId > 0, "MCSERV load failed"); + for (uint32_t operation : {0x70u, 0xFEu}) + for (uint32_t size = 0u; size <= 20u; ++size) + { + host.fill(0x7FCu, 32u); + require(iop.handleRpc(request(mcSid, operation, size)).handled, "init RPC failed"); + const uint32_t words = operation == 0xFEu ? 3u : 1u; + const uint32_t written = std::min(size / 4u, words) * 4u; + for (uint32_t offset = written; offset < 24u; ++offset) + require(host.guest[0x800u + offset] == 0xCCu, "init clobbered response tail"); + require(host.word(0x7FCu) == 0xCCCCCCCCu, "init underflowed buffer"); + } + auto query = request(mcSid, 0xFEu); + query.receive.address = 0xFFFFFFF8u; + require(iop.handleRpc(query).handled, "RPC failed"); + require(host.word(0u) == 0xCCCCCCCCu, "init overflowed guest address"); + } + + void mcShortNamePacket() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:MCSERV").moduleId > 0, "MCSERV load failed"); + auto query = request(mcSid, 0x02u, 4u); + query.send = {0x1000u, 20u}; + host.fill(0x1000u, 1044u, 0u); + const size_t calls = host.cardCalls.size(); + require(iop.handleRpc(query).handled, "RPC failed"); + require(host.cardCalls.size() == calls, "short packet read a filename beyond send.size"); + require(static_cast(host.word(0x800u)) == -5, "short packet not rejected"); + } + + void mcFullNamePacket() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:MCSERV").moduleId > 0, "MCSERV load failed"); + const std::array header{1u, 0u, 1u, 0u, 0u}; + host.fill(0x1000u, 1044u, 0u); + require(host.writeGuest(0x1000u, header.data(), sizeof(header)), "packet header write failed"); + constexpr char name[] = "/save.dat"; + require(host.writeGuest(0x1014u, name, sizeof(name)), "packet filename write failed"); + for (uint32_t operation : {0x02u, 0x71u}) + { + auto query = request(mcSid, operation, 4u); + query.send = {0x1000u, 1044u}; + const size_t before = host.cardCalls.size(); + require(iop.handleRpc(query).handled, "open RPC failed"); + require(host.cardCalls.size() == before + 1u, "valid packet not dispatched"); + const auto &call = host.cardCalls.back(); + require(call.operation == MemoryCardOperation::Open && + call.arguments[0] == 1u && call.arguments[1] == 0u && + call.arguments[2] == 0x1014u && call.arguments[3] == 1u, + "valid name packet decoded incorrectly"); + } + } + + void mcStatusBounds() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:MCSERV").moduleId > 0, "MCSERV load failed"); + for (uint32_t size = 0u; size <= 20u; ++size) + { + host.fill(0x7FCu, 32u); + require(iop.handleRpc(request(mcSid, 0xFFFFFFFFu, size)).handled, "RPC failed"); + const uint32_t written = size >= 4u ? 4u : 0u; + if (written != 0u) + require(static_cast(host.word(0x800u)) == -5, "missing error status"); + for (uint32_t offset = written; offset < 24u; ++offset) + require(host.guest[0x800u + offset] == 0xCCu, "status clobbered receive tail"); + require(host.word(0x7FCu) == 0xCCCCCCCCu, "status underflowed receive buffer"); + } + auto query = request(mcSid, 0xFFFFFFFFu, 16u); + query.receive.address = 0xFFFFFFFCu; + require(iop.handleRpc(query).handled, "RPC failed"); + require(host.word(0u) == 0xCCCCCCCCu && host.word(4u) == 0xCCCCCCCCu, + "status reply wrapped and zeroed low guest memory"); + } + + void moduleAliases() + { + Host host; + IopSubsystem iop(host); + for (const char *path : {"rom0:XSIO2MAN", "rom0:XPADMAN", "rom0:XMCMAN"}) + { + auto result = iop.loadModule(path); + require(result.moduleId > 0 && result.startResult == 0, "known extended module rejected"); + } + require(!iop.canBindRpc(mcSid), "XMCMAN alone enabled a memory-card RPC server"); + const auto module = iop.loadModule("CDROM0:\\IOP\\xMcSeRv.IrX;1"); + require(module.moduleId > 0 && iop.canBindRpc(mcSid), "normalized XMCSERV alias not activated"); + require(iop.stopModule(module.moduleId) && !iop.canBindRpc(mcSid), "stopped alias remained active"); + } + + void unknownModulesStayUnknown() + { + Host host; + IopSubsystem iop(host); + for (const char *name : {"MC2_D.IRX", "DS2U_D.IRX", "CDVDSTM.IRX", "SDRDRV.IRX", "EZPCM.IRX", "ANYTHING_D.IRX"}) + { + const auto result = iop.loadModule(std::string("host0:IOPModules/") + name); + require(result.moduleId < 0 && result.startResult < 0, "unsupported module got a fake success"); + } + require(!iop.canBindRpc(0x19740512u), "game-specific SDRDRV activated globally"); + } + + void moduleLifetime() + { + Host host; + IopSubsystem iop(host); + const auto a = iop.loadModule("rom0:DBCMAN"); + const auto b = iop.loadModule("rom0:dbcman.irx"); + const auto alias = iop.loadModule("rom0:DBCM"); + require(a.moduleId > 0 && a.moduleId == b.moduleId && alias.moduleId > 0, "module IDs unstable"); + require(iop.stopModule(a.moduleId) && iop.canBindRpc(dbcSid), "first release removed shared route"); + require(iop.stopModule(b.moduleId) && iop.canBindRpc(dbcSid), "remaining alias not honored"); + require(iop.stopModule(alias.moduleId) && !iop.canBindRpc(dbcSid), "last release retained route"); + } + + void loaderDiagnostics() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("host0:LIBSD.IRX").moduleId > 0, "LIBSD fallback failed"); + require(iop.loadModule("host0:MISSING.IRX").moduleId < 0, "unknown load accepted"); + for (unsigned i = 0u; i < 100u; ++i) + (void)iop.loadModule("host0:MISSING.IRX"); + auto snapshot = iop.debugSnapshot(); + require(snapshot.diagnostics.size() == 2u, "final loader outcomes not deduplicated"); + require(snapshot.diagnostics[0].find("[IOP:HLE]") != std::string::npos, "no fallback diagnostic"); + require(snapshot.diagnostics[1].find("no HLE provider") != std::string::npos, "no final failure diagnostic"); + for (unsigned i = 0u; i < 100u; ++i) + (void)iop.loadModule("rom0:missing" + std::to_string(i)); + require(iop.debugSnapshot().diagnostics.size() <= 32u, "unbounded module diagnostics"); + iop.reset(); + require(iop.debugSnapshot().diagnostics.empty(), "stale load outcomes survived reset"); + } + + void libsdUnchanged() + { + Host host; + IopSubsystem iop(host); + require(iop.loadModule("rom0:LIBSD").moduleId > 0, "LIBSD load failed"); + require(iop.handleRpc(request(0x80000701u, 0x8010u)).handled, "LIBSD RPC not handled"); + require(host.audioCalls == 1u, "DBCMAN option intercepted LIBSD RPC"); + } +} + +int main() +{ + const Test tests[] = { + {"DBCMAN default and dormant route", dbcDefault}, + {"DBCMAN reboot and reconfiguration", dbcResetAndReconfigure}, + {"DBCMAN bounded whole-word response", dbcReplyBounds}, + {"DBCMAN rejects wrapping reply addresses", dbcNoAddressWrap}, + {"DBCMAN does not infer version from arbitrary RPC payload", dbcNoRequestGuessing}, + {"Physical DBCMAN server wins over configured HLE", dbcPhysicalServerWins}, + {"XMCSERV init status and two version fields", mcNewInit}, + {"Old MCSERV init is status only", mcOldInit}, + {"MCSERV propagates initialization failure", mcInitFailure}, + {"MCSERV response bounds for both dialects", mcReplyBounds}, + {"MCSERV rejects truncated name packet", mcShortNamePacket}, + {"MCSERV accepts complete name packets in both dialects", mcFullNamePacket}, + {"MCSERV status replies preserve bounds and cannot wrap", mcStatusBounds}, + {"Extended module aliases and activation", moduleAliases}, + {"Unsupported debug and game IRX stay unsupported", unknownModulesStayUnknown}, + {"HLE repeated loads and alias lifetime", moduleLifetime}, + {"Loader outcomes are bounded and resettable", loaderDiagnostics}, + {"LIBSD audio dispatch is unchanged", libsdUnchanged}, + }; + return run(tests); +} diff --git a/ps2xIOP/tests/iop_import_version_tests.cpp b/ps2xIOP/tests/iop_import_version_tests.cpp new file mode 100644 index 0000000..af3346b --- /dev/null +++ b/ps2xIOP/tests/iop_import_version_tests.cpp @@ -0,0 +1,170 @@ +#include "iop_compat_test_support.h" + +#include "emulator/core/iop_cpu.h" +#include "emulator/core/iop_memory.h" +#include "emulator/imports/iop_imports.h" +#include "emulator/imports/iop_loadcore.h" + +namespace +{ + using namespace iop_test; + using namespace ps2x::iop::detail; + + void addExport(IopMemory &memory, IopImportRegistry &imports, uint32_t address, + uint16_t version, uint32_t target, uint32_t count = 4u) + { + require(memory.zeroRam(address, 128u), "export table does not fit"); + memory.write32(address, 0x41C00000u); + memory.write16(address + 8u, version); + constexpr char name[8] = "tstlib"; + require(memory.writeRam(address + 12u, name, sizeof(name)), "export name does not fit"); + for (uint32_t i = 0u; i < count; ++i) + memory.write32(address + 20u + 4u * i, target); + require(imports.registerExportTable(address), "export registration failed"); + } + + void importTable(IopMemory &memory, uint32_t address, uint16_t version) + { + require(memory.zeroRam(address, 64u), "import table does not fit"); + memory.write32(address, 0x41E00000u); + memory.write16(address + 8u, version); + constexpr char name[8] = "tstlib"; + require(memory.writeRam(address + 12u, name, sizeof(name)), "import name does not fit"); + memory.write32(address + 20u, 0x03E00008u); + memory.write32(address + 24u, 0x24000003u); + } + + void decodeVersion() + { + IopMemory memory; + IopImportRegistry imports(memory); + importTable(memory, 0x1000u, 0x0310u); + const auto call = imports.decode(0x1014u); + require(call && call->library == "tstlib" && call->ordinal == 3u && call->version == 0x0310u, + "decoder dropped the import library version"); + const auto alias = imports.decode(0x80001014u); + require(alias && alias->version == 0x0310u, "cached alias lost import version"); + } + + void majorIsolation() + { + IopMemory memory; + IopImportRegistry imports(memory); + addExport(memory, imports, 0x1000u, 0x0201u, 0x2100u); + addExport(memory, imports, 0x1800u, 0x0101u, 0x3100u); + require(imports.resolve("tstlib", 3u, 0x0101u) == 0x3100u, "linked to wrong library major"); + require(imports.resolve("tstlib", 3u, 0x0201u) == 0x2100u, "second major unavailable"); + require(imports.resolve("tstlib", 3u, 0x0300u) == 0u, "incompatible major silently linked"); + require(imports.findTable("tstlib", 0x0300u) == 0u, "query ignored requested major"); + } + + void newestMinor() + { + IopMemory memory; + IopImportRegistry imports(memory); + addExport(memory, imports, 0x1000u, 0x0101u, 0x2100u); + addExport(memory, imports, 0x1800u, 0x0104u, 0x3100u); + addExport(memory, imports, 0x1400u, 0x0103u, 0x4100u); + require(imports.resolve("tstlib", 3u, 0x0101u) == 0x3100u, "selected lowest address, not newest minor"); + require(imports.resolve("tstlib", 3u, 0x017Fu) == 0x3100u, + "invented a minimum-minor rule absent from LOADCORE linking"); + require(imports.releaseExportTable(0x1800u), "unregister failed"); + require(imports.resolve("tstlib", 3u, 0x0101u) == 0x4100u, "unregistered library remained selected"); + } + + void missingOrdinal() + { + IopMemory memory; + IopImportRegistry imports(memory); + addExport(memory, imports, 0x1000u, 0x0101u, 0x2100u, 8u); + addExport(memory, imports, 0x1800u, 0x0102u, 0x3100u, 4u); + require(imports.resolve("tstlib", 7u, 0x0101u) == 0u, + "missing ordinal fell back to a different export table"); + require(imports.resolve("missing", 0u, 0x0101u) == 0u, "missing library resolved"); + imports.reset(); + require(imports.resolve("tstlib", 0u, 0x0101u) == 0u, "registry reset left exports"); + } + + void queryFunctionArray() + { + IopMemory memory; + IopImportRegistry imports(memory); + IopLoadcore loadcore(memory, imports); + addExport(memory, imports, 0x1000u, 0x0201u, 0x2100u); + addExport(memory, imports, 0x1800u, 0x0101u, 0x3100u); + importTable(memory, 0x800u, 0x0102u); + IopCpuState cpu{}; + cpu.gpr[4] = 0x800u; + require(loadcore.dispatchImport(11u, cpu), "QueryLibraryEntryTable unhandled"); + require(cpu.gpr[2] == 0x1814u && memory.read32(cpu.gpr[2]) == 0x3100u, + "query returned an export header instead of function array"); + memory.write16(0x808u, 0x0300u); + require(loadcore.dispatchImport(11u, cpu) && cpu.gpr[2] == 0u, "query accepted wrong major"); + for (uint32_t address : {0u, 0xFFFFFFF8u, IopMemory::RamSize - 4u}) + { + cpu.gpr[4] = address; + require(loadcore.dispatchImport(11u, cpu) && cpu.gpr[2] == 0u, "invalid query pointer accepted"); + } + } + + Irx provider(uint32_t base, uint16_t version, uint32_t result) + { + Irx image(base); + const uint32_t table = base + 0x80u; + const uint32_t importStub = base + 0xC0u + 20u; + image.words(0u, {0x27BDFFE0u, 0xAFBF001Cu, + 0x3C040000u | (table >> 16u), 0x34840000u | (table & 0xFFFFu), + 0x0C000000u | (importStub >> 2u), 0u, + 0x8FBF001Cu, 0x00001021u, 0x27BD0020u, 0x03E00008u, 0u}); + image.words(0x60u, {0x03E00008u, 0x24020000u | result}); + image.words(0x80u, {0x41C00000u, 0u, version, 0x6C747374u, 0x00006269u, + base, base, base, base + 0x60u, 0u}); + image.words(0xC0u, {0x41E00000u, 0u, 0x0101u, 0x64616F6Cu, 0x65726F63u, + 0x03E00008u, 0x24000006u, 0u, 0u}); + return image; + } + + Irx consumer(uint16_t version) + { + constexpr uint32_t base = 0x13000u; + Irx image(base); + image.words(0u, {0x27BDFFF0u, 0xAFBF000Cu, + 0x0C000000u | ((base + 0x54u) >> 2u), 0u, + 0x8FBF000Cu, 0x27BD0010u, 0x03E00008u, 0u}); + image.words(0x40u, {0x41E00000u, 0u, version, 0x6C747374u, 0x00006269u, + 0x03E00008u, 0x24000003u, 0u, 0u}); + return image; + } + + void physicalImportsEndToEnd() + { + Host host; + IopSubsystem iop(host); + auto wrongMajor = provider(0x10000u, 0x0201u, 0x22u); + wrongMajor.install(host); + require(iop.loadModuleBuffer(0x1000u).startResult == 0, "provider 2 failed"); + auto oldMinor = provider(0x11000u, 0x0101u, 0x11u); + oldMinor.install(host); + require(iop.loadModuleBuffer(0x1000u).startResult == 0, "provider 1 failed"); + auto newMinor = provider(0x12000u, 0x0103u, 0x13u); + newMinor.install(host); + require(iop.loadModuleBuffer(0x1000u).startResult == 0, "provider 1.3 failed"); + auto client = consumer(0x0101u); + client.install(host); + const auto result = iop.loadModuleBuffer(0x1000u); + require(result.moduleId > 0 && result.startResult == 0x13, "R3000A called wrong export version"); + } +} + +int main() +{ + const Test tests[] = { + {"Import decoder preserves library ABI version", decodeVersion}, + {"Different major versions cannot cross-link", majorIsolation}, + {"Newest registered minor wins within the requested major", newestMinor}, + {"Ordinal lookup stays in the selected table", missingOrdinal}, + {"LOADCORE query returns function array and honors major", queryFunctionArray}, + {"Physical IRX consumer links correct version end to end", physicalImportsEndToEnd}, + }; + return run(tests); +} diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 3c144c4..2421f9e 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -1,9 +1,9 @@ #pragma once #include "runtime/gs/gs_backend.h" +#include "runtime/gs/gs_texture_page_cache.h" #include -#include #include #include @@ -61,8 +61,8 @@ private: uint32_t sourceOriginX, uint32_t sourceOriginY) const; - using WriteVramFunc = std::function; - using ReadVramFunc = std::function; + using WriteVramFunc = void (*)(uint8_t *, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); + using ReadVramFunc = uint32_t (*)(uint8_t *, uint32_t, uint32_t, uint32_t, uint32_t); static constexpr size_t kPsmHandlerCount = 1u << 6u; mutable std::mutex m_mutex; @@ -72,8 +72,7 @@ private: std::array m_writeVramFuncs{}; std::array m_clut{}; std::array m_clutCbp{}; - std::vector m_texturePageBuffer; - uint32_t m_texturePageIndex = UINT32_MAX; + GSMem::TexturePageCache m_texturePageCache; GSTransferCommand m_transfer{}; GSTransferSnapshot m_transferState{}; diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h new file mode 100644 index 0000000..1cb1540 --- /dev/null +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +namespace GSMem +{ + class TexturePageCache + { + public: + static constexpr uint32_t kPageSize = 8192u; + + void Invalidate() noexcept + { + m_pageBase = UINT32_MAX; + } + + // byteAddress is the wrapped, swizzled VRAM address. The returned + // pointer is valid only until the next miss or invalidation. + const uint8_t* Resolve(const uint8_t* vram, uint32_t byteAddress) noexcept + { + const uint32_t pageBase = byteAddress & ~(kPageSize - 1u); + if (m_pageBase != pageBase) + { + std::memcpy(m_bytes.data(), vram + pageBase, kPageSize); + m_pageBase = pageBase; + } + return m_bytes.data() + (byteAddress & (kPageSize - 1u)); + } + + private: + alignas(64) std::array m_bytes{}; + uint32_t m_pageBase = UINT32_MAX; + }; +} diff --git a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h index 095e4e5..c784ff7 100644 --- a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h +++ b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h @@ -7,11 +7,12 @@ #include #include "types.h" +#include "runtime/gs/gs_texture_page_cache.h" namespace GSMem { constexpr usz MEMORY_SIZE = 4_mb; - constexpr usz GS_PAGE_SIZE = 8_kb; + constexpr usz GS_PAGE_SIZE = TexturePageCache::kPageSize; // these are all the same regardless of storage mode constexpr usz BLOCKS_PER_PAGE = 32; @@ -261,7 +262,7 @@ namespace GSMem static constexpr void Write(const PageLookupTableT& table, u8* data, u32 block, u32 bw, u32 x, u32 y, PackedT value); // reads the pixel - static constexpr auto Read(const PageLookupTableT& table, u8* data, u32 block, u32 bw, u32 x, u32 y) -> PackedT; + static constexpr auto Read(const PageLookupTableT& table, const u8* data, u32 block, u32 bw, u32 x, u32 y, TexturePageCache* cache = nullptr) -> PackedT; static_assert(BlocksPerPage() == BLOCKS_PER_PAGE); static_assert(IsValidPsm(psm)); @@ -501,15 +502,16 @@ namespace GSMem } template - constexpr auto PixelStorageTraits::Read(const PageLookupTableT& table, u8* data, u32 block, u32 bw, u32 x, u32 y) -> PackedT + constexpr auto PixelStorageTraits::Read(const PageLookupTableT& table, const u8* data, u32 block, u32 bw, u32 x, u32 y, TexturePageCache* cache) -> PackedT { const u32 pixel_addr = Address(table, block, bw, x, y); const u32 bits = pixel_addr * UnpackedBitWidth(psm) + BitOffset(); const u32 byte_addr = (bits / 8) & (MEMORY_SIZE - sizeof(PackedT)); const u32 shift = bits % 8; + const u8* source = cache ? cache->Resolve(data, byte_addr) : data + byte_addr; PackedT v; - std::memcpy(&v, &data[byte_addr], sizeof(PackedT)); + std::memcpy(&v, source, sizeof(PackedT)); switch (psm) { @@ -533,11 +535,14 @@ namespace GSMem break; } - return 0xFFFF00FFu; + return static_cast(0xFFFF00FFu); } void InitLookupTables(); + // Shares swizzle, VRAM wrapping, and lane extraction with the direct reads. + u32 ReadTexture(TexturePageCache& cache, const u8* data, u32 psm, u32 bp, u32 bw, u32 x, u32 y); + void WriteCT32(u8* data, u32 bp, u32 bw, u32 x, u32 y, u32 value); void WriteZ32(u8* data, u32 bp, u32 bw, u32 x, u32 y, u32 value); diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 03e625d..f0ad507 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -13,6 +13,7 @@ #include #include #include +#include using namespace GSInternal; @@ -291,32 +292,6 @@ namespace return psm == GS_PSM_T8 || psm == GS_PSM_T8H; } - uint32_t texturePageIndex(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) - { - switch (psm & 0x3Fu) - { - case GS_PSM_CT32: - case GS_PSM_CT24: - case GS_PSM_Z32: - case GS_PSM_Z24: - case GS_PSM_T8H: - case GS_PSM_T4HL: - case GS_PSM_T4HH: - return static_cast(GSMem::PixelStorageTraits::PageId(base, bw, x, y)); - case GS_PSM_CT16: - case GS_PSM_CT16S: - case GS_PSM_Z16: - case GS_PSM_Z16S: - return static_cast(GSMem::PixelStorageTraits::PageId(base, bw, x, y)); - case GS_PSM_T8: - return static_cast(GSMem::PixelStorageTraits::PageId(base, bw, x, y)); - case GS_PSM_T4: - return static_cast(GSMem::PixelStorageTraits::PageId(base, bw, x, y)); - default: - return UINT32_MAX; - } - } - uint8_t lerpChannel(uint8_t c00, uint8_t c10, uint8_t c01, uint8_t c11, float fx, float fy) { const float top = static_cast(c00) + (static_cast(c10) - static_cast(c00)) * fx; @@ -540,10 +515,12 @@ GSCpuBackend::GSCpuBackend() void GSCpuBackend::Initialize(uint8_t *vram, uint32_t vramSize) { + if (vram && vramSize < GSMem::MEMORY_SIZE) + throw std::invalid_argument("GS CPU backend requires at least 4 MiB of VRAM"); + std::lock_guard lock(m_mutex); m_vram = vram; m_vramSize = vramSize; - m_texturePageBuffer.resize(vramSize); ResetUnlocked(); } @@ -557,7 +534,7 @@ void GSCpuBackend::ResetUnlocked() { m_clut.fill(0u); m_clutCbp.fill(0u); - m_texturePageIndex = UINT32_MAX; + m_texturePageCache.Invalidate(); m_transfer = {}; m_transfer.direction = 3u; m_transferState = {}; @@ -645,9 +622,8 @@ void GSCpuBackend::LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &t sourceY = static_cast(texclut.cov); } - const uint32_t raw = ReadTextureVramUnlocked(tex0.cpsm, tex0.cbp, sourceWidth, sourceX, sourceY); - const uint32_t destination = (loadCsm1Suffix ? entry : destinationBase + entry) & - (sixteenBit ? 0x1FFu : 0x0FFu); + const uint32_t raw = ReadTextureVramUnlocked(tex0.cpsm, tex0.cbp, sourceWidth, sourceX, sourceY); + const uint32_t destination = (loadCsm1Suffix ? entry : destinationBase + entry) & (sixteenBit ? 0x1FFu : 0x0FFu); if (sixteenBit) { m_clut[destination] = static_cast(raw); @@ -668,7 +644,7 @@ void GSCpuBackend::Flush() void GSCpuBackend::TextureFlush() { std::lock_guard lock(m_mutex); - m_texturePageIndex = UINT32_MAX; + m_texturePageCache.Invalidate(); } void GSCpuBackend::Sync(GSSyncReason) @@ -694,20 +670,7 @@ uint32_t GSCpuBackend::ReadTextureVramUnlocked(uint32_t psm, uint32_t base, uint if (!m_vram) return 0u; - const uint32_t pageCount = m_vramSize / static_cast(GSMem::GS_PAGE_SIZE); - uint32_t page = texturePageIndex(psm, base, bw, x, y); - if (page == UINT32_MAX || pageCount == 0u || m_texturePageBuffer.size() < m_vramSize) - return ReadVramUnlocked(psm, base, bw, x, y); - - page %= pageCount; - if (m_texturePageIndex != page) - { - const size_t pageOffset = static_cast(page) * GSMem::GS_PAGE_SIZE; - std::memcpy(m_texturePageBuffer.data() + pageOffset, m_vram + pageOffset, GSMem::GS_PAGE_SIZE); - m_texturePageIndex = page; - } - - return m_readVramFuncs[psm & 0x3Fu](m_texturePageBuffer.data(), base, bw, x, y); + return GSMem::ReadTexture(m_texturePageCache, m_vram, psm, base, bw, x, y); } void GSCpuBackend::WriteVram(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value) diff --git a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp index fecf9fd..a8f9fa8 100644 --- a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp +++ b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp @@ -220,6 +220,41 @@ namespace GSMem PixelStorageTraits::InitPageLookupTable(PageTableP4, BlockTableP4, ColumnTable4); } + u32 ReadTexture(TexturePageCache& cache, const u8* data, u32 psm, u32 bp, u32 bw, u32 x, u32 y) + { + switch (static_cast(psm & 0x3Fu)) + { + case C32: + return PixelStorageTraits::Read(PageTableC32, data, bp, bw, x, y, &cache); + case C24: + return PixelStorageTraits::Read(PageTableC32, data, bp, bw, x, y, &cache); + case C16: + return PixelStorageTraits::Read(PageTableC16, data, bp, bw, x, y, &cache); + case C16S: + return PixelStorageTraits::Read(PageTableC16S, data, bp, bw, x, y, &cache); + case P8: + return PixelStorageTraits::Read(PageTableP8, data, bp, bw, x, y, &cache); + case P4: + return PixelStorageTraits::Read(PageTableP4, data, bp, bw, x, y, &cache); + case P8H: + return PixelStorageTraits::Read(PageTableC32, data, bp, bw, x, y, &cache); + case P4HL: + return PixelStorageTraits::Read(PageTableC32, data, bp, bw, x, y, &cache); + case P4HH: + return PixelStorageTraits::Read(PageTableC32, data, bp, bw, x, y, &cache); + case Z32: + return PixelStorageTraits::Read(PageTableZ32, data, bp, bw, x, y, &cache); + case Z24: + return PixelStorageTraits::Read(PageTableZ32, data, bp, bw, x, y, &cache); + case Z16: + return PixelStorageTraits::Read(PageTableZ16, data, bp, bw, x, y, &cache); + case Z16S: + return PixelStorageTraits::Read(PageTableZ16S, data, bp, bw, x, y, &cache); + default: + return 0u; + } + } + void WriteCT32(u8* data, u32 bp, u32 bw, u32 x, u32 y, u32 value) { PixelStorageTraits::Write(PageTableC32, data, bp, bw, x, y, value); diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index fe30540..5e68959 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -15,6 +15,7 @@ #include "ps2x/iop/iop_subsystem.h" #include +#include #include #include #include diff --git a/ps2xTest/CMakeLists.txt b/ps2xTest/CMakeLists.txt index 39dbc65..4d19781 100644 --- a/ps2xTest/CMakeLists.txt +++ b/ps2xTest/CMakeLists.txt @@ -5,6 +5,11 @@ project(ps2xTest LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(CTest) +if(BUILD_TESTING) + add_subdirectory(gs_cache) +endif() + if(PS2X_IOP_ENABLE_PLUGINS AND (WIN32 OR (UNIX AND NOT APPLE))) add_library(ps2_iop_fake_plugin MODULE src/fake_iop_plugin.cpp diff --git a/ps2xTest/gs_cache/CMakeLists.txt b/ps2xTest/gs_cache/CMakeLists.txt new file mode 100644 index 0000000..6b7210a --- /dev/null +++ b/ps2xTest/gs_cache/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(PS2GSCacheTests LANGUAGES CXX) + include(CTest) +endif() + +get_filename_component(PS2_GS_DEFAULT_RUNTIME_DIR "${CMAKE_CURRENT_LIST_DIR}/../../ps2xRuntime" ABSOLUTE) +set(PS2_GS_RUNTIME_DIR "${PS2_GS_DEFAULT_RUNTIME_DIR}" CACHE PATH "Runtime source to test") +option(PS2_GS_CACHE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +option(PS2_GS_CACHE_BUILD_MEMORY_TESTS "Build tests for the new shared cached-reader API" ON) + +find_package(Threads REQUIRED) +add_library(ps2_gs_cache_backend_under_test STATIC + "${PS2_GS_RUNTIME_DIR}/src/lib/gs/gs_cpu_backend.cpp" + "${PS2_GS_RUNTIME_DIR}/src/lib/gs/gs_frontend.cpp" + "${PS2_GS_RUNTIME_DIR}/src/lib/gs/ps2_gs_memory.cpp" +) +target_include_directories(ps2_gs_cache_backend_under_test PUBLIC "${PS2_GS_RUNTIME_DIR}/include") +target_compile_features(ps2_gs_cache_backend_under_test PUBLIC cxx_std_20) +target_compile_definitions(ps2_gs_cache_backend_under_test PRIVATE PS2_RUNTIME_LOGS=0 AGRESSIVE_LOGS=0) +target_link_libraries(ps2_gs_cache_backend_under_test PUBLIC Threads::Threads) +set_target_properties(ps2_gs_cache_backend_under_test PROPERTIES CXX_EXTENSIONS OFF) + +if(PS2_GS_CACHE_SANITIZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" OR MSVC) + message(FATAL_ERROR "PS2_GS_CACHE_SANITIZERS requires a GCC/Clang sanitizer toolchain") + endif() + target_compile_options(ps2_gs_cache_backend_under_test PUBLIC + -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all) + target_link_options(ps2_gs_cache_backend_under_test PUBLIC -fsanitize=address,undefined) +endif() + +function(add_gs_cache_suite target source prefix) + add_executable(${target} "${source}") + target_link_libraries(${target} PRIVATE ps2_gs_cache_backend_under_test) + set_target_properties(${target} PROPERTIES CXX_EXTENSIONS OFF) + foreach(test_name IN LISTS ARGN) + add_test(NAME "gs_cache.${prefix}.${test_name}" COMMAND ${target} "${test_name}") + set_tests_properties("gs_cache.${prefix}.${test_name}" PROPERTIES LABELS "gs;cache" TIMEOUT 60) + endforeach() +endfunction() + +add_gs_cache_suite(ps2_gs_texture_cache_tests gs_texture_cache_tests.cpp texture + unaligned_texture unaligned_wrap stale_mirror page_alternation + flush_visibility upload_visibility local_copy_visibility raster_visibility + reset_and_rebind invalid_vram_size reserved_psm) + +add_gs_cache_suite(ps2_gs_clut_cache_tests gs_clut_cache_tests.cpp clut + unaligned_csm1_ct32 unaligned_csm1_ct16 unaligned_csm1_ct16s wrapped_clut unaligned_csm2 + retained_palette clut_uses_page_cache cbp0_conditional cbp1_conditional + reserved_cld nonindexed_cld tex2_reload shared_contexts + csa_ct32 csa_ct16 csa_ct16s texa_without_reload palette_before_filtering high_planes) + +if(PS2_GS_CACHE_BUILD_MEMORY_TESTS) + add_gs_cache_suite(ps2_gs_memory_cache_tests gs_memory_cache_tests.cpp memory + ct32 ct24 ct16 ct16s t8 t4 t8h t4hl t4hh z32 z24 z16 z16s + alias_lanes physical_tag_aliases) +endif() diff --git a/ps2xTest/gs_cache/gs_clut_cache_tests.cpp b/ps2xTest/gs_cache/gs_clut_cache_tests.cpp new file mode 100644 index 0000000..5bde0fd --- /dev/null +++ b/ps2xTest/gs_cache/gs_clut_cache_tests.cpp @@ -0,0 +1,283 @@ +#include "gs_test_support.h" + +using namespace GSTest; + +namespace +{ + template + void unalignedCsm1() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T8, 64); + tex.cbp = 31; + tex.cpsm = Cpsm; + f.index(tex, 128); + f.palette(tex, 128, Cpsm == GS_PSM_CT32 ? kRed : 0x801Fu); + f.bind(tex); + expectEqual(f.sample(), kRed, "CSM1 CLUT load crosses a physical page"); + } + + void wrappedClut() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T8, 64); + tex.cbp = 16383; + f.index(tex, 128); + f.palette(tex, 128, kGreen); + f.bind(tex); + expectEqual(f.sample(), kGreen, "CLUT load wraps at the end of VRAM"); + } + + void unalignedCsm2() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T8, 64); + tex.cbp = 31; + tex.cpsm = GS_PSM_CT16; + tex.csm = 1; + constexpr uint32_t entry = 193; + f.index(tex, entry); + f.gs.writeRegister(GS_REG_TEXCLUT, 4ull | (3ull << 6) | (2ull << 12)); + f.gs.WriteVram(GS_PSM_CT16, tex.cbp, 4, 48 + entry, 2, 0x83E0); + f.bind(tex); + expectEqual(f.sample(), kGreen, "CSM2 CBW/COU/COV and swizzle carry"); + } + + void retainedPalette() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + f.index(tex, 8); + f.palette(tex, 8, kRed); + f.bind(tex); + expectEqual(f.sample(), kRed, "initial palette"); + f.palette(tex, 8, kGreen); + f.flush(); + tex.cld = 0; + f.bind(tex); + expectEqual(f.sample(), kRed, "TEXFLUSH and CLD=0 preserve the CLUT temporary buffer"); + tex.cld = 1; + f.bind(tex); + expectEqual(f.sample(), kGreen, "CLD=1 reloads the palette"); + } + + void clutUsesPageCache() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + f.index(tex, 8); + f.palette(tex, 8, kRed); + f.bind(tex); + // No texture sampling between loads: the CLUT source page is still resident. + f.palette(tex, 8, kGreen); + f.bind(tex); + expectEqual(f.sample(), kRed, "CLD=1 alone does not invalidate the texture page buffer"); + f.flush(); + f.bind(tex); + expectEqual(f.sample(), kGreen, "identical TEX0 write still loads after TEXFLUSH"); + } + + template + void conditionalLoad() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + tex.cld = 2 + Bank; + f.index(tex, 0); + f.palette(tex, 0, kRed); + f.bind(tex); + auto other = tex; + other.cbp = 192; + other.cld = 3 - Bank; + f.palette(other, 0, kGreen); + f.bind(other); + tex.cld = 4 + Bank; + f.bind(tex); + expectEqual(f.sample(), kGreen, "matching CBP skips load, not switches palettes"); + tex.cbp = 160; + f.palette(tex, 0, kBlue); + f.flush(); + f.bind(tex); + expectEqual(f.sample(), kBlue, "different CBP loads and updates comparison memory"); + f.palette(tex, 0, kRed); + f.flush(); + f.bind(tex); + expectEqual(f.sample(), kBlue, "repeated conditional CBP skips reload"); + } + + void reservedCld() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + f.index(tex, 0); + f.palette(tex, 0, kRed); + f.bind(tex); + tex.cbp = 192; + f.palette(tex, 0, kGreen); + for (uint8_t cld : {6, 7}) + { + tex.cld = cld; + f.flush(); + f.bind(tex); + expectEqual(f.sample(), kRed, "reserved CLD leaves palette unchanged"); + } + } + + void nonIndexedCld() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + tex.cld = 2; + f.index(tex, 0); + f.palette(tex, 0, kRed); + f.bind(tex); + auto direct = tex; + direct.psm = GS_PSM_CT32; + direct.cbp = 192; + f.bind(direct); + f.palette(tex, 0, kGreen); + f.flush(); + tex.cld = 4; + f.bind(tex); + expectEqual(f.sample(), kRed, "direct texture TEX0 must not modify CBP0"); + } + + void tex2Reload() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T8, 64); + f.index(tex, 128); + f.palette(tex, 128, kRed); + f.bind(tex); + expectEqual(f.sample(), kRed, "initial TEX0 palette"); + tex.cbp = 31; + f.palette(tex, 128, kGreen); + tex.tbp0 = 2048; + tex.tbw = 8; + tex.tw = tex.th = 9; + f.flush(); + f.bind(tex, 0, true); + expectEqual(f.sample(), kGreen, "TEX2 reloads from a crossing CLUT without changing texture layout"); + const auto state = f.gs.getDebugSnapshot(); + expectEqual(state.ctx[0].tex0.tbp0, 64, "TEX2 preserves TBP"); + expectEqual(state.ctx[0].tex0.tbw, 2, "TEX2 preserves TBW"); + expectEqual(state.ctx[0].tex0.tw, 8, "TEX2 preserves TW"); + } + + void sharedContexts() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + f.index(tex, 0); + f.palette(tex, 0, kRed); + f.bind(tex, 0); + tex.cbp = 192; + f.palette(tex, 0, kGreen); + f.bind(tex, 1); + expectEqual(f.sample(0, 0, 0), kGreen, "both drawing contexts share one CLUT temporary buffer"); + tex.cbp = 256; + f.palette(tex, 0, kBlue); + f.bind(tex, 1, true); + expectEqual(f.sample(0, 0, 0), kBlue, "context 1 TEX2 changes palette visible to context 0"); + } + + template + void csaBanks() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + tex.cpsm = Cpsm; + f.index(tex, 15); + f.palette(tex, 15, Cpsm == GS_PSM_CT32 ? kRed : 0x801Fu); + f.bind(tex); + auto other = tex; + other.cbp = 192; + other.csa = Cpsm == GS_PSM_CT32 ? 15 : 31; + f.palette(other, 15, Cpsm == GS_PSM_CT32 ? kGreen : 0x83E0u); + f.bind(other); + expectEqual(f.sample(), kGreen, "highest CSA bank is readable"); + tex.cld = 0; + tex.csa = Cpsm == GS_PSM_CT32 ? 16 : 0; + f.bind(tex); + expectEqual(f.sample(), kRed, "partial CLUT load retains unrelated banks and masks CSA per CPSM"); + } + + void texaWithoutReload() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + tex.cpsm = GS_PSM_CT16; + f.index(tex, 0); + f.index(tex, 1, 1); + f.index(tex, 2, 2); + f.palette(tex, 0, 0x001F); + f.palette(tex, 1, 0x8000); + f.palette(tex, 2, 0x0000); + f.bind(tex); + f.gs.writeRegister(GS_REG_TEXA, 0x20ull | (0x40ull << 32)); + expectEqual(f.sample(), 0x200000F8, "TA0 applied at lookup"); + expectEqual(f.sample(1), 0x40000000, "TA1 applied to CLUT alpha bit"); + f.gs.writeRegister(GS_REG_TEXA, 0x70ull | (1ull << 15) | (0x60ull << 32)); + expectEqual(f.sample(), 0x700000F8, "TEXA changes without reloading raw palette"); + expectEqual(f.sample(1), 0x60000000, "AEM does not clear black with alpha bit set"); + expectEqual(f.sample(2), 0, "AEM clears zero color with alpha bit clear"); + } + + void paletteBeforeFiltering() + { + FrontendFixture f; + auto tex = texture(GS_PSM_T4, 64); + f.index(tex, 0, 0, 0); + f.index(tex, 2, 1, 0); + f.index(tex, 4, 0, 1); + f.index(tex, 6, 1, 1); + f.palette(tex, 0, kRed); + f.palette(tex, 2, kGreen); + f.palette(tex, 4, kBlue); + f.palette(tex, 6, 0x80F8F8F8); + f.palette(tex, 3, 0x80FF00FF); + f.bind(tex); + f.gs.writeRegister(GS_REG_TEX1_1, (1ull << 5) | (1ull << 6)); + expectEqual(f.sample(1, 1), 0x807C7C7C, "bilinear filtering blends four colors, never four indices"); + } + + void highPlanes() + { + FrontendFixture f; + auto low = texture(GS_PSM_T4HL, 31); + auto high = low; + high.psm = GS_PSM_T4HH; + high.cbp = 192; + high.csa = 1; + f.gs.WriteVram(GS_PSM_CT32, 31, 2, 8, 0, 0x00ABCDEF); + f.index(low, 3, 8); + f.index(high, 12, 8); + f.palette(low, 3, kRed); + f.palette(high, 12, kGreen); + f.bind(low); + f.bind(high); + low.cld = high.cld = 0; + f.bind(low); + expectEqual(f.sample(8), kRed, "low nibble uses its own CSA bank"); + f.bind(high); + expectEqual(f.sample(8), kGreen, "same cached physical bytes supply the high nibble"); + expectEqual(f.gs.ReadVram(GS_PSM_CT24, 31, 2, 8, 0), 0xABCDEF, "index writes preserve the RGB plane"); + } +} + +int main(int argc, char** argv) +{ + return run(argc, argv, { + {"unaligned_csm1_ct32", unalignedCsm1}, + {"unaligned_csm1_ct16", unalignedCsm1}, + {"unaligned_csm1_ct16s", unalignedCsm1}, + {"wrapped_clut", wrappedClut}, {"unaligned_csm2", unalignedCsm2}, + {"retained_palette", retainedPalette}, {"clut_uses_page_cache", clutUsesPageCache}, + {"cbp0_conditional", conditionalLoad<0>}, {"cbp1_conditional", conditionalLoad<1>}, + {"reserved_cld", reservedCld}, {"nonindexed_cld", nonIndexedCld}, + {"tex2_reload", tex2Reload}, {"shared_contexts", sharedContexts}, + {"csa_ct32", csaBanks}, {"csa_ct16", csaBanks}, + {"csa_ct16s", csaBanks}, {"texa_without_reload", texaWithoutReload}, + {"palette_before_filtering", paletteBeforeFiltering}, {"high_planes", highPlanes} + }); +} diff --git a/ps2xTest/gs_cache/gs_memory_cache_tests.cpp b/ps2xTest/gs_cache/gs_memory_cache_tests.cpp new file mode 100644 index 0000000..7f340bc --- /dev/null +++ b/ps2xTest/gs_cache/gs_memory_cache_tests.cpp @@ -0,0 +1,99 @@ +#include "gs_test_support.h" +#include "runtime/gs/ps2_gs_memory.h" + +using namespace GSTest; + +namespace +{ + template + void addressCoverage() + { + BackendFixture f; + GSMem::TexturePageCache cache; + uint32_t random = 0x51375A9Du; + for (auto& byte : f.vram) + { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + byte = static_cast(random); + } + constexpr auto mode = static_cast(Psm); + constexpr auto extent = GSMem::PixelStorageTraits::PageExtent(); + uint32_t checked = 0; + const auto check = [&](uint32_t base, uint32_t bw, uint32_t x, uint32_t y) + { + const auto direct = f.backend.ReadVram(Psm, base, bw, x, y); + const auto cached = GSMem::ReadTexture(cache, f.vram.data(), Psm, base, bw, x, y); + if (cached != direct) + { + std::ostringstream error; + error << "PSM=" << unsigned(Psm) << " BP=" << base << " BW=" << bw << " XY=" << x << ',' << y; + expectEqual(cached, direct, error.str()); + } + ++checked; + }; + // Every local texel, for every 256-byte base offset inside an 8 KiB page. + for (uint32_t offset = 0; offset < 32; ++offset) + for (uint32_t y = 0; y < extent.y; ++y) + for (uint32_t x = 0; x < extent.x; ++x) + check(32 + offset, 2, x, y); + + for (uint32_t base : {0u, 31u, 32u, 12160u, 16256u, 16383u}) + for (uint32_t bw : {0u, 1u, 2u, 3u, 7u, 8u, 10u, 63u}) + for (uint32_t y : {0u, 1u, 7u, 8u, 15u, 16u, 31u, 32u, 63u, 64u, 127u, 128u, 255u, 256u, 511u, 512u, 1023u, 2047u}) + for (uint32_t x : {0u, 1u, 7u, 8u, 15u, 16u, 31u, 32u, 63u, 64u, 127u, 128u, 255u, 256u, 511u, 512u, 1023u, 2047u}) + check(base, bw, x, y); + std::cout << checked << " cached/direct comparisons\n"; + } + + void aliasLanes() + { + BackendFixture f; + GSMem::TexturePageCache cache; + constexpr uint32_t base = 31; + const auto read = [&](uint32_t psm) + { + return GSMem::ReadTexture(cache, f.vram.data(), psm, base, 2, 8, 0); + }; + f.backend.WriteVram(GS_PSM_CT32, base, 2, 8, 0, 0xAB123456); + expectEqual(read(GS_PSM_T8H), 0xAB, "8H lane on a crossing page"); + f.backend.WriteVram(GS_PSM_CT24, base, 2, 8, 0, 0x654321); + expectEqual(read(GS_PSM_CT32), 0xAB123456, "changing PSM does not refresh the physical page"); + cache.Invalidate(); + expectEqual(read(GS_PSM_CT32), 0xAB654321, "CT24 upload preserves alpha"); + f.backend.WriteVram(GS_PSM_T4HL, base, 2, 8, 0, 5); + expectEqual(read(GS_PSM_T4HL), 11, "low nibble remains cached before flush"); + cache.Invalidate(); + expectEqual(read(GS_PSM_T4HL), 5, "low nibble after flush"); + expectEqual(read(GS_PSM_T4HH), 10, "high nibble preserved"); + expectEqual(read(GS_PSM_CT24), 0x654321, "RGB plane preserved"); + } + + void physicalTagAliases() + { + BackendFixture f; + GSMem::TexturePageCache cache; + f.backend.WriteVram(GS_PSM_CT32, 32, 2, 0, 0, kRed); + expectEqual(GSMem::ReadTexture(cache, f.vram.data(), GS_PSM_CT32, 32, 2, 0, 0), kRed, "prime aligned view"); + f.backend.WriteVram(GS_PSM_CT32, 32, 2, 0, 0, kGreen); + // Both descriptors resolve to the very same physical byte, so this is a hit. + expectEqual(GSMem::ReadTexture(cache, f.vram.data(), GS_PSM_CT32, 31, 2, 8, 0), kRed, "alias descriptor keeps the same cached bytes"); + cache.Invalidate(); + expectEqual(GSMem::ReadTexture(cache, f.vram.data(), GS_PSM_CT32, 31, 2, 8, 0), kGreen, "alias after flush"); + } +} + +int main(int argc, char** argv) +{ + return run(argc, argv, { + {"ct32", addressCoverage}, {"ct24", addressCoverage}, + {"ct16", addressCoverage}, {"ct16s", addressCoverage}, + {"t8", addressCoverage}, {"t4", addressCoverage}, + {"t8h", addressCoverage}, {"t4hl", addressCoverage}, + {"t4hh", addressCoverage}, {"z32", addressCoverage}, + {"z24", addressCoverage}, {"z16", addressCoverage}, + {"z16s", addressCoverage}, {"alias_lanes", aliasLanes}, + {"physical_tag_aliases", physicalTagAliases} + }); +} diff --git a/ps2xTest/gs_cache/gs_test_support.h b/ps2xTest/gs_cache/gs_test_support.h new file mode 100644 index 0000000..2b2c3ee --- /dev/null +++ b/ps2xTest/gs_cache/gs_test_support.h @@ -0,0 +1,190 @@ +#pragma once + +#include "runtime/gs/gs_cpu_backend.h" +#include "runtime/gs/gs_frontend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GSTest +{ + constexpr uint32_t kVramSize = 4u * 1024u * 1024u; + constexpr uint32_t kOutputPage = 200u; + constexpr uint32_t kRed = 0x800000F8u; + constexpr uint32_t kGreen = 0x8000F800u; + constexpr uint32_t kBlue = 0x80F80000u; + + inline void require(bool condition, std::string_view message) + { + if (!condition) + throw std::runtime_error(std::string(message)); + } + + inline void expectEqual(uint32_t actual, uint32_t expected, std::string_view message) + { + if (actual != expected) + { + std::ostringstream error; + error << message << ": expected 0x" << std::hex << expected << ", got 0x" << actual; + throw std::runtime_error(error.str()); + } + } + + struct Test + { + std::string_view name; + void (*run)(); + }; + + inline int run(int argc, char** argv, std::initializer_list tests) + { + try + { + if (argc != 2) + throw std::invalid_argument("Pass one test name; run the complete suite with CTest."); + for (const Test& test : tests) + { + if (test.name == argv[1]) + { + test.run(); + std::cout << "PASS " << test.name << '\n'; + return 0; + } + } + throw std::invalid_argument("Unknown test name: " + std::string(argv[1])); + } + catch (const std::exception& error) + { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } + } + + inline GSTex0Reg texture(uint8_t psm = GS_PSM_CT32, uint32_t base = 32u) + { + GSTex0Reg tex{}; + tex.tbp0 = base; + tex.tbw = 2; + tex.psm = psm; + tex.tw = tex.th = 8; + tex.tcc = tex.tfx = 1; + tex.cbp = 128; + tex.cpsm = GS_PSM_CT32; + tex.cld = 1; + return tex; + } + + inline uint64_t encodeTex0(const GSTex0Reg& tex) + { + return uint64_t(tex.tbp0) | (uint64_t(tex.tbw) << 14) | (uint64_t(tex.psm) << 20) | + (uint64_t(tex.tw) << 26) | (uint64_t(tex.th) << 30) | (uint64_t(tex.tcc) << 34) | + (uint64_t(tex.tfx) << 35) | (uint64_t(tex.cbp) << 37) | (uint64_t(tex.cpsm) << 51) | + (uint64_t(tex.csm) << 55) | (uint64_t(tex.csa) << 56) | (uint64_t(tex.cld) << 61); + } + + inline GSPrimitiveBatch sprite(const GSTex0Reg& tex, uint32_t x, uint32_t y, bool linear = false) + { + GSPrimitiveBatch batch{}; + batch.vertexCount = 2; + auto& state = batch.state; + state.prim.type = GS_PRIM_SPRITE; + state.prim.tme = state.prim.fst = true; + state.context.frame.fbp = kOutputPage; + state.context.frame.fbw = 1; + state.context.zbuf.zmask = true; + state.context.test = 1ull << 17; + state.context.tex0 = tex; + state.context.clamp = 5; // Clamp both axes. + state.textureWidth = state.textureHeight = 256; + state.texa.ta0 = state.texa.ta1 = 128; + state.linearFilter = linear; + for (auto& vertex : batch.vertices) + { + vertex.r = vertex.g = vertex.b = vertex.a = 128; + vertex.u = static_cast(x * 16u); + vertex.v = static_cast(y * 16u); + } + batch.vertices[1].x = batch.vertices[1].y = 1; + return batch; + } + + struct BackendFixture + { + std::vector vram = std::vector(kVramSize); + GSCpuBackend backend; + + BackendFixture() + { + backend.Initialize(vram.data(), static_cast(vram.size())); + } + + uint32_t sample(const GSTex0Reg& tex, uint32_t x = 0, uint32_t y = 0, bool linear = false) + { + backend.Submit(sprite(tex, x, y, linear)); + return backend.ReadVram(GS_PSM_CT32, kOutputPage * 32u, 1u, 0u, 0u); + } + }; + + struct FrontendFixture + { + std::vector vram = std::vector(kVramSize); + GS gs; + + FrontendFixture() + { + gs.init(vram.data(), static_cast(vram.size()), nullptr); + for (uint8_t context = 0; context < 2; ++context) + { + gs.writeRegister(context ? GS_REG_FRAME_2 : GS_REG_FRAME_1, kOutputPage | (1ull << 16)); + gs.writeRegister(context ? GS_REG_ZBUF_2 : GS_REG_ZBUF_1, 1ull << 32); + gs.writeRegister(context ? GS_REG_SCISSOR_2 : GS_REG_SCISSOR_1, 0); + gs.writeRegister(context ? GS_REG_TEST_2 : GS_REG_TEST_1, 0x30000); + gs.writeRegister(context ? GS_REG_CLAMP_2 : GS_REG_CLAMP_1, 5); + } + gs.writeRegister(GS_REG_TEXA, 128ull | (128ull << 32)); + } + + void bind(const GSTex0Reg& tex, uint8_t context = 0, bool tex2 = false) + { + const uint8_t reg = tex2 ? (context ? GS_REG_TEX2_2 : GS_REG_TEX2_1) + : (context ? GS_REG_TEX0_2 : GS_REG_TEX0_1); + gs.writeRegister(reg, encodeTex0(tex)); + } + + void flush() + { + gs.writeRegister(GS_REG_TEXFLUSH, 0); + } + + void index(const GSTex0Reg& tex, uint32_t value, uint32_t x = 0, uint32_t y = 0) + { + gs.WriteVram(tex.psm, tex.tbp0, tex.tbw, x, y, value); + } + + void palette(const GSTex0Reg& tex, uint32_t entry, uint32_t value) + { + // CSM1 source layout; CSA selects the destination, not this source. + const uint32_t position = (entry & ~0x18u) | ((entry & 8u) << 1u) | ((entry & 16u) >> 1u); + gs.WriteVram(tex.cpsm, tex.cbp, 1, position & 15u, position >> 4u, value); + } + + uint32_t sample(uint32_t x = 0, uint32_t y = 0, uint8_t context = 0) + { + gs.writeRegister(GS_REG_PRIM, GS_PRIM_SPRITE | (1ull << 4) | (1ull << 8) | (uint64_t(context) << 9)); + gs.writeRegister(GS_REG_RGBAQ, 0x80808080); + const uint64_t uv = (uint64_t(y * 16u) << 16) | uint64_t(x * 16u); + gs.writeRegister(GS_REG_UV, uv); + gs.writeRegister(GS_REG_XYZ2, 0); + gs.writeRegister(GS_REG_UV, uv); + gs.writeRegister(GS_REG_XYZ2, 16ull | (16ull << 16)); + return gs.ReadVram(GS_PSM_CT32, kOutputPage * 32u, 1, 0, 0); + } + }; +} diff --git a/ps2xTest/gs_cache/gs_texture_cache_tests.cpp b/ps2xTest/gs_cache/gs_texture_cache_tests.cpp new file mode 100644 index 0000000..efd10e7 --- /dev/null +++ b/ps2xTest/gs_cache/gs_texture_cache_tests.cpp @@ -0,0 +1,177 @@ +#include "gs_test_support.h" + +#include + +using namespace GSTest; + +namespace +{ + void unalignedTexture() + { + BackendFixture f; + auto tex = texture(GS_PSM_CT32, 31); + // TBP=31, CT32(8,0): block 31 + swizzled block 1 = physical page 1. + std::memcpy(f.vram.data() + 8192u, &kRed, sizeof(kRed)); + expectEqual(f.sample(tex, 8), kRed, "non-page-aligned texture base"); + } + + void unalignedWrap() + { + BackendFixture f; + auto tex = texture(GS_PSM_CT32, 16383); + std::memcpy(f.vram.data(), &kGreen, sizeof(kGreen)); + expectEqual(f.sample(tex, 8), kGreen, "swizzle carry wraps through the 4 MiB boundary"); + } + + void staleMirror() + { + BackendFixture f; + auto tex = texture(); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + expectEqual(f.sample(tex), kRed, "prime the following physical page"); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kGreen); + f.backend.TextureFlush(); + tex.tbp0 = 31; + expectEqual(f.sample(tex, 8), kGreen, "TEXFLUSH must not expose stale bytes in the old mirror"); + } + + void pageAlternation() + { + BackendFixture f; + auto tex = texture(GS_PSM_CT32, 31); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 8, 0, kGreen); + for (unsigned i = 0; i < 8; ++i) + { + expectEqual(f.sample(tex), kRed, "first physical page"); + expectEqual(f.sample(tex, 8), kGreen, "second physical page in the same logical page"); + } + } + + void flushVisibility() + { + BackendFixture f; + auto tex = texture(); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + expectEqual(f.sample(tex), kRed, "initial cache fill"); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kGreen); + expectEqual(f.backend.ReadVram(tex.psm, tex.tbp0, tex.tbw, 0, 0), kGreen, "canonical VRAM changes immediately"); + f.backend.Flush(); + f.backend.Sync(GSSyncReason::Finish); + expectEqual(f.sample(tex), kRed, "ordinary flush and FINISH do not invalidate texels"); + f.backend.TextureFlush(); + expectEqual(f.sample(tex), kGreen, "TEXFLUSH exposes the updated texels"); + } + + void uploadVisibility() + { + BackendFixture f; + auto tex = texture(); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + expectEqual(f.sample(tex), kRed, "prime destination"); + GSTransferCommand transfer{}; + transfer.direction = 0; + transfer.bitbltbuf.dbp = tex.tbp0; + transfer.bitbltbuf.dbw = tex.tbw; + transfer.bitbltbuf.dpsm = tex.psm; + transfer.trxreg.rrw = transfer.trxreg.rrh = 1; + f.backend.BeginTransfer(transfer); + f.backend.UploadImage(reinterpret_cast(&kGreen), sizeof(kGreen)); + expectEqual(f.sample(tex), kRed, "host upload does not implicitly flush texels"); + f.backend.TextureFlush(); + expectEqual(f.sample(tex), kGreen, "host upload visible after TEXFLUSH"); + } + + void localCopyVisibility() + { + BackendFixture f; + auto tex = texture(); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + f.backend.WriteVram(tex.psm, 96, tex.tbw, 0, 0, kGreen); + expectEqual(f.sample(tex), kRed, "prime destination"); + GSTransferCommand transfer{}; + transfer.direction = 2; + transfer.bitbltbuf.sbp = 96; + transfer.bitbltbuf.sbw = transfer.bitbltbuf.dbw = tex.tbw; + transfer.bitbltbuf.spsm = transfer.bitbltbuf.dpsm = tex.psm; + transfer.bitbltbuf.dbp = tex.tbp0; + transfer.trxreg.rrw = transfer.trxreg.rrh = 1; + f.backend.BeginTransfer(transfer); + expectEqual(f.sample(tex), kRed, "local copy does not implicitly flush texels"); + f.backend.TextureFlush(); + expectEqual(f.sample(tex), kGreen, "local copy visible after TEXFLUSH"); + } + + void rasterVisibility() + { + BackendFixture f; + auto tex = texture(); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + expectEqual(f.sample(tex), kRed, "prime render target as texture"); + auto batch = sprite(tex, 0, 0); + batch.state.prim.tme = false; + batch.state.context.frame.fbp = tex.tbp0 / 32; + for (auto& vertex : batch.vertices) + { + vertex.r = 0; + vertex.g = 248; + vertex.b = 0; + } + f.backend.Submit(batch); + expectEqual(f.sample(tex), kRed, "raster writes do not implicitly flush texels"); + f.backend.TextureFlush(); + expectEqual(f.sample(tex), kGreen, "render-to-texture visible after TEXFLUSH"); + } + + void resetAndRebind() + { + BackendFixture f; + auto tex = texture(); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kRed); + expectEqual(f.sample(tex), kRed, "prime cache"); + f.backend.WriteVram(tex.psm, tex.tbp0, tex.tbw, 0, 0, kGreen); + f.backend.Reset(); + expectEqual(f.sample(tex), kGreen, "reset invalidates without clearing VRAM"); + std::vector other(kVramSize); + std::memcpy(other.data() + 8192u, &kBlue, sizeof(kBlue)); + f.backend.Initialize(other.data(), static_cast(other.size())); + expectEqual(f.sample(tex), kBlue, "initialize invalidates the previous VRAM allocation"); + } + + void invalidVramSize() + { + BackendFixture f; + std::vector shortVram(8192); + bool rejected = false; + try { f.backend.Initialize(shortVram.data(), static_cast(shortVram.size())); } + catch (const std::invalid_argument&) { rejected = true; } + require(rejected, "undersized VRAM must be rejected before masked accesses can escape it"); + f.backend.WriteVram(GS_PSM_CT32, 32, 2, 0, 0, kGreen); + expectEqual(f.sample(texture()), kGreen, "failed initialize preserves the existing backend binding"); + f.backend.Initialize(nullptr, 0); + expectEqual(f.backend.ReadVram(GS_PSM_CT32, 32, 2, 0, 0), 0, "null binding is safe"); + } + + void reservedPsm() + { + BackendFixture f; + auto tex = texture(0x3F); + f.backend.WriteVram(GS_PSM_CT32, 32, 2, 0, 0, kGreen); + f.backend.WriteVram(0x3F, 32, 2, 0, 0, kRed); + expectEqual(f.backend.ReadVram(GS_PSM_CT32, 32, 2, 0, 0), kGreen, "reserved writes are no-op"); + expectEqual(f.backend.ReadVram(0x3F, 32, 2, 0, 0), 0, "reserved raw reads use null semantics"); + expectEqual(f.sample(tex), 0xFFFF00FFu, "reserved sampling preserves the existing magenta diagnostic"); + } +} + +int main(int argc, char** argv) +{ + return run(argc, argv, { + {"unaligned_texture", unalignedTexture}, {"unaligned_wrap", unalignedWrap}, + {"stale_mirror", staleMirror}, {"page_alternation", pageAlternation}, + {"flush_visibility", flushVisibility}, {"upload_visibility", uploadVisibility}, + {"local_copy_visibility", localCopyVisibility}, {"raster_visibility", rasterVisibility}, + {"reset_and_rebind", resetAndRebind}, {"invalid_vram_size", invalidVramSize}, + {"reserved_psm", reservedPsm} + }); +}