From c235b8a769141b07456373e0d6330b750cb04dcb Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:06:21 -0300 Subject: [PATCH 01/91] Bind sceCdLayerSearchFile to the host CD file search --- .../tools/ghidra/ExportPS2Functions.java | 2 +- ps2xRuntime/include/ps2_call_list.h | 1 + ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp | 7 +++++ ps2xRuntime/src/lib/Kernel/Stubs/CD.h | 1 + ps2xTest/src/ps2_runtime_io_tests.cpp | 26 +++++++++++++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/ps2xRecomp/tools/ghidra/ExportPS2Functions.java b/ps2xRecomp/tools/ghidra/ExportPS2Functions.java index 694c5185a5..c3e7d42016 100644 --- a/ps2xRecomp/tools/ghidra/ExportPS2Functions.java +++ b/ps2xRecomp/tools/ghidra/ExportPS2Functions.java @@ -143,7 +143,7 @@ public class ExportPS2Functions extends GhidraScript { "mcSetFileSelectWindowCursol", "mcSetFileSelectWindowCursolInit", "mcSetStringSaveFile", "mcSetTyepWriteMode", "mcWriteIconData", "mcWriteStartConfigFile", "mcWriteStartSaveFile", "mceGetInfoApdx", "mceIntrReadFixAlign", "mceStorePwd", "sceCdApplyNCmd", "sceCdBreak", "sceCdCallback", "sceCdChangeThreadPriority", "sceCdDelayThread", "sceCdDiskReady", "sceCdGetDiskType", "sceCdGetError", - "sceCdGetReadPos", "sceCdGetToc", "sceCdInit", "sceCdInitEeCB", "sceCdIntToPos", "sceCdMmode", + "sceCdGetReadPos", "sceCdGetToc", "sceCdInit", "sceCdInitEeCB", "sceCdIntToPos", "sceCdLayerSearchFile", "sceCdMmode", "sceCdNcmdDiskReady", "sceCdPause", "sceCdPosToInt", "sceCdRead", "sceCdReadChain", "sceCdReadClock", "sceCdReadIOPm", "sceCdSearchFile", "sceCdSeek", "sceCdStInit", "sceCdStPause", "sceCdStRead", "sceCdStResume", "sceCdStSeek", "sceCdStSeekF", "sceCdStStart", "sceCdStStat", "sceCdStStop", diff --git a/ps2xRuntime/include/ps2_call_list.h b/ps2xRuntime/include/ps2_call_list.h index 94a6fa123e..78e1b641eb 100644 --- a/ps2xRuntime/include/ps2_call_list.h +++ b/ps2xRuntime/include/ps2_call_list.h @@ -298,6 +298,7 @@ X(sceCdInit) \ X(sceCdInitEeCB) \ X(sceCdIntToPos) \ + X(sceCdLayerSearchFile) \ X(sceCdMmode) \ X(sceCdNcmdDiskReady) \ X(sceCdPause) \ diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp index 56a978eae4..974e10f7fc 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp @@ -648,6 +648,13 @@ namespace ps2_stubs setReturnS32(ctx, 1); } + void sceCdLayerSearchFile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + // Host files have no disc layers, so the layer argument in $a2 is irrelevant. + // Left to the guest libcdvd, this binds CDVDFSV's RPC, which no BIOS-free IOP registers. + sceCdSearchFile(rdram, ctx, runtime); + } + void sceCdSeek(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { restartCdStreamAt(getRegU32(ctx, 4), runtime); diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/CD.h b/ps2xRuntime/src/lib/Kernel/Stubs/CD.h index dd5bae923c..25cd85dc86 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/CD.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/CD.h @@ -57,6 +57,7 @@ namespace ps2_stubs void sceCdInit(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceCdInitEeCB(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceCdIntToPos(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void sceCdLayerSearchFile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceCdMmode(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceCdNcmdDiskReady(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceCdPause(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); diff --git a/ps2xTest/src/ps2_runtime_io_tests.cpp b/ps2xTest/src/ps2_runtime_io_tests.cpp index 4f979be0d6..a3c91c4804 100644 --- a/ps2xTest/src/ps2_runtime_io_tests.cpp +++ b/ps2xTest/src/ps2_runtime_io_tests.cpp @@ -707,6 +707,32 @@ void register_ps2_runtime_io_tests() "sceCdSearchFile should assign a pseudo LSN for the resolved host file"); }); + tc.Run("sceCdLayerSearchFile resolves host files regardless of layer", [](TestCase &t) + { + TestContext test; + + std::filesystem::create_directories(test.paths.cdRoot / "modules"); + { + std::ofstream out(test.paths.cdRoot / "modules" / "ioprp310.img", std::ios::binary); + const std::string payload = "romdir"; + out.write(payload.data(), static_cast(payload.size())); + } + + constexpr uint32_t fileAddr = GUEST_BUFFER_AREA_START + 0x1B00; + constexpr uint32_t pathAddr = GUEST_STRING_AREA_START + 0xB00; + writeGuestString(test.rdram.data(), pathAddr, "\\MODULES\\IOPRP310.IMG;1"); + + clearContext(test.ctx); + setRegU32(test.ctx, 4, fileAddr); + setRegU32(test.ctx, 5, pathAddr); + setRegU32(test.ctx, 6, 1u); // second layer + ps2_stubs::sceCdLayerSearchFile(test.rdram.data(), &test.ctx, nullptr); + + t.Equals(getRegS32(&test.ctx, 2), 1, "sceCdLayerSearchFile should resolve the extracted file"); + t.Equals(readGuestU32(test.rdram.data(), fileAddr + 4), 6u, + "sceCdLayerSearchFile should report the host file size"); + }); + tc.Run("sceCdRead reads from explicit cdImage path", [](TestCase &t) { TestContext test; From 89bda683049c7fd01dded9e3ad97041ca98ce731 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:06:21 -0300 Subject: [PATCH 02/91] Load sceSifLoadModuleBuffer images from IOP RAM before EE RAM --- ps2xIOP/src/emulator/iop_emulator.cpp | 10 +++++++- .../emulator/services/iop_module_loader.cpp | 23 +++++++++++++++---- .../src/emulator/services/iop_module_loader.h | 1 + ps2xIOP/tests/iop_emulator_tests.cpp | 14 +++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index e5f63a9fe2..e39d33ccaa 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -655,8 +655,16 @@ namespace ps2x::iop::detail ModuleLoadResult loadModuleBuffer(uint32_t guestAddress, const void *arguments, uint32_t argumentSize) { std::vector image; - if (!IopModuleLoader::readElfFromGuest(host, guestAddress, image)) + // sceSifLoadModuleBuffer takes an IOP address the EE filled with sceSifSetDma; + // EE memory remains a fallback for hosts that stage the image there. + if (!IopModuleLoader::readElfFromIopRam(memory, guestAddress, image) && + !IopModuleLoader::readElfFromGuest(host, guestAddress, image)) + { + std::ostringstream out; + out << "[IOP] no ELF image at buffer 0x" << std::hex << guestAddress << " in IOP or EE memory"; + log(LogLevel::Warning, out.str()); return {true, -1, -1}; + } std::ostringstream tag; tag << "buffer@0x" << std::hex << guestAddress; return loadImage(tag.str(), image, arguments, argumentSize); diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.cpp b/ps2xIOP/src/emulator/services/iop_module_loader.cpp index 80e59d6bc3..bf2a757dc2 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.cpp +++ b/ps2xIOP/src/emulator/services/iop_module_loader.cpp @@ -320,10 +320,11 @@ namespace ps2x::iop::detail return ok; } - bool IopModuleLoader::readElfFromGuest(IopHost &host, uint32_t guestAddress, std::vector &bytes) + template + static bool readElfImage(ReadFn &&read, uint32_t address, std::vector &bytes) { Elf32Ehdr header{}; - if (!host.readGuest(guestAddress, &header, sizeof(header)) || !validElfHeader(header)) + if (!read(address, &header, sizeof(header)) || !validElfHeader(header)) return false; uint64_t required = sizeof(header); @@ -334,7 +335,7 @@ namespace ps2x::iop::detail return false; // Should we log an error here? TODO check later bytes.resize(static_cast(required)); - if (!host.readGuest(guestAddress, bytes.data(), bytes.size())) + if (!read(address, bytes.data(), bytes.size())) return false; if (header.shnum != 0u && header.shentsize >= sizeof(Elf32Shdr)) @@ -364,7 +365,21 @@ namespace ps2x::iop::detail return false; bytes.resize(static_cast(required)); - return host.readGuest(guestAddress, bytes.data(), bytes.size()); + return read(address, bytes.data(), bytes.size()); + } + + bool IopModuleLoader::readElfFromGuest(IopHost &host, uint32_t guestAddress, std::vector &bytes) + { + return readElfImage([&host](uint32_t address, void *destination, size_t size) + { return host.readGuest(address, destination, size); }, + guestAddress, bytes); + } + + bool IopModuleLoader::readElfFromIopRam(const IopMemory &memory, uint32_t iopAddress, std::vector &bytes) + { + return readElfImage([&memory](uint32_t address, void *destination, size_t size) + { return memory.readRam(address, destination, size); }, + iopAddress, bytes); } IopImageLoadResult IopModuleLoader::load(std::span image, IopMemory &memory, uint32_t moduleCursor) diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.h b/ps2xIOP/src/emulator/services/iop_module_loader.h index 6cd9ef6c18..045c0dff83 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.h +++ b/ps2xIOP/src/emulator/services/iop_module_loader.h @@ -43,6 +43,7 @@ namespace ps2x::iop::detail public: [[nodiscard]] static bool readWholeHostFile(IopHost &host, std::string_view guestPath, std::vector &bytes); [[nodiscard]] static bool readElfFromGuest(IopHost &host, uint32_t guestAddress, std::vector &bytes); + [[nodiscard]] static bool readElfFromIopRam(const IopMemory &memory, uint32_t iopAddress, std::vector &bytes); [[nodiscard]] static IopImageLoadResult load(std::span image, IopMemory &memory, uint32_t moduleCursor); }; } diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index 44c2494993..87c0fff7a8 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -911,6 +911,20 @@ int main() if (!expect(stopResult == 0, "Emulated module stop result mismatch")) return 1; if (!expect(iop.debugSnapshot().emulatorLoadedModules == 0u, "Module was not released")) return 1; + // Retail games sceSifSetDma the IRX into IOP RAM and pass that IOP address to sceSifLoadModuleBuffer. + constexpr uint32_t irxStageSize = 0x200u; + writeMinimalIrx(host, 0x100u); + const uint32_t iopStage = iop.allocateMemory(irxStageSize); + if (!expect(iopStage != 0u, "IOP staging allocation failed")) return 1; + if (!expect(iop.writeMemory(iopStage, host.guest.data() + 0x100u, irxStageSize), + "Copying the IRX into IOP RAM failed")) return 1; + std::memset(host.guest.data() + 0x100u, 0, irxStageSize); + const ModuleLoadResult iopStaged = iop.loadModuleBuffer(iopStage); + if (!expect(iopStaged.handled && iopStaged.moduleId > 0 && iopStaged.startResult == 7, + "IRX staged in IOP RAM did not load")) return 1; + if (!expect(iop.stopModule(iopStaged.moduleId), "IOP-staged module stop failed")) return 1; + if (!expect(iop.freeMemory(iopStage), "IOP staging free failed")) return 1; + constexpr uint32_t rpcSid = 0xF00DCAFEu; writeRpcServerIrx(host, 0x100u); const ModuleLoadResult rpcModule = iop.loadModuleBuffer(0x100u); From c511e4846db06e8b308c99b4732671fc99dbdfe3 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:06:21 -0300 Subject: [PATCH 03/91] Add sceSifSearchModuleByAddress backed by the IOP module table --- ps2xIOP/include/ps2x/iop/iop_subsystem.h | 2 ++ ps2xIOP/src/emulator/iop_emulator.cpp | 11 +++++++++++ ps2xIOP/src/emulator/iop_emulator.h | 1 + ps2xIOP/src/iop_subsystem.cpp | 5 +++++ ps2xIOP/tests/iop_emulator_tests.cpp | 8 ++++++++ ps2xRecomp/tools/ghidra/ExportPS2Functions.java | 2 +- ps2xRuntime/include/ps2_call_list.h | 1 + ps2xRuntime/include/ps2_runtime.h | 1 + ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp | 8 ++++++++ ps2xRuntime/src/lib/Kernel/Stubs/SIF.h | 1 + ps2xRuntime/src/lib/ps2_runtime.cpp | 5 +++++ 11 files changed, 44 insertions(+), 1 deletion(-) diff --git a/ps2xIOP/include/ps2x/iop/iop_subsystem.h b/ps2xIOP/include/ps2x/iop/iop_subsystem.h index 68fec81cfb..3c39b7eeb4 100644 --- a/ps2xIOP/include/ps2x/iop/iop_subsystem.h +++ b/ps2xIOP/include/ps2x/iop/iop_subsystem.h @@ -26,6 +26,8 @@ namespace ps2x::iop [[nodiscard]] ModuleLoadResult loadModule(std::string_view path, const void *arguments = nullptr, uint32_t argumentSize = 0); [[nodiscard]] ModuleLoadResult loadModuleBuffer(uint32_t guestAddress, const void *arguments = nullptr, uint32_t argumentSize = 0); [[nodiscard]] bool stopModule(int32_t moduleId, int32_t *result = nullptr); + // Returns the id of the physical IRX whose image contains the IOP address, or -1. + [[nodiscard]] int32_t findModuleByAddress(uint32_t iopAddress) const noexcept; void runEeCycles(uint64_t eeCycles) noexcept; [[nodiscard]] RpcAbi selectRpcAbi(const RpcAbiRequest &request) const; diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index e39d33ccaa..13af0a806a 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -747,6 +747,17 @@ namespace ps2x::iop::detail return m_impl->stopModule(moduleId, result); } + int32_t IopEmulator::findModuleByAddress(uint32_t address) const noexcept + { + const uint32_t phys = address & 0x1FFFFFFFu; + for (const auto &[id, module] : m_impl->modules) + { + if (phys >= module.base && phys - module.base < module.size) + return id; + } + return -1; + } + void IopEmulator::runEeCycles(uint64_t eeCycles) noexcept { const uint64_t total = m_impl->eeCycleCarry + eeCycles; diff --git a/ps2xIOP/src/emulator/iop_emulator.h b/ps2xIOP/src/emulator/iop_emulator.h index 01b48df574..d016e0eeb9 100644 --- a/ps2xIOP/src/emulator/iop_emulator.h +++ b/ps2xIOP/src/emulator/iop_emulator.h @@ -24,6 +24,7 @@ namespace ps2x::iop::detail [[nodiscard]] ModuleLoadResult loadModule(std::string_view path, const void *arguments, uint32_t argumentSize); [[nodiscard]] ModuleLoadResult loadModuleBuffer(uint32_t guestAddress, const void *arguments, uint32_t argumentSize); [[nodiscard]] bool stopModule(int32_t moduleId, int32_t *result); + [[nodiscard]] int32_t findModuleByAddress(uint32_t address) const noexcept; void runEeCycles(uint64_t eeCycles) noexcept; [[nodiscard]] RpcResult handleRpc(const RpcRequest &request); [[nodiscard]] bool hasRpcServer(uint32_t sid) const noexcept; diff --git a/ps2xIOP/src/iop_subsystem.cpp b/ps2xIOP/src/iop_subsystem.cpp index b498155409..58ae756250 100644 --- a/ps2xIOP/src/iop_subsystem.cpp +++ b/ps2xIOP/src/iop_subsystem.cpp @@ -164,6 +164,11 @@ namespace ps2x::iop return true; } + int32_t IopSubsystem::findModuleByAddress(uint32_t iopAddress) const noexcept + { + return m_impl->emulator.findModuleByAddress(iopAddress); + } + void IopSubsystem::runEeCycles(uint64_t eeCycles) noexcept { m_impl->emulator.runEeCycles(eeCycles); diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index 87c0fff7a8..d2405a8e8b 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -922,7 +922,15 @@ int main() const ModuleLoadResult iopStaged = iop.loadModuleBuffer(iopStage); if (!expect(iopStaged.handled && iopStaged.moduleId > 0 && iopStaged.startResult == 7, "IRX staged in IOP RAM did not load")) return 1; + if (!expect(iop.findModuleByAddress(0x00010004u) == iopStaged.moduleId, + "Module lookup missed an address inside the loaded IRX")) return 1; + if (!expect(iop.findModuleByAddress(0x80010004u) == iopStaged.moduleId, + "Module lookup must accept KSEG0 IOP addresses")) return 1; + if (!expect(iop.findModuleByAddress(0x00018000u) == -1, + "Module lookup matched an address outside the IRX image")) return 1; if (!expect(iop.stopModule(iopStaged.moduleId), "IOP-staged module stop failed")) return 1; + if (!expect(iop.findModuleByAddress(0x00010004u) == -1, + "Module lookup still matched a stopped IRX")) return 1; if (!expect(iop.freeMemory(iopStage), "IOP staging free failed")) return 1; constexpr uint32_t rpcSid = 0xF00DCAFEu; diff --git a/ps2xRecomp/tools/ghidra/ExportPS2Functions.java b/ps2xRecomp/tools/ghidra/ExportPS2Functions.java index c3e7d42016..8e28151884 100644 --- a/ps2xRecomp/tools/ghidra/ExportPS2Functions.java +++ b/ps2xRecomp/tools/ghidra/ExportPS2Functions.java @@ -185,7 +185,7 @@ public class ExportPS2Functions extends GhidraScript { "sceSifGetDataTable", "sceSifGetIopAddr", "sceSifGetNextRequest", "sceSifGetOtherData", "sceSifGetReg", "sceSifGetSreg", "sceSifInitCmd", "sceSifInitIopHeap", "sceSifInitRpc", "sceSifIsAliveIop", "sceSifLoadFileReset", "sceSifLoadIopHeap", "sceSifRebootIop", "sceSifRegisterRpc", "sceSifRemoveCmdHandler", "sceSifRemoveRpc", "sceSifRemoveRpcQueue", "sceSifResetIop", - "sceSifRpcLoop", "sceSifSetCmdBuffer", "sceSifSetDChain", "sceSifSetDma", "isceSifSetDChain", "isceSifSetDma", + "sceSifRpcLoop", "sceSifSearchModuleByAddress", "sceSifSetCmdBuffer", "sceSifSetDChain", "sceSifSetDma", "isceSifSetDChain", "isceSifSetDma", "sceSifSetIopAddr", "sceSifSetReg", "sceSifSetRpcQueue", "sceSifSetSreg", "sceSifSetSysCmdBuffer", "sceSifStopDma", "sceSifSyncIop", "sceSifWriteBackDCache", "sceSynthSizerLfoTriangle", "sceSynthesizerAmpProcI", "sceSynthesizerAmpProcNI", "sceSynthesizerAssignAllNoteOff", "sceSynthesizerAssignAllSoundOff", "sceSynthesizerAssignHoldChange", "sceSynthesizerAssignNoteOff", "sceSynthesizerAssignNoteOn", "sceSynthesizerCalcEnv", "sceSynthesizerCalcPortamentPitch", diff --git a/ps2xRuntime/include/ps2_call_list.h b/ps2xRuntime/include/ps2_call_list.h index 78e1b641eb..666ccc0f2f 100644 --- a/ps2xRuntime/include/ps2_call_list.h +++ b/ps2xRuntime/include/ps2_call_list.h @@ -550,6 +550,7 @@ X(sceSifRemoveRpcQueue) \ X(sceSifResetIop) \ X(sceSifRpcLoop) \ + X(sceSifSearchModuleByAddress) \ X(sceSifSetCmdBuffer) \ X(sceSifSetDChain) \ X(sceSifSetDma) \ diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index 4b9242cb4c..d81f628684 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -293,6 +293,7 @@ class PS2Runtime [[nodiscard]] ps2x::iop::ModuleLoadResult loadIopModule(std::string_view path, const void *arguments = nullptr, uint32_t argumentSize = 0); [[nodiscard]] ps2x::iop::ModuleLoadResult loadIopModuleBuffer(uint32_t guestAddress, const void *arguments = nullptr, uint32_t argumentSize = 0); [[nodiscard]] bool stopIopModule(int32_t moduleId, int32_t *result = nullptr); + [[nodiscard]] int32_t findIopModuleByAddress(uint32_t iopAddress) const noexcept; [[nodiscard]] ps2x::iop::DebugSnapshot iopDebugSnapshot() const; uint32_t allocateIopMemory(uint32_t size, uint32_t alignment = 16u); bool freeIopMemory(uint32_t address); diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp index dc975edb5a..21b76594f4 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp @@ -597,6 +597,14 @@ namespace ps2_stubs setReturnS32(ctx, 0); } + void sceSifSearchModuleByAddress(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + // The guest libkernel version asks LOADFILE over RPC, a BIOS module no BIOS-free IOP registers. + constexpr int32_t kUnknownModule = -202; // KE_UNKNOWN_MODULE, as loadcore reports a miss + const int32_t moduleId = runtime ? runtime->findIopModuleByAddress(getRegU32(ctx, 4)) : -1; + setReturnS32(ctx, moduleId > 0 ? moduleId : kUnknownModule); + } + void sceSifSetCmdBuffer(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { const uint32_t newBuffer = getRegU32(ctx, 4); diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/SIF.h b/ps2xRuntime/src/lib/Kernel/Stubs/SIF.h index 8673e80dd3..e1efb2ec15 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/SIF.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/SIF.h @@ -42,6 +42,7 @@ namespace ps2_stubs void sceSifRemoveRpcQueue(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceSifResetIop(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceSifRpcLoop(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void sceSifSearchModuleByAddress(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceSifSetCmdBuffer(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void isceSifSetDChain(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void isceSifSetDma(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 45cb9d267e..e561e3ee51 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -587,6 +587,11 @@ ps2x::iop::RpcAbi PS2Runtime::selectIopRpcAbi(const ps2x::iop::RpcAbiRequest &re return m_iopSubsystem->selectRpcAbi(request); } +int32_t PS2Runtime::findIopModuleByAddress(uint32_t iopAddress) const noexcept +{ + return m_iopSubsystem->findModuleByAddress(iopAddress); +} + bool PS2Runtime::canBindIopRpc(uint32_t sid) const noexcept { return m_iopSubsystem->canBindRpc(sid); From 1b873177018b04a1b2735788b89bac38b0f14947 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:45:19 -0300 Subject: [PATCH 04/91] Share one first-fit IOP arena between modules and heap allocations --- ps2xIOP/src/emulator/core/iop_memory.cpp | 49 ++++++++++--------- ps2xIOP/src/emulator/core/iop_memory.h | 7 +-- ps2xIOP/src/emulator/iop_emulator.cpp | 9 ++-- .../emulator/services/iop_module_loader.cpp | 25 ++++++++-- .../src/emulator/services/iop_module_loader.h | 4 +- ps2xIOP/tests/iop_emulator_tests.cpp | 18 ++++++- ps2xTest/src/ps2_sif_dma_tests.cpp | 2 +- 7 files changed, 74 insertions(+), 40 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index 25e9b36d19..cdc2ad3641 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -32,7 +32,6 @@ namespace ps2x::iop::detail std::fill(m_scratch.begin(), m_scratch.end(), uint8_t{0}); m_hardware.clear(); m_allocations.clear(); - m_heapCursor = HeapBase; m_interruptStatus = 0; m_interruptMask = 0; m_interruptControl = 1; @@ -286,41 +285,35 @@ namespace ps2x::iop::detail { size = alignUp(std::max(size, 1u), 16u); alignment = std::max(alignment, 4u); + uint32_t address = 0u; if (fixed) { - const uint32_t address = *fixed; - if (address < HeapBase || address + size > HeapLimit) + address = *fixed; + if (address < HeapBase || address > HeapLimit || size > HeapLimit - address) return 0u; for (const auto &block : m_allocations) if (address < block.address + block.size && block.address < address + size) return 0u; - m_allocations.push_back({address, size}); - markOwned(address, size); - return address; } - - uint32_t candidate = alignUp(m_heapCursor, alignment); - for (;;) + else { - bool overlap = false; + // First fit over address-ordered blocks, so freed IRX staging buffers are reused like sysmem does. + address = alignUp(HeapBase, alignment); for (const auto &block : m_allocations) { - if (candidate < block.address + block.size && block.address < candidate + size) - { - candidate = alignUp(block.address + block.size, alignment); - overlap = true; + if (address < block.address && size <= block.address - address) break; - } + address = std::max(address, alignUp(block.address + block.size, alignment)); } - if (!overlap) - break; + if (address > HeapLimit || size > HeapLimit - address) + return 0u; } - if (candidate > HeapLimit || size > HeapLimit - candidate) - return 0u; - m_allocations.push_back({candidate, size}); - markOwned(candidate, size); - m_heapCursor = std::max(m_heapCursor, candidate + size); - return candidate; + const auto position = std::lower_bound(m_allocations.begin(), m_allocations.end(), address, + [](const Allocation &block, uint32_t value) + { return block.address < value; }); + m_allocations.insert(position, {address, size}); + markOwned(address, size); + return address; } bool IopMemory::freeAllocation(uint32_t address) @@ -339,7 +332,15 @@ namespace ps2x::iop::detail uint32_t IopMemory::maxFreeMemory() const { - return m_heapCursor < HeapLimit ? HeapLimit - m_heapCursor : 0u; + uint32_t largest = 0u; + uint32_t cursor = HeapBase; + for (const auto &block : m_allocations) + { + if (block.address > cursor) + largest = std::max(largest, block.address - cursor); + cursor = std::max(cursor, block.address + block.size); + } + return cursor < HeapLimit ? std::max(largest, HeapLimit - cursor) : largest; } std::optional IopMemory::allocationContaining(uint32_t address) const diff --git a/ps2xIOP/src/emulator/core/iop_memory.h b/ps2xIOP/src/emulator/core/iop_memory.h index a932a5d87b..d41b71c829 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.h +++ b/ps2xIOP/src/emulator/core/iop_memory.h @@ -22,7 +22,9 @@ namespace ps2x::iop::detail static constexpr uint32_t Spu2End = 0x1FA00000u; static constexpr uint32_t SifBase = 0x1D000000u; static constexpr uint32_t SifEnd = 0x1D001000u; - static constexpr uint32_t HeapBase = 0x00120000u; + // Modules and heap blocks share one arena, as under sysmem; a fixed split starved games that load + // few modules but allocate large IOP buffers (CRI PSJ asks for 0x41040 bytes after 13 IRX). + static constexpr uint32_t HeapBase = 0x00010000u; static constexpr uint32_t HeapLimit = 0x001F0000u; struct Allocation @@ -81,8 +83,7 @@ namespace ps2x::iop::detail std::vector m_owned; std::vector m_scratch; std::unordered_map m_hardware; - std::vector m_allocations; - uint32_t m_heapCursor = HeapBase; + std::vector m_allocations; // sorted by address uint32_t m_interruptStatus = 0; uint32_t m_interruptMask = 0; uint32_t m_interruptControl = 1; diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index 13af0a806a..3ef227ef38 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -78,6 +78,7 @@ namespace ps2x::iop::detail uint32_t entry = 0; uint32_t gp = 0; bool resident = false; + bool reserved = false; }; struct GuestCallback @@ -127,7 +128,6 @@ namespace ps2x::iop::detail pendingDmaInterrupts.clear(); pendingGuestCallbacks.clear(); nextModuleId = 1; - moduleCursor = kModuleLoadBase; totalCycles = 0; totalInstructions = 0; eeCycleCarry = 0; @@ -591,8 +591,7 @@ namespace ps2x::iop::detail ModuleLoadResult loadImage(std::string path, std::span image, const void *arguments, uint32_t argumentSize) { ModuleLoadResult result{true, -1, -1}; - const IopImageLoadResult loaded = IopModuleLoader::load(image, memory, moduleCursor); - moduleCursor = loaded.nextModuleCursor; + const IopImageLoadResult loaded = IopModuleLoader::load(image, memory); if (!loaded) { if (loaded.error == IopImageLoadError::InvalidElf) @@ -613,6 +612,7 @@ namespace ps2x::iop::detail module.size = loaded.size; module.entry = loaded.entry; module.gp = loaded.gp; + module.reserved = loaded.reserved; uint32_t args = 0u; if (arguments && argumentSize) @@ -679,6 +679,8 @@ namespace ps2x::iop::detail kernel.terminateThreadsInRange(it->second.base, it->second.size); rpc.removeServersInRange(it->second.base, it->second.size); imports.eraseRange(it->second.base, it->second.size); + if (it->second.reserved) + (void)memory.freeAllocation(it->second.base); modules.erase(it); kernel.cleanupDeadThreads(); if (result) @@ -706,7 +708,6 @@ namespace ps2x::iop::detail std::map pendingDmaInterrupts; std::multimap pendingGuestCallbacks; uint32_t nextModuleId = 1; - uint32_t moduleCursor = kModuleLoadBase; uint64_t totalCycles = 0; uint64_t totalInstructions = 0; uint64_t eeCycleCarry = 0; diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.cpp b/ps2xIOP/src/emulator/services/iop_module_loader.cpp index bf2a757dc2..aa454143de 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.cpp +++ b/ps2xIOP/src/emulator/services/iop_module_loader.cpp @@ -382,10 +382,9 @@ namespace ps2x::iop::detail iopAddress, bytes); } - IopImageLoadResult IopModuleLoader::load(std::span image, IopMemory &memory, uint32_t moduleCursor) + IopImageLoadResult IopModuleLoader::load(std::span image, IopMemory &memory) { IopImageLoadResult result; - result.nextModuleCursor = moduleCursor; if (image.size() < sizeof(Elf32Ehdr)) return result; @@ -442,22 +441,36 @@ namespace ps2x::iop::detail const bool relocate = header.type != ET_EXEC || maxVaddr > IopMemory::RamSize || (minVaddr < kModuleLoadBase && minVaddr != 0u); + // Releases the arena block if a later step rejects the image. + struct Reservation + { + IopMemory &memory; + uint32_t address = 0u; + ~Reservation() + { + if (address != 0u) + (void)memory.freeAllocation(address); + } + } reservation{memory}; + uint32_t base = 0u; int64_t delta = 0; if (relocate) { - base = alignUp(moduleCursor, 0x100u); - if (base + span >= IopMemory::HeapBase) + base = memory.allocate(span, 0x100u); + if (base == 0u) { result.error = IopImageLoadError::ArenaExhausted; return result; } + reservation.address = base; delta = static_cast(base) - minVaddr; - result.nextModuleCursor = base + span; } else { base = minVaddr; + // Linked-address images still load when their range is outside the arena or already taken. + reservation.address = memory.allocate(span, 4u, base); } if (hasLoad) @@ -571,6 +584,8 @@ namespace ps2x::iop::detail } result.error = IopImageLoadError::None; + result.reserved = reservation.address != 0u; + reservation.address = 0u; return result; } } diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.h b/ps2xIOP/src/emulator/services/iop_module_loader.h index 045c0dff83..a96b21f58e 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.h +++ b/ps2xIOP/src/emulator/services/iop_module_loader.h @@ -29,8 +29,8 @@ namespace ps2x::iop::detail uint32_t size = 0; uint32_t entry = 0; uint32_t gp = 0; - uint32_t nextModuleCursor = 0; bool relocationsComplete = true; + bool reserved = false; // base..base+size is an IopMemory allocation the caller frees on unload [[nodiscard]] explicit operator bool() const noexcept { @@ -44,6 +44,6 @@ namespace ps2x::iop::detail [[nodiscard]] static bool readWholeHostFile(IopHost &host, std::string_view guestPath, std::vector &bytes); [[nodiscard]] static bool readElfFromGuest(IopHost &host, uint32_t guestAddress, std::vector &bytes); [[nodiscard]] static bool readElfFromIopRam(const IopMemory &memory, uint32_t iopAddress, std::vector &bytes); - [[nodiscard]] static IopImageLoadResult load(std::span image, IopMemory &memory, uint32_t moduleCursor); + [[nodiscard]] static IopImageLoadResult load(std::span image, IopMemory &memory); }; } diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index d2405a8e8b..5ad99a0d1b 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -914,8 +914,11 @@ int main() // Retail games sceSifSetDma the IRX into IOP RAM and pass that IOP address to sceSifLoadModuleBuffer. constexpr uint32_t irxStageSize = 0x200u; writeMinimalIrx(host, 0x100u); + // Keep the stage clear of 0x10000, where the test IRX is linked. + const uint32_t stageGuard = iop.allocateMemory(0x1000u); const uint32_t iopStage = iop.allocateMemory(irxStageSize); - if (!expect(iopStage != 0u, "IOP staging allocation failed")) return 1; + if (!expect(stageGuard == 0x00010000u && iopStage != 0u, "IOP staging allocation failed")) return 1; + if (!expect(iop.freeMemory(stageGuard), "IOP stage guard free failed")) return 1; if (!expect(iop.writeMemory(iopStage, host.guest.data() + 0x100u, irxStageSize), "Copying the IRX into IOP RAM failed")) return 1; std::memset(host.guest.data() + 0x100u, 0, irxStageSize); @@ -928,10 +931,23 @@ int main() "Module lookup must accept KSEG0 IOP addresses")) return 1; if (!expect(iop.findModuleByAddress(0x00018000u) == -1, "Module lookup matched an address outside the IRX image")) return 1; + if (!expect(iop.allocateMemory(0x10u) != 0x00010000u, + "Heap allocation landed inside a loaded IRX image")) return 1; if (!expect(iop.stopModule(iopStaged.moduleId), "IOP-staged module stop failed")) return 1; if (!expect(iop.findModuleByAddress(0x00010004u) == -1, "Module lookup still matched a stopped IRX")) return 1; if (!expect(iop.freeMemory(iopStage), "IOP staging free failed")) return 1; + iop.reset(); + + // Modules and heap share one arena, and freed blocks are reused first-fit. + const uint32_t firstBlock = iop.allocateMemory(0x1000u); + const uint32_t secondBlock = iop.allocateMemory(0x1000u); + if (!expect(firstBlock != 0u && secondBlock > firstBlock, "IOP heap allocations failed")) return 1; + if (!expect(iop.freeMemory(firstBlock), "IOP heap free failed")) return 1; + if (!expect(iop.allocateMemory(0x800u) == firstBlock, "Freed IOP heap block was not reused")) return 1; + if (!expect(iop.allocateMemory(0x100000u) != 0u, + "IOP heap could not satisfy a 1 MiB allocation")) return 1; + iop.reset(); constexpr uint32_t rpcSid = 0xF00DCAFEu; writeRpcServerIrx(host, 0x100u); diff --git a/ps2xTest/src/ps2_sif_dma_tests.cpp b/ps2xTest/src/ps2_sif_dma_tests.cpp index aace83cbd6..233368d6fd 100644 --- a/ps2xTest/src/ps2_sif_dma_tests.cpp +++ b/ps2xTest/src/ps2_sif_dma_tests.cpp @@ -200,7 +200,7 @@ void register_ps2_sif_dma_tests() setRegU32(env.ctx, 4, kIopBlockSize); ps2_stubs::sceSifAllocIopHeap(env.rdram.data(), &env.ctx, &env.runtime); const uint32_t iopAddress = ::getRegU32(&env.ctx, 2); - t.IsTrue(iopAddress >= 0x00120000u && iopAddress < 0x00200000u, + t.IsTrue(iopAddress >= 0x00010000u && iopAddress < 0x00200000u, "sceSifAllocIopHeap should return an address in physical IOP RAM"); std::memset(env.rdram.data() + iopAddress, 0x5Au, payload.size()); From be7d152a75b5a1c7398b9428a6d444e942c401cc Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:05:28 -0300 Subject: [PATCH 05/91] Format vfprintf into guest newlib string FILEs and read va_list as doubleword slots --- .../src/lib/Kernel/Stubs/Helpers/Support.h | 16 +++---- ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp | 39 +++++++++++++++- ps2xTest/src/ps2_runtime_expansion_tests.cpp | 44 +++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index b1bb6d8515..98cb3e12d0 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -842,20 +842,20 @@ namespace { } + // EE GCC's EABI spills each variadic GPR as a doubleword, so va_arg steps 8 bytes even for int. uint32_t nextU32() { - uint32_t value = 0; - (void)tryReadWordFromGuest(m_rdram, m_runtime, m_curr, value); - m_curr += 4; - return value; + return static_cast(nextU64()); } uint64_t nextU64() { - m_curr = (m_curr + 7u) & ~7u; - const uint64_t low = nextU32(); - const uint64_t high = nextU32(); - return low | (high << 32); + uint32_t low = 0; + uint32_t high = 0; + (void)tryReadWordFromGuest(m_rdram, m_runtime, m_curr, low); + (void)tryReadWordFromGuest(m_rdram, m_runtime, m_curr + 4u, high); + m_curr += 8u; + return static_cast(low) | (static_cast(high) << 32); } private: diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp index d467c4b68e..94f46a4b03 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp @@ -45,6 +45,37 @@ namespace ps2_stubs return (offset < PS2_RAM_SIZE) ? (PS2_RAM_SIZE - offset) : 0u; } + // Guest newlib snprintf/vsprintf format through vfprintf into a stack FILE flagged __SSTR. + // Mirror __sprint: copy at most _w bytes to _p and advance both; the caller NUL-terminates. + bool writeGuestStringFile(uint8_t *rdram, PS2Runtime *runtime, uint32_t file, const std::string &text) + { + constexpr uint32_t kNewlibSstr = 0x0200u; + constexpr uint32_t kOffsetP = 0x0u; + constexpr uint32_t kOffsetW = 0x8u; + constexpr uint32_t kOffsetFlags = 0xCu; + uint32_t flagsWord = 0u; + uint32_t cursor = 0u; + uint32_t room = 0u; + if (file == 0u || + !tryReadWordFromGuest(rdram, runtime, file + kOffsetFlags, flagsWord) || + (flagsWord & kNewlibSstr) == 0u || + !tryReadWordFromGuest(rdram, runtime, file + kOffsetP, cursor) || + !tryReadWordFromGuest(rdram, runtime, file + kOffsetW, room)) + { + return false; + } + const uint32_t count = static_cast( + std::min(text.size(), static_cast(room) > 0 ? room : 0u)); + if (count != 0u && + !writeGuestBytes(rdram, runtime, cursor, reinterpret_cast(text.data()), count)) + { + return false; + } + const uint32_t nextCursor = cursor + count; + const uint32_t nextRoom = room - count; + return writeGuestBytes(rdram, runtime, file + kOffsetP, reinterpret_cast(&nextCursor), 4u) && + writeGuestBytes(rdram, runtime, file + kOffsetW, reinterpret_cast(&nextRoom), 4u); + } } void malloc(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -1127,7 +1158,13 @@ namespace ps2_stubs std::string rendered = formatPs2StringWithVaList(rdram, runtime, formatOwned.c_str(), va_list_addr); ret = std::fprintf(fp, "%s", rendered.c_str()); } - else + else if (format_addr != 0) + { + std::string rendered = formatPs2StringWithVaList(rdram, runtime, formatOwned.c_str(), va_list_addr); + if (writeGuestStringFile(rdram, runtime, file_handle, rendered)) + ret = static_cast(rendered.size()); + } + if (ret < 0) { std::cerr << "vfprintf error: Invalid file handle or format address." << " Handle: 0x" << std::hex << file_handle << " (file valid: " << (fp != nullptr) << ")" diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index 4772f3fb77..263e75fb79 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -994,6 +994,50 @@ void register_ps2_runtime_expansion_tests() "sprintf should return the rendered length"); }); + tc.Run("vfprintf fills guest newlib string FILEs from doubleword va_list slots", [](TestCase &t) + { + PS2Runtime runtime; + std::vector rdram(PS2_RAM_SIZE, 0u); + R5900Context ctx{}; + + // Layout of guest newlib snprintf's stack FILE: _p, _w and __SWR|__SSTR flags. + constexpr uint32_t kFileAddr = 0x00003000u; + constexpr uint32_t kDestAddr = 0x00003100u; + constexpr uint32_t kFormatAddr = 0x00003200u; + constexpr uint32_t kVaListAddr = 0x00003300u; + constexpr char kFormat[] = "%s/%02d_%d.bin"; + constexpr char kDir[] = "DATA"; + constexpr uint32_t kDirAddr = 0x00003280u; + constexpr uint32_t kRoom = 8u; + + std::memcpy(rdram.data() + kFormatAddr, kFormat, sizeof(kFormat)); + std::memcpy(rdram.data() + kDirAddr, kDir, sizeof(kDir)); + const uint32_t fileWords[] = {kDestAddr, 0u, kRoom, 0x0208u}; + std::memcpy(rdram.data() + kFileAddr, fileWords, sizeof(fileWords)); + const uint64_t slots[] = {kDirAddr, 7u, 3u}; + std::memcpy(rdram.data() + kVaListAddr, slots, sizeof(slots)); + std::memset(rdram.data() + kDestAddr, 0xAA, 16u); + + setRegU32(ctx, 4, kFileAddr); + setRegU32(ctx, 5, kFormatAddr); + setRegU32(ctx, 6, kVaListAddr); + ps2_stubs::vfprintf(rdram.data(), &ctx, &runtime); + + const std::string full = "DATA/07_3.bin"; + t.Equals(getRegS32(ctx, 2), static_cast(full.size()), + "vfprintf should return the full rendered length"); + t.Equals(std::string(reinterpret_cast(rdram.data() + kDestAddr), kRoom), + full.substr(0, kRoom), "vfprintf should copy at most _w bytes to _p"); + t.Equals(rdram[kDestAddr + kRoom], static_cast(0xAAu), + "vfprintf must not write past _w; the caller NUL-terminates"); + uint32_t cursor = 0u; + uint32_t room = 0u; + std::memcpy(&cursor, rdram.data() + kFileAddr, 4u); + std::memcpy(&room, rdram.data() + kFileAddr + 8u, 4u); + t.Equals(cursor, kDestAddr + kRoom, "vfprintf should advance _p"); + t.Equals(room, 0u, "vfprintf should consume _w"); + }); + tc.Run("multiply-add matrix writes rd only when R5900 requires it", [](TestCase &t) { R5900Decoder decoder; From cde92835ef4359f4c56cd9b12ebbb7370a3b0b8d Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:52:12 -0300 Subject: [PATCH 06/91] Return the previous priority from ChangeThreadPriority --- .../src/lib/Kernel/Syscalls/Thread.cpp | 3 ++- ps2xTest/src/ps2_runtime_kernel_tests.cpp | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp b/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp index 5110ee2c25..0216b3176a 100644 --- a/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp +++ b/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp @@ -112,7 +112,8 @@ namespace ps2_syscalls const int priority = static_cast(getRegU32(ctx, 5)); int oldPriority = 0; const int result = ee.changePriority(id, priority, interruptSafe, oldPriority); - setReturnS32(ctx, result); + // The EE kernel returns the previous priority; middleware (CRI ADX) restores it after locking. + setReturnS32(ctx, result == KE_OK ? oldPriority : result); ee.transferIfRequested(interruptSafe); } diff --git a/ps2xTest/src/ps2_runtime_kernel_tests.cpp b/ps2xTest/src/ps2_runtime_kernel_tests.cpp index 63b12d9278..98c1146aa2 100644 --- a/ps2xTest/src/ps2_runtime_kernel_tests.cpp +++ b/ps2xTest/src/ps2_runtime_kernel_tests.cpp @@ -727,6 +727,31 @@ void register_ps2_runtime_kernel_tests() "the resumed waiter should re-enter its priority queue once"); }); + tc.Run("ChangeThreadPriority returns the previous priority", [](TestCase &t) + { + TestEnv env; + EeScheduler &ee = env.runtime.eeScheduler(); + ee.reset(env.rdram.data(), env.ctx); + ee.bindMainContextForSyscall(env.ctx, env.rdram.data()); + + // CRI ADX locks with old = ChangeThreadPriority(self, 1) and unlocks with ChangeThreadPriority(self, old). + const int id = ee.createThread(EeThreadCreateParams{0u, K_SCHED_HIGH, 0x24000u, 0x800u, 0u, 20, 0u}); + ee.startThread(id, 0u, env.ctx, false); + setRegU32(env.ctx, 4, static_cast(id)); + setRegU32(env.ctx, 5, 30u); + ps2_syscalls::ChangeThreadPriority(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegS32(env.ctx, 2), 20, "ChangeThreadPriority should return the priority it replaced"); + setRegU32(env.ctx, 4, static_cast(id)); + setRegU32(env.ctx, 5, 25u); + ps2_syscalls::ChangeThreadPriority(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegS32(env.ctx, 2), 30, "a second change should return the first change's priority"); + t.Equals(ee.thread(id)->currentPriority, 25, "the new priority should still be applied"); + setRegU32(env.ctx, 4, 0x7FFFu); + setRegU32(env.ctx, 5, 25u); + ps2_syscalls::ChangeThreadPriority(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegS32(env.ctx, 2), KE_UNKNOWN_THID, "an unknown thread should still return an error code"); + }); + tc.Run("semaphore signal, delete, and release complete blocked contexts with exact results", [](TestCase &t) { const auto blockMain = [](TestEnv &env, int &semaId) From d660bd753c363216a060ae72a4f2d03427bc7a32 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:54:02 -0300 Subject: [PATCH 07/91] Keep async callback stacks below a top-of-RAM main thread stack --- ps2xRuntime/include/ps2_runtime.h | 2 ++ .../src/lib/Kernel/Syscalls/System.cpp | 6 ++++++ ps2xRuntime/src/lib/ps2_runtime.cpp | 17 +++++++++++++++ ps2xTest/src/ps2_runtime_kernel_tests.cpp | 21 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index d81f628684..86639c7d83 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -385,6 +385,8 @@ class PS2Runtime uint32_t guestHeapEnd() const; uint32_t guestHeapLimit() const; uint32_t reserveAsyncCallbackStack(uint32_t size, uint32_t alignment = 16u); + // Keeps async callback stacks out of [base, end), e.g. a main-thread stack placed at the top of RAM. + void excludeAsyncCallbackStackRange(uint32_t base, uint32_t end); void drainCompletedDmacHandlers(uint8_t *rdram); diff --git a/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp b/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp index 961401614d..401e53e714 100644 --- a/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp +++ b/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp @@ -531,6 +531,12 @@ namespace ps2_syscalls } scheduler.setupCurrentThread(initialStack, stackSize, getRegU32(ctx, 28)); + if (stack != 0u && stackSize != 0u && stackSize < PS2_RAM_SIZE) + { + // crt0 often places the main stack in the top of RAM, where callback stacks are carved from. + const uint32_t stackBase = stack == 0xFFFFFFFFu ? PS2_RAM_SIZE - stackSize : stack; + runtime->excludeAsyncCallbackStackRange(stackBase, stackBase + stackSize); + } setReturnU32(ctx, sp); } diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index e561e3ee51..bfc18ee6d1 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -2018,6 +2018,23 @@ uint32_t PS2Runtime::reserveAsyncCallbackStack(uint32_t size, uint32_t alignment return top - 0x10u; } +void PS2Runtime::excludeAsyncCallbackStackRange(uint32_t base, uint32_t end) +{ + std::lock_guard lock(m_asyncCallbackStackMutex); + if (end <= m_asyncCallbackStackFloor || base >= m_asyncCallbackStackTop) + { + return; + } + if (base < m_asyncCallbackStackFloor) + { + std::cerr << "[runtime] stack range 0x" << std::hex << base << "-0x" << end + << " covers the async callback stack area starting at 0x" << m_asyncCallbackStackFloor + << std::dec << "; callbacks may corrupt it" << std::endl; + return; + } + m_asyncCallbackStackTop = base & ~(kGuestHeapDefaultAlignment - 1u); +} + uint8_t PS2Runtime::Load8(uint8_t *rdram, R5900Context *ctx, uint32_t vaddr) { try diff --git a/ps2xTest/src/ps2_runtime_kernel_tests.cpp b/ps2xTest/src/ps2_runtime_kernel_tests.cpp index 98c1146aa2..2328c7ea9a 100644 --- a/ps2xTest/src/ps2_runtime_kernel_tests.cpp +++ b/ps2xTest/src/ps2_runtime_kernel_tests.cpp @@ -727,6 +727,27 @@ void register_ps2_runtime_kernel_tests() "the resumed waiter should re-enter its priority queue once"); }); + tc.Run("SetupThread keeps async callback stacks out of a top-of-RAM main stack", [](TestCase &t) + { + TestEnv env; + EeScheduler &ee = env.runtime.eeScheduler(); + ee.reset(env.rdram.data(), env.ctx); + ee.bindMainContextForSyscall(env.ctx, env.rdram.data()); + + // Mana Khemia's crt0: SetupThread(gp, 0x01FC0000, 0x40000, ...). + constexpr uint32_t kStack = 0x01FC0000u; + setRegU32(env.ctx, 4, 0x00BAE3F0u); + setRegU32(env.ctx, 5, kStack); + setRegU32(env.ctx, 6, 0x40000u); + ps2_syscalls::SetupThread(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(static_cast(getRegS32(env.ctx, 2)), PS2_RAM_SIZE, + "SetupThread should still return the top of the requested stack"); + + const uint32_t callbackTop = env.runtime.reserveAsyncCallbackStack(0x4000u, 16u); + t.IsTrue(callbackTop != 0u && callbackTop <= kStack, + "callback stacks must be carved below the main thread stack"); + }); + tc.Run("ChangeThreadPriority returns the previous priority", [](TestCase &t) { TestEnv env; From e405abd60fa40a9e53b6349a879261edcd04b883 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:54:02 -0300 Subject: [PATCH 08/91] Warn when an EE thread exits by reaching pc=0 --- ps2xRuntime/src/lib/Kernel/EeScheduler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp index c5e31c48d8..748b41fe87 100644 --- a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp +++ b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -246,6 +247,11 @@ void EeScheduler::run() } continue; } + // Returning from the entry point is a normal exit, but a null call or corrupt $ra lands here too. + if (running->id > 0) + std::cerr << "[EeScheduler] thread " << running->id << " reached pc=0 from branch_pc=0x" << std::hex + << context.branch_pc << " ra=0x" << getRegU32(&context, 31) << std::dec + << "; treating as thread exit" << std::endl; makeDormant(*running); m_currentThreadId = 0; copyMainContextToRuntime(); From c9fc41ae2bd66010192a2938517088532f5bc38d Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:54:02 -0300 Subject: [PATCH 09/91] Read sceCdReadIOPm sectors into IOP RAM --- ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp | 18 ++++++++++- ps2xTest/src/ps2_runtime_io_tests.cpp | 42 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp index 974e10f7fc..f6f3e41e46 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/CD.cpp @@ -539,7 +539,23 @@ namespace ps2_stubs void sceCdReadIOPm(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - sceCdRead(rdram, ctx, runtime); + // The buffer is an IOP address; writing it into EE RAM corrupts the EE and leaves the IOP buffer stale. + const uint32_t lbn = getRegU32(ctx, 4); + const uint32_t sectors = getRegU32(ctx, 5); + const uint32_t iopBuffer = getRegU32(ctx, 6); + constexpr uint32_t kIopRamSize = 2u * 1024u * 1024u; + std::vector data(static_cast(sectors) * kCdSectorSize); + const bool ok = runtime && sectors != 0u && sectors <= kIopRamSize / kCdSectorSize && + readCdSectors(lbn, sectors, data.data(), data.size()) && + runtime->writeIopMemory(iopBuffer, data.data(), data.size()); + if (!ok) + { + std::cerr << "[sceCdReadIOPm] failed lbn=0x" << std::hex << lbn << " sectors=0x" << sectors + << " iopBuffer=0x" << iopBuffer << std::dec << std::endl; + } + if (ok) + g_cdStreamingLbn = lbn + sectors; + setReturnS32(ctx, ok ? 1 : 0); } void sceCdSearchFile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) diff --git a/ps2xTest/src/ps2_runtime_io_tests.cpp b/ps2xTest/src/ps2_runtime_io_tests.cpp index a3c91c4804..0c68479666 100644 --- a/ps2xTest/src/ps2_runtime_io_tests.cpp +++ b/ps2xTest/src/ps2_runtime_io_tests.cpp @@ -768,5 +768,47 @@ void register_ps2_runtime_io_tests() t.Equals(std::memcmp(test.rdram.data() + bufAddr, "cd-image", 8), 0, "sceCdRead should copy sector data from the configured image"); }); + + tc.Run("sceCdReadIOPm writes sectors into IOP RAM and leaves EE RAM alone", [](TestCase &t) + { + TestContext test; + PS2Runtime runtime; + + constexpr uint32_t kSectorSize = 2048u; + const std::filesystem::path imagePath = test.paths.base / "disc.iso"; + { + std::vector sector(kSectorSize, 0); + const char payload[] = "iop-read"; + std::memcpy(sector.data(), payload, sizeof(payload) - 1); + std::ofstream out(imagePath, std::ios::binary); + out.write(reinterpret_cast(sector.data()), static_cast(sector.size())); + } + + PS2Runtime::IoPaths ioPaths; + ioPaths.elfDirectory = test.paths.cdRoot; + ioPaths.hostRoot = test.paths.cdRoot; + ioPaths.cdRoot = test.paths.cdRoot; + ioPaths.mcRoot = test.paths.mcRoot; + ioPaths.cdImage = imagePath; + PS2Runtime::setIoPaths(ioPaths); + + const uint32_t iopBuffer = runtime.allocateIopMemory(kSectorSize, 64u); + t.IsTrue(iopBuffer != 0u, "the test needs an IOP buffer"); + std::memset(test.rdram.data() + iopBuffer, 0x5A, 8u); + + clearContext(test.ctx); + setRegU32(test.ctx, 4, 0u); + setRegU32(test.ctx, 5, 1u); + setRegU32(test.ctx, 6, iopBuffer); + ps2_stubs::sceCdReadIOPm(test.rdram.data(), &test.ctx, &runtime); + + char iopBytes[8] = {}; + t.Equals(getRegS32(&test.ctx, 2), 1, "sceCdReadIOPm should accept the read"); + t.IsTrue(runtime.readIopMemory(iopBuffer, iopBytes, sizeof(iopBytes)) && + std::memcmp(iopBytes, "iop-read", 8) == 0, + "sceCdReadIOPm should copy sector data into IOP RAM"); + t.Equals(test.rdram[iopBuffer], static_cast(0x5Au), + "sceCdReadIOPm must not write the IOP address into EE RAM"); + }); }); } From 41ded1f44559887d12e6c038c83651110d131f7c Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:54:03 -0300 Subject: [PATCH 10/91] Implement sceDmaRecvN as a normal-mode receive --- ps2xRuntime/src/lib/Kernel/Stubs/DMA.cpp | 2 +- .../src/lib/Kernel/Stubs/Helpers/Support.h | 31 ++++++++++++++++ ps2xTest/src/ps2_memory_tests.cpp | 35 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/DMA.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/DMA.cpp index b32e44a249..ae37fac8ae 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/DMA.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/DMA.cpp @@ -167,7 +167,7 @@ namespace ps2_stubs void sceDmaRecvN(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - TODO_NAMED("sceDmaRecvN", rdram, ctx, runtime); + setReturnS32(ctx, submitDmaRecvNormal(rdram, ctx, runtime)); } void sceDmaReset(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index 98cb3e12d0..5bb6b4e40c 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1437,6 +1437,37 @@ namespace return 0; } + // Normal-mode receive into memory (DIR=0), e.g. fromSPR; the DMAC model performs the copy when STR is set. + int32_t submitDmaRecvNormal(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + if (!runtime) + { + return -1; + } + + const uint32_t channelBase = resolveDmaChannelBase(rdram, getRegU32(ctx, 4)); + if (channelBase == 0) + { + return -1; + } + + PS2Memory &mem = runtime->memory(); + mem.writeIORegister(channelBase + 0x20u, normalizeQwcFromArg(getRegU32(ctx, 6)) & 0xFFFFu); + mem.writeIORegister(channelBase + 0x10u, toDmaPhys(getRegU32(ctx, 5))); + mem.writeIORegister(channelBase + 0x00u, 0x00000180u); // DIR=0, TIE=1, STR=1 (normal mode). + mem.processPendingTransfers(); + + { + std::lock_guard lock(g_dmaStubMutex); + g_dmaPendingPolls[channelBase] = 1; + } + for (const uint32_t completedCause : mem.consumeCompletedDmacCauses()) + { + ps2_syscalls::dispatchDmacHandlersForCause(rdram, runtime, completedCause); + } + return 0; + } + int32_t submitDmaSync(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { if (!runtime) diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index 0b22fe03c6..e1e8805d22 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -2044,6 +2044,41 @@ void register_ps2_memory_tests() "preserved TTE should deliver the tag high-half VIFcode"); }); + tc.Run("sceDmaRecvN copies scratchpad data back to RDRAM", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kFromSprCh = 0x1000D000u; + constexpr uint32_t kDest = 0x00028000u; + constexpr uint32_t kScratchOffset = 0x400u; + constexpr uint32_t kQwc = 3u; + + PS2Memory &mem = runtime.memory(); + uint8_t *rdram = mem.getRDRAM(); + uint8_t *scratch = mem.getScratchpad(); + for (uint32_t i = 0; i < kQwc * 16u; ++i) + { + scratch[kScratchOffset + i] = static_cast(0x30u + i); + } + std::memset(rdram + kDest, 0, kQwc * 16u + 16u); + + // Mana Khemia sets the channel's SADR, then calls sceDmaRecvN(fromSPR, dest, qwc). + t.IsTrue(mem.writeIORegister(kFromSprCh + 0x80u, kScratchOffset), "guest should be able to set SADR"); + R5900Context ctx{}; + setRegU32(ctx, 4, kFromSprCh); + setRegU32(ctx, 5, kDest); + setRegU32(ctx, 6, kQwc); + ps2_stubs::sceDmaRecvN(rdram, &ctx, &runtime); + + t.Equals(static_cast(::getRegU32(&ctx, 2)), 0, "sceDmaRecvN should accept the transfer"); + t.Equals(std::memcmp(rdram + kDest, scratch + kScratchOffset, kQwc * 16u), 0, + "sceDmaRecvN should copy QWC quadwords from SADR into RDRAM"); + t.Equals(rdram[kDest + kQwc * 16u], static_cast(0u), "sceDmaRecvN must not copy past QWC"); + t.IsTrue((mem.readIORegister(kFromSprCh + 0x00u) & 0x100u) == 0u, + "the fromSPR channel should finish (STR cleared)"); + }); + tc.Run("VIF1 DMA DIRECT image packet reaches GS through arbiter", [](TestCase &t) { PS2Memory mem; From 133c51efa0b6cd027e68f67d44e9c2f99ab75486 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:54:03 -0300 Subject: [PATCH 11/91] Synthesize wrappers for configured stubs the function scan missed --- ps2xRecomp/src/lib/ps2_recompiler.cpp | 29 ++++++++++++++++++ ps2xTest/src/ps2_recompiler_tests.cpp | 42 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/ps2xRecomp/src/lib/ps2_recompiler.cpp b/ps2xRecomp/src/lib/ps2_recompiler.cpp index 8c1c7c5e28..0827a88a33 100644 --- a/ps2xRecomp/src/lib/ps2_recompiler.cpp +++ b/ps2xRecomp/src/lib/ps2_recompiler.cpp @@ -1083,6 +1083,35 @@ namespace ps2recomp } } + // A configured stub whose routine was never discovered (e.g. only reached by a tail-call J) + // would otherwise get no wrapper, and any branch to it hits an unregistered address at runtime. + for (const auto &[stubStart, handlerName] : m_stubHandlerBindingsByStart) + { + if (resolveStubTarget(handlerName) == StubTarget::Unknown) + { + continue; + } + // Only recompiled guest code can already reach this address; a stubbed owner emits none. + const bool covered = std::any_of( + m_functions.begin(), m_functions.end(), + [&](const Function &function) + { + return stubStart >= function.start && + stubStart < std::max(function.end, function.start + 4u) && + (function.start == stubStart || !isStubFunction(function)); + }); + if (covered) + { + continue; + } + Function stubEntry{}; + stubEntry.name = handlerName; + stubEntry.start = stubStart; + stubEntry.end = stubStart + 4u; + m_functions.push_back(std::move(stubEntry)); + m_reporter.info("stub", "Synthesized entry for undiscovered stub '" + handlerName + "'"); + } + for (auto &function : m_functions) { m_reporter.recordFunctionProcessed(); diff --git a/ps2xTest/src/ps2_recompiler_tests.cpp b/ps2xTest/src/ps2_recompiler_tests.cpp index 201b71e8c3..65a0a243f4 100644 --- a/ps2xTest/src/ps2_recompiler_tests.cpp +++ b/ps2xTest/src/ps2_recompiler_tests.cpp @@ -1471,6 +1471,48 @@ void register_ps2_recompiler_tests() std::filesystem::remove_all(tempRoot, removeError); }); + tc.Run("configured stub at an undiscovered address still gets a registered wrapper", [](TestCase &t) { + const std::string uniqueSuffix = + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + const std::filesystem::path tempRoot = + std::filesystem::temp_directory_path() / ("ps2recomp-stub-synthesis-" + uniqueSuffix); + const std::filesystem::path elfPath = tempRoot / "stub-entry.elf"; + const std::filesystem::path configPath = tempRoot / "stub-entry.toml"; + const std::filesystem::path outputPath = tempRoot / "output"; + std::filesystem::create_directories(tempRoot); + + // Mana Khemia only tail-calls sceDmaSend with J, so discovery never sees its start. + const bool elfWritten = writeMinimalMipsElfWithUnmappedEntryHint(elfPath); + const bool configWritten = writeRecompilerTestConfig( + configPath, + elfPath, + outputPath, + {}, + {"InitAlarm@0x00100008", "sceDmaSend@0x00100010"}); + t.IsTrue(elfWritten && configWritten, "stub synthesis regression inputs should be generated"); + + if (elfWritten && configWritten) + { + PS2Recompiler recompiler(configPath.string()); + t.IsTrue(recompiler.initialize(), "stub synthesis config should initialize"); + t.IsTrue(recompiler.recompile(), "stub synthesis config should recompile"); + recompiler.generateOutput(); + + const std::filesystem::path registrationPath = outputPath / "register_functions.cpp"; + std::ifstream registrationFile(registrationPath); + const std::string registration{ + std::istreambuf_iterator(registrationFile), + std::istreambuf_iterator()}; + t.IsTrue(registration.find("// 0x100010") != std::string::npos, + "the undiscovered stub address should be registered for guest dispatch"); + t.IsTrue(recompiler.reportCounters().functionsStubbed >= 1u, + "the synthesized entry should be emitted as a stub"); + } + + std::error_code removeError; + std::filesystem::remove_all(tempRoot, removeError); + }); + tc.Run("standalone entry_ blocks register resume points for their JAL return addresses", [](TestCase &t) { const std::string uniqueSuffix = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); From 3e9bf09023ef7dee0126cd33589da124130f53f6 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:32:13 -0300 Subject: [PATCH 12/91] Treat DMAC addresses with bit 31 set as scratchpad offsets --- ps2xRuntime/include/runtime/ps2_memory.h | 10 ++++++ .../src/lib/Kernel/Stubs/Helpers/Support.h | 4 +++ ps2xRuntime/src/lib/ps2_memory.cpp | 13 ++++--- ps2xTest/src/ps2_memory_tests.cpp | 36 +++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/ps2xRuntime/include/runtime/ps2_memory.h b/ps2xRuntime/include/runtime/ps2_memory.h index ea86a9cb25..f96fa7836f 100644 --- a/ps2xRuntime/include/runtime/ps2_memory.h +++ b/ps2xRuntime/include/runtime/ps2_memory.h @@ -103,6 +103,16 @@ inline bool ps2IsScratchpadAddress(uint32_t addr) return false; } +// DMAC addresses (MADR, TADR, ASR, DMAtag ADDR) select scratchpad with bit 31 and carry its offset in the low bits. +inline uint32_t ps2DmacAddressToEe(uint32_t addr) +{ + if ((addr & 0x80000000u) != 0u && (addr & 0x7FFFFFFFu) < PS2_SCRATCHPAD_SIZE) + { + return PS2_SCRATCHPAD_BASE + (addr & (PS2_SCRATCHPAD_SIZE - 1u)); + } + return addr; +} + inline uint32_t ps2ScratchpadOffset(uint32_t addr) { if (addr >= PS2_SCRATCHPAD_BASE && addr < (PS2_SCRATCHPAD_BASE + PS2_SCRATCHPAD_SIZE)) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index 5bb6b4e40c..f318d1222b 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1239,6 +1239,10 @@ namespace uint32_t toDmaPhys(uint32_t addr) { + if (const uint32_t spr = ps2DmacAddressToEe(addr); spr != addr) + { + return spr; + } if ((addr & 0x80000000u) != 0) { uint32_t lower = addr & 0x7FFFFFFFu; diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index 37b6a4decc..bdbef474de 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -1302,7 +1302,7 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) } const uint32_t channelBase = address & 0xFFFFFF00; - const uint32_t madr = m_ioRegisters[channelBase + 0x10]; + const uint32_t madr = ps2DmacAddressToEe(m_ioRegisters[channelBase + 0x10]); const uint32_t qwc = m_ioRegisters[channelBase + 0x20]; m_dmaStartCount.fetch_add(1, std::memory_order_relaxed); @@ -1339,9 +1339,9 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) } else if (mode == 1) { - uint32_t tagAddr = m_ioRegisters[channelBase + 0x30]; - uint32_t asr0 = m_ioRegisters[channelBase + 0x40]; - uint32_t asr1 = m_ioRegisters[channelBase + 0x50]; + uint32_t tagAddr = ps2DmacAddressToEe(m_ioRegisters[channelBase + 0x30]); + uint32_t asr0 = ps2DmacAddressToEe(m_ioRegisters[channelBase + 0x40]); + uint32_t asr1 = ps2DmacAddressToEe(m_ioRegisters[channelBase + 0x50]); uint32_t asp = (chcr >> 4) & 0x3u; const bool tieEnabled = (chcr & (1u << 7)) != 0u; const int kMaxChainTags = 4096; @@ -1438,7 +1438,10 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) uint16_t tagQwc = static_cast(tag & 0xFFFF); uint32_t id = static_cast((tag >> 28) & 0x7); const bool irq = ((tag >> 31) & 0x1ull) != 0ull; - uint32_t addr = static_cast((tag >> 32) & 0x7FFFFFFF); + const uint32_t rawAddr = static_cast(tag >> 32); + uint32_t addr = ps2DmacAddressToEe(rawAddr); + if (addr == rawAddr) + addr &= 0x7FFFFFFFu; lastTagUpper = static_cast((tag >> 16) & 0xFFFFu); ++tagsProcessed; diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index e1e8805d22..683cea4dc0 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -1144,6 +1144,42 @@ void register_ps2_memory_tests() t.IsTrue(contentOk, "scratchpad alias chain payload should match scratchpad bytes"); }); + tc.Run("GIF DMA chain follows SPR-flagged tag and data addresses into scratchpad", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kGifCh = 0x1000A000u; + constexpr uint32_t kSprFlag = 0x80000000u; + + // Hardware form: bit 31 selects scratchpad, low bits are the offset (Mana Khemia builds GIF packets there). + uint8_t *scratch = mem.getScratchpad(); + uint8_t *rdram = mem.getRDRAM(); + std::memset(rdram, 0, 0x400u); + // makeDmaTag masks bit 31, so build the REFE tag (qwc=1, id=0) with the SPR flag directly. + const uint64_t refeTag = 1ull | (static_cast(kSprFlag | 0x200u) << 32); + std::memcpy(scratch + 0x100u, &refeTag, sizeof(refeTag)); + for (uint32_t i = 0; i < 16u; ++i) + { + scratch[0x200u + i] = static_cast(0x90u + i); + } + + std::vector> captured; + mem.setGifPacketCallback([&](const uint8_t *data, uint32_t sizeBytes) + { + captured.emplace_back(data, data + sizeBytes); + }); + + t.IsTrue(mem.writeIORegister(kGifCh + 0x30u, kSprFlag | 0x100u), "write SPR-flagged TADR should succeed"); + t.IsTrue(mem.writeIORegister(kGifCh + 0x00u, 0x104u), "write CHCR STR|CHAIN should succeed"); + mem.processPendingTransfers(); + + t.Equals(captured.size(), static_cast(1u), "SPR-flagged chain should emit one packet"); + t.IsTrue(!captured.empty() && captured[0].size() == 16u && + std::memcmp(captured[0].data(), scratch + 0x200u, 16u) == 0, + "SPR-flagged REFE data should come from scratchpad, not RDRAM"); + }); + tc.Run("native GIF image upload recognizes canonical load-image chain", [](TestCase &t) { PS2Memory mem; From e077bbba2bf289ba55577e9b72ff5e2567091874 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:21:29 -0300 Subject: [PATCH 13/91] Read PMFHL/PMTHL words from the matching HI/LO pipeline registers --- .../src/lib/mmi_translation_helpers.cpp | 12 +-- ps2xRuntime/include/ps2_runtime_macros.h | 78 +++++++++++++++++-- ps2xTest/src/ps2_runtime_expansion_tests.cpp | 44 +++++++++++ 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/ps2xRecomp/src/lib/mmi_translation_helpers.cpp b/ps2xRecomp/src/lib/mmi_translation_helpers.cpp index a2bcb777f6..aa2c32f44b 100644 --- a/ps2xRecomp/src/lib/mmi_translation_helpers.cpp +++ b/ps2xRecomp/src/lib/mmi_translation_helpers.cpp @@ -228,15 +228,15 @@ namespace ps2recomp switch (subfunc) { case PMFHL_LW: - return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_LW(ctx->hi, ctx->lo));", inst.rd); + return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_LW(ctx));", inst.rd); case PMFHL_UW: - return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_UW(ctx->hi, ctx->lo));", inst.rd); + return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_UW(ctx));", inst.rd); case PMFHL_SLW: - return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_SLW(ctx->hi, ctx->lo));", inst.rd); + return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_SLW(ctx));", inst.rd); case PMFHL_LH: - return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_LH(ctx->hi, ctx->lo));", inst.rd); + return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_LH(ctx));", inst.rd); case PMFHL_SH: - return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_SH(ctx->hi, ctx->lo));", inst.rd); + return fmt::format("SET_GPR_VEC(ctx, {}, PS2_PMFHL_SH(ctx));", inst.rd); default: return emitUnhandledInstruction(inst, fmt::format("Unhandled PMFHL instruction: function 0x{:X}", subfunc)); } @@ -249,7 +249,7 @@ namespace ps2recomp switch (subfunc) { case PMFHL_LW: - return fmt::format("{{ __m128i val = GPR_VEC(ctx, {}); ctx->lo = _mm_extract_epi32(val, 0); ctx->hi = _mm_extract_epi32(val, 1); }}", inst.rs); + return fmt::format("PS2_PMTHL_LW(ctx, GPR_VEC(ctx, {}));", inst.rs); default: return emitUnhandledInstruction(inst, fmt::format("Unhandled PMTHL instruction: function 0x{:X}", subfunc)); } diff --git a/ps2xRuntime/include/ps2_runtime_macros.h b/ps2xRuntime/include/ps2_runtime_macros.h index 0485d50060..65be538155 100644 --- a/ps2xRuntime/include/ps2_runtime_macros.h +++ b/ps2xRuntime/include/ps2_runtime_macros.h @@ -826,11 +826,79 @@ inline __m128i ps2_u64_to_epi64_pair(uint64_t value) return _mm_set1_epi64x(static_cast(value)); } -#define PS2_PMFHL_LW(hi, lo) _mm_unpacklo_epi64(ps2_u64_to_epi64_pair(lo), ps2_u64_to_epi64_pair(hi)) -#define PS2_PMFHL_UW(hi, lo) _mm_unpackhi_epi64(ps2_u64_to_epi64_pair(lo), ps2_u64_to_epi64_pair(hi)) -#define PS2_PMFHL_SLW(hi, lo) _mm_packs_epi32(ps2_u64_to_epi64_pair(lo), ps2_u64_to_epi64_pair(hi)) -#define PS2_PMFHL_LH(hi, lo) _mm_shuffle_epi32(_mm_packs_epi32(ps2_u64_to_epi64_pair(lo), ps2_u64_to_epi64_pair(hi)), _MM_SHUFFLE(3, 1, 2, 0)) -#define PS2_PMFHL_SH(hi, lo) _mm_shufflehi_epi16(_mm_shufflelo_epi16(_mm_packs_epi32(ps2_u64_to_epi64_pair(lo), ps2_u64_to_epi64_pair(hi)), _MM_SHUFFLE(3, 1, 2, 0)), _MM_SHUFFLE(3, 1, 2, 0)) +// PMFHL/PMTHL see LO and HI as 128-bit registers: pipeline 0 in bits 0-63 (lo/hi) and pipeline 1 in +// bits 64-127 (lo1/hi1). PMFHL.LW after MULTU is how EE code reads a full 64-bit product. +inline int16_t ps2_saturate_s16(uint32_t word) +{ + const int32_t value = static_cast(word); + return static_cast(value > 0x7FFF ? 0x7FFF : (value < -0x8000 ? -0x8000 : value)); +} + +inline int64_t ps2_pmfhl_slw_lane(uint64_t hi, uint64_t lo) +{ + const int64_t value = static_cast((static_cast(static_cast(hi)) << 32) | static_cast(lo)); + if (value >= 0x7FFFFFFFll) + return 0x7FFFFFFFll; + if (value <= -0x80000000ll) + return -0x80000000ll; + return static_cast(lo); +} + +template +inline __m128i ps2_pmfhl(const Ctx *ctx, int mode) +{ + const uint64_t lanes[4] = {ctx->lo, ctx->hi, ctx->lo1, ctx->hi1}; + switch (mode) + { + case 0: // LW + return _mm_set_epi32(static_cast(static_cast(lanes[3])), static_cast(static_cast(lanes[2])), + static_cast(static_cast(lanes[1])), static_cast(static_cast(lanes[0]))); + case 1: // UW + return _mm_set_epi32(static_cast(lanes[3] >> 32), static_cast(lanes[2] >> 32), + static_cast(lanes[1] >> 32), static_cast(lanes[0] >> 32)); + case 2: // SLW + return _mm_set_epi64x(ps2_pmfhl_slw_lane(ctx->hi1, ctx->lo1), ps2_pmfhl_slw_lane(ctx->hi, ctx->lo)); + case 3: // LH: halfwords 0 and 2 of LO, HI, LO1, HI1 + { + uint16_t h[8]; + for (int i = 0; i < 4; ++i) + { + h[i * 2] = static_cast(lanes[i]); + h[i * 2 + 1] = static_cast(lanes[i] >> 32); + } + return _mm_set_epi16(static_cast(h[7]), static_cast(h[6]), static_cast(h[5]), static_cast(h[4]), + static_cast(h[3]), static_cast(h[2]), static_cast(h[1]), static_cast(h[0])); + } + default: // SH: every 32-bit word of LO, HI, LO1, HI1 saturated to 16 bits + { + int16_t h[8]; + for (int i = 0; i < 4; ++i) + { + h[i * 2] = ps2_saturate_s16(static_cast(lanes[i])); + h[i * 2 + 1] = ps2_saturate_s16(static_cast(lanes[i] >> 32)); + } + return _mm_set_epi16(h[7], h[6], h[5], h[4], h[3], h[2], h[1], h[0]); + } + } +} + +template +inline void ps2_pmthl_lw(Ctx *ctx, __m128i value) +{ + alignas(16) uint32_t words[4]; + _mm_store_si128(reinterpret_cast<__m128i *>(words), value); + ctx->lo = (ctx->lo & 0xFFFFFFFF00000000ull) | words[0]; + ctx->hi = (ctx->hi & 0xFFFFFFFF00000000ull) | words[1]; + ctx->lo1 = (ctx->lo1 & 0xFFFFFFFF00000000ull) | words[2]; + ctx->hi1 = (ctx->hi1 & 0xFFFFFFFF00000000ull) | words[3]; +} + +#define PS2_PMFHL_LW(ctx) ps2_pmfhl(ctx, 0) +#define PS2_PMFHL_UW(ctx) ps2_pmfhl(ctx, 1) +#define PS2_PMFHL_SLW(ctx) ps2_pmfhl(ctx, 2) +#define PS2_PMFHL_LH(ctx) ps2_pmfhl(ctx, 3) +#define PS2_PMFHL_SH(ctx) ps2_pmfhl(ctx, 4) +#define PS2_PMTHL_LW(ctx, value) ps2_pmthl_lw(ctx, value) // FPU (COP1) operations #define FPU_SET_ACC(ctx, res) (ctx->f_acc = res) diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index 263e75fb79..9836dad10b 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -1038,6 +1038,50 @@ void register_ps2_runtime_expansion_tests() t.Equals(room, 0u, "vfprintf should consume _w"); }); + tc.Run("PMFHL and PMTHL move 32-bit words between HI/LO pipelines and a GPR", [](TestCase &t) + { + // MULTU leaves a 64-bit product split across LO/HI; libgcc's soft-double multiply reads it + // back with PMFHL.LW, so every word must come from the right pipeline register. + R5900Context ctx{}; + ctx.lo = 0xFFFFFFFF89ABCDEFull; // MULTU sign-extends each 32-bit half into LO/HI + ctx.hi = 0x0000000001234567ull; + ctx.lo1 = 0x00000000AAAA5555ull; + ctx.hi1 = 0xFFFFFFFF80000000ull; + + alignas(16) uint32_t w[4]; + _mm_store_si128(reinterpret_cast<__m128i *>(w), PS2_PMFHL_LW(&ctx)); + t.Equals(w[0], 0x89ABCDEFu, "PMFHL.LW word 0 is LO0's low word"); + t.Equals(w[1], 0x01234567u, "PMFHL.LW word 1 is HI0's low word, completing the 64-bit product"); + t.Equals(w[2], 0xAAAA5555u, "PMFHL.LW word 2 comes from LO1"); + t.Equals(w[3], 0x80000000u, "PMFHL.LW word 3 comes from HI1"); + + _mm_store_si128(reinterpret_cast<__m128i *>(w), PS2_PMFHL_UW(&ctx)); + t.Equals(w[0], 0xFFFFFFFFu, "PMFHL.UW word 0 is LO0's high word"); + t.Equals(w[1], 0x00000000u, "PMFHL.UW word 1 is HI0's high word"); + + alignas(16) int64_t d[2]; + _mm_store_si128(reinterpret_cast<__m128i *>(d), PS2_PMFHL_SLW(&ctx)); + t.Equals(d[0], static_cast(0x7FFFFFFF), "PMFHL.SLW saturates a large {HI,LO} pair"); + t.Equals(d[1], static_cast(-0x80000000ll), "PMFHL.SLW saturates negative pipeline-1 values"); + + alignas(16) uint16_t h[8]; + _mm_store_si128(reinterpret_cast<__m128i *>(h), PS2_PMFHL_LH(&ctx)); + t.Equals(h[0], static_cast(0xCDEF), "PMFHL.LH takes LO halfword 0"); + t.Equals(h[1], static_cast(0xFFFF), "PMFHL.LH takes LO halfword 2"); + t.Equals(h[2], static_cast(0x4567), "PMFHL.LH takes HI halfword 0"); + + alignas(16) int16_t sh[8]; + _mm_store_si128(reinterpret_cast<__m128i *>(sh), PS2_PMFHL_SH(&ctx)); + t.Equals(sh[0], static_cast(-0x8000), "PMFHL.SH saturates negative words"); + t.Equals(sh[2], static_cast(0x7FFF), "PMFHL.SH saturates positive words"); + + PS2_PMTHL_LW(&ctx, _mm_set_epi32(4, 3, 2, 1)); + t.Equals(ctx.lo, 0xFFFFFFFF00000001ull, "PMTHL.LW replaces only LO0's low word"); + t.Equals(ctx.hi, 0x0000000000000002ull, "PMTHL.LW replaces only HI0's low word"); + t.Equals(ctx.lo1, 0x0000000000000003ull, "PMTHL.LW writes LO1"); + t.Equals(ctx.hi1, 0xFFFFFFFF00000004ull, "PMTHL.LW writes HI1"); + }); + tc.Run("multiply-add matrix writes rd only when R5900 requires it", [](TestCase &t) { R5900Decoder decoder; From 3f369e444f7f21f37ab9a6fde1955815289e37dd Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:21:30 -0300 Subject: [PATCH 14/91] Place the second sceGsSetDefDBuffDc frame buffer before the Z buffer --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 3 ++- ps2xTest/src/ps2_gs_tests.cpp | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index aed4cb5437..a7623cc97e 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -903,7 +903,8 @@ namespace ps2_stubs zbufAddr = getRegU32(&temp, 2); } - const uint32_t fbp1 = zbufAddr; + // sceGszbufaddr places Z after both frame buffers, so the second buffer starts halfway there. + const uint32_t fbp1 = zbufAddr / 2u; const uint64_t dispfb0 = makeDispFb(fbp1, fbw, psm, 0u, 0u); const uint64_t dispfb1 = makeDispFb(0u, fbw, psm, 0u, 0u); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 113cc88813..78a1c42222 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -583,6 +583,28 @@ void register_ps2_gs_tests() "sceGsSwapDBuffDc should preserve the display width from the seeded env"); }); + tc.Run("sceGsSetDefDBuffDc keeps the second frame buffer out of the Z buffer", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x4000u; + + // 640x448 CT24: 140 pages per buffer, so libgraph puts buffer 1 at page 140 and Z at 280. + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, GS_PSM_CT24); + setRegU32(ctx, 6, 640u); + setRegU32(ctx, 7, 448u); + ps2_stubs::sceGsSetDefDBuffDc(rdram.data(), &ctx, &runtime); + + GsDBuffDcMem db{}; + std::memcpy(&db, rdram.data() + kEnvAddr, sizeof(db)); + t.Equals(db.draw11.frame1.value & 0x1FFull, 140ull, "the second draw buffer should start after the first"); + t.Equals(db.draw11.zbuf1.value & 0x1FFull, 280ull, "Z should follow both frame buffers"); + t.Equals(db.disp[0].dispfb & 0x1FFull, 140ull, "the first display env should show the second buffer"); + }); + tc.Run("sceGsSetDefDBuffDc seeds a clear packet and swap clears the draw buffer", [](TestCase &t) { PS2Runtime runtime; From 67265405f385f246a9ae5675b5a4e83cbb5a5a04 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:21:30 -0300 Subject: [PATCH 15/91] Honor libgraph image packet layout and transfer packing in sceGsExecLoadImage/StoreImage --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 22 ++++-- .../src/lib/Kernel/Stubs/Helpers/Support.h | 65 ++++++++++++++--- ps2xTest/src/ps2_gs_tests.cpp | 72 ++++++++++++++++++- 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index a7623cc97e..0137094d8a 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -631,7 +631,8 @@ namespace ps2_stubs return; } - uint32_t dbp = (static_cast(img.vram_addr) * 2048u) / 256u; + // libgraph passes the base pointer straight into BITBLTBUF, in 64-word block units. + uint32_t dbp = static_cast(img.vram_addr); uint32_t dsax = static_cast(img.x); uint32_t dsay = static_cast(img.y); @@ -673,7 +674,7 @@ namespace ps2_stubs uint32_t dstAddr = getRegU32(ctx, 5); GsImageMem img{}; - if (!runtime || !runtime->syncCoreSubsystems() || !readGsImage(rdram, imgAddr, img)) + if (!runtime || !runtime->syncCoreSubsystems() || !readGsImage(rdram, imgAddr, img, true)) { setReturnS32(ctx, -1); return; @@ -696,7 +697,7 @@ namespace ps2_stubs return; } - uint32_t sbp = (static_cast(img.vram_addr) * 2048u) / 256u; + uint32_t sbp = static_cast(img.vram_addr); uint64_t bitbltbuf = (static_cast(sbp & 0x3FFFu) << 0) | (static_cast(fbw & 0x3Fu) << 16) | (static_cast(img.psm & 0x3Fu) << 24) | @@ -1126,7 +1127,20 @@ namespace ps2_stubs void sceGsSetDefStoreImage(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - sceGsSetDefLoadImage(rdram, ctx, runtime); + uint32_t imgAddr = getRegU32(ctx, 4); + const GsSetDefImageArgs args = decodeGsSetDefImageArgs(rdram, ctx); + + GsImageMem img{}; + img.x = static_cast(args.x); + img.y = static_cast(args.y); + img.width = static_cast(args.width); + img.height = static_cast(args.height); + img.vram_addr = static_cast(args.vramAddr); + img.vram_width = static_cast(args.vramWidth); + img.psm = static_cast(args.psm); + + writeGsImage(rdram, imgAddr, img, true); + setReturnS32(ctx, 0); } void sceGsSwapDBuffDc(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index f318d1222b..6a0accd6f3 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1729,23 +1729,31 @@ namespace { const uint64_t pixels = static_cast(pixelCount); uint64_t bytes = 0; + // Host/local transfer packing, not VRAM storage: the high-bit formats and 24-bit colour + // travel packed even though they occupy 32-bit words in local memory. switch (psm) { case 0: // PSMCT32 - case 1: // PSMCT24 (treat as 32) - case 27: // PSMT8H (packed in 32-bit lanes) - case 36: // PSMT4HL (packed in 32-bit lanes) - case 44: // PSMT4HH (packed in 32-bit lanes) + case 48: // PSMZ32 bytes = pixels * 4ull; break; + case 1: // PSMCT24 + case 49: // PSMZ24 + bytes = pixels * 3ull; + break; case 2: // PSMCT16 case 10: // PSMCT16S + case 50: // PSMZ16 + case 58: // PSMZ16S bytes = pixels * 2ull; break; case 19: // PSMT8 + case 27: // PSMT8H bytes = pixels; break; case 20: // PSMT4 + case 36: // PSMT4HL + case 44: // PSMT4HH bytes = (pixels + 1ull) / 2ull; break; default: @@ -1822,21 +1830,62 @@ namespace return decoded; } - static bool readGsImage(uint8_t *rdram, uint32_t addr, GsImageMem &out) + // sceGsLoadImage/sceGsStoreImage are libgraph's 0x60-byte GIF packets (giftag, BITBLTBUF, TRXPOS, + // TRXREG, ... as A+D pairs). Games patch those fields in place, so decode the registers themselves. + constexpr uint32_t kGsImageStructBytes = 0x60u; + + static bool readGsImage(uint8_t *rdram, uint32_t addr, GsImageMem &out, bool store = false) { const uint8_t *ptr = getConstMemPtr(rdram, addr); if (!ptr) return false; - std::memcpy(&out, ptr, sizeof(out)); + uint64_t bitbltbuf = 0u; + uint64_t trxpos = 0u; + uint64_t trxreg = 0u; + std::memcpy(&bitbltbuf, ptr + 0x10u, sizeof(bitbltbuf)); + std::memcpy(&trxpos, ptr + 0x20u, sizeof(trxpos)); + std::memcpy(&trxreg, ptr + 0x30u, sizeof(trxreg)); + const uint32_t shift = store ? 0u : 32u; // source fields for stores, destination fields for loads + out.vram_addr = static_cast((bitbltbuf >> shift) & 0x3FFFu); + out.vram_width = static_cast((bitbltbuf >> (shift + 16u)) & 0x3Fu); + out.psm = static_cast((bitbltbuf >> (shift + 24u)) & 0x3Fu); + out.x = static_cast((trxpos >> shift) & 0x7FFu); + out.y = static_cast((trxpos >> (shift + 16u)) & 0x7FFu); + out.width = static_cast(trxreg & 0xFFFu); + out.height = static_cast((trxreg >> 32u) & 0xFFFu); return true; } - static bool writeGsImage(uint8_t *rdram, uint32_t addr, const GsImageMem &img) + static bool writeGsImage(uint8_t *rdram, uint32_t addr, const GsImageMem &img, bool store = false) { uint8_t *ptr = getMemPtr(rdram, addr); if (!ptr) return false; - std::memcpy(ptr, &img, sizeof(img)); + const uint32_t shift = store ? 0u : 32u; + const uint32_t imageQwc = (bytesForPixels(img.psm, static_cast(img.width) * img.height) + 15u) / 16u; + uint64_t q[kGsImageStructBytes / 8u] = {}; + q[0] = makeGiftagAplusDOpen(store ? 5u : 4u); + q[1] = 0xEull; + q[2] = (static_cast(img.vram_addr & 0x3FFFu) << shift) | + (static_cast(img.vram_width & 0x3Fu) << (shift + 16u)) | + (static_cast(img.psm & 0x3Fu) << (shift + 24u)); + q[3] = 0x50ull; + q[4] = (static_cast(img.x & 0x7FFu) << shift) | (static_cast(img.y & 0x7FFu) << (shift + 16u)); + q[5] = 0x51ull; + q[6] = static_cast(img.width) | (static_cast(img.height) << 32u); + q[7] = 0x52ull; + if (store) + { + q[9] = 0x61ull; // FINISH + q[10] = 1ull; // TRXDIR local -> host + q[11] = 0x53ull; + } + else + { + q[9] = 0x53ull; // TRXDIR host -> local + q[10] = (static_cast(2u) << 58u) | (1ull << 15u) | (imageQwc & 0x7FFFu); + } + std::memcpy(ptr, q, sizeof(q)); return true; } diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 78a1c42222..6c8d26030a 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -158,9 +158,18 @@ namespace runtime->requestStop(); } + // Encodes the libgraph sceGsLoadImage register fields the runtime stubs decode. void writeGsImageTest(uint8_t *rdram, uint32_t addr, const GsImageMem &image) { - std::memcpy(rdram + addr, &image, sizeof(image)); + uint64_t q[12] = {}; + q[2] = (static_cast(image.vram_addr) << 32) | (static_cast(image.vram_width) << 48) | + (static_cast(image.psm) << 56); + q[3] = 0x50u; + q[4] = (static_cast(image.x) << 32) | (static_cast(image.y) << 48); + q[5] = 0x51u; + q[6] = static_cast(image.width) | (static_cast(image.height) << 32); + q[7] = 0x52u; + std::memcpy(rdram + addr, q, sizeof(q)); } void writeGsImageTest(std::vector &rdram, uint32_t addr, const GsImageMem &image) @@ -3967,6 +3976,67 @@ void register_ps2_gs_tests() "triangle fan quad should light at least one framebuffer row"); }); + tc.Run("sceGsExecLoadImage passes the libgraph block address through to BITBLTBUF", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "PS2Memory initialize should succeed"); + uint8_t *rdram = runtime.memory().getRDRAM(); + + // Mana Khemia fills sceGsLoadImage once, then patches BITBLTBUF in place before each upload. + constexpr uint32_t kImageAddr = 0x4000u; + constexpr uint32_t kSrcAddr = 0x5000u; + R5900Context defCtx{}; + setRegU32(defCtx, 4, kImageAddr); + setRegU32(defCtx, 5, 0u); // dbp + setRegU32(defCtx, 6, 1u); // dbw + setRegU32(defCtx, 7, 0x02u); // PSMCT16 + setRegU32(defCtx, 8, 0u); + setRegU32(defCtx, 9, 0u); + setRegU32(defCtx, 10, 16u); + setRegU32(defCtx, 11, 16u); + ps2_stubs::sceGsSetDefLoadImage(rdram, &defCtx, &runtime); + uint64_t structBitbltbuf = 0u; + std::memcpy(&structBitbltbuf, rdram + kImageAddr + 0x10u, sizeof(structBitbltbuf)); + t.Equals(structBitbltbuf >> 56u, 0x02ull, "sceGsSetDefLoadImage should store DPSM in the libgraph BITBLTBUF slot"); + structBitbltbuf = (structBitbltbuf & ~(0x3FFFull << 32u)) | (11202ull << 32u); + std::memcpy(rdram + kImageAddr + 0x10u, &structBitbltbuf, sizeof(structBitbltbuf)); + + R5900Context loadCtx{}; + setRegU32(loadCtx, 4, kImageAddr); + setRegU32(loadCtx, 5, kSrcAddr); + ps2_stubs::sceGsExecLoadImage(rdram, &loadCtx, &runtime); + + uint64_t bitbltbuf = 0u; + std::memcpy(&bitbltbuf, rdram + runtime.guestHeapBase() + 16u, sizeof(bitbltbuf)); + t.Equals(static_cast((bitbltbuf >> 32u) & 0x3FFFu), 11202u, + "BITBLTBUF.DBP should be the block address the game passed"); + }); + + tc.Run("sceGsExecLoadImage sizes PSMT4HH uploads at four bits per pixel", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "PS2Memory initialize should succeed"); + uint8_t *rdram = runtime.memory().getRDRAM(); + + // Mana Khemia's font sheet is a 512x512 PSMT4HH image; transfers pack it at 4 bpp. + constexpr uint32_t kImageAddr = 0x4000u; + constexpr uint32_t kSrcAddr = 0x5000u; + const GsImageMem image{0u, 0u, 16u, 16u, 0u, 1u, 0x2Cu}; + writeGsImageTest(rdram, kImageAddr, image); + std::memset(rdram + kSrcAddr, 0x31, 16u * 16u / 2u); + + R5900Context loadCtx{}; + setRegU32(loadCtx, 4, kImageAddr); + setRegU32(loadCtx, 5, kSrcAddr); + ps2_stubs::sceGsExecLoadImage(rdram, &loadCtx, &runtime); + + uint64_t imageTag = 0u; + std::memcpy(&imageTag, rdram + runtime.guestHeapBase() + 5u * 16u, sizeof(imageTag)); + t.Equals(imageTag & 0x7FFFull, 8ull, "a 16x16 PSMT4HH image is 128 bytes, eight quadwords"); + t.Equals(runtime.gs().ReadVram(0x2Cu, 0u, 1u, 0u, 0u), 1u, "the first texel should land in the high nibble plane"); + t.Equals(runtime.gs().ReadVram(0x2Cu, 0u, 1u, 1u, 0u), 3u, "the second texel comes from the upper half of the byte"); + }); + tc.Run("sceGsExecLoadImage and sceGsExecStoreImage roundtrip and free guest packets", [](TestCase &t) { PS2Runtime runtime; From f84cc3f98fc1bd6fbe5d4f11b386a78c0eb339a0 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:21:30 -0300 Subject: [PATCH 16/91] Emulate the IPU for intra MPEG decoding and colour conversion --- ps2xRuntime/CMakeLists.txt | 1 + ps2xRuntime/include/runtime/ps2_ipu.h | 107 +++ ps2xRuntime/include/runtime/ps2_memory.h | 6 + ps2xRuntime/src/lib/ps2_ipu.cpp | 795 +++++++++++++++++++++++ ps2xRuntime/src/lib/ps2_memory.cpp | 212 +++++- ps2xTest/src/ps2_memory_tests.cpp | 105 +++ 6 files changed, 1191 insertions(+), 35 deletions(-) create mode 100644 ps2xRuntime/include/runtime/ps2_ipu.h create mode 100644 ps2xRuntime/src/lib/ps2_ipu.cpp diff --git a/ps2xRuntime/CMakeLists.txt b/ps2xRuntime/CMakeLists.txt index 589d76850a..5086ce3936 100644 --- a/ps2xRuntime/CMakeLists.txt +++ b/ps2xRuntime/CMakeLists.txt @@ -385,6 +385,7 @@ add_library(ps2_runtime STATIC src/lib/gs/gs_cpu_backend.cpp src/lib/ps2_iop_host.cpp src/lib/ps2_memory.cpp + src/lib/ps2_ipu.cpp src/lib/ps2_pad.cpp src/lib/ps2_rom_device.cpp src/lib/ps2_vfs.cpp diff --git a/ps2xRuntime/include/runtime/ps2_ipu.h b/ps2xRuntime/include/runtime/ps2_ipu.h new file mode 100644 index 0000000000..5a4e85d7fc --- /dev/null +++ b/ps2xRuntime/include/runtime/ps2_ipu.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Image Processing Unit: MPEG-1/2 intra decoding and colour conversion fed through the +// IPU FIFOs (0x10007000/0x10007010) or the toIPU/fromIPU DMA channels. +// Commands run synchronously; one that runs out of input is rolled back and retried +// when more data arrives, so callers never observe a half-executed command. +class Ps2Ipu +{ +public: + static constexpr uint32_t kRegCmd = 0x10002000u; + static constexpr uint32_t kRegCtrl = 0x10002010u; + static constexpr uint32_t kRegBp = 0x10002020u; + static constexpr uint32_t kRegTop = 0x10002030u; + + Ps2Ipu(); + + void reset(); + + // 32-bit register access in 0x10002000-0x1000203F; the high words carry BUSY. + uint32_t readRegister(uint32_t address) const; + void writeRegister(uint32_t address, uint32_t value); + + // IN_FIFO write / OUT_FIFO read, one quadword each. + void writeInputFifo(const uint8_t *quadword); + bool readOutputFifo(uint8_t *quadword); + + // Data delivered by the toIPU DMA; it enters the 8-quadword input FIFO as commands consume it. + void feedDmaInput(const uint8_t *data, size_t size); + size_t takeOutput(uint8_t *destination, size_t maxBytes); + // Decoded data becomes visible at a hardware-like rate; games re-arm fromIPU expecting the IPU to lag. + size_t outputBytes() const noexcept { return std::min(m_output.size(), m_releasedBytes); } + void advance(uint64_t eeCycles); + static constexpr uint64_t kEeCyclesPerMacroblock = 1024u; + + bool busy() const noexcept { return m_busy; } + +private: + struct Snapshot + { + std::vector fifo; + uint32_t bitPos = 0; + size_t dmaCursor = 0; + std::array dcPred{}; + int32_t quantizerScale = 0; + size_t outputSize = 0; + }; + + struct NeedInput + { + }; + + void runCommand(); + void executeCommand(); + Snapshot snapshot() const; + void restore(const Snapshot &state); + + // Bitstream access over the input FIFO, refilled from pending DMA input. + void refill(); + bool ensureBits(uint32_t count); + uint32_t peekBits(uint32_t count); + uint32_t getBits(uint32_t count); + void skipBits(uint32_t count); + + void commandBclr(); + void commandIdec(); + void commandFdec(); + void commandSetiq(); + void commandSetvq(); + void commandSetth(); + void commandCsc(); + + int decodeMacroblockAddressIncrement(); + bool decodeIntraBlock(int component, int16_t *block); + void inverseDct(const int16_t *coefficients, uint8_t *destination, int stride) const; + void emitMacroblock(const uint8_t *luma, const uint8_t *cb, const uint8_t *cr, bool rgb16, bool dither); + + uint32_t m_cmd = 0; + uint32_t m_cmdData = 0; + uint32_t m_ctrl = 0; + bool m_busy = false; + bool m_pending = false; + bool m_commandError = false; + bool m_startCodeSeen = false; + + std::vector m_fifo; + uint32_t m_bitPos = 0; + std::vector m_dmaInput; + size_t m_dmaCursor = 0; + std::deque m_output; + size_t m_releasedBytes = 0; + uint64_t m_releaseCycles = 0; + + std::array m_intraMatrix{}; + std::array m_nonIntraMatrix{}; + std::array m_vqClut{}; + uint32_t m_threshold0 = 0; + uint32_t m_threshold1 = 0; + std::array m_dcPred{}; + int32_t m_quantizerScale = 0; +}; diff --git a/ps2xRuntime/include/runtime/ps2_memory.h b/ps2xRuntime/include/runtime/ps2_memory.h index f96fa7836f..43b474d5e4 100644 --- a/ps2xRuntime/include/runtime/ps2_memory.h +++ b/ps2xRuntime/include/runtime/ps2_memory.h @@ -12,6 +12,7 @@ #include #include "gs/ps2_gif_arbiter.h" +#include "ps2_ipu.h" #if defined(_MSC_VER) #include #elif defined(USE_SSE2NEON) @@ -285,6 +286,7 @@ class PS2Memory // Memory access methods uint8_t *getRDRAM() { return m_rdram; } uint8_t *getScratchpad() { return m_scratchpad; } + Ps2Ipu &ipu() { return m_ipu; } uint8_t *getIOPRAM() { return iop_ram; } uint64_t dmaStartCount() const { return m_dmaStartCount.load(std::memory_order_relaxed); } uint64_t gifCopyCount() const { return m_gifCopyCount.load(std::memory_order_relaxed); } @@ -460,7 +462,11 @@ class PS2Memory std::array m_eeTimers{}; bool tryProcessScratchpadDma(uint32_t channelBase, uint32_t chcr); + bool tryStartIpuDma(uint32_t channelBase, uint32_t chcr); + void serviceIpuDma(); + bool appendDmaSource(uint32_t address, uint32_t bytes, std::vector &out) const; void completeDmacChannel(uint32_t channelBase, uint32_t cause); + Ps2Ipu m_ipu; void queueCompletedDmacCause(uint32_t cause); }; diff --git a/ps2xRuntime/src/lib/ps2_ipu.cpp b/ps2xRuntime/src/lib/ps2_ipu.cpp new file mode 100644 index 0000000000..3d8603ef05 --- /dev/null +++ b/ps2xRuntime/src/lib/ps2_ipu.cpp @@ -0,0 +1,795 @@ +#include "runtime/ps2_ipu.h" + +#include +#include +#include +#include + +namespace +{ + enum : uint32_t + { + kCmdBclr = 0x0, + kCmdIdec = 0x1, + kCmdBdec = 0x2, + kCmdVdec = 0x3, + kCmdFdec = 0x4, + kCmdSetiq = 0x5, + kCmdSetvq = 0x6, + kCmdCsc = 0x7, + kCmdPack = 0x8, + kCmdSetth = 0x9, + }; + + constexpr uint32_t kCtrlEcd = 1u << 14; + constexpr uint32_t kCtrlScd = 1u << 15; + constexpr uint32_t kCtrlRst = 1u << 30; + constexpr uint32_t kCtrlBusy = 1u << 31; + // IDP, AS, IVF, QST, MP1, PCT and the reset request are software-writable. + constexpr uint32_t kCtrlWritable = (3u << 16) | (1u << 20) | (1u << 21) | (1u << 22) | (1u << 23) | (7u << 24) | kCtrlRst; + constexpr size_t kFifoBytes = 8u * 16u; + + constexpr uint8_t kZigzag[64] = { + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63}; + + constexpr uint8_t kAlternateScan[64] = { + 0, 8, 16, 24, 1, 9, 2, 10, 17, 25, 32, 40, 48, 56, 57, 49, + 41, 33, 26, 18, 3, 11, 4, 12, 19, 27, 34, 42, 50, 58, 35, 43, + 51, 59, 20, 28, 5, 13, 6, 14, 21, 29, 36, 44, 52, 60, 37, 45, + 53, 61, 22, 30, 7, 15, 23, 31, 38, 46, 54, 62, 39, 47, 55, 63}; + + constexpr uint8_t kNonLinearQuantizer[32] = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 18, 20, 22, + 24, 28, 32, 36, 40, 44, 48, 52, 56, 64, 72, 80, 88, 96, 104, 112}; + + // ISO/IEC 13818-2 tables B.12-B.15 (codes, lengths, run/level) as published in FFmpeg's mpeg12data.c. + constexpr uint16_t kDcLumaCode[12] = {0x4, 0x0, 0x1, 0x5, 0x6, 0xe, 0x1e, 0x3e, 0x7e, 0xfe, 0x1fe, 0x1ff}; + constexpr uint8_t kDcLumaBits[12] = {3, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9, 9}; + constexpr uint16_t kDcChromaCode[12] = {0x0, 0x1, 0x2, 0x6, 0xe, 0x1e, 0x3e, 0x7e, 0xfe, 0x1fe, 0x3fe, 0x3ff}; + constexpr uint8_t kDcChromaBits[12] = {2, 2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10}; + + constexpr int kRunLevelCount = 111; + constexpr uint16_t kTableZero[kRunLevelCount + 2][2] = { + {0x3, 2}, {0x4, 4}, {0x5, 5}, {0x6, 7}, {0x26, 8}, {0x21, 8}, {0xa, 10}, {0x1d, 12}, + {0x18, 12}, {0x13, 12}, {0x10, 12}, {0x1a, 13}, {0x19, 13}, {0x18, 13}, {0x17, 13}, {0x1f, 14}, + {0x1e, 14}, {0x1d, 14}, {0x1c, 14}, {0x1b, 14}, {0x1a, 14}, {0x19, 14}, {0x18, 14}, {0x17, 14}, + {0x16, 14}, {0x15, 14}, {0x14, 14}, {0x13, 14}, {0x12, 14}, {0x11, 14}, {0x10, 14}, {0x18, 15}, + {0x17, 15}, {0x16, 15}, {0x15, 15}, {0x14, 15}, {0x13, 15}, {0x12, 15}, {0x11, 15}, {0x10, 15}, + {0x3, 3}, {0x6, 6}, {0x25, 8}, {0xc, 10}, {0x1b, 12}, {0x16, 13}, {0x15, 13}, {0x1f, 15}, + {0x1e, 15}, {0x1d, 15}, {0x1c, 15}, {0x1b, 15}, {0x1a, 15}, {0x19, 15}, {0x13, 16}, {0x12, 16}, + {0x11, 16}, {0x10, 16}, {0x5, 4}, {0x4, 7}, {0xb, 10}, {0x14, 12}, {0x14, 13}, {0x7, 5}, + {0x24, 8}, {0x1c, 12}, {0x13, 13}, {0x6, 5}, {0xf, 10}, {0x12, 12}, {0x7, 6}, {0x9, 10}, + {0x12, 13}, {0x5, 6}, {0x1e, 12}, {0x14, 16}, {0x4, 6}, {0x15, 12}, {0x7, 7}, {0x11, 12}, + {0x5, 7}, {0x11, 13}, {0x27, 8}, {0x10, 13}, {0x23, 8}, {0x1a, 16}, {0x22, 8}, {0x19, 16}, + {0x20, 8}, {0x18, 16}, {0xe, 10}, {0x17, 16}, {0xd, 10}, {0x16, 16}, {0x8, 10}, {0x15, 16}, + {0x1f, 12}, {0x1a, 12}, {0x19, 12}, {0x17, 12}, {0x16, 12}, {0x1f, 13}, {0x1e, 13}, {0x1d, 13}, + {0x1c, 13}, {0x1b, 13}, {0x1f, 16}, {0x1e, 16}, {0x1d, 16}, {0x1c, 16}, {0x1b, 16}, + {0x1, 6}, // escape + {0x2, 2}, // end of block + }; + constexpr uint16_t kTableOne[kRunLevelCount + 2][2] = { + {0x02, 2}, {0x06, 3}, {0x07, 4}, {0x1c, 5}, {0x1d, 5}, {0x05, 6}, {0x04, 6}, {0x7b, 7}, + {0x7c, 7}, {0x23, 8}, {0x22, 8}, {0xfa, 8}, {0xfb, 8}, {0xfe, 8}, {0xff, 8}, {0x1f, 14}, + {0x1e, 14}, {0x1d, 14}, {0x1c, 14}, {0x1b, 14}, {0x1a, 14}, {0x19, 14}, {0x18, 14}, {0x17, 14}, + {0x16, 14}, {0x15, 14}, {0x14, 14}, {0x13, 14}, {0x12, 14}, {0x11, 14}, {0x10, 14}, {0x18, 15}, + {0x17, 15}, {0x16, 15}, {0x15, 15}, {0x14, 15}, {0x13, 15}, {0x12, 15}, {0x11, 15}, {0x10, 15}, + {0x02, 3}, {0x06, 5}, {0x79, 7}, {0x27, 8}, {0x20, 8}, {0x16, 13}, {0x15, 13}, {0x1f, 15}, + {0x1e, 15}, {0x1d, 15}, {0x1c, 15}, {0x1b, 15}, {0x1a, 15}, {0x19, 15}, {0x13, 16}, {0x12, 16}, + {0x11, 16}, {0x10, 16}, {0x05, 5}, {0x07, 7}, {0xfc, 8}, {0x0c, 10}, {0x14, 13}, {0x07, 5}, + {0x26, 8}, {0x1c, 12}, {0x13, 13}, {0x06, 6}, {0xfd, 8}, {0x12, 12}, {0x07, 6}, {0x04, 9}, + {0x12, 13}, {0x06, 7}, {0x1e, 12}, {0x14, 16}, {0x04, 7}, {0x15, 12}, {0x05, 7}, {0x11, 12}, + {0x78, 7}, {0x11, 13}, {0x7a, 7}, {0x10, 13}, {0x21, 8}, {0x1a, 16}, {0x25, 8}, {0x19, 16}, + {0x24, 8}, {0x18, 16}, {0x05, 9}, {0x17, 16}, {0x07, 9}, {0x16, 16}, {0x0d, 10}, {0x15, 16}, + {0x1f, 12}, {0x1a, 12}, {0x19, 12}, {0x17, 12}, {0x16, 12}, {0x1f, 13}, {0x1e, 13}, {0x1d, 13}, + {0x1c, 13}, {0x1b, 13}, {0x1f, 16}, {0x1e, 16}, {0x1d, 16}, {0x1c, 16}, {0x1b, 16}, + {0x01, 6}, // escape + {0x06, 4}, // end of block + }; + constexpr int8_t kLevel[kRunLevelCount] = { + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 40, 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 1, 2, 3, 4, 5, 1, + 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 1, 2, + 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + constexpr int8_t kRun[kRunLevelCount] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, + 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 8, 8, + 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}; + // macroblock_address_increment 1..33, then escape (+33) and stuffing. + constexpr uint8_t kMbaCode[35][2] = { + {0x1, 1}, {0x3, 3}, {0x2, 3}, {0x3, 4}, {0x2, 4}, {0x3, 5}, {0x2, 5}, {0x7, 7}, + {0x6, 7}, {0xb, 8}, {0xa, 8}, {0x9, 8}, {0x8, 8}, {0x7, 8}, {0x6, 8}, {0x17, 10}, + {0x16, 10}, {0x15, 10}, {0x14, 10}, {0x13, 10}, {0x12, 10}, {0x23, 11}, {0x22, 11}, {0x21, 11}, + {0x20, 11}, {0x1f, 11}, {0x1e, 11}, {0x1d, 11}, {0x1c, 11}, {0x1b, 11}, {0x1a, 11}, {0x19, 11}, + {0x18, 11}, {0x8, 11}, {0xf, 11}}; + + enum class DctKind : uint8_t + { + Invalid, + RunLevel, + Escape, + EndOfBlock + }; + + struct DctEntry + { + DctKind kind = DctKind::Invalid; + uint8_t length = 0; + uint8_t run = 0; + uint8_t level = 0; + }; + + struct ShortEntry + { + uint8_t length = 0; + uint8_t value = 0; + }; + + struct Tables + { + std::vector dct[2]; + std::array dcLuma{}; + std::array dcChroma{}; + std::array mba{}; + double cosine[8][8]{}; + + Tables() + { + for (int table = 0; table < 2; ++table) + { + const auto &codes = table == 0 ? kTableZero : kTableOne; + dct[table].assign(1u << 16, DctEntry{}); + for (int i = 0; i < kRunLevelCount + 2; ++i) + { + DctEntry entry; + entry.length = static_cast(codes[i][1]); + if (i < kRunLevelCount) + { + entry.kind = DctKind::RunLevel; + entry.run = static_cast(kRun[i]); + entry.level = static_cast(kLevel[i]); + } + else + { + entry.kind = i == kRunLevelCount ? DctKind::Escape : DctKind::EndOfBlock; + } + fill(dct[table], codes[i][0], entry.length, 16, entry); + } + } + for (uint8_t size = 0; size < 12; ++size) + { + fill(dcLuma, kDcLumaCode[size], kDcLumaBits[size], 10, ShortEntry{kDcLumaBits[size], size}); + fill(dcChroma, kDcChromaCode[size], kDcChromaBits[size], 10, ShortEntry{kDcChromaBits[size], size}); + } + for (uint8_t i = 0; i < 35; ++i) + { + fill(mba, kMbaCode[i][0], kMbaCode[i][1], 11, ShortEntry{kMbaCode[i][1], static_cast(i + 1u)}); + } + for (int x = 0; x < 8; ++x) + { + for (int u = 0; u < 8; ++u) + { + const double scale = u == 0 ? std::sqrt(0.5) : 1.0; + cosine[x][u] = scale * std::cos((2.0 * x + 1.0) * u * 3.14159265358979323846 / 16.0); + } + } + } + + template + static void fill(Table &table, uint32_t code, uint32_t length, uint32_t width, const Entry &entry) + { + const uint32_t shift = width - length; + const uint32_t first = code << shift; + for (uint32_t i = 0; i < (1u << shift); ++i) + table[first + i] = entry; + } + }; + + const Tables &tables() + { + static const Tables instance; + return instance; + } + + uint8_t clampByte(int value) + { + return static_cast(std::clamp(value, 0, 255)); + } +} + +Ps2Ipu::Ps2Ipu() +{ + reset(); +} + +void Ps2Ipu::reset() +{ + m_cmd = 0; + m_cmdData = 0; + m_ctrl = 0; + m_busy = false; + m_pending = false; + m_commandError = false; + m_startCodeSeen = false; + m_fifo.clear(); + m_bitPos = 0; + m_dmaInput.clear(); + m_dmaCursor = 0; + m_output.clear(); + m_releasedBytes = 0; + m_releaseCycles = 0; + m_intraMatrix.fill(16u); + m_nonIntraMatrix.fill(16u); + m_vqClut.fill(0u); + m_threshold0 = 0; + m_threshold1 = 0; + m_dcPred.fill(0); + m_quantizerScale = 0; +} + +uint32_t Ps2Ipu::readRegister(uint32_t address) const +{ + const uint32_t fifoQw = static_cast((m_fifo.size() - std::min(m_fifo.size(), (m_bitPos / 128u) * 16u)) / 16u); + const uint32_t outQw = static_cast(std::min(m_output.size() / 16u, 8u)); + switch (address & ~3u) + { + case kRegCmd: + return m_cmdData; + case kRegCmd + 4u: + return m_busy ? kCtrlBusy : 0u; + case kRegCtrl: + return (m_ctrl & kCtrlWritable) | (m_commandError ? kCtrlEcd : 0u) | (m_startCodeSeen ? kCtrlScd : 0u) | + (std::min(fifoQw, 8u) & 0xFu) | ((outQw & 0xFu) << 4u) | (m_busy ? kCtrlBusy : 0u); + case kRegBp: + return (m_bitPos % 128u) | ((std::min(fifoQw, 8u) & 0xFu) << 8u); + case kRegTop: + { + // BSTOP shows the next 32 bits without consuming them; reads must not disturb the stream. + Ps2Ipu &self = const_cast(*this); + const Snapshot state = self.snapshot(); + uint32_t value = 0; + try + { + value = self.peekBits(32); + } + catch (const NeedInput &) + { + } + self.restore(state); + return value; + } + case kRegTop + 4u: + return m_busy ? kCtrlBusy : 0u; + default: + return 0u; + } +} + +void Ps2Ipu::writeRegister(uint32_t address, uint32_t value) +{ + switch (address & ~3u) + { + case kRegCmd: + m_cmd = value; + m_busy = true; + m_pending = true; + runCommand(); + break; + case kRegCtrl: + if ((value & kCtrlRst) != 0u) + { + const std::array intra = m_intraMatrix; + const std::array nonIntra = m_nonIntraMatrix; + reset(); + // Reset clears the FIFOs and decoder state; the quantiser matrices are left as loaded. + m_intraMatrix = intra; + m_nonIntraMatrix = nonIntra; + } + m_ctrl = value & kCtrlWritable; + break; + default: + break; + } +} + +void Ps2Ipu::writeInputFifo(const uint8_t *quadword) +{ + m_fifo.insert(m_fifo.end(), quadword, quadword + 16u); + if (m_pending) + runCommand(); +} + +void Ps2Ipu::advance(uint64_t eeCycles) +{ + if (m_releasedBytes >= m_output.size()) + { + m_releaseCycles = 0; + return; + } + m_releaseCycles += eeCycles; + const uint64_t macroblocks = m_releaseCycles / kEeCyclesPerMacroblock; + m_releaseCycles %= kEeCyclesPerMacroblock; + m_releasedBytes = static_cast(std::min(m_output.size(), m_releasedBytes + macroblocks * 1024u)); +} + +bool Ps2Ipu::readOutputFifo(uint8_t *quadword) +{ + if (outputBytes() < 16u) + { + std::memset(quadword, 0, 16u); + return false; + } + takeOutput(quadword, 16u); + return true; +} + +void Ps2Ipu::feedDmaInput(const uint8_t *data, size_t size) +{ + if (m_dmaCursor >= m_dmaInput.size()) + { + m_dmaInput.clear(); + m_dmaCursor = 0; + } + m_dmaInput.insert(m_dmaInput.end(), data, data + size); + refill(); + if (m_pending) + runCommand(); +} + +size_t Ps2Ipu::takeOutput(uint8_t *destination, size_t maxBytes) +{ + const size_t count = std::min(maxBytes, outputBytes()); + std::copy_n(m_output.begin(), count, destination); + m_output.erase(m_output.begin(), m_output.begin() + static_cast(count)); + m_releasedBytes -= count; + return count; +} + +Ps2Ipu::Snapshot Ps2Ipu::snapshot() const +{ + return Snapshot{m_fifo, m_bitPos, m_dmaCursor, m_dcPred, m_quantizerScale, m_output.size()}; +} + +void Ps2Ipu::restore(const Snapshot &state) +{ + m_fifo = state.fifo; + m_bitPos = state.bitPos; + m_dmaCursor = state.dmaCursor; + m_dcPred = state.dcPred; + m_quantizerScale = state.quantizerScale; + m_output.resize(state.outputSize); + m_releasedBytes = std::min(m_releasedBytes, m_output.size()); +} + +void Ps2Ipu::runCommand() +{ + const Snapshot state = snapshot(); + try + { + executeCommand(); + m_pending = false; + m_busy = false; + } + catch (const NeedInput &) + { + // Keep BUSY set and replay the whole command once more input has been queued. + restore(state); + } +} + +void Ps2Ipu::executeCommand() +{ + switch (m_cmd >> 28) + { + case kCmdBclr: + commandBclr(); + break; + case kCmdIdec: + commandIdec(); + break; + case kCmdFdec: + commandFdec(); + break; + case kCmdSetiq: + commandSetiq(); + break; + case kCmdSetvq: + commandSetvq(); + break; + case kCmdCsc: + commandCsc(); + break; + case kCmdSetth: + commandSetth(); + break; + default: + { + static bool warned[16] = {}; + const uint32_t code = m_cmd >> 28; + if (!warned[code]) + { + warned[code] = true; + std::cerr << "[IPU] command 0x" << std::hex << code << std::dec << " is not emulated; completing it with ECD set" << std::endl; + } + m_commandError = true; + break; + } + } +} + +void Ps2Ipu::refill() +{ + const size_t consumed = (m_bitPos / 128u) * 16u; + if (consumed != 0u) + { + m_fifo.erase(m_fifo.begin(), m_fifo.begin() + static_cast(std::min(consumed, m_fifo.size()))); + m_bitPos -= static_cast(consumed * 8u); + } + while (m_fifo.size() < kFifoBytes && m_dmaCursor + 16u <= m_dmaInput.size()) + { + m_fifo.insert(m_fifo.end(), m_dmaInput.begin() + static_cast(m_dmaCursor), + m_dmaInput.begin() + static_cast(m_dmaCursor + 16u)); + m_dmaCursor += 16u; + } +} + +bool Ps2Ipu::ensureBits(uint32_t count) +{ + if (m_bitPos + count > m_fifo.size() * 8u) + refill(); + return m_bitPos + count <= m_fifo.size() * 8u; +} + +uint32_t Ps2Ipu::peekBits(uint32_t count) +{ + if (count == 0u) + return 0u; + if (!ensureBits(count)) + throw NeedInput{}; + uint64_t window = 0; + const uint32_t firstByte = m_bitPos / 8u; + for (uint32_t i = 0; i < 5u; ++i) + { + const size_t index = firstByte + i; + window = (window << 8u) | (index < m_fifo.size() ? m_fifo[index] : 0u); + } + const uint32_t shift = 40u - (m_bitPos % 8u) - count; + return static_cast((window >> shift) & ((1ull << count) - 1ull)); +} + +uint32_t Ps2Ipu::getBits(uint32_t count) +{ + const uint32_t value = peekBits(count); + skipBits(count); + return value; +} + +void Ps2Ipu::skipBits(uint32_t count) +{ + if (!ensureBits(count)) + throw NeedInput{}; + m_bitPos += count; +} + +void Ps2Ipu::commandBclr() +{ + // BCLR empties only the FIFO; DMA data not yet transferred into it is kept. + m_fifo.clear(); + m_bitPos = m_cmd & 0x7Fu; + m_commandError = false; + m_startCodeSeen = false; +} + +void Ps2Ipu::commandFdec() +{ + skipBits(m_cmd & 0x3Fu); + m_cmdData = peekBits(32); +} + +void Ps2Ipu::commandSetiq() +{ + skipBits(m_cmd & 0x3Fu); + // Matrices arrive in zigzag order like the MPEG quant_matrix syntax; keep them in raster order. + std::array matrix{}; + for (int i = 0; i < 64; ++i) + matrix[kZigzag[i]] = static_cast(getBits(8)); + if ((m_cmd & (1u << 27)) != 0u) + m_nonIntraMatrix = matrix; + else + m_intraMatrix = matrix; +} + +void Ps2Ipu::commandSetvq() +{ + skipBits(m_cmd & 0x3Fu); + for (auto &entry : m_vqClut) + { + const uint32_t high = getBits(8); + entry = static_cast((getBits(8) << 8u) | high); + } +} + +void Ps2Ipu::commandSetth() +{ + m_threshold0 = m_cmd & 0x1FFu; + m_threshold1 = (m_cmd >> 16u) & 0x1FFu; +} + +void Ps2Ipu::commandCsc() +{ + const uint32_t count = m_cmd & 0x7FFu; + const bool rgb16 = (m_cmd & (1u << 27)) != 0u; + const bool dither = (m_cmd & (1u << 26)) != 0u; + for (uint32_t mb = 0; mb < count; ++mb) + { + uint8_t luma[256]; + uint8_t cb[64]; + uint8_t cr[64]; + for (uint8_t &value : luma) + value = static_cast(getBits(8)); + for (uint8_t &value : cb) + value = static_cast(getBits(8)); + for (uint8_t &value : cr) + value = static_cast(getBits(8)); + emitMacroblock(luma, cb, cr, rgb16, dither); + } +} + +int Ps2Ipu::decodeMacroblockAddressIncrement() +{ + int increment = 0; + for (;;) + { + const ShortEntry entry = tables().mba[peekBits(11)]; + if (entry.length == 0u) + return -1; + skipBits(entry.length); + if (entry.value == 34u) // macroblock_escape + { + increment += 33; + continue; + } + if (entry.value == 35u) // macroblock_stuffing + continue; + return increment + entry.value; + } +} + +void Ps2Ipu::commandIdec() +{ + skipBits(m_cmd & 0x3Fu); + m_commandError = false; + m_startCodeSeen = false; + + const uint32_t idp = (m_ctrl >> 16u) & 3u; + const bool decodeDctType = (m_cmd & (1u << 24)) != 0u; + const bool rgb16 = (m_cmd & (1u << 27)) != 0u; + const bool dither = (m_cmd & (1u << 26)) != 0u; + const bool nonLinear = (m_ctrl & (1u << 22)) != 0u; + auto scaleFor = [&](uint32_t code) + { return nonLinear ? static_cast(kNonLinearQuantizer[code & 31u]) : static_cast((code & 31u) << 1u); }; + + m_quantizerScale = scaleFor((m_cmd >> 16u) & 0x1Fu); + m_dcPred.fill(1 << (7 + idp)); + + for (;;) + { + // macroblock_type for I pictures: '1' intra, '01' intra with quantiser_scale_code. + bool quant = false; + if (getBits(1) == 0u) + { + if (getBits(1) == 0u) + { + m_commandError = true; + return; + } + quant = true; + } + const bool fieldDct = decodeDctType && getBits(1) != 0u; + if (quant) + m_quantizerScale = scaleFor(getBits(5)); + + int16_t blocks[6][64]; + for (int i = 0; i < 6; ++i) + { + if (!decodeIntraBlock(i < 4 ? 0 : i - 3, blocks[i])) + { + m_commandError = true; + return; + } + } + + uint8_t luma[256]; + uint8_t cb[64]; + uint8_t cr[64]; + for (int i = 0; i < 4; ++i) + { + // Field DCT interleaves the two upper blocks' rows with the lower ones. + const int x = (i & 1) * 8; + uint8_t *destination = fieldDct ? luma + (i >> 1) * 16 + x : luma + (i >> 1) * 128 + x; + inverseDct(blocks[i], destination, fieldDct ? 32 : 16); + } + inverseDct(blocks[4], cb, 8); + inverseDct(blocks[5], cr, 8); + emitMacroblock(luma, cb, cr, rgb16, dither); + + // A start code ends the picture; anything but an increment of one is a decode error. + if (peekBits(23) == 0u) + { + // Leave the stream on the byte-aligned start code so FDEC 32 steps over exactly that code. + skipBits((8u - (m_bitPos % 8u)) % 8u); + while (peekBits(24) != 0x000001u) + skipBits(8); + m_startCodeSeen = true; + return; + } + const int increment = decodeMacroblockAddressIncrement(); + if (increment != 1) + { + m_commandError = true; + return; + } + } +} + +bool Ps2Ipu::decodeIntraBlock(int component, int16_t *block) +{ + const Tables &t = tables(); + std::fill(block, block + 64, int16_t{0}); + + const uint32_t idp = (m_ctrl >> 16u) & 3u; + const bool mpeg1 = (m_ctrl & (1u << 23)) != 0u; + const bool alternateScan = (m_ctrl & (1u << 20)) != 0u; + const bool tableOne = (m_ctrl & (1u << 21)) != 0u && !mpeg1; + const uint8_t *scan = alternateScan ? kAlternateScan : kZigzag; + + const ShortEntry dc = component == 0 ? t.dcLuma[peekBits(10)] : t.dcChroma[peekBits(10)]; + if (dc.length == 0u) + return false; + skipBits(dc.length); + int32_t diff = 0; + if (dc.value != 0u) + { + diff = static_cast(getBits(dc.value)); + if (diff < (1 << (dc.value - 1))) + diff -= (1 << dc.value) - 1; + } + m_dcPred[component] += diff; + int32_t sum = m_dcPred[component] << (3 - idp); + block[0] = static_cast(sum); + + const std::vector &table = t.dct[tableOne ? 1 : 0]; + int index = 0; + for (;;) + { + const DctEntry entry = table[peekBits(16)]; + int run = 0; + int level = 0; + switch (entry.kind) + { + case DctKind::EndOfBlock: + skipBits(entry.length); + if (!mpeg1 && (sum & 1) == 0) + block[63] ^= 1; // MPEG-2 mismatch control + return true; + case DctKind::RunLevel: + skipBits(entry.length); + run = entry.run; + level = getBits(1) != 0u ? -static_cast(entry.level) : entry.level; + break; + case DctKind::Escape: + skipBits(entry.length); + run = static_cast(getBits(6)); + if (mpeg1) + { + level = static_cast(getBits(8)); + if (level == 0) + level = static_cast(getBits(8)); + else if (level == -128) + level = static_cast(getBits(8)) - 256; + } + else + { + level = static_cast(getBits(12)); + if (level & 0x800) + level -= 0x1000; + } + break; + default: + return false; + } + + index += run + 1; + if (index > 63) + return false; + const int position = scan[index]; + int32_t magnitude = (std::abs(level) * m_quantizerScale * m_intraMatrix[position]) >> 4; + if (mpeg1 && magnitude != 0 && (magnitude & 1) == 0) + magnitude -= 1; // MPEG-1 oddification + int32_t value = level < 0 ? -magnitude : magnitude; + value = std::clamp(value, -2048, 2047); + block[position] = static_cast(value); + sum += value; + } +} + +void Ps2Ipu::inverseDct(const int16_t *coefficients, uint8_t *destination, int stride) const +{ + const auto &c = tables().cosine; + double rows[8][8]; + for (int y = 0; y < 8; ++y) + { + for (int x = 0; x < 8; ++x) + { + double value = 0.0; + for (int u = 0; u < 8; ++u) + value += c[x][u] * coefficients[y * 8 + u]; + rows[y][x] = value; + } + } + for (int x = 0; x < 8; ++x) + { + for (int y = 0; y < 8; ++y) + { + double value = 0.0; + for (int v = 0; v < 8; ++v) + value += c[y][v] * rows[v][x]; + destination[y * stride + x] = clampByte(static_cast(std::floor(value / 4.0 + 0.5))); + } + } +} + +void Ps2Ipu::emitMacroblock(const uint8_t *luma, const uint8_t *cb, const uint8_t *cr, bool rgb16, bool dither) +{ + static constexpr int kDither[4][4] = {{-4, 0, -3, 1}, {2, -2, 3, -1}, {-3, 1, -4, 0}, {3, -1, 2, -2}}; + for (int y = 0; y < 16; ++y) + { + for (int x = 0; x < 16; ++x) + { + // ITU-R BT.601 studio range, in the IPU's 1/128 fixed-point coefficients. + const int lum = 0x95 * (luma[y * 16 + x] - 16); + const int u = cb[(y / 2) * 8 + x / 2] - 128; + const int v = cr[(y / 2) * 8 + x / 2] - 128; + int r = (lum + 0xCC * v) >> 7; + int g = (lum - 0x68 * v - 0x32 * u) >> 7; + int b = (lum + 0x102 * u) >> 7; + r = std::clamp(r, 0, 255); + g = std::clamp(g, 0, 255); + b = std::clamp(b, 0, 255); + + uint8_t alpha = 0x80; + if (static_cast(r) < m_threshold0 && static_cast(g) < m_threshold0 && static_cast(b) < m_threshold0) + alpha = 0; + else if (static_cast(r) < m_threshold1 && static_cast(g) < m_threshold1 && static_cast(b) < m_threshold1) + alpha = 0x40; + + if (!rgb16) + { + m_output.push_back(static_cast(r)); + m_output.push_back(static_cast(g)); + m_output.push_back(static_cast(b)); + m_output.push_back(alpha); + continue; + } + if (dither) + { + const int offset = kDither[y & 3][x & 3]; + r = std::clamp(r + offset, 0, 255); + g = std::clamp(g + offset, 0, 255); + b = std::clamp(b + offset, 0, 255); + } + const uint16_t packed = static_cast((r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10) | (alpha == 0x40 ? 0x8000 : 0)); + m_output.push_back(static_cast(packed & 0xFFu)); + m_output.push_back(static_cast(packed >> 8u)); + } + } +} diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index bdbef474de..28e7378d82 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -410,6 +410,14 @@ uint32_t PS2Memory::advanceEeTimers(uint64_t eeCycles) noexcept { return 0u; } + try + { + m_ipu.advance(eeCycles); + serviceIpuDma(); + } + catch (...) + { + } constexpr uint32_t kGifStat = 0x10003020u; constexpr uint32_t kGifFqcMask = 0x1F000000u; @@ -840,6 +848,14 @@ __m128i PS2Memory::read128(uint32_t address) const bool scratch = isScratchpad(address); uint32_t physAddr = translateAddress(address); + if (!scratch && physAddr == 0x10007000u) // IPU_out_FIFO + { + alignas(16) uint8_t fifoData[16]; + m_ipu.readOutputFifo(fifoData); + serviceIpuDma(); + return _mm_loadu_si128(reinterpret_cast(fifoData)); + } + if (scratch) { inRange(physAddr, sizeof(__m128i), PS2_SCRATCHPAD_SIZE, "read128 scratchpad", address); @@ -1084,6 +1100,13 @@ void PS2Memory::write128(uint32_t address, __m128i value) processVIF1Data(fifoData, sizeof(fifoData)); return; } + if (!scratch && physAddr == 0x10007010u) // IPU_in_FIFO + { + alignas(16) uint8_t fifoData[16]; + _mm_storeu_si128(reinterpret_cast<__m128i *>(fifoData), value); + m_ipu.writeInputFifo(fifoData); + return; + } if (scratch) { @@ -1179,22 +1202,9 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) return true; } - if (address >= 0x10002000 && address <= 0x10002030) + if (address >= Ps2Ipu::kRegCmd && address < Ps2Ipu::kRegTop + 0x10u) { - if (address == 0x10002010) - { - m_ioRegisters[address] = value & ~(1u << 31); - if (value & (1u << 30)) - { - m_ioRegisters[0x10002000] = 0; - m_ioRegisters[0x10002020] = 0; - m_ioRegisters[0x10002030] = 0; - } - } - else - { - m_ioRegisters[address] = value; - } + m_ipu.writeRegister(address, value); return true; } @@ -1306,7 +1316,7 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) const uint32_t qwc = m_ioRegisters[channelBase + 0x20]; m_dmaStartCount.fetch_add(1, std::memory_order_relaxed); - if (tryProcessScratchpadDma(channelBase, value)) + if (tryProcessScratchpadDma(channelBase, value) || tryStartIpuDma(channelBase, value)) { return true; } @@ -1646,6 +1656,144 @@ bool PS2Memory::tryProcessScratchpadDma(uint32_t channelBase, uint32_t chcr) return true; } +bool PS2Memory::appendDmaSource(uint32_t address, uint32_t bytes, std::vector &out) const +{ + const uint32_t eeAddress = ps2DmacAddressToEe(address); + const bool scratch = isScratchpad(eeAddress); + uint32_t offset = 0u; + try + { + offset = const_cast(this)->translateAddress(eeAddress); + } + catch (const std::exception &) + { + return false; + } + const uint8_t *base = scratch ? m_scratchpad : m_rdram; + const uint32_t limit = scratch ? PS2_SCRATCHPAD_SIZE : PS2_RAM_SIZE; + if (offset > limit || bytes > limit - offset) + return false; + out.insert(out.end(), base + offset, base + offset + bytes); + return true; +} + +bool PS2Memory::tryStartIpuDma(uint32_t channelBase, uint32_t chcr) +{ + static constexpr uint32_t kFromIpu = 0x1000B000u; + static constexpr uint32_t kToIpu = 0x1000B400u; + if (channelBase == kFromIpu) + { + // Real IPU output trails the CPU, so games re-arm fromIPU before any data moves; drain lazily. + return true; + } + if (channelBase != kToIpu) + return false; + + // The whole source is copied up front; the IPU still only exposes 8 quadwords of it at a time. + std::vector data; + const uint32_t mode = (chcr >> 2u) & 0x3u; + uint32_t madr = m_ioRegisters[channelBase + 0x10u]; + uint32_t qwc = m_ioRegisters[channelBase + 0x20u] & 0xFFFFu; + if (mode == 0u) + { + appendDmaSource(madr, qwc * 16u, data); + madr += qwc * 16u; + qwc = 0u; + } + else + { + uint32_t tadr = m_ioRegisters[channelBase + 0x30u]; + uint32_t asr[2] = {m_ioRegisters[channelBase + 0x40u], m_ioRegisters[channelBase + 0x50u]}; + uint32_t asp = (chcr >> 4u) & 0x3u; + for (int tags = 0; tags < 4096; ++tags) + { + std::vector tagBytes; + if (!appendDmaSource(tadr, 16u, tagBytes)) + break; + uint64_t tag = 0; + std::memcpy(&tag, tagBytes.data(), sizeof(tag)); + const uint32_t tagQwc = static_cast(tag & 0xFFFFu); + const uint32_t id = static_cast((tag >> 28u) & 0x7u); + const uint32_t addr = static_cast(tag >> 32u); + const bool irq = ((tag >> 31u) & 1u) != 0u; + bool end = false; + uint32_t dataAddr = tadr + 16u; + switch (id) + { + case 0: // refe + dataAddr = addr; + end = true; + break; + case 1: // cnt + tadr = dataAddr + tagQwc * 16u; + break; + case 2: // next + tadr = addr; + break; + case 3: // ref + case 4: // refs + dataAddr = addr; + tadr += 16u; + break; + case 5: // call + if (asp < 2u) + asr[asp++] = dataAddr + tagQwc * 16u; + tadr = addr; + break; + case 6: // ret + if (asp > 0u) + tadr = asr[--asp]; + else + end = true; + break; + default: // end + end = true; + break; + } + appendDmaSource(dataAddr, tagQwc * 16u, data); + if (end || (irq && (chcr & (1u << 7)) != 0u)) + break; + } + m_ioRegisters[channelBase + 0x30u] = tadr; + qwc = 0u; + } + m_ioRegisters[channelBase + 0x10u] = madr; + m_ioRegisters[channelBase + 0x20u] = qwc; + m_ipu.feedDmaInput(data.data(), data.size()); + completeDmacChannel(channelBase, 4u); + return true; +} + +void PS2Memory::serviceIpuDma() +{ + static constexpr uint32_t kFromIpu = 0x1000B000u; + uint32_t &chcr = m_ioRegisters[kFromIpu]; + if ((chcr & 0x100u) == 0u) + return; + uint32_t &madr = m_ioRegisters[kFromIpu + 0x10u]; + uint32_t &qwc = m_ioRegisters[kFromIpu + 0x20u]; + // fromIPU stays active until the decoder has produced the requested quadwords. + while ((qwc & 0xFFFFu) != 0u && m_ipu.outputBytes() >= 16u) + { + alignas(16) uint8_t quadword[16]; + m_ipu.takeOutput(quadword, sizeof(quadword)); + const uint32_t eeAddress = ps2DmacAddressToEe(madr); + const bool scratch = isScratchpad(eeAddress); + const uint32_t offset = translateAddress(eeAddress); + uint8_t *base = scratch ? m_scratchpad : m_rdram; + const uint32_t limit = scratch ? PS2_SCRATCHPAD_SIZE : PS2_RAM_SIZE; + if (offset + 16u > limit) + break; + std::memcpy(base + offset, quadword, sizeof(quadword)); + if (!scratch) + markModified(offset, sizeof(quadword)); + madr += 16u; + qwc = (qwc & 0xFFFF0000u) | ((qwc & 0xFFFFu) - 1u); + } + if ((qwc & 0xFFFFu) == 0u) + completeDmacChannel(kFromIpu, 3u); +} + void PS2Memory::completeDmacChannel(uint32_t channelBase, uint32_t cause) { static constexpr uint32_t kDStat = 0x1000E010u; @@ -2320,26 +2468,14 @@ uint32_t PS2Memory::readIORegister(uint32_t address) return 0u; } - if (address >= 0x10002000 && address <= 0x10002030) + if (address >= Ps2Ipu::kRegCmd && address < Ps2Ipu::kRegTop + 0x10u) { - uint32_t val = 0; - switch (address) - { - case 0x10002000: - val = m_ioRegisters[address]; - break; - case 0x10002010: - val = m_ioRegisters[address] & ~(1u << 31); - break; - case 0x10002020: - case 0x10002030: - val = m_ioRegisters[address]; - break; - default: - val = 0; - break; - } - return val; + return m_ipu.readRegister(address); + } + + if (address == 0x1000E010u || (address >= 0x1000B000u && address < 0x1000B040u)) + { + serviceIpuDma(); } if (address == 0x10003020u) // GIF_STAT @@ -2362,6 +2498,12 @@ uint32_t PS2Memory::readIORegister(uint32_t address) { if (address >= 0x10008000 && address < 0x1000F000) { + // IPU channels finish only when the decoder has consumed or produced the data, so report STR as-is. + if (address == 0x1000B000u || address == 0x1000B400u) + { + serviceIpuDma(); + return m_ioRegisters[address]; + } if ((address & 0xFF) == 0x00) { uint32_t channelStatus = m_ioRegisters[address] & ~0x100u; diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index 683cea4dc0..6f15006a60 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -1180,6 +1180,111 @@ void register_ps2_memory_tests() "SPR-flagged REFE data should come from scratchpad, not RDRAM"); }); + tc.Run("IPU FDEC peeks and advances the bitstream fed by toIPU DMA", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kToIpu = 0x1000B400u; + constexpr uint32_t kSrc = 0x00030000u; + uint8_t *rdram = mem.getRDRAM(); + const uint8_t stream[16] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0}; + std::memcpy(rdram + kSrc, stream, sizeof(stream)); + + mem.writeIORegister(kToIpu + 0x10u, kSrc); + mem.writeIORegister(kToIpu + 0x20u, 1u); + mem.writeIORegister(kToIpu + 0x00u, 0x101u); + t.IsTrue((mem.readIORegister(kToIpu) & 0x100u) == 0u, "toIPU should finish once its data is queued"); + + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000000u); // FDEC, FB=0 + t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd), 0x12345678u, "FDEC 0 should peek the first 32 bits"); + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000008u); // FDEC, FB=8 + t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd), 0x3456789Au, "FDEC 8 should advance one byte before peeking"); + t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd + 4u) & 0x80000000u, 0u, "FDEC should not stay busy"); + }); + + tc.Run("IPU IDEC decodes an intra macroblock into RGB32 for fromIPU DMA", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + // One intra macroblock with zero DC differentials and immediate end-of-block, then a start code. + std::vector stream; + uint32_t accumulator = 0u; + uint32_t bits = 0u; + auto put = [&](uint32_t value, uint32_t count) + { + for (uint32_t i = count; i-- > 0u;) + { + accumulator = (accumulator << 1u) | ((value >> i) & 1u); + if (++bits == 8u) + { + stream.push_back(static_cast(accumulator)); + accumulator = 0u; + bits = 0u; + } + } + }; + put(1u, 1u); // macroblock_type: intra + for (int block = 0; block < 4; ++block) + { + put(0x4u, 3u); // luma dct_dc_size 0 + put(0x2u, 2u); // end of block (table B.14) + } + for (int block = 0; block < 2; ++block) + { + put(0x0u, 2u); // chroma dct_dc_size 0 + put(0x2u, 2u); + } + while (bits != 0u) + put(0u, 1u); + for (uint8_t byte : {0x00, 0x00, 0x01, 0xB0}) + put(byte, 8u); + while (stream.size() % 16u != 0u) + stream.push_back(0u); + + constexpr uint32_t kToIpu = 0x1000B400u; + constexpr uint32_t kFromIpu = 0x1000B000u; + constexpr uint32_t kSrc = 0x00030000u; + constexpr uint32_t kDst = 0x00040000u; + uint8_t *rdram = mem.getRDRAM(); + std::memcpy(rdram + kSrc, stream.data(), stream.size()); + std::memset(rdram + kDst, 0xEE, 1024u + 16u); + + mem.writeIORegister(kToIpu + 0x10u, kSrc); + mem.writeIORegister(kToIpu + 0x20u, static_cast(stream.size() / 16u)); + mem.writeIORegister(kToIpu + 0x00u, 0x101u); + // Mana Khemia arms fromIPU for the whole picture, starts IDEC, then re-arms it per chunk + // before the (slow, on hardware) IPU has produced anything; the re-arm must win. + mem.writeIORegister(kFromIpu + 0x10u, kDst + 0x2000u); + mem.writeIORegister(kFromIpu + 0x20u, 128u); + mem.writeIORegister(kFromIpu + 0x00u, 0x100u); + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x10010000u); // IDEC, QSC=1, RGB32 + mem.writeIORegister(kFromIpu + 0x10u, kDst); + mem.writeIORegister(kFromIpu + 0x20u, 64u); + mem.writeIORegister(kFromIpu + 0x00u, 0x100u); + t.IsTrue((mem.readIORegister(kFromIpu) & 0x100u) != 0u, "fromIPU should wait while the IPU is still decoding"); + mem.advanceEeTimers(Ps2Ipu::kEeCyclesPerMacroblock); + const uint32_t ctrl = mem.readIORegister(Ps2Ipu::kRegCtrl); + t.Equals(ctrl & (1u << 14), 0u, "IDEC should not report a decode error"); + t.IsTrue((ctrl & (1u << 15)) != 0u, "IDEC should stop at the start code"); + t.IsTrue((mem.readIORegister(kFromIpu) & 0x100u) == 0u, "fromIPU should complete with one macroblock"); + + bool uniform = true; + for (uint32_t i = 0; i < 256u; ++i) + { + const uint8_t *pixel = rdram + kDst + i * 4u; + if (pixel[0] != 130u || pixel[1] != 130u || pixel[2] != 130u || pixel[3] != 0x80u) + uniform = false; + } + t.IsTrue(uniform, "a flat DC-only macroblock should convert to uniform mid gray with alpha 0x80"); + t.Equals(rdram[kDst + 1024u], static_cast(0xEEu), "fromIPU must not write past its QWC"); + t.Equals(rdram[kDst + 0x2000u], static_cast(0u), "the superseded fromIPU request must not receive output"); + + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000000u); // FDEC + t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd), 0x000001B0u, "IDEC should leave the start code unconsumed"); + }); + tc.Run("native GIF image upload recognizes canonical load-image chain", [](TestCase &t) { PS2Memory mem; From 0982658c36b36f102d9c2eca4097d74fb07cc7cf Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:40:48 -0300 Subject: [PATCH 17/91] Serve libpad2 sockets and libvib actuator calls from host input --- .../tools/ghidra/ExportPS2Functions.java | 4 +- ps2xRuntime/include/ps2_call_list.h | 9 + ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp | 180 ++++++++++++++++++ ps2xRuntime/src/lib/Kernel/Stubs/Pad.h | 9 + ps2xTest/src/pad_input_tests.cpp | 60 ++++++ 5 files changed, 261 insertions(+), 1 deletion(-) diff --git a/ps2xRecomp/tools/ghidra/ExportPS2Functions.java b/ps2xRecomp/tools/ghidra/ExportPS2Functions.java index 8e28151884..cf7ccbef4f 100644 --- a/ps2xRecomp/tools/ghidra/ExportPS2Functions.java +++ b/ps2xRecomp/tools/ghidra/ExportPS2Functions.java @@ -171,7 +171,9 @@ public class ExportPS2Functions extends GhidraScript { "sceMpegCreate", "sceMpegDelete", "sceMpegDemuxPss", "sceMpegDemuxPssRing", "sceMpegDispCenterOffX", "sceMpegDispCenterOffY", "sceMpegDispHeight", "sceMpegDispWidth", "sceMpegGetDecodeMode", "sceMpegGetPicture", "sceMpegGetPictureRAW8", "sceMpegGetPictureRAW8xy", "sceMpegInit", "sceMpegIsEnd", "sceMpegIsRefBuffEmpty", "sceMpegReset", "sceMpegResetDefaultPtsGap", "sceMpegSetDecodeMode", - "sceMpegSetDefaultPtsGap", "sceMpegSetImageBuff", "sceOpen", "scePadEnd", "scePadEnterPressMode", "scePadExitPressMode", + "sceMpegSetDefaultPtsGap", "sceMpegSetImageBuff", "sceOpen", "scePad2CreateSocket", "scePad2DeleteSocket", "scePad2End", + "scePad2GetButtonProfile", "scePad2GetState", "scePad2Init", "scePad2Read", "scePadEnd", + "sceVibGetProfile", "sceVibSetActParam", "scePadEnterPressMode", "scePadExitPressMode", "scePadGetButtonMask", "scePadGetDmaStr", "scePadGetFrameCount", "scePadGetModVersion", "scePadGetPortMax", "scePadGetReqState", "scePadGetSlotMax", "scePadGetState", "scePadInfoAct", "scePadInfoComb", "scePadInfoMode", "scePadInfoPressMode", "scePadInit", "scePadInit2", "scePadPortClose", "scePadPortOpen", "scePadRead", "scePadReqIntToStr", diff --git a/ps2xRuntime/include/ps2_call_list.h b/ps2xRuntime/include/ps2_call_list.h index 666ccc0f2f..9ce2cf4817 100644 --- a/ps2xRuntime/include/ps2_call_list.h +++ b/ps2xRuntime/include/ps2_call_list.h @@ -465,6 +465,13 @@ X(sceMpegSetDefaultPtsGap) \ X(sceMpegSetImageBuff) \ X(sceOpen) \ + X(scePad2CreateSocket) \ + X(scePad2DeleteSocket) \ + X(scePad2End) \ + X(scePad2GetButtonProfile) \ + X(scePad2GetState) \ + X(scePad2Init) \ + X(scePad2Read) \ X(scePadEnd) \ X(scePadEnterPressMode) \ X(scePadExitPressMode) \ @@ -644,6 +651,8 @@ X(sceTtyRead) \ X(sceTtyWrite) \ X(sceVpu0Reset) \ + X(sceVibGetProfile) \ + X(sceVibSetActParam) \ X(sceVu0AddVector) \ X(sceVu0ApplyMatrix) \ X(sceVu0CameraMatrix) \ diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp index b50a4cf6aa..b9551a32eb 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp @@ -15,6 +15,16 @@ namespace ps2_stubs constexpr int32_t kPadStateStable = 6; constexpr size_t kPadPortCount = 2; constexpr size_t kPadSlotCount = 1; + // libpad2 (scePad2*) talks to dbcman on the IOP; these handlers serve its sockets from the + // same host input as libpad. + constexpr int32_t kPad2StateNoLink = 0; + constexpr int32_t kPad2StateStable = 1; + constexpr int32_t kPad2ReadSize = 18; // buttons, four sticks, twelve pressures + constexpr int32_t kPad2ButtonProfileSize = 4; // one bit per DualShock 2 input + constexpr size_t kPad2SocketCount = 8; + // libvib: a DualShock 2 has a small on/off motor and a large variable one. + constexpr uint8_t kVibDualShock2Profile = 0x03; + constexpr int32_t kVibProfileSize = 1; constexpr uint16_t kPadBtnSelect = 1u << 0; constexpr uint16_t kPadBtnL3 = 1u << 1; @@ -65,6 +75,14 @@ namespace ps2_stubs bool g_padOverrideEnabled = false; PadInputState g_padOverrideState{}; PadPortState g_padPorts[kPadPortCount]{}; + + struct Pad2Socket + { + bool open = false; + int port = 0; + int slot = 0; + }; + Pad2Socket g_pad2Sockets[kPad2SocketCount]{}; int g_padReadLogCount = 0; uint8_t axisToByte(float axis) @@ -798,6 +816,168 @@ namespace ps2_stubs setReturnS32(ctx, 0); } + void scePad2Init(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)rdram; + (void)runtime; + { + std::lock_guard lock(g_padStateMutex); + resetPadStateLocked(); + for (Pad2Socket &socket : g_pad2Sockets) + { + socket = Pad2Socket{}; + } + } + setReturnS32(ctx, 1); + } + + void scePad2End(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + scePad2Init(rdram, ctx, runtime); + } + + void scePad2CreateSocket(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)runtime; + // scePad2SocketParam: { int option; int port; int slot; int number; } + const uint32_t paramAddr = getRegU32(ctx, 4); + const uint8_t *param = getConstMemPtr(rdram, paramAddr); + if (!param) + { + setReturnS32(ctx, -1); + return; + } + + int32_t port = 0; + int32_t slot = 0; + std::memcpy(&port, param + 4, sizeof(port)); + std::memcpy(&slot, param + 8, sizeof(slot)); + + std::lock_guard lock(g_padStateMutex); + PadPortState *portState = lookupPadPortStateLocked(port, slot); + if (!portState) + { + setReturnS32(ctx, -1); + return; + } + + for (size_t i = 0; i < kPad2SocketCount; ++i) + { + Pad2Socket &socket = g_pad2Sockets[i]; + if (socket.open) + { + continue; + } + socket = Pad2Socket{true, port, slot}; + initializePadPortLocked(*portState, getRegU32(ctx, 5)); + // dbcman drives the DualShock 2 in analog mode with pressure reporting from the start. + portState->analogMode = true; + portState->pressureEnabled = true; + setReturnS32(ctx, static_cast(i)); + return; + } + setReturnS32(ctx, -1); + } + + void scePad2DeleteSocket(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)rdram; + (void)runtime; + const uint32_t index = getRegU32(ctx, 4); + std::lock_guard lock(g_padStateMutex); + if (index >= kPad2SocketCount || !g_pad2Sockets[index].open) + { + setReturnS32(ctx, 0); + return; + } + g_pad2Sockets[index] = Pad2Socket{}; + setReturnS32(ctx, 1); + } + + void scePad2GetState(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)rdram; + (void)runtime; + const uint32_t index = getRegU32(ctx, 4); + std::lock_guard lock(g_padStateMutex); + const bool open = index < kPad2SocketCount && g_pad2Sockets[index].open; + setReturnS32(ctx, open ? kPad2StateStable : kPad2StateNoLink); + } + + void scePad2GetButtonProfile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)runtime; + const uint32_t index = getRegU32(ctx, 4); + uint8_t *profile = getMemPtr(rdram, getRegU32(ctx, 5)); + { + std::lock_guard lock(g_padStateMutex); + if (index >= kPad2SocketCount || !g_pad2Sockets[index].open || !profile) + { + setReturnS32(ctx, 0); + return; + } + } + std::memset(profile, 0xFF, kPad2ButtonProfileSize); + setReturnS32(ctx, kPad2ButtonProfileSize); + } + + void scePad2Read(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + const uint32_t index = getRegU32(ctx, 4); + const uint32_t dataAddr = getRegU32(ctx, 5); + uint8_t *data = getMemPtr(rdram, dataAddr); + + Pad2Socket socket; + { + std::lock_guard lock(g_padStateMutex); + if (index < kPad2SocketCount) + { + socket = g_pad2Sockets[index]; + } + } + + // libpad2 data is libpad's status block without the two-byte header. + uint8_t status[32]{}; + if (!socket.open || !data || !readPadPortData(socket.port, socket.slot, runtime, status, dataAddr)) + { + setReturnS32(ctx, 0); + return; + } + ps2TraceGuestRangeWrite(rdram, dataAddr, kPad2ReadSize, "scePad2Read", ctx); + std::memcpy(data, status + 2, kPad2ReadSize); + setReturnS32(ctx, kPad2ReadSize); + } + + void sceVibGetProfile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)runtime; + // Games size the actuator data they send from this return value, so it must never be negative + // garbage: a failed dbcman RPC here makes sceVibSetActParam overrun its stack buffer. + const uint32_t index = getRegU32(ctx, 4); + uint8_t *profile = getMemPtr(rdram, getRegU32(ctx, 5)); + { + std::lock_guard lock(g_padStateMutex); + if (index >= kPad2SocketCount || !g_pad2Sockets[index].open || !profile) + { + setReturnS32(ctx, 0); + return; + } + } + profile[0] = kVibDualShock2Profile; + setReturnS32(ctx, kVibProfileSize); + } + + void sceVibSetActParam(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)rdram; + (void)runtime; + // No host rumble yet; accept the motor values so the game's actuator updates succeed. + const uint32_t index = getRegU32(ctx, 4); + std::lock_guard lock(g_padStateMutex); + const bool open = index < kPad2SocketCount && g_pad2Sockets[index].open; + setReturnS32(ctx, open ? 1 : 0); + } + PadDebugSnapshot getPadDebugSnapshot() { PadDebugSnapshot snapshot{}; diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Pad.h b/ps2xRuntime/src/lib/Kernel/Stubs/Pad.h index c4de234cf5..cca90fae32 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Pad.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Pad.h @@ -8,6 +8,15 @@ namespace ps2_stubs { void PadSyncCallback(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2CreateSocket(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2DeleteSocket(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2End(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2GetButtonProfile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2GetState(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2Init(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void scePad2Read(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void sceVibGetProfile(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + void sceVibSetActParam(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void scePadEnd(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void scePadEnterPressMode(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void scePadExitPressMode(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); diff --git a/ps2xTest/src/pad_input_tests.cpp b/ps2xTest/src/pad_input_tests.cpp index 07f9b7a29d..f5fbe70587 100644 --- a/ps2xTest/src/pad_input_tests.cpp +++ b/ps2xTest/src/pad_input_tests.cpp @@ -3,6 +3,7 @@ #include "ps2_syscalls.h" #include "Stubs/Pad.h" +#include #include #include #include @@ -93,6 +94,65 @@ void register_pad_input_tests() closePadPort(ctx, rdram); }); + tc.Run("scePad2 sockets read host input in libpad2's headerless layout", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0); + R5900Context ctx; + constexpr uint32_t kParamAddr = kPadDataAddr + 0x100u; + constexpr uint32_t kProfileAddr = kPadDataAddr + 0x140u; + + ps2_stubs::scePad2Init(rdram.data(), &ctx, nullptr); + + const int32_t param[4] = {2, 0, 0, 0}; // option, port, slot, number + std::memcpy(rdram.data() + kParamAddr, param, sizeof(param)); + setRegU32(ctx, 4, kParamAddr); + setRegU32(ctx, 5, kPadDataAddr + 0x200u); + ps2_stubs::scePad2CreateSocket(rdram.data(), &ctx, nullptr); + const int32_t socket = static_cast(getRegU32(&ctx, 2)); + t.Equals(socket, 0, "first socket should be number 0"); + + setRegU32(ctx, 4, static_cast(socket)); + ps2_stubs::scePad2GetState(rdram.data(), &ctx, nullptr); + t.Equals(static_cast(getRegU32(&ctx, 2)), 1, "an open socket should report the stable state"); + + setRegU32(ctx, 4, static_cast(socket)); + setRegU32(ctx, 5, kProfileAddr); + ps2_stubs::scePad2GetButtonProfile(rdram.data(), &ctx, nullptr); + t.Equals(static_cast(getRegU32(&ctx, 2)), 4, "a DualShock 2 profile is four bytes"); + t.Equals(rdram[kProfileAddr + 3], static_cast(0xFF), "every DualShock 2 input should be present"); + + const uint16_t buttons = static_cast(0xFFFFu & ~kPadBtnCross & ~kPadBtnUp); + ps2_stubs::setPadOverrideState(buttons, 0x01, 0x02, 0x03, 0x04); + setRegU32(ctx, 4, static_cast(socket)); + setRegU32(ctx, 5, kPadDataAddr); + ps2_stubs::scePad2Read(rdram.data(), &ctx, nullptr); + ps2_stubs::clearPadOverrideState(); + + const uint8_t *data = rdram.data() + kPadDataAddr; + t.Equals(static_cast(getRegU32(&ctx, 2)), 18, "scePad2Read should report 18 bytes"); + t.Equals(static_cast(data[0] | (data[1] << 8)), buttons, "buttons should start at byte 0, active-low"); + t.Equals(data[2], static_cast(0x03), "rx follows the buttons"); + t.Equals(data[5], static_cast(0x02), "ly is the last stick byte"); + t.Equals(data[12], static_cast(0xFF), "cross pressure should be full while held"); + t.Equals(data[13], static_cast(0x00), "square pressure should be zero when released"); + + setRegU32(ctx, 4, static_cast(socket)); + setRegU32(ctx, 5, kProfileAddr); + ps2_stubs::sceVibGetProfile(rdram.data(), &ctx, nullptr); + t.Equals(static_cast(getRegU32(&ctx, 2)), 1, "a DualShock 2 vibration profile is one byte; games size actuator data from it"); + t.Equals(rdram[kProfileAddr], static_cast(0x03), "both DualShock 2 motors should be reported"); + + setRegU32(ctx, 4, static_cast(socket)); + ps2_stubs::sceVibSetActParam(rdram.data(), &ctx, nullptr); + t.Equals(static_cast(getRegU32(&ctx, 2)), 1, "actuator updates on an open socket should succeed"); + + setRegU32(ctx, 4, static_cast(socket)); + ps2_stubs::scePad2DeleteSocket(rdram.data(), &ctx, nullptr); + setRegU32(ctx, 4, static_cast(socket)); + ps2_stubs::scePad2GetState(rdram.data(), &ctx, nullptr); + t.Equals(static_cast(getRegU32(&ctx, 2)), 0, "a deleted socket should report no link"); + }); + tc.Run("scePadRead button bits are active-low", [](TestCase &t) { std::vector rdram(PS2_RAM_SIZE, 0); From ee2ad9098dfe669204767d51ebf4e70c5b113d69 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:37:20 -0300 Subject: [PATCH 18/91] Test the full 64-bit register in sign-comparing branches --- ps2xRecomp/src/lib/control_flow_emitter.cpp | 9 ++++--- ps2xTest/src/code_generator_tests.cpp | 28 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/ps2xRecomp/src/lib/control_flow_emitter.cpp b/ps2xRecomp/src/lib/control_flow_emitter.cpp index 4bf8dc550c..d1e68cbb92 100644 --- a/ps2xRecomp/src/lib/control_flow_emitter.cpp +++ b/ps2xRecomp/src/lib/control_flow_emitter.cpp @@ -404,12 +404,13 @@ namespace ps2recomp case OPCODE_BNE: case OPCODE_BNEL: return fmt::format("GPR_U64(ctx, {}) != GPR_U64(ctx, {})", rsReg, rtReg); + // Sign branches test all 64 bits: an ld of an IPU register yields 0x00000000_8xxxxxxx, which is positive. case OPCODE_BLEZ: case OPCODE_BLEZL: - return fmt::format("GPR_S32(ctx, {}) <= 0", rsReg); + return fmt::format("GPR_S64(ctx, {}) <= 0", rsReg); case OPCODE_BGTZ: case OPCODE_BGTZL: - return fmt::format("GPR_S32(ctx, {}) > 0", rsReg); + return fmt::format("GPR_S64(ctx, {}) > 0", rsReg); case OPCODE_REGIMM: switch (m_branchInst.rt) { @@ -417,12 +418,12 @@ namespace ps2recomp case REGIMM_BLTZL: case REGIMM_BLTZAL: case REGIMM_BLTZALL: - return fmt::format("GPR_S32(ctx, {}) < 0", rsReg); + return fmt::format("GPR_S64(ctx, {}) < 0", rsReg); case REGIMM_BGEZ: case REGIMM_BGEZL: case REGIMM_BGEZAL: case REGIMM_BGEZALL: - return fmt::format("GPR_S32(ctx, {}) >= 0", rsReg); + return fmt::format("GPR_S64(ctx, {}) >= 0", rsReg); default: return "false"; } diff --git a/ps2xTest/src/code_generator_tests.cpp b/ps2xTest/src/code_generator_tests.cpp index 463b6cff1a..25c7628bbc 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -1719,6 +1719,34 @@ void register_code_generator_tests() t.IsTrue(generated.find("if (branch_taken_0x1200)") != std::string::npos, "should generate branch_taken variable and if for likely branch"); }); + tc.Run("sign branches compare the full 64-bit register", [](TestCase &t) { + // libmpeg polls IPU_CMD with ld and bgezl; a 32-bit test reads DATA bit 31 as BUSY. + const auto conditionFor = [](uint8_t opcode, uint8_t rt) { + Function func; + func.name = "sign_branch"; + func.start = 0x1300; + func.end = 0x1310; + func.isRecompiled = true; + func.isStub = false; + + Instruction br{}; + br.address = 0x1300; + br.opcode = opcode; + br.rs = 17; + br.rt = rt; + br.simmediate = 1; + br.isBranch = true; + br.hasDelaySlot = true; + + CodeGenerator gen({}, {}); + return gen.generateFunction(func, {br, makeNop(0x1304), makeNop(0x1308)}, false); + }; + t.IsTrue(conditionFor(OPCODE_REGIMM, REGIMM_BGEZL).find("GPR_S64(ctx, 17) >= 0") != std::string::npos, "bgezl should test 64 bits"); + t.IsTrue(conditionFor(OPCODE_REGIMM, REGIMM_BLTZ).find("GPR_S64(ctx, 17) < 0") != std::string::npos, "bltz should test 64 bits"); + t.IsTrue(conditionFor(OPCODE_BLEZ, 0).find("GPR_S64(ctx, 17) <= 0") != std::string::npos, "blez should test 64 bits"); + t.IsTrue(conditionFor(OPCODE_BGTZ, 0).find("GPR_S64(ctx, 17) > 0") != std::string::npos, "bgtz should test 64 bits"); + }); + tc.Run("JR $31 returns through dynamic target without broad local switch", [](TestCase &t) { Function func; func.name = "jr_ra_return"; From 55b73e5e695e117d8b214a7e4186b69d58a28a41 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:37:20 -0300 Subject: [PATCH 19/91] Return the thread id from SuspendThread and ResumeThread --- .../src/lib/Kernel/Syscalls/Thread.cpp | 16 +++++++++++---- ps2xTest/src/ps2_runtime_kernel_tests.cpp | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp b/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp index 0216b3176a..c3400aaa35 100644 --- a/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp +++ b/ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp @@ -314,16 +314,24 @@ namespace ps2_syscalls void SuspendThread(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { EeScheduler &ee = scheduler(rdram, ctx, runtime); - const int result = ee.suspendThread(static_cast(getRegU32(ctx, 4)), false); - setReturnS32(ctx, result); + int id = static_cast(getRegU32(ctx, 4)); + if (id == 0) + { + id = ee.currentThreadId(); + } + // The EE kernel returns the thread id on success; CRI middleware checks for it. + const int result = ee.suspendThread(id, false); + setReturnS32(ctx, result == KE_OK ? id : result); ee.transferIfRequested(false); } void ResumeThread(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { EeScheduler &ee = scheduler(rdram, ctx, runtime); - const int result = ee.resumeThread(static_cast(getRegU32(ctx, 4)), false); - setReturnS32(ctx, result); + const int id = static_cast(getRegU32(ctx, 4)); + // CRI Sofdec only wakes its decoder after ResumeThread(t) == t, as on the real kernel. + const int result = ee.resumeThread(id, false); + setReturnS32(ctx, result == KE_OK ? id : result); ee.transferIfRequested(false); } diff --git a/ps2xTest/src/ps2_runtime_kernel_tests.cpp b/ps2xTest/src/ps2_runtime_kernel_tests.cpp index 2328c7ea9a..3ef2889878 100644 --- a/ps2xTest/src/ps2_runtime_kernel_tests.cpp +++ b/ps2xTest/src/ps2_runtime_kernel_tests.cpp @@ -773,6 +773,26 @@ void register_ps2_runtime_kernel_tests() t.Equals(getRegS32(env.ctx, 2), KE_UNKNOWN_THID, "an unknown thread should still return an error code"); }); + tc.Run("SuspendThread and ResumeThread return the thread id on success", [](TestCase &t) + { + TestEnv env; + EeScheduler &ee = env.runtime.eeScheduler(); + ee.reset(env.rdram.data(), env.ctx); + ee.bindMainContextForSyscall(env.ctx, env.rdram.data()); + + const int id = ee.createThread(EeThreadCreateParams{0u, K_SCHED_HIGH, 0x24000u, 0x800u, 0u, 40, 0u}); + ee.startThread(id, 0u, env.ctx, false); + setRegU32(env.ctx, 4, static_cast(id)); + ps2_syscalls::SuspendThread(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegS32(env.ctx, 2), id, "SuspendThread should return the suspended thread's id"); + setRegU32(env.ctx, 4, static_cast(id)); + ps2_syscalls::ResumeThread(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegS32(env.ctx, 2), id, "ResumeThread should return the resumed thread's id"); + setRegU32(env.ctx, 4, static_cast(id)); + ps2_syscalls::ResumeThread(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegS32(env.ctx, 2), -415, "resuming a running thread should still report KE_NOT_SUSPEND"); + }); + tc.Run("semaphore signal, delete, and release complete blocked contexts with exact results", [](TestCase &t) { const auto blockMain = [](TestEnv &env, int &semaId) From 5b34b0d6c2dadfba7ee3339109e5ee6ad2035d47 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:37:20 -0300 Subject: [PATCH 20/91] Send the swap clear only when the dbuff GIF tag includes it --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 15 +++++++++++---- ps2xTest/src/ps2_gs_tests.cpp | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index 0137094d8a..c8b03708cc 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -1175,27 +1175,34 @@ namespace ps2_stubs }); ++s_swapDbuffLogCount; } + // The swap sends NLOOP A+D registers from the buffer's GIF tag: two 8-register draw environments, + // then the 6-register clear only when NLOOP reaches it. Games drop the clear by shortening NLOOP + // (Mana Khemia does while Sofdec uploads movie frames straight into the framebuffers). + const uint32_t nloop = static_cast((which == 0u ? db.giftag0.lo : db.giftag1.lo) & 0x7FFFu); + const bool sendsClear = nloop >= 22u; if (which == 0u) { applyGsRegPairs(runtime, reinterpret_cast(&db.draw01), 8u); applyGsRegPairs(runtime, reinterpret_cast(&db.draw02), 8u); - if (hasSeededGsClearPacket(db.clear0)) + if (sendsClear && hasSeededGsClearPacket(db.clear0)) { const uint32_t clearContext = static_cast((db.clear0.prim.value >> 9) & 0x1u); runtime->gs().clearFramebufferContext(clearContext, static_cast(db.clear0.rgbaq.value)); } - applyGsClearPacket(runtime, db.clear0); + if (sendsClear) + applyGsClearPacket(runtime, db.clear0); } else { applyGsRegPairs(runtime, reinterpret_cast(&db.draw11), 8u); applyGsRegPairs(runtime, reinterpret_cast(&db.draw12), 8u); - if (hasSeededGsClearPacket(db.clear1)) + if (sendsClear && hasSeededGsClearPacket(db.clear1)) { const uint32_t clearContext = static_cast((db.clear1.prim.value >> 9) & 0x1u); runtime->gs().clearFramebufferContext(clearContext, static_cast(db.clear1.rgbaq.value)); } - applyGsClearPacket(runtime, db.clear1); + if (sendsClear) + applyGsClearPacket(runtime, db.clear1); } setReturnS32(ctx, static_cast(which ^ 1u)); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 6c8d26030a..99010613eb 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -687,6 +687,22 @@ void register_ps2_gs_tests() std::memcpy(&clearedMidPixel, runtime.memory().getGSVRAM() + kMidOffset, sizeof(clearedMidPixel)); t.Equals(clearedMidPixel, kClearColor, "sceGsSwapDBuffDc should clear the interior of the active draw buffer, not just the first pixel"); + + // A GIF tag that stops after the two draw environments (NLOOP 16) sends no clear. + constexpr uint32_t kGiftag0Offset = 0x50u; + uint64_t giftag = 0u; + std::memcpy(&giftag, rdram.data() + kEnvAddr + kGiftag0Offset, sizeof(giftag)); + t.Equals(giftag & 0x7FFFull, 22ull, "a clearing dbuff should send 22 A+D registers"); + giftag = (giftag & ~0x7FFFull) | 16ull; + std::memcpy(rdram.data() + kEnvAddr + kGiftag0Offset, &giftag, sizeof(giftag)); + std::memset(runtime.memory().getGSVRAM(), 0x5A, 16u); + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, 0u); + ps2_stubs::sceGsSwapDBuffDc(rdram.data(), &ctx, &runtime); + uint32_t keptPixel = 0u; + std::memcpy(&keptPixel, runtime.memory().getGSVRAM(), sizeof(keptPixel)); + t.Equals(keptPixel, 0x5A5A5A5Au, "a swap whose GIF tag excludes the clear must leave the draw buffer intact"); }); tc.Run("sceGsSetDefDBuffDc accepts trailing args from the recompiler register ABI", [](TestCase &t) From 29880ddc89e9c692cdd98482a15d4caffb0cfba7 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:37:20 -0300 Subject: [PATCH 21/91] Emulate SIO2 and an 8 MB memory card image on the IOP --- ps2xIOP/CMakeLists.txt | 1 + ps2xIOP/src/emulator/core/iop_memory.cpp | 84 +++- ps2xIOP/src/emulator/core/iop_memory.h | 14 +- ps2xIOP/src/emulator/core/iop_sio2.cpp | 490 +++++++++++++++++++++++ ps2xIOP/src/emulator/core/iop_sio2.h | 105 +++++ ps2xIOP/src/emulator/iop_emulator.cpp | 35 +- ps2xIOP/tests/iop_import_tests.cpp | 73 +++- 7 files changed, 792 insertions(+), 10 deletions(-) create mode 100644 ps2xIOP/src/emulator/core/iop_sio2.cpp create mode 100644 ps2xIOP/src/emulator/core/iop_sio2.h diff --git a/ps2xIOP/CMakeLists.txt b/ps2xIOP/CMakeLists.txt index f8d3e4437a..848a46e65f 100644 --- a/ps2xIOP/CMakeLists.txt +++ b/ps2xIOP/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(ps2_iop STATIC src/emulator/core/iop_cpu.cpp src/emulator/core/iop_kernel.cpp src/emulator/core/iop_memory.cpp + src/emulator/core/iop_sio2.cpp src/emulator/services/iop_module_loader.cpp src/emulator/services/iop_rpc.cpp src/emulator/imports/iop_cdvd.cpp diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index cdc2ad3641..d90a6fa19f 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -12,6 +12,11 @@ namespace ps2x::iop::detail constexpr uint32_t kDmaStart = 1u << 24u; constexpr int kDmaSpu0Irq = 0x24; constexpr int kDmaSpu1Irq = 0x28; + constexpr uint32_t kDmaSio2InChcr = 0x1F801548u; + constexpr uint32_t kDmaSio2OutChcr = 0x1F801558u; + constexpr int kDmaSio2InIrq = 0x2C; + constexpr int kDmaSio2OutIrq = 0x2D; + constexpr uint64_t kSio2TransferCycles = 64u; uint32_t alignUp(uint32_t value, uint32_t alignment) { @@ -35,7 +40,8 @@ namespace ps2x::iop::detail m_interruptStatus = 0; m_interruptMask = 0; m_interruptControl = 1; - m_dmaStart.reset(); + m_dmaStarts.clear(); + m_sio2.reset(); } uint32_t IopMemory::physicalAddress(uint32_t address) noexcept @@ -50,6 +56,8 @@ namespace ps2x::iop::detail return m_ram[phys]; if (phys >= ScratchBase && phys < ScratchBase + ScratchSize) return m_scratch[phys - ScratchBase]; + if (phys == IopSio2::kDataOut) + return m_sio2.readData(); const uint32_t value = readHardware32(phys & ~3u); return static_cast(value >> ((phys & 3u) * 8u)); } @@ -104,6 +112,11 @@ namespace ps2x::iop::detail m_scratch[phys - ScratchBase] = value; return; } + if (phys == IopSio2::kDataIn) + { + m_sio2.writeData(value); + return; + } const uint32_t aligned = phys & ~3u; uint32_t current = readHardware32(aligned); const uint32_t shift = (phys & 3u) * 8u; @@ -212,6 +225,8 @@ namespace ps2x::iop::detail uint32_t IopMemory::readHardware32(uint32_t address) const { + if (IopSio2::contains(address)) + return m_sio2.read32(address); const auto value = m_hardware.find(address); if (value != m_hardware.end()) return value->second; @@ -245,7 +260,18 @@ namespace ps2x::iop::detail break; } + if (IopSio2::contains(address)) + { + writeSio2(address, value); + return; + } + m_hardware[address] = value; + if (address == kDmaSio2InChcr || address == kDmaSio2OutChcr) + { + runSio2Dma(address, value); + return; + } if ((address != kDmaSpu0Chcr && address != kDmaSpu1Chcr) || (value & kDmaStart) == 0u) return; @@ -268,19 +294,63 @@ namespace ps2x::iop::detail const uint32_t wordsPerBlock = std::max(blockControl & 0xFFFFu, 1u); const uint32_t blockCount = std::max(blockControl >> 16u, 1u); const uint64_t transferWords = static_cast(wordsPerBlock) * blockCount; - m_dmaStart = DmaStart{ - secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, - std::max(transferWords * 2u, 64u), - }; + queueInterrupt(secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, std::max(transferWords * 2u, 64u)); + } + + void IopMemory::queueInterrupt(int irq, uint64_t delayCycles) + { + m_dmaStarts.push_back(DmaStart{irq, delayCycles}); } std::optional IopMemory::takeDmaStart() noexcept { - std::optional result = m_dmaStart; - m_dmaStart.reset(); + if (m_dmaStarts.empty()) + return std::nullopt; + const DmaStart result = m_dmaStarts.front(); + m_dmaStarts.pop_front(); return result; } + bool IopMemory::attachMemoryCard(size_t port, const std::string &path) + { + m_sio2.card(port).attach(path); + return true; + } + + void IopMemory::writeSio2(uint32_t address, uint32_t value) + { + m_sio2.write32(address, value); + if (m_sio2.takeInterrupt()) + queueInterrupt(IopSio2::kIrq, kSio2TransferCycles); + } + + void IopMemory::runSio2Dma(uint32_t chcrAddress, uint32_t chcr) + { + if ((chcr & kDmaStart) == 0u) + return; + const uint32_t madrAddress = chcrAddress - 8u; + const uint32_t bcr = readHardware32(chcrAddress - 4u); + uint32_t madr = readHardware32(madrAddress) & 0x1FFFFFu; + const size_t blockSize = static_cast(bcr & 0xFFFFu) * 4u; + const size_t size = blockSize * std::max(bcr >> 16u, 1u); + if (madr + size <= RamSize) + { + if (chcrAddress == kDmaSio2InChcr) + { + m_sio2.dmaIn(m_ram.data() + madr, size, blockSize); + } + else + { + m_sio2.dmaOut(m_ram.data() + madr, size); + markOwned(madr, size); + } + madr += static_cast(size); + } + m_hardware[madrAddress] = madr; + m_hardware[chcrAddress] = chcr & ~kDmaStart; + queueInterrupt(chcrAddress == kDmaSio2InChcr ? kDmaSio2InIrq : kDmaSio2OutIrq, std::max(size / 4u, 64u)); + } + uint32_t IopMemory::allocate(uint32_t size, uint32_t alignment, std::optional fixed) { size = alignUp(std::max(size, 1u), 16u); diff --git a/ps2xIOP/src/emulator/core/iop_memory.h b/ps2xIOP/src/emulator/core/iop_memory.h index d41b71c829..8e52502c8f 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.h +++ b/ps2xIOP/src/emulator/core/iop_memory.h @@ -1,5 +1,10 @@ #pragma once +#include "iop_sio2.h" + +#include +#include + #include #include #include @@ -70,6 +75,8 @@ namespace ps2x::iop::detail void setInterruptControl(uint32_t value) noexcept { m_interruptControl = value & 1u; } [[nodiscard]] std::optional takeDmaStart() noexcept; + // Attaches a raw memory card image to a SIO2 port; a missing file is created blank on first access. + bool attachMemoryCard(size_t port, const std::string &path); [[nodiscard]] std::span ram() const noexcept { return m_ram; } [[nodiscard]] static uint32_t physicalAddress(uint32_t address) noexcept; @@ -78,6 +85,9 @@ namespace ps2x::iop::detail [[nodiscard]] uint32_t readHardware32(uint32_t address) const; void writeHardware32(uint32_t address, uint32_t value); void markOwned(uint32_t address, size_t size); + void writeSio2(uint32_t address, uint32_t value); + void runSio2Dma(uint32_t chcrAddress, uint32_t chcr); + void queueInterrupt(int irq, uint64_t delayCycles); std::vector m_ram; std::vector m_owned; @@ -87,6 +97,8 @@ namespace ps2x::iop::detail uint32_t m_interruptStatus = 0; uint32_t m_interruptMask = 0; uint32_t m_interruptControl = 1; - std::optional m_dmaStart; + std::deque m_dmaStarts; + // SIO2 data reads pop its FIFO, so the controller changes even on const reads. + mutable IopSio2 m_sio2; }; } diff --git a/ps2xIOP/src/emulator/core/iop_sio2.cpp b/ps2xIOP/src/emulator/core/iop_sio2.cpp new file mode 100644 index 0000000000..6fb68205b9 --- /dev/null +++ b/ps2xIOP/src/emulator/core/iop_sio2.cpp @@ -0,0 +1,490 @@ +#include "iop_sio2.h" + +#include +#include +#include + +namespace ps2x::iop::detail +{ + namespace + { + constexpr uint8_t kModePad = 0x01; + constexpr uint8_t kModeMemoryCard = 0x81; + + constexpr uint32_t kRecvConnected = 0x1100u; + constexpr uint32_t kRecvDisconnected = 0x1D100u; + constexpr uint32_t kCtrlStart = 0x1u; + constexpr uint32_t kCtrlResetFifos = 0xCu; + } + + void IopMemoryCard::attach(const std::string &path) + { + if (path == m_attachedPath) + return; + close(); + m_attachedPath = path; + m_openFailed = false; + } + + bool IopMemoryCard::ensureOpen() + { + if (present()) + return true; + if (m_attachedPath.empty() || m_openFailed) + return false; + m_openFailed = !open(m_attachedPath); + if (m_openFailed) + std::cerr << "[IOP] could not open or create memory card image " << m_attachedPath + << "; check the folder exists and is writable. Slot 1 reads as empty." << std::endl; + return !m_openFailed; + } + + bool IopMemoryCard::open(const std::string &path) + { + close(); + std::error_code ec; + const size_t expected = static_cast(kRawPageSize) * kPageCount; + if (!std::filesystem::exists(path, ec)) + { + // A new card is blank flash; the game formats it like a fresh retail card. + std::ofstream create(path, std::ios::binary); + if (!create) + return false; + const std::vector blank(expected, 0xFFu); + create.write(reinterpret_cast(blank.data()), static_cast(blank.size())); + if (!create) + return false; + } + + m_file.open(path, std::ios::binary | std::ios::in | std::ios::out); + if (!m_file) + return false; + m_image.assign(expected, 0xFFu); + m_file.read(reinterpret_cast(m_image.data()), static_cast(m_image.size())); + m_file.clear(); + m_path = path; + terminator = 0x55; + m_transferAddress = 0; + return true; + } + + void IopMemoryCard::close() + { + if (m_file.is_open()) + m_file.close(); + m_image.clear(); + m_path.clear(); + } + + void IopMemoryCard::setSector(uint32_t page) noexcept + { + m_transferAddress = static_cast(page) * kRawPageSize; + } + + void IopMemoryCard::read(uint8_t *destination, size_t size) + { + for (size_t i = 0; i < size; ++i) + { + const size_t offset = m_transferAddress + i; + destination[i] = offset < m_image.size() ? m_image[offset] : 0xFFu; + } + m_transferAddress += size; + } + + void IopMemoryCard::write(const uint8_t *source, size_t size) + { + const size_t start = std::min(m_transferAddress, m_image.size()); + const size_t count = std::min(size, m_image.size() - start); + for (size_t i = 0; i < count; ++i) + m_image[start + i] &= source[i]; + flush(start, count); + m_transferAddress += size; + } + + void IopMemoryCard::eraseBlock() + { + const size_t blockBytes = static_cast(kRawPageSize) * kPagesPerBlock; + const size_t start = std::min(m_transferAddress, m_image.size()); + const size_t count = std::min(blockBytes, m_image.size() - start); + std::fill_n(m_image.begin() + static_cast(start), count, uint8_t{0xFFu}); + flush(start, count); + } + + void IopMemoryCard::flush(size_t offset, size_t size) + { + if (!m_file.is_open() || size == 0u) + return; + m_file.seekp(static_cast(offset)); + m_file.write(reinterpret_cast(m_image.data() + offset), static_cast(size)); + m_file.flush(); + } + + void IopSio2::reset() + { + m_send3.fill(0u); + m_send1.fill(0u); + m_send2.fill(0u); + m_ctrl = 0x3BCu; + m_recv1 = kRecvDisconnected; + m_recv2 = 0xFu; + m_recv3 = 0u; + m_unknown278 = 0u; + m_unknown27c = 0u; + m_istat = 0u; + m_interruptPending = false; + m_fifoOut.clear(); + m_port = 0; + softReset(); + } + + void IopSio2::softReset() + { + m_queueRead = false; + m_queuePosition = 0; + m_commandLength = 0; + m_dmaBlockSize = 0; + m_queueComplete = false; + m_fifoIn.clear(); + m_recv1 = 0u; + } + + uint32_t IopSio2::read32(uint32_t address) + { + const uint32_t offset = address - kBase; + if (offset < 0x40u) + return m_send3[offset / 4u]; + if (offset < 0x60u) + return (offset & 4u) != 0u ? m_send2[(offset - 0x40u) / 8u] : m_send1[(offset - 0x40u) / 8u]; + switch (address) + { + case kDataOut: + return readData(); + case 0x1F808268u: + return m_ctrl; + case 0x1F80826Cu: + return m_recv1; + case 0x1F808270u: + return m_recv2; + case 0x1F808274u: + return m_recv3; + case 0x1F808278u: + return m_unknown278; + case 0x1F80827Cu: + return m_unknown27c; + case 0x1F808280u: + return m_istat; + default: + return 0u; + } + } + + void IopSio2::write32(uint32_t address, uint32_t value) + { + const uint32_t offset = address - kBase; + if (offset < 0x40u) + { + m_send3[offset / 4u] = value; + // Writing the first queue entry begins a new packet. + if (offset == 0u) + softReset(); + return; + } + if (offset < 0x60u) + { + ((offset & 4u) != 0u ? m_send2 : m_send1)[(offset - 0x40u) / 8u] = value; + return; + } + switch (address) + { + case kDataIn: + writeData(static_cast(value)); + break; + case 0x1F808268u: + m_ctrl = value; + if ((value & kCtrlResetFifos) != 0u) + { + m_fifoIn.clear(); + m_fifoOut.clear(); + } + if ((value & kCtrlStart) != 0u) + { + // Replies are produced as bytes arrive, so the transfer is complete once it starts. + if (m_istat == 0u) + m_interruptPending = true; + m_istat |= 1u; + } + break; + case 0x1F80826Cu: + m_recv1 = value; + break; + case 0x1F808270u: + m_recv2 = value; + break; + case 0x1F808274u: + m_recv3 = value; + break; + case 0x1F808278u: + m_unknown278 = value; + break; + case 0x1F80827Cu: + m_unknown27c = value; + break; + case 0x1F808280u: + m_istat &= ~value; + break; + default: + break; + } + } + + bool IopSio2::takeInterrupt() noexcept + { + const bool pending = m_interruptPending; + m_interruptPending = false; + return pending; + } + + uint8_t IopSio2::readData() + { + if (m_fifoOut.empty()) + return 0xFFu; + const uint8_t value = m_fifoOut.front(); + m_fifoOut.pop_front(); + return value; + } + + void IopSio2::writeData(uint8_t value) + { + if (!m_queueRead) + { + if (m_queuePosition >= m_send3.size()) + return; + const uint32_t command = m_send3[m_queuePosition]; + m_port = command & 1u; + m_commandLength = (command >> 8u) & 0x3FFu; + m_queueRead = true; + if (m_commandLength == 0u) + m_queueComplete = true; + m_fifoIn.clear(); + } + if (m_queueComplete) + return; + + m_fifoIn.push_back(value); + const bool fromDma = m_dmaBlockSize != 0u; + if ((!fromDma && m_fifoIn.size() == m_commandLength) || (fromDma && m_fifoIn.size() == m_dmaBlockSize)) + { + m_queueRead = false; + ++m_queuePosition; + process(); + if (fromDma) + { + while (m_fifoOut.size() % m_dmaBlockSize != 0u) + m_fifoOut.push_back(0x00u); + } + } + } + + void IopSio2::dmaIn(const uint8_t *data, size_t size, size_t blockSize) + { + m_dmaBlockSize = blockSize; + for (size_t i = 0; i < size; ++i) + writeData(data[i]); + } + + void IopSio2::dmaOut(uint8_t *data, size_t size) + { + for (size_t i = 0; i < size; ++i) + data[i] = readData(); + } + + void IopSio2::process() + { + const uint8_t mode = m_fifoIn.front(); + m_fifoIn.pop_front(); + if (mode == kModeMemoryCard) + { + memoryCard(); + return; + } + + // Pads, multitaps and infrared are not wired to SIO2 here: answer as an empty port. + m_recv1 = mode == kModePad ? (m_recv1 | 0x1000u | (m_port == 0u ? 0x1D000u : 0x2D000u)) : kRecvDisconnected; + m_fifoOut.push_back(0xFFu); + while (!m_fifoIn.empty()) + { + m_fifoIn.pop_front(); + m_fifoOut.push_back(0xFFu); + } + } + + void IopSio2::memoryCard() + { + IopMemoryCard &mc = m_cards[m_port]; + (void)mc.ensureOpen(); + m_recv1 = mc.present() ? kRecvConnected : kRecvDisconnected; + const uint8_t command = m_fifoIn.front(); + m_fifoIn.pop_front(); + const uint8_t echo = mc.present() ? 0x00u : 0xFFu; + // Reply lengths count from this command's first byte; earlier packets may still be queued. + const size_t base = m_fifoOut.size(); + m_fifoOut.push_back(echo); + m_fifoOut.push_back(echo); + + const auto pop = [this]() -> uint8_t + { + if (m_fifoIn.empty()) + return 0u; + const uint8_t value = m_fifoIn.front(); + m_fifoIn.pop_front(); + return value; + }; + // Most replies are zero padding ending in 0x2B and the terminator byte. + const auto finish = [this, &mc, base](size_t length) + { + while (m_fifoOut.size() - base + 2u < length) + m_fifoOut.push_back(0x00u); + m_fifoOut.push_back(0x2Bu); + m_fifoOut.push_back(mc.terminator); + }; + + if (!mc.present()) + { + while (!m_fifoIn.empty()) + { + m_fifoIn.pop_front(); + m_fifoOut.push_back(0xFFu); + } + return; + } + + switch (command) + { + case 0x11: // probe + case 0x12: + case 0x81: // read/write end + finish(4); + break; + case 0x21: // set erase sector + case 0x22: // set write sector + case 0x23: // set read sector + { + uint32_t page = 0; + for (int i = 0; i < 4; ++i) + page |= static_cast(pop()) << (8 * i); + (void)pop(); // XOR checksum of the address bytes + mc.setSector(page); + finish(9); + break; + } + case 0x26: // get specs: page size, erase block size, page count, XOR + { + const uint8_t specs[8] = { + static_cast(IopMemoryCard::kPageSize), static_cast(IopMemoryCard::kPageSize >> 8), + static_cast(IopMemoryCard::kPagesPerBlock), static_cast(IopMemoryCard::kPagesPerBlock >> 8), + static_cast(IopMemoryCard::kPageCount), static_cast(IopMemoryCard::kPageCount >> 8), + static_cast(IopMemoryCard::kPageCount >> 16), static_cast(IopMemoryCard::kPageCount >> 24)}; + uint8_t checksum = 0; + m_fifoOut.push_back(0x2Bu); + for (const uint8_t value : specs) + { + checksum ^= value; + m_fifoOut.push_back(value); + } + m_fifoOut.push_back(checksum); + m_fifoOut.push_back(mc.terminator); + break; + } + case 0x27: // set terminator + mc.terminator = pop(); + m_fifoOut.push_back(0x00u); + m_fifoOut.push_back(0x2Bu); + m_fifoOut.push_back(mc.terminator); + break; + case 0x28: // get terminator + m_fifoOut.push_back(0x2Bu); + m_fifoOut.push_back(mc.terminator); + m_fifoOut.push_back(mc.terminator); + break; + case 0x42: // write data + { + m_fifoOut.push_back(0x00u); + m_fifoOut.push_back(0x2Bu); + const uint8_t length = pop(); + std::vector data(length); + uint8_t checksum = 0; + for (uint8_t &value : data) + { + value = pop(); + checksum ^= value; + m_fifoOut.push_back(0x00u); + } + mc.write(data.data(), data.size()); + m_fifoOut.push_back(checksum); + m_fifoOut.push_back(mc.terminator); + break; + } + case 0x43: // read data + { + const uint8_t length = pop(); + m_fifoOut.push_back(0x00u); + m_fifoOut.push_back(0x2Bu); + std::vector data(length); + mc.read(data.data(), data.size()); + uint8_t checksum = 0; + for (const uint8_t value : data) + { + checksum ^= value; + m_fifoOut.push_back(value); + } + m_fifoOut.push_back(checksum); + m_fifoOut.push_back(mc.terminator); + break; + } + case 0x82: // erase block + mc.eraseBlock(); + finish(4); + break; + case 0xBF: + finish(5); + break; + case 0xF0: // MagicGate authentication; the card side only echoes or XORs + { + const uint8_t step = pop(); + switch (step) + { + case 0x01: case 0x02: case 0x04: case 0x0F: case 0x11: case 0x13: + { + m_fifoOut.push_back(0x00u); + m_fifoOut.push_back(0x2Bu); + uint8_t checksum = 0; + for (int i = 0; i < 8; ++i) + { + checksum ^= pop(); + m_fifoOut.push_back(0x00u); + } + m_fifoOut.push_back(checksum); + m_fifoOut.push_back(mc.terminator); + break; + } + case 0x06: case 0x07: case 0x0B: + finish(14); + break; + default: + finish(5); + break; + } + break; + } + case 0xF3: // reset authentication + mc.terminator = 0x55; + finish(5); + break; + case 0xF7: + finish(5); + break; + default: + break; + } + } +} diff --git a/ps2xIOP/src/emulator/core/iop_sio2.h b/ps2xIOP/src/emulator/core/iop_sio2.h new file mode 100644 index 0000000000..7ced946969 --- /dev/null +++ b/ps2xIOP/src/emulator/core/iop_sio2.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace ps2x::iop::detail +{ + // PS2 memory card as seen over SIO2: 528-byte pages (512 data + 16 ECC), 16-page erase blocks. + // The image layout matches PCSX2's .ps2 files, so cards can move between the two. + class IopMemoryCard + { + public: + static constexpr uint32_t kPageSize = 512u; + static constexpr uint32_t kSpareSize = 16u; + static constexpr uint32_t kRawPageSize = kPageSize + kSpareSize; + static constexpr uint32_t kPagesPerBlock = 16u; + static constexpr uint32_t kPageCount = 0x4000u; // 8 MB + + // The image is opened on first use, so hosts that never touch SIO2 never create a file. + void attach(const std::string &path); + bool ensureOpen(); + bool open(const std::string &path); + void close(); + [[nodiscard]] bool present() const noexcept { return !m_image.empty(); } + [[nodiscard]] const std::string &path() const noexcept { return m_path; } + + void setSector(uint32_t page) noexcept; + void read(uint8_t *destination, size_t size); + // Flash programming can only clear bits; erase is what sets them again. + void write(const uint8_t *source, size_t size); + void eraseBlock(); + + uint8_t terminator = 0x55; + + private: + void flush(size_t offset, size_t size); + + std::string m_path; + std::string m_attachedPath; + bool m_openFailed = false; + std::vector m_image; + std::fstream m_file; + size_t m_transferAddress = 0; + }; + + // SIO2 serial controller (0x1F808200-0x1F808280) with DMA channels 11 (in) and 12 (out). + // Only memory cards are attached: pads are served at the EE level, so pad traffic reads as disconnected. + class IopSio2 + { + public: + static constexpr uint32_t kBase = 0x1F808200u; + static constexpr uint32_t kEnd = 0x1F808284u; + static constexpr uint32_t kDataIn = 0x1F808260u; + static constexpr uint32_t kDataOut = 0x1F808264u; + static constexpr int kIrq = 17; + + void reset(); + IopMemoryCard &card(size_t port) noexcept { return m_cards[port & 1u]; } + + [[nodiscard]] static bool contains(uint32_t address) noexcept { return address >= kBase && address < kEnd; } + uint32_t read32(uint32_t address); + void write32(uint32_t address, uint32_t value); + uint8_t readData(); + void writeData(uint8_t value); + + // DMA11 delivers command bytes in blocks; replies are padded to the same block size for DMA12. + void dmaIn(const uint8_t *data, size_t size, size_t blockSize); + void dmaOut(uint8_t *data, size_t size); + + [[nodiscard]] bool takeInterrupt() noexcept; + + private: + void softReset(); + void process(); + void memoryCard(); + + std::array m_send3{}; + std::array m_send1{}; + std::array m_send2{}; + uint32_t m_ctrl = 0x3BCu; + uint32_t m_recv1 = 0x1D100u; + uint32_t m_recv2 = 0xFu; + uint32_t m_recv3 = 0u; + uint32_t m_unknown278 = 0u; + uint32_t m_unknown27c = 0u; + uint32_t m_istat = 0u; + bool m_interruptPending = false; + + std::deque m_fifoIn; + std::deque m_fifoOut; + size_t m_queuePosition = 0; + size_t m_commandLength = 0; + size_t m_dmaBlockSize = 0; + uint32_t m_port = 0; + bool m_queueRead = false; + bool m_queueComplete = false; + + std::array m_cards; + }; +} diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index 3ef227ef38..c61ad0e4d1 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -117,6 +117,7 @@ namespace ps2x::iop::detail void reset() { memory.reset(); + attachMemoryCard(); kernel.reset(); modules.clear(); imports.reset(); @@ -174,9 +175,18 @@ namespace ps2x::iop::detail schedulePendingDma(); } + void attachMemoryCard() + { + // Slot 1 is a raw card image next to the folder the HLE memory card services use. + const std::string root = host.hostPath(HostPathKind::MemoryCardRoot); + if (root.empty()) + return; + (void)memory.attachMemoryCard(0, root + ".ps2"); + } + void schedulePendingDma() { - if (const auto dma = memory.takeDmaStart()) + while (const auto dma = memory.takeDmaStart()) pendingDmaInterrupts[dma->irq] = totalCycles + dma->delayCycles; } @@ -588,6 +598,27 @@ namespace ps2x::iop::detail } } + // Disc modules are loaded from anonymous buffers; their export table names what they are. + std::string exportedLibraryName(const Module &module) const + { + constexpr uint32_t kExportMagic = 0x41C00000u; + for (uint32_t address = module.base; address + 20u <= module.base + module.size; address += 4u) + { + if (memory.read32(address) != kExportMagic) + continue; + std::string name; + for (uint32_t i = 0; i < 8u; ++i) + { + const char c = static_cast(memory.read8(address + 12u + i)); + if (c == '\0') + break; + name.push_back(c); + } + return name; + } + return {}; + } + ModuleLoadResult loadImage(std::string path, std::span image, const void *arguments, uint32_t argumentSize) { ModuleLoadResult result{true, -1, -1}; @@ -637,6 +668,8 @@ namespace ps2x::iop::detail << " entry=0x" << std::hex << modules[result.moduleId].entry << " base=0x" << modules[result.moduleId].base << " start=" << std::dec << result.startResult; + if (const std::string library = exportedLibraryName(modules[result.moduleId]); !library.empty()) + out << " exports=" << library; log(LogLevel::Info, out.str()); return result; } diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index 3035618443..e7e571f7a0 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace { @@ -328,10 +329,80 @@ namespace } } +namespace +{ + // Sends one SIO2 packet the way sio2man does in PIO mode and returns the reply bytes. + std::vector sio2Transfer(IopMemory &memory, const std::vector &command) + { + memory.write32(0x1F808200u, static_cast(command.size()) << 8u); // SEND3[0]: port 0, length + memory.write32(0x1F808204u, 0u); + for (const uint8_t value : command) + memory.write8(0x1F808260u, value); + memory.write32(0x1F808268u, 1u); // start + std::vector reply(command.size()); + for (uint8_t &value : reply) + value = memory.read8(0x1F808264u); + memory.write32(0x1F808280u, 1u); // acknowledge + return reply; + } + + bool testSio2MemoryCard() + { + const std::filesystem::path image = std::filesystem::temp_directory_path() / "ps2x_sio2_test_card.ps2"; + std::error_code ec; + std::filesystem::remove(image, ec); + + IopMemory memory; + memory.attachMemoryCard(0, image.string()); + if (!expect(!std::filesystem::exists(image), "the card image should not be created before SIO2 touches it")) + return false; + + const std::vector probe = sio2Transfer(memory, {0x81, 0x11, 0, 0}); + bool irq17 = false; + while (const auto start = memory.takeDmaStart()) + irq17 = irq17 || start->irq == 17; + if (!expect(irq17, "starting a SIO2 transfer should raise IRQ 17") || + !expect(std::filesystem::file_size(image, ec) == 0x4000u * 528u, "a blank 8 MB card image with ECC should be created") || + !expect(probe[2] == 0x2B && probe[3] == 0x55, "probe should answer 0x2B and the terminator") || + !expect(memory.read32(0x1F80826Cu) == 0x1100u, "RECV1 should report a connected card")) + return false; + + const std::vector specs = sio2Transfer(memory, {0x81, 0x26, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + if (!expect(specs[3] == 0x00 && specs[4] == 0x02 && specs[5] == 0x10 && specs[8] == 0x40, + "specs should report 512-byte pages, 16-page blocks and 0x4000 pages") || + !expect(specs[11] == 0x52 && specs[12] == 0x55, "the spec checksum should XOR the eight spec bytes, then the terminator")) + return false; + + // Program page 3, then read it back: flash writes only clear bits. + (void)sio2Transfer(memory, {0x81, 0x22, 3, 0, 0, 0, 3, 0, 0}); + std::vector write = {0x81, 0x42, 4, 0x12, 0x34, 0x56, 0x78, 0, 0, 0}; + // Replies are as long as the request: two echo bytes, 0x00, 0x2B, the data, XOR and terminator. + (void)sio2Transfer(memory, write); + (void)sio2Transfer(memory, {0x81, 0x23, 3, 0, 0, 0, 3, 0, 0}); + const std::vector read = sio2Transfer(memory, {0x81, 0x43, 4, 0, 0, 0, 0, 0, 0, 0}); + if (!expect(read[3] == 0x2B && read[4] == 0x12 && read[7] == 0x78 && read[9] == 0x55, "read data should return the programmed bytes") || + !expect(read[8] == (0x12 ^ 0x34 ^ 0x56 ^ 0x78), "read data should end with the XOR checksum")) + return false; + + // The write must survive reopening the image. + IopMemory reopened; + reopened.attachMemoryCard(0, image.string()); + (void)sio2Transfer(reopened, {0x81, 0x23, 3, 0, 0, 0, 3, 0, 0}); + const std::vector persisted = sio2Transfer(reopened, {0x81, 0x43, 4, 0, 0, 0, 0, 0, 0, 0}); + (void)sio2Transfer(reopened, {0x81, 0x21, 0, 0, 0, 0, 0, 0, 0}); + (void)sio2Transfer(reopened, {0x81, 0x82, 0, 0}); + (void)sio2Transfer(reopened, {0x81, 0x23, 3, 0, 0, 0, 3, 0, 0}); + const std::vector erased = sio2Transfer(reopened, {0x81, 0x43, 4, 0, 0, 0, 0, 0, 0, 0}); + std::filesystem::remove(image, ec); + return expect(persisted[4] == 0x12, "programmed pages should persist in the image file") && + expect(erased[4] == 0xFF && erased[7] == 0xFF, "erasing block 0 should reset page 3 to 0xFF"); + } +} + int main() { if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdSearchFile() || - !testTimrmanPeriodicCallback()) + !testTimrmanPeriodicCallback() || !testSio2MemoryCard()) return 1; std::cout << "ps2xIOP import tests passed\n"; return 0; From c609e557bbb39cc29c529d61fdcf56a7cf04a821 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:37:20 -0300 Subject: [PATCH 22/91] Emulate IPU VDEC/BDEC and pace toIPU, scratchpad chain and stall DMA like hardware --- ps2xRuntime/include/runtime/ps2_ipu.h | 30 +- ps2xRuntime/include/runtime/ps2_memory.h | 9 + ps2xRuntime/src/lib/ps2_ipu.cpp | 366 ++++++++++++++++++- ps2xRuntime/src/lib/ps2_memory.cpp | 278 +++++++++++--- ps2xTest/src/ps2_memory_tests.cpp | 295 +++++++++++++++ ps2xTest/src/ps2_runtime_expansion_tests.cpp | 9 +- 6 files changed, 917 insertions(+), 70 deletions(-) diff --git a/ps2xRuntime/include/runtime/ps2_ipu.h b/ps2xRuntime/include/runtime/ps2_ipu.h index 5a4e85d7fc..bfa385c00a 100644 --- a/ps2xRuntime/include/runtime/ps2_ipu.h +++ b/ps2xRuntime/include/runtime/ps2_ipu.h @@ -5,6 +5,7 @@ #include #include #include +#include #include // Image Processing Unit: MPEG-1/2 intra decoding and colour conversion fed through the @@ -31,22 +32,31 @@ class Ps2Ipu void writeInputFifo(const uint8_t *quadword); bool readOutputFifo(uint8_t *quadword); - // Data delivered by the toIPU DMA; it enters the 8-quadword input FIFO as commands consume it. + // Pulls up to N quadwords from the toIPU DMA channel into a buffer, returning how many were moved. + // The channel only advances as the FIFO drains, which is what sceIpuStopDMA/RestartDMA rely on. + using InputSource = std::function; + void setInputSource(InputSource source); + // Called when the source may have data again (a toIPU DMA was started). + void inputAvailable(); + // Queues bytes as if already transferred by DMA; used where no live channel exists, such as tests. void feedDmaInput(const uint8_t *data, size_t size); size_t takeOutput(uint8_t *destination, size_t maxBytes); - // Decoded data becomes visible at a hardware-like rate; games re-arm fromIPU expecting the IPU to lag. + // Decoded data becomes visible after a hardware-like latency; games re-arm fromIPU expecting the IPU to lag. size_t outputBytes() const noexcept { return std::min(m_output.size(), m_releasedBytes); } void advance(uint64_t eeCycles); - static constexpr uint64_t kEeCyclesPerMacroblock = 1024u; + // Latency before output becomes visible: games re-arm fromIPU right after starting a command and + // expect no data yet. Keep it short; movie players busy-wait on the drain. + static constexpr uint64_t kEeCyclesPerMacroblock = 64u; - bool busy() const noexcept { return m_busy; } + // A command is only finished once its output fits in the 8-quadword OUT FIFO; until fromIPU drains + // the rest it stays BUSY, which is what callers wait on before using the decoded data. + static constexpr size_t kOutputFifoBytes = 8u * 16u; + bool busy() const noexcept { return m_busy || m_output.size() > kOutputFifoBytes; } private: struct Snapshot { - std::vector fifo; uint32_t bitPos = 0; - size_t dmaCursor = 0; std::array dcPred{}; int32_t quantizerScale = 0; size_t outputSize = 0; @@ -63,6 +73,8 @@ class Ps2Ipu // Bitstream access over the input FIFO, refilled from pending DMA input. void refill(); + void discardConsumed(); + size_t liveFifoBytes() const noexcept; bool ensureBits(uint32_t count); uint32_t peekBits(uint32_t count); uint32_t getBits(uint32_t count); @@ -70,6 +82,8 @@ class Ps2Ipu void commandBclr(); void commandIdec(); + void commandBdec(); + void commandVdec(); void commandFdec(); void commandSetiq(); void commandSetvq(); @@ -78,7 +92,10 @@ class Ps2Ipu int decodeMacroblockAddressIncrement(); bool decodeIntraBlock(int component, int16_t *block); + bool decodeNonIntraBlock(int16_t *block); + uint32_t decodeCodedBlockPattern(); void inverseDct(const int16_t *coefficients, uint8_t *destination, int stride) const; + void inverseDctSigned(const int16_t *coefficients, int16_t *destination, int stride, bool intra) const; void emitMacroblock(const uint8_t *luma, const uint8_t *cb, const uint8_t *cr, bool rgb16, bool dither); uint32_t m_cmd = 0; @@ -91,6 +108,7 @@ class Ps2Ipu std::vector m_fifo; uint32_t m_bitPos = 0; + InputSource m_inputSource; std::vector m_dmaInput; size_t m_dmaCursor = 0; std::deque m_output; diff --git a/ps2xRuntime/include/runtime/ps2_memory.h b/ps2xRuntime/include/runtime/ps2_memory.h index 43b474d5e4..09f3d9ce03 100644 --- a/ps2xRuntime/include/runtime/ps2_memory.h +++ b/ps2xRuntime/include/runtime/ps2_memory.h @@ -466,7 +466,16 @@ class PS2Memory void serviceIpuDma(); bool appendDmaSource(uint32_t address, uint32_t bytes, std::vector &out) const; void completeDmacChannel(uint32_t channelBase, uint32_t cause); + size_t pullToIpu(uint8_t *destination, size_t maxQuadwords); + bool processToScratchpadChain(uint32_t chcr); + // DMAC stall control (D_CTRL.STS/STD, D_STADR): sources advance D_STADR, drains park on REFS tags beyond it. + static constexpr uint32_t kStallSourceFromSpr = 2u; + static constexpr uint32_t kStallSourceFromIpu = 3u; + bool dmaStallBlocks(uint32_t channelBase, uint32_t dataAddr, uint32_t qwc); + void advanceDmaStallAddress(uint32_t sourceChannel, uint32_t physicalEnd); + uint32_t m_stalledDrainChannel = 0u; Ps2Ipu m_ipu; + bool m_toIpuLastTag = false; void queueCompletedDmacCause(uint32_t cause); }; diff --git a/ps2xRuntime/src/lib/ps2_ipu.cpp b/ps2xRuntime/src/lib/ps2_ipu.cpp index 3d8603ef05..a34254dbbc 100644 --- a/ps2xRuntime/src/lib/ps2_ipu.cpp +++ b/ps2xRuntime/src/lib/ps2_ipu.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace { @@ -26,7 +27,8 @@ namespace constexpr uint32_t kCtrlRst = 1u << 30; constexpr uint32_t kCtrlBusy = 1u << 31; // IDP, AS, IVF, QST, MP1, PCT and the reset request are software-writable. - constexpr uint32_t kCtrlWritable = (3u << 16) | (1u << 20) | (1u << 21) | (1u << 22) | (1u << 23) | (7u << 24) | kCtrlRst; + // RST is a write-only strobe: libmpeg read-modify-writes CTRL, so echoing RST would reset the IPU every time. + constexpr uint32_t kCtrlWritable = (3u << 16) | (1u << 20) | (1u << 21) | (1u << 22) | (1u << 23) | (7u << 24); constexpr size_t kFifoBytes = 8u * 16u; constexpr uint8_t kZigzag[64] = { @@ -112,6 +114,35 @@ namespace {0x20, 11}, {0x1f, 11}, {0x1e, 11}, {0x1d, 11}, {0x1c, 11}, {0x1b, 11}, {0x1a, 11}, {0x19, 11}, {0x18, 11}, {0x8, 11}, {0xf, 11}}; + // coded_block_pattern (table B.9), indexed by pattern value, as published in FFmpeg's mpeg12data.c. + constexpr uint8_t kCbpCode[64][2] = { + {0x1, 9}, {0xb, 5}, {0x9, 5}, {0xd, 6}, {0xd, 4}, {0x17, 7}, {0x13, 7}, {0x1f, 8}, + {0xc, 4}, {0x16, 7}, {0x12, 7}, {0x1e, 8}, {0x13, 5}, {0x1b, 8}, {0x17, 8}, {0x13, 8}, + {0xb, 4}, {0x15, 7}, {0x11, 7}, {0x1d, 8}, {0x11, 5}, {0x19, 8}, {0x15, 8}, {0x11, 8}, + {0xf, 6}, {0xf, 8}, {0xd, 8}, {0x3, 9}, {0xf, 5}, {0xb, 8}, {0x7, 8}, {0x7, 9}, + {0xa, 4}, {0x14, 7}, {0x10, 7}, {0x1c, 8}, {0xe, 6}, {0xe, 8}, {0xc, 8}, {0x2, 9}, + {0x10, 5}, {0x18, 8}, {0x14, 8}, {0x10, 8}, {0xe, 5}, {0xa, 8}, {0x6, 8}, {0x6, 9}, + {0x12, 5}, {0x1a, 8}, {0x16, 8}, {0x12, 8}, {0xd, 5}, {0x9, 8}, {0x5, 8}, {0x5, 9}, + {0xc, 5}, {0x8, 8}, {0x4, 8}, {0x4, 9}, {0x7, 3}, {0xa, 5}, {0x8, 5}, {0xc, 6}}; + // motion_code magnitudes 1..16 (table B.10); 0 is the single bit '1'. A sign bit follows non-zero codes. + constexpr uint8_t kMotionCode[17][2] = { + {0x1, 1}, {0x1, 2}, {0x1, 3}, {0x1, 4}, {0x3, 6}, {0x5, 7}, {0x4, 7}, {0x3, 7}, {0xb, 9}, + {0xa, 9}, {0x9, 9}, {0x11, 10}, {0x10, 10}, {0xf, 10}, {0xe, 10}, {0xd, 10}, {0xc, 10}}; + // macroblock_type flags as VDEC reports them: quant 0x10, forward 0x08, backward 0x04, pattern 0x02, intra 0x01. + struct MacroblockTypeCode + { + uint8_t code; + uint8_t length; + uint8_t flags; + }; + constexpr MacroblockTypeCode kMbTypeI[] = {{0x1, 1, 0x01}, {0x1, 2, 0x11}}; + constexpr MacroblockTypeCode kMbTypeP[] = {{0x1, 1, 0x0A}, {0x1, 2, 0x02}, {0x1, 3, 0x08}, {0x3, 5, 0x01}, + {0x2, 5, 0x1A}, {0x1, 5, 0x12}, {0x1, 6, 0x11}}; + constexpr MacroblockTypeCode kMbTypeB[] = {{0x2, 2, 0x0C}, {0x3, 2, 0x0E}, {0x2, 3, 0x04}, {0x3, 3, 0x06}, + {0x2, 4, 0x08}, {0x3, 4, 0x0A}, {0x3, 5, 0x01}, {0x2, 5, 0x1E}, + {0x3, 6, 0x1A}, {0x2, 6, 0x16}, {0x1, 6, 0x11}}; + constexpr MacroblockTypeCode kMbTypeD[] = {{0x1, 1, 0x01}}; + enum class DctKind : uint8_t { Invalid, @@ -140,6 +171,9 @@ namespace std::array dcLuma{}; std::array dcChroma{}; std::array mba{}; + std::array cbp{}; + std::array motion{}; + std::array mbType[4]{}; double cosine[8][8]{}; Tables() @@ -174,6 +208,19 @@ namespace { fill(mba, kMbaCode[i][0], kMbaCode[i][1], 11, ShortEntry{kMbaCode[i][1], static_cast(i + 1u)}); } + for (uint8_t i = 0; i < 64; ++i) + fill(cbp, kCbpCode[i][0], kCbpCode[i][1], 9, ShortEntry{kCbpCode[i][1], i}); + for (uint8_t i = 1; i < 17; ++i) + fill(motion, kMotionCode[i][0], kMotionCode[i][1], 10, ShortEntry{kMotionCode[i][1], i}); + const auto fillTypes = [this](int pictureType, const MacroblockTypeCode *codes, size_t count) + { + for (size_t i = 0; i < count; ++i) + fill(mbType[pictureType], codes[i].code, codes[i].length, 6, ShortEntry{codes[i].length, codes[i].flags}); + }; + fillTypes(0, kMbTypeI, std::size(kMbTypeI)); + fillTypes(1, kMbTypeP, std::size(kMbTypeP)); + fillTypes(2, kMbTypeB, std::size(kMbTypeB)); + fillTypes(3, kMbTypeD, std::size(kMbTypeD)); for (int x = 0; x < 8; ++x) { for (int u = 0; u < 8; ++u) @@ -239,16 +286,17 @@ void Ps2Ipu::reset() uint32_t Ps2Ipu::readRegister(uint32_t address) const { const uint32_t fifoQw = static_cast((m_fifo.size() - std::min(m_fifo.size(), (m_bitPos / 128u) * 16u)) / 16u); - const uint32_t outQw = static_cast(std::min(m_output.size() / 16u, 8u)); + // OFC must count only readable data: games poll it before reading OUT_FIFO with lq. + const uint32_t outQw = static_cast(std::min(outputBytes() / 16u, 8u)); switch (address & ~3u) { case kRegCmd: return m_cmdData; case kRegCmd + 4u: - return m_busy ? kCtrlBusy : 0u; + return busy() ? kCtrlBusy : 0u; case kRegCtrl: return (m_ctrl & kCtrlWritable) | (m_commandError ? kCtrlEcd : 0u) | (m_startCodeSeen ? kCtrlScd : 0u) | - (std::min(fifoQw, 8u) & 0xFu) | ((outQw & 0xFu) << 4u) | (m_busy ? kCtrlBusy : 0u); + (std::min(fifoQw, 8u) & 0xFu) | ((outQw & 0xFu) << 4u) | (busy() ? kCtrlBusy : 0u); case kRegBp: return (m_bitPos % 128u) | ((std::min(fifoQw, 8u) & 0xFu) << 8u); case kRegTop: @@ -268,7 +316,7 @@ uint32_t Ps2Ipu::readRegister(uint32_t address) const return value; } case kRegTop + 4u: - return m_busy ? kCtrlBusy : 0u; + return busy() ? kCtrlBusy : 0u; default: return 0u; } @@ -315,10 +363,13 @@ void Ps2Ipu::advance(uint64_t eeCycles) m_releaseCycles = 0; return; } + // Output appears after a short latency and then flows freely: the lag is what games that re-arm + // fromIPU rely on, while throttling throughput starves movie players that busy-wait on the drain. m_releaseCycles += eeCycles; - const uint64_t macroblocks = m_releaseCycles / kEeCyclesPerMacroblock; - m_releaseCycles %= kEeCyclesPerMacroblock; - m_releasedBytes = static_cast(std::min(m_output.size(), m_releasedBytes + macroblocks * 1024u)); + if (m_releaseCycles < kEeCyclesPerMacroblock) + return; + m_releaseCycles = 0; + m_releasedBytes = m_output.size(); } bool Ps2Ipu::readOutputFifo(uint8_t *quadword) @@ -340,6 +391,16 @@ void Ps2Ipu::feedDmaInput(const uint8_t *data, size_t size) m_dmaCursor = 0; } m_dmaInput.insert(m_dmaInput.end(), data, data + size); + inputAvailable(); +} + +void Ps2Ipu::setInputSource(InputSource source) +{ + m_inputSource = std::move(source); +} + +void Ps2Ipu::inputAvailable() +{ refill(); if (m_pending) runCommand(); @@ -356,14 +417,13 @@ size_t Ps2Ipu::takeOutput(uint8_t *destination, size_t maxBytes) Ps2Ipu::Snapshot Ps2Ipu::snapshot() const { - return Snapshot{m_fifo, m_bitPos, m_dmaCursor, m_dcPred, m_quantizerScale, m_output.size()}; + return Snapshot{m_bitPos, m_dcPred, m_quantizerScale, m_output.size()}; } void Ps2Ipu::restore(const Snapshot &state) { - m_fifo = state.fifo; + // FIFO bytes stay: whatever was pulled from DMA meanwhile is still owed to the replay. m_bitPos = state.bitPos; - m_dmaCursor = state.dmaCursor; m_dcPred = state.dcPred; m_quantizerScale = state.quantizerScale; m_output.resize(state.outputSize); @@ -378,6 +438,8 @@ void Ps2Ipu::runCommand() executeCommand(); m_pending = false; m_busy = false; + discardConsumed(); + refill(); } catch (const NeedInput &) { @@ -396,6 +458,12 @@ void Ps2Ipu::executeCommand() case kCmdIdec: commandIdec(); break; + case kCmdBdec: + commandBdec(); + break; + case kCmdVdec: + commandVdec(); + break; case kCmdFdec: commandFdec(); break; @@ -426,20 +494,37 @@ void Ps2Ipu::executeCommand() } } -void Ps2Ipu::refill() +void Ps2Ipu::discardConsumed() +{ + const size_t consumed = std::min((m_bitPos / 128u) * 16u, m_fifo.size() / 16u * 16u); + if (consumed == 0u) + return; + m_fifo.erase(m_fifo.begin(), m_fifo.begin() + static_cast(consumed)); + m_bitPos -= static_cast(consumed * 8u); +} + +size_t Ps2Ipu::liveFifoBytes() const noexcept { const size_t consumed = (m_bitPos / 128u) * 16u; - if (consumed != 0u) - { - m_fifo.erase(m_fifo.begin(), m_fifo.begin() + static_cast(std::min(consumed, m_fifo.size()))); - m_bitPos -= static_cast(consumed * 8u); - } - while (m_fifo.size() < kFifoBytes && m_dmaCursor + 16u <= m_dmaInput.size()) + return m_fifo.size() > consumed ? m_fifo.size() - consumed : 0u; +} + +void Ps2Ipu::refill() +{ + // Like the hardware FIFO, accept input only while fewer than 8 unread quadwords are queued. + // Consumed bytes are kept until the command completes so a rolled-back command can replay them. + while (liveFifoBytes() < kFifoBytes && m_dmaCursor + 16u <= m_dmaInput.size()) { m_fifo.insert(m_fifo.end(), m_dmaInput.begin() + static_cast(m_dmaCursor), m_dmaInput.begin() + static_cast(m_dmaCursor + 16u)); m_dmaCursor += 16u; } + if (!m_inputSource || liveFifoBytes() >= kFifoBytes) + return; + const size_t wantQuadwords = (kFifoBytes - liveFifoBytes()) / 16u; + uint8_t buffer[kFifoBytes]; + const size_t got = m_inputSource(buffer, wantQuadwords); + m_fifo.insert(m_fifo.end(), buffer, buffer + got * 16u); } bool Ps2Ipu::ensureBits(uint32_t count) @@ -544,6 +629,219 @@ void Ps2Ipu::commandCsc() } } +void Ps2Ipu::commandVdec() +{ + skipBits(m_cmd & 0x3Fu); + const Tables &t = tables(); + const uint32_t start = m_bitPos; + int32_t value = 0; + bool valid = true; + switch ((m_cmd >> 26u) & 3u) + { + case 0: // macroblock_address_increment; escape and stuffing come back as 0x23 and 0x22 + { + const ShortEntry entry = t.mba[peekBits(11)]; + const bool mpeg1 = (m_ctrl & (1u << 23)) != 0u; + valid = entry.length != 0u && (entry.value != 35u || mpeg1); + if (valid) + { + skipBits(entry.length); + value = entry.value == 34u ? 0x23 : (entry.value == 35u ? 0x22 : entry.value); + } + break; + } + case 1: // macroblock_type for the picture type in CTRL.PCT + { + const uint32_t pictureType = (m_ctrl >> 24u) & 7u; + const int table = pictureType >= 1u && pictureType <= 4u ? static_cast(pictureType) - 1 : 0; + const ShortEntry entry = t.mbType[table][peekBits(6)]; + valid = entry.length != 0u; + if (valid) + { + skipBits(entry.length); + value = entry.value; + } + break; + } + case 2: // motion_code + if (getBits(1) != 0u) + break; + { + // The leading '0' was consumed above, so the next nine bits complete the 10-bit index. + const ShortEntry entry = t.motion[peekBits(9)]; + valid = entry.length > 1u; + if (valid) + { + skipBits(entry.length - 1u); + value = getBits(1) != 0u ? -static_cast(entry.value) : entry.value; + } + } + break; + default: // dmvector: '0' 0, '10' +1, '11' -1 + if (getBits(1) != 0u) + value = getBits(1) != 0u ? -1 : 1; + break; + } + // DATA holds the decoded symbol in bits 0-15 and the code length in bits 16-31. + const uint32_t length = m_bitPos - start; + m_cmdData = valid ? ((static_cast(value) & 0xFFFFu) | (length << 16u)) : 0u; + m_commandError = !valid; +} + +void Ps2Ipu::commandBdec() +{ + skipBits(m_cmd & 0x3Fu); + m_commandError = false; + m_startCodeSeen = false; + + const uint32_t idp = (m_ctrl >> 16u) & 3u; + const bool intra = (m_cmd & (1u << 27)) != 0u; + const bool dcReset = (m_cmd & (1u << 26)) != 0u; + const bool fieldDct = (m_cmd & (1u << 25)) != 0u; + const bool nonLinear = (m_ctrl & (1u << 22)) != 0u; + const uint32_t code = (m_cmd >> 16u) & 0x1Fu; + m_quantizerScale = nonLinear ? static_cast(kNonLinearQuantizer[code]) : static_cast(code << 1u); + if (dcReset) + m_dcPred.fill(1 << (7 + idp)); + + // RAW16 macroblock: 16x16 luma, then 8x8 Cb and Cr, as signed halfwords. + int16_t luma[256] = {}; + int16_t cb[64] = {}; + int16_t cr[64] = {}; + const uint32_t pattern = intra ? 0x3Fu : decodeCodedBlockPattern(); + for (int i = 0; i < 6; ++i) + { + if ((pattern & (0x20u >> i)) == 0u) + continue; + int16_t block[64]; + const bool ok = intra ? decodeIntraBlock(i < 4 ? 0 : i - 3, block) : decodeNonIntraBlock(block); + if (!ok) + { + m_commandError = true; + return; + } + int16_t *destination = i == 4 ? cb : (i == 5 ? cr : nullptr); + int stride = 8; + if (destination == nullptr) + { + const int x = (i & 1) * 8; + destination = fieldDct ? luma + (i >> 1) * 16 + x : luma + (i >> 1) * 128 + x; + stride = fieldDct ? 32 : 16; + } + inverseDctSigned(block, destination, stride, intra); + } + for (const int16_t *plane : {static_cast(luma), static_cast(cb), static_cast(cr)}) + { + const size_t count = plane == luma ? 256u : 64u; + for (size_t i = 0; i < count; ++i) + { + m_output.push_back(static_cast(static_cast(plane[i]) & 0xFFu)); + m_output.push_back(static_cast(static_cast(plane[i]) >> 8u)); + } + } + + // Zero bits after a macroblock mean the slice ended: stop on the start code (SCD) or flag garbage (ECD). + if (peekBits(8) == 0u) + { + skipBits((8u - (m_bitPos % 8u)) % 8u); + for (;;) + { + const uint32_t next = peekBits(24); + if (next != 0u) + { + if (next == 1u) + m_startCodeSeen = true; + else + m_commandError = true; + break; + } + skipBits(8); + } + } +} + +uint32_t Ps2Ipu::decodeCodedBlockPattern() +{ + const ShortEntry entry = tables().cbp[peekBits(9)]; + if (entry.length == 0u) + return 0u; + skipBits(entry.length); + return entry.value; +} + +bool Ps2Ipu::decodeNonIntraBlock(int16_t *block) +{ + const Tables &t = tables(); + std::fill(block, block + 64, int16_t{0}); + + const bool mpeg1 = (m_ctrl & (1u << 23)) != 0u; + const bool alternateScan = (m_ctrl & (1u << 20)) != 0u; + const uint8_t *scan = alternateScan ? kAlternateScan : kZigzag; + + int32_t sum = 0; + int index = -1; + for (;;) + { + int run = 0; + int level = 0; + if (index < 0 && peekBits(1) != 0u) + { + // First coefficient of a non-intra block: '1s' is run 0, level 1 (there is no end of block yet). + skipBits(1); + level = getBits(1) != 0u ? -1 : 1; + } + else + { + const DctEntry entry = t.dct[0][peekBits(16)]; + switch (entry.kind) + { + case DctKind::EndOfBlock: + skipBits(entry.length); + if (!mpeg1 && (sum & 1) == 0) + block[63] ^= 1; + return true; + case DctKind::RunLevel: + skipBits(entry.length); + run = entry.run; + level = getBits(1) != 0u ? -static_cast(entry.level) : entry.level; + break; + case DctKind::Escape: + skipBits(entry.length); + run = static_cast(getBits(6)); + if (mpeg1) + { + level = static_cast(getBits(8)); + if (level == 0) + level = static_cast(getBits(8)); + else if (level == -128) + level = static_cast(getBits(8)) - 256; + } + else + { + level = static_cast(getBits(12)); + if (level & 0x800) + level -= 0x1000; + } + break; + default: + return false; + } + } + + index += run + 1; + if (index > 63) + return false; + const int position = scan[index]; + int32_t magnitude = ((2 * std::abs(level) + 1) * m_quantizerScale * m_nonIntraMatrix[position]) >> 5; + if (mpeg1 && magnitude != 0 && (magnitude & 1) == 0) + magnitude -= 1; // MPEG-1 oddification + int32_t value = level < 0 ? -magnitude : magnitude; + value = std::clamp(value, -2048, 2047); + block[position] = static_cast(value); + sum += value; + } +} + int Ps2Ipu::decodeMacroblockAddressIncrement() { int increment = 0; @@ -722,6 +1020,38 @@ bool Ps2Ipu::decodeIntraBlock(int component, int16_t *block) } } +void Ps2Ipu::inverseDctSigned(const int16_t *coefficients, int16_t *destination, int stride, bool intra) const +{ + // Intra blocks are pixels (0..255); residuals saturate to the 9-bit range motion compensation adds. + uint8_t pixels[64]; + if (intra) + { + inverseDct(coefficients, pixels, 8); + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + destination[y * stride + x] = pixels[y * 8 + x]; + return; + } + const auto &c = tables().cosine; + double rows[8][8]; + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + { + double value = 0.0; + for (int u = 0; u < 8; ++u) + value += c[x][u] * coefficients[y * 8 + u]; + rows[y][x] = value; + } + for (int x = 0; x < 8; ++x) + for (int y = 0; y < 8; ++y) + { + double value = 0.0; + for (int v = 0; v < 8; ++v) + value += c[y][v] * rows[v][x]; + destination[y * stride + x] = static_cast(std::clamp(static_cast(std::floor(value / 4.0 + 0.5)), -256, 255)); + } +} + void Ps2Ipu::inverseDct(const int16_t *coefficients, uint8_t *destination, int stride) const { const auto &c = tables().cosine; diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index 28e7378d82..41d724dbc2 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -207,7 +207,8 @@ namespace out.qwc = static_cast(tag & 0xFFFFu); out.id = static_cast((tag >> 28u) & 0x7u); out.irq = ((tag >> 31u) & 0x1ull) != 0ull; - out.addr = static_cast((tag >> 32u) & 0x7FFFFFFFu); + // Bit 31 is the scratchpad select; resolveContiguous maps it. + out.addr = static_cast(tag >> 32u); out.upper = static_cast((tag >> 16u) & 0xFFFFu); return out; } @@ -242,6 +243,8 @@ PS2Memory::PS2Memory() : m_rdram(nullptr), m_scratchpad(nullptr), iop_ram(nullptr), m_seenGifCopy(false), m_gsVRAM(nullptr) { ps2SetScratchpadHostPtr(nullptr); + m_ipu.setInputSource([this](uint8_t *destination, size_t maxQuadwords) + { return pullToIpu(destination, maxQuadwords); }); } PS2Memory::~PS2Memory() @@ -827,6 +830,14 @@ uint64_t PS2Memory::read64(uint32_t address) return loadScalar(vuMem, vuOffset, vuLimit, "read64 vu", address); } + // libmpeg polls IPU_CMD/IPU_TOP with ld: DATA in the low word, BUSY in bit 63. + if (address >= Ps2Ipu::kRegCmd && address < Ps2Ipu::kRegTop + 0x10u) + { + serviceIpuDma(); + return static_cast(m_ipu.readRegister(address)) | + (static_cast(m_ipu.readRegister(address + 4u)) << 32); + } + // 64-bit IO read: compose from the two adjacent 32-bit IO register slots // to avoid any side-effects from read32 handlers. if (isIoRegister(address)) @@ -1414,6 +1425,9 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) int tagsProcessed = 0; uint32_t lastTagUpper = (chcr >> 16) & 0xFFFFu; + bool stalled = false; + if (m_stalledDrainChannel == channelBase) + m_stalledDrainChannel = 0u; while (tagsProcessed < kMaxChainTags) { @@ -1523,6 +1537,14 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) break; } + if (id == 4u && hasPayload && dmaStallBlocks(channelBase, dataAddr, tagQwc)) + { + // REFS reads past what the stall source has written: park on this tag until D_STADR moves. + tagAddr = currentTagAddr; + stalled = true; + break; + } + if (transferTagData) appendVifTagData(currentTagAddr); @@ -1534,6 +1556,8 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) break; } + if (stalled) + m_stalledDrainChannel = channelBase; m_ioRegisters[channelBase + 0x30] = tagAddr; m_ioRegisters[channelBase + 0x40] = asr0; m_ioRegisters[channelBase + 0x50] = asr1; @@ -1605,6 +1629,8 @@ bool PS2Memory::tryProcessScratchpadDma(uint32_t channelBase, uint32_t chcr) return false; const uint32_t mode = (chcr >> 2u) & 0x3u; + if (mode == 1u && channelBase == kSprToChannel) + return processToScratchpadChain(chcr); if (mode != 0u) return false; @@ -1653,6 +1679,134 @@ bool PS2Memory::tryProcessScratchpadDma(uint32_t channelBase, uint32_t chcr) m_ioRegisters[channelBase + 0x20u] = 0u; m_ioRegisters[channelBase + 0x80u] = (originalSadr + byteCount) & 0x3FF0u; completeDmacChannel(channelBase, fromScratchpad ? 8u : 9u); + if (fromScratchpad) + advanceDmaStallAddress(kStallSourceFromSpr, mainOffset + byteCount); + return true; +} + +bool PS2Memory::dmaStallBlocks(uint32_t channelBase, uint32_t dataAddr, uint32_t qwc) +{ + // D_CTRL.STD names the drain channel that honours D_STADR; REFS tags on it wait for the source. + static constexpr uint32_t kDCtrl = 0x1000E000u; + static constexpr uint32_t kDStadr = 0x1000E020u; + const uint32_t dctrl = m_ioRegisters.count(kDCtrl) ? m_ioRegisters[kDCtrl] : 0u; + const uint32_t drain = (dctrl >> 6u) & 0x3u; + const bool isDrain = (drain == 1u && channelBase == 0x10009000u) || (drain == 2u && channelBase == 0x1000A000u) || + (drain == 3u && channelBase == 0x1000C400u); + if (!isDrain || ((dctrl >> 4u) & 0x3u) == 0u || isScratchpad(dataAddr)) + return false; + uint32_t phys = 0u; + try + { + phys = translateAddress(dataAddr); + } + catch (const std::exception &) + { + return false; + } + const uint32_t stadr = (m_ioRegisters.count(kDStadr) ? m_ioRegisters[kDStadr] : 0u) & 0x7FFFFFF0u; + return phys + qwc * 16u > stadr; +} + +void PS2Memory::advanceDmaStallAddress(uint32_t sourceChannel, uint32_t physicalEnd) +{ + static constexpr uint32_t kDCtrl = 0x1000E000u; + static constexpr uint32_t kDStadr = 0x1000E020u; + const uint32_t dctrl = m_ioRegisters.count(kDCtrl) ? m_ioRegisters[kDCtrl] : 0u; + if (((dctrl >> 4u) & 0x3u) != sourceChannel) + return; + m_ioRegisters[kDStadr] = physicalEnd & 0x7FFFFFF0u; + if (m_stalledDrainChannel == 0u) + return; + // Re-issue the parked chain from its TADR; it picks up from the REFS tag it stopped on. + const uint32_t channel = m_stalledDrainChannel; + m_stalledDrainChannel = 0u; + if ((m_ioRegisters[channel] & 0x100u) != 0u) + writeIORegister(channel, m_ioRegisters[channel]); +} + +bool PS2Memory::processToScratchpadChain(uint32_t chcr) +{ + // libmpeg gathers motion-compensation reference blocks with a toSPR source chain, one ref tag per row. + static constexpr uint32_t kSprToChannel = 0x1000D400u; + uint32_t tadr = m_ioRegisters[kSprToChannel + 0x30u]; + uint32_t madr = m_ioRegisters[kSprToChannel + 0x10u]; + uint32_t sadr = m_ioRegisters[kSprToChannel + 0x80u] & 0x3FF0u; + uint32_t asr[2] = {m_ioRegisters[kSprToChannel + 0x40u], m_ioRegisters[kSprToChannel + 0x50u]}; + uint32_t asp = (chcr >> 4u) & 0x3u; + const bool tagInterrupts = (chcr & (1u << 7)) != 0u; + + for (int tags = 0; tags < 65536; ++tags) + { + std::vector tagBytes; + if (!appendDmaSource(tadr, 16u, tagBytes)) + break; + uint64_t tag = 0; + std::memcpy(&tag, tagBytes.data(), sizeof(tag)); + const uint32_t tagQwc = static_cast(tag & 0xFFFFu); + const uint32_t id = static_cast((tag >> 28u) & 0x7u); + const uint32_t addr = static_cast(tag >> 32u); + bool end = false; + switch (id) + { + case 0: // refe + madr = addr; + tadr += 16u; + end = true; + break; + case 1: // cnt + madr = tadr + 16u; + tadr = madr + tagQwc * 16u; + break; + case 2: // next + madr = tadr + 16u; + tadr = addr; + break; + case 3: // ref + case 4: // refs + madr = addr; + tadr += 16u; + break; + case 5: // call + madr = tadr + 16u; + if (asp < 2u) + asr[asp++] = madr + tagQwc * 16u; + tadr = addr; + break; + case 6: // ret + madr = tadr + 16u; + if (asp > 0u) + tadr = asr[--asp]; + else + end = true; + break; + default: // end + madr = tadr + 16u; + end = true; + break; + } + + std::vector data; + if (tagQwc != 0u && !appendDmaSource(madr, tagQwc * 16u, data)) + break; + for (size_t offset = 0; offset < data.size(); offset += 16u) + { + std::memcpy(m_scratchpad + sadr, data.data() + offset, 16u); + sadr = (sadr + 16u) & (PS2_SCRATCHPAD_SIZE - 1u); + } + madr += tagQwc * 16u; + if (end || (tagInterrupts && ((tag >> 31u) & 1u) != 0u)) + break; + } + + m_ioRegisters[kSprToChannel + 0x10u] = madr; + m_ioRegisters[kSprToChannel + 0x20u] = 0u; + m_ioRegisters[kSprToChannel + 0x30u] = tadr; + m_ioRegisters[kSprToChannel + 0x40u] = asr[0]; + m_ioRegisters[kSprToChannel + 0x50u] = asr[1]; + m_ioRegisters[kSprToChannel + 0x80u] = sadr; + m_ioRegisters[kSprToChannel] = (chcr & ~0x30u) | (asp << 4u); + completeDmacChannel(kSprToChannel, 9u); return true; } @@ -1689,79 +1843,103 @@ bool PS2Memory::tryStartIpuDma(uint32_t channelBase, uint32_t chcr) if (channelBase != kToIpu) return false; - // The whole source is copied up front; the IPU still only exposes 8 quadwords of it at a time. - std::vector data; - const uint32_t mode = (chcr >> 2u) & 0x3u; - uint32_t madr = m_ioRegisters[channelBase + 0x10u]; - uint32_t qwc = m_ioRegisters[channelBase + 0x20u] & 0xFFFFu; - if (mode == 0u) - { - appendDmaSource(madr, qwc * 16u, data); - madr += qwc * 16u; - qwc = 0u; - } - else + // The channel stays live: the IPU pulls quadwords as its FIFO drains (see pullToIpu). + m_toIpuLastTag = false; + m_ipu.inputAvailable(); + return true; +} + +size_t PS2Memory::pullToIpu(uint8_t *destination, size_t maxQuadwords) +{ + static constexpr uint32_t kToIpu = 0x1000B400u; + size_t moved = 0; + for (int guard = 0; moved < maxQuadwords && guard < 4096; ++guard) { - uint32_t tadr = m_ioRegisters[channelBase + 0x30u]; - uint32_t asr[2] = {m_ioRegisters[channelBase + 0x40u], m_ioRegisters[channelBase + 0x50u]}; - uint32_t asp = (chcr >> 4u) & 0x3u; - for (int tags = 0; tags < 4096; ++tags) + uint32_t &chcr = m_ioRegisters[kToIpu]; + if ((chcr & 0x100u) == 0u) + break; + uint32_t &madr = m_ioRegisters[kToIpu + 0x10u]; + uint32_t &qwc = m_ioRegisters[kToIpu + 0x20u]; + uint32_t &tadr = m_ioRegisters[kToIpu + 0x30u]; + const bool chain = ((chcr >> 2u) & 0x3u) == 1u; + + if ((qwc & 0xFFFFu) == 0u) { + if (!chain || m_toIpuLastTag) + { + completeDmacChannel(kToIpu, 4u); + break; + } std::vector tagBytes; if (!appendDmaSource(tadr, 16u, tagBytes)) + { + completeDmacChannel(kToIpu, 4u); break; + } uint64_t tag = 0; std::memcpy(&tag, tagBytes.data(), sizeof(tag)); const uint32_t tagQwc = static_cast(tag & 0xFFFFu); const uint32_t id = static_cast((tag >> 28u) & 0x7u); const uint32_t addr = static_cast(tag >> 32u); - const bool irq = ((tag >> 31u) & 1u) != 0u; - bool end = false; - uint32_t dataAddr = tadr + 16u; + uint32_t asp = (chcr >> 4u) & 0x3u; switch (id) { case 0: // refe - dataAddr = addr; - end = true; + madr = addr; + tadr += 16u; + m_toIpuLastTag = true; break; case 1: // cnt - tadr = dataAddr + tagQwc * 16u; + madr = tadr + 16u; + tadr = madr + tagQwc * 16u; break; case 2: // next + madr = tadr + 16u; tadr = addr; break; case 3: // ref case 4: // refs - dataAddr = addr; + madr = addr; tadr += 16u; break; case 5: // call + madr = tadr + 16u; if (asp < 2u) - asr[asp++] = dataAddr + tagQwc * 16u; + m_ioRegisters[kToIpu + 0x40u + asp++ * 0x10u] = madr + tagQwc * 16u; tadr = addr; break; case 6: // ret + madr = tadr + 16u; if (asp > 0u) - tadr = asr[--asp]; + tadr = m_ioRegisters[kToIpu + 0x40u + --asp * 0x10u]; else - end = true; + m_toIpuLastTag = true; break; default: // end - end = true; + madr = tadr + 16u; + m_toIpuLastTag = true; break; } - appendDmaSource(dataAddr, tagQwc * 16u, data); - if (end || (irq && (chcr & (1u << 7)) != 0u)) - break; + // The tag's upper half lands in CHCR.TAG, as on hardware. + chcr = (chcr & 0xFFCFu) | (asp << 4u) | (static_cast(tag) & 0xFFFF0000u); + if (((tag >> 31u) & 1u) != 0u && (chcr & (1u << 7)) != 0u) + m_toIpuLastTag = true; + qwc = (qwc & 0xFFFF0000u) | tagQwc; + continue; } - m_ioRegisters[channelBase + 0x30u] = tadr; - qwc = 0u; + + std::vector quadword; + if (!appendDmaSource(madr, 16u, quadword)) + { + completeDmacChannel(kToIpu, 4u); + break; + } + std::memcpy(destination + moved * 16u, quadword.data(), 16u); + ++moved; + madr += 16u; + qwc = (qwc & 0xFFFF0000u) | ((qwc & 0xFFFFu) - 1u); } - m_ioRegisters[channelBase + 0x10u] = madr; - m_ioRegisters[channelBase + 0x20u] = qwc; - m_ipu.feedDmaInput(data.data(), data.size()); - completeDmacChannel(channelBase, 4u); - return true; + return moved; } void PS2Memory::serviceIpuDma() @@ -1773,6 +1951,7 @@ void PS2Memory::serviceIpuDma() uint32_t &madr = m_ioRegisters[kFromIpu + 0x10u]; uint32_t &qwc = m_ioRegisters[kFromIpu + 0x20u]; // fromIPU stays active until the decoder has produced the requested quadwords. + uint32_t drainedTo = 0u; while ((qwc & 0xFFFFu) != 0u && m_ipu.outputBytes() >= 16u) { alignas(16) uint8_t quadword[16]; @@ -1789,7 +1968,11 @@ void PS2Memory::serviceIpuDma() markModified(offset, sizeof(quadword)); madr += 16u; qwc = (qwc & 0xFFFF0000u) | ((qwc & 0xFFFFu) - 1u); + if (!scratch) + drainedTo = offset + 16u; } + if (drainedTo != 0u) + advanceDmaStallAddress(kStallSourceFromIpu, drainedTo); if ((qwc & 0xFFFFu) == 0u) completeDmacChannel(kFromIpu, 3u); } @@ -2033,7 +2216,7 @@ void PS2Memory::processPendingTransfers() m_ioRegisters[D_STAT] = dstat; }; - if (hadGif) + if (hadGif && m_stalledDrainChannel != GIF_CHANNEL) { raiseDStatChannel(2u); // GIF channel queueCompletedDmacCause(2u); @@ -2047,7 +2230,7 @@ void PS2Memory::processPendingTransfers() m_ioRegisters[VIF0_CHANNEL + 0x00] &= ~0x100u; m_ioRegisters[VIF0_CHANNEL + 0x20] = 0; } - if (hadVif1) + if (hadVif1 && m_stalledDrainChannel != VIF1_CHANNEL) { raiseDStatChannel(1u); // VIF1 channel queueCompletedDmacCause(1u); @@ -2171,10 +2354,12 @@ bool PS2Memory::tryProcessNativeGifImageUploadChain(GS &gs, uint32_t tadr, uint3 if (dctrlIt != m_ioRegisters.end() && ((dctrlIt->second & 0x1u) == 0u)) return false; - auto resolveContiguous = [&](uint32_t guestAddr, uint32_t bytes, const uint8_t *&out) -> bool + auto resolveContiguous = [&](uint32_t dmaAddr, uint32_t bytes, const uint8_t *&out) -> bool { try { + // Tag addresses come straight from DMA tags, where bit 31 selects scratchpad. + const uint32_t guestAddr = ps2DmacAddressToEe(dmaAddr); const bool scratch = isScratchpad(guestAddr); const uint32_t phys = translateAddress(guestAddr); const uint8_t *base = scratch ? m_scratchpad : m_rdram; @@ -2291,6 +2476,8 @@ bool PS2Memory::tryProcessNativeGifImageUploadChain(GS &gs, uint32_t tadr, uint3 uint32_t imageDataAddr = 0u; uint32_t finalTadr = payloadTagAddr; uint32_t lastTagUpper = payloadTag.upper; + if (payloadTag.id == 4u && dmaStallBlocks(GIF_CHANNEL, ps2DmacAddressToEe(payloadTag.addr), payloadTag.qwc)) + return false; // let the general chain walker park on the stall if (payloadTag.id == 3u || payloadTag.id == 4u) { imageDataAddr = payloadTag.addr; @@ -2363,10 +2550,12 @@ bool PS2Memory::tryProcessNativeGifPackedChain(GS &gs, uint32_t tadr, uint32_t c if (dctrlIt != m_ioRegisters.end() && ((dctrlIt->second & 0x1u) == 0u)) return false; - auto resolveContiguous = [&](uint32_t guestAddr, uint32_t bytes, const uint8_t *&out) -> bool + auto resolveContiguous = [&](uint32_t dmaAddr, uint32_t bytes, const uint8_t *&out) -> bool { try { + // Tag addresses come straight from DMA tags, where bit 31 selects scratchpad. + const uint32_t guestAddr = ps2DmacAddressToEe(dmaAddr); const bool scratch = isScratchpad(guestAddr); const uint32_t phys = translateAddress(guestAddr); const uint8_t *base = scratch ? m_scratchpad : m_rdram; @@ -2470,6 +2659,8 @@ uint32_t PS2Memory::readIORegister(uint32_t address) if (address >= Ps2Ipu::kRegCmd && address < Ps2Ipu::kRegTop + 0x10u) { + // Pollers wait for BUSY to clear, which needs fromIPU to keep draining output. + serviceIpuDma(); return m_ipu.readRegister(address); } @@ -2504,6 +2695,9 @@ uint32_t PS2Memory::readIORegister(uint32_t address) serviceIpuDma(); return m_ioRegisters[address]; } + // A drain parked on DMAC stall control is genuinely still running. + if (address == m_stalledDrainChannel) + return m_ioRegisters[address]; if ((address & 0xFF) == 0x00) { uint32_t channelStatus = m_ioRegisters[address] & ~0x100u; diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index 6f15006a60..c7aa58e8bc 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -155,6 +155,34 @@ namespace } +namespace +{ + // Packs {value, bitCount} fields MSB-first and pads to whole quadwords, as the IPU consumes them. + std::vector packIpuBits(const std::vector> &fields) + { + std::vector out; + uint32_t accumulator = 0u; + uint32_t bits = 0u; + for (const auto &[value, count] : fields) + { + for (uint32_t i = count; i-- > 0u;) + { + accumulator = (accumulator << 1u) | ((value >> i) & 1u); + if (++bits == 8u) + { + out.push_back(static_cast(accumulator)); + accumulator = 0u; + bits = 0u; + } + } + } + if (bits != 0u) + out.push_back(static_cast(accumulator << (8u - bits))); + out.resize((out.size() + 31u) / 16u * 16u, 0u); + return out; + } +} + void register_ps2_memory_tests() { MiniTest::Case("PS2Memory", [](TestCase &tc) @@ -1180,6 +1208,50 @@ void register_ps2_memory_tests() "SPR-flagged REFE data should come from scratchpad, not RDRAM"); }); + tc.Run("GIF REFS tags wait for D_STADR under DMAC stall control", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kGifCh = 0x1000A000u; + constexpr uint32_t kFromSpr = 0x1000D000u; + constexpr uint32_t kChain = 0x00030000u; + constexpr uint32_t kBuffer = 0x00031000u; + uint8_t *rdram = mem.getRDRAM(); + uint8_t *scratch = mem.getScratchpad(); + std::memset(rdram + kBuffer, 0, 16u); + for (uint32_t i = 0; i < 16u; ++i) + scratch[0x400u + i] = static_cast(0xA0u + i); + + // Sofdec queues the whole frame's GIF chain first; fromSPR fills each REFS block afterwards. + const uint64_t refsTag = 1ull | (4ull << 28) | (static_cast(kBuffer) << 32); + const uint64_t endTag = (7ull << 28); + std::memcpy(rdram + kChain, &refsTag, sizeof(refsTag)); + std::memcpy(rdram + kChain + 16u, &endTag, sizeof(endTag)); + + std::vector> captured; + mem.setGifPacketCallback([&](const uint8_t *data, uint32_t sizeBytes) + { captured.emplace_back(data, data + sizeBytes); }); + + mem.writeIORegister(0x1000E000u, 1u | (2u << 4) | (2u << 6)); // DMAE, STS=fromSPR, STD=GIF + mem.writeIORegister(0x1000E020u, kBuffer); // D_STADR: nothing written yet + mem.writeIORegister(kGifCh + 0x30u, kChain); + mem.writeIORegister(kGifCh + 0x00u, 0x105u); + mem.processPendingTransfers(); + t.IsTrue(captured.empty(), "GIF should not read the REFS block before the source writes it"); + t.IsTrue((mem.readIORegister(kGifCh) & 0x100u) != 0u, "GIF should stay busy while stalled"); + + mem.writeIORegister(kFromSpr + 0x10u, kBuffer); + mem.writeIORegister(kFromSpr + 0x80u, 0x400u); + mem.writeIORegister(kFromSpr + 0x20u, 1u); + mem.writeIORegister(kFromSpr + 0x00u, 0x100u); + mem.processPendingTransfers(); + t.Equals(captured.size(), static_cast(1u), "advancing D_STADR should release the stalled REFS block"); + t.IsTrue(!captured.empty() && captured[0].size() == 16u && captured[0][0] == 0xA0u, + "the released block should carry the data fromSPR wrote"); + t.Equals(mem.readIORegister(kGifCh) & 0x100u, 0u, "GIF should complete after the END tag"); + }); + tc.Run("IPU FDEC peeks and advances the bitstream fed by toIPU DMA", [](TestCase &t) { PS2Memory mem; @@ -1203,6 +1275,200 @@ void register_ps2_memory_tests() t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd + 4u) & 0x80000000u, 0u, "FDEC should not stay busy"); }); + tc.Run("toSPR source chain gathers ref-tagged blocks into scratchpad", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kToSpr = 0x1000D400u; + constexpr uint32_t kTags = 0x00050000u; + constexpr uint32_t kRowA = 0x00060000u; + constexpr uint32_t kRowB = 0x00061000u; + uint8_t *rdram = mem.getRDRAM(); + std::memset(rdram + kRowA, 0xAA, 32); + std::memset(rdram + kRowB, 0xBB, 16); + // libmpeg's _getRef0 fetches reference rows with ref tags and closes the list with refe. + const uint64_t tags[4] = { + (3ull << 28) | 2u | (static_cast(kRowA) << 32), 0u, // ref, 2 qw + (0ull << 28) | 1u | (static_cast(kRowB) << 32), 0u, // refe, 1 qw + }; + std::memcpy(rdram + kTags, tags, sizeof(tags)); + + mem.writeIORegister(kToSpr + 0x30u, kTags); + mem.writeIORegister(kToSpr + 0x80u, 0x100u); + mem.writeIORegister(kToSpr + 0x20u, 0u); + mem.writeIORegister(kToSpr + 0x00u, 0x105u); + + t.Equals(mem.readIORegister(kToSpr) & 0x100u, 0u, "the chain should complete"); + t.Equals(mem.read8(0x70000100u), static_cast(0xAA), "the first ref block should land at SADR"); + t.Equals(mem.read8(0x7000011Fu), static_cast(0xAA), "both quadwords of the first block should arrive"); + t.Equals(mem.read8(0x70000120u), static_cast(0xBB), "the refe block should follow contiguously"); + t.Equals(mem.readIORegister(kToSpr + 0x80u), 0x130u, "SADR should advance by the transferred quadwords"); + }); + + tc.Run("IPU stays BUSY until its output fits in the OUT FIFO", [](TestCase &t) + { + // CSC of one macroblock produces 64 quadwords of RGB32; callers start the GS upload once BUSY clears. + std::vector> fields; + for (int i = 0; i < 384; ++i) + fields.push_back({0x80u, 8u}); + const std::vector stream = packIpuBits(fields); + Ps2Ipu ipu; + ipu.feedDmaInput(stream.data(), stream.size()); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x70000001u); // CSC, one macroblock, RGB32 + t.IsTrue((ipu.readRegister(Ps2Ipu::kRegCtrl) & 0x80000000u) != 0u, "CSC should be busy while output is pending"); + ipu.advance(1u << 20); + std::vector out(1024u - 128u); + ipu.takeOutput(out.data(), out.size()); + t.Equals(ipu.readRegister(Ps2Ipu::kRegCtrl) & 0x80000000u, 0u, "BUSY should clear once 8 quadwords remain"); + }); + + tc.Run("IPU_CTRL.OFC counts only output that OUT_FIFO reads can return", [](TestCase &t) + { + // Sofdec drains CSC output with lq from OUT_FIFO after checking OFC; a count ahead of the data reads zeros. + std::vector> fields; + for (int i = 0; i < 384; ++i) + fields.push_back({0x80u, 8u}); + const std::vector stream = packIpuBits(fields); + Ps2Ipu ipu; + ipu.feedDmaInput(stream.data(), stream.size()); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x70000001u); // CSC, one macroblock, RGB32 + const uint32_t ofc = (ipu.readRegister(Ps2Ipu::kRegCtrl) >> 4u) & 0xFu; + uint8_t quadword[16] = {}; + for (uint32_t i = 0; i < ofc; ++i) + t.IsTrue(ipu.readOutputFifo(quadword), "every quadword OFC promises should be readable"); + ipu.advance(1u << 20); + t.Equals((ipu.readRegister(Ps2Ipu::kRegCtrl) >> 4u) & 0xFu, 8u, "released output should fill OFC"); + t.IsTrue(ipu.readOutputFifo(quadword) && quadword[0] != 0u, "the read should return converted pixels"); + }); + + tc.Run("IPU_CTRL reset is a strobe that reads back clear", [](TestCase &t) + { + Ps2Ipu ipu; + ipu.writeRegister(Ps2Ipu::kRegCtrl, 0x40000000u | (1u << 23)); // RST + MP1 + t.Equals(ipu.readRegister(Ps2Ipu::kRegCtrl) & 0x40000000u, 0u, "RST should not read back"); + t.IsTrue((ipu.readRegister(Ps2Ipu::kRegCtrl) & (1u << 23)) != 0u, "MP1 should be kept"); + + const std::vector stream = packIpuBits({{0xABCDEF01u, 32u}}); + ipu.feedDmaInput(stream.data(), stream.size()); + // libmpeg updates PCT with a read-modify-write; that must not wipe the input FIFO. + ipu.writeRegister(Ps2Ipu::kRegCtrl, ipu.readRegister(Ps2Ipu::kRegCtrl) | (2u << 24)); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x40000000u); + t.Equals(ipu.readRegister(Ps2Ipu::kRegCmd), 0xABCDEF01u, "the FIFO should survive a CTRL update"); + }); + + tc.Run("toIPU DMA advances only as the IPU input FIFO drains", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kToIpu = 0x1000B400u; + constexpr uint32_t kSrc = 0x00040000u; + uint8_t *rdram = mem.getRDRAM(); + for (uint32_t i = 0; i < 32u * 16u; ++i) + rdram[kSrc + i] = static_cast(i / 16u); // each quadword holds its index + + mem.writeIORegister(kToIpu + 0x10u, kSrc); + mem.writeIORegister(kToIpu + 0x20u, 32u); + mem.writeIORegister(kToIpu + 0x00u, 0x101u); + // sceIpuStopDMA rewinds MADR by the FIFO count, so the channel must not run ahead of the FIFO. + t.IsTrue((mem.readIORegister(kToIpu) & 0x100u) != 0u, "toIPU should stay busy while the FIFO is full"); + t.Equals(mem.readIORegister(kToIpu + 0x20u) & 0xFFFFu, 24u, "only eight quadwords should have moved"); + t.Equals(mem.readIORegister(kToIpu + 0x10u), kSrc + 8u * 16u, "MADR should follow the moved quadwords"); + t.Equals((mem.readIORegister(Ps2Ipu::kRegCtrl) & 0xFu), 8u, "IPU_CTRL.IFC should report a full FIFO"); + + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000000u | 32u); // FDEC 32 drains no whole quadword yet + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000000u | 32u); + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000000u | 32u); + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x40000000u | 32u); // 128 bits: quadword 0 consumed + t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd) >> 24u, 1u, "the stream should now read from quadword 1"); + t.Equals(mem.readIORegister(kToIpu + 0x20u) & 0xFFFFu, 23u, "draining one quadword should pull exactly one more"); + }); + + tc.Run("64-bit reads of IPU_CMD return the command result and BUSY", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + const std::vector stream = packIpuBits({{0x000001B3u, 32u}}); + mem.ipu().feedDmaInput(stream.data(), stream.size()); + mem.write32(Ps2Ipu::kRegCmd, 0x40000000u); // FDEC 0 + // libmpeg's _sceMpegWaitIpuIdle64 reads the result with ld and checks bit 63 for BUSY. + t.Equals(mem.read64(Ps2Ipu::kRegCmd), static_cast(0x000001B3u), "ld IPU_CMD should return DATA with BUSY clear"); + t.Equals(mem.read64(Ps2Ipu::kRegTop), static_cast(0x000001B3u), "ld IPU_TOP should return BSTOP"); + }); + + tc.Run("IPU VDEC reports each VLC symbol with its code length", [](TestCase &t) + { + // MBAI 2 '011', P macroblock_type forward-only '001', motion_code -3 '0001'+'1', dmvector -1 '11'. + const std::vector stream = packIpuBits({{0x3u, 3u}, {0x1u, 3u}, {0x1u, 4u}, {0x1u, 1u}, {0x3u, 2u}}); + Ps2Ipu ipu; + ipu.writeRegister(Ps2Ipu::kRegCtrl, 2u << 24); // PCT = P picture + ipu.feedDmaInput(stream.data(), stream.size()); + + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x30000000u); // VDEC TBL=0 + t.Equals(ipu.readRegister(Ps2Ipu::kRegCmd), 0x00030002u, "MBAI should decode 2 from a 3-bit code"); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x34000000u); // VDEC TBL=1 + t.Equals(ipu.readRegister(Ps2Ipu::kRegCmd), 0x00030008u, "a P macroblock '001' is forward motion, not coded"); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x38000000u); // VDEC TBL=2 + t.Equals(ipu.readRegister(Ps2Ipu::kRegCmd), 0x0005FFFDu, "motion_code should be signed and count its sign bit"); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x3C000000u); // VDEC TBL=3 + t.Equals(ipu.readRegister(Ps2Ipu::kRegCmd), 0x0002FFFFu, "dmvector '11' should decode to -1"); + t.Equals(ipu.readRegister(Ps2Ipu::kRegCtrl) & (1u << 14), 0u, "valid symbols should not set ECD"); + }); + + tc.Run("IPU BDEC outputs RAW16 residuals for a coded non-intra block and stops on a start code", [](TestCase &t) + { + // coded_block_pattern 4 '1101' selects luma block 3; '1'+'0' is DCT first coefficient +1, '10' ends it. + const std::vector stream = + packIpuBits({{0xDu, 4u}, {0x1u, 1u}, {0x0u, 1u}, {0x2u, 2u}, {0x0u, 8u}, {0x000001B3u, 32u}}); + Ps2Ipu ipu; + ipu.feedDmaInput(stream.data(), stream.size()); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x20080000u); // BDEC, non-intra, quantiser_scale_code 8 + ipu.advance(1u << 20); + + t.Equals(ipu.outputBytes(), static_cast(768), "a macroblock is 384 signed halfwords"); + std::vector out(768); + ipu.takeOutput(out.data(), out.size()); + const auto sample = [&](size_t index) + { return static_cast(out[index * 2] | (out[index * 2 + 1] << 8)); }; + // (2*1+1) * 16 * 16 >> 5 = 24 at DC, so every pixel of the block carries 24/8 = 3. + t.Equals(sample(8 * 16 + 8), static_cast(3), "block 3 starts at luma row 8, column 8"); + t.Equals(sample(15 * 16 + 15), static_cast(3), "the whole block should carry the residual"); + t.Equals(sample(0), static_cast(0), "uncoded blocks should be zero"); + t.Equals(sample(256), static_cast(0), "uncoded chroma should be zero"); + const uint32_t ctrl = ipu.readRegister(Ps2Ipu::kRegCtrl); + t.IsTrue((ctrl & (1u << 15)) != 0u, "the following start code should set SCD"); + t.Equals(ctrl & (1u << 14), 0u, "a clean slice end should not set ECD"); + t.Equals(ipu.readRegister(Ps2Ipu::kRegTop), 0x000001B3u, "BSTOP should sit on the start code"); + }); + + tc.Run("IPU BDEC decodes intra blocks as pixels and honours DC reset", [](TestCase &t) + { + // Six blocks with zero DC differentials and immediate end-of-block. + std::vector> fields; + for (int i = 0; i < 4; ++i) + { + fields.push_back({0x4u, 3u}); // luma dct_dc_size 0 + fields.push_back({0x2u, 2u}); // end of block + } + for (int i = 0; i < 2; ++i) + { + fields.push_back({0x0u, 2u}); // chroma dct_dc_size 0 + fields.push_back({0x2u, 2u}); + } + fields.push_back({0x1u, 1u}); + const std::vector stream = packIpuBits(fields); + Ps2Ipu ipu; + ipu.feedDmaInput(stream.data(), stream.size()); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x2C010000u); // BDEC, intra, DC reset, quantiser_scale_code 1 + ipu.advance(1u << 20); + + std::vector out(768); + t.Equals(ipu.takeOutput(out.data(), out.size()), static_cast(768), "an intra macroblock should be output"); + t.Equals(out[0] | (out[1] << 8), 128, "a reset DC predictor reconstructs mid-grey luma"); + t.Equals(out[767 - 1] | (out[767] << 8), 128, "chroma should also be mid-grey"); + }); + tc.Run("IPU IDEC decodes an intra macroblock into RGB32 for fromIPU DMA", [](TestCase &t) { PS2Memory mem; @@ -1285,6 +1551,35 @@ void register_ps2_memory_tests() t.Equals(mem.readIORegister(Ps2Ipu::kRegCmd), 0x000001B0u, "IDEC should leave the start code unconsumed"); }); + tc.Run("native GIF image upload reads SPR-flagged ref payloads from scratchpad", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + GS gs; + gs.init(mem.getGSVRAM(), static_cast(PS2_GS_VRAM_SIZE), &mem.gs()); + + constexpr uint32_t kChain = 0x00028000u; + constexpr uint32_t kSprOffset = 0x1800u; + uint8_t *rdram = mem.getRDRAM(); + uint8_t *scratch = mem.getScratchpad(); + for (uint32_t i = 0; i < 16u; ++i) + scratch[kSprOffset + i] = static_cast(0x60u + i); + std::memset(rdram + kSprOffset, 0, 16u); // what a plain RAM read would see + + uint32_t chain = kChain; + chain = writeTextureUploadSetup(rdram, chain, 0u, GS_PSM_CT32); + chain = writeTextureImageRef(rdram, chain, 1u, 0u); + // Sofdec stages converted movie macroblocks in scratchpad and refs them with the SPR flag. + const uint64_t refTag = 1ull | (3ull << 28) | (static_cast(0x80000000u | kSprOffset) << 32); + std::memcpy(rdram + chain - 16u, &refTag, sizeof(refTag)); + writeDmaTag(rdram, chain, makeDmaTag(0u, 7u, 0u, false)); // END. + + t.IsTrue(mem.tryProcessNativeGifImageUploadChain(gs, kChain, 0x105u), "the chain should use the native upload path"); + const uint32_t dstOff = GSPSMCT32::addrPSMCT32(0u, 1u, 0u, 0u); + t.Equals(mem.getGSVRAM()[dstOff], static_cast(0x60u), "pixels should come from scratchpad, not RDRAM"); + }); + tc.Run("native GIF image upload recognizes canonical load-image chain", [](TestCase &t) { PS2Memory mem; diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index 9836dad10b..fa442c4afd 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -936,8 +936,9 @@ void register_ps2_runtime_expansion_tests() t.IsFalse(runtime.isStopRequested(), "sceIpuInit should tolerate the missing optional SetD4 helper"); - t.Equals(runtime.memory().read32(0x10002010u), 0x40000000u, - "sceIpuInit should still program IPU_CTRL"); + // The final reset strobe empties the FIFO and, being write-only, leaves IPU_CTRL reading clear. + t.Equals(runtime.memory().read32(0x10002010u), 0u, + "sceIpuInit should end by resetting the IPU"); t.Equals(runtime.memory().read32(0x10002000u), 0u, "sceIpuInit should leave IPU_CMD reset after initialization"); }); @@ -964,8 +965,8 @@ void register_ps2_runtime_expansion_tests() "the invocation should receive the SetD4 enable argument"); t.Equals(gIpuInitResult.load(std::memory_order_acquire), 0, "the HLE completion should resume the preserved base context with success"); - t.Equals(runtime.memory().read32(0x10002010u), 0x40000000u, - "IPU initialization should finish only after the guest invocation completes"); + t.Equals(runtime.memory().read32(0x10002010u), 0u, + "IPU initialization should leave the IPU reset once the guest invocation completes"); }); tc.Run("sprintf consumes EE varargs from a2 a3 t0 and preserves width formatting", [](TestCase &t) From 818ab03effb2986aa0492339c3f74416cd85abc4 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:46 -0300 Subject: [PATCH 23/91] Make generated-function call tracing opt-in via PS2X_TRACE_CALLS --- ps2xRuntime/include/ps2_log.h | 19 ++++++++++++++++++- ps2xTest/src/ps2_runtime_io_tests.cpp | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/include/ps2_log.h b/ps2xRuntime/include/ps2_log.h index a42b1ba9e1..bdc9ebd2c6 100644 --- a/ps2xRuntime/include/ps2_log.h +++ b/ps2xRuntime/include/ps2_log.h @@ -2,6 +2,7 @@ #define PS2_LOG_H #include +#include #include #include #include @@ -160,8 +161,21 @@ inline int &depth() static thread_local int d = 0; return d; } +// Tracing every generated function with a flush per line took ~90% of the EE thread, +// so the trace is opt-in: set PS2X_TRACE_CALLS=1 to write ps2_log.txt. +inline bool trace_calls_enabled() +{ + static const bool enabled = []() + { + const char *value = std::getenv("PS2X_TRACE_CALLS"); + return value != nullptr && value[0] != '\0' && value[0] != '0'; + }(); + return enabled; +} inline void log_entry(const char *name) { + if (!trace_calls_enabled()) + return; for (int i = 0; i < depth(); ++i) log_stream() << '\t'; log_stream() << ">> " << name << " enter\n"; @@ -170,6 +184,8 @@ inline void log_entry(const char *name) } inline void log_exit(const char *name) { + if (!trace_calls_enabled()) + return; depth()--; for (int i = 0; i < depth(); ++i) log_stream() << '\t'; @@ -178,7 +194,8 @@ inline void log_exit(const char *name) } inline void print_saved_location() { - std::cout << "[PS2 LOG] Logs saved at " << log_path() << std::endl; + if (trace_calls_enabled()) + std::cout << "[PS2 LOG] Logs saved at " << log_path() << std::endl; } } diff --git a/ps2xTest/src/ps2_runtime_io_tests.cpp b/ps2xTest/src/ps2_runtime_io_tests.cpp index 0c68479666..63ea0ac716 100644 --- a/ps2xTest/src/ps2_runtime_io_tests.cpp +++ b/ps2xTest/src/ps2_runtime_io_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include using namespace ps2_syscalls; @@ -168,6 +169,21 @@ void register_ps2_runtime_io_tests() { MiniTest::Case("PS2RuntimeIO", [](TestCase &tc) { +#if AGRESSIVE_LOGS + tc.Run("Generated-function call tracing is opt-in", [](TestCase &t) + { + if (std::getenv("PS2X_TRACE_CALLS") != nullptr) + return; + t.IsFalse(ps2_log::trace_calls_enabled(), "call tracing must stay off without PS2X_TRACE_CALLS"); + const int before = ps2_log::depth(); + { + PS_LOG_ENTRY("trace_probe"); + t.Equals(ps2_log::depth(), before, "a disabled trace must not track call depth"); + } + t.Equals(ps2_log::depth(), before, "a disabled trace must not unwind call depth"); + }); +#endif + tc.Run("ROM0 ROMVER is exposed as the 14-byte firmware pseudo-file", [](TestCase &t) { TestContext test; From 0140d1b943be498a1e14b6428468657fb8764695 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:57 -0300 Subject: [PATCH 24/91] Cache decoded IOP import stubs instead of rescanning for the table on every call --- ps2xIOP/src/emulator/imports/iop_imports.cpp | 35 +++++++++++++++++++- ps2xIOP/src/emulator/imports/iop_imports.h | 13 ++++++++ ps2xIOP/tests/iop_import_version_tests.cpp | 26 +++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/ps2xIOP/src/emulator/imports/iop_imports.cpp b/ps2xIOP/src/emulator/imports/iop_imports.cpp index f13267fe10..3a27d33fb3 100644 --- a/ps2xIOP/src/emulator/imports/iop_imports.cpp +++ b/ps2xIOP/src/emulator/imports/iop_imports.cpp @@ -43,6 +43,18 @@ namespace ps2x::iop::detail void IopImportRegistry::reset() { m_libraries.clear(); + m_decodedStubs.clear(); + } + + void IopImportRegistry::forgetDecodedStubs() noexcept + { + m_decodedStubs.clear(); + } + + uint64_t IopImportRegistry::readLibraryName(uint32_t table) const + { + return static_cast(m_memory.read32(table + 12u)) | + (static_cast(m_memory.read32(table + 16u)) << 32u); } std::optional IopImportRegistry::decode(uint32_t pc) const @@ -54,6 +66,23 @@ namespace ps2x::iop::detail return std::nullopt; const uint32_t physicalPc = IopMemory::physicalAddress(pc); + if (const auto cached = m_decodedStubs.find(physicalPc); cached != m_decodedStubs.end()) + { + // A module unloaded or overwritten in place no longer carries the same table header. + const DecodedStub &stub = cached->second; + if (stub.call.ordinal == static_cast(delay & 0xFFFFu) && + m_memory.read32(stub.table) == kImportMagic && + m_memory.read16(stub.table + 8u) == stub.call.version && + readLibraryName(stub.table) == stub.name) + return stub.call; + m_decodedStubs.erase(cached); + } + + return scanImportTable(physicalPc, delay); + } + + std::optional IopImportRegistry::scanImportTable(uint32_t physicalPc, uint32_t delay) const + { const uint32_t searchBegin = physicalPc > 0x10000u ? physicalPc - 0x10000u : 0u; for (uint32_t candidate = physicalPc & ~3u; candidate >= searchBegin + 20u; candidate -= 4u) { @@ -89,11 +118,14 @@ namespace ps2x::iop::detail } if (valid) { - return IopImportCall{ + IopImportCall call{ trimLibraryName(name), static_cast(delay & 0xFFFFu), m_memory.read16(table + 8u), }; + if (physicalPc + 8u <= IopMemory::RamSize) + m_decodedStubs[physicalPc] = DecodedStub{table, readLibraryName(table), call}; + return call; } } return std::nullopt; @@ -184,6 +216,7 @@ namespace ps2x::iop::detail void IopImportRegistry::eraseRange(uint32_t base, uint32_t size) { + m_decodedStubs.clear(); for (auto library = m_libraries.begin(); library != m_libraries.end();) { if (library->first >= base && library->first < base + size) diff --git a/ps2xIOP/src/emulator/imports/iop_imports.h b/ps2xIOP/src/emulator/imports/iop_imports.h index cae8d91a0e..0c8346cb8a 100644 --- a/ps2xIOP/src/emulator/imports/iop_imports.h +++ b/ps2xIOP/src/emulator/imports/iop_imports.h @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace ps2x::iop::detail @@ -32,6 +33,7 @@ namespace ps2x::iop::detail [[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); + void forgetDecodedStubs() noexcept; private: struct ExportLibrary @@ -42,9 +44,20 @@ namespace ps2x::iop::detail std::vector functions; }; + struct DecodedStub + { + uint32_t table = 0; + uint64_t name = 0; + IopImportCall call; + }; + [[nodiscard]] const ExportLibrary *findLibrary(std::string_view name, std::optional version) const; + [[nodiscard]] std::optional scanImportTable(uint32_t physicalPc, uint32_t delay) const; + [[nodiscard]] uint64_t readLibraryName(uint32_t table) const; IopMemory &m_memory; std::map m_libraries; + // Import stubs are hit on every HLE call; re-scanning backwards for the table header each time dominated the IOP. + mutable std::unordered_map m_decodedStubs; }; } diff --git a/ps2xIOP/tests/iop_import_version_tests.cpp b/ps2xIOP/tests/iop_import_version_tests.cpp index af3346b265..29e4003b04 100644 --- a/ps2xIOP/tests/iop_import_version_tests.cpp +++ b/ps2xIOP/tests/iop_import_version_tests.cpp @@ -46,6 +46,31 @@ namespace require(alias && alias->version == 0x0310u, "cached alias lost import version"); } + void decodeFollowsRewrittenTable() + { + IopMemory memory; + IopImportRegistry imports(memory); + importTable(memory, 0x1000u, 0x0310u); + require(imports.decode(0x1014u) && imports.decode(0x1014u)->version == 0x0310u, "first decode failed"); + + // A module loaded over the old image without an unload in between must not reuse stale stub data. + memory.write16(0x1008u, 0x0420u); + const auto bumped = imports.decode(0x1014u); + require(bumped && bumped->version == 0x0420u, "decoder kept a stale library version"); + + constexpr char other[8] = "othlib"; + require(memory.writeRam(0x100Cu, other, sizeof(other)), "name rewrite failed"); + const auto renamed = imports.decode(0x1014u); + require(renamed && renamed->library == "othlib", "decoder kept a stale library name"); + + memory.write32(0x1018u, 0x24000005u); + const auto reordered = imports.decode(0x1014u); + require(reordered && reordered->ordinal == 5u, "decoder kept a stale ordinal"); + + memory.write32(0x1000u, 0u); + require(!imports.decode(0x1014u), "decoder still resolved a stub whose table header is gone"); + } + void majorIsolation() { IopMemory memory; @@ -160,6 +185,7 @@ int main() { const Test tests[] = { {"Import decoder preserves library ABI version", decodeVersion}, + {"Import decoder notices a rewritten import table", decodeFollowsRewrittenTable}, {"Different major versions cannot cross-link", majorIsolation}, {"Newest registered minor wins within the requested major", newestMinor}, {"Ordinal lookup stays in the selected table", missingOrdinal}, From 52149f54fb29c5f26dcf2bcb082729b4872a7027 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:57 -0300 Subject: [PATCH 25/91] Trim per-instruction and per-slice IOP interpreter overhead --- ps2xIOP/src/emulator/core/iop_kernel.cpp | 14 +++-- ps2xIOP/src/emulator/core/iop_kernel.h | 2 + ps2xIOP/src/emulator/core/iop_memory.cpp | 12 ++-- ps2xIOP/src/emulator/core/iop_memory.h | 77 ++++++++++++++++++++++-- ps2xIOP/src/emulator/iop_emulator.cpp | 36 ++++++++--- 5 files changed, 114 insertions(+), 27 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_kernel.cpp b/ps2xIOP/src/emulator/core/iop_kernel.cpp index 4832d8f5ba..d7d0887fcb 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.cpp +++ b/ps2xIOP/src/emulator/core/iop_kernel.cpp @@ -32,6 +32,7 @@ namespace ps2x::iop::detail m_nextSemaphoreId = 1; m_nextEventFlagId = 1; m_currentThread = nullptr; + m_maybeDeadThreads = false; } bool IopKernel::dispatchThreadImport(uint16_t ordinal, IopCpuState &cpu, uint64_t currentCycle) @@ -107,6 +108,7 @@ namespace ps2x::iop::detail if (m_currentThread != nullptr) { m_currentThread->state = ordinal == 9 ? IopThreadState::Dead : IopThreadState::Dormant; + m_maybeDeadThreads = m_maybeDeadThreads || ordinal == 9; cpu.stopped = true; cpu.yielded = true; } @@ -671,15 +673,11 @@ namespace ps2x::iop::detail IopThread *IopKernel::beginNextReady(uint64_t currentCycle) { + IopThread *next = nullptr; for (auto &[id, thread] : m_threads) { if (thread.state == IopThreadState::Delay && thread.wakeCycle <= currentCycle) thread.state = IopThreadState::Ready; - } - - IopThread *next = nullptr; - for (auto &[id, thread] : m_threads) - { if (thread.state != IopThreadState::Ready) continue; if (next == nullptr || thread.priority < next->priority || @@ -719,6 +717,9 @@ namespace ps2x::iop::detail void IopKernel::cleanupDeadThreads() { + if (!m_maybeDeadThreads) + return; + m_maybeDeadThreads = false; for (auto thread = m_threads.begin(); thread != m_threads.end();) { if (thread->second.state != IopThreadState::Dead) @@ -738,7 +739,10 @@ namespace ps2x::iop::detail { const uint32_t pc = IopMemory::physicalAddress(thread.cpu.pc); if (pc >= base && pc < base + size) + { thread.state = IopThreadState::Dead; + m_maybeDeadThreads = true; + } } } } diff --git a/ps2xIOP/src/emulator/core/iop_kernel.h b/ps2xIOP/src/emulator/core/iop_kernel.h index 4977102447..ea0699f0d8 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.h +++ b/ps2xIOP/src/emulator/core/iop_kernel.h @@ -93,6 +93,8 @@ namespace ps2x::iop::detail IopMemory &m_memory; std::map m_threads; + // Set whenever a thread may have become Dead; endTimeslice runs every slice and must not walk all threads. + bool m_maybeDeadThreads = false; std::map m_semaphores; std::map m_eventFlags; uint32_t m_nextThreadId = 1; diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index d90a6fa19f..71dfeb924b 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -49,7 +49,7 @@ namespace ps2x::iop::detail return address & 0x1FFFFFFFu; } - uint8_t IopMemory::read8(uint32_t address) const + uint8_t IopMemory::read8Slow(uint32_t address) const { const uint32_t phys = physicalAddress(address); if (phys < RamSize) @@ -62,7 +62,7 @@ namespace ps2x::iop::detail return static_cast(value >> ((phys & 3u) * 8u)); } - uint16_t IopMemory::read16(uint32_t address) const + uint16_t IopMemory::read16Slow(uint32_t address) const { const uint32_t phys = physicalAddress(address); if (phys + 1u < RamSize) @@ -74,7 +74,7 @@ namespace ps2x::iop::detail return static_cast(read8(address) | (static_cast(read8(address + 1u)) << 8u)); } - uint32_t IopMemory::read32(uint32_t address) const + uint32_t IopMemory::read32Slow(uint32_t address) const { const uint32_t phys = physicalAddress(address); if ((phys & 3u) == 0u && phys + 3u < RamSize) @@ -98,7 +98,7 @@ namespace ps2x::iop::detail (static_cast(read8(address + 3u)) << 24u); } - void IopMemory::write8(uint32_t address, uint8_t value) + void IopMemory::write8Slow(uint32_t address, uint8_t value) { const uint32_t phys = physicalAddress(address); if (phys < RamSize) @@ -124,7 +124,7 @@ namespace ps2x::iop::detail writeHardware32(aligned, current); } - void IopMemory::write16(uint32_t address, uint16_t value) + void IopMemory::write16Slow(uint32_t address, uint16_t value) { const uint32_t phys = physicalAddress(address); if (phys + 1u < RamSize) @@ -137,7 +137,7 @@ namespace ps2x::iop::detail write8(address + 1u, static_cast(value >> 8u)); } - void IopMemory::write32(uint32_t address, uint32_t value) + void IopMemory::write32Slow(uint32_t address, uint32_t value) { const uint32_t phys = physicalAddress(address); if ((phys & 3u) == 0u && phys + 3u < RamSize) diff --git a/ps2xIOP/src/emulator/core/iop_memory.h b/ps2xIOP/src/emulator/core/iop_memory.h index 8e52502c8f..ae36d40a1d 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.h +++ b/ps2xIOP/src/emulator/core/iop_memory.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,7 @@ namespace ps2x::iop::detail { public: static constexpr uint32_t RamSize = 2u * 1024u * 1024u; + static constexpr uint32_t PhysicalMask = 0x1FFFFFFFu; static constexpr uint32_t ScratchBase = 0x1F800000u; static constexpr uint32_t ScratchSize = 0x400u; static constexpr uint32_t HardwareBase = 0x1F801000u; @@ -48,12 +50,68 @@ namespace ps2x::iop::detail void reset(); - [[nodiscard]] uint8_t read8(uint32_t address) const; - [[nodiscard]] uint16_t read16(uint32_t address) const; - [[nodiscard]] uint32_t read32(uint32_t address) const; - void write8(uint32_t address, uint8_t value); - void write16(uint32_t address, uint16_t value); - void write32(uint32_t address, uint32_t value); + // RAM accesses are inlined: the interpreter makes several per instruction and the call overhead dominated. + [[nodiscard]] uint8_t read8(uint32_t address) const + { + const uint32_t phys = address & PhysicalMask; + return phys < RamSize ? m_ram[phys] : read8Slow(address); + } + [[nodiscard]] uint16_t read16(uint32_t address) const + { + const uint32_t phys = address & PhysicalMask; + if (phys + 1u < RamSize) + { + uint16_t value; + std::memcpy(&value, m_ram.data() + phys, sizeof(value)); + return value; + } + return read16Slow(address); + } + [[nodiscard]] uint32_t read32(uint32_t address) const + { + const uint32_t phys = address & PhysicalMask; + if ((phys & 3u) == 0u && phys + 3u < RamSize) + { + uint32_t value; + std::memcpy(&value, m_ram.data() + phys, sizeof(value)); + return value; + } + return read32Slow(address); + } + void write8(uint32_t address, uint8_t value) + { + const uint32_t phys = address & PhysicalMask; + if (phys < RamSize) + { + m_ram[phys] = value; + m_owned[phys] = 1u; + return; + } + write8Slow(address, value); + } + void write16(uint32_t address, uint16_t value) + { + const uint32_t phys = address & PhysicalMask; + if (phys + 1u < RamSize) + { + std::memcpy(m_ram.data() + phys, &value, sizeof(value)); + m_owned[phys] = 1u; + m_owned[phys + 1u] = 1u; + return; + } + write16Slow(address, value); + } + void write32(uint32_t address, uint32_t value) + { + const uint32_t phys = address & PhysicalMask; + if ((phys & 3u) == 0u && phys + 3u < RamSize) + { + std::memcpy(m_ram.data() + phys, &value, sizeof(value)); + std::memset(m_owned.data() + phys, 1, sizeof(value)); + return; + } + write32Slow(address, value); + } [[nodiscard]] bool readRam(uint32_t address, void *destination, size_t size) const; [[nodiscard]] bool writeRam(uint32_t address, const void *source, size_t size); @@ -75,6 +133,7 @@ namespace ps2x::iop::detail void setInterruptControl(uint32_t value) noexcept { m_interruptControl = value & 1u; } [[nodiscard]] std::optional takeDmaStart() noexcept; + [[nodiscard]] bool hasDmaStart() const noexcept { return !m_dmaStarts.empty(); } // Attaches a raw memory card image to a SIO2 port; a missing file is created blank on first access. bool attachMemoryCard(size_t port, const std::string &path); [[nodiscard]] std::span ram() const noexcept { return m_ram; } @@ -82,6 +141,12 @@ namespace ps2x::iop::detail [[nodiscard]] static uint32_t physicalAddress(uint32_t address) noexcept; private: + [[nodiscard]] uint8_t read8Slow(uint32_t address) const; + [[nodiscard]] uint16_t read16Slow(uint32_t address) const; + [[nodiscard]] uint32_t read32Slow(uint32_t address) const; + void write8Slow(uint32_t address, uint8_t value); + void write16Slow(uint32_t address, uint16_t value); + void write32Slow(uint32_t address, uint32_t value); [[nodiscard]] uint32_t readHardware32(uint32_t address) const; void writeHardware32(uint32_t address, uint32_t value); void markOwned(uint32_t address, size_t size); diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index c61ad0e4d1..a5cfde19f6 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -127,6 +127,7 @@ namespace ps2x::iop::detail timrman.reset(); ioman.reset(); pendingDmaInterrupts.clear(); + earliestDmaInterrupt = UINT64_MAX; pendingGuestCallbacks.clear(); nextModuleId = 1; totalCycles = 0; @@ -186,8 +187,13 @@ namespace ps2x::iop::detail void schedulePendingDma() { + if (!memory.hasDmaStart()) + return; while (const auto dma = memory.takeDmaStart()) + { pendingDmaInterrupts[dma->irq] = totalCycles + dma->delayCycles; + earliestDmaInterrupt = std::min(earliestDmaInterrupt, totalCycles + dma->delayCycles); + } } bool readRam(uint32_t address, void *destination, size_t size) const @@ -388,16 +394,21 @@ namespace ps2x::iop::detail if (checkInterrupt(cpu)) return true; - if (const auto import = imports.decode(cpu.pc)) + // Import stubs are 'jr ra' followed by the ordinal; skip the registry for every other instruction. + constexpr uint32_t kJrRa = 0x03E00008u; + if (memory.read32(cpu.pc) == kJrRa) { - const ImportDisposition disposition = dispatchImport(*import, cpu); - ++totalInstructions; - ++totalCycles; - if (disposition == ImportDisposition::JumpToGuest) - return true; - cpu.pc = cpu.gpr[31]; - cpu.branchPending = false; - return !cpu.stopped; + if (const auto import = imports.decode(cpu.pc)) + { + const ImportDisposition disposition = dispatchImport(*import, cpu); + ++totalInstructions; + ++totalCycles; + if (disposition == ImportDisposition::JumpToGuest) + return true; + cpu.pc = cpu.gpr[31]; + cpu.branchPending = false; + return !cpu.stopped; + } } const bool running = cpuCore.executeInstruction(cpu); @@ -416,7 +427,7 @@ namespace ps2x::iop::detail { if (!step(cpu)) break; - if (!servicingDmaInterrupts && !pendingDmaInterrupts.empty()) + if (!servicingDmaInterrupts && totalCycles >= earliestDmaInterrupt) servicePendingDmaInterrupts(); if (!servicingGuestCallbacks && !pendingGuestCallbacks.empty()) servicePendingGuestCallbacks(); @@ -499,10 +510,12 @@ namespace ps2x::iop::detail servicingDmaInterrupts = true; std::vector completed; + earliestDmaInterrupt = UINT64_MAX; for (auto it = pendingDmaInterrupts.begin(); it != pendingDmaInterrupts.end();) { if (it->second > totalCycles) { + earliestDmaInterrupt = std::min(earliestDmaInterrupt, it->second); ++it; continue; } @@ -633,6 +646,7 @@ namespace ps2x::iop::detail } if (!loaded.relocationsComplete) log(LogLevel::Warning, "[IOP] one or more IRX relocations were unsupported"); + imports.forgetDecodedStubs(); Module module; module.id = nextModuleId++; @@ -739,6 +753,8 @@ namespace ps2x::iop::detail IopLoadcore loadcore; std::map modules; std::map pendingDmaInterrupts; + // Never later than the earliest entry above, so the per-instruction check can skip the map walk. + uint64_t earliestDmaInterrupt = UINT64_MAX; std::multimap pendingGuestCallbacks; uint32_t nextModuleId = 1; uint64_t totalCycles = 0; From 9358125545d57a5b1aefcda6b49b3387512ead95 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:57 -0300 Subject: [PATCH 26/91] Run the IOP in 2048-cycle batches from guest checkpoints --- ps2xRuntime/include/runtime/ee_scheduler.h | 8 +++++++ ps2xRuntime/src/lib/Kernel/EeScheduler.cpp | 26 ++++++++++++++++++++-- ps2xTest/src/ps2_sif_rpc_tests.cpp | 24 ++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/include/runtime/ee_scheduler.h b/ps2xRuntime/include/runtime/ee_scheduler.h index f247ff998d..cfacd16dc4 100644 --- a/ps2xRuntime/include/runtime/ee_scheduler.h +++ b/ps2xRuntime/include/runtime/ee_scheduler.h @@ -267,6 +267,10 @@ class EeScheduler static constexpr uint32_t kGeneratedCheckpointCycles = 32u; static constexpr uint32_t kGuestDispatchCycles = 8u; static constexpr uint64_t kDefaultTimeSliceCycles = 65536ull; + // Guest checkpoints arrive every few dozen cycles; running the IOP that often spent more time + // in its scheduler than in IRX code. One IOP time slice (256 IOP cycles) per batch keeps the + // processors within a few microseconds of each other. + static constexpr uint64_t kIopBatchEeCycles = 2048ull; explicit EeScheduler(PS2Runtime &runtime); ~EeScheduler(); @@ -279,6 +283,7 @@ class EeScheduler void requestStop(); void postEvent(EeEvent event); [[nodiscard]] bool checkpointDue(uint32_t cycles = kGeneratedCheckpointCycles) noexcept; + // Advances EE time and runs the IOP up to it immediately. void accountCycles(uint32_t cycles) noexcept; [[nodiscard]] bool isExecutingGuest() const noexcept; @@ -370,6 +375,8 @@ class EeScheduler }; void assertExecutor() const; + void advanceGuestCycles(uint32_t cycles) noexcept; + void flushIopCycles() noexcept; [[nodiscard]] int allocateThreadId(); GuestThread &acquireInvocationThread(); void enqueueReady(GuestThread &thread, bool front = false); @@ -426,6 +433,7 @@ class EeScheduler bool m_insideInterrupt = false; uint32_t m_pendingEeTimerInterrupts = 0; uint64_t m_eeCycle = 0; + uint64_t m_iopPendingEeCycles = 0; uint64_t m_sliceEndCycle = kDefaultTimeSliceCycles; std::thread::id m_executorThread{}; std::atomic m_running{false}; diff --git a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp index 748b41fe87..7269603e92 100644 --- a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp +++ b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp @@ -113,6 +113,7 @@ void EeScheduler::reset(uint8_t *rdram, const R5900Context &mainContext) m_insideInterrupt = false; m_pendingEeTimerInterrupts = 0u; m_eeCycle = 0u; + m_iopPendingEeCycles = 0u; m_sliceEndCycle = kDefaultTimeSliceCycles; m_stopRequested.store(false, std::memory_order_release); m_checkpointPending.store(false, std::memory_order_release); @@ -362,7 +363,11 @@ void EeScheduler::postEvent(EeEvent event) bool EeScheduler::checkpointDue(uint32_t cycles) noexcept { - accountCycles(cycles); + advanceGuestCycles(cycles); + if (m_iopPendingEeCycles >= kIopBatchEeCycles) + { + flushIopCycles(); + } if (m_checkpointPending.load(std::memory_order_acquire) || m_stopRequested.load(std::memory_order_acquire)) @@ -395,17 +400,34 @@ bool EeScheduler::checkpointDue(uint32_t cycles) noexcept } void EeScheduler::accountCycles(uint32_t cycles) noexcept +{ + advanceGuestCycles(cycles); + flushIopCycles(); +} + +void EeScheduler::advanceGuestCycles(uint32_t cycles) noexcept { const uint64_t elapsed = std::max(1u, cycles); m_eeCycle += elapsed; m_pendingEeTimerInterrupts |= m_runtime.memory().advanceEeTimers(elapsed); - m_runtime.advanceIopEeCycles(elapsed); + m_iopPendingEeCycles += elapsed; if (m_pendingEeTimerInterrupts != 0u) { m_checkpointPending.store(true, std::memory_order_release); } } +void EeScheduler::flushIopCycles() noexcept +{ + if (m_iopPendingEeCycles == 0u) + { + return; + } + const uint64_t pending = m_iopPendingEeCycles; + m_iopPendingEeCycles = 0u; + m_runtime.advanceIopEeCycles(pending); +} + bool EeScheduler::isExecutingGuest() const noexcept { return m_guestExecuting.load(std::memory_order_acquire); diff --git a/ps2xTest/src/ps2_sif_rpc_tests.cpp b/ps2xTest/src/ps2_sif_rpc_tests.cpp index 7242e65952..06208f98f2 100644 --- a/ps2xTest/src/ps2_sif_rpc_tests.cpp +++ b/ps2xTest/src/ps2_sif_rpc_tests.cpp @@ -203,6 +203,30 @@ void register_ps2_sif_rpc_tests() "SifInitRpc must not reboot or reset the IOP"); }); + tc.Run("Guest checkpoints run the IOP in bounded batches", [](TestCase &t) + { + TestEnv env; + EeScheduler &scheduler = env.runtime.eeScheduler(); + const uint64_t start = env.runtime.iopDebugSnapshot().emulatorCycles; + + constexpr uint32_t kStep = EeScheduler::kGeneratedCheckpointCycles; + constexpr uint64_t kStepsPerBatch = EeScheduler::kIopBatchEeCycles / kStep; + for (uint64_t i = 0; i + 1u < kStepsPerBatch; ++i) + (void)scheduler.checkpointDue(kStep); + t.Equals(env.runtime.iopDebugSnapshot().emulatorCycles, start, + "IOP should not run for every few dozen guest cycles"); + + (void)scheduler.checkpointDue(kStep); + t.Equals(env.runtime.iopDebugSnapshot().emulatorCycles, start + EeScheduler::kIopBatchEeCycles / 8u, + "IOP must catch up to the EE once a batch has accumulated"); + + (void)scheduler.checkpointDue(kStep); + scheduler.accountCycles(8u); + t.Equals(env.runtime.iopDebugSnapshot().emulatorCycles, + start + (EeScheduler::kIopBatchEeCycles + kStep + 8u) / 8u, + "accountCycles must run the IOP up to the current EE time"); + }); + tc.Run("SifLoadModule validates ROM modules and activates their HLE service", [](TestCase &t) { TestEnv env; From 49514cb453dea6e5a0463378d2894c87333a742e Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:57 -0300 Subject: [PATCH 27/91] Speed up IPU CSC input, macroblock output, sparse IDCT rows and toIPU pulls --- ps2xRuntime/src/lib/ps2_ipu.cpp | 107 +++++++++++++----------- ps2xRuntime/src/lib/ps2_memory.cpp | 31 +++++-- ps2xTest/src/ps2_memory_tests.cpp | 126 +++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 52 deletions(-) diff --git a/ps2xRuntime/src/lib/ps2_ipu.cpp b/ps2xRuntime/src/lib/ps2_ipu.cpp index a34254dbbc..238edd3c2f 100644 --- a/ps2xRuntime/src/lib/ps2_ipu.cpp +++ b/ps2xRuntime/src/lib/ps2_ipu.cpp @@ -616,16 +616,22 @@ void Ps2Ipu::commandCsc() const bool dither = (m_cmd & (1u << 26)) != 0u; for (uint32_t mb = 0; mb < count; ++mb) { - uint8_t luma[256]; - uint8_t cb[64]; - uint8_t cr[64]; - for (uint8_t &value : luma) - value = static_cast(getBits(8)); - for (uint8_t &value : cb) - value = static_cast(getBits(8)); - for (uint8_t &value : cr) - value = static_cast(getBits(8)); - emitMacroblock(luma, cb, cr, rgb16, dither); + // RAW8 input is byte-aligned: copy what the FIFO already holds instead of 384 getBits calls. + uint8_t raw[384]; + size_t filled = 0; + while (filled < sizeof(raw)) + { + if ((m_bitPos & 7u) == 0u && m_bitPos / 8u < m_fifo.size()) + { + const size_t available = std::min(sizeof(raw) - filled, m_fifo.size() - m_bitPos / 8u); + std::memcpy(raw + filled, m_fifo.data() + m_bitPos / 8u, available); + filled += available; + m_bitPos += static_cast(available * 8u); + continue; + } + raw[filled++] = static_cast(getBits(8)); + } + emitMacroblock(raw, raw + 256, raw + 320, rgb16, dither); } } @@ -730,14 +736,11 @@ void Ps2Ipu::commandBdec() } inverseDctSigned(block, destination, stride, intra); } + // Halfwords go out little-endian, which is the host layout on every supported target. for (const int16_t *plane : {static_cast(luma), static_cast(cb), static_cast(cr)}) { - const size_t count = plane == luma ? 256u : 64u; - for (size_t i = 0; i < count; ++i) - { - m_output.push_back(static_cast(static_cast(plane[i]) & 0xFFu)); - m_output.push_back(static_cast(static_cast(plane[i]) >> 8u)); - } + const auto *bytes = reinterpret_cast(plane); + m_output.insert(m_output.end(), bytes, bytes + (plane == luma ? sizeof(luma) : sizeof(cb))); } // Zero bits after a macroblock mean the slice ended: stop on the start code (SCD) or flag garbage (ECD). @@ -1020,6 +1023,28 @@ bool Ps2Ipu::decodeIntraBlock(int component, int16_t *block) } } +static int inverseDctRows(const int16_t *coefficients, double (&rows)[8][8], int (&live)[8]) +{ + // Most coded rows are all zero and contribute exactly nothing, so only the others are transformed and summed. + const auto &c = tables().cosine; + int count = 0; + for (int y = 0; y < 8; ++y) + { + const int16_t *row = coefficients + y * 8; + if ((row[0] | row[1] | row[2] | row[3] | row[4] | row[5] | row[6] | row[7]) == 0) + continue; + live[count++] = y; + for (int x = 0; x < 8; ++x) + { + double value = 0.0; + for (int u = 0; u < 8; ++u) + value += c[x][u] * row[u]; + rows[y][x] = value; + } + } + return count; +} + void Ps2Ipu::inverseDctSigned(const int16_t *coefficients, int16_t *destination, int stride, bool intra) const { // Intra blocks are pixels (0..255); residuals saturate to the 9-bit range motion compensation adds. @@ -1032,47 +1057,34 @@ void Ps2Ipu::inverseDctSigned(const int16_t *coefficients, int16_t *destination, destination[y * stride + x] = pixels[y * 8 + x]; return; } - const auto &c = tables().cosine; double rows[8][8]; - for (int y = 0; y < 8; ++y) - for (int x = 0; x < 8; ++x) - { - double value = 0.0; - for (int u = 0; u < 8; ++u) - value += c[x][u] * coefficients[y * 8 + u]; - rows[y][x] = value; - } + int live[8]; + const int liveRows = inverseDctRows(coefficients, rows, live); + const auto &c = tables().cosine; for (int x = 0; x < 8; ++x) for (int y = 0; y < 8; ++y) { double value = 0.0; - for (int v = 0; v < 8; ++v) - value += c[y][v] * rows[v][x]; + for (int i = 0; i < liveRows; ++i) + value += c[y][live[i]] * rows[live[i]][x]; destination[y * stride + x] = static_cast(std::clamp(static_cast(std::floor(value / 4.0 + 0.5)), -256, 255)); } } + void Ps2Ipu::inverseDct(const int16_t *coefficients, uint8_t *destination, int stride) const { - const auto &c = tables().cosine; double rows[8][8]; - for (int y = 0; y < 8; ++y) - { - for (int x = 0; x < 8; ++x) - { - double value = 0.0; - for (int u = 0; u < 8; ++u) - value += c[x][u] * coefficients[y * 8 + u]; - rows[y][x] = value; - } - } + int live[8]; + const int liveRows = inverseDctRows(coefficients, rows, live); + const auto &c = tables().cosine; for (int x = 0; x < 8; ++x) { for (int y = 0; y < 8; ++y) { double value = 0.0; - for (int v = 0; v < 8; ++v) - value += c[y][v] * rows[v][x]; + for (int i = 0; i < liveRows; ++i) + value += c[y][live[i]] * rows[live[i]][x]; destination[y * stride + x] = clampByte(static_cast(std::floor(value / 4.0 + 0.5))); } } @@ -1081,6 +1093,8 @@ void Ps2Ipu::inverseDct(const int16_t *coefficients, uint8_t *destination, int s void Ps2Ipu::emitMacroblock(const uint8_t *luma, const uint8_t *cb, const uint8_t *cr, bool rgb16, bool dither) { static constexpr int kDither[4][4] = {{-4, 0, -3, 1}, {2, -2, 3, -1}, {-3, 1, -4, 0}, {3, -1, 2, -2}}; + uint8_t pixels[1024]; + uint8_t *out = pixels; for (int y = 0; y < 16; ++y) { for (int x = 0; x < 16; ++x) @@ -1104,10 +1118,10 @@ void Ps2Ipu::emitMacroblock(const uint8_t *luma, const uint8_t *cb, const uint8_ if (!rgb16) { - m_output.push_back(static_cast(r)); - m_output.push_back(static_cast(g)); - m_output.push_back(static_cast(b)); - m_output.push_back(alpha); + *out++ = static_cast(r); + *out++ = static_cast(g); + *out++ = static_cast(b); + *out++ = alpha; continue; } if (dither) @@ -1118,8 +1132,9 @@ void Ps2Ipu::emitMacroblock(const uint8_t *luma, const uint8_t *cb, const uint8_ b = std::clamp(b + offset, 0, 255); } const uint16_t packed = static_cast((r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10) | (alpha == 0x40 ? 0x8000 : 0)); - m_output.push_back(static_cast(packed & 0xFFu)); - m_output.push_back(static_cast(packed >> 8u)); + *out++ = static_cast(packed & 0xFFu); + *out++ = static_cast(packed >> 8u); } } + m_output.insert(m_output.end(), pixels, out); } diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index 41d724dbc2..95f0c19593 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -1853,6 +1853,25 @@ size_t PS2Memory::pullToIpu(uint8_t *destination, size_t maxQuadwords) { static constexpr uint32_t kToIpu = 0x1000B400u; size_t moved = 0; + // Same bounds rules as appendDmaSource, without a heap buffer per quadword. + auto source = [this](uint32_t address) -> const uint8_t * + { + const uint32_t eeAddress = ps2DmacAddressToEe(address); + const bool scratch = isScratchpad(eeAddress); + uint32_t offset = 0u; + try + { + offset = translateAddress(eeAddress); + } + catch (const std::exception &) + { + return nullptr; + } + const uint32_t limit = scratch ? PS2_SCRATCHPAD_SIZE : PS2_RAM_SIZE; + if (offset > limit || 16u > limit - offset) + return nullptr; + return (scratch ? m_scratchpad : m_rdram) + offset; + }; for (int guard = 0; moved < maxQuadwords && guard < 4096; ++guard) { uint32_t &chcr = m_ioRegisters[kToIpu]; @@ -1870,14 +1889,14 @@ size_t PS2Memory::pullToIpu(uint8_t *destination, size_t maxQuadwords) completeDmacChannel(kToIpu, 4u); break; } - std::vector tagBytes; - if (!appendDmaSource(tadr, 16u, tagBytes)) + const uint8_t *tagBytes = source(tadr); + if (!tagBytes) { completeDmacChannel(kToIpu, 4u); break; } uint64_t tag = 0; - std::memcpy(&tag, tagBytes.data(), sizeof(tag)); + std::memcpy(&tag, tagBytes, sizeof(tag)); const uint32_t tagQwc = static_cast(tag & 0xFFFFu); const uint32_t id = static_cast((tag >> 28u) & 0x7u); const uint32_t addr = static_cast(tag >> 32u); @@ -1928,13 +1947,13 @@ size_t PS2Memory::pullToIpu(uint8_t *destination, size_t maxQuadwords) continue; } - std::vector quadword; - if (!appendDmaSource(madr, 16u, quadword)) + const uint8_t *quadword = source(madr); + if (!quadword) { completeDmacChannel(kToIpu, 4u); break; } - std::memcpy(destination + moved * 16u, quadword.data(), 16u); + std::memcpy(destination + moved * 16u, quadword, 16u); ++moved; madr += 16u; qwc = (qwc & 0xFFFF0000u) | ((qwc & 0xFFFFu) - 1u); diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index c7aa58e8bc..024e066ad6 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace @@ -1442,6 +1443,131 @@ void register_ps2_memory_tests() t.Equals(ipu.readRegister(Ps2Ipu::kRegTop), 0x000001B3u, "BSTOP should sit on the start code"); }); + tc.Run("IPU BDEC inverse DCT matches the reference transform for sparse AC blocks", [](TestCase &t) + { + static constexpr uint8_t kZigzag[64] = { + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63}; + // Coefficients spread over several rows and columns, including all-zero rows in between. + const std::vector> coefficients = {{0, 3}, {2, -2}, {9, 5}, {20, -4}, {35, 1}, {44, 7}}; + + // coded_block_pattern '1101' codes luma block 3 only; each coefficient uses the MPEG-2 escape code. + std::vector> fields = {{0xDu, 4u}}; + int previous = -1; + for (const auto &[index, level] : coefficients) + { + fields.push_back({0x01u, 6u}); + fields.push_back({static_cast(index - previous - 1), 6u}); + fields.push_back({static_cast(level) & 0xFFFu, 12u}); + previous = index; + } + fields.push_back({0x2u, 2u}); + fields.push_back({0x0u, 8u}); + fields.push_back({0x000001B3u, 32u}); + const std::vector stream = packIpuBits(fields); + + Ps2Ipu ipu; + ipu.feedDmaInput(stream.data(), stream.size()); + ipu.writeRegister(Ps2Ipu::kRegCmd, 0x20080000u); // BDEC, non-intra, quantiser_scale_code 8 + ipu.advance(1u << 20); + std::vector out(768); + t.Equals(ipu.takeOutput(out.data(), out.size()), static_cast(768), "BDEC should output one macroblock"); + + // Reference: non-intra dequantisation with the flat default matrix, mismatch control, separable IDCT. + int32_t block[64] = {}; + int32_t sum = 0; + for (const auto &[index, level] : coefficients) + { + const int32_t magnitude = ((2 * std::abs(level) + 1) * 16 * 16) >> 5; + block[kZigzag[index]] = level < 0 ? -magnitude : magnitude; + sum += block[kZigzag[index]]; + } + if ((sum & 1) == 0) + block[63] ^= 1; + double cosine[8][8]; + for (int x = 0; x < 8; ++x) + for (int u = 0; u < 8; ++u) + cosine[x][u] = (u == 0 ? std::sqrt(0.5) : 1.0) * std::cos((2.0 * x + 1.0) * u * 3.14159265358979323846 / 16.0); + bool matches = true; + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + { + double value = 0.0; + for (int v = 0; v < 8; ++v) + { + double row = 0.0; + for (int u = 0; u < 8; ++u) + row += cosine[x][u] * block[v * 8 + u]; + value += cosine[y][v] * row; + } + const int expected = std::clamp(static_cast(std::floor(value / 4.0 + 0.5)), -256, 255); + const size_t sample = static_cast((8 + y) * 16 + 8 + x); + const int actual = static_cast(out[sample * 2] | (out[sample * 2 + 1] << 8)); + if (actual != expected) + matches = false; + } + t.IsTrue(matches, "luma block 3 should equal the reference inverse DCT"); + }); + + tc.Run("IPU CSC output drains through fromIPU across QWC re-arms", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kToIpu = 0x1000B400u; + constexpr uint32_t kFromIpu = 0x1000B000u; + constexpr uint32_t kSrc = 0x00030000u; + constexpr uint32_t kDst = 0x00040000u; + constexpr uint32_t kMacroblocks = 3u; + uint8_t *rdram = mem.getRDRAM(); + for (uint32_t mb = 0; mb < kMacroblocks; ++mb) + for (uint32_t i = 0; i < 384u; ++i) + rdram[kSrc + mb * 384u + i] = static_cast(i < 256u ? 16u + ((i * 7u + mb * 31u) % 220u) + : 128u + static_cast((i + mb) % 9u) - 4); + std::memset(rdram + kDst, 0xEE, kMacroblocks * 1024u + 16u); + + mem.writeIORegister(kToIpu + 0x10u, kSrc); + mem.writeIORegister(kToIpu + 0x20u, kMacroblocks * 384u / 16u); + mem.writeIORegister(kToIpu + 0x00u, 0x101u); + mem.writeIORegister(Ps2Ipu::kRegCmd, 0x70000000u | kMacroblocks); // CSC to RGB32 + + // Drain in uneven pieces so the copy crosses chunk and QWC boundaries. + uint32_t written = 0u; + for (uint32_t piece : {100u, 1u, 91u}) + { + mem.writeIORegister(kFromIpu + 0x10u, kDst + written * 16u); + mem.writeIORegister(kFromIpu + 0x20u, piece); + mem.writeIORegister(kFromIpu + 0x00u, 0x100u); + mem.advanceEeTimers(Ps2Ipu::kEeCyclesPerMacroblock); + t.IsTrue((mem.readIORegister(kFromIpu) & 0x100u) == 0u, "each fromIPU request should complete"); + written += piece; + } + t.Equals(written, kMacroblocks * 64u, "the pieces cover the whole CSC output"); + + bool matches = true; + for (uint32_t mb = 0; mb < kMacroblocks; ++mb) + { + const uint8_t *in = rdram + kSrc + mb * 384u; + for (int y = 0; y < 16; ++y) + for (int x = 0; x < 16; ++x) + { + const int lum = 0x95 * (in[y * 16 + x] - 16); + const int u = in[256 + (y / 2) * 8 + x / 2] - 128; + const int v = in[320 + (y / 2) * 8 + x / 2] - 128; + const int r = std::clamp((lum + 0xCC * v) >> 7, 0, 255); + const int g = std::clamp((lum - 0x68 * v - 0x32 * u) >> 7, 0, 255); + const int b = std::clamp((lum + 0x102 * u) >> 7, 0, 255); + const uint8_t *pixel = rdram + kDst + mb * 1024u + static_cast(y * 16 + x) * 4u; + if (pixel[0] != r || pixel[1] != g || pixel[2] != b || pixel[3] != 0x80u) + matches = false; + } + } + t.IsTrue(matches, "every macroblock should convert in order with no lost or repeated quadwords"); + t.Equals(rdram[kDst + kMacroblocks * 1024u], static_cast(0xEEu), "fromIPU must not write past the output"); + }); + tc.Run("IPU BDEC decodes intra blocks as pixels and honours DC reset", [](TestCase &t) { // Six blocks with zero DC differentials and immediate end-of-block. From 7bd7746b3e224c53a04595d55d32a5874af70419 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:57 -0300 Subject: [PATCH 28/91] Hoist GS pixel, sprite texel and image-upload position work out of per-pixel loops --- .../include/runtime/gs/gs_cpu_backend.h | 15 + ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 261 ++++++++++++------ ps2xTest/src/ps2_gs_tests.cpp | 83 ++++++ 3 files changed, 276 insertions(+), 83 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 2421f9ed3a..ef88c9b96f 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -44,8 +44,23 @@ class GSCpuBackend final : public GSRasterBackend void DrawSprite(const GSPrimitiveBatch &batch); void DrawTriangle(const GSPrimitiveBatch &batch); void DrawLine(const GSPrimitiveBatch &batch); + // Per-primitive register decoding hoisted out of the per-pixel path. + struct PixelPipeline; + // A wrapped texel coordinate along one axis (plus its neighbour and weight when filtering). + struct TextureAxis + { + int c0 = 0; + int c1 = 0; + float frac = 0.0f; + }; + + static PixelPipeline MakePixelPipeline(const GSDrawState &state); void WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); + void WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); uint32_t SampleTexture(const GSDrawState &state, float s, float t, float q, uint16_t u, uint16_t v); + static TextureAxis PrepareTextureAxis(float coordinate, int size, uint8_t mode, uint16_t regionMin, uint16_t regionMax, bool linear); + uint32_t SampleTexel(const GSDrawState &state, int u, int v); + uint32_t SampleTextureAxes(const GSDrawState &state, const TextureAxis &u, const TextureAxis &v); uint32_t LookupCLUT(const GSDrawState &state, uint8_t index, uint8_t cpsm, uint8_t csm, uint8_t csa, uint8_t sourcePsm); void PerformLocalToLocalTransfer(); diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index f0ad5070b4..724cae2048 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -846,13 +846,39 @@ void GSCpuBackend::DrawPrimitive(const GSPrimitiveBatch &batch) } } +struct GSCpuBackend::PixelPipeline +{ + const GSDrawState *state = nullptr; + bool fge = false; + u32 fbp = 0; + u32 fbw = 1; + u32 fpsm = 0; + u32 zbp = 0; + u32 zpsm = 0; + uint32_t ztestMethod = 0; + bool destinationAlphaTestNeedsRead = false; + bool framebuffer16 = false; + bool fbaSetsAlpha = false; + uint8_t asel = 0; + uint8_t bsel = 0; + uint8_t csel = 0; + uint8_t dsel = 0; + uint8_t fix = 0; +}; + void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) { + WritePixel(MakePixelPipeline(state), x, y, z, r, g, b, a, fog); +} + +void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) +{ + const GSDrawState &state = *pipe.state; const auto &ctx = state.context; if (x < ctx.scissor.x0 || x > ctx.scissor.x1 || y < ctx.scissor.y0 || y > ctx.scissor.y1) return; - if (state.prim.fge) + if (pipe.fge) { const uint32_t inverseFog = 255u - fog; auto applyFog = [&](uint8_t input, uint8_t fogColor) -> uint8_t @@ -865,11 +891,11 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin b = applyFog(b, state.fogB); } - const u32 fbp = GSInternal::framePageBaseToBlock(ctx.frame.fbp); - const u32 fbw = std::max(ctx.frame.fbw, 1u); - const u32 fpsm = ctx.frame.psm; - const u32 zbp = GSInternal::framePageBaseToBlock(ctx.zbuf.zbp); - const u32 zpsm = ctx.zbuf.psm; + const u32 fbp = pipe.fbp; + const u32 fbw = pipe.fbw; + const u32 fpsm = pipe.fpsm; + const u32 zbp = pipe.zbp; + const u32 zpsm = pipe.zpsm; const PixelWriteMask writeMask = classifyAlphaTest(ctx.test, a, static_cast(fpsm)); if (!writeMask.writesAnything()) @@ -877,10 +903,10 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin return; } - const uint32_t ztestMethod = static_cast((ctx.test >> 17) & 3u); + const uint32_t ztestMethod = pipe.ztestMethod; const bool alphaBlendEnabled = state.prim.abe; const bool preserveDestinationAlpha = writeMask.writeRgb && !writeMask.writeAlpha && fpsm == GS_PSM_CT32; - const bool destinationAlphaTestNeedsRead = ((ctx.test >> 14) & 0x1u) != 0u && (fpsm == GS_PSM_CT32 || fpsm == GS_PSM_CT16 || fpsm == GS_PSM_CT16S); + const bool destinationAlphaTestNeedsRead = pipe.destinationAlphaTestNeedsRead; // small optimization, avoid reading the framebuffer for simple draws // TODO: only one address lookup for rmw @@ -893,7 +919,7 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin rawFramebufferPixel = ReadVramUnlocked(fpsm, fbp, fbw, x, y); fbrgba = rawFramebufferPixel; - if (bitsPerPixel(fpsm) == 16) + if (pipe.framebuffer16) { fbrgba = Rgba5551ToRgba8888(fbrgba); } @@ -950,12 +976,11 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin // PABE disables alpha blending when the source alpha MSB is clear. if (!(state.pabe && (a & 0x80u) == 0u)) { - uint64_t alphaReg = ctx.alpha; - uint8_t asel = alphaReg & 3; - uint8_t bsel = (alphaReg >> 2) & 3; - uint8_t csel = (alphaReg >> 4) & 3; - uint8_t dsel = (alphaReg >> 6) & 3; - uint8_t fix = static_cast((alphaReg >> 32) & 0xFF); + const uint8_t asel = pipe.asel; + const uint8_t bsel = pipe.bsel; + const uint8_t csel = pipe.csel; + const uint8_t dsel = pipe.dsel; + const uint8_t fix = pipe.fix; auto pickRGB = [&](uint8_t sel, int cs, int cd) -> int { @@ -980,7 +1005,7 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin } } - if (writeMask.writeAlpha && (ctx.fba & 0x1ull) != 0ull && ctx.frame.psm != GS_PSM_CT24) + if (writeMask.writeAlpha && pipe.fbaSetsAlpha) { a = static_cast(a | 0x80u); } @@ -998,7 +1023,7 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin } // format conversion - if (bitsPerPixel(fpsm) == 16) + if (pipe.framebuffer16) { pixel = Rgba8888ToRgba5551(pixel); } @@ -1012,6 +1037,31 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin } } +GSCpuBackend::PixelPipeline GSCpuBackend::MakePixelPipeline(const GSDrawState &state) +{ + const auto &ctx = state.context; + PixelPipeline pipe{}; + pipe.state = &state; + pipe.fge = state.prim.fge; + pipe.fbp = GSInternal::framePageBaseToBlock(ctx.frame.fbp); + pipe.fbw = std::max(ctx.frame.fbw, 1u); + pipe.fpsm = ctx.frame.psm; + pipe.zbp = GSInternal::framePageBaseToBlock(ctx.zbuf.zbp); + pipe.zpsm = ctx.zbuf.psm; + pipe.ztestMethod = static_cast((ctx.test >> 17) & 3u); + pipe.destinationAlphaTestNeedsRead = ((ctx.test >> 14) & 0x1u) != 0u && + (pipe.fpsm == GS_PSM_CT32 || pipe.fpsm == GS_PSM_CT16 || pipe.fpsm == GS_PSM_CT16S); + pipe.framebuffer16 = bitsPerPixel(pipe.fpsm) == 16; + pipe.fbaSetsAlpha = (ctx.fba & 0x1ull) != 0ull && ctx.frame.psm != GS_PSM_CT24; + const uint64_t alphaReg = ctx.alpha; + pipe.asel = alphaReg & 3; + pipe.bsel = (alphaReg >> 2) & 3; + pipe.csel = (alphaReg >> 4) & 3; + pipe.dsel = (alphaReg >> 6) & 3; + pipe.fix = static_cast((alphaReg >> 32) & 0xFF); + return pipe; +} + uint32_t GSCpuBackend::LookupCLUT(const GSDrawState &state, uint8_t index, uint8_t cpsm, @@ -1058,7 +1108,6 @@ uint32_t GSCpuBackend::LookupCLUT(const GSDrawState &state, uint32_t GSCpuBackend::SampleTexture(const GSDrawState &state, float s, float t, float q, uint16_t u, uint16_t v) { const auto &ctx = state.context; - const auto &tex = ctx.tex0; const int texW = state.textureWidth; const int texH = state.textureHeight; @@ -1083,54 +1132,68 @@ uint32_t GSCpuBackend::SampleTexture(const GSDrawState &state, float s, float t, texVf = t * invQ * static_cast(texH); } - auto samplePoint = [&](int sampleU, int sampleV) -> uint32_t + return SampleTextureAxes(state, + PrepareTextureAxis(texUf, texW, wrapU, minU, maxU, state.linearFilter), + PrepareTextureAxis(texVf, texH, wrapV, minV, maxV, state.linearFilter)); +} + +GSCpuBackend::TextureAxis GSCpuBackend::PrepareTextureAxis(float coordinate, int size, uint8_t mode, uint16_t regionMin, uint16_t regionMax, bool linear) +{ + TextureAxis axis{}; + if (!linear) { - sampleU = wrapTextureCoordinate(sampleU, texW, wrapU, minU, maxU); - sampleV = wrapTextureCoordinate(sampleV, texH, wrapV, minV, maxV); + axis.c0 = wrapTextureCoordinate(static_cast(coordinate), size, mode, regionMin, regionMax); + return axis; + } + const float sample = coordinate - 0.5f; + const int c0 = static_cast(std::floor(sample)); + axis.frac = sample - static_cast(c0); + axis.c0 = wrapTextureCoordinate(c0, size, mode, regionMin, regionMax); + axis.c1 = wrapTextureCoordinate(c0 + 1, size, mode, regionMin, regionMax); + return axis; +} - u32 out = ReadTextureVramUnlocked(tex.psm, tex.tbp0, tex.tbw, sampleU, sampleV); +uint32_t GSCpuBackend::SampleTexel(const GSDrawState &state, int u, int v) +{ + const auto &tex = state.context.tex0; + u32 out = ReadTextureVramUnlocked(tex.psm, tex.tbp0, tex.tbw, u, v); - switch (tex.psm) - { - case GS_PSM_CT32: - case GS_PSM_Z32: - case GS_PSM_CT24: - case GS_PSM_Z24: - return applyTexa(state.texa, tex.psm, out); - case GS_PSM_CT16: - case GS_PSM_CT16S: - case GS_PSM_Z16: - case GS_PSM_Z16S: - return applyTexa(state.texa, tex.psm, Rgba5551ToRgba8888(out)); - case GS_PSM_T8: - case GS_PSM_T8H: - case GS_PSM_T4: - case GS_PSM_T4HL: - case GS_PSM_T4HH: - return LookupCLUT(state, static_cast(out), tex.cpsm, tex.csm, tex.csa, tex.psm); - } + switch (tex.psm) + { + case GS_PSM_CT32: + case GS_PSM_Z32: + case GS_PSM_CT24: + case GS_PSM_Z24: + return applyTexa(state.texa, tex.psm, out); + case GS_PSM_CT16: + case GS_PSM_CT16S: + case GS_PSM_Z16: + case GS_PSM_Z16S: + return applyTexa(state.texa, tex.psm, Rgba5551ToRgba8888(out)); + case GS_PSM_T8: + case GS_PSM_T8H: + case GS_PSM_T4: + case GS_PSM_T4HL: + case GS_PSM_T4HH: + return LookupCLUT(state, static_cast(out), tex.cpsm, tex.csm, tex.csa, tex.psm); + } - return 0xFFFF00FFu; - }; + return 0xFFFF00FFu; +} +uint32_t GSCpuBackend::SampleTextureAxes(const GSDrawState &state, const TextureAxis &u, const TextureAxis &v) +{ if (!state.linearFilter) { - return samplePoint(static_cast(texUf), static_cast(texVf)); + return SampleTexel(state, u.c0, v.c0); } - const float sampleU = texUf - 0.5f; - const float sampleV = texVf - 0.5f; - const int u0 = static_cast(std::floor(sampleU)); - const int v0 = static_cast(std::floor(sampleV)); - const int u1 = u0 + 1; - const int v1 = v0 + 1; - const float fx = sampleU - static_cast(u0); - const float fy = sampleV - static_cast(v0); - - const uint32_t c00 = samplePoint(u0, v0); - const uint32_t c10 = samplePoint(u1, v0); - const uint32_t c01 = samplePoint(u0, v1); - const uint32_t c11 = samplePoint(u1, v1); + const float fx = u.frac; + const float fy = v.frac; + const uint32_t c00 = SampleTexel(state, u.c0, v.c0); + const uint32_t c10 = SampleTexel(state, u.c1, v.c0); + const uint32_t c01 = SampleTexel(state, u.c0, v.c1); + const uint32_t c11 = SampleTexel(state, u.c1, v.c1); const uint8_t r = lerpChannel(static_cast(c00 & 0xFFu), static_cast(c10 & 0xFFu), @@ -1234,28 +1297,48 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) if (spriteH < 1.0f) spriteH = 1.0f; + // Texel coordinates depend only on the column or the row, so wrap them once per axis + // with exactly the arithmetic SampleTexture applies to each pixel. + const uint64_t clamp = ctx.clamp; + const uint8_t wrapU = static_cast(clamp & 0x3u); + const uint8_t wrapV = static_cast((clamp >> 2) & 0x3u); + const uint16_t minU = static_cast((clamp >> 4) & 0x3FFu); + const uint16_t maxU = static_cast((clamp >> 14) & 0x3FFu); + const uint16_t minV = static_cast((clamp >> 24) & 0x3FFu); + const uint16_t maxV = static_cast((clamp >> 34) & 0x3FFu); + auto axisCoordinate = [&](float texF, int size) -> float + { + if (state.prim.fst) + { + const int fixed = static_cast((texF * 16.0f) + 0.5f); + const uint16_t sample = static_cast(clampInt(fixed, 0, 0xFFFF)); + return static_cast(sample) / 16.0f; + } + const float normalized = texF / static_cast(size); + const float invQ = 1.0f / fabsQ(1.0f); + return normalized * invQ * static_cast(size); + }; + + thread_local std::vector columns; + columns.resize(static_cast(drawX1 - drawX0 + 1)); + for (int x = drawX0; x <= drawX1; ++x) + { + float tx = (static_cast(x - unclippedX0) + 0.5f) / spriteW; + float texUf = u0f + (u1f - u0f) * tx; + columns[static_cast(x - drawX0)] = + PrepareTextureAxis(axisCoordinate(texUf, texW), texW, wrapU, minU, maxU, state.linearFilter); + } + + const PixelPipeline pipe = MakePixelPipeline(state); for (int y = drawY0; y <= drawY1; ++y) { float ty = (static_cast(y - unclippedY0) + 0.5f) / spriteH; float texVf = v0f + (v1f - v0f) * ty; + const TextureAxis row = PrepareTextureAxis(axisCoordinate(texVf, texH), texH, wrapV, minV, maxV, state.linearFilter); for (int x = drawX0; x <= drawX1; ++x) { - float tx = (static_cast(x - unclippedX0) + 0.5f) / spriteW; - float texUf = u0f + (u1f - u0f) * tx; - uint32_t texel = 0xFFFF00FFu; - if (state.prim.fst) - { - const int fixedU = static_cast((texUf * 16.0f) + 0.5f); - const int fixedV = static_cast((texVf * 16.0f) + 0.5f); - const uint16_t sampleU = static_cast(clampInt(fixedU, 0, 0xFFFF)); - const uint16_t sampleV = static_cast(clampInt(fixedV, 0, 0xFFFF)); - texel = SampleTexture(state, 0.0f, 0.0f, 1.0f, sampleU, sampleV); - } - else - { - texel = SampleTexture(state, texUf / static_cast(texW), texVf / static_cast(texH), 1.0f, 0u, 0u); - } + const uint32_t texel = SampleTextureAxes(state, columns[static_cast(x - drawX0)], row); uint8_t tr = static_cast(texel & 0xFF); uint8_t tg = static_cast((texel >> 8) & 0xFF); @@ -1263,15 +1346,16 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) uint8_t ta = static_cast((texel >> 24) & 0xFF); const TextureCombineResult color = combineTexture(tex, r, g, b, a, tr, tg, tb, ta); - WritePixel(state, x, y, z1, color.r, color.g, color.b, color.a, v1.fog); + WritePixel(pipe, x, y, z1, color.r, color.g, color.b, color.a, v1.fog); } } } else { + const PixelPipeline pipe = MakePixelPipeline(state); for (int y = drawY0; y <= drawY1; ++y) for (int x = drawX0; x <= drawX1; ++x) - WritePixel(state, x, y, z1, r, g, b, a, v1.fog); + WritePixel(pipe, x, y, z1, r, g, b, a, v1.fog); } } @@ -1282,6 +1366,7 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) const GSVertex &v1 = batch.vertices[1]; const GSVertex &v2 = batch.vertices[2]; const auto &ctx = state.context; + const PixelPipeline pipe = MakePixelPipeline(state); int ofx = ctx.xyoffset.ofx >> 4; int ofy = ctx.xyoffset.ofy >> 4; @@ -1388,7 +1473,7 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) } const uint8_t fog = clampU8(static_cast(v0.fog * w0 + v1.fog * w1 + v2.fog * w2)); - WritePixel(state, x, y, static_cast(z + 0.5), r, g, b, a, fog); + WritePixel(pipe, x, y, static_cast(z + 0.5), r, g, b, a, fog); } } } @@ -1399,6 +1484,7 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch) const GSVertex &v0 = batch.vertices[0]; const GSVertex &v1 = batch.vertices[1]; const auto &ctx = state.context; + const PixelPipeline pipe = MakePixelPipeline(state); int ofx = ctx.xyoffset.ofx >> 4; int ofy = ctx.xyoffset.ofy >> 4; @@ -1440,7 +1526,7 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch) double z = (v0.z + (v1.z - v0.z) * t); const uint8_t fog = clampU8(static_cast(v0.fog + (v1.fog - v0.fog) * t)); - WritePixel(state, x0, y0, static_cast(z), r, g, b, a, fog); + WritePixel(pipe, x0, y0, static_cast(z), r, g, b, a, fog); if (x0 == x1 && y0 == y1) break; @@ -1491,6 +1577,9 @@ void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) const uint32_t rrw = m_transfer.trxreg.rrw; const uint32_t dsax = m_transfer.trxpos.dsax; uint32_t offset = 0u; + // Track the column/row of copiedPixels incrementally; a divide per uploaded pixel showed up in movie playback. + uint32_t column = m_transferState.copiedPixels % rrw; + uint32_t row = m_transferState.copiedPixels / rrw; auto advancePixel = [&](uint32_t count) { @@ -1505,8 +1594,14 @@ void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) return; } - m_transferState.x = dsax + (m_transferState.copiedPixels % rrw); - m_transferState.y = m_transfer.trxpos.dsay + (m_transferState.copiedPixels / rrw); + column += count; + while (column >= rrw) + { + column -= rrw; + ++row; + } + m_transferState.x = dsax + column; + m_transferState.y = m_transfer.trxpos.dsay + row; }; while (offset < sizeBytes && m_transferState.direction == 0u) @@ -1564,15 +1659,15 @@ void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) const uint8_t packed = data[offset++]; const uint32_t firstPixel = m_transferState.copiedPixels; WriteVramUnlocked(dpsm, dbp, dbw, - dsax + (firstPixel % rrw), - m_transfer.trxpos.dsay + (firstPixel / rrw), + dsax + column, + m_transfer.trxpos.dsay + row, packed & 0x0Fu); if (firstPixel + 1u < m_transferState.totalPixels) { - const uint32_t secondPixel = firstPixel + 1u; + const bool wraps = column + 1u >= rrw; WriteVramUnlocked(dpsm, dbp, dbw, - dsax + (secondPixel % rrw), - m_transfer.trxpos.dsay + (secondPixel / rrw), + dsax + (wraps ? column + 1u - rrw : column + 1u), + m_transfer.trxpos.dsay + (wraps ? row + 1u : row), (packed >> 4u) & 0x0Fu); } advancePixel(std::min(2u, m_transferState.totalPixels - firstPixel)); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 99010613eb..2c1764d6a9 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -1,4 +1,6 @@ #include "MiniTest.h" +#include +#include #include "runtime/ps2_memory.h" #include "ps2_runtime.h" #include "ps2_stubs.h" @@ -3599,6 +3601,87 @@ void register_ps2_gs_tests() "linear filtering should preserve the shared opaque alpha from the CLUT entries"); }); + tc.Run("FST sprite bilinear magnification clamps and blends texels per column and row", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + constexpr uint32_t kTexTbp = 64u; + constexpr uint64_t kFrame = (1ull << 16) | (static_cast(GS_PSM_CT32) << 24); + constexpr uint64_t kZbuf = (1ull << 32); + constexpr uint64_t kTex0 = + (static_cast(kTexTbp) << 0) | + (1ull << 14) | + (static_cast(GS_PSM_CT32) << 20) | + (2ull << 26) | + (2ull << 30) | + (1ull << 34); + constexpr uint64_t kPrim = static_cast(GS_PRIM_SPRITE) | (1ull << 4) | (1ull << 8); + uint32_t texels[4][4]; + for (uint32_t y = 0u; y < 4u; ++y) + for (uint32_t x = 0u; x < 4u; ++x) + { + texels[y][x] = 0x80000000u | ((x * 70u + y * 9u) & 0xFFu) | (((y * 80u + 5u) & 0xFFu) << 8) | + (((x * 13u + y * 50u) & 0xFFu) << 16); + writeReferencePSMCT32Pixel(vram, kTexTbp, 1u, x, y, texels[y][x]); + } + + gs.writeRegister(GS_REG_FRAME_1, kFrame); + gs.writeRegister(GS_REG_ZBUF_1, kZbuf); + gs.writeRegister(GS_REG_SCISSOR_1, (7ull << 16) | (7ull << 48)); + gs.writeRegister(GS_REG_XYOFFSET_1, 0ull); + gs.writeRegister(GS_REG_TEST_1, 0x30000ull); + gs.writeRegister(GS_REG_ALPHA_1, 0ull); + gs.writeRegister(GS_REG_TEX0_1, kTex0 | (1ull << 35)); // DECAL: output the filtered texel + gs.writeRegister(GS_REG_TEX1_1, 1ull << 5); // MMAG linear + gs.writeRegister(GS_REG_CLAMP_1, 1ull); // CLAMP in U, REPEAT in V + gs.writeRegister(GS_REG_PRIM, kPrim); + gs.writeRegister(GS_REG_RGBAQ, 0x80808080ull); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, 0ull); + gs.writeRegister(GS_REG_UV, (64ull << 0) | (64ull << 16)); + gs.writeRegister(GS_REG_XYZ2, (static_cast(8u << 4) << 0) | (static_cast(8u << 4) << 16)); + + // 2x magnification: pixel centres land on quarter texels, so every pixel blends four texels. + auto axis = [](int pixel, bool clamp, int &c0, int &c1, float &frac) + { + const float texF = 0.0f + (4.0f - 0.0f) * ((static_cast(pixel) + 0.5f) / 8.0f); + const uint16_t fixed = static_cast(static_cast((texF * 16.0f) + 0.5f)); + const float sample = static_cast(fixed) / 16.0f - 0.5f; + const int base = static_cast(std::floor(sample)); + frac = sample - static_cast(base); + auto wrap = [&](int c) { return clamp ? std::clamp(c, 0, 3) : (c & 3); }; + c0 = wrap(base); + c1 = wrap(base + 1); + }; + auto lerp = [](uint32_t a, uint32_t b, uint32_t c, uint32_t d, int shift, float fx, float fy) + { + const float c00 = static_cast((a >> shift) & 0xFFu); + const float c10 = static_cast((b >> shift) & 0xFFu); + const float c01 = static_cast((c >> shift) & 0xFFu); + const float c11 = static_cast((d >> shift) & 0xFFu); + const float top = c00 + (c10 - c00) * fx; + const float bottom = c01 + (c11 - c01) * fx; + return static_cast(std::clamp(static_cast(std::lround(top + (bottom - top) * fy)), 0, 255)) << shift; + }; + bool matches = true; + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + { + int u0, u1, v0, v1; + float fx, fy; + axis(x, true, u0, u1, fx); + axis(y, false, v0, v1, fy); + uint32_t expected = 0u; + for (int shift : {0, 8, 16, 24}) + expected |= lerp(texels[v0][u0], texels[v0][u1], texels[v1][u0], texels[v1][u1], shift, fx, fy); + if (readReferencePSMCT32Pixel(vram, 0u, 1u, static_cast(x), static_cast(y)) != expected) + matches = false; + } + t.IsTrue(matches, "every sprite pixel should be the bilinear blend of its clamped/repeated texel neighbours"); + }); + tc.Run("GS CLAMP modes transform texture coordinates before sampling", [](TestCase &t) { auto renderConstantUv = [](uint64_t clampReg, From c47e88b4c408520b1e2e8681d6aad03c2594d81f Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:38:35 -0300 Subject: [PATCH 29/91] Leave the DMA tag QWC to Terminate in sceGifPk HLE stubs so Sofdec strip uploads are not dropped --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 52 ++++++++++---- ps2xTest/src/ps2_gs_tests.cpp | 93 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index c8b03708cc..ab7cdf84f0 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -124,6 +124,7 @@ namespace ps2_stubs void refreshPacketBuilderPendingCount(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr); void writePacketBuilderCurrent(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t currentAddr); + void advancePacketBuilderCurrent(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t currentAddr); void initPacketBuilderState(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { @@ -262,6 +263,26 @@ namespace ps2_stubs refreshPacketBuilderPendingCount(rdram, runtime, stateAddr); } + // libpkt's sceGifPkAdd*/Open/Close/Reserve only move pCurrent; the open DMA tag's QWC is added by + // sceGifPkTerminate (also run by Cnt/Ref/End). Games can mix these HLE stubs with recompiled + // Terminate code, so refreshing the QWC here would make that guest Terminate count it twice. + void advancePacketBuilderCurrent(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t currentAddr) + { + writeGuestU32(rdram, runtime, stateAddr, currentAddr); + } + + uint32_t advanceGifPacketWords(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t wordCount) + { + uint32_t currentAddr = 0u; + if (!tryReadWordFromGuest(rdram, runtime, stateAddr, currentAddr)) + { + return 0u; + } + + advancePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + wordCount * 4u); + return currentAddr; + } + uint32_t reservePacketBuilderWords(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t wordCount) { uint32_t currentAddr = 0u; @@ -311,7 +332,8 @@ namespace ps2_stubs R5900Context *ctx, PS2Runtime *runtime, uint32_t stateAddr, - uint32_t openAddrOffset) + uint32_t openAddrOffset, + bool refreshDmaTag) { uint32_t currentAddr = 0u; if (!tryReadWordFromGuest(rdram, runtime, stateAddr, currentAddr)) @@ -320,11 +342,14 @@ namespace ps2_stubs } writeGuestVec128(rdram, runtime, currentAddr, GPR_VEC(ctx, 5)); - writePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 16u); + if (refreshDmaTag) + writePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 16u); + else + advancePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 16u); writeGuestU32(rdram, runtime, stateAddr + openAddrOffset, currentAddr); } - void closePacketGifTag(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t openAddrOffset) + void closePacketGifTag(uint8_t *rdram, PS2Runtime *runtime, uint32_t stateAddr, uint32_t openAddrOffset, bool refreshDmaTag) { uint32_t openAddr = 0u; uint32_t currentAddr = 0u; @@ -366,7 +391,10 @@ namespace ps2_stubs writeGuestU32(rdram, runtime, currentAddr, 0u); currentAddr += 4u; } - writePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr); + if (refreshDmaTag) + writePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr); + else + advancePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr); } } @@ -383,7 +411,7 @@ namespace ps2_stubs const uint64_t regValue = static_cast(getRegU32(ctx, 5)); writeGuestU64(rdram, runtime, currentAddr, dataValue); writeGuestU64(rdram, runtime, currentAddr + 8u, regValue); - writePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 16u); + advancePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 16u); } void sceGifPkAddGsData(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -396,13 +424,13 @@ namespace ps2_stubs } writeGuestU64(rdram, runtime, currentAddr, GPR_U64(ctx, 5)); - writePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 8u); + advancePacketBuilderCurrent(rdram, runtime, stateAddr, currentAddr + 8u); } void sceGifPkCloseGifTag(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { (void)ctx; - closePacketGifTag(rdram, runtime, getRegU32(ctx, 4), 12u); + closePacketGifTag(rdram, runtime, getRegU32(ctx, 4), 12u, false); } void sceGifPkCnt(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -450,7 +478,7 @@ namespace ps2_stubs void sceGifPkOpenGifTag(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - openPacketGifTag(rdram, ctx, runtime, getRegU32(ctx, 4), 12u); + openPacketGifTag(rdram, ctx, runtime, getRegU32(ctx, 4), 12u, false); } void sceGifPkRef(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -535,7 +563,7 @@ namespace ps2_stubs writeGuestU64(rdram, runtime, addr + 8u, static_cast(GS_REG_TRXDIR)); addr += 16u; writePacketBuilderCurrent(rdram, runtime, stateAddr, addr); - closePacketGifTag(rdram, runtime, stateAddr, 12u); + closePacketGifTag(rdram, runtime, stateAddr, 12u, true); } } @@ -582,7 +610,7 @@ namespace ps2_stubs void sceGifPkReserve(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - setReturnU32(ctx, reservePacketBuilderWords(rdram, runtime, getRegU32(ctx, 4), getRegU32(ctx, 5))); + setReturnU32(ctx, advanceGifPacketWords(rdram, runtime, getRegU32(ctx, 4), getRegU32(ctx, 5))); } void sceGifPkTerminate(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -1475,7 +1503,7 @@ namespace ps2_stubs void sceVif1PkCloseGifTag(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { (void)ctx; - closePacketGifTag(rdram, runtime, getRegU32(ctx, 4), 20u); + closePacketGifTag(rdram, runtime, getRegU32(ctx, 4), 20u, true); } void sceVif1PkCnt(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -1537,7 +1565,7 @@ namespace ps2_stubs void sceVif1PkOpenGifTag(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - openPacketGifTag(rdram, ctx, runtime, getRegU32(ctx, 4), 20u); + openPacketGifTag(rdram, ctx, runtime, getRegU32(ctx, 4), 20u, true); } void sceVif1PkReset(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 2c1764d6a9..43f627f377 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -4307,6 +4307,99 @@ void register_ps2_gs_tests() "TRXDIR payload must encode dir=0 (host-to-local)"); }); + tc.Run("sceGifPk HLE stubs leave the open DMA tag QWC to a recompiled Terminate", [](TestCase &t) + { + // Mana Khemia's movie strip upload builds its chain with recompiled sceGifPkCnt/Terminate + // around HLE sceGifPkAddGsAD/CloseGifTag; Terminate adds the QWC, as libpkt does. + std::vector rdram(PS2_RAM_SIZE, 0u); + PS2Runtime runtime; + R5900Context ctx{}; + + constexpr uint32_t stateAddr = 0x1000u; + constexpr uint32_t tagAddr = 0x2000u; + auto writeWord = [&](uint32_t addr, uint32_t value) + { std::memcpy(rdram.data() + addr, &value, sizeof(value)); }; + auto readWord = [&](uint32_t addr) + { + uint32_t value = 0u; + std::memcpy(&value, rdram.data() + addr, sizeof(value)); + return value; + }; + + // What the recompiled sceGifPkCnt leaves behind: CNT tag with QWC 0, pDmaTag set, pCurrent past it. + writeWord(tagAddr, 0x10000000u); + writeWord(stateAddr + 0u, tagAddr + 16u); + writeWord(stateAddr + 4u, tagAddr); + writeWord(stateAddr + 8u, tagAddr); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + ctx.r[5] = _mm_set_epi64x(0x0Eull, 0x1000000000000000ull); // A+D GIFtag, NLOOP filled on close + ps2_stubs::sceGifPkOpenGifTag(rdram.data(), &ctx, &runtime); + + const uint32_t regs[2] = {GS_REG_BITBLTBUF, GS_REG_TRXREG}; + for (uint32_t reg : regs) + { + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + setRegU32(ctx, 5, reg); + ps2_stubs::sceGifPkAddGsAD(rdram.data(), &ctx, &runtime); + } + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + ps2_stubs::sceGifPkCloseGifTag(rdram.data(), &ctx, &runtime); + + t.Equals(readWord(tagAddr), 0x10000000u, "HLE GIF packet stubs must not write the open DMA tag's QWC"); + t.Equals(readWord(tagAddr + 16u) & 0x7FFFu, 2u, "CloseGifTag should still set the GIFtag NLOOP"); + + // The recompiled sceGifPkTerminate: tag.word0 += (pCurrent - pDmaTag) / 16 - 1. + const uint32_t current = readWord(stateAddr); + writeWord(tagAddr, readWord(tagAddr) + ((current - tagAddr) >> 4u) - 1u); + t.Equals(readWord(tagAddr), 0x10000003u, "Terminate should leave QWC 3 (GIFtag + two A+D), not double it"); + }); + + tc.Run("sceGifPk HLE Terminate still sets the QWC of an all-HLE chain", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + PS2Runtime runtime; + R5900Context ctx{}; + + constexpr uint32_t stateAddr = 0x1000u; + constexpr uint32_t baseAddr = 0x2000u; + + setRegU32(ctx, 4, stateAddr); + setRegU32(ctx, 5, baseAddr); + ps2_stubs::sceGifPkInit(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + ps2_stubs::sceGifPkCnt(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + ctx.r[5] = _mm_set_epi64x(0x0Eull, 0x1000000000000000ull); + ps2_stubs::sceGifPkOpenGifTag(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + setRegU32(ctx, 5, GS_REG_TEXFLUSH); + ps2_stubs::sceGifPkAddGsAD(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + ps2_stubs::sceGifPkCloseGifTag(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, stateAddr); + ps2_stubs::sceGifPkTerminate(rdram.data(), &ctx, &runtime); + + uint32_t tagWord = 0u; + std::memcpy(&tagWord, rdram.data() + baseAddr, sizeof(tagWord)); + t.Equals(tagWord & 0xFFFFu, 2u, "HLE Terminate should close the CNT tag over GIFtag + one A+D"); + t.Equals(tagWord >> 28u, 1u, "the chain head should stay a CNT tag"); + }); + tc.Run("sceGsResetGraph frees its temporary GIF packet", [](TestCase &t) { PS2Runtime runtime; From 194d4071265c66eb7ab554e28bb9d733cf07ab73 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:38:41 -0300 Subject: [PATCH 30/91] Carry CT24/CT16/CT32 pixels that straddle host-to-local image chunks --- .../include/runtime/gs/gs_cpu_backend.h | 4 + ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 86 ++++++++++++------- ps2xTest/src/ps2_gs_tests.cpp | 72 ++++++++++++++++ 3 files changed, 129 insertions(+), 33 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index ef88c9b96f..7c4715c69d 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -93,4 +93,8 @@ class GSCpuBackend final : public GSRasterBackend GSTransferSnapshot m_transferState{}; std::vector m_localToHostBuffer; size_t m_localToHostReadPos = 0; + // Host->local data is one byte stream across IMAGE packets and HWREG writes; a CT24/CT16 pixel can + // straddle two of them, so the leading bytes of a split pixel wait here for the rest. + std::array m_uploadCarry{}; + uint32_t m_uploadCarryBytes = 0; }; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 724cae2048..694e5337aa 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -541,6 +541,7 @@ void GSCpuBackend::ResetUnlocked() m_transferState.direction = 3u; m_localToHostBuffer.clear(); m_localToHostReadPos = 0u; + m_uploadCarryBytes = 0u; } void GSCpuBackend::Submit(const GSPrimitiveBatch &batch) @@ -1556,6 +1557,7 @@ void GSCpuBackend::BeginTransfer(const GSTransferCommand &command) m_transferState.copiedPixels = 0u; m_transferState.direction = command.direction; m_transferState.localToHostPendingBytes = 0u; + m_uploadCarryBytes = 0u; if (command.direction == 2u) PerformLocalToLocalTransfer(); @@ -1604,49 +1606,67 @@ void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) m_transferState.y = m_transfer.trxpos.dsay + row; }; - while (offset < sizeBytes && m_transferState.direction == 0u) + uint32_t bytesPerPixel = 0u; + switch (dpsm) { - switch (dpsm) - { - case GS_PSM_CT32: - case GS_PSM_Z32: + case GS_PSM_CT32: + case GS_PSM_Z32: + bytesPerPixel = 4u; + break; + case GS_PSM_CT24: + case GS_PSM_Z24: + bytesPerPixel = 3u; + break; + case GS_PSM_CT16: + case GS_PSM_CT16S: + case GS_PSM_Z16: + case GS_PSM_Z16S: + bytesPerPixel = 2u; + break; + default: + break; + } + + if (bytesPerPixel != 0u) + { + auto writeDirectPixel = [&](const uint8_t *bytes) { - if (sizeBytes - offset < 4u) - return; uint32_t value = 0u; - std::memcpy(&value, data + offset, sizeof(value)); + std::memcpy(&value, bytes, bytesPerPixel); WriteVramUnlocked(dpsm, dbp, dbw, m_transferState.x, m_transferState.y, value); - offset += 4u; advancePixel(1u); - break; - } - case GS_PSM_CT24: - case GS_PSM_Z24: + }; + + if (m_uploadCarryBytes != 0u) { - if (sizeBytes - offset < 3u) + const uint32_t take = std::min(bytesPerPixel - m_uploadCarryBytes, sizeBytes); + std::memcpy(m_uploadCarry.data() + m_uploadCarryBytes, data, take); + m_uploadCarryBytes += take; + offset = take; + if (m_uploadCarryBytes < bytesPerPixel) return; - const uint32_t value = static_cast(data[offset]) | - (static_cast(data[offset + 1u]) << 8u) | - (static_cast(data[offset + 2u]) << 16u); - WriteVramUnlocked(dpsm, dbp, dbw, m_transferState.x, m_transferState.y, value); - offset += 3u; - advancePixel(1u); - break; + m_uploadCarryBytes = 0u; + writeDirectPixel(m_uploadCarry.data()); } - case GS_PSM_CT16: - case GS_PSM_CT16S: - case GS_PSM_Z16: - case GS_PSM_Z16S: + + while (m_transferState.direction == 0u && sizeBytes - offset >= bytesPerPixel) { - if (sizeBytes - offset < 2u) - return; - uint16_t value = 0u; - std::memcpy(&value, data + offset, sizeof(value)); - WriteVramUnlocked(dpsm, dbp, dbw, m_transferState.x, m_transferState.y, value); - offset += 2u; - advancePixel(1u); - break; + writeDirectPixel(data + offset); + offset += bytesPerPixel; + } + + if (m_transferState.direction == 0u && offset < sizeBytes) + { + m_uploadCarryBytes = sizeBytes - offset; + std::memcpy(m_uploadCarry.data(), data + offset, m_uploadCarryBytes); } + return; + } + + while (offset < sizeBytes && m_transferState.direction == 0u) + { + switch (dpsm) + { case GS_PSM_T8: case GS_PSM_T8H: WriteVramUnlocked(dpsm, dbp, dbw, m_transferState.x, m_transferState.y, data[offset++]); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 43f627f377..8be5e3bc40 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -2430,6 +2430,78 @@ void register_ps2_gs_tests() t.Equals(out[5], static_cast(0x66u), "pixel1 B should roundtrip"); }); + tc.Run("GS CT24 upload keeps pixels that straddle IMAGE packets", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + const uint64_t bitblt = + (static_cast(1u) << 16) | // SBW + (static_cast(GS_PSM_CT24) << 24) | + (static_cast(1u) << 48) | // DBW + (static_cast(GS_PSM_CT24) << 56); + gs.writeRegister(GS_REG_BITBLTBUF, bitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, (16ull << 0) | (1ull << 32)); // 16 pixels = 48 bytes = 3 qwords + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + uint8_t rgb[48]; + for (uint32_t i = 0; i < sizeof(rgb); ++i) + rgb[i] = static_cast(0x10u + i); + + // One qword per IMAGE packet: 16 bytes is 5 pixels plus one byte of the sixth. + for (uint32_t qw = 0; qw < 3u; ++qw) + { + std::vector packet; + appendU64(packet, makeGifTag(1u, GIF_FMT_IMAGE, 0u, true)); + appendU64(packet, 0ull); + packet.insert(packet.end(), rgb + qw * 16u, rgb + qw * 16u + 16u); + gs.processGIFPacket(packet.data(), static_cast(packet.size())); + } + + gs.writeRegister(GS_REG_TRXDIR, 1ull); + uint8_t out[48] = {}; + const uint32_t outBytes = gs.consumeLocalToHostBytes(out, sizeof(out)); + t.Equals(outBytes, 48u, "16 CT24 pixels should read back as 48 bytes"); + t.IsTrue(std::memcmp(out, rgb, sizeof(rgb)) == 0, + "CT24 pixels split across IMAGE packets should land intact and in order"); + }); + + tc.Run("GS CT24 upload through HWREG assembles pixels across 8-byte writes", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + const uint64_t bitblt = + (static_cast(1u) << 16) | + (static_cast(GS_PSM_CT24) << 24) | + (static_cast(1u) << 48) | + (static_cast(GS_PSM_CT24) << 56); + gs.writeRegister(GS_REG_BITBLTBUF, bitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, (8ull << 0) | (1ull << 32)); // 8 pixels = 24 bytes = 3 HWREG writes + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + uint8_t rgb[24]; + for (uint32_t i = 0; i < sizeof(rgb); ++i) + rgb[i] = static_cast(0xA0u + i); + for (uint32_t i = 0; i < 3u; ++i) + { + uint64_t value = 0; + std::memcpy(&value, rgb + i * 8u, sizeof(value)); + gs.writeRegister(GS_REG_HWREG, value); + } + + gs.writeRegister(GS_REG_TRXDIR, 1ull); + uint8_t out[24] = {}; + const uint32_t outBytes = gs.consumeLocalToHostBytes(out, sizeof(out)); + t.Equals(outBytes, 24u, "8 CT24 pixels should read back as 24 bytes"); + t.IsTrue(std::memcmp(out, rgb, sizeof(rgb)) == 0, + "CT24 pixels split across HWREG writes should land intact and in order"); + }); + tc.Run("GS PSMT4 host-local-host keeps nibble packing stable", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From c6753c891d4b2b2d83f0aebdc407cfe81bd39031 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:38:41 -0300 Subject: [PATCH 31/91] Test CT24 macroblock strip uploads through a GIF REF chain --- ps2xTest/src/ps2_memory_tests.cpp | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index 024e066ad6..27ce44aa62 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -1932,6 +1932,81 @@ void register_ps2_memory_tests() t.IsTrue(ct32Ok, "all CT32 pixels should survive a preceding paletted upload in the same GIF DMA chain"); }); + tc.Run("GIF DMA chain uploads CT24 column strips from per-macroblock REF tags", [](TestCase &t) + { + // Movie players upload a frame as 16-pixel-wide CT24 strips: one setup CNT that ends in the IMAGE + // GIFtag, then one REF per 16x16 macroblock (768 bytes = 48 qwords), strips side by side via DSAX. + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + GS gs; + gs.init(mem.getGSVRAM(), static_cast(PS2_GS_VRAM_SIZE), &mem.gs()); + mem.setGifPacketCallback([&](const uint8_t *data, uint32_t sizeBytes) + { gs.processGIFPacket(data, sizeBytes); }); + + constexpr uint32_t kGifCh = 0x1000A000u; + constexpr uint32_t kChain = 0x00030000u; + constexpr uint32_t kData = 0x00040000u; + constexpr uint32_t kStrips = 3u; + constexpr uint32_t kBlocksPerStrip = 2u; + constexpr uint32_t kBlockBytes = 16u * 16u * 3u; + constexpr uint32_t kDbp = 0u; + constexpr uint32_t kDbw = 10u; + + uint8_t *rdram = mem.getRDRAM(); + for (uint32_t i = 0; i < kStrips * kBlocksPerStrip * kBlockBytes; ++i) + rdram[kData + i] = static_cast((i * 7u) ^ (i >> 8u)); + + uint32_t chain = kChain; + for (uint32_t strip = 0; strip < kStrips; ++strip) + { + writeDmaTag(rdram, chain, makeDmaTag(6u, 1u, 0u, false)); // CNT: setup + IMAGE GIFtag. + chain += 16u; + writeU64(rdram, chain + 0u, makeGifTag(4u, GIF_FMT_PACKED, 1u, false)); + writeU64(rdram, chain + 8u, 0x0Eull); + chain += 16u; + chain = writeGifAd(rdram, chain, makeBitbltbuf(kDbp, kDbw, GS_PSM_CT24), GS_REG_BITBLTBUF); + chain = writeGifAd(rdram, chain, static_cast(strip * 16u) << 32, GS_REG_TRXPOS); + chain = writeGifAd(rdram, chain, 16ull | (static_cast(16u * kBlocksPerStrip) << 32), GS_REG_TRXREG); + chain = writeGifAd(rdram, chain, 0ull, GS_REG_TRXDIR); + writeU64(rdram, chain + 0u, makeGifTag(static_cast(kBlocksPerStrip * kBlockBytes / 16u), GIF_FMT_IMAGE, 0u, true)); + writeU64(rdram, chain + 8u, 0ull); + chain += 16u; + for (uint32_t block = 0; block < kBlocksPerStrip; ++block) + { + const uint32_t src = kData + (block * kStrips + strip) * kBlockBytes; // raster macroblock order + writeDmaTag(rdram, chain, makeDmaTag(static_cast(kBlockBytes / 16u), 3u, src, false)); + chain += 16u; + } + } + writeDmaTag(rdram, chain, makeDmaTag(0u, 7u, 0u, false)); // END. + + t.IsTrue(mem.writeIORegister(kGifCh + 0x30u, kChain), "write GIF TADR should succeed"); + t.IsTrue(mem.writeIORegister(kGifCh + 0x00u, 0x104u), "write GIF CHCR STR|CHAIN should succeed"); + mem.processPendingTransfers(); + + bool ok = true; + for (uint32_t y = 0; y < 16u * kBlocksPerStrip && ok; ++y) + { + for (uint32_t x = 0; x < 16u * kStrips && ok; ++x) + { + const uint32_t block = (y / 16u) * kStrips + (x / 16u); + const uint32_t src = kData + block * kBlockBytes + ((y % 16u) * 16u + (x % 16u)) * 3u; + const uint32_t dst = GSPSMCT32::addrPSMCT32(kDbp, kDbw, x, y); + for (uint32_t c = 0; c < 3u; ++c) + { + if (mem.getGSVRAM()[dst + c] != rdram[src + c]) + { + ok = false; + t.IsTrue(false, "CT24 strip pixel mismatch at x=" + std::to_string(x) + " y=" + std::to_string(y)); + break; + } + } + } + } + t.IsTrue(ok, "every strip should land at its own DSAX with its own macroblocks"); + }); + tc.Run("VIF1 DMA DIRECT forwards payload to GIF callback and clears channel", [](TestCase &t) { PS2Memory mem; From b963656a9f4c8c8c989998fd8fcbf9776fbc4b63 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:41:36 -0300 Subject: [PATCH 32/91] Complete SPU2 AutoDMA transfers at the 48 kHz playback rate instead of bus speed --- ps2xIOP/src/emulator/core/iop_memory.cpp | 16 +++++++++++++++- ps2xIOP/tests/iop_import_tests.cpp | 23 ++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index 71dfeb924b..7e81d940c1 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -17,6 +17,10 @@ namespace ps2x::iop::detail constexpr int kDmaSio2InIrq = 0x2C; constexpr int kDmaSio2OutIrq = 0x2D; constexpr uint64_t kSio2TransferCycles = 64u; + constexpr uint32_t kSpu2Core0Admas = 0x1F9001B0u; + constexpr uint32_t kSpu2Core1Admas = 0x1F9005B0u; + constexpr uint64_t kIopClockHzForSpu2 = 36'864'000ull; + constexpr uint64_t kSpu2SampleRate = 48'000ull; uint32_t alignUp(uint32_t value, uint32_t alignment) { @@ -294,7 +298,17 @@ namespace ps2x::iop::detail const uint32_t wordsPerBlock = std::max(blockControl & 0xFFFFu, 1u); const uint32_t blockCount = std::max(blockControl >> 16u, 1u); const uint64_t transferWords = static_cast(wordsPerBlock) * blockCount; - queueInterrupt(secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, std::max(transferWords * 2u, 64u)); + // In AutoDMA (ADMAS set for this core) the SPU2 pulls PCM as it plays it: one 32-bit word is one + // 16-bit stereo frame at 48 kHz. Streaming drivers (CRI ADX) wait for this interrupt, so ending it + // at bus speed makes their mixer thread spin instead of blocking between blocks. + const uint32_t admasAddress = secondCore ? kSpu2Core1Admas : kSpu2Core0Admas; + uint32_t admas = 0u; + if (const auto current = m_hardware.find(admasAddress & ~3u); current != m_hardware.end()) + admas = (current->second >> ((admasAddress & 2u) * 8u)) & 0xFFFFu; + const bool autoDma = (admas & (secondCore ? 2u : 1u)) != 0u; + const uint64_t delayCycles = autoDma ? transferWords * (kIopClockHzForSpu2 / kSpu2SampleRate) + : std::max(transferWords * 2u, 64u); + queueInterrupt(secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, delayCycles); } void IopMemory::queueInterrupt(int irq, uint64_t delayCycles) diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index e7e571f7a0..4a90586c62 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -275,6 +275,26 @@ namespace return passed; } + uint64_t startSpu2Dma(IopMemory &memory, bool autoDma) + { + memory.write16(0x1F9001B0u, static_cast(autoDma ? 1u : 0u)); // core 0 ADMAS + memory.write32(0x1F8010C4u, (2u << 16u) | 0x100u); // 2 blocks of 0x100 words + memory.write32(0x1F8010C8u, 0x01000201u); // start, from memory + const auto start = memory.takeDmaStart(); + return start ? start->delayCycles : 0u; + } + + bool testSpu2AutoDmaCompletesAtPlaybackRate() + { + // ADX streaming waits for this interrupt between PCM blocks; finishing it at bus speed made the + // mixer thread spin and starve everything else at its priority. + IopMemory memory; + const uint64_t autoDelay = startSpu2Dma(memory, true); + const uint64_t plainDelay = startSpu2Dma(memory, false); + return expect(autoDelay == 0x200u * 768u, "AutoDMA should complete after 512 frames at 48 kHz") && + expect(plainDelay == 0x200u * 2u, "a plain SPU2 DMA should keep its bus-speed delay"); + } + bool testTimrmanPeriodicCallback() { IopTimrman timrman; @@ -402,7 +422,8 @@ namespace int main() { if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdSearchFile() || - !testTimrmanPeriodicCallback() || !testSio2MemoryCard()) + !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || + !testSpu2AutoDmaCompletesAtPlaybackRate()) return 1; std::cout << "ps2xIOP import tests passed\n"; return 0; From 4cd72825460fc2f6c925043368e60ab2f5f56110 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:41:36 -0300 Subject: [PATCH 33/91] Keep an IOP thread running across emulator slice ends until it blocks, as the IOP kernel never time-slices --- ps2xIOP/src/emulator/core/iop_kernel.cpp | 21 +++++- ps2xIOP/src/emulator/core/iop_kernel.h | 5 ++ ps2xIOP/tests/iop_import_tests.cpp | 91 +++++++++++++++++++++++- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_kernel.cpp b/ps2xIOP/src/emulator/core/iop_kernel.cpp index d7d0887fcb..f21c3e957b 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.cpp +++ b/ps2xIOP/src/emulator/core/iop_kernel.cpp @@ -674,14 +674,24 @@ namespace ps2x::iop::detail IopThread *IopKernel::beginNextReady(uint64_t currentCycle) { IopThread *next = nullptr; + bool resumeCriticalSection = false; for (auto &[id, thread] : m_threads) { if (thread.state == IopThreadState::Delay && thread.wakeCycle <= currentCycle) thread.state = IopThreadState::Ready; - if (thread.state != IopThreadState::Ready) + if (thread.state != IopThreadState::Ready || resumeCriticalSection) continue; + if (thread.sliceEndedWithInterruptsOff) + { + next = &thread; + resumeCriticalSection = true; + continue; + } + // Among equal priorities the interrupted thread goes first; one that merely became ready + // (e.g. its DelayThread expired) must not cut into it. if (next == nullptr || thread.priority < next->priority || - (thread.priority == next->priority && thread.id < next->id)) + (thread.priority == next->priority && + (thread.sliceEnded != next->sliceEnded ? thread.sliceEnded : thread.id < next->id))) next = &thread; } if (next == nullptr) @@ -707,10 +717,17 @@ namespace ps2x::iop::detail void IopKernel::endTimeslice(IopThread &thread, uint32_t returnSentinel) { + thread.sliceEnded = false; + thread.sliceEndedWithInterruptsOff = false; if (thread.cpu.pc == returnSentinel || thread.cpu.stopped) thread.state = IopThreadState::Dormant; else if (thread.state == IopThreadState::Running) + { thread.state = IopThreadState::Ready; + // A yield (e.g. RotateThreadReadyQueue) gives way; running out of emulator budget does not. + thread.sliceEnded = !thread.cpu.yielded; + thread.sliceEndedWithInterruptsOff = thread.sliceEnded && m_memory.interruptControl() == 0u; + } m_currentThread = nullptr; cleanupDeadThreads(); } diff --git a/ps2xIOP/src/emulator/core/iop_kernel.h b/ps2xIOP/src/emulator/core/iop_kernel.h index ea0699f0d8..cf4a3792b4 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.h +++ b/ps2xIOP/src/emulator/core/iop_kernel.h @@ -41,6 +41,11 @@ namespace ps2x::iop::detail uint32_t waitMode = 0; uint32_t waitResultAddress = 0; int wakeupCount = 0; + // The emulator ended this thread's slice although the thread could still run. The IOP kernel does + // not time-slice: such a thread keeps the CPU until it blocks or a higher-priority thread becomes + // ready, and with interrupts suspended (CpuSuspendIntr) not even that can preempt it. + bool sliceEnded = false; + bool sliceEndedWithInterruptsOff = false; }; class IopKernel diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index 4a90586c62..066d1a6c67 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -1,5 +1,6 @@ #include "emulator/core/iop_cpu.h" #include "emulator/core/iop_kernel.h" +#include "emulator/iop_emulator_const.h" #include "emulator/core/iop_memory.h" #include "emulator/imports/iop_cdvd.h" #include "emulator/imports/iop_imports.h" @@ -275,6 +276,92 @@ namespace return passed; } + int startTestThread(IopKernel &kernel, IopMemory &memory, uint32_t descriptor, uint32_t entry, uint32_t priority) + { + memory.write32(descriptor + 0u, 0x02000000u); // TH_C + memory.write32(descriptor + 4u, 0u); + memory.write32(descriptor + 8u, entry); + memory.write32(descriptor + 12u, 0x800u); + memory.write32(descriptor + 16u, priority); + IopCpuState cpu{}; + cpu.gpr[4] = descriptor; + if (!kernel.dispatchThreadImport(4u, cpu, 0u)) // CreateThread + return -1; + const int id = static_cast(cpu.gpr[2]); + cpu = {}; + cpu.gpr[4] = static_cast(id); + if (!kernel.dispatchThreadImport(6u, cpu, 0u)) // StartThread + return -1; + return id; + } + + bool testSliceEndDoesNotHandTheCpuToAnEqualPriorityThread() + { + // CRI's ADX driver has a mixer and a command thread at priority 39. The command thread stops a + // stream by clearing fields the mixer divides by; if it runs when the emulator ends the mixer's + // slice mid-stream, the mixer hits its divide-by-zero break and runs into the exception vector. + IopMemory memory; + IopKernel kernel(memory); + kernel.reset(); + + const int stopper = startTestThread(kernel, memory, 0x3000u, 0x10000u, 39u); + const int mixer = startTestThread(kernel, memory, 0x3040u, 0x20000u, 39u); + if (!expect(stopper > 0 && mixer > stopper, "could not create the two equal-priority threads")) + return false; + + IopThread *running = kernel.beginNextReady(0u); + if (!expect(running != nullptr && running->id == stopper, "the lower-id thread should run first")) + return false; + IopCpuState delay{}; + delay.gpr[4] = 100u; // DelayThread(100 us) + if (!expect(kernel.dispatchThreadImport(33u, delay, 0u), "DelayThread was not handled")) + return false; + running->cpu.yielded = delay.yielded; + kernel.endTimeslice(*running, kThreadReturnSentinel); + + running = kernel.beginNextReady(10u); + if (!expect(running != nullptr && running->id == mixer, "the mixer should run while the stopper delays")) + return false; + kernel.endTimeslice(*running, kThreadReturnSentinel); // emulator budget ran out mid-stream + + running = kernel.beginNextReady(1'000'000u); // the stopper's delay has long expired + if (!expect(running != nullptr && running->id == mixer, + "an equal-priority thread that became ready must not take over at a slice boundary")) + return false; + + // A yield hands the CPU over as usual. + running->cpu.yielded = true; + kernel.endTimeslice(*running, kThreadReturnSentinel); + running = kernel.beginNextReady(1'000'001u); + return expect(running != nullptr && running->id == stopper, "after a yield the waiting thread should run"); + } + + bool testSliceEndInsideCpuSuspendIntrResumesSameThread() + { + IopMemory memory; + IopKernel kernel(memory); + kernel.reset(); + + const int worker = startTestThread(kernel, memory, 0x3000u, 0x10000u, 39u); + IopThread *running = kernel.beginNextReady(0u); + if (!expect(running != nullptr && running->id == worker, "the worker should run")) + return false; + memory.setInterruptControl(0u); // CpuSuspendIntr + kernel.endTimeslice(*running, kThreadReturnSentinel); + + const int urgent = startTestThread(kernel, memory, 0x3040u, 0x20000u, 10u); + running = kernel.beginNextReady(1u); + if (!expect(running != nullptr && running->id == worker, + "nothing may preempt a thread that has interrupts suspended")) + return false; + memory.setInterruptControl(1u); // CpuResumeIntr + kernel.endTimeslice(*running, kThreadReturnSentinel); + + running = kernel.beginNextReady(2u); + return expect(running != nullptr && running->id == urgent, + "once interrupts are back on, the higher-priority thread must run"); + } + uint64_t startSpu2Dma(IopMemory &memory, bool autoDma) { memory.write16(0x1F9001B0u, static_cast(autoDma ? 1u : 0u)); // core 0 ADMAS @@ -422,8 +509,8 @@ namespace int main() { if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdSearchFile() || - !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || - !testSpu2AutoDmaCompletesAtPlaybackRate()) + !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || !testSliceEndDoesNotHandTheCpuToAnEqualPriorityThread() || + !testSliceEndInsideCpuSuspendIntrResumesSameThread() || !testSpu2AutoDmaCompletesAtPlaybackRate()) return 1; std::cout << "ps2xIOP import tests passed\n"; return 0; From 25f16ad0da37d6684ecdbbcab766ae5d8f3f56f6 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:58:21 -0300 Subject: [PATCH 34/91] Find a jump table's bound check beyond 30 instructions while the index register is unchanged --- ps2xRecomp/src/lib/control_flow_analyzer.cpp | 71 +++++++++- ps2xTest/src/code_generator_tests.cpp | 130 +++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/ps2xRecomp/src/lib/control_flow_analyzer.cpp b/ps2xRecomp/src/lib/control_flow_analyzer.cpp index ed7578dd19..f493863ccf 100644 --- a/ps2xRecomp/src/lib/control_flow_analyzer.cpp +++ b/ps2xRecomp/src/lib/control_flow_analyzer.cpp @@ -9,6 +9,51 @@ namespace ps2recomp { + namespace + { + constexpr int kJumpTableBoundNearWindow = 30; + constexpr int kJumpTableBoundSearchLimit = 256; + + // Destination GPR of the integer instructions that can sit between a jump-table bound check and + // the table load; anything else is treated as not touching the index register. + bool writesGpr(const Instruction &inst, uint32_t reg) + { + if (reg == 0u || !inst.modificationInfo.modifiesGPR) + { + return false; + } + switch (inst.opcode) + { + case OPCODE_SPECIAL: + case OPCODE_MMI: + return inst.rd == reg; + case OPCODE_JAL: + return reg == 31u; + default: + return inst.rt == reg; + } + } + + // The delay slot of a branch-likely only runs when the branch is taken, so it cannot change a + // register on the fall-through path that reaches the jump-table load. + bool isBranchLikely(const Instruction &inst) + { + switch (inst.opcode) + { + case OPCODE_BEQL: + case OPCODE_BNEL: + case OPCODE_BLEZL: + case OPCODE_BGTZL: + return true; + case OPCODE_REGIMM: + return inst.rt == REGIMM_BLTZL || inst.rt == REGIMM_BGEZL || + inst.rt == REGIMM_BLTZALL || inst.rt == REGIMM_BGEZALL; + default: + return false; + } + } + } + ControlFlowAnalyzer::ControlFlowAnalyzer( const std::vector
§ions, const std::unordered_map> &configuredJumpTableTargetsByAddress, @@ -312,12 +357,14 @@ namespace ps2recomp } uint32_t unshiftedIndexReg = 0; + int sllIndex = -1; for (int i = adduIndex - 1; i >= 0 && i >= adduIndex - 10; --i) { const auto &inst = instructions[i]; if (inst.opcode == OPCODE_SPECIAL && inst.function == SPECIAL_SLL && (inst.rd == tableBaseReg || inst.rd == indexReg)) { unshiftedIndexReg = inst.rt; + sllIndex = i; break; } } @@ -325,7 +372,24 @@ namespace ps2recomp uint32_t numCases = 0; if (unshiftedIndexReg != 0) { - for (int i = adduIndex - 1; i >= 0 && i >= adduIndex - 30; --i) + for (int i = adduIndex - 1; i >= 0 && i >= adduIndex - kJumpTableBoundNearWindow; --i) + { + const auto &inst = instructions[i]; + if ((inst.opcode == OPCODE_SLTIU || inst.opcode == OPCODE_SLTI) && inst.rs == unshiftedIndexReg) + { + numCases = inst.immediate; + break; + } + } + } + if (numCases == 0 && sllIndex > 0) + { + // GCC can hoist the bound check far above the table load when it schedules a + // long block in between. Past the near window, keep walking back only until + // an instruction writes the index register, so a compare on an older value is + // never taken as the bound. Start at the SLL: the index register is often + // reused for the table base after it. + for (int i = sllIndex - 1; i >= 0 && i >= sllIndex - kJumpTableBoundSearchLimit; --i) { const auto &inst = instructions[i]; if ((inst.opcode == OPCODE_SLTIU || inst.opcode == OPCODE_SLTI) && inst.rs == unshiftedIndexReg) @@ -333,6 +397,11 @@ namespace ps2recomp numCases = inst.immediate; break; } + if (writesGpr(inst, unshiftedIndexReg) && + !(i > 0 && isBranchLikely(instructions[i - 1]))) + { + break; + } } } diff --git a/ps2xTest/src/code_generator_tests.cpp b/ps2xTest/src/code_generator_tests.cpp index 25c7628bbc..208249fbf4 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -1850,6 +1850,136 @@ void register_code_generator_tests() "unresolved non-RA JR should use indirect-jump diagnostics"); }); + tc.Run("JR jump table bound check far above the table load still resolves", [](TestCase &t) { + // Mana Khemia 0x1C1D90: SLTIU on the case index sits ~50 instructions above the table load, + // past a long scheduled prologue. Missing it left the JR unresolved, and a case target in the + // next function had no entry, so the game stopped when it took that case. + constexpr uint32_t tableAddress = 0x00200000u; + constexpr uint32_t start = 0x1700u; + constexpr uint32_t prologueLength = 60u; + + Function func; + func.name = "jr_far_bound_jump_table"; + func.start = start; + func.isRecompiled = true; + func.isStub = false; + + std::vector instructions; + uint32_t address = start; + auto push = [&](Instruction inst) + { + inst.address = address; + address += 4u; + instructions.push_back(inst); + }; + + Instruction index{}; // addiu t4, a1, -4 + index.opcode = OPCODE_ADDIU; + index.rs = 5; + index.rt = 12; + index.immediate = 0xFFFCu; + index.simmediate = 0xFFFFFFFCu; + index.modificationInfo.modifiesGPR = true; + push(index); + + Instruction bound{}; // sltiu t5, t4, 3 + bound.opcode = OPCODE_SLTIU; + bound.rs = 12; + bound.rt = 13; + bound.immediate = 3u; + bound.simmediate = 3u; + bound.modificationInfo.modifiesGPR = true; + push(bound); + + for (uint32_t i = 0; i < prologueLength; ++i) + push(makeNop(0)); + + Instruction lui{}; // lui t6, hi(table) + lui.opcode = OPCODE_LUI; + lui.rt = 14; + lui.immediate = static_cast(tableAddress >> 16); + push(lui); + + Instruction sll{}; // sll t7, t4, 2 + sll.opcode = OPCODE_SPECIAL; + sll.function = SPECIAL_SLL; + sll.rd = 15; + sll.rt = 12; + sll.sa = 2; + push(sll); + + Instruction addiu{}; // addiu t6, t6, lo(table) + addiu.opcode = OPCODE_ADDIU; + addiu.rs = 14; + addiu.rt = 14; + addiu.immediate = static_cast(tableAddress & 0xFFFFu); + addiu.simmediate = addiu.immediate; + push(addiu); + + Instruction addu{}; // addu t7, t7, t6 + addu.opcode = OPCODE_SPECIAL; + addu.function = SPECIAL_ADDU; + addu.rs = 15; + addu.rt = 14; + addu.rd = 15; + push(addu); + + Instruction lw{}; // lw t7, 0(t7) + lw.opcode = OPCODE_LW; + lw.rs = 15; + lw.rt = 15; + push(lw); + + const uint32_t jrAddress = address; + push(makeJr(0, 15)); + push(makeNop(0)); + const uint32_t case0 = address; + push(makeNop(0)); + const uint32_t case1 = address; + push(makeNop(0)); + const uint32_t case2 = address; + push(makeNop(0)); + func.end = address; + + std::vector table(12u); + const uint32_t targets[3] = {case0, case1, case2}; + std::memcpy(table.data(), targets, sizeof(targets)); + Section rodata{}; + rodata.name = ".rodata"; + rodata.address = tableAddress; + rodata.size = static_cast(table.size()); + rodata.isData = true; + rodata.isReadOnly = true; + rodata.data = table.data(); + + const std::vector symbols; + const std::vector
sections{rodata}; // CodeGenerator keeps a reference to these + CodeGenerator gen(symbols, sections); + CodeGenerator::AnalysisResult analysis = gen.collectInternalBranchTargets(func, instructions); + t.IsTrue(analysis.jumpTableTargets.contains(jrAddress), + "a bound check above a long prologue should still size the jump table"); + if (analysis.jumpTableTargets.contains(jrAddress)) + { + t.Equals(analysis.jumpTableTargets.at(jrAddress).size(), static_cast(3), + "all three table targets should be resolved"); + } + + // Once the index register is overwritten after the compare, that bound no longer applies. + std::vector clobbered = instructions; + Instruction overwrite{}; // addiu t4, zero, 9 + overwrite.address = clobbered[2].address; + overwrite.opcode = OPCODE_ADDIU; + overwrite.rs = 0; + overwrite.rt = 12; + overwrite.immediate = 9u; + overwrite.simmediate = 9u; + overwrite.modificationInfo.modifiesGPR = true; + clobbered[2] = overwrite; + CodeGenerator::AnalysisResult clobberedAnalysis = gen.collectInternalBranchTargets(func, clobbered); + t.IsFalse(clobberedAnalysis.jumpTableTargets.contains(jrAddress), + "a compare on an overwritten index must not be taken as the table bound"); + }); + tc.Run("configured jump table addresses drive JR dispatch targets", [](TestCase &t) { Function func; func.name = "jr_configured_jump_table"; From d5162ec391a86337ff6fe55d2aeee8ff6c132157 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:16:21 -0300 Subject: [PATCH 35/91] Read the SQRT.S operand from ft as the R5900 encodes it --- ps2xRecomp/src/lib/fpu_translator.cpp | 3 ++- ps2xTest/src/code_generator_tests.cpp | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ps2xRecomp/src/lib/fpu_translator.cpp b/ps2xRecomp/src/lib/fpu_translator.cpp index 435db3aa93..ab25f3dab4 100644 --- a/ps2xRecomp/src/lib/fpu_translator.cpp +++ b/ps2xRecomp/src/lib/fpu_translator.cpp @@ -58,7 +58,8 @@ namespace ps2recomp "ctx->f[{}] = FPU_DIV_S(ctx->f[{}], ctx->f[{}]); }}", ft, fd, fs, ft); case COP1_S_SQRT: - return fmt::format("ctx->f[{}] = FPU_SQRT_S(ctx->f[{}]);", fd, fs); + // The R5900 encodes SQRT.S as fd, ft (fs is zero), unlike MIPS-I's fd, fs. + return fmt::format("ctx->f[{}] = FPU_SQRT_S(ctx->f[{}]);", fd, ft); case COP1_S_ABS: return fmt::format("ctx->f[{}] = FPU_ABS_S(ctx->f[{}]);", fd, fs); case COP1_S_MOV: diff --git a/ps2xTest/src/code_generator_tests.cpp b/ps2xTest/src/code_generator_tests.cpp index 208249fbf4..b2daaee325 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -1020,6 +1020,24 @@ void register_code_generator_tests() t.IsTrue(ctc1Code.find("ignored") == std::string::npos, "CTC1 FCR31 should not be ignored"); }); + tc.Run("SQRT.S reads its operand from ft", [](TestCase &t) { + CodeGenerator gen({}, {}); + + // 0x46160504: sqrt.s $f20, $f22 as the EE encodes it, with the fs field zero. + Instruction sqrt{}; + sqrt.opcode = OPCODE_COP1; + sqrt.rs = COP1_S; + sqrt.rt = 22; + sqrt.rd = 0; + sqrt.sa = 20; + sqrt.function = COP1_S_SQRT; + + std::string code = gen.translateInstruction(sqrt); + printGeneratedCode("SQRT.S reads its operand from ft", code); + t.IsTrue(code.find("ctx->f[20] = FPU_SQRT_S(ctx->f[22])") != std::string::npos, + "SQRT.S should take the square root of ft into fd"); + }); + tc.Run("VU CFC2/CTC2 access VI registers directly", [](TestCase& t) { CodeGenerator gen({}, {}); From 3629b9d9d70ce589137583f3e1693a51e5fe94fc Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:16:48 -0300 Subject: [PATCH 36/91] Let IOP time pass while an RPC handler waits on an event flag, semaphore or DelayThread --- ps2xIOP/src/emulator/core/iop_kernel.cpp | 12 +++++++ ps2xIOP/src/emulator/core/iop_kernel.h | 5 +++ ps2xIOP/src/emulator/iop_emulator.cpp | 36 +++++++++++++++++++ ps2xIOP/tests/iop_emulator_tests.cpp | 45 ++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_kernel.cpp b/ps2xIOP/src/emulator/core/iop_kernel.cpp index f21c3e957b..e383fe225f 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.cpp +++ b/ps2xIOP/src/emulator/core/iop_kernel.cpp @@ -473,6 +473,18 @@ namespace ps2x::iop::detail } } + bool IopKernel::eventFlagReady(int id, uint32_t bits, uint32_t mode) const + { + const auto event = m_eventFlags.find(id); + return event == m_eventFlags.end() || bits == 0u || eventSatisfied(event->second, bits, mode); + } + + bool IopKernel::semaphoreReady(int id) const + { + const auto semaphore = m_semaphores.find(id); + return semaphore == m_semaphores.end() || semaphore->second.current > 0; + } + bool IopKernel::eventSatisfied(const EventFlag &event, uint32_t bits, uint32_t mode) { if (bits == 0u) diff --git a/ps2xIOP/src/emulator/core/iop_kernel.h b/ps2xIOP/src/emulator/core/iop_kernel.h index cf4a3792b4..9a7499fb10 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.h +++ b/ps2xIOP/src/emulator/core/iop_kernel.h @@ -72,6 +72,11 @@ namespace ps2x::iop::detail void terminateThreadsInRange(uint32_t base, uint32_t size); [[nodiscard]] size_t threadCount() const noexcept { return m_threads.size(); } + [[nodiscard]] bool inThread() const noexcept { return m_currentThread != nullptr; } + // Whether a WaitEventFlag / WaitSema issued now could return without blocking (unknown IDs count as + // ready so the regular import reports the error). + [[nodiscard]] bool eventFlagReady(int id, uint32_t bits, uint32_t mode) const; + [[nodiscard]] bool semaphoreReady(int id) const; private: struct Semaphore diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index a5cfde19f6..ec4ea4375a 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -18,6 +18,7 @@ #include "iop_emulator_const.h" #include +#include #include #include #include @@ -282,6 +283,9 @@ namespace ps2x::iop::detail if (iequals(call.library, "loadcore") && loadcore.dispatchImport(call.ordinal, cpu)) return ImportDisposition::Handled; + if (!kernel.inThread() && callDepth > 0u) + waitOutsideThread(call.library, call.ordinal, cpu); + if (iequals(call.library, "thbase") || iequals(call.library, "threadman")) { return kernel.dispatchThreadImport(call.ordinal, cpu, totalCycles) @@ -611,6 +615,38 @@ namespace ps2x::iop::detail } } + // RPC handlers run synchronously, outside any IOP thread, so a blocking wait cannot park them. The + // real server thread would sleep while the rest of the IOP keeps running, so do exactly that here: + // run other threads, timers and interrupts until the wait can be satisfied. + void waitOutsideThread(std::string_view library, uint16_t ordinal, const CpuState &cpu) + { + constexpr uint64_t kSliceCycles = 2048u; + constexpr uint64_t kGiveUpCycles = kIopClockHz; // a second of IOP time; then fall back to not blocking + std::function ready; + uint64_t wakeCycle = 0u; + if (iequals(library, "thevent") && ordinal == 10u) // WaitEventFlag + ready = [&, id = static_cast(cpu.gpr[4]), bits = cpu.gpr[5], mode = cpu.gpr[6]]() + { return kernel.eventFlagReady(id, bits, mode); }; + else if (iequals(library, "thsemap") && ordinal == 8u) // WaitSema + ready = [&, id = static_cast(cpu.gpr[4])]() + { return kernel.semaphoreReady(id); }; + else if ((iequals(library, "thbase") || iequals(library, "threadman")) && ordinal == 33u) // DelayThread + { + wakeCycle = totalCycles + (static_cast(cpu.gpr[4]) * kIopClockHz + 999'999ull) / 1'000'000ull; + ready = [&]() + { return totalCycles >= wakeCycle; }; + } + else + return; + + // The waiting handler's instruction budget must not be spent by the code that runs meanwhile. + const uint64_t instructionsBefore = totalInstructions; + const uint64_t giveUp = totalCycles + kGiveUpCycles; + while (!ready() && totalCycles < giveUp) + runCycles(wakeCycle != 0u ? std::min(kSliceCycles, std::max(wakeCycle - totalCycles, 1u)) : kSliceCycles); + totalInstructions = instructionsBefore; + } + // Disc modules are loaded from anonymous buffers; their export table names what they are. std::string exportedLibraryName(const Module &module) const { diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index 5ad99a0d1b..eab9bde44d 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -184,7 +184,8 @@ namespace void writeRpcServerIrx(TestHost &host, uint32_t address, - uint32_t sid = 0xF00DCAFEu) + uint32_t sid = 0xF00DCAFEu, + bool importDelayThread = false) { constexpr uint32_t codeOffset = 0x100u; constexpr uint32_t loadAddress = 0x00010000u; @@ -241,13 +242,25 @@ namespace 0x00000000u, 0x00000000u, }; + // Optional second table at +0x90: thbase DelayThread, whose stub lands at 0x100A4. + const uint32_t threadImportTable[] = { + 0x41E00000u, + 0x00000000u, + 0x00000101u, + 0x61626874u, // "thba" + 0x00006573u, // "se" + 0x03E00008u, // jr ra + 0x24000021u, // addiu zero, zero, 33 (DelayThread) + 0x00000000u, + 0x00000000u, + }; ProgramHeader program{}; program.type = 1u; program.offset = codeOffset; program.vaddr = loadAddress; program.paddr = loadAddress; - program.filesz = 0xA0u; + program.filesz = importDelayThread ? 0xC0u : 0xA0u; program.memsz = 0x500u; program.flags = 7u; program.align = 4u; @@ -258,6 +271,9 @@ namespace std::memcpy(host.guest.data() + address + codeOffset, code, sizeof(code)); std::memcpy(host.guest.data() + address + codeOffset + importTableOffset, importTable, sizeof(importTable)); + if (importDelayThread) + std::memcpy(host.guest.data() + address + codeOffset + 0x90u, + threadImportTable, sizeof(threadImportTable)); } void writeRelocatableRpcServerIrx(TestHost &host, uint32_t address) @@ -992,6 +1008,31 @@ int main() if (!expect(iop.debugSnapshot().emulatorInstructions > soundInstructionsBefore, "Sound RPC did not execute the physical IOP handler")) return 1; + // RPC handlers run outside any IOP thread; a DelayThread there must still let IOP time pass. + iop.reset(); + writeRpcServerIrx(host, 0x100u, lotrSoundSid, true); + if (!expect(iop.loadModuleBuffer(0x100u).startResult == 0, "Delaying RPC server did not start")) return 1; + const uint32_t delayingHandler[] = { + 0x27BDFFE0u, // addiu sp, sp, -0x20 + 0xAFBF001Cu, // sw ra, 0x1c(sp) + 0x340403E8u, // ori a0, zero, 1000 (us) + 0x0C004029u, // jal 0x100A4 (DelayThread stub) + 0x00000000u, // nop + 0x8FBF001Cu, // lw ra, 0x1c(sp) + 0x3C020001u, // lui v0, 1 + 0x34420400u, // ori v0, v0, 0x400 + 0x03E00008u, // jr ra + 0x27BD0020u, // addiu sp, sp, 0x20 + }; + if (!expect(iop.writeMemory(0x00010300u, delayingHandler, sizeof(delayingHandler)), + "Could not install the delaying RPC handler")) return 1; + RpcRequest delayRequest{}; + delayRequest.sid = lotrSoundSid; + const uint64_t cyclesBeforeDelay = iop.debugSnapshot().emulatorCycles; + if (!expect(iop.handleRpc(delayRequest).handled, "Delaying RPC was not handled")) return 1; + if (!expect(iop.debugSnapshot().emulatorCycles - cyclesBeforeDelay >= 36864u, + "DelayThread(1000) in an RPC handler returned before 1 ms of IOP time passed")) return 1; + constexpr uint32_t relocatableRpcSid = 0xA11CE001u; writeRelocatableRpcServerIrx(host, 0x100u); const ModuleLoadResult relocatableRpcModule = iop.loadModuleBuffer(0x100u); From 6a4f364299ce0ec118f9a8b8b1c4acdb35cfcbb3 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:16:48 -0300 Subject: [PATCH 37/91] Emulate SPU2 voices, sound RAM, DMA and AutoDMA on the IOP and play the mix on the host --- ps2xIOP/CMakeLists.txt | 6 + ps2xIOP/include/ps2x/iop/iop_host.h | 7 + ps2xIOP/src/emulator/core/iop_memory.cpp | 91 +++- ps2xIOP/src/emulator/core/iop_memory.h | 7 + ps2xIOP/src/emulator/core/iop_spu2.cpp | 667 +++++++++++++++++++++++ ps2xIOP/src/emulator/core/iop_spu2.h | 148 +++++ ps2xIOP/src/emulator/iop_emulator.cpp | 27 + ps2xIOP/tests/iop_compat_test_support.h | 2 + ps2xIOP/tests/iop_import_tests.cpp | 17 +- ps2xIOP/tests/iop_spu2_tests.cpp | 246 +++++++++ ps2xRuntime/CMakeLists.txt | 1 + ps2xRuntime/src/lib/ps2_audio_stream.cpp | 158 ++++++ ps2xRuntime/src/lib/ps2_audio_stream.h | 53 ++ ps2xRuntime/src/lib/ps2_iop_host.cpp | 5 + ps2xRuntime/src/lib/ps2_iop_host.h | 3 + ps2xTest/CMakeLists.txt | 1 + ps2xTest/src/main.cpp | 2 + ps2xTest/src/ps2_audio_stream_tests.cpp | 83 +++ 18 files changed, 1493 insertions(+), 31 deletions(-) create mode 100644 ps2xIOP/src/emulator/core/iop_spu2.cpp create mode 100644 ps2xIOP/src/emulator/core/iop_spu2.h create mode 100644 ps2xIOP/tests/iop_spu2_tests.cpp create mode 100644 ps2xRuntime/src/lib/ps2_audio_stream.cpp create mode 100644 ps2xRuntime/src/lib/ps2_audio_stream.h create mode 100644 ps2xTest/src/ps2_audio_stream_tests.cpp diff --git a/ps2xIOP/CMakeLists.txt b/ps2xIOP/CMakeLists.txt index 848a46e65f..543b6b123a 100644 --- a/ps2xIOP/CMakeLists.txt +++ b/ps2xIOP/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(ps2_iop STATIC src/emulator/core/iop_kernel.cpp src/emulator/core/iop_memory.cpp src/emulator/core/iop_sio2.cpp + src/emulator/core/iop_spu2.cpp src/emulator/services/iop_module_loader.cpp src/emulator/services/iop_rpc.cpp src/emulator/imports/iop_cdvd.cpp @@ -63,6 +64,11 @@ if(PS2X_IOP_BUILD_TESTS) 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) + add_executable(ps2_iop_spu2_tests tests/iop_spu2_tests.cpp) + target_link_libraries(ps2_iop_spu2_tests PRIVATE ps2_iop) + target_include_directories(ps2_iop_spu2_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + add_test(NAME ps2_iop_spu2_tests COMMAND ps2_iop_spu2_tests) + endif() install(TARGETS ps2_iop diff --git a/ps2xIOP/include/ps2x/iop/iop_host.h b/ps2xIOP/include/ps2x/iop/iop_host.h index 1eceb08348..da2bcbf281 100644 --- a/ps2xIOP/include/ps2x/iop/iop_host.h +++ b/ps2xIOP/include/ps2x/iop/iop_host.h @@ -96,6 +96,13 @@ namespace ps2x::iop virtual void audioCommand(uint32_t sid, uint32_t function, GuestBuffer send, GuestBuffer receive) = 0; + // Mixed SPU2 output: interleaved stereo 16-bit frames at 48 kHz, produced in emulated time. + virtual void audioOutput(const int16_t *samples, size_t frames) + { + (void)samples; + (void)frames; + } + virtual std::string hostPath(HostPathKind kind) const = 0; virtual std::string translateGuestPath(std::string_view path) const = 0; virtual uint64_t openHostFile(std::string_view path) = 0; diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index 7e81d940c1..dffc137248 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -17,10 +17,6 @@ namespace ps2x::iop::detail constexpr int kDmaSio2InIrq = 0x2C; constexpr int kDmaSio2OutIrq = 0x2D; constexpr uint64_t kSio2TransferCycles = 64u; - constexpr uint32_t kSpu2Core0Admas = 0x1F9001B0u; - constexpr uint32_t kSpu2Core1Admas = 0x1F9005B0u; - constexpr uint64_t kIopClockHzForSpu2 = 36'864'000ull; - constexpr uint64_t kSpu2SampleRate = 48'000ull; uint32_t alignUp(uint32_t value, uint32_t alignment) { @@ -29,7 +25,7 @@ namespace ps2x::iop::detail } IopMemory::IopMemory() - : m_ram(RamSize), m_owned(RamSize), m_scratch(ScratchSize) + : m_ram(RamSize), m_owned(RamSize), m_scratch(ScratchSize), m_spu2(m_ram) { reset(); } @@ -46,6 +42,7 @@ namespace ps2x::iop::detail m_interruptControl = 1; m_dmaStarts.clear(); m_sio2.reset(); + m_spu2.reset(); } uint32_t IopMemory::physicalAddress(uint32_t address) noexcept @@ -62,6 +59,8 @@ namespace ps2x::iop::detail return m_scratch[phys - ScratchBase]; if (phys == IopSio2::kDataOut) return m_sio2.readData(); + if (IopSpu2::contains(phys)) + return static_cast(m_spu2.read16(phys & ~1u) >> ((phys & 1u) * 8u)); const uint32_t value = readHardware32(phys & ~3u); return static_cast(value >> ((phys & 3u) * 8u)); } @@ -75,6 +74,8 @@ namespace ps2x::iop::detail std::memcpy(&value, m_ram.data() + phys, sizeof(value)); return value; } + if ((phys & 1u) == 0u && IopSpu2::contains(phys)) + return m_spu2.read16(phys); return static_cast(read8(address) | (static_cast(read8(address + 1u)) << 8u)); } @@ -93,6 +94,8 @@ namespace ps2x::iop::detail std::memcpy(&value, m_scratch.data() + (phys - ScratchBase), sizeof(value)); return value; } + if ((phys & 3u) == 0u && IopSpu2::contains(phys)) + return m_spu2.read16(phys) | (static_cast(m_spu2.read16(phys + 2u)) << 16u); if ((phys & 3u) == 0u && isHardwareAddress(phys)) return readHardware32(phys); @@ -121,6 +124,13 @@ namespace ps2x::iop::detail m_sio2.writeData(value); return; } + if (IopSpu2::contains(phys)) + { + const uint32_t shift = (phys & 1u) * 8u; + const uint16_t current = m_spu2.read16(phys & ~1u); + m_spu2.write16(phys & ~1u, static_cast((current & ~(0xFFu << shift)) | (static_cast(value) << shift))); + return; + } const uint32_t aligned = phys & ~3u; uint32_t current = readHardware32(aligned); const uint32_t shift = (phys & 3u) * 8u; @@ -137,6 +147,11 @@ namespace ps2x::iop::detail markOwned(phys, sizeof(value)); return; } + if ((phys & 1u) == 0u && IopSpu2::contains(phys)) + { + m_spu2.write16(phys, value); + return; + } write8(address, static_cast(value)); write8(address + 1u, static_cast(value >> 8u)); } @@ -155,6 +170,12 @@ namespace ps2x::iop::detail std::memcpy(m_scratch.data() + (phys - ScratchBase), &value, sizeof(value)); return; } + if ((phys & 3u) == 0u && IopSpu2::contains(phys)) + { + m_spu2.write16(phys, static_cast(value)); + m_spu2.write16(phys + 2u, static_cast(value >> 16u)); + return; + } if ((phys & 3u) == 0u) { writeHardware32(phys, value); @@ -280,35 +301,51 @@ namespace ps2x::iop::detail return; const bool secondCore = address == kDmaSpu1Chcr; - m_hardware[address] = value & ~kDmaStart; - - const uint32_t statusAddress = 0x1F900344u + (secondCore ? 0x400u : 0u); - const uint32_t alignedStatus = statusAddress & ~3u; - const uint32_t shift = (statusAddress & 2u) * 8u; - uint32_t status = 0u; - if (const auto current = m_hardware.find(alignedStatus); current != m_hardware.end()) - status = current->second; - status |= 0x80u << shift; - m_hardware[alignedStatus] = status; - + const uint32_t core = secondCore ? 1u : 0u; const uint32_t blockControlAddress = address - sizeof(uint32_t); + const uint32_t madrAddress = address - 2u * sizeof(uint32_t); uint32_t blockControl = 0u; if (const auto current = m_hardware.find(blockControlAddress); current != m_hardware.end()) blockControl = current->second; + uint32_t madr = 0u; + if (const auto current = m_hardware.find(madrAddress); current != m_hardware.end()) + madr = current->second; const uint32_t wordsPerBlock = std::max(blockControl & 0xFFFFu, 1u); const uint32_t blockCount = std::max(blockControl >> 16u, 1u); const uint64_t transferWords = static_cast(wordsPerBlock) * blockCount; - // In AutoDMA (ADMAS set for this core) the SPU2 pulls PCM as it plays it: one 32-bit word is one - // 16-bit stereo frame at 48 kHz. Streaming drivers (CRI ADX) wait for this interrupt, so ending it - // at bus speed makes their mixer thread spin instead of blocking between blocks. - const uint32_t admasAddress = secondCore ? kSpu2Core1Admas : kSpu2Core0Admas; - uint32_t admas = 0u; - if (const auto current = m_hardware.find(admasAddress & ~3u); current != m_hardware.end()) - admas = (current->second >> ((admasAddress & 2u) * 8u)) & 0xFFFFu; - const bool autoDma = (admas & (secondCore ? 2u : 1u)) != 0u; - const uint64_t delayCycles = autoDma ? transferWords * (kIopClockHzForSpu2 / kSpu2SampleRate) - : std::max(transferWords * 2u, 64u); - queueInterrupt(secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, delayCycles); + const bool toSpu = (value & 1u) != 0u; + if (!m_spu2.startDma(core, madr, static_cast(transferWords * 4u), toSpu)) + return; // AutoDMA keeps CHCR busy until playback has drained it + + m_hardware[address] = value & ~kDmaStart; + queueInterrupt(secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, std::max(transferWords * 2u, 64u)); + } + + void IopMemory::mixSpu2(uint32_t frames, std::vector &out) + { + m_spu2.generate(frames, out); + for (uint32_t core = 0; core < 2u; ++core) + { + const uint32_t chcr = core == 0u ? kDmaSpu0Chcr : kDmaSpu1Chcr; + const auto control = m_hardware.find(chcr); + if (control == m_hardware.end() || (control->second & kDmaStart) == 0u) + continue; + // libsd reports AutoDMA progress from MADR. + m_hardware[chcr - 2u * sizeof(uint32_t)] = m_spu2.autoDmaAddress(core); + } + const uint32_t events = m_spu2.takeEvents(); + if ((events & IopSpu2::EventIrq) != 0u) + queueInterrupt(IopSpu2::kIrq, 0u); + if ((events & IopSpu2::EventDmaDone0) != 0u) + { + m_hardware[kDmaSpu0Chcr] &= ~kDmaStart; + queueInterrupt(kDmaSpu0Irq, 0u); + } + if ((events & IopSpu2::EventDmaDone1) != 0u) + { + m_hardware[kDmaSpu1Chcr] &= ~kDmaStart; + queueInterrupt(kDmaSpu1Irq, 0u); + } } void IopMemory::queueInterrupt(int irq, uint64_t delayCycles) diff --git a/ps2xIOP/src/emulator/core/iop_memory.h b/ps2xIOP/src/emulator/core/iop_memory.h index ae36d40a1d..370dad2d34 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.h +++ b/ps2xIOP/src/emulator/core/iop_memory.h @@ -1,6 +1,7 @@ #pragma once #include "iop_sio2.h" +#include "iop_spu2.h" #include #include @@ -134,6 +135,10 @@ namespace ps2x::iop::detail [[nodiscard]] std::optional takeDmaStart() noexcept; [[nodiscard]] bool hasDmaStart() const noexcept { return !m_dmaStarts.empty(); } + // Mixes SPU2 output in emulated time and queues the IRQs and AutoDMA completions it caused. + void mixSpu2(uint32_t frames, std::vector &out); + [[nodiscard]] uint32_t spu2FramesUntilBlock() const noexcept { return m_spu2.framesUntilBlock(); } + [[nodiscard]] IopSpu2 &spu2() noexcept { return m_spu2; } // Attaches a raw memory card image to a SIO2 port; a missing file is created blank on first access. bool attachMemoryCard(size_t port, const std::string &path); [[nodiscard]] std::span ram() const noexcept { return m_ram; } @@ -165,5 +170,7 @@ namespace ps2x::iop::detail std::deque m_dmaStarts; // SIO2 data reads pop its FIFO, so the controller changes even on const reads. mutable IopSio2 m_sio2; + // IRQINFO and the STD data port change state on read. + mutable IopSpu2 m_spu2; }; } diff --git a/ps2xIOP/src/emulator/core/iop_spu2.cpp b/ps2xIOP/src/emulator/core/iop_spu2.cpp new file mode 100644 index 0000000000..95995c5816 --- /dev/null +++ b/ps2xIOP/src/emulator/core/iop_spu2.cpp @@ -0,0 +1,667 @@ +#include "iop_spu2.h" + +#include +#include + +namespace ps2x::iop::detail +{ + namespace + { + constexpr uint32_t kRamMask = IopSpu2::kRamWords - 1u; + + // Core-relative register offsets (bytes). + constexpr uint32_t kRegPmon = 0x180u; + constexpr uint32_t kRegVmixL = 0x188u; + constexpr uint32_t kRegVmixR = 0x190u; + constexpr uint32_t kRegMmix = 0x198u; + constexpr uint32_t kRegAttr = 0x19Au; + constexpr uint32_t kRegIrqaHi = 0x19Cu; + constexpr uint32_t kRegIrqaLo = 0x19Eu; + constexpr uint32_t kRegKonLo = 0x1A0u; + constexpr uint32_t kRegKonHi = 0x1A2u; + constexpr uint32_t kRegKoffLo = 0x1A4u; + constexpr uint32_t kRegKoffHi = 0x1A6u; + constexpr uint32_t kRegTsaHi = 0x1A8u; + constexpr uint32_t kRegTsaLo = 0x1AAu; + constexpr uint32_t kRegStd = 0x1ACu; + constexpr uint32_t kRegAdmas = 0x1B0u; + constexpr uint32_t kRegVoiceAddr = 0x1C0u; + constexpr uint32_t kRegVoiceAddrEnd = kRegVoiceAddr + IopSpu2::kVoicesPerCore * 12u; + constexpr uint32_t kRegEndxLo = 0x340u; + constexpr uint32_t kRegEndxHi = 0x342u; + constexpr uint32_t kRegStatx = 0x344u; + + // Global block at 0x760: per-core volumes 0x28 bytes apart, then SPDIF/IRQ info. + constexpr uint32_t kGlobalBase = 0x760u; + constexpr uint32_t kCoreVolumeStride = 0x28u; + constexpr uint32_t kRegMvolL = 0x00u; + constexpr uint32_t kRegMvolR = 0x02u; + constexpr uint32_t kRegAvolL = 0x08u; + constexpr uint32_t kRegAvolR = 0x0Au; + constexpr uint32_t kRegBvolL = 0x0Cu; + constexpr uint32_t kRegBvolR = 0x0Eu; + constexpr uint32_t kRegIrqInfo = 0x7C2u; + + constexpr uint16_t kAttrIrqEnable = 0x0040u; + constexpr uint16_t kAttrCoreEnable = 0x8000u; + constexpr uint16_t kStatxDmaReady = 0x0080u; + + constexpr uint8_t kFlagLoopEnd = 0x01u; + constexpr uint8_t kFlagLoopRepeat = 0x02u; + constexpr uint8_t kFlagLoopStart = 0x04u; + + constexpr int32_t kFilterPositive[5] = {0, 60, 115, 98, 122}; + constexpr int32_t kFilterNegative[5] = {0, 0, -52, -55, -60}; + + int32_t clamp16(int32_t value) + { + return std::clamp(value, -0x8000, 0x7FFF); + } + + // Fixed volumes are 15-bit signed halves; sweeps are approximated by where they settle. + int32_t decodeVolume(uint16_t value) + { + if ((value & 0x8000u) == 0u) + return static_cast(static_cast(value << 1u)); + if ((value & 0x2000u) != 0u) + return 0; + return (value & 0x1000u) != 0u ? -0x7FFF : 0x7FFF; + } + + uint32_t combineAddress(uint16_t hi, uint16_t lo) + { + return ((static_cast(hi) << 16u) | lo) & kRamMask; + } + } + + void decodeAdpcmBlock(const uint16_t *block, int16_t *out, AdpcmHistory &history) + { + const uint8_t header = static_cast(block[0] & 0xFFu); + uint32_t shift = header & 0x0Fu; + if (shift > 12u) + shift = 9u; // reserved shifts behave like 9 on hardware + const uint32_t filter = std::min((header >> 4u) & 0x07u, 4u); + const int32_t positive = kFilterPositive[filter]; + const int32_t negative = kFilterNegative[filter]; + + for (uint32_t i = 0; i < 28u; ++i) + { + const uint16_t word = block[1u + i / 4u]; + const uint16_t nibble = static_cast((word >> ((i & 3u) * 4u)) & 0x0Fu); + int32_t sample = static_cast(static_cast(nibble << 12u)) >> shift; + sample += (history.prev1 * positive + history.prev2 * negative + 32) >> 6; + sample = clamp16(sample); + history.prev2 = history.prev1; + history.prev1 = sample; + out[i] = static_cast(sample); + } + } + + IopSpu2::IopSpu2(std::span iopRam) + : m_iopRam(iopRam), m_ram(kRamWords) + { + reset(); + } + + void IopSpu2::reset() + { + std::fill(m_ram.begin(), m_ram.end(), uint16_t{0}); + m_cores = {}; + m_globals = {}; + m_irqInfo = 0u; + m_admaFrame = 0u; + m_events = 0u; + } + + uint32_t IopSpu2::takeEvents() noexcept + { + const uint32_t events = m_events; + m_events = 0u; + return events; + } + + int32_t IopSpu2::globalVolume(uint32_t reg) const + { + return decodeVolume(m_globals[reg / 2u]); + } + + int32_t IopSpu2::plainVolume(uint32_t reg) const + { + return static_cast(m_globals[reg / 2u]); + } + + uint16_t IopSpu2::read16(uint32_t phys) + { + const uint32_t offset = (phys - kBase) & ~1u; + if (offset < kGlobalBase) + return readCore(offset >> 10u, offset & 0x3FFu); + if (offset == kRegIrqInfo) + { + // libsd's handler reads which core fired; the bits clear once read. + const uint16_t info = m_irqInfo; + m_irqInfo = 0u; + return info; + } + return m_globals[(offset - kGlobalBase) / 2u]; + } + + void IopSpu2::write16(uint32_t phys, uint16_t value) + { + const uint32_t offset = (phys - kBase) & ~1u; + if (offset < kGlobalBase) + { + writeCore(offset >> 10u, offset & 0x3FFu, value); + return; + } + m_globals[(offset - kGlobalBase) / 2u] = value; + } + + uint16_t IopSpu2::readCore(uint32_t coreIndex, uint32_t reg) + { + Core &core = m_cores[coreIndex]; + if (reg < kRegPmon) + { + const Voice &voice = core.voices[reg >> 4u]; + switch (reg & 0x0Fu) + { + case 0x0A: + return static_cast(voice.envelope); + case 0x0C: + return static_cast(voice.volumeLeft >> 1); + case 0x0E: + return static_cast(voice.volumeRight >> 1); + default: + return core.regs[reg / 2u]; + } + } + if (reg >= kRegVoiceAddr && reg < kRegVoiceAddrEnd) + { + const Voice &voice = core.voices[(reg - kRegVoiceAddr) / 12u]; + uint32_t address = 0u; + switch ((reg - kRegVoiceAddr) % 12u) + { + case 0: + case 2: + address = voice.startAddress; + break; + case 4: + case 6: + address = voice.loopAddress; + break; + default: + address = voice.blockAddress; + break; + } + return ((reg - kRegVoiceAddr) % 4u) == 0u ? static_cast(address >> 16u) + : static_cast(address); + } + switch (reg) + { + case kRegAttr: + return core.attr; + case kRegIrqaHi: + return static_cast(core.irqAddress >> 16u); + case kRegIrqaLo: + return static_cast(core.irqAddress); + case kRegTsaHi: + return static_cast(core.transferAddress >> 16u); + case kRegTsaLo: + return static_cast(core.transferAddress); + case kRegStd: + { + const uint16_t value = m_ram[core.transferAddress]; + core.transferAddress = (core.transferAddress + 1u) & kRamMask; + return value; + } + case kRegAdmas: + return core.admas; + case kRegEndxLo: + return static_cast(core.endFlags); + case kRegEndxHi: + return static_cast(core.endFlags >> 16u); + case kRegStatx: + return core.statx; + default: + return core.regs[reg / 2u]; + } + } + + void IopSpu2::writeCore(uint32_t coreIndex, uint32_t reg, uint16_t value) + { + Core &core = m_cores[coreIndex]; + core.regs[reg / 2u] = value; + if (reg < kRegPmon) + { + Voice &voice = core.voices[reg >> 4u]; + switch (reg & 0x0Fu) + { + case 0x00: + voice.volumeLeftReg = value; + voice.volumeLeft = decodeVolume(value); + break; + case 0x02: + voice.volumeRightReg = value; + voice.volumeRight = decodeVolume(value); + break; + case 0x04: + voice.pitch = std::min(value, 0x3FFFu); + break; + case 0x06: + voice.adsr1 = value; + break; + case 0x08: + voice.adsr2 = value; + break; + case 0x0A: + voice.envelope = value & 0x7FFF; + break; + default: + break; + } + return; + } + if (reg >= kRegVoiceAddr && reg < kRegVoiceAddrEnd) + { + Voice &voice = core.voices[(reg - kRegVoiceAddr) / 12u]; + const uint32_t field = (reg - kRegVoiceAddr) % 12u; + const bool high = (field % 4u) == 0u; + const auto merge = [&](uint32_t current) + { + return high ? combineAddress(value, static_cast(current)) + : combineAddress(static_cast(current >> 16u), value); + }; + if (field < 4u) + { + voice.startAddress = merge(voice.startAddress); + } + else if (field < 8u) + { + // A loop address set by the driver wins over loop-start flags in the sample data. + voice.loopAddress = merge(voice.loopAddress); + voice.customLoop = true; + } + else + { + voice.nextAddress = merge(voice.nextAddress); + voice.blockAddress = voice.nextAddress; + voice.fresh = true; + } + return; + } + + switch (reg) + { + case kRegAttr: + core.attr = value; + // libsd resets a core by clearing ATTR and then waits for STATX to read idle. + if ((value & kAttrCoreEnable) == 0u) + core.statx = 0u; + if ((value & kAttrIrqEnable) == 0u) + m_irqInfo = static_cast(m_irqInfo & ~(4u << coreIndex)); + break; + case kRegIrqaHi: + core.irqAddress = combineAddress(value, static_cast(core.irqAddress)); + break; + case kRegIrqaLo: + core.irqAddress = combineAddress(static_cast(core.irqAddress >> 16u), value); + break; + case kRegKonLo: + keyOn(core, value); + break; + case kRegKonHi: + keyOn(core, static_cast(value & 0xFFu) << 16u); + break; + case kRegKoffLo: + keyOff(core, value); + break; + case kRegKoffHi: + keyOff(core, static_cast(value & 0xFFu) << 16u); + break; + case kRegTsaHi: + core.transferAddress = combineAddress(value, static_cast(core.transferAddress)); + break; + case kRegTsaLo: + core.transferAddress = combineAddress(static_cast(core.transferAddress >> 16u), value); + break; + case kRegStd: + m_ram[core.transferAddress] = value; + checkIrqAll(core.transferAddress, 1u); + core.transferAddress = (core.transferAddress + 1u) & kRamMask; + break; + case kRegAdmas: + core.admas = value; + break; + case kRegEndxLo: + case kRegEndxHi: + core.endFlags = 0u; + break; + default: + break; + } + } + + void IopSpu2::keyOn(Core &core, uint32_t mask) + { + for (uint32_t i = 0; i < kVoicesPerCore; ++i) + { + if ((mask & (1u << i)) == 0u) + continue; + Voice &voice = core.voices[i]; + voice.phase = Phase::Attack; + voice.envelope = 0; + voice.envelopeCounter = 0u; + voice.nextAddress = voice.startAddress; + voice.blockAddress = voice.startAddress; + voice.customLoop = false; + voice.fresh = true; + voice.blockFlags = 0u; + voice.pitchCounter = 0u; + voice.sampleIndex = 28u; + voice.history = {}; + voice.previous = 0; + voice.current = 0; + core.endFlags &= ~(1u << i); + } + } + + void IopSpu2::keyOff(Core &core, uint32_t mask) + { + for (uint32_t i = 0; i < kVoicesPerCore; ++i) + { + if ((mask & (1u << i)) != 0u && core.voices[i].phase != Phase::Off) + { + core.voices[i].phase = Phase::Release; + core.voices[i].envelopeCounter = 0u; + } + } + } + + void IopSpu2::checkIrq(uint32_t coreIndex, uint32_t start, uint32_t count) + { + Core &core = m_cores[coreIndex]; + if ((core.attr & kAttrIrqEnable) == 0u || (m_irqInfo & (4u << coreIndex)) != 0u) + return; + const uint32_t distance = (core.irqAddress - start) & kRamMask; + if (distance >= count) + return; + m_irqInfo = static_cast(m_irqInfo | (4u << coreIndex)); + m_events |= EventIrq; + } + + void IopSpu2::checkIrqAll(uint32_t start, uint32_t count) + { + // Either core's IRQ address matches accesses made on behalf of both cores. + checkIrq(0u, start, count); + checkIrq(1u, start, count); + } + + void IopSpu2::loadBlock(uint32_t coreIndex, Voice &voice, uint32_t voiceIndex) + { + Core &core = m_cores[coreIndex]; + if (!voice.fresh) + { + if ((voice.blockFlags & kFlagLoopEnd) != 0u) + { + core.endFlags |= 1u << voiceIndex; + voice.nextAddress = voice.loopAddress; + if ((voice.blockFlags & kFlagLoopRepeat) == 0u) + { + // End without repeat: the voice is silenced at once, not released. + voice.phase = Phase::Off; + voice.envelope = 0; + } + } + else + { + voice.nextAddress = (voice.blockAddress + 8u) & kRamMask; + } + } + voice.fresh = false; + voice.blockAddress = voice.nextAddress & kRamMask & ~7u; + + uint16_t block[8]; + for (uint32_t i = 0; i < 8u; ++i) + block[i] = m_ram[(voice.blockAddress + i) & kRamMask]; + voice.blockFlags = static_cast(block[0] >> 8u); + if ((voice.blockFlags & kFlagLoopStart) != 0u && !voice.customLoop) + voice.loopAddress = voice.blockAddress; + checkIrqAll(voice.blockAddress, 8u); + decodeAdpcmBlock(block, voice.samples.data(), voice.history); + voice.sampleIndex = 0u; + } + + int32_t IopSpu2::nextSample(uint32_t coreIndex, Voice &voice, uint32_t voiceIndex) + { + if (voice.sampleIndex >= 28u) + loadBlock(coreIndex, voice, voiceIndex); + return voice.samples[voice.sampleIndex++]; + } + + void IopSpu2::tickEnvelope(Voice &voice) + { + const int32_t sustainLevel = static_cast(((voice.adsr1 & 0x0Fu) + 1u) * 0x800u); + if (voice.phase == Phase::Decay && voice.envelope <= sustainLevel) + { + voice.phase = Phase::Sustain; + voice.envelopeCounter = 0u; + } + + uint32_t rate = 0u; + bool decrease = false; + bool exponential = false; + switch (voice.phase) + { + case Phase::Attack: + rate = (voice.adsr1 >> 8u) & 0x7Fu; + exponential = (voice.adsr1 & 0x8000u) != 0u; + break; + case Phase::Decay: + rate = ((voice.adsr1 >> 4u) & 0x0Fu) << 2u; + decrease = true; + exponential = true; + break; + case Phase::Sustain: + rate = (voice.adsr2 >> 6u) & 0x7Fu; + decrease = (voice.adsr2 & 0x4000u) != 0u; + exponential = (voice.adsr2 & 0x8000u) != 0u; + break; + case Phase::Release: + rate = (voice.adsr2 & 0x1Fu) << 2u; + decrease = true; + exponential = (voice.adsr2 & 0x0020u) != 0u; + break; + case Phase::Off: + return; + } + + // Envelope timing as documented for the PS1 SPU, which the SPU2 voices keep. + const int32_t shift = static_cast(rate >> 2u); + const int32_t stepBits = static_cast(rate & 3u); + int32_t step = decrease ? (-8 + stepBits) : (7 - stepBits); + uint32_t cycles = 1u << std::min(std::max(0, shift - 11), 30); + step = static_cast(static_cast(step) << std::max(0, 11 - shift)); + if (exponential && !decrease && voice.envelope > 0x6000) + cycles *= 4u; + if (exponential && decrease) + step = (step * voice.envelope) >> 15; + + if (++voice.envelopeCounter < cycles) + return; + voice.envelopeCounter = 0u; + voice.envelope = std::clamp(voice.envelope + step, 0, 0x7FFF); + + switch (voice.phase) + { + case Phase::Attack: + if (voice.envelope >= 0x7FFF) + { + voice.phase = Phase::Decay; + voice.envelopeCounter = 0u; + } + break; + case Phase::Decay: + if (voice.envelope <= sustainLevel) + { + voice.phase = Phase::Sustain; + voice.envelopeCounter = 0u; + } + break; + case Phase::Release: + if (voice.envelope == 0) + voice.phase = Phase::Off; + break; + default: + break; + } + } + + bool IopSpu2::startDma(uint32_t coreIndex, uint32_t madr, uint32_t bytes, bool toSpu) + { + Core &core = m_cores[coreIndex & 1u]; + madr &= 0x1FFFFFu; + if (toSpu && (core.admas & (1u << coreIndex)) != 0u) + { + core.adma = {true, madr, bytes}; + return false; + } + + const uint32_t available = madr < m_iopRam.size() ? static_cast(m_iopRam.size()) - madr : 0u; + const uint32_t words = std::min(bytes, available) / 2u; + uint8_t *iop = m_iopRam.data() + madr; + for (uint32_t i = 0; i < words; ++i) + { + const uint32_t address = (core.transferAddress + i) & kRamMask; + if (toSpu) + std::memcpy(&m_ram[address], iop + i * 2u, 2u); + else + std::memcpy(iop + i * 2u, &m_ram[address], 2u); + } + checkIrqAll(core.transferAddress, words); + core.transferAddress = (core.transferAddress + words) & kRamMask; + // libsd's DMA handler spins until the core reports the transfer drained. + core.statx |= kStatxDmaReady; + return true; + } + + void IopSpu2::pullAdmaBlock(uint32_t coreIndex) + { + Core &core = m_cores[coreIndex]; + core.inputLeft.fill(0); + core.inputRight.fill(0); + if ((core.admas & (1u << coreIndex)) == 0u || !core.adma.active) + return; + + // Each block is 256 left samples followed by 256 right samples. + constexpr uint32_t kBlockBytes = kAdmaBlockFrames * 4u; + const uint32_t bytes = std::min(core.adma.remaining, kBlockBytes); + const uint32_t address = core.adma.address; + const auto copy = [&](uint32_t offset, std::array &target) + { + const uint32_t length = std::min(bytes > offset ? bytes - offset : 0u, kBlockBytes / 2u); + if (address + offset + length <= m_iopRam.size()) + std::memcpy(target.data(), m_iopRam.data() + address + offset, length); + }; + copy(0u, core.inputLeft); + copy(kBlockBytes / 2u, core.inputRight); + + // Mirror the block into the core's input area so IRQ addresses there fire like on hardware. + const uint32_t base = 0x2000u + coreIndex * 0x400u + core.admaHalf * 0x100u; + std::memcpy(&m_ram[base], core.inputLeft.data(), kBlockBytes / 2u); + std::memcpy(&m_ram[base + 0x200u], core.inputRight.data(), kBlockBytes / 2u); + checkIrqAll(base, 0x100u); + checkIrqAll(base + 0x200u, 0x100u); + core.admaHalf ^= 1u; + + core.adma.address += bytes; + core.adma.remaining -= bytes; + if (core.adma.remaining == 0u) + { + core.adma.active = false; + core.statx |= kStatxDmaReady; + m_events |= coreIndex == 0u ? EventDmaDone0 : EventDmaDone1; + } + } + + void IopSpu2::generate(uint32_t frames, std::vector &out) + { + out.reserve(out.size() + frames * 2u); + for (uint32_t frame = 0; frame < frames; ++frame) + { + if (m_admaFrame == 0u) + { + pullAdmaBlock(0u); + pullAdmaBlock(1u); + } + + int32_t external[2] = {0, 0}; + int32_t result[2] = {0, 0}; + for (uint32_t coreIndex = 0; coreIndex < 2u; ++coreIndex) + { + Core &core = m_cores[coreIndex]; + const uint32_t vmixL = core.regs[kRegVmixL / 2u] | (static_cast(core.regs[kRegVmixL / 2u + 1u]) << 16u); + const uint32_t vmixR = core.regs[kRegVmixR / 2u] | (static_cast(core.regs[kRegVmixR / 2u + 1u]) << 16u); + int32_t dryL = 0; + int32_t dryR = 0; + for (uint32_t i = 0; i < kVoicesPerCore; ++i) + { + Voice &voice = core.voices[i]; + if (voice.phase == Phase::Off) + continue; + + voice.pitchCounter += voice.pitch; + while (voice.pitchCounter >= 0x1000u && voice.phase != Phase::Off) + { + voice.pitchCounter -= 0x1000u; + voice.previous = voice.current; + voice.current = nextSample(coreIndex, voice, i); + } + if (voice.phase == Phase::Off) + continue; + + const int32_t fraction = static_cast(voice.pitchCounter); + const int32_t sample = voice.previous + (((voice.current - voice.previous) * fraction) >> 12); + const int32_t value = (sample * voice.envelope) >> 15; + tickEnvelope(voice); + if ((vmixL & (1u << i)) != 0u) + dryL += (value * voice.volumeLeft) >> 15; + if ((vmixR & (1u << i)) != 0u) + dryR += (value * voice.volumeRight) >> 15; + } + + const uint32_t volumes = kCoreVolumeStride * coreIndex; + const uint16_t mmix = core.regs[kRegMmix / 2u]; + const int32_t inputL = (core.inputLeft[m_admaFrame] * plainVolume(volumes + kRegBvolL)) >> 15; + const int32_t inputR = (core.inputRight[m_admaFrame] * plainVolume(volumes + kRegBvolR)) >> 15; + const int32_t extL = (external[0] * plainVolume(volumes + kRegAvolL)) >> 15; + const int32_t extR = (external[1] * plainVolume(volumes + kRegAvolR)) >> 15; + + int32_t left = 0; + int32_t right = 0; + if (mmix & 0x0800u) + left += clamp16(dryL); + if (mmix & 0x0400u) + right += clamp16(dryR); + if (mmix & 0x0080u) + left += inputL; + if (mmix & 0x0040u) + right += inputR; + if (mmix & 0x0008u) + left += extL; + if (mmix & 0x0004u) + right += extR; + + // Core 0's output feeds core 1's external input; core 1's output is the DAC. + const int32_t masteredL = clamp16((clamp16(left) * globalVolume(volumes + kRegMvolL)) >> 15); + const int32_t masteredR = clamp16((clamp16(right) * globalVolume(volumes + kRegMvolR)) >> 15); + external[0] = masteredL; + external[1] = masteredR; + result[0] = masteredL; + result[1] = masteredR; + } + + out.push_back(static_cast(result[0])); + out.push_back(static_cast(result[1])); + m_admaFrame = (m_admaFrame + 1u) % kAdmaBlockFrames; + } + } +} diff --git a/ps2xIOP/src/emulator/core/iop_spu2.h b/ps2xIOP/src/emulator/core/iop_spu2.h new file mode 100644 index 0000000000..393e038769 --- /dev/null +++ b/ps2xIOP/src/emulator/core/iop_spu2.h @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ps2x::iop::detail +{ + struct AdpcmHistory + { + int32_t prev1 = 0; + int32_t prev2 = 0; + }; + + // PS-ADPCM block: 8 little-endian halfwords; the first holds shift/filter (low byte) and loop flags + // (high byte), the other seven pack 28 4-bit samples. + void decodeAdpcmBlock(const uint16_t *block, int16_t *out, AdpcmHistory &history); + + // SPU2 (0x1F900000-0x1F9007FF): two cores of 24 ADPCM voices, 2 MB sound RAM, AutoDMA PCM input + // and DMA channels 4/7. The disc's own libsd drives it, so voices, streams and IRQs behave as the + // drivers expect. Reverb is not emulated: the effect return is left out of the mix. + class IopSpu2 + { + public: + static constexpr uint32_t kBase = 0x1F900000u; + static constexpr uint32_t kEnd = 0x1F900800u; + static constexpr uint32_t kRamWords = 0x100000u; // 2 MB of 16-bit words + static constexpr uint32_t kCyclesPerSample = 768u; // 36.864 MHz IOP clock / 48 kHz + static constexpr uint32_t kSampleRate = 48000u; + static constexpr uint32_t kVoicesPerCore = 24u; + static constexpr uint32_t kAdmaBlockFrames = 256u; + static constexpr int kIrq = 9; + + enum Event : uint32_t + { + EventIrq = 1u, + EventDmaDone0 = 2u, + EventDmaDone1 = 4u, + }; + + explicit IopSpu2(std::span iopRam); + + void reset(); + [[nodiscard]] static bool contains(uint32_t phys) noexcept { return phys >= kBase && phys < kEnd; } + [[nodiscard]] uint16_t read16(uint32_t phys); + void write16(uint32_t phys, uint16_t value); + + // Starts DMA channel 4 (core 0) or 7 (core 1). Returns false for AutoDMA, which drains at the + // playback rate and reports completion through takeEvents(). + bool startDma(uint32_t core, uint32_t madr, uint32_t bytes, bool toSpu); + [[nodiscard]] bool autoDmaActive(uint32_t core) const noexcept { return m_cores[core & 1u].adma.active; } + [[nodiscard]] uint32_t autoDmaAddress(uint32_t core) const noexcept { return m_cores[core & 1u].adma.address; } + + // Frames until an AutoDMA core pulls its next 256-frame block; DMA completions only happen there. + [[nodiscard]] uint32_t framesUntilBlock() const noexcept { return kAdmaBlockFrames - m_admaFrame; } + // Appends `frames` interleaved stereo samples. + void generate(uint32_t frames, std::vector &out); + [[nodiscard]] uint32_t takeEvents() noexcept; + + [[nodiscard]] std::span ram() noexcept { return m_ram; } + + private: + enum class Phase : uint8_t + { + Off, + Attack, + Decay, + Sustain, + Release, + }; + + struct Voice + { + uint16_t volumeLeftReg = 0u; + uint16_t volumeRightReg = 0u; + int32_t volumeLeft = 0; + int32_t volumeRight = 0; + uint16_t pitch = 0u; + uint16_t adsr1 = 0u; + uint16_t adsr2 = 0u; + int32_t envelope = 0; + uint32_t envelopeCounter = 0u; + Phase phase = Phase::Off; + + uint32_t startAddress = 0u; + uint32_t loopAddress = 0u; + uint32_t nextAddress = 0u; + uint32_t blockAddress = 0u; + bool customLoop = false; + bool fresh = true; + uint8_t blockFlags = 0u; + + uint32_t pitchCounter = 0u; + uint32_t sampleIndex = 28u; + std::array samples{}; + AdpcmHistory history; + int32_t previous = 0; + int32_t current = 0; + }; + + struct AutoDma + { + bool active = false; + uint32_t address = 0u; + uint32_t remaining = 0u; + }; + + struct Core + { + std::array voices{}; + std::array regs{}; + uint32_t irqAddress = 0u; + uint32_t transferAddress = 0u; + uint32_t endFlags = 0u; + uint16_t attr = 0u; + uint16_t admas = 0u; + uint16_t statx = 0u; + AutoDma adma; + std::array inputLeft{}; + std::array inputRight{}; + uint32_t admaHalf = 0u; + }; + + [[nodiscard]] uint16_t readCore(uint32_t core, uint32_t reg); + void writeCore(uint32_t core, uint32_t reg, uint16_t value); + void keyOn(Core &core, uint32_t mask); + void keyOff(Core &core, uint32_t mask); + void checkIrq(uint32_t core, uint32_t start, uint32_t count); + void checkIrqAll(uint32_t start, uint32_t count); + void loadBlock(uint32_t coreIndex, Voice &voice, uint32_t voiceIndex); + int32_t nextSample(uint32_t coreIndex, Voice &voice, uint32_t voiceIndex); + static void tickEnvelope(Voice &voice); + void pullAdmaBlock(uint32_t coreIndex); + // MVOL uses the voice volume format (15-bit, optional sweep); AVOL/BVOL are plain signed 16-bit. + [[nodiscard]] int32_t globalVolume(uint32_t reg) const; + [[nodiscard]] int32_t plainVolume(uint32_t reg) const; + + std::span m_iopRam; + std::vector m_ram; + std::array m_cores{}; + std::array m_globals{}; // 0x760-0x7FF + uint16_t m_irqInfo = 0u; + uint32_t m_admaFrame = 0u; + uint32_t m_events = 0u; + }; +} diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index ec4ea4375a..7bc39fdd7f 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -132,6 +132,8 @@ namespace ps2x::iop::detail pendingGuestCallbacks.clear(); nextModuleId = 1; totalCycles = 0; + spu2Cycle = 0; + spu2Output.clear(); totalInstructions = 0; eeCycleCarry = 0; activeCpu = nullptr; @@ -587,6 +589,7 @@ namespace ps2x::iop::detail const uint64_t target = totalCycles + cycles; while (totalCycles < target) { + advanceSpu2(); servicePendingDmaInterrupts(); servicePendingGuestCallbacks(); timrman.serviceDue(totalCycles, *this); @@ -599,6 +602,8 @@ namespace ps2x::iop::detail if (!pendingGuestCallbacks.empty()) nextWake = std::min(nextWake, pendingGuestCallbacks.begin()->first); nextWake = timrman.nextEventCycle(nextWake); + // AutoDMA completions fall on SPU2 block boundaries; sleeping past them starves streams. + nextWake = std::min(nextWake, spu2Cycle + static_cast(memory.spu2FramesUntilBlock()) * IopSpu2::kCyclesPerSample); totalCycles = std::max(totalCycles + 1u, std::min(target, nextWake)); continue; } @@ -647,6 +652,26 @@ namespace ps2x::iop::detail totalInstructions = instructionsBefore; } + void advanceSpu2() + { + constexpr size_t kFlushFrames = 512u; + while (totalCycles - spu2Cycle >= IopSpu2::kCyclesPerSample) + { + const uint64_t due = (totalCycles - spu2Cycle) / IopSpu2::kCyclesPerSample; + // Stop at each AutoDMA block so a completion IRQ can restart the stream before the next pull. + const uint32_t frames = static_cast(std::min(due, memory.spu2FramesUntilBlock())); + memory.mixSpu2(frames, spu2Output); + spu2Cycle += static_cast(frames) * IopSpu2::kCyclesPerSample; + schedulePendingDma(); + servicePendingDmaInterrupts(); + if (spu2Output.size() >= kFlushFrames * 2u) + { + host.audioOutput(spu2Output.data(), spu2Output.size() / 2u); + spu2Output.clear(); + } + } + } + // Disc modules are loaded from anonymous buffers; their export table names what they are. std::string exportedLibraryName(const Module &module) const { @@ -794,6 +819,8 @@ namespace ps2x::iop::detail std::multimap pendingGuestCallbacks; uint32_t nextModuleId = 1; uint64_t totalCycles = 0; + uint64_t spu2Cycle = 0; + std::vector spu2Output; uint64_t totalInstructions = 0; uint64_t eeCycleCarry = 0; CpuState *activeCpu = nullptr; diff --git a/ps2xIOP/tests/iop_compat_test_support.h b/ps2xIOP/tests/iop_compat_test_support.h index 21831fe268..c6309beeef 100644 --- a/ps2xIOP/tests/iop_compat_test_support.h +++ b/ps2xIOP/tests/iop_compat_test_support.h @@ -62,6 +62,7 @@ namespace iop_test 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; } + void audioOutput(const int16_t *, size_t frames) override { audioFrames += frames; } 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; } @@ -113,6 +114,7 @@ namespace iop_test mutable size_t guestReads = 0u; size_t guestWrites = 0u; size_t audioCalls = 0u; + size_t audioFrames = 0u; int32_t initResult = 0; uint32_t nextHandle = 0x1000u; }; diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index 066d1a6c67..a19d29e1f0 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -374,12 +374,21 @@ namespace bool testSpu2AutoDmaCompletesAtPlaybackRate() { // ADX streaming waits for this interrupt between PCM blocks; finishing it at bus speed made the - // mixer thread spin and starve everything else at its priority. + // mixer thread spin and starve everything else at its priority. The SPU2 pulls one 1 KB block + // (256 stereo frames) per 256 output frames, and the transfer ends when its last block is pulled. IopMemory memory; - const uint64_t autoDelay = startSpu2Dma(memory, true); + if (!expect(startSpu2Dma(memory, true) == 0u && !memory.takeDmaStart(), "AutoDMA must not complete at bus speed")) + return false; + std::vector out; + memory.mixSpu2(256u, out); + if (!expect(!memory.takeDmaStart(), "AutoDMA completed before playback reached its last block")) + return false; + memory.mixSpu2(1u, out); + const auto done = memory.takeDmaStart(); + if (!expect(done && done->irq == 0x24, "AutoDMA should complete once playback pulls its last block")) + return false; const uint64_t plainDelay = startSpu2Dma(memory, false); - return expect(autoDelay == 0x200u * 768u, "AutoDMA should complete after 512 frames at 48 kHz") && - expect(plainDelay == 0x200u * 2u, "a plain SPU2 DMA should keep its bus-speed delay"); + return expect(plainDelay == 0x200u * 2u, "a plain SPU2 DMA should keep its bus-speed delay"); } bool testTimrmanPeriodicCallback() diff --git a/ps2xIOP/tests/iop_spu2_tests.cpp b/ps2xIOP/tests/iop_spu2_tests.cpp new file mode 100644 index 0000000000..2b9970fd69 --- /dev/null +++ b/ps2xIOP/tests/iop_spu2_tests.cpp @@ -0,0 +1,246 @@ +#include "emulator/core/iop_memory.h" +#include "emulator/core/iop_spu2.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + using namespace ps2x::iop::detail; + + constexpr uint32_t kSpu = 0x1F900000u; + constexpr uint32_t kCore1 = 0x400u; + + bool expect(bool condition, std::string_view message) + { + if (condition) + return true; + std::cerr << "FAIL: " << message << '\n'; + return false; + } + + // Builds one ADPCM block; nibbles are given in playback order. + std::vector adpcmBlock(uint8_t shift, uint8_t filter, uint8_t flags, const std::vector &nibbles) + { + std::vector block(8u, 0u); + block[0] = static_cast((shift & 0x0Fu) | ((filter & 0x07u) << 4u) | (flags << 8u)); + for (size_t i = 0; i < 28u && i < nibbles.size(); ++i) + block[1u + i / 4u] |= static_cast((nibbles[i] & 0x0Fu) << ((i & 3u) * 4u)); + return block; + } + + void writeSpuRam(IopMemory &memory, uint32_t core, uint32_t address, const std::vector &words) + { + const uint32_t base = kSpu + core * kCore1; + memory.write16(base + 0x1A8u, static_cast(address >> 16u)); + memory.write16(base + 0x1AAu, static_cast(address)); + for (const uint16_t word : words) + memory.write16(base + 0x1ACu, word); + } + + void setVolume(IopMemory &memory, uint32_t reg, uint16_t value) + { + memory.write16(kSpu + reg, value); + memory.write16(kSpu + reg + 2u, value); + } + + bool testAdpcmDecode() + { + // Shift 12 leaves the raw signed nibble; filter 1 then adds 60/64 of the previous sample. + const std::vector raw = adpcmBlock(12u, 0u, 0u, {1u, 7u, 8u, 0xFu}); + int16_t out[28]; + AdpcmHistory history; + decodeAdpcmBlock(raw.data(), out, history); + if (!expect(out[0] == 1 && out[1] == 7 && out[2] == -8 && out[3] == -1 && out[4] == 0, + "shift 12 / filter 0 should decode signed nibbles unchanged")) + return false; + + const std::vector filtered = adpcmBlock(12u, 1u, 0u, {7u, 0u, 0u}); + history = {}; + decodeAdpcmBlock(filtered.data(), out, history); + if (!expect(out[0] == 7 && out[1] == 7 && out[2] == 7, "filter 1 should carry (prev * 60 + 32) >> 6")) + return false; + + // Shift 0 puts the nibble in the top bits; the result saturates instead of wrapping. + const std::vector loud = adpcmBlock(0u, 4u, 0u, {7u, 7u, 7u}); + history = {}; + decodeAdpcmBlock(loud.data(), out, history); + return expect(out[0] == 0x7000 && out[1] == 0x7FFF && out[2] == 0x7FFF, + "filter 4 on loud input should clamp to 16 bits"); + } + + // Core 1 voice 0 plays a two-block one-shot sample at 1:1 pitch with a near-instant attack. + void setupOneShotVoice(IopMemory &memory, uint32_t address) + { + std::vector data = adpcmBlock(0u, 0u, 0u, std::vector(28u, 7u)); + const std::vector last = adpcmBlock(0u, 0u, 0x01u, std::vector(28u, 7u)); + data.insert(data.end(), last.begin(), last.end()); + writeSpuRam(memory, 1u, address, data); + + const uint32_t voice = kSpu + kCore1; + memory.write16(voice + 0x00u, 0x3FFFu); // VOLL + memory.write16(voice + 0x02u, 0x3FFFu); // VOLR + memory.write16(voice + 0x04u, 0x1000u); // PITCH 1.0 + memory.write16(voice + 0x06u, 0x000Fu); // ADSR1: fastest linear attack, full sustain level + memory.write16(voice + 0x08u, 0x1FC0u); // ADSR2: sustain held + memory.write16(voice + 0x1C0u, static_cast(address >> 16u)); + memory.write16(voice + 0x1C2u, static_cast(address)); + memory.write16(voice + 0x188u, 1u); // VMIXL + memory.write16(voice + 0x190u, 1u); // VMIXR + memory.write16(voice + 0x198u, 0x0C00u); // MMIX: voices to dry out + setVolume(memory, 0x788u, 0x3FFFu); // core 1 MVOL + } + + bool testVoicePlaysAndEnds() + { + IopMemory memory; + setupOneShotVoice(memory, 0x5000u); + memory.write16(kSpu + kCore1 + 0x1A0u, 1u); // KON voice 0 + + std::vector out; + memory.mixSpu2(40u, out); + const int16_t peak = *std::max_element(out.begin(), out.end()); + if (!expect(peak > 0x3000, "a keyed-on voice should reach the DAC at close to its sample level") || + !expect(memory.read16(kSpu + kCore1 + 0x0Au) == 0x7FFFu, "ENVX should report the finished attack") || + !expect(memory.read16(kSpu + kCore1 + 0x340u) == 0u, "ENDX must stay clear mid-sample")) + return false; + + out.clear(); + memory.mixSpu2(40u, out); + if (!expect((memory.read16(kSpu + kCore1 + 0x340u) & 1u) != 0u, "ENDX should flag the voice after the loop-end block") || + !expect(memory.read16(kSpu + kCore1 + 0x0Au) == 0u, "a loop end without repeat silences the voice")) + return false; + out.clear(); + memory.mixSpu2(16u, out); + if (!expect(std::all_of(out.begin(), out.end(), [](int16_t sample) + { return sample == 0; }), + "a stopped voice must not keep sounding")) + return false; + + // Core 0 voices reach the DAC only through core 1's external input. + memory.write16(kSpu + kCore1 + 0x198u, 0x0000u); + out.clear(); + memory.write16(kSpu + kCore1 + 0x1A0u, 1u); + memory.mixSpu2(20u, out); + return expect(std::all_of(out.begin(), out.end(), [](int16_t sample) + { return sample == 0; }), + "MMIX gates the voice sum"); + } + + bool testVoiceIrqAndKeyOff() + { + IopMemory memory; + setupOneShotVoice(memory, 0x6000u); + memory.write16(kSpu + kCore1 + 0x19Cu, 0u); + memory.write16(kSpu + kCore1 + 0x19Eu, 0x6008u); // IRQA at the second block + memory.write16(kSpu + kCore1 + 0x19Au, 0x8040u); // ATTR: core on, IRQ enabled + memory.write16(kSpu + kCore1 + 0x1A0u, 1u); + + std::vector out; + memory.mixSpu2(20u, out); + if (!expect(!memory.takeDmaStart().has_value(), "the IRQ must wait for the voice to reach IRQA")) + return false; + memory.mixSpu2(20u, out); + const auto irq = memory.takeDmaStart(); + if (!expect(irq && irq->irq == IopSpu2::kIrq, "reading the IRQA block should raise the SPU2 interrupt") || + !expect(memory.read16(kSpu + 0x7C2u) == 8u, "IRQINFO should name core 1") || + !expect(memory.read16(kSpu + 0x7C2u) == 0u, "IRQINFO clears once read")) + return false; + + // Key off releases instead of cutting. + memory.write16(kSpu + kCore1 + 0x08u, 0x1FC0u | 0x000Au); + memory.write16(kSpu + kCore1 + 0x1A0u, 1u); + memory.mixSpu2(10u, out); + memory.write16(kSpu + kCore1 + 0x1A4u, 1u); + memory.mixSpu2(1u, out); + const uint16_t released = memory.read16(kSpu + kCore1 + 0x0Au); + return expect(released > 0u && released < 0x7FFFu, "KOFF should start a gradual release"); + } + + bool testDmaWritesSoundRam() + { + IopMemory memory; + std::vector payload(256u); + for (size_t i = 0; i < payload.size(); ++i) + payload[i] = static_cast(i * 3u); + if (!expect(memory.writeRam(0x40000u, payload.data(), payload.size()), "test payload should fit in IOP RAM")) + return false; + + memory.write16(kSpu + 0x1A8u, 0x0001u); + memory.write16(kSpu + 0x1AAu, 0x2345u); // TSA 0x12345 + memory.write32(0x1F8010C0u, 0x40000u); // ch4 MADR + memory.write32(0x1F8010C4u, (4u << 16u) | 16u); // 4 blocks of 16 words + memory.write32(0x1F8010C8u, 0x01000201u); // to SPU, start + + const auto irq = memory.takeDmaStart(); + const std::span ram = memory.spu2().ram(); + return expect(irq && irq->irq == 0x24, "a finished voice transfer should raise the channel 4 IRQ") && + expect(ram[0x12345u] == (payload[0] | (payload[1] << 8u)), "DMA should land at TSA") && + expect(ram[0x12345u + 127u] == (payload[254] | (payload[255] << 8u)), "DMA should copy the whole BCR size") && + expect(memory.read16(kSpu + 0x1AAu) == 0x23C5u, "TSA should advance past the transfer"); + } + + bool testAutoDmaStreamsPcm() + { + IopMemory memory; + // Two 256-frame blocks: left at +1000, right at -1000. + std::vector pcm(1024u); + for (size_t block = 0; block < 2u; ++block) + for (size_t i = 0; i < 256u; ++i) + { + pcm[block * 512u + i] = 1000; + pcm[block * 512u + 256u + i] = -1000; + } + if (!expect(memory.writeRam(0x50000u, pcm.data(), pcm.size() * 2u), "PCM should fit in IOP RAM")) + return false; + + memory.write16(kSpu + 0x1B0u, 1u); // core 0 ADMAS + memory.write16(kSpu + 0x198u, 0x00C0u); // core 0 MMIX: sound data input to dry out + memory.write16(kSpu + kCore1 + 0x198u, 0x000Cu); // core 1 MMIX: core 0 to dry out + setVolume(memory, 0x760u, 0x3FFFu); // core 0 MVOL + setVolume(memory, 0x76Cu, 0x7FFFu); // core 0 BVOL, plain signed 16-bit unlike MVOL + setVolume(memory, 0x788u, 0x3FFFu); // core 1 MVOL + setVolume(memory, 0x790u, 0x7FFFu); // core 1 AVOL + + memory.write32(0x1F8010C0u, 0x50000u); + memory.write32(0x1F8010C4u, (32u << 16u) | 16u); // 2048 bytes + memory.write32(0x1F8010C8u, 0x01000201u); + if (!expect(!memory.takeDmaStart().has_value(), "AutoDMA must not complete before playback drains it") || + !expect((memory.read32(0x1F8010C8u) & 0x01000000u) != 0u, "CHCR should stay busy while streaming")) + return false; + + std::vector out; + memory.mixSpu2(256u, out); + if (!expect(out[0] > 900 && out[1] < -900 && out[510] > 900 && out[511] < -900, + "streamed PCM should reach both channels through core 1") || + !expect(memory.read32(0x1F8010C0u) == 0x50400u, "MADR should advance one block per 256 frames") || + !expect(!memory.takeDmaStart().has_value(), "the transfer still has a block left")) + return false; + + out.clear(); + memory.mixSpu2(256u, out); + const auto irq = memory.takeDmaStart(); + if (!expect(irq && irq->irq == 0x24, "draining the last block should complete channel 4") || + !expect((memory.read32(0x1F8010C8u) & 0x01000000u) == 0u, "CHCR should go idle after the last block")) + return false; + + out.clear(); + memory.mixSpu2(256u, out); + return expect(std::all_of(out.begin(), out.end(), [](int16_t sample) + { return sample == 0; }), + "an idle AutoDMA input plays silence"); + } +} + +int main() +{ + if (!testAdpcmDecode() || !testVoicePlaysAndEnds() || !testVoiceIrqAndKeyOff() || !testDmaWritesSoundRam() || + !testAutoDmaStreamsPcm()) + return EXIT_FAILURE; + std::cout << "ps2xIOP SPU2 tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/ps2xRuntime/CMakeLists.txt b/ps2xRuntime/CMakeLists.txt index 5086ce3936..1d95b1fe60 100644 --- a/ps2xRuntime/CMakeLists.txt +++ b/ps2xRuntime/CMakeLists.txt @@ -380,6 +380,7 @@ add_library(ps2_runtime STATIC src/lib/gs/ps2_gif_arbiter.cpp src/lib/ps2_audio.cpp src/lib/ps2_audio_vag.cpp + src/lib/ps2_audio_stream.cpp src/lib/gs/ps2_gs_memory.cpp src/lib/gs/gs_frontend.cpp src/lib/gs/gs_cpu_backend.cpp diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.cpp b/ps2xRuntime/src/lib/ps2_audio_stream.cpp new file mode 100644 index 0000000000..821e3908bc --- /dev/null +++ b/ps2xRuntime/src/lib/ps2_audio_stream.cpp @@ -0,0 +1,158 @@ +#include "ps2_audio_stream.h" + +#include "ps2_host_backend.h" + +#include +#include + +Spu2FrameRing::Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames) + : m_samples((capacityFrames + 1u) * 2u), + m_capacity(capacityFrames + 1u), + m_prebuffer(std::min(prebufferFrames, capacityFrames)), + m_maxLatency(std::min(maxLatencyFrames, capacityFrames)) +{ +} + +size_t Spu2FrameRing::available() const noexcept +{ + const size_t write = m_writeIndex.load(std::memory_order_acquire); + const size_t read = m_readIndex.load(std::memory_order_acquire); + return (write + m_capacity - read) % m_capacity; +} + +size_t Spu2FrameRing::push(const int16_t *samples, size_t frames) +{ + const size_t write = m_writeIndex.load(std::memory_order_relaxed); + const size_t read = m_readIndex.load(std::memory_order_acquire); + const size_t free = m_capacity - 1u - (write + m_capacity - read) % m_capacity; + const size_t count = std::min(frames, free); + for (size_t i = 0; i < count;) + { + const size_t index = (write + i) % m_capacity; + const size_t run = std::min(count - i, m_capacity - index); + std::memcpy(&m_samples[index * 2u], samples + i * 2u, run * 2u * sizeof(int16_t)); + i += run; + } + m_writeIndex.store((write + count) % m_capacity, std::memory_order_release); + return count; +} + +size_t Spu2FrameRing::pop(int16_t *out, size_t frames) +{ + size_t read = m_readIndex.load(std::memory_order_relaxed); + const size_t write = m_writeIndex.load(std::memory_order_acquire); + size_t queued = (write + m_capacity - read) % m_capacity; + + if (m_starving && queued < m_prebuffer) + { + std::memset(out, 0, frames * 2u * sizeof(int16_t)); + return 0u; + } + m_starving = false; + + if (queued > m_maxLatency) + { + read = (read + queued - m_maxLatency) % m_capacity; + queued = m_maxLatency; + } + + const size_t count = std::min(frames, queued); + for (size_t i = 0; i < count;) + { + const size_t index = (read + i) % m_capacity; + const size_t run = std::min(count - i, m_capacity - index); + std::memcpy(out + i * 2u, &m_samples[index * 2u], run * 2u * sizeof(int16_t)); + i += run; + } + m_readIndex.store((read + count) % m_capacity, std::memory_order_release); + + if (count < frames) + { + std::memset(out + count * 2u, 0, (frames - count) * 2u * sizeof(int16_t)); + m_starving = true; + m_underruns.fetch_add(1u, std::memory_order_relaxed); + } + return count; +} + +namespace +{ + constexpr unsigned int kSpu2SampleRate = 48000u; + // 250 ms of headroom, 40 ms to (re)start, and never more than 150 ms behind the game. + constexpr size_t kRingFrames = kSpu2SampleRate / 4u; + constexpr size_t kPrebufferFrames = kSpu2SampleRate / 25u; + constexpr size_t kMaxLatencyFrames = kSpu2SampleRate * 3u / 20u; + + // raylib audio callbacks carry no user pointer. + std::atomic g_activeRing{nullptr}; + +#if !defined(PLATFORM_VITA) + void fillHostAudio(void *buffer, unsigned int frames) + { + Spu2FrameRing *ring = g_activeRing.load(std::memory_order_acquire); + if (!ring) + { + std::memset(buffer, 0, static_cast(frames) * 2u * sizeof(int16_t)); + return; + } + ring->pop(static_cast(buffer), frames); + } +#endif +} + +struct Spu2AudioOutput::Impl +{ + Spu2FrameRing ring{kRingFrames, kPrebufferFrames, kMaxLatencyFrames}; + bool started = false; + bool unavailable = false; +#if !defined(PLATFORM_VITA) + AudioStream stream{}; +#endif + + void start() + { +#if defined(PLATFORM_VITA) + unavailable = true; +#else + // The device is opened with the window; until then audio is dropped, like on a muted TV. + if (!IsAudioDeviceReady()) + return; + stream = LoadAudioStream(kSpu2SampleRate, 16u, 2u); + if (!IsAudioStreamValid(stream)) + { + unavailable = true; + return; + } + g_activeRing.store(&ring, std::memory_order_release); + SetAudioStreamCallback(stream, &fillHostAudio); + PlayAudioStream(stream); + started = true; +#endif + } + + ~Impl() + { +#if !defined(PLATFORM_VITA) + if (started) + { + StopAudioStream(stream); + g_activeRing.store(nullptr, std::memory_order_release); + UnloadAudioStream(stream); + } +#endif + } +}; + +Spu2AudioOutput::Spu2AudioOutput() : m_impl(std::make_unique()) +{ +} + +Spu2AudioOutput::~Spu2AudioOutput() = default; + +void Spu2AudioOutput::submit(const int16_t *samples, size_t frames) +{ + if (!m_impl->started && !m_impl->unavailable) + m_impl->start(); + if (m_impl->started) + m_impl->ring.push(samples, frames); +} diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.h b/ps2xRuntime/src/lib/ps2_audio_stream.h new file mode 100644 index 0000000000..3fba8f50db --- /dev/null +++ b/ps2xRuntime/src/lib/ps2_audio_stream.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Single-producer/single-consumer ring of stereo 16-bit frames: the IOP mixes into it on the EE +// thread and the host audio callback drains it, so neither side ever blocks the other. +class Spu2FrameRing +{ +public: + Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames); + + // Producer side. Frames that do not fit are dropped: the host is behind and old audio is stale anyway. + size_t push(const int16_t *samples, size_t frames); + + // Consumer side. Always fills `frames`, padding with silence. After running dry it waits for + // `prebufferFrames` before resuming, so a slow producer gives gaps instead of a buzz of tiny chunks; + // beyond `maxLatencyFrames` queued, the oldest frames are skipped to keep audio in sync. + size_t pop(int16_t *out, size_t frames); + + [[nodiscard]] size_t available() const noexcept; + [[nodiscard]] uint64_t underruns() const noexcept { return m_underruns.load(std::memory_order_relaxed); } + +private: + std::vector m_samples; + size_t m_capacity; + size_t m_prebuffer; + size_t m_maxLatency; + std::atomic m_readIndex{0}; + std::atomic m_writeIndex{0}; + bool m_starving = true; + std::atomic m_underruns{0}; +}; + +// Plays SPU2 output through the host audio device. +class Spu2AudioOutput +{ +public: + Spu2AudioOutput(); + ~Spu2AudioOutput(); + + Spu2AudioOutput(const Spu2AudioOutput &) = delete; + Spu2AudioOutput &operator=(const Spu2AudioOutput &) = delete; + + void submit(const int16_t *samples, size_t frames); + +private: + struct Impl; + std::unique_ptr m_impl; +}; diff --git a/ps2xRuntime/src/lib/ps2_iop_host.cpp b/ps2xRuntime/src/lib/ps2_iop_host.cpp index 8d46e03292..113b1b291f 100644 --- a/ps2xRuntime/src/lib/ps2_iop_host.cpp +++ b/ps2xRuntime/src/lib/ps2_iop_host.cpp @@ -267,6 +267,11 @@ void PS2IopHostAdapter::audioCommand(uint32_t sid, receive.size); } +void PS2IopHostAdapter::audioOutput(const int16_t *samples, size_t frames) +{ + m_audioOutput.submit(samples, frames); +} + std::string PS2IopHostAdapter::hostPath(ps2x::iop::HostPathKind kind) const { const PS2Runtime::IoPaths &paths = PS2Runtime::getIoPaths(); diff --git a/ps2xRuntime/src/lib/ps2_iop_host.h b/ps2xRuntime/src/lib/ps2_iop_host.h index 89d8576a82..ebe81e73a8 100644 --- a/ps2xRuntime/src/lib/ps2_iop_host.h +++ b/ps2xRuntime/src/lib/ps2_iop_host.h @@ -1,6 +1,7 @@ #pragma once #include "ps2x/iop/iop_host.h" +#include "ps2_audio_stream.h" #include #include @@ -60,6 +61,7 @@ class PS2IopHostAdapter final : public ps2x::iop::IopHost uint32_t function, ps2x::iop::GuestBuffer send, ps2x::iop::GuestBuffer receive) override; + void audioOutput(const int16_t *samples, size_t frames) override; std::string hostPath(ps2x::iop::HostPathKind kind) const override; std::string translateGuestPath(std::string_view path) const override; @@ -105,4 +107,5 @@ class PS2IopHostAdapter final : public ps2x::iop::IopHost mutable std::mutex m_hostFileMutex; std::unordered_map m_hostFiles; uint64_t m_nextHostFileHandle = 1u; + Spu2AudioOutput m_audioOutput; }; diff --git a/ps2xTest/CMakeLists.txt b/ps2xTest/CMakeLists.txt index 3eaa8b3d28..02daae918f 100644 --- a/ps2xTest/CMakeLists.txt +++ b/ps2xTest/CMakeLists.txt @@ -38,6 +38,7 @@ add_library(ps2_test_lib STATIC src/ps2_recompiler_tests.cpp src/ps2_runtime_expansion_tests.cpp src/ps2_float_semantics_tests.cpp + src/ps2_audio_stream_tests.cpp ) target_include_directories(ps2_test_lib PRIVATE diff --git a/ps2xTest/src/main.cpp b/ps2xTest/src/main.cpp index 7b78891488..5274e0c4a9 100644 --- a/ps2xTest/src/main.cpp +++ b/ps2xTest/src/main.cpp @@ -19,6 +19,7 @@ void register_ps2_sif_dma_tests(); void register_ps2_recompiler_tests(); void register_ps2_runtime_expansion_tests(); void register_ps2_float_semantics_tests(); +void register_ps2_audio_stream_tests(); void reset_ps2_test_function_table(); int main() @@ -42,6 +43,7 @@ int main() register_ps2_recompiler_tests(); register_ps2_runtime_expansion_tests(); register_ps2_float_semantics_tests(); + register_ps2_audio_stream_tests(); int res = MiniTest::Run(); std::cout.flush(); std::cerr.flush(); diff --git a/ps2xTest/src/ps2_audio_stream_tests.cpp b/ps2xTest/src/ps2_audio_stream_tests.cpp new file mode 100644 index 0000000000..08919ff58e --- /dev/null +++ b/ps2xTest/src/ps2_audio_stream_tests.cpp @@ -0,0 +1,83 @@ +#include "MiniTest.h" +#include "ps2_audio_stream.h" +#include "ps2x/iop/iop_subsystem.h" +#include "../../ps2xIOP/tests/iop_compat_test_support.h" + +#include + +namespace +{ + std::vector rampFrames(size_t frames, int16_t first) + { + std::vector samples(frames * 2u); + for (size_t i = 0; i < frames; ++i) + { + samples[i * 2u] = static_cast(first + static_cast(i)); + samples[i * 2u + 1u] = static_cast(-(first + static_cast(i))); + } + return samples; + } +} + +void register_ps2_audio_stream_tests() +{ + MiniTest::Case("Spu2FrameRing", [](TestCase &tc) + { + tc.Run("playback waits for the prebuffer, then keeps frame order", [](TestCase &t) + { + Spu2FrameRing ring(64u, 8u, 64u); + std::vector out(8u, 7); + const std::vector first = rampFrames(4u, 100); + ring.push(first.data(), 4u); + t.Equals(ring.pop(out.data(), 4u), size_t{0}, "a short queue must not start playback"); + t.Equals(out[0], int16_t{0}, "waiting for the prebuffer plays silence"); + + const std::vector second = rampFrames(4u, 104); + ring.push(second.data(), 4u); + t.Equals(ring.pop(out.data(), 4u), size_t{4}, "a full prebuffer starts playback"); + t.Equals(out[0], int16_t{100}, "the oldest frame plays first"); + t.Equals(out[1], int16_t{-100}, "channels stay interleaved"); + t.Equals(out[6], int16_t{103}, "frames come out in order"); + }); + + tc.Run("an underrun pads silence and rebuffers before resuming", [](TestCase &t) + { + Spu2FrameRing ring(64u, 8u, 64u); + const std::vector frames = rampFrames(10u, 1); + ring.push(frames.data(), 10u); + std::vector out(32u, 7); + t.Equals(ring.pop(out.data(), 16u), size_t{10}, "every queued frame should play"); + t.Equals(out[19], int16_t{-10}, "the last queued frame is intact"); + t.Equals(out[20], int16_t{0}, "the missing tail is silence"); + t.Equals(ring.underruns(), uint64_t{1}, "running dry is counted"); + + const std::vector trickle = rampFrames(2u, 50); + ring.push(trickle.data(), 2u); + t.Equals(ring.pop(out.data(), 2u), size_t{0}, "a trickle after an underrun must wait for the prebuffer"); + }); + + tc.Run("a full ring drops new frames and a long queue is trimmed to the latency cap", [](TestCase &t) + { + Spu2FrameRing ring(16u, 1u, 8u); + const std::vector frames = rampFrames(20u, 0); + t.Equals(ring.push(frames.data(), 20u), size_t{16}, "frames beyond capacity are dropped"); + std::vector out(8u); + t.Equals(ring.pop(out.data(), 4u), size_t{4}, "playback starts"); + t.Equals(out[0], int16_t{8}, "audio more than the latency cap behind is skipped"); + }); + }); + + MiniTest::Case("IopSpu2Output", [](TestCase &tc) + { + tc.Run("the IOP delivers SPU2 frames at 48 kHz of emulated time", [](TestCase &t) + { + iop_test::Host host; + ps2x::iop::IopSubsystem subsystem(host); + // 0.1 s of EE time at 294.912 MHz. + for (int i = 0; i < 100; ++i) + subsystem.runEeCycles(294912u); + t.IsTrue(host.audioFrames >= 4800u - 512u && host.audioFrames <= 4800u, + "0.1 s should yield 4800 frames, flushed in blocks of at most 512"); + }); + }); +} From 40a709d12423282d00a0fd70960276f07eac241a Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:11:42 -0300 Subject: [PATCH 38/91] Reach the memory card over SIO2: dmacman DMA, a DualShock 2 on port 1, secrman auth, SIF DMA callbacks and lwl/lwr load-delay merging --- ps2xIOP/CMakeLists.txt | 1 + ps2xIOP/src/emulator/core/iop_cpu.cpp | 10 +- ps2xIOP/src/emulator/core/iop_sio2.cpp | 139 ++++++++++++++- ps2xIOP/src/emulator/core/iop_sio2.h | 22 ++- ps2xIOP/src/emulator/imports/iop_dmacman.cpp | 164 +++++++++++++++++ ps2xIOP/src/emulator/imports/iop_dmacman.h | 29 +++ ps2xIOP/src/emulator/iop_emulator.cpp | 31 +++- ps2xIOP/src/emulator/services/iop_rpc.cpp | 18 +- ps2xIOP/tests/iop_emulator_tests.cpp | 121 +++++++++++++ ps2xIOP/tests/iop_import_tests.cpp | 178 ++++++++++++++++++- 10 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 ps2xIOP/src/emulator/imports/iop_dmacman.cpp create mode 100644 ps2xIOP/src/emulator/imports/iop_dmacman.h diff --git a/ps2xIOP/CMakeLists.txt b/ps2xIOP/CMakeLists.txt index 543b6b123a..c78cee8aab 100644 --- a/ps2xIOP/CMakeLists.txt +++ b/ps2xIOP/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(ps2_iop STATIC src/emulator/services/iop_module_loader.cpp src/emulator/services/iop_rpc.cpp src/emulator/imports/iop_cdvd.cpp + src/emulator/imports/iop_dmacman.cpp src/emulator/imports/iop_heaplib.cpp src/emulator/imports/iop_imports.cpp src/emulator/imports/iop_intrman.cpp diff --git a/ps2xIOP/src/emulator/core/iop_cpu.cpp b/ps2xIOP/src/emulator/core/iop_cpu.cpp index e09642e30c..511e3a8d56 100644 --- a/ps2xIOP/src/emulator/core/iop_cpu.cpp +++ b/ps2xIOP/src/emulator/core/iop_cpu.cpp @@ -96,6 +96,12 @@ namespace ps2x::iop::detail { scheduleLoad(reg, value, scheduledLoad, scheduledReg, scheduledValue); }; + // LWL/LWR merge into a value still in the load delay slot, so an lwl/lwr pair + // assembles one word even though the register has not been written yet. + auto unalignedLoadBase = [&](uint32_t reg) + { + return cpu.pendingLoad && cpu.pendingLoadReg == reg ? cpu.pendingLoadValue : cpu.gpr[reg]; + }; auto overflowAdd = [&](int32_t lhs, int32_t rhs, uint32_t reg) { const int64_t result = static_cast(lhs) + rhs; @@ -356,7 +362,7 @@ namespace ps2x::iop::detail { const uint32_t address = cpu.gpr[rs] + static_cast(simm); const uint32_t memory = m_memory.read32(address & ~3u); - const uint32_t old = cpu.gpr[rt]; + const uint32_t old = unalignedLoadBase(rt); static constexpr uint32_t masks[4] = {0x00FFFFFFu, 0x0000FFFFu, 0x000000FFu, 0x00000000u}; static constexpr uint32_t shifts[4] = {24u, 16u, 8u, 0u}; load(rt, (old & masks[address & 3u]) | (memory << shifts[address & 3u])); @@ -377,7 +383,7 @@ namespace ps2x::iop::detail { const uint32_t address = cpu.gpr[rs] + static_cast(simm); const uint32_t memory = m_memory.read32(address & ~3u); - const uint32_t old = cpu.gpr[rt]; + const uint32_t old = unalignedLoadBase(rt); static constexpr uint32_t masks[4] = {0x00000000u, 0xFF000000u, 0xFFFF0000u, 0xFFFFFF00u}; static constexpr uint32_t shifts[4] = {0u, 8u, 16u, 24u}; load(rt, (old & masks[address & 3u]) | (memory >> shifts[address & 3u])); diff --git a/ps2xIOP/src/emulator/core/iop_sio2.cpp b/ps2xIOP/src/emulator/core/iop_sio2.cpp index 6fb68205b9..b36915a3fb 100644 --- a/ps2xIOP/src/emulator/core/iop_sio2.cpp +++ b/ps2xIOP/src/emulator/core/iop_sio2.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace ps2x::iop::detail { @@ -13,6 +14,10 @@ namespace ps2x::iop::detail constexpr uint32_t kRecvConnected = 0x1100u; constexpr uint32_t kRecvDisconnected = 0x1D100u; + constexpr uint32_t kRecvNoDevicesMissing = 0x1000u; + constexpr uint32_t kRecvPort2Missing = 0x2D000u; + constexpr uint32_t kRecvOnePortOpen = 0x100u; + constexpr uint32_t kRecvTwoPortsOpen = 0x200u; constexpr uint32_t kCtrlStart = 0x1u; constexpr uint32_t kCtrlResetFifos = 0xCu; } @@ -134,6 +139,7 @@ namespace ps2x::iop::detail m_interruptPending = false; m_fifoOut.clear(); m_port = 0; + m_pad.reset(); softReset(); } @@ -308,8 +314,14 @@ namespace ps2x::iop::detail return; } - // Pads, multitaps and infrared are not wired to SIO2 here: answer as an empty port. - m_recv1 = mode == kModePad ? (m_recv1 | 0x1000u | (m_port == 0u ? 0x1D000u : 0x2D000u)) : kRecvDisconnected; + if (mode == kModePad) + { + pad(); + return; + } + + // Multitaps and infrared have nothing attached. + m_recv1 = kRecvDisconnected; m_fifoOut.push_back(0xFFu); while (!m_fifoIn.empty()) { @@ -318,6 +330,129 @@ namespace ps2x::iop::detail } } + void IopSio2::pad() + { + // RECV1 is rebuilt per packet: bits 8-9 count the pad ports addressed, 0x1000 is always set, + // and a missing controller adds 0x1D000 (port 0) or 0x2D000 (port 1). + if ((m_recv1 & kRecvOnePortOpen) != 0u) + m_recv1 = (m_recv1 & ~kRecvOnePortOpen) | kRecvTwoPortsOpen; + else + m_recv1 |= kRecvOnePortOpen; + m_recv1 |= kRecvNoDevicesMissing; + m_fifoOut.push_back(0xFFu); + if (m_port == 0u) + { + m_pad.transfer(m_fifoIn, m_fifoOut); + m_fifoIn.clear(); + return; + } + m_recv1 |= kRecvPort2Missing; + while (!m_fifoIn.empty()) + { + m_fifoIn.pop_front(); + m_fifoOut.push_back(0xFFu); + } + } + + void IopDualShock2::reset() noexcept + { + *this = IopDualShock2{}; + } + + uint8_t IopDualShock2::modeId() const noexcept + { + if (m_config) + return 0xF3u; + if (!m_analog) + return 0x41u; + return m_pressure ? 0x79u : 0x73u; + } + + void IopDualShock2::transfer(const std::deque &command, std::deque &reply) + { + // Replies follow the DualShock 2 protocol: mode id, 0x5A, then command data. Buttons are active low. + const auto arg = [&command](size_t index) -> uint8_t + { return index < command.size() ? command[index] : 0u; }; + const uint8_t cmd = arg(0); + const uint8_t id = modeId(); + std::vector data; + const auto pollData = [this]() + { + std::vector state = {0xFFu, 0xFFu}; + if (m_analog || m_config) + state.insert(state.end(), {0x7Fu, 0x7Fu, 0x7Fu, 0x7Fu}); + if (m_pressure && !m_config) + state.insert(state.end(), 12u, 0x00u); + return state; + }; + + switch (cmd) + { + case 0x42: // poll + data = pollData(); + break; + case 0x43: // enter or leave config mode + data = m_config ? std::vector(6u, 0x00u) : pollData(); + m_config = arg(2) == 1u; + break; + default: + if (!m_config) + { + // Outside config mode only polling is answered; the rest reads like a poll. + data = pollData(); + break; + } + switch (cmd) + { + case 0x40: + data = {0x00, 0x00, 0x02, 0x00, 0x00, 0x5A}; + break; + case 0x41: // which buttons report pressure + data = m_analog ? std::vector{0xFF, 0xFF, 0x03, 0x00, 0x00, 0x5A} : std::vector(6u, 0x00u); + break; + case 0x44: // set analog mode + m_analog = arg(2) == 1u; + if (!m_analog) + m_pressure = false; + data = std::vector(6u, 0x00u); + break; + case 0x45: // model and current mode + data = {0x03, 0x02, static_cast(m_analog ? 0x01 : 0x00), 0x02, 0x01, 0x00}; + break; + case 0x46: + data = arg(2) == 0u ? std::vector{0x00, 0x00, 0x01, 0x02, 0x00, 0x0A} + : std::vector{0x00, 0x00, 0x01, 0x01, 0x01, 0x14}; + break; + case 0x47: + data = {0x00, 0x00, 0x02, 0x00, 0x01, 0x00}; + break; + case 0x4C: + data = arg(2) == 0u ? std::vector{0x00, 0x00, 0x00, 0x04, 0x00, 0x00} + : std::vector{0x00, 0x00, 0x00, 0x07, 0x00, 0x00}; + break; + case 0x4D: // vibration motor map: answer with the previous one + data.assign(m_vibrationMap.begin(), m_vibrationMap.end()); + for (size_t i = 0; i < m_vibrationMap.size(); ++i) + m_vibrationMap[i] = arg(2u + i); + break; + case 0x4F: // enable pressure reporting + m_pressure = m_analog; + data = {0x00, 0x00, 0x00, 0x00, 0x00, 0x5A}; + break; + default: + data = std::vector(6u, 0x00u); + break; + } + break; + } + + // The address byte was answered by the caller; the rest of the reply mirrors the request length. + std::vector bytes = {id, 0x5Au}; + bytes.insert(bytes.end(), data.begin(), data.end()); + for (size_t i = 0; i < command.size(); ++i) + reply.push_back(i < bytes.size() ? bytes[i] : 0x00u); + } + void IopSio2::memoryCard() { IopMemoryCard &mc = m_cards[m_port]; diff --git a/ps2xIOP/src/emulator/core/iop_sio2.h b/ps2xIOP/src/emulator/core/iop_sio2.h index 7ced946969..286c2e116f 100644 --- a/ps2xIOP/src/emulator/core/iop_sio2.h +++ b/ps2xIOP/src/emulator/core/iop_sio2.h @@ -48,8 +48,26 @@ namespace ps2x::iop::detail size_t m_transferAddress = 0; }; + // A DualShock 2 at rest. Real input reaches the game through the EE-side pad library, but IOP drivers + // such as sio2d judge the bus by the controller in port 1, as on a console where one is always plugged in. + class IopDualShock2 + { + public: + void reset() noexcept; + // Answers one packet (command byte first, after the 0x01 address byte) with a reply of equal length. + void transfer(const std::deque &command, std::deque &reply); + + private: + [[nodiscard]] uint8_t modeId() const noexcept; + + bool m_analog = false; + bool m_config = false; + bool m_pressure = false; + std::array m_vibrationMap{0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF}; + }; + // SIO2 serial controller (0x1F808200-0x1F808280) with DMA channels 11 (in) and 12 (out). - // Only memory cards are attached: pads are served at the EE level, so pad traffic reads as disconnected. + // Port 0 holds a DualShock 2 and slot 1 a memory card; the second pad port stays empty. class IopSio2 { public: @@ -78,6 +96,7 @@ namespace ps2x::iop::detail void softReset(); void process(); void memoryCard(); + void pad(); std::array m_send3{}; std::array m_send1{}; @@ -101,5 +120,6 @@ namespace ps2x::iop::detail bool m_queueComplete = false; std::array m_cards; + IopDualShock2 m_pad; }; } diff --git a/ps2xIOP/src/emulator/imports/iop_dmacman.cpp b/ps2xIOP/src/emulator/imports/iop_dmacman.cpp new file mode 100644 index 0000000000..2c7eb46269 --- /dev/null +++ b/ps2xIOP/src/emulator/imports/iop_dmacman.cpp @@ -0,0 +1,164 @@ +#include "iop_dmacman.h" + +#include "../core/iop_cpu.h" +#include "../core/iop_memory.h" + +namespace ps2x::iop::detail +{ + namespace + { + constexpr uint32_t kMadr = 0x0u; + constexpr uint32_t kBcr = 0x4u; + constexpr uint32_t kChcr = 0x8u; + constexpr uint32_t kTadr = 0xCu; + + constexpr uint32_t kDpcr = 0x1F8010F0u; + constexpr uint32_t kDicr = 0x1F8010F4u; + constexpr uint32_t kDpcr2 = 0x1F801570u; + constexpr uint32_t kDicr2 = 0x1F801574u; + constexpr uint32_t kDpcr3 = 0x1F8015F0u; + constexpr uint32_t kReg157C = 0x1F80157Cu; + constexpr uint32_t kReg1578 = 0x1F801578u; + + constexpr uint32_t kChannelCount = 13u; + constexpr uint32_t kChannelOtc = 6u; + constexpr uint32_t kChcrStart = 0x01000000u; + + // Each channel owns a nibble of DPCR (channels 0-6) or DPCR2 (7-12): priority in bits 0-2, enable in bit 3. + struct ControlSlot + { + uint32_t address = 0u; + uint32_t shift = 0u; + }; + + std::optional controlSlot(uint32_t channel) noexcept + { + if (channel < 7u) + return ControlSlot{kDpcr, channel * 4u}; + if (channel < kChannelCount) + return ControlSlot{kDpcr2, (channel - 7u) * 4u}; + return std::nullopt; + } + } + + IopDmacman::IopDmacman(IopMemory &memory) noexcept + : m_memory(memory) + { + } + + std::optional IopDmacman::channelBase(uint32_t channel) noexcept + { + if (channel < 7u) + return 0x1F801080u + channel * 0x10u; + if (channel < kChannelCount) + return 0x1F801500u + (channel - 7u) * 0x10u; + return std::nullopt; + } + + uint32_t IopDmacman::readChannel(uint32_t channel, uint32_t offset) const + { + const auto base = channelBase(channel); + return base ? m_memory.read32(*base + offset) : 0u; + } + + void IopDmacman::writeChannel(uint32_t channel, uint32_t offset, uint32_t value) + { + if (const auto base = channelBase(channel)) + m_memory.write32(*base + offset, value); + } + + bool IopDmacman::dispatchImport(uint16_t ordinal, IopCpuState &cpu) + { + const uint32_t a0 = cpu.gpr[4]; + const uint32_t a1 = cpu.gpr[5]; + const auto setV0 = [&](uint32_t value) + { + cpu.gpr[2] = value; + }; + + switch (ordinal) + { + case 4: // dmac_ch_set_madr + case 6: // dmac_ch_set_bcr + case 8: // dmac_ch_set_chcr + case 10: // dmac_ch_set_tadr + writeChannel(a0, (ordinal - 4u) * 2u, a1); + setV0(0u); + return true; + case 5: // dmac_ch_get_madr + case 7: // dmac_ch_get_bcr + case 9: // dmac_ch_get_chcr + case 11: // dmac_ch_get_tadr + setV0(readChannel(a0, (ordinal - 5u) * 2u)); + return true; + case 14: // dmac_set_dpcr + case 15: + case 16: // dmac_set_dpcr2 + case 17: + case 18: // dmac_set_dpcr3 + case 19: + case 20: // dmac_set_dicr + case 21: + case 22: // dmac_set_dicr2 + case 23: + case 24: // dmac_set_BF80157C + case 25: + case 26: // dmac_set_BF801578 + case 27: + { + static constexpr uint32_t registers[] = {kDpcr, kDpcr2, kDpcr3, kDicr, kDicr2, kReg157C, kReg1578}; + const uint32_t address = registers[(ordinal - 14u) / 2u]; + if ((ordinal & 1u) == 0u) + { + m_memory.write32(address, a0); + setV0(0u); + } + else + { + setV0(m_memory.read32(address)); + } + return true; + } + case 28: // sceSetSliceDMA(channel, address, blockWords, blockCount, direction) + { + if (a0 >= kChannelCount || a0 == kChannelOtc) + { + setV0(0u); + return true; + } + const uint32_t direction = m_memory.read32(cpu.gpr[29] + 16u); + writeChannel(a0, kMadr, a1 & 0xFFFFFFu); + writeChannel(a0, kBcr, (cpu.gpr[6] & 0xFFFFu) | (cpu.gpr[7] << 16u)); + writeChannel(a0, kChcr, (direction & 1u) | 0x200u | (direction == 0u ? 0x40000000u : 0u)); + setV0(1u); + return true; + } + case 32: // sceStartDMA + writeChannel(a0, kChcr, readChannel(a0, kChcr) | kChcrStart); + setV0(0u); + return true; + case 33: // sceSetDMAPriority(channel, priority) + case 34: // sceEnableDMAChannel + case 35: // sceDisableDMAChannel + { + if (const auto slot = controlSlot(a0)) + { + uint32_t value = m_memory.read32(slot->address); + if (ordinal == 33u) + value = (value & ~(7u << slot->shift)) | ((a1 & 7u) << slot->shift); + else if (ordinal == 34u) + value |= 8u << slot->shift; + else + value &= ~(8u << slot->shift); + m_memory.write32(slot->address, value); + } + setV0(0u); + return true; + } + default: + // SIF channel setup (29-31) is owned by the runtime's SIF transport, not these registers. + setV0(0u); + return true; + } + } +} diff --git a/ps2xIOP/src/emulator/imports/iop_dmacman.h b/ps2xIOP/src/emulator/imports/iop_dmacman.h new file mode 100644 index 0000000000..7215dbcdce --- /dev/null +++ b/ps2xIOP/src/emulator/imports/iop_dmacman.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace ps2x::iop::detail +{ + struct IopCpuState; + class IopMemory; + + // dmacman only programs DMA controller registers. Modules such as sio2man start their + // transfers through it, so the writes must land in IopMemory where the channels run. + class IopDmacman + { + public: + explicit IopDmacman(IopMemory &memory) noexcept; + + [[nodiscard]] bool dispatchImport(uint16_t ordinal, IopCpuState &cpu); + + // MADR address of a channel; BCR, CHCR and TADR follow at +4, +8 and +0xC. + [[nodiscard]] static std::optional channelBase(uint32_t channel) noexcept; + + private: + [[nodiscard]] uint32_t readChannel(uint32_t channel, uint32_t offset) const; + void writeChannel(uint32_t channel, uint32_t offset, uint32_t value); + + IopMemory &m_memory; + }; +} diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index 7bc39fdd7f..19947dad55 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -1,5 +1,6 @@ #include "iop_emulator.h" #include "imports/iop_cdvd.h" +#include "imports/iop_dmacman.h" #include "core/iop_cpu.h" #include "imports/iop_heaplib.h" #include "imports/iop_imports.h" @@ -38,6 +39,7 @@ namespace ps2x::iop::detail constexpr uint32_t kCallStackSize = 0x2000u; constexpr uint32_t kCallStackCapacity = (kCallStackLimit - kCallStackBase) / kCallStackSize; constexpr uint64_t kCdvdCompletionCycles = 128u; + constexpr uint64_t kSifDmaCompletionCycles = 128u; uint32_t physicalAddress(uint32_t address) { @@ -106,6 +108,7 @@ namespace ps2x::iop::detail stdio(host, memory), heaplib(memory), intrman(memory), + dmacman(memory), timrman(), ioman(memory), cpuCore(memory), @@ -326,6 +329,14 @@ namespace ps2x::iop::detail secrMcDevIdHandler = {a0, cpu.gpr[28]}; setV0(0); return ImportDisposition::Handled; + case 6: // SecrAuthCard(port, slot, cnum) + // Without the ROM's MagicGate keys the handshake cannot be replayed; the emulated + // card is genuine by construction, so report it authenticated as the BIOS would. + setV0(1); + return ImportDisposition::Handled; + case 7: // SecrResetAuthCard + setV0(0); + return ImportDisposition::Handled; default: break; } @@ -338,6 +349,20 @@ namespace ps2x::iop::detail } if (iequals(call.library, "ioman") && ioman.dispatchImport(call.ordinal, cpu, *this)) return ImportDisposition::Handled; + if (iequals(call.library, "sifman") && call.ordinal == 32u) + { + // sceSifSetDmaIntr(transfers, count, callback, argument): the copy is sceSifSetDma's, + // and completion runs the callback as the SIF0 DMA interrupt would. + const uint32_t callback = cpu.gpr[6]; + const uint32_t argument = cpu.gpr[7]; + (void)rpc.dispatchSifManImport(7u, cpu); + if (cpu.gpr[2] != 0u && callback != 0u) + { + pendingGuestCallbacks.emplace(totalCycles + kSifDmaCompletionCycles, + ScheduledGuestCallback{callback, cpu.gpr[28], argument}); + } + return ImportDisposition::Handled; + } if (iequals(call.library, "sifman")) { return rpc.dispatchSifManImport(call.ordinal, cpu) @@ -348,9 +373,10 @@ namespace ps2x::iop::detail return ImportDisposition::Handled; if (iequals(call.library, "timrman") && timrman.dispatchImport(call.ordinal, cpu, totalCycles)) return ImportDisposition::Handled; - if (iequals(call.library, "dmacman")) + if (iequals(call.library, "dmacman") && dmacman.dispatchImport(call.ordinal, cpu)) { - setV0(0); + // A started channel (SIO2, SPU) queues its completion here, before the caller resumes. + schedulePendingDma(); return ImportDisposition::Handled; } if (iequals(call.library, "stdio") && stdio.dispatchImport(call.ordinal, cpu)) @@ -807,6 +833,7 @@ namespace ps2x::iop::detail IopStdio stdio; IopHeaplib heaplib; IopIntrman intrman; + IopDmacman dmacman; IopTimrman timrman; IopIoman ioman; IopCpuCore cpuCore; diff --git a/ps2xIOP/src/emulator/services/iop_rpc.cpp b/ps2xIOP/src/emulator/services/iop_rpc.cpp index cf4fdc6ec2..7e5c9cd64d 100644 --- a/ps2xIOP/src/emulator/services/iop_rpc.cpp +++ b/ps2xIOP/src/emulator/services/iop_rpc.cpp @@ -244,9 +244,25 @@ namespace ps2x::iop::detail m_kernel.sleepCurrent(cpu); setV0(0); return true; - case 23: + case 23: // sceSifGetOtherData(receiveData, eeSource, iopDestination, size, mode) + { + // Servers pull large payloads (e.g. memory card writes) straight from EE memory. + const uint32_t source = cpu.gpr[5]; + const uint32_t destination = cpu.gpr[6]; + const int32_t size = static_cast(cpu.gpr[7]); + if (size > 0) + { + std::vector payload(static_cast(size)); + if (!m_host.readGuest(source, payload.data(), payload.size()) || + !m_memory.writeRam(destination, payload.data(), payload.size())) + { + setV0(static_cast(-1)); + return true; + } + } setV0(0); return true; + } case 24: // RemoveRpc { const uint32_t serverData = cpu.gpr[4]; diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index eab9bde44d..a5cd66b3c6 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -577,6 +577,63 @@ namespace std::memcpy(host.guest.data() + address + codeOffset, segment.data(), segment.size()); } + // sceSifSetDmaIntr copies like sceSifSetDma, then the completion callback stores its argument at 0x101E0. + void writeSifDmaIntrIrx(TestHost &host, uint32_t address) + { + constexpr uint32_t codeOffset = 0x100u; + constexpr uint32_t loadAddress = 0x00010000u; + constexpr uint32_t importTableAddress = loadAddress + 0x100u; + constexpr uint32_t setDmaIntrStub = importTableAddress + 20u; + constexpr uint32_t descriptorAddress = loadAddress + 0x180u; + constexpr uint32_t payloadAddress = loadAddress + 0x1A0u; + constexpr uint32_t eeDestination = 0xD00u; + + ElfHeader header{}; + header.ident[0] = 0x7Fu; header.ident[1] = 'E'; header.ident[2] = 'L'; header.ident[3] = 'F'; + header.ident[4] = 1u; header.ident[5] = 1u; header.ident[6] = 1u; + header.type = 2u; header.machine = 8u; header.version = 1u; + header.entry = loadAddress; header.phoff = sizeof(ElfHeader); + header.ehsize = sizeof(ElfHeader); header.phentsize = sizeof(ProgramHeader); header.phnum = 1u; + + ProgramHeader program{}; + program.type = 1u; program.offset = codeOffset; program.vaddr = loadAddress; program.paddr = loadAddress; + program.filesz = 0x200u; program.memsz = 0x200u; program.flags = 7u; program.align = 4u; + + const auto jal = [](uint32_t target) { return 0x0C000000u | ((target >> 2u) & 0x03FFFFFFu); }; + const uint32_t entry[] = { + 0x27BDFFF0u, 0xAFBF000Cu, + 0x3C040001u, 0x34840180u, 0x24050001u, // a0 = descriptor, a1 = 1 + 0x3C060001u, 0x34C601C0u, // a2 = callback + jal(setDmaIntrStub), 0x24070055u, // a3 = 0x55 (delay slot) + 0x8FBF000Cu, 0x27BD0010u, 0x03E00008u, 0x00000000u, + }; + const uint32_t callback[] = { + 0x3C080001u, 0xAD0401E0u, // sw a0, 0x101E0 + 0x03E00008u, 0x00000000u, + }; + const uint32_t imports[] = { + 0x41E00000u, 0u, 0x00000101u, + 0x6D666973u, 0x00006E61u, // "sifman" + 0x03E00008u, 0x24000020u, // sceSifSetDmaIntr + 0u, 0u, + }; + const uint32_t descriptor[] = { + payloadAddress, eeDestination, sizeof(uint32_t), 0u, + }; + constexpr uint32_t payload = 0x494E5452u; // "INTR" + + std::vector segment(program.filesz, 0u); + std::memcpy(segment.data(), entry, sizeof(entry)); + std::memcpy(segment.data() + 0x100u, imports, sizeof(imports)); + std::memcpy(segment.data() + 0x180u, descriptor, sizeof(descriptor)); + std::memcpy(segment.data() + 0x1A0u, &payload, sizeof(payload)); + std::memcpy(segment.data() + 0x1C0u, callback, sizeof(callback)); + std::memset(host.guest.data() + address, 0, codeOffset + program.filesz); + std::memcpy(host.guest.data() + address, &header, sizeof(header)); + std::memcpy(host.guest.data() + address + sizeof(header), &program, sizeof(program)); + std::memcpy(host.guest.data() + address + codeOffset, segment.data(), segment.size()); + } + void writeSifDmaIrx(TestHost &host, uint32_t address) { constexpr uint32_t codeOffset = 0x100u; @@ -738,6 +795,49 @@ namespace std::memcpy(host.guest.data() + address + codeOffset, segment.data(), segment.size()); } + // Module start calls SecrAuthCard(2, 0, 0) and returns its result, the way card drivers gate access on it. + void writeSecrAuthCardIrx(TestHost &host, uint32_t address) + { + constexpr uint32_t codeOffset = 0x100u; + constexpr uint32_t loadAddress = 0x00010000u; + constexpr uint32_t secrmanTableAddress = loadAddress + 0x100u; + constexpr uint32_t authCardStub = secrmanTableAddress + 20u; + + ElfHeader header{}; + header.ident[0] = 0x7Fu; header.ident[1] = 'E'; header.ident[2] = 'L'; header.ident[3] = 'F'; + header.ident[4] = 1u; header.ident[5] = 1u; header.ident[6] = 1u; + header.type = 2u; header.machine = 8u; header.version = 1u; + header.entry = loadAddress; header.phoff = sizeof(ElfHeader); + header.ehsize = sizeof(ElfHeader); header.phentsize = sizeof(ProgramHeader); header.phnum = 1u; + + ProgramHeader program{}; + program.type = 1u; program.offset = codeOffset; program.vaddr = loadAddress; program.paddr = loadAddress; + program.filesz = 0x200u; program.memsz = 0x200u; program.flags = 7u; program.align = 4u; + + const auto jal = [](uint32_t target) { return 0x0C000000u | ((target >> 2u) & 0x03FFFFFFu); }; + const uint32_t entry[] = { + 0x27BDFFE0u, 0xAFBF001Cu, // addiu sp, -0x20; sw ra, 0x1c(sp) + 0x24040002u, 0x00002821u, // li a0, 2; move a1, zero + 0x00003021u, // move a2, zero + jal(authCardStub), 0x00000000u, + 0x8FBF001Cu, 0x27BD0020u, 0x03E00008u, 0x00000000u, + }; + const uint32_t secrmanImports[] = { + 0x41E00000u, 0u, 0x00000103u, + 0x72636573u, 0x006E616Du, // "secrman" + 0x03E00008u, 0x24000006u, // SecrAuthCard + 0u, 0u, + }; + + std::vector segment(program.filesz, 0u); + std::memcpy(segment.data(), entry, sizeof(entry)); + std::memcpy(segment.data() + 0x100u, secrmanImports, sizeof(secrmanImports)); + std::memset(host.guest.data() + address, 0, codeOffset + program.filesz); + std::memcpy(host.guest.data() + address, &header, sizeof(header)); + std::memcpy(host.guest.data() + address + sizeof(header), &program, sizeof(program)); + std::memcpy(host.guest.data() + address + codeOffset, segment.data(), segment.size()); + } + void writeCdvdLifecycleIrx(TestHost &host, uint32_t address) { constexpr uint32_t codeOffset = 0x100u; @@ -1111,6 +1211,20 @@ int main() if (!expect(sifDmaPayload == 0x53494621u, "IOP sceSifSetDma did not copy the payload into EE memory")) return 1; + iop.reset(); + writeSifDmaIntrIrx(host, 0x100u); + const ModuleLoadResult sifDmaIntrModule = iop.loadModuleBuffer(0x100u); + uint32_t sifDmaIntrPayload = 0u; + std::memcpy(&sifDmaIntrPayload, host.guest.data() + 0xD00u, sizeof(sifDmaIntrPayload)); + if (!expect(sifDmaIntrModule.handled && sifDmaIntrModule.startResult > 0 && sifDmaIntrPayload == 0x494E5452u, + "sceSifSetDmaIntr should copy to EE memory and return a transfer id")) return 1; + uint32_t sifDmaIntrMarker = 0u; + (void)iop.readMemory(0x000101E0u, &sifDmaIntrMarker, sizeof(sifDmaIntrMarker)); + if (!expect(sifDmaIntrMarker == 0u, "the completion callback should wait for the transfer to finish")) return 1; + iop.runEeCycles(8u * 4096u); + (void)iop.readMemory(0x000101E0u, &sifDmaIntrMarker, sizeof(sifDmaIntrMarker)); + if (!expect(sifDmaIntrMarker == 0x55u, "sceSifSetDmaIntr should run its completion callback with its argument")) return 1; + iop.reset(); writeVblankSchedulingIrx(host, 0x100u); const ModuleLoadResult vblankModule = iop.loadModuleBuffer(0x100u); @@ -1148,6 +1262,13 @@ int main() if (!expect(!emittedUnhandledImport, "MCMAN registration still emitted an unhandled IOP import")) return 1; + iop.reset(); + host.logs.clear(); + writeSecrAuthCardIrx(host, 0x100u); + const ModuleLoadResult secrAuth = iop.loadModuleBuffer(0x100u); + if (!expect(secrAuth.handled && secrAuth.startResult == 1, + "SecrAuthCard should authenticate the emulated memory card")) return 1; + iop.reset(); host.logs.clear(); writeCdvdLifecycleIrx(host, 0x100u); diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index a19d29e1f0..b33902e64c 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -3,6 +3,7 @@ #include "emulator/iop_emulator_const.h" #include "emulator/core/iop_memory.h" #include "emulator/imports/iop_cdvd.h" +#include "emulator/imports/iop_dmacman.h" #include "emulator/imports/iop_imports.h" #include "emulator/imports/iop_loadcore.h" #include "emulator/imports/iop_timrman.h" @@ -513,13 +514,188 @@ namespace return expect(persisted[4] == 0x12, "programmed pages should persist in the image file") && expect(erased[4] == 0xFF && erased[7] == 0xFF, "erasing block 0 should reset page 3 to 0xFF"); } + // Server modules pull EE payloads (memory card writes) with sceSifGetOtherData. + bool testSifGetOtherData() + { + class GuestHost final : public NullHost + { + public: + bool readGuest(uint32_t address, void *destination, size_t size) const override + { + if (address > guest.size() || size > guest.size() - address) + return false; + std::memcpy(destination, guest.data() + address, size); + return true; + } + std::vector guest = std::vector(0x1000u, 0u); + }; + + GuestHost host; + IopMemory memory; + IopKernel kernel(memory); + IopRpcBridge rpc(host, memory, kernel); + const uint8_t payload[8] = {'M', 'C', '2', 'S', 'A', 'V', 'E', '!'}; + std::memcpy(host.guest.data() + 0x400u, payload, sizeof(payload)); + + IopCpuState cpu{}; + cpu.gpr[4] = 0x2000u; // receive data + cpu.gpr[5] = 0x400u; // EE source + cpu.gpr[6] = 0x30000u; // IOP destination + cpu.gpr[7] = sizeof(payload); + cpu.gpr[2] = 0xFFFFu; + uint8_t copied[8] = {}; + if (!expect(rpc.dispatchSifCmdImport(23u, cpu), "sceSifGetOtherData was not handled") || + !expect(cpu.gpr[2] == 0u, "sceSifGetOtherData should succeed") || + !expect(memory.readRam(0x30000u, copied, sizeof(copied)) && std::memcmp(copied, payload, sizeof(payload)) == 0, + "sceSifGetOtherData should copy EE memory into IOP RAM")) + return false; + + cpu.gpr[5] = 0xFFF0u; // outside EE memory + return expect(rpc.dispatchSifCmdImport(23u, cpu) && static_cast(cpu.gpr[2]) < 0, + "sceSifGetOtherData should fail on an unreadable EE source"); + } + + // mc2_s1 copies card specs with back-to-back lwl/lwr; the second must merge into the first's pending load. + bool testUnalignedLoadPair() + { + IopMemory memory; + IopCpuCore core(memory); + const uint32_t program[] = { + 0x88A70003u, // lwl a3, 3(a1) + 0x98A70000u, // lwr a3, 0(a1) + 0x00000000u, + 0x00000000u, + }; + const uint8_t data[8] = {0xAA, 0xAA, 0xAA, 0x11, 0x22, 0x33, 0x44, 0xAA}; + (void)memory.writeRam(0x1000u, program, sizeof(program)); + (void)memory.writeRam(0x2000u, data, sizeof(data)); + + IopCpuState cpu{}; + cpu.pc = 0x1000u; + cpu.gpr[5] = 0x2003u; + cpu.gpr[7] = 0xDEADBEEFu; + for (int i = 0; i < 4; ++i) + (void)core.executeInstruction(cpu); + return expect(cpu.gpr[7] == 0x44332211u, "an lwl/lwr pair should load the unaligned word"); + } + + // IOP pad drivers (and sio2d's memory card path) expect a controller in port 1 to answer on SIO2. + bool testSio2DualShock2() + { + IopMemory memory; + const auto transfer = [&memory](uint32_t port, const std::vector &command) + { + memory.write32(0x1F808200u, port | (static_cast(command.size()) << 8u)); + memory.write32(0x1F808204u, 0u); + for (const uint8_t value : command) + memory.write8(0x1F808260u, value); + memory.write32(0x1F808268u, 1u); + std::vector reply(command.size()); + for (uint8_t &value : reply) + value = memory.read8(0x1F808264u); + memory.write32(0x1F808280u, 1u); + return reply; + }; + + const std::vector poll = transfer(0u, {0x01, 0x42, 0x00, 0x00, 0x00}); + if (!expect(poll == std::vector{0xFF, 0x41, 0x5A, 0xFF, 0xFF}, "port 0 should answer a poll as a digital pad at rest") || + !expect(memory.read32(0x1F80826Cu) == 0x1100u, "RECV1 should report the port 0 controller as connected")) + return false; + + const std::vector empty = transfer(1u, {0x01, 0x42, 0x00, 0x00, 0x00}); + if (!expect(empty[1] == 0xFF && memory.read32(0x1F80826Cu) == 0x2D100u, "port 1 should read as an empty socket")) + return false; + + // Enter config mode, switch to analog, leave: polls then carry the sticks at centre. + (void)transfer(0u, {0x01, 0x43, 0x00, 0x01, 0x00}); + const std::vector status = transfer(0u, {0x01, 0x45, 0x00, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A}); + (void)transfer(0u, {0x01, 0x44, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00}); + (void)transfer(0u, {0x01, 0x43, 0x00, 0x00, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A}); + const std::vector analog = transfer(0u, {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + return expect(status[1] == 0xF3 && status[3] == 0x03, "config mode should identify as 0xF3 and report a DualShock 2") && + expect(analog[1] == 0x73 && analog[5] == 0x7F && analog[8] == 0x7F, "analog polls should report centred sticks"); + } + + // sio2man sends memory card packets by DMA through dmacman, so those calls must drive SIO2. + bool testDmacmanDrivesSio2Dma() + { + const std::filesystem::path image = std::filesystem::temp_directory_path() / "ps2x_dmacman_test_card.ps2"; + std::error_code ec; + std::filesystem::remove(image, ec); + + IopMemory memory; + IopDmacman dmacman(memory); + memory.attachMemoryCard(0, image.string()); + + constexpr uint32_t kStack = 0x1000u; + constexpr uint32_t kSendBuffer = 0x17000u; + constexpr uint32_t kReplyBuffer = 0x17630u; + constexpr uint32_t kChannelSio2In = 11u; + constexpr uint32_t kChannelSio2Out = 12u; + const auto call = [&](uint16_t ordinal, uint32_t a0, uint32_t a1 = 0u, uint32_t a2 = 0u, uint32_t a3 = 0u) + { + IopCpuState cpu{}; + cpu.gpr[4] = a0; + cpu.gpr[5] = a1; + cpu.gpr[6] = a2; + cpu.gpr[7] = a3; + cpu.gpr[29] = kStack; + const bool handled = dmacman.dispatchImport(ordinal, cpu); + return handled ? cpu.gpr[2] : 0xDEADBEEFu; + }; + + (void)call(33u, kChannelSio2In, 3u); + (void)call(34u, kChannelSio2In); + (void)call(34u, kChannelSio2Out); + if (!expect(memory.read32(0x1F801570u) == 0x008B0000u, "DPCR2 should hold SIO2in priority 3 and both SIO2 enable bits")) + return false; + + // One probe packet: port 0, four bytes, sent as one four-byte DMA block each way. + memory.write32(0x1F808200u, 4u << 8u); + const uint8_t probe[4] = {0x81, 0x11, 0x00, 0x00}; + (void)memory.writeRam(kSendBuffer, probe, sizeof(probe)); + + memory.write32(kStack + 16u, 1u); // direction: from memory + if (!expect(call(28u, kChannelSio2In, kSendBuffer, 1u, 1u) == 1u, "sceSetSliceDMA should accept SIO2in")) + return false; + if (!expect(memory.read32(0x1F801540u) == kSendBuffer && memory.read32(0x1F801544u) == 0x10001u && + memory.read32(0x1F801548u) == 0x201u, + "sceSetSliceDMA should program MADR, BCR and CHCR without starting the channel") || + !expect(!std::filesystem::exists(image), "no SIO2 byte should move before sceStartDMA")) + return false; + (void)call(32u, kChannelSio2In); + + memory.write32(kStack + 16u, 0u); // direction: to memory + (void)call(28u, kChannelSio2Out, kReplyBuffer, 1u, 1u); + (void)call(32u, kChannelSio2Out); + + uint8_t reply[4] = {}; + (void)memory.readRam(kReplyBuffer, reply, sizeof(reply)); + bool sio2InIrq = false; + bool sio2OutIrq = false; + while (const auto start = memory.takeDmaStart()) + { + sio2InIrq = sio2InIrq || start->irq == 0x2C; + sio2OutIrq = sio2OutIrq || start->irq == 0x2D; + } + const bool created = std::filesystem::exists(image); + std::filesystem::remove(image, ec); + return expect(created, "the DMA-fed probe should reach the memory card") && + expect(reply[2] == 0x2B && reply[3] == 0x55, "the SIO2out DMA should deliver the probe reply to IOP RAM") && + expect((memory.read32(0x1F801548u) & 0x01000000u) == 0u && (memory.read32(0x1F801558u) & 0x01000000u) == 0u, + "finished channels should clear their CHCR start bit") && + expect(sio2InIrq && sio2OutIrq, "both SIO2 DMA channels should raise their completion interrupts") && + expect(call(9u, kChannelSio2Out) == memory.read32(0x1F801558u), "dmac_ch_get_chcr should read the channel register") && + expect(call(28u, 6u, kSendBuffer, 1u, 1u) == 0u, "sceSetSliceDMA must reject the OTC channel"); + } } int main() { if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdSearchFile() || !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || !testSliceEndDoesNotHandTheCpuToAnEqualPriorityThread() || - !testSliceEndInsideCpuSuspendIntrResumesSameThread() || !testSpu2AutoDmaCompletesAtPlaybackRate()) + !testSliceEndInsideCpuSuspendIntrResumesSameThread() || !testSpu2AutoDmaCompletesAtPlaybackRate() || + !testDmacmanDrivesSio2Dma() || !testSio2DualShock2() || !testSifGetOtherData() || !testUnalignedLoadPair()) return 1; std::cout << "ps2xIOP import tests passed\n"; return 0; From 727eef42d4b2f878e6039027341b92e2709511ad Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:26:22 -0300 Subject: [PATCH 39/91] Rasterize batched GS primitives in row bands on worker threads, with per-primitive texture setup and in-place texture pages --- .../include/runtime/gs/gs_cpu_backend.h | 102 ++- .../runtime/gs/gs_texture_page_cache.h | 75 ++- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 631 ++++++++++++++++-- ps2xTest/src/ps2_gs_tests.cpp | 160 +++++ 4 files changed, 910 insertions(+), 58 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 7c4715c69d..161d18e8f9 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -4,6 +4,8 @@ #include "runtime/gs/gs_texture_page_cache.h" #include +#include +#include #include #include @@ -11,6 +13,11 @@ class GSCpuBackend final : public GSRasterBackend { public: GSCpuBackend(); + ~GSCpuBackend() override; + + // Threads (including the caller) that rasterize batched primitives; 0 picks the default + // (PS2X_GS_THREADS, else half the cores). 1 draws every primitive immediately. + void SetRasterThreads(uint32_t participants); void Initialize(uint8_t *vram, uint32_t vramSize) override; void Reset() override; @@ -40,10 +47,51 @@ class GSCpuBackend final : public GSRasterBackend uint32_t ReadTextureVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y); void WriteVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value); - void DrawPrimitive(const GSPrimitiveBatch &batch); - void DrawSprite(const GSPrimitiveBatch &batch); - void DrawTriangle(const GSPrimitiveBatch &batch); - void DrawLine(const GSPrimitiveBatch &batch); + // Where a rasterizer pass writes and samples: all rows, or every Nth band of 8 rows on a worker. + struct RasterTrace + { + uint32_t primitive = 0; + int y = -1; + int x = -1; + bool sampled = false; + }; + struct RasterContext + { + uint32_t workers = 1; + uint32_t index = 0; + GSMem::TexturePageCache *cache = nullptr; + RasterTrace *trace = nullptr; + uint32_t primitive = 0; + // The palette as it was when a queued primitive was submitted; null uses the current CLUT. + const uint32_t *clut = nullptr; + + bool ownsRow(int y) const + { + return workers == 1u || ((static_cast(y) >> 3u) % workers) == index; + } + void sampled(int y, int x) const + { + if (trace) + { + trace->primitive = primitive; + trace->y = y; + trace->x = x; + trace->sampled = true; + } + } + }; + class RasterWorkers; + + void DrawPrimitive(const GSPrimitiveBatch &batch, const uint32_t *clut = nullptr); + void DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster); + void PrepareTexturePageCache(const GSDrawState &state); + void QueuePrimitive(const GSPrimitiveBatch &batch); + void FlushPendingUnlocked(); + void RasterizePendingInParallel(); + uint32_t RasterParticipants() const; + void DrawSprite(const GSPrimitiveBatch &batch, const RasterContext &raster); + void DrawTriangle(const GSPrimitiveBatch &batch, const RasterContext &raster); + void DrawLine(const GSPrimitiveBatch &batch, const RasterContext &raster); // Per-primitive register decoding hoisted out of the per-pixel path. struct PixelPipeline; // A wrapped texel coordinate along one axis (plus its neighbour and weight when filtering). @@ -57,10 +105,31 @@ class GSCpuBackend final : public GSRasterBackend static PixelPipeline MakePixelPipeline(const GSDrawState &state); void WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); void WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); - uint32_t SampleTexture(const GSDrawState &state, float s, float t, float q, uint16_t u, uint16_t v); + // Texture registers decoded once per primitive, with the CLUT expanded to RGBA for indexed formats. + struct TextureSampler + { + const GSDrawState *state = nullptr; + uint32_t psm = 0; + uint32_t tbp0 = 0; + uint32_t tbw = 0; + const uint32_t *clut = nullptr; + GSMem::TexturePageCache *cache = nullptr; + int texW = 1; + int texH = 1; + uint8_t wrapU = 0; + uint8_t wrapV = 0; + uint16_t minU = 0; + uint16_t maxU = 0; + uint16_t minV = 0; + uint16_t maxV = 0; + bool linear = false; + }; + TextureSampler MakeTextureSampler(const GSDrawState &state, GSMem::TexturePageCache &cache, const uint32_t *clut = nullptr); + void ExpandClut(const GSDrawState &state, uint32_t *rgba); + uint32_t SampleTexture(const TextureSampler &sampler, float s, float t, float q, uint16_t u, uint16_t v); static TextureAxis PrepareTextureAxis(float coordinate, int size, uint8_t mode, uint16_t regionMin, uint16_t regionMax, bool linear); - uint32_t SampleTexel(const GSDrawState &state, int u, int v); - uint32_t SampleTextureAxes(const GSDrawState &state, const TextureAxis &u, const TextureAxis &v); + uint32_t SampleTexel(const TextureSampler &sampler, int u, int v); + uint32_t SampleTextureAxes(const TextureSampler &sampler, const TextureAxis &u, const TextureAxis &v); uint32_t LookupCLUT(const GSDrawState &state, uint8_t index, uint8_t cpsm, uint8_t csm, uint8_t csa, uint8_t sourcePsm); void PerformLocalToLocalTransfer(); @@ -87,6 +156,8 @@ class GSCpuBackend final : public GSRasterBackend std::array m_writeVramFuncs{}; std::array m_clut{}; std::array m_clutCbp{}; + // Bumped whenever m_clut changes so expanded palettes cached per thread are rebuilt. + uint64_t m_clutGeneration = 0; GSMem::TexturePageCache m_texturePageCache; GSTransferCommand m_transfer{}; @@ -97,4 +168,21 @@ class GSCpuBackend final : public GSRasterBackend // straddle two of them, so the leading bytes of a split pixel wait here for the rest. std::array m_uploadCarry{}; uint32_t m_uploadCarryBytes = 0; + + // Primitives that touch disjoint memory from each other's texture reads are batched and rasterized by + // row bands on worker threads; anything that reads or writes VRAM otherwise flushes the batch first. + std::vector m_pending; + // Indexed textures keep the palette they were queued with: CLUT loads run immediately. + std::vector> m_pendingCluts; + std::vector m_pendingClutIndex; + uint64_t m_pendingClutGeneration = UINT64_MAX; + uint64_t m_pendingClutKey = 0; + // First queued primitive after the latest CLUT load; earlier samples no longer decide the cached page. + size_t m_pendingClutReadIndex = 0; + std::bitset<512> m_pendingWritePages; + std::bitset<512> m_pendingTexturePages; + uint64_t m_pendingTargetKey = 0; + uint64_t m_pendingPixels = 0; + std::unique_ptr m_workers; + uint32_t m_rasterParticipants = 0; }; diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h index 1cb15401e3..b4a8dfccf6 100644 --- a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -11,27 +11,98 @@ namespace GSMem { public: static constexpr uint32_t kPageSize = 8192u; + static constexpr uint32_t kPageCount = 512u; void Invalidate() noexcept { m_pageBase = UINT32_MAX; + m_hasCopy = false; } // byteAddress is the wrapped, swizzled VRAM address. The returned // pointer is valid only until the next miss or invalidation. + // + // The cached page shows VRAM as it was when the page was loaded. Copying it on every miss + // dominated textured drawing, so a page is read in place until a writer is about to change it + // (see LivePage/Snapshot); copyOnMiss restores the eager copy where reads and writes interleave. 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; + m_hasCopy = m_copyOnMiss && CopyRangeHas(pageBase / kPageSize); + if (m_hasCopy) + std::memcpy(m_bytes.data(), vram + pageBase, kPageSize); } - return m_bytes.data() + (byteAddress & (kPageSize - 1u)); + return (m_hasCopy ? m_bytes.data() : vram + pageBase) + (byteAddress & (kPageSize - 1u)); + } + + // True when a page is cached but still read in place, i.e. a write to it must snapshot it first. + bool LivePage(uint32_t& pageBase) const noexcept + { + if (m_hasCopy || m_pageBase == UINT32_MAX) + return false; + pageBase = m_pageBase; + return true; + } + + void Snapshot(const uint8_t* vram) noexcept + { + std::memcpy(m_bytes.data(), vram + m_pageBase, kPageSize); + m_hasCopy = true; + } + + bool CachedPage(uint32_t& pageBase) const noexcept + { + if (m_pageBase == UINT32_MAX) + return false; + pageBase = m_pageBase; + return true; + } + + bool MatchesVram(const uint8_t* vram) const noexcept + { + return !m_hasCopy || std::memcmp(m_bytes.data(), vram + m_pageBase, kPageSize) == 0; + } + + void MakeLive() noexcept + { + m_hasCopy = false; + } + + void SetLivePage(uint32_t pageBase) noexcept + { + m_pageBase = pageBase; + m_hasCopy = false; + } + + // While set, pages in the two ranges (page indices, wrapping at kPageCount) are copied when loaded + // because the current primitive writes them; other pages cannot change meanwhile and stay in place. + void SetCopyOnMiss(bool copyOnMiss, uint32_t first0 = 0u, uint32_t count0 = kPageCount, + uint32_t first1 = 0u, uint32_t count1 = 0u) noexcept + { + m_copyOnMiss = copyOnMiss; + m_copyFirst[0] = first0; + m_copyCount[0] = count0; + m_copyFirst[1] = first1; + m_copyCount[1] = count1; } private: + bool CopyRangeHas(uint32_t page) const noexcept + { + for (int i = 0; i < 2; ++i) + if (((page + kPageCount - m_copyFirst[i]) % kPageCount) < m_copyCount[i]) + return true; + return false; + } + alignas(64) std::array m_bytes{}; uint32_t m_pageBase = UINT32_MAX; + bool m_hasCopy = false; + bool m_copyOnMiss = false; + uint32_t m_copyFirst[2] = {0u, 0u}; + uint32_t m_copyCount[2] = {0u, 0u}; }; } diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 694e5337aa..3952fd8d4a 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -8,6 +8,9 @@ #include "ps2_log.h" #include #include +#include +#include +#include #include #include #include @@ -96,6 +99,79 @@ namespace } } + + // Conservative VRAM page sets, used to decide when the texture page cache must snapshot a page. + constexpr uint32_t kVramPageCount = GSMem::MEMORY_SIZE / GSMem::TexturePageCache::kPageSize; + + struct PageSpan + { + uint32_t first = 0; + uint32_t count = 0; + }; + + void psmPageShift(uint32_t psm, uint32_t &widthShift, uint32_t &heightShift) + { + switch (psm) + { + case GS_PSM_CT16: + case GS_PSM_CT16S: + case GS_PSM_Z16: + case GS_PSM_Z16S: + widthShift = 6u; + heightShift = 6u; + break; + case GS_PSM_T8: + widthShift = 7u; + heightShift = 6u; + break; + case GS_PSM_T4: + widthShift = 7u; + heightShift = 7u; + break; + default: + widthShift = 6u; + heightShift = 5u; + break; + } + } + + // Pages a buffer can touch for pixels in [x, maxX] x [y, maxY]. A base block that is not page aligned + // spills its swizzled blocks into the following page, so that page is included too. + PageSpan pageSpan(uint32_t baseBlock, uint32_t bw, uint32_t psm, uint32_t x, uint32_t y, uint32_t maxX, uint32_t maxY) + { + uint32_t widthShift = 6u; + uint32_t heightShift = 5u; + psmPageShift(psm, widthShift, heightShift); + const uint64_t stride = (bw * 64u) >> widthShift; + const uint64_t first = baseBlock / 32u + (y >> heightShift) * stride + (x >> widthShift); + const uint64_t last = baseBlock / 32u + (maxY >> heightShift) * stride + (maxX >> widthShift) + ((baseBlock % 32u) != 0u ? 1u : 0u); + PageSpan span{}; + span.first = static_cast(first % kVramPageCount); + span.count = static_cast(std::min(last - first + 1u, kVramPageCount)); + return span; + } + + bool spanHasPage(const PageSpan &span, uint32_t page) + { + return ((page + kVramPageCount - span.first) % kVramPageCount) < span.count; + } + + bool spansOverlap(const PageSpan &a, const PageSpan &b) + { + if (a.count == 0u || b.count == 0u) + return false; + return a.count + b.count > kVramPageCount || spanHasPage(a, b.first) || spanHasPage(b, a.first); + } + + + // Palette versions are unique across backends: expanded palettes are cached per thread, and a new + // backend can reuse a destroyed one's address. + uint64_t nextClutGeneration() + { + static std::atomic generation{0}; + return generation.fetch_add(1u, std::memory_order_relaxed) + 1u; + } + std::atomic s_debugPrimitiveCount{0}; std::atomic s_debugPixelCount{0}; std::atomic s_debugContext1PrimitiveCount{0}; @@ -513,12 +589,325 @@ GSCpuBackend::GSCpuBackend() Reset(); } + + +class GSCpuBackend::RasterWorkers +{ +public: + explicit RasterWorkers(uint32_t participants) + : m_participants(participants) + { + for (uint32_t index = 1; index < participants; ++index) + m_threads.emplace_back([this, index]() { loop(index); }); + } + + ~RasterWorkers() + { + { + std::lock_guard lock(m_mutex); + m_stop = true; + } + m_start.notify_all(); + for (std::thread &thread : m_threads) + thread.join(); + } + + uint32_t participants() const { return m_participants; } + + // Runs job(i) for every participant; the caller is participant 0. + void run(const std::function &job) + { + { + std::lock_guard lock(m_mutex); + m_job = &job; + m_remaining = m_participants - 1u; + ++m_generation; + } + m_start.notify_all(); + job(0u); + std::unique_lock lock(m_mutex); + m_done.wait(lock, [this]() { return m_remaining == 0u; }); + m_job = nullptr; + } + +private: + void loop(uint32_t index) + { + uint64_t seen = 0; + for (;;) + { + const std::function *job = nullptr; + { + std::unique_lock lock(m_mutex); + m_start.wait(lock, [&]() { return m_stop || m_generation != seen; }); + if (m_stop) + return; + seen = m_generation; + job = m_job; + } + (*job)(index); + { + std::lock_guard lock(m_mutex); + if (--m_remaining == 0u) + m_done.notify_one(); + } + } + } + + uint32_t m_participants = 1; + std::vector m_threads; + std::mutex m_mutex; + std::condition_variable m_start; + std::condition_variable m_done; + const std::function *m_job = nullptr; + uint64_t m_generation = 0; + uint32_t m_remaining = 0; + bool m_stop = false; +}; + +namespace +{ + uint32_t defaultRasterParticipants() + { + static const uint32_t participants = []() + { + if (const char *value = std::getenv("PS2X_GS_THREADS")) + return static_cast(std::clamp(std::atoi(value), 1, 16)); + const uint32_t cores = std::max(1u, std::thread::hardware_concurrency()); + return std::clamp(cores / 2u, 1u, 6u); + }(); + return participants; + } + + constexpr size_t kMaxPendingPrimitives = 8192u; + constexpr uint64_t kMaxPendingPixels = 1u << 22u; + // Below this, waking the workers costs more than drawing the batch on the calling thread. + constexpr uint64_t kMinParallelPixels = 16384u; + + void markPages(std::bitset<512> &pages, const PageSpan &span) + { + for (uint32_t i = 0; i < span.count; ++i) + pages.set((span.first + i) % 512u); + } + + bool anyPage(const std::bitset<512> &pages, const PageSpan &span) + { + for (uint32_t i = 0; i < span.count; ++i) + if (pages.test((span.first + i) % 512u)) + return true; + return false; + } +} + +GSCpuBackend::~GSCpuBackend() = default; + +void GSCpuBackend::SetRasterThreads(uint32_t participants) +{ + std::lock_guard lock(m_mutex); + FlushPendingUnlocked(); + m_rasterParticipants = participants; +} + +uint32_t GSCpuBackend::RasterParticipants() const +{ + return m_rasterParticipants != 0u ? m_rasterParticipants : defaultRasterParticipants(); +} + +void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) +{ + const GSDrawState &state = batch.state; + const auto &ctx = state.context; + const uint32_t maxX = ctx.scissor.x1; + const uint32_t maxY = ctx.scissor.y1; + const uint32_t fbw = std::max(ctx.frame.fbw, 1u); + const PageSpan frameSpan = pageSpan(GSInternal::framePageBaseToBlock(ctx.frame.fbp), fbw, ctx.frame.psm, 0u, 0u, maxX, maxY); + const PageSpan depthSpan = pageSpan(GSInternal::framePageBaseToBlock(ctx.zbuf.zbp), fbw, ctx.zbuf.psm, 0u, 0u, maxX, maxY); + const PageSpan &frame = frameSpan; + const PageSpan &depth = depthSpan; + PageSpan texture{}; + if (state.prim.tme) + { + const auto &tex = ctx.tex0; + const uint32_t extentU = (ctx.clamp & 0x2u) != 0u ? 1024u : std::max(state.textureWidth, 1u); + const uint32_t extentV = ((ctx.clamp >> 2) & 0x2u) != 0u ? 1024u : std::max(state.textureHeight, 1u); + const PageSpan span = pageSpan(tex.tbp0, tex.tbw, tex.psm, 0u, 0u, extentU - 1u, extentV - 1u); + texture = span; + } + + // Pixels of one primitive map to distinct memory only if colour and depth do not alias each other, + // and rows can be split only if the primitive never samples what it draws. + const bool selfOverlap = spansOverlap(frameSpan, depthSpan) || + (texture.count != 0u && (spansOverlap(texture, frameSpan) || spansOverlap(texture, depthSpan))); + if (selfOverlap) + { + FlushPendingUnlocked(); + DrawPrimitive(batch); + return; + } + + // Every queued primitive must address colour and depth the same way, so a pixel's bytes stay on the + // row band that owns the pixel. + const uint64_t targetKey = (static_cast(ctx.frame.fbp) << 0u) ^ (static_cast(fbw) << 12u) ^ + (static_cast(ctx.frame.psm) << 20u) ^ (static_cast(ctx.zbuf.zbp) << 28u) ^ + (static_cast(ctx.zbuf.psm) << 40u) ^ (1ull << 63u); + if (!m_pending.empty() && + (targetKey != m_pendingTargetKey || anyPage(m_pendingWritePages, texture) || + anyPage(m_pendingTexturePages, frame) || anyPage(m_pendingTexturePages, depth))) + FlushPendingUnlocked(); + + m_pendingTargetKey = targetKey; + markPages(m_pendingWritePages, frame); + markPages(m_pendingWritePages, depth); + markPages(m_pendingTexturePages, texture); + int32_t clutIndex = -1; + if (state.prim.tme && (isFourBitIndexedPsm(ctx.tex0.psm) || isEightBitIndexedPsm(ctx.tex0.psm))) + { + const uint64_t clutKey = (static_cast(ctx.tex0.cpsm) << 0u) | (static_cast(ctx.tex0.csm) << 8u) | + (static_cast(ctx.tex0.csa) << 16u) | (static_cast(ctx.tex0.psm) << 24u) | + (static_cast(state.texa.ta0) << 32u) | (static_cast(state.texa.ta1) << 40u) | + (static_cast(state.texa.aem ? 1u : 0u) << 48u); + if (m_pendingCluts.empty() || m_pendingClutGeneration != m_clutGeneration || m_pendingClutKey != clutKey) + { + m_pendingCluts.emplace_back(); + ExpandClut(state, m_pendingCluts.back().data()); + m_pendingClutGeneration = m_clutGeneration; + m_pendingClutKey = clutKey; + } + clutIndex = static_cast(m_pendingCluts.size() - 1u); + } + m_pendingClutIndex.push_back(clutIndex); + m_pending.push_back(batch); + const uint64_t width = static_cast(maxX >= ctx.scissor.x0 ? maxX - ctx.scissor.x0 + 1u : 1u); + const uint64_t height = static_cast(maxY >= ctx.scissor.y0 ? maxY - ctx.scissor.y0 + 1u : 1u); + uint64_t estimate = width * height; + if (batch.vertexCount >= 2u) + { + float minX = batch.vertices[0].x, maxXf = batch.vertices[0].x, minY = batch.vertices[0].y, maxYf = batch.vertices[0].y; + for (uint32_t i = 1; i < std::min(batch.vertexCount, 3u); ++i) + { + minX = std::min(minX, batch.vertices[i].x); + maxXf = std::max(maxXf, batch.vertices[i].x); + minY = std::min(minY, batch.vertices[i].y); + maxYf = std::max(maxYf, batch.vertices[i].y); + } + const double area = (static_cast(maxXf) - minX + 1.0) * (static_cast(maxYf) - minY + 1.0); + if (area >= 0.0 && area < static_cast(estimate)) + estimate = static_cast(area); + } + m_pendingPixels += estimate; + if (m_pending.size() >= kMaxPendingPrimitives || m_pendingPixels >= kMaxPendingPixels) + FlushPendingUnlocked(); +} + +void GSCpuBackend::FlushPendingUnlocked() +{ + if (m_pending.empty()) + return; + + bool parallel = m_pendingPixels >= kMinParallelPixels && RasterParticipants() > 1u; + uint32_t livePage = 0u; + if (parallel && !m_texturePageCache.LivePage(livePage) && m_texturePageCache.CachedPage(livePage)) + { + // Workers read pages in place, which matches a copied page only if VRAM still holds the same bytes. + if (!m_texturePageCache.MatchesVram(m_vram)) + parallel = false; + else + m_texturePageCache.MakeLive(); + } + + if (parallel) + { + RasterizePendingInParallel(); + } + else + { + for (size_t i = 0; i < m_pending.size(); ++i) + { + const int32_t clutIndex = m_pendingClutIndex[i]; + DrawPrimitive(m_pending[i], clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr); + } + } + m_pending.clear(); + m_pendingCluts.clear(); + m_pendingClutIndex.clear(); + m_pendingClutGeneration = UINT64_MAX; + m_pendingClutReadIndex = 0u; + m_pendingWritePages.reset(); + m_pendingTexturePages.reset(); + m_pendingPixels = 0u; +} + +void GSCpuBackend::RasterizePendingInParallel() +{ + // The page cached before the batch keeps whatever a sequential draw would leave in it: a page about to + // be drawn over is snapshotted first, exactly as PrepareTexturePageCache does for the first writer. + uint32_t livePage = 0u; + if (m_texturePageCache.LivePage(livePage) && + m_pendingWritePages.test((livePage / GSMem::TexturePageCache::kPageSize) % 512u)) + m_texturePageCache.Snapshot(m_vram); + + if (!m_workers || m_workers->participants() != RasterParticipants()) + m_workers = std::make_unique(RasterParticipants()); + const uint32_t participants = m_workers->participants(); + + std::vector caches(participants); + std::vector traces(participants); + const std::function job = [&](uint32_t index) + { + RasterContext raster{}; + raster.workers = participants; + raster.index = index; + raster.cache = &caches[index]; + raster.trace = &traces[index]; + for (size_t i = 0; i < m_pending.size(); ++i) + { + raster.primitive = static_cast(i); + const int32_t clutIndex = m_pendingClutIndex[i]; + raster.clut = clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr; + DrawPrimitiveRows(m_pending[i], raster); + } + }; + m_workers->run(job); + + // Sequentially, the cache would end on the page of the last texel read: the last sampled pixel in + // primitive then raster order, which lies on exactly one band. + int best = -1; + for (uint32_t i = 0; i < participants; ++i) + { + const RasterTrace &trace = traces[i]; + if (!trace.sampled) + continue; + if (best < 0) + { + best = static_cast(i); + continue; + } + const RasterTrace ¤t = traces[static_cast(best)]; + if (trace.primitive > current.primitive || + (trace.primitive == current.primitive && (trace.y > current.y || (trace.y == current.y && trace.x > current.x)))) + best = static_cast(i); + } + uint32_t lastPage = 0u; + if (best >= 0 && traces[static_cast(best)].primitive >= m_pendingClutReadIndex && + caches[static_cast(best)].CachedPage(lastPage)) + m_texturePageCache.SetLivePage(lastPage); +} + 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); + // A new VRAM binding makes queued primitives meaningless; nothing may still refer to the old memory. + m_pending.clear(); + m_pendingCluts.clear(); + m_pendingClutIndex.clear(); + m_pendingClutGeneration = UINT64_MAX; + m_pendingClutReadIndex = 0u; + m_pendingWritePages.reset(); + m_pendingTexturePages.reset(); + m_pendingPixels = 0u; m_vram = vram; m_vramSize = vramSize; ResetUnlocked(); @@ -527,6 +916,7 @@ void GSCpuBackend::Initialize(uint8_t *vram, uint32_t vramSize) void GSCpuBackend::Reset() { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); ResetUnlocked(); } @@ -534,6 +924,7 @@ void GSCpuBackend::ResetUnlocked() { m_clut.fill(0u); m_clutCbp.fill(0u); + m_clutGeneration = nextClutGeneration(); m_texturePageCache.Invalidate(); m_transfer = {}; m_transfer.direction = 3u; @@ -549,12 +940,26 @@ void GSCpuBackend::Submit(const GSPrimitiveBatch &batch) std::lock_guard lock(m_mutex); if (!m_vram || batch.vertexCount == 0u) return; - DrawPrimitive(batch); + if (RasterParticipants() > 1u) + QueuePrimitive(batch); + else + DrawPrimitive(batch); } void GSCpuBackend::LoadClut(const GSTex0Reg &tex0, const GSTexClutReg &texclut) { std::lock_guard lock(m_mutex); + if (!m_pending.empty()) + { + // Queued primitives captured their palettes, so the load can run now unless it reads memory they + // will draw, or a stale cached page would make it read differently than after drawing them. + const uint32_t sourceWidth = tex0.csm == 0u ? 1u : std::max(texclut.cbw, 1u); + const uint32_t maxX = tex0.csm == 0u ? 15u : (static_cast(texclut.cou) << 4u) + 255u; + const uint32_t maxY = tex0.csm == 0u ? 15u : texclut.cov; + if (anyPage(m_pendingWritePages, pageSpan(tex0.cbp, sourceWidth, tex0.cpsm, 0u, 0u, maxX, maxY)) || + !m_texturePageCache.MatchesVram(m_vram)) + FlushPendingUnlocked(); + } if (!m_vram || (!isFourBitIndexedPsm(tex0.psm) && !isEightBitIndexedPsm(tex0.psm))) return; @@ -587,6 +992,9 @@ void GSCpuBackend::LoadClut(const GSTex0Reg &tex0, const GSTexClutReg &texclut) } LoadClutUnlocked(tex0, texclut); + // LoadClutUnlocked reads VRAM (moving the texture page cache) only for 16- and 32-bit palettes. + if (tex0.cpsm == GS_PSM_CT16 || tex0.cpsm == GS_PSM_CT16S || tex0.cpsm == GS_PSM_CT32 || tex0.cpsm == GS_PSM_CT24) + m_pendingClutReadIndex = m_pending.size(); } void GSCpuBackend::LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &texclut) @@ -597,6 +1005,7 @@ void GSCpuBackend::LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &t if (!sixteenBit && !thirtyTwoBit) return; + m_clutGeneration = nextClutGeneration(); const uint32_t entryCount = fourBit ? 16u : 256u; const uint32_t csaMask = sixteenBit ? 0x1Fu : 0x0Fu; const uint32_t destinationBase = (static_cast(tex0.csa) & csaMask) << 4u; @@ -639,23 +1048,27 @@ void GSCpuBackend::LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &t void GSCpuBackend::Flush() { - // CPU backend is immediate. GPU backends may submit command buffers here. + std::lock_guard lock(m_mutex); + FlushPendingUnlocked(); } void GSCpuBackend::TextureFlush() { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); m_texturePageCache.Invalidate(); } void GSCpuBackend::Sync(GSSyncReason) { - // CPU backend is immediate. GPU backends may wait on fences/readbacks here. + std::lock_guard lock(m_mutex); + FlushPendingUnlocked(); } uint32_t GSCpuBackend::ReadVram(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); return ReadVramUnlocked(psm, base, bw, x, y); } @@ -677,6 +1090,7 @@ uint32_t GSCpuBackend::ReadTextureVramUnlocked(uint32_t psm, uint32_t base, uint void GSCpuBackend::WriteVram(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value) { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); WriteVramUnlocked(psm, base, bw, x, y, value); } @@ -684,12 +1098,17 @@ void GSCpuBackend::WriteVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, u { if (!m_vram) return; + uint32_t livePage = 0u; + if (m_texturePageCache.LivePage(livePage) && + spanHasPage(pageSpan(base, bw, psm, x, y, x, y), livePage / GSMem::TexturePageCache::kPageSize)) + m_texturePageCache.Snapshot(m_vram); m_writeVramFuncs[psm & 0x3Fu](m_vram, base, bw, x, y, value); } void GSCpuBackend::SnapshotVram(std::vector &out) const { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); if (!m_vram || m_vramSize == 0u) { out.clear(); @@ -709,7 +1128,37 @@ GSTransferSnapshot GSCpuBackend::GetTransferSnapshot() const return result; } -void GSCpuBackend::DrawPrimitive(const GSPrimitiveBatch &batch) +void GSCpuBackend::PrepareTexturePageCache(const GSDrawState &state) +{ + const auto &ctx = state.context; + const uint32_t maxX = ctx.scissor.x1; + const uint32_t maxY = ctx.scissor.y1; + const uint32_t fbw = std::max(ctx.frame.fbw, 1u); + const PageSpan frame = pageSpan(GSInternal::framePageBaseToBlock(ctx.frame.fbp), fbw, ctx.frame.psm, 0u, 0u, maxX, maxY); + const PageSpan depth = pageSpan(GSInternal::framePageBaseToBlock(ctx.zbuf.zbp), fbw, ctx.zbuf.psm, 0u, 0u, maxX, maxY); + + uint32_t livePage = 0u; + if (m_texturePageCache.LivePage(livePage)) + { + const uint32_t page = livePage / GSMem::TexturePageCache::kPageSize; + if (spanHasPage(frame, page) || spanHasPage(depth, page)) + m_texturePageCache.Snapshot(m_vram); + } + + // A primitive sampling pages it also writes must see each page as of its load, so copy on miss. + bool copyOnMiss = false; + if (state.prim.tme) + { + const auto &tex = ctx.tex0; + const uint32_t extentU = (ctx.clamp & 0x2u) != 0u ? 1024u : std::max(state.textureWidth, 1u); + const uint32_t extentV = ((ctx.clamp >> 2) & 0x2u) != 0u ? 1024u : std::max(state.textureHeight, 1u); + const PageSpan texture = pageSpan(tex.tbp0, tex.tbw, tex.psm, 0u, 0u, extentU - 1u, extentV - 1u); + copyOnMiss = spansOverlap(texture, frame) || spansOverlap(texture, depth); + } + m_texturePageCache.SetCopyOnMiss(copyOnMiss, frame.first, frame.count, depth.first, depth.count); +} + +void GSCpuBackend::DrawPrimitive(const GSPrimitiveBatch &batch, const uint32_t *clut) { const GSDrawState &state = batch.state; const auto &ctx = state.context; @@ -819,19 +1268,35 @@ void GSCpuBackend::DrawPrimitive(const GSPrimitiveBatch &batch) } }); + PrepareTexturePageCache(state); + struct CopyOnMissReset + { + GSMem::TexturePageCache &cache; + ~CopyOnMissReset() { cache.SetCopyOnMiss(false); } + } copyOnMissReset{m_texturePageCache}; + + RasterContext raster{}; + raster.cache = &m_texturePageCache; + raster.clut = clut; + DrawPrimitiveRows(batch, raster); +} + +void GSCpuBackend::DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster) +{ + const GSDrawState &state = batch.state; switch (state.prim.type) { case GS_PRIM_SPRITE: - DrawSprite(batch); + DrawSprite(batch, raster); break; case GS_PRIM_TRIANGLE: case GS_PRIM_TRISTRIP: case GS_PRIM_TRIFAN: - DrawTriangle(batch); + DrawTriangle(batch, raster); break; case GS_PRIM_LINE: case GS_PRIM_LINESTRIP: - DrawLine(batch); + DrawLine(batch, raster); break; case GS_PRIM_POINT: { @@ -839,7 +1304,8 @@ void GSCpuBackend::DrawPrimitive(const GSPrimitiveBatch &batch) const auto &ctx = state.context; int px = static_cast(v.x) - (ctx.xyoffset.ofx >> 4); int py = static_cast(v.y) - (ctx.xyoffset.ofy >> 4); - WritePixel(state, px, py, static_cast(v.z), v.r, v.g, v.b, v.a, v.fog); + if (raster.ownsRow(py)) + WritePixel(state, px, py, static_cast(v.z), v.r, v.g, v.b, v.a, v.fog); break; } default: @@ -1029,12 +1495,13 @@ void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, ui pixel = Rgba8888ToRgba5551(pixel); } - WriteVramUnlocked(fpsm, fbp, fbw, x, y, pixel); + // PrepareTexturePageCache already made the texture page cache safe for this primitive's writes. + m_writeVramFuncs[fpsm & 0x3Fu](m_vram, fbp, fbw, x, y, pixel); } if (writeMask.writeDepth && !ctx.zbuf.zmask) { - WriteVramUnlocked(zpsm, zbp, fbw, x, y, z); + m_writeVramFuncs[zpsm & 0x3Fu](m_vram, zbp, fbw, x, y, z); } } @@ -1106,22 +1573,76 @@ uint32_t GSCpuBackend::LookupCLUT(const GSDrawState &state, return 0xFFFF00FFu; } -uint32_t GSCpuBackend::SampleTexture(const GSDrawState &state, float s, float t, float q, uint16_t u, uint16_t v) +void GSCpuBackend::ExpandClut(const GSDrawState &state, uint32_t *rgba) { - const auto &ctx = state.context; + const auto &tex = state.context.tex0; + for (uint32_t index = 0; index < 256u; ++index) + rgba[index] = LookupCLUT(state, static_cast(index), tex.cpsm, tex.csm, tex.csa, tex.psm); +} - const int texW = state.textureWidth; - const int texH = state.textureHeight; +GSCpuBackend::TextureSampler GSCpuBackend::MakeTextureSampler(const GSDrawState &state, GSMem::TexturePageCache &cache, const uint32_t *clut) +{ + const auto &ctx = state.context; + const auto &tex = ctx.tex0; + TextureSampler sampler{}; + sampler.state = &state; + sampler.psm = tex.psm; + sampler.tbp0 = tex.tbp0; + sampler.tbw = tex.tbw; + sampler.texW = state.textureWidth; + sampler.texH = state.textureHeight; const uint64_t clamp = ctx.clamp; - const uint8_t wrapU = static_cast(clamp & 0x3u); - const uint8_t wrapV = static_cast((clamp >> 2) & 0x3u); - const uint16_t minU = static_cast((clamp >> 4) & 0x3FFu); - const uint16_t maxU = static_cast((clamp >> 14) & 0x3FFu); - const uint16_t minV = static_cast((clamp >> 24) & 0x3FFu); - const uint16_t maxV = static_cast((clamp >> 34) & 0x3FFu); + sampler.wrapU = static_cast(clamp & 0x3u); + sampler.wrapV = static_cast((clamp >> 2) & 0x3u); + sampler.minU = static_cast((clamp >> 4) & 0x3FFu); + sampler.maxU = static_cast((clamp >> 14) & 0x3FFu); + sampler.minV = static_cast((clamp >> 24) & 0x3FFu); + sampler.maxV = static_cast((clamp >> 34) & 0x3FFu); + sampler.linear = state.linearFilter; + sampler.cache = &cache; + + if (clut && (isFourBitIndexedPsm(tex.psm) || isEightBitIndexedPsm(tex.psm))) + { + sampler.clut = clut; + } + else if (isFourBitIndexedPsm(tex.psm) || isEightBitIndexedPsm(tex.psm)) + { + // LookupCLUT depends only on these inputs and m_clut; expanding all 256 indices once lets the + // per-texel path index a table. Kept per thread so concurrent rasterizers never share it. + struct ExpandedClut + { + uint64_t generation = UINT64_MAX; + uint8_t cpsm = 0, csm = 0, csa = 0, psm = 0, ta0 = 0, ta1 = 0; + bool aem = false; + std::array rgba{}; + }; + thread_local ExpandedClut expanded; + if (expanded.generation != m_clutGeneration || expanded.cpsm != tex.cpsm || + expanded.csm != tex.csm || expanded.csa != tex.csa || expanded.psm != tex.psm || + expanded.ta0 != state.texa.ta0 || expanded.ta1 != state.texa.ta1 || expanded.aem != state.texa.aem) + { + expanded.generation = m_clutGeneration; + expanded.cpsm = tex.cpsm; + expanded.csm = tex.csm; + expanded.csa = tex.csa; + expanded.psm = tex.psm; + expanded.ta0 = state.texa.ta0; + expanded.ta1 = state.texa.ta1; + expanded.aem = state.texa.aem; + ExpandClut(state, expanded.rgba.data()); + } + sampler.clut = expanded.rgba.data(); + } + return sampler; +} + +uint32_t GSCpuBackend::SampleTexture(const TextureSampler &sampler, float s, float t, float q, uint16_t u, uint16_t v) +{ + const int texW = sampler.texW; + const int texH = sampler.texH; float texUf, texVf; - if (state.prim.fst) + if (sampler.state->prim.fst) { texUf = static_cast(u) / 16.0f; texVf = static_cast(v) / 16.0f; @@ -1133,9 +1654,9 @@ uint32_t GSCpuBackend::SampleTexture(const GSDrawState &state, float s, float t, texVf = t * invQ * static_cast(texH); } - return SampleTextureAxes(state, - PrepareTextureAxis(texUf, texW, wrapU, minU, maxU, state.linearFilter), - PrepareTextureAxis(texVf, texH, wrapV, minV, maxV, state.linearFilter)); + return SampleTextureAxes(sampler, + PrepareTextureAxis(texUf, texW, sampler.wrapU, sampler.minU, sampler.maxU, sampler.linear), + PrepareTextureAxis(texVf, texH, sampler.wrapV, sampler.minV, sampler.maxV, sampler.linear)); } GSCpuBackend::TextureAxis GSCpuBackend::PrepareTextureAxis(float coordinate, int size, uint8_t mode, uint16_t regionMin, uint16_t regionMax, bool linear) @@ -1154,47 +1675,42 @@ GSCpuBackend::TextureAxis GSCpuBackend::PrepareTextureAxis(float coordinate, int return axis; } -uint32_t GSCpuBackend::SampleTexel(const GSDrawState &state, int u, int v) +uint32_t GSCpuBackend::SampleTexel(const TextureSampler &sampler, int u, int v) { - const auto &tex = state.context.tex0; - u32 out = ReadTextureVramUnlocked(tex.psm, tex.tbp0, tex.tbw, u, v); + const u32 out = GSMem::ReadTexture(*sampler.cache, m_vram, sampler.psm, sampler.tbp0, sampler.tbw, u, v); + if (sampler.clut) + return sampler.clut[static_cast(out)]; - switch (tex.psm) + switch (sampler.psm) { case GS_PSM_CT32: case GS_PSM_Z32: case GS_PSM_CT24: case GS_PSM_Z24: - return applyTexa(state.texa, tex.psm, out); + return applyTexa(sampler.state->texa, static_cast(sampler.psm), out); case GS_PSM_CT16: case GS_PSM_CT16S: case GS_PSM_Z16: case GS_PSM_Z16S: - return applyTexa(state.texa, tex.psm, Rgba5551ToRgba8888(out)); - case GS_PSM_T8: - case GS_PSM_T8H: - case GS_PSM_T4: - case GS_PSM_T4HL: - case GS_PSM_T4HH: - return LookupCLUT(state, static_cast(out), tex.cpsm, tex.csm, tex.csa, tex.psm); + return applyTexa(sampler.state->texa, static_cast(sampler.psm), Rgba5551ToRgba8888(out)); } return 0xFFFF00FFu; } -uint32_t GSCpuBackend::SampleTextureAxes(const GSDrawState &state, const TextureAxis &u, const TextureAxis &v) +uint32_t GSCpuBackend::SampleTextureAxes(const TextureSampler &sampler, const TextureAxis &u, const TextureAxis &v) { - if (!state.linearFilter) + if (!sampler.linear) { - return SampleTexel(state, u.c0, v.c0); + return SampleTexel(sampler, u.c0, v.c0); } const float fx = u.frac; const float fy = v.frac; - const uint32_t c00 = SampleTexel(state, u.c0, v.c0); - const uint32_t c10 = SampleTexel(state, u.c1, v.c0); - const uint32_t c01 = SampleTexel(state, u.c0, v.c1); - const uint32_t c11 = SampleTexel(state, u.c1, v.c1); + const uint32_t c00 = SampleTexel(sampler, u.c0, v.c0); + const uint32_t c10 = SampleTexel(sampler, u.c1, v.c0); + const uint32_t c01 = SampleTexel(sampler, u.c0, v.c1); + const uint32_t c11 = SampleTexel(sampler, u.c1, v.c1); const uint8_t r = lerpChannel(static_cast(c00 & 0xFFu), static_cast(c10 & 0xFFu), @@ -1223,7 +1739,7 @@ uint32_t GSCpuBackend::SampleTextureAxes(const GSDrawState &state, const Texture (static_cast(a) << 24); } -void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) +void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext &raster) { const GSDrawState &state = batch.state; const GSVertex &v0 = batch.vertices[0]; @@ -1331,15 +1847,19 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) } const PixelPipeline pipe = MakePixelPipeline(state); + const TextureSampler sampler = MakeTextureSampler(state, *raster.cache, raster.clut); for (int y = drawY0; y <= drawY1; ++y) { + if (!raster.ownsRow(y)) + continue; float ty = (static_cast(y - unclippedY0) + 0.5f) / spriteH; float texVf = v0f + (v1f - v0f) * ty; const TextureAxis row = PrepareTextureAxis(axisCoordinate(texVf, texH), texH, wrapV, minV, maxV, state.linearFilter); for (int x = drawX0; x <= drawX1; ++x) { - const uint32_t texel = SampleTextureAxes(state, columns[static_cast(x - drawX0)], row); + const uint32_t texel = SampleTextureAxes(sampler, columns[static_cast(x - drawX0)], row); + raster.sampled(y, x); uint8_t tr = static_cast(texel & 0xFF); uint8_t tg = static_cast((texel >> 8) & 0xFF); @@ -1355,12 +1875,16 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) { const PixelPipeline pipe = MakePixelPipeline(state); for (int y = drawY0; y <= drawY1; ++y) + { + if (!raster.ownsRow(y)) + continue; for (int x = drawX0; x <= drawX1; ++x) WritePixel(pipe, x, y, z1, r, g, b, a, v1.fog); + } } } -void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) +void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterContext &raster) { const GSDrawState &state = batch.state; const GSVertex &v0 = batch.vertices[0]; @@ -1368,6 +1892,7 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) const GSVertex &v2 = batch.vertices[2]; const auto &ctx = state.context; const PixelPipeline pipe = MakePixelPipeline(state); + const TextureSampler sampler = state.prim.tme ? MakeTextureSampler(state, *raster.cache, raster.clut) : TextureSampler{}; int ofx = ctx.xyoffset.ofx >> 4; int ofy = ctx.xyoffset.ofy >> 4; @@ -1399,6 +1924,8 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) for (int y = minY; y <= maxY; ++y) { + if (!raster.ownsRow(y)) + continue; float py = static_cast(y) + 0.5f; for (int x = minX; x <= maxX; ++x) { @@ -1453,7 +1980,8 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) iv = 0; } - uint32_t texel = SampleTexture(state, is, it, iq, iu, iv); + uint32_t texel = SampleTexture(sampler, is, it, iq, iu, iv); + raster.sampled(y, x); uint8_t tr = static_cast(texel & 0xFF); uint8_t tg = static_cast((texel >> 8) & 0xFF); @@ -1479,7 +2007,7 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch) } } -void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch) +void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch, const RasterContext &raster) { const GSDrawState &state = batch.state; const GSVertex &v0 = batch.vertices[0]; @@ -1527,7 +2055,8 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch) double z = (v0.z + (v1.z - v0.z) * t); const uint8_t fog = clampU8(static_cast(v0.fog + (v1.fog - v0.fog) * t)); - WritePixel(pipe, x0, y0, static_cast(z), r, g, b, a, fog); + if (raster.ownsRow(y0)) + WritePixel(pipe, x0, y0, static_cast(z), r, g, b, a, fog); if (x0 == x1 && y0 == y1) break; @@ -1550,6 +2079,7 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch) void GSCpuBackend::BeginTransfer(const GSTransferCommand &command) { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); m_transfer = command; m_transferState.x = command.trxpos.dsax; m_transferState.y = command.trxpos.dsay; @@ -1568,6 +2098,7 @@ void GSCpuBackend::BeginTransfer(const GSTransferCommand &command) void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); if (!data || sizeBytes == 0u || !m_vram || m_transferState.direction != 0u) return; if (m_transfer.trxreg.rrw == 0u || m_transfer.trxreg.rrh == 0u || m_transferState.totalPixels == 0u) @@ -1812,6 +2343,7 @@ void GSCpuBackend::PerformLocalToHostTransfer() uint32_t GSCpuBackend::ConsumeLocalToHostBytes(uint8_t *dst, uint32_t maxBytes) { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); if (!dst || maxBytes == 0u || m_localToHostReadPos >= m_localToHostBuffer.size()) return 0u; const size_t count = std::min(maxBytes, m_localToHostBuffer.size() - m_localToHostReadPos); @@ -1824,6 +2356,7 @@ uint32_t GSCpuBackend::ConsumeLocalToHostBytes(uint8_t *dst, uint32_t maxBytes) bool GSCpuBackend::ClearFramebuffer(const GSContext &context, uint32_t rgba) { std::lock_guard lock(m_mutex); + const_cast(this)->FlushPendingUnlocked(); if (!m_vram || context.frame.fbw == 0u) return false; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 8be5e3bc40..ffb6381cac 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -6,6 +6,7 @@ #include "ps2_stubs.h" #include "ps2_syscalls.h" #include "runtime/gs/gs_frontend.h" +#include "runtime/gs/gs_cpu_backend.h" #include "runtime/ee_scheduler.h" #include "runtime/gs/ps2_gs_memory.h" #include "runtime/gs/ps2_gs_psmct32.h" @@ -1123,6 +1124,7 @@ void register_ps2_gs_tests() gs.writeRegister(GS_REG_XYZ2, kXyz0); gs.writeRegister(GS_REG_UV, kUv1); gs.writeRegister(GS_REG_XYZ2, kXyz1); + gs.writeRegister(GS_REG_FINISH, 0ull); // drawing may be batched until the GS is synchronized for (uint32_t y = 0u; y < 4u; ++y) { @@ -1996,6 +1998,7 @@ void register_ps2_gs_tests() gs.writeRegister(GS_REG_XYZ2, (64ull << 0) | (0ull << 16)); gs.writeRegister(GS_REG_ST, packSt(s, tVal)); gs.writeRegister(GS_REG_XYZ2, (0ull << 0) | (64ull << 16)); + gs.writeRegister(GS_REG_FINISH, 0ull); // drawing may be batched until the GS is synchronized const uint32_t pixel = readReferencePSMCT32Pixel(vram, 0u, 1u, 1u, 1u); t.Equals(pixel, sample.color, @@ -2837,6 +2840,163 @@ void register_ps2_gs_tests() "T4 CSM1 lookup should keep the cached palette after its source VRAM is reused"); }); + tc.Run("GS row-band parallel rasterization writes the same VRAM as drawing serially", [](TestCase &t) + { + // A mixed stream: CT32 and T8/CLUT textures, point and bilinear sampling, STQ and UV + // coordinates, blending, Z tests, primitives that sample their own target, uploads, TEXFLUSH. + auto render = [](uint32_t threads) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0x1234567u; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + + GSTex0Reg clutTex{}; + clutTex.psm = GS_PSM_T8; + clutTex.cpsm = GS_PSM_CT32; + clutTex.cbp = 12000u; + clutTex.cld = 1u; + gs.LoadClut(clutTex, GSTexClutReg{}); + + for (int i = 0; i < 600; ++i) + { + GSPrimitiveBatch batch{}; + GSDrawState &state = batch.state; + const bool sprite = (next() % 3u) == 0u; + state.prim.type = sprite ? GS_PRIM_SPRITE : GS_PRIM_TRIANGLE; + state.prim.tme = (next() % 5u) != 0u; + state.prim.abe = (next() % 2u) != 0u; + state.prim.iip = (next() % 2u) != 0u; + state.prim.fst = sprite || (next() % 2u) != 0u; + auto &ctx = state.context; + ctx.frame.fbp = 0u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT32; + ctx.zbuf.zbp = 150u; + ctx.zbuf.psm = GS_PSM_Z24; + ctx.zbuf.zmask = (next() % 4u) == 0u; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.test = (static_cast(1u + next() % 3u) << 17u) | (1ull << 16u); + ctx.alpha = 0x80ull << 32u | (next() % 2u ? 0x44u : 0x48u); + const bool clut = (next() % 2u) != 0u; + const bool selfSample = (next() % 25u) == 0u; + ctx.tex0.psm = clut ? GS_PSM_T8 : GS_PSM_CT32; + ctx.tex0.tbp0 = selfSample ? 0u : (clut ? 9000u : 10000u) + (next() % 4u) * 32u; + ctx.tex0.tbw = clut ? 4u : 2u; + ctx.tex0.tcc = 1u; + ctx.tex0.tfx = static_cast(next() % 2u); + ctx.tex0.cpsm = GS_PSM_CT32; + ctx.clamp = next() % 2u; + state.textureWidth = clut ? 256u : 128u; + state.textureHeight = 128u; + state.linearFilter = (next() % 2u) != 0u; + batch.vertexCount = sprite ? 2u : 3u; + for (int v = 0; v < 3; ++v) + { + GSVertex &vertex = batch.vertices[static_cast(v)]; + vertex.x = static_cast(next() % 700u) - 30.0f; + vertex.y = static_cast(next() % 500u) - 30.0f; + vertex.z = static_cast(next() % 0xFFFFFFu); + vertex.r = static_cast(next()); + vertex.g = static_cast(next()); + vertex.b = static_cast(next()); + vertex.a = static_cast(next()); + vertex.u = static_cast(next() % (256u * 16u)); + vertex.v = static_cast(next() % (128u * 16u)); + vertex.s = static_cast(next() % 1000u) / 500.0f; + vertex.t = static_cast(next() % 1000u) / 500.0f; + vertex.q = 0.5f + static_cast(next() % 100u) / 100.0f; + } + gs.Submit(batch); + + if (i % 97 == 50) + { + // Rewrite part of a texture without TEXFLUSH, then flush on alternate rounds. + GSTransferCommand upload{}; + upload.bitbltbuf.dbp = 10000u; + upload.bitbltbuf.dbw = 2u; + upload.bitbltbuf.dpsm = GS_PSM_CT32; + upload.trxreg.rrw = 32u; + upload.trxreg.rrh = 8u; + upload.direction = 0u; + gs.BeginTransfer(upload); + std::vector pixels(32u * 8u * 4u); + for (uint8_t &byte : pixels) + byte = static_cast(next()); + gs.UploadImage(pixels.data(), static_cast(pixels.size())); + if ((i / 97) % 2 == 0) + gs.TextureFlush(); + } + } + std::vector out; + gs.SnapshotVram(out); + return out; + }; + + const std::vector serial = render(1u); + const std::vector parallel = render(4u); + t.IsTrue(serial == parallel, "four row bands must produce byte-identical VRAM to one thread"); + }); + + tc.Run("GS texture page buffer also hides draws into the cached page until TEXFLUSH", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + constexpr uint32_t kTextureTbp = 32u; // Physical GS page 1, which the second pass renders into. + constexpr uint64_t kFrameOutput = (2ull << 0) | (1ull << 16) | (static_cast(GS_PSM_CT32) << 24); + constexpr uint64_t kFrameTexturePage = (1ull << 0) | (1ull << 16) | (static_cast(GS_PSM_CT32) << 24); + constexpr uint64_t kTex0 = + (static_cast(kTextureTbp) << 0) | + (1ull << 14) | + (static_cast(GS_PSM_CT32) << 20) | + (1ull << 34) | + (1ull << 35); + constexpr uint32_t kInitialColor = 0x80112233u; + + gs.WriteVram(GS_PSM_CT32, kTextureTbp, 1u, 0u, 0u, kInitialColor); + gs.writeRegister(GS_REG_ZBUF_1, 1ull << 32); + gs.writeRegister(GS_REG_SCISSOR_1, (3ull << 16) | (3ull << 48)); + gs.writeRegister(GS_REG_XYOFFSET_1, 0ull); + gs.writeRegister(GS_REG_TEST_1, 0x30000ull); + gs.writeRegister(GS_REG_ALPHA_1, 0ull); + gs.writeRegister(GS_REG_TEX0_1, kTex0); + + const auto drawPixel = [&gs](uint64_t frame, bool textured, uint32_t x) + { + gs.writeRegister(GS_REG_FRAME_1, frame); + gs.writeRegister(GS_REG_PRIM, static_cast(GS_PRIM_SPRITE) | (textured ? (1ull << 4) : 0ull) | (1ull << 8)); + gs.writeRegister(GS_REG_RGBAQ, 0x80FFEEDDull); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, static_cast(x * 16u)); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, static_cast((x + 1u) * 16u) | (16ull << 16u)); + }; + + drawPixel(kFrameOutput, true, 0u); // loads texture page 1 into the page buffer + drawPixel(kFrameTexturePage, false, 0u); // overwrites the texel by rendering into page 1 + drawPixel(kFrameOutput, true, 1u); // still served from the buffered page + gs.writeRegister(GS_REG_TEXFLUSH, 0ull); + drawPixel(kFrameOutput, true, 2u); + + t.Equals(gs.ReadVram(GS_PSM_CT32, 64u, 1u, 0u, 0u), kInitialColor, "the first sample should read the original texel"); + t.Equals(gs.ReadVram(GS_PSM_CT32, 32u, 1u, 0u, 0u), 0x80FFEEDDu, "the untextured pass should have drawn into page 1"); + t.Equals(gs.ReadVram(GS_PSM_CT32, 64u, 1u, 1u, 0u), kInitialColor, + "a draw into the cached page must stay hidden from texture reads until TEXFLUSH"); + t.Equals(gs.ReadVram(GS_PSM_CT32, 64u, 1u, 2u, 0u), 0x80FFEEDDu, + "TEXFLUSH must expose the page the GS rendered into"); + }); + tc.Run("GS texture page buffer hides local-memory writes until TEXFLUSH", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From 43188f3633060fd998a1803bfa4cea4b5b686802 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:29:34 -0300 Subject: [PATCH 40/91] Stop SPU2 AutoDMA when its channel's STR or the core's ADMAS bit is cleared --- ps2xIOP/src/emulator/core/iop_memory.cpp | 8 +++++++- ps2xIOP/src/emulator/core/iop_spu2.cpp | 2 ++ ps2xIOP/src/emulator/core/iop_spu2.h | 1 + ps2xIOP/tests/iop_spu2_tests.cpp | 18 ++++++++++++++++-- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index dffc137248..33e0405a78 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -297,8 +297,14 @@ namespace ps2x::iop::detail runSio2Dma(address, value); return; } - if ((address != kDmaSpu0Chcr && address != kDmaSpu1Chcr) || (value & kDmaStart) == 0u) + if (address != kDmaSpu0Chcr && address != kDmaSpu1Chcr) return; + if ((value & kDmaStart) == 0u) + { + // Clearing STR aborts the channel, which is how a driver stops a looping block transfer. + m_spu2.stopAutoDma(address == kDmaSpu1Chcr ? 1u : 0u); + return; + } const bool secondCore = address == kDmaSpu1Chcr; const uint32_t core = secondCore ? 1u : 0u; diff --git a/ps2xIOP/src/emulator/core/iop_spu2.cpp b/ps2xIOP/src/emulator/core/iop_spu2.cpp index 95995c5816..4d4feffb9c 100644 --- a/ps2xIOP/src/emulator/core/iop_spu2.cpp +++ b/ps2xIOP/src/emulator/core/iop_spu2.cpp @@ -330,6 +330,8 @@ namespace ps2x::iop::detail break; case kRegAdmas: core.admas = value; + if ((value & (1u << coreIndex)) == 0u) + core.adma.active = false; break; case kRegEndxLo: case kRegEndxHi: diff --git a/ps2xIOP/src/emulator/core/iop_spu2.h b/ps2xIOP/src/emulator/core/iop_spu2.h index 393e038769..7a79c9f8a2 100644 --- a/ps2xIOP/src/emulator/core/iop_spu2.h +++ b/ps2xIOP/src/emulator/core/iop_spu2.h @@ -50,6 +50,7 @@ namespace ps2x::iop::detail // Starts DMA channel 4 (core 0) or 7 (core 1). Returns false for AutoDMA, which drains at the // playback rate and reports completion through takeEvents(). bool startDma(uint32_t core, uint32_t madr, uint32_t bytes, bool toSpu); + void stopAutoDma(uint32_t core) noexcept { m_cores[core & 1u].adma.active = false; } [[nodiscard]] bool autoDmaActive(uint32_t core) const noexcept { return m_cores[core & 1u].adma.active; } [[nodiscard]] uint32_t autoDmaAddress(uint32_t core) const noexcept { return m_cores[core & 1u].adma.address; } diff --git a/ps2xIOP/tests/iop_spu2_tests.cpp b/ps2xIOP/tests/iop_spu2_tests.cpp index 2b9970fd69..c2112e8b66 100644 --- a/ps2xIOP/tests/iop_spu2_tests.cpp +++ b/ps2xIOP/tests/iop_spu2_tests.cpp @@ -230,9 +230,23 @@ namespace out.clear(); memory.mixSpu2(256u, out); - return expect(std::all_of(out.begin(), out.end(), [](int16_t sample) + if (!expect(std::all_of(out.begin(), out.end(), [](int16_t sample) + { return sample == 0; }), + "an idle AutoDMA input plays silence")) + return false; + + // Clearing STR mid-stream aborts it: no more blocks are pulled and no completion is raised. + memory.write32(0x1F8010C0u, 0x50000u); + memory.write32(0x1F8010C4u, (32u << 16u) | 16u); + memory.write32(0x1F8010C8u, 0x01000201u); + memory.mixSpu2(256u, out); + memory.write32(0x1F8010C8u, 0x00000201u); + out.clear(); + memory.mixSpu2(512u, out); + return expect(!memory.takeDmaStart().has_value(), "an aborted AutoDMA must not complete") && + expect(std::all_of(out.begin(), out.end(), [](int16_t sample) { return sample == 0; }), - "an idle AutoDMA input plays silence"); + "an aborted AutoDMA must stop feeding the mix"); } } From f0b37a5c5b1be49589be8200c61ff5076767c881 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:41:25 -0300 Subject: [PATCH 41/91] Draw GS primitives that sample the top byte of their own RGB24/Z24 target in parallel row bands --- .../include/runtime/gs/gs_cpu_backend.h | 10 + .../runtime/gs/gs_texture_page_cache.h | 66 ++++++ ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 196 +++++++++++++++++- ps2xTest/src/ps2_gs_tests.cpp | 133 ++++++++++++ 4 files changed, 404 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 161d18e8f9..9ac6f39a12 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -64,11 +64,18 @@ class GSCpuBackend final : public GSRasterBackend uint32_t primitive = 0; // The palette as it was when a queued primitive was submitted; null uses the current CLUT. const uint32_t *clut = nullptr; + // Set when the band's cache records read positions (see RasterizeTopByteAliasInParallel). + bool trackReads = false; bool ownsRow(int y) const { return workers == 1u || ((static_cast(y) >> 3u) % workers) == index; } + void beginSample(int y, int x) const + { + if (trackReads) + cache->BeginPixel(y, x); + } void sampled(int y, int x) const { if (trace) @@ -88,6 +95,7 @@ class GSCpuBackend final : public GSRasterBackend void QueuePrimitive(const GSPrimitiveBatch &batch); void FlushPendingUnlocked(); void RasterizePendingInParallel(); + bool RasterizeTopByteAliasInParallel(const GSPrimitiveBatch &batch); uint32_t RasterParticipants() const; void DrawSprite(const GSPrimitiveBatch &batch, const RasterContext &raster); void DrawTriangle(const GSPrimitiveBatch &batch, const RasterContext &raster); @@ -185,4 +193,6 @@ class GSCpuBackend final : public GSRasterBackend uint64_t m_pendingPixels = 0; std::unique_ptr m_workers; uint32_t m_rasterParticipants = 0; + // Rebuilds the texture cache's copied page after a parallel draw into the page it holds. + std::vector m_pageRebuildScratch; }; diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h index b4a8dfccf6..b7d3d619ee 100644 --- a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -28,6 +28,8 @@ namespace GSMem const uint8_t* Resolve(const uint8_t* vram, uint32_t byteAddress) noexcept { const uint32_t pageBase = byteAddress & ~(kPageSize - 1u); + if (m_tracking) + NoteRead(pageBase); if (m_pageBase != pageBase) { m_pageBase = pageBase; @@ -89,7 +91,66 @@ namespace GSMem m_copyCount[1] = count1; } + + // Replaces the cached page with a copy, as if it had been loaded when VRAM held these bytes. + void SetCopy(const uint8_t* bytes, uint32_t pageBase) noexcept + { + std::memcpy(m_bytes.data(), bytes, kPageSize); + m_pageBase = pageBase; + m_hasCopy = true; + } + + // Read tracking for a rasterizer band: remembers the page of the latest texel read and the position of + // the latest read of any other page, which is where a sequential cache last switched pages. + struct ReadPosition + { + int y = -1; + int x = -1; + uint32_t texel = 0; + bool valid = false; + }; + struct ReadTrack + { + uint32_t lastPage = UINT32_MAX; + ReadPosition last; + ReadPosition other; + bool switchedInsidePixel = false; + }; + + void EnableTracking() noexcept + { + m_tracking = true; + m_track = {}; + } + + void BeginPixel(int y, int x) noexcept + { + m_tagY = y; + m_tagX = x; + m_tagTexel = 0u; + } + + const ReadTrack& Track() const noexcept + { + return m_track; + } + private: + void NoteRead(uint32_t page) noexcept + { + const ReadPosition position{m_tagY, m_tagX, m_tagTexel++, true}; + if (page != m_track.lastPage) + { + if (m_track.lastPage != UINT32_MAX) + { + m_track.other = m_track.last; + m_track.switchedInsidePixel = m_track.last.y == m_tagY && m_track.last.x == m_tagX; + } + m_track.lastPage = page; + } + m_track.last = position; + } + bool CopyRangeHas(uint32_t page) const noexcept { for (int i = 0; i < 2; ++i) @@ -102,6 +163,11 @@ namespace GSMem uint32_t m_pageBase = UINT32_MAX; bool m_hasCopy = false; bool m_copyOnMiss = false; + bool m_tracking = false; + int m_tagY = -1; + int m_tagX = -1; + uint32_t m_tagTexel = 0u; + ReadTrack m_track; uint32_t m_copyFirst[2] = {0u, 0u}; uint32_t m_copyCount[2] = {0u, 0u}; }; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 3952fd8d4a..3673baba1d 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -684,6 +684,21 @@ namespace // Below this, waking the workers costs more than drawing the batch on the calling thread. constexpr uint64_t kMinParallelPixels = 16384u; + // T8H/T4HL/T4HH texels are the top byte of 32-bit words, which RGB24/Z24 writes never change, so such a + // texture can share pages with a 24-bit colour or depth buffer without depending on what is drawn. + bool readsTopByteOnly(uint32_t psm) + { + return psm == GS_PSM_T8H || psm == GS_PSM_T4HL || psm == GS_PSM_T4HH; + } + + bool keepsTopByte(uint32_t psm) + { + return psm == GS_PSM_CT24 || psm == GS_PSM_Z24; + } + + // Below this, a self-sampling primitive is cheaper to draw on the calling thread. + constexpr uint64_t kMinParallelAliasPixels = 4096u; + void markPages(std::bitset<512> &pages, const PageSpan &span) { for (uint32_t i = 0; i < span.count; ++i) @@ -741,7 +756,11 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) if (selfOverlap) { FlushPendingUnlocked(); - DrawPrimitive(batch); + const bool topByteAlias = !spansOverlap(frameSpan, depthSpan) && readsTopByteOnly(ctx.tex0.psm) && + (!spansOverlap(texture, frameSpan) || keepsTopByte(ctx.frame.psm)) && + (!spansOverlap(texture, depthSpan) || keepsTopByte(ctx.zbuf.psm)); + if (!topByteAlias || !RasterizeTopByteAliasInParallel(batch)) + DrawPrimitive(batch); return; } @@ -799,6 +818,179 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) FlushPendingUnlocked(); } +bool GSCpuBackend::RasterizeTopByteAliasInParallel(const GSPrimitiveBatch &batch) +{ + // The primitive samples top bytes of words it rewrites below them, so every pixel comes out the same + // drawn in any order. What a sequential draw also leaves behind is the texture cache: it copies each + // drawn-over page when loading it, so the page it ends on can hold words as they were mid-primitive. + const GSDrawState &state = batch.state; + const auto &ctx = state.context; + float minX = batch.vertices[0].x, maxX = minX, minY = batch.vertices[0].y, maxY = minY; + for (uint32_t i = 1; i < std::min(batch.vertexCount, 3u); ++i) + { + minX = std::min(minX, batch.vertices[i].x); + maxX = std::max(maxX, batch.vertices[i].x); + minY = std::min(minY, batch.vertices[i].y); + maxY = std::max(maxY, batch.vertices[i].y); + } + const double area = (static_cast(maxX) - minX + 1.0) * (static_cast(maxY) - minY + 1.0); + if (RasterParticipants() <= 1u || !(area >= static_cast(kMinParallelAliasPixels))) + return false; + if (!m_texturePageCache.MatchesVram(m_vram)) + return false; + + const uint32_t scissorX = ctx.scissor.x1; + const uint32_t scissorY = ctx.scissor.y1; + const uint32_t fbw = std::max(ctx.frame.fbw, 1u); + const uint32_t frameBlock = GSInternal::framePageBaseToBlock(ctx.frame.fbp); + const uint32_t depthBlock = GSInternal::framePageBaseToBlock(ctx.zbuf.zbp); + const PageSpan frame = pageSpan(frameBlock, fbw, ctx.frame.psm, 0u, 0u, scissorX, scissorY); + const PageSpan depth = pageSpan(depthBlock, fbw, ctx.zbuf.psm, 0u, 0u, scissorX, scissorY); + const uint32_t extentU = (ctx.clamp & 0x2u) != 0u ? 1024u : std::max(state.textureWidth, 1u); + const uint32_t extentV = ((ctx.clamp >> 2) & 0x2u) != 0u ? 1024u : std::max(state.textureHeight, 1u); + const PageSpan texture = pageSpan(ctx.tex0.tbp0, ctx.tex0.tbw, ctx.tex0.psm, 0u, 0u, extentU - 1u, extentV - 1u); + + // Sequentially, a cached page in place that is about to be drawn over is snapshotted first. + uint32_t livePage = 0u; + if (m_texturePageCache.LivePage(livePage)) + { + const uint32_t page = livePage / GSMem::TexturePageCache::kPageSize; + if (spanHasPage(frame, page) || spanHasPage(depth, page)) + m_texturePageCache.Snapshot(m_vram); + } + + // Keep the pages that are both sampled and drawn, as they were before drawing. + std::vector keptPages; + for (uint32_t i = 0; i < texture.count; ++i) + { + const uint32_t page = (texture.first + i) % kVramPageCount; + if (spanHasPage(frame, page) || spanHasPage(depth, page)) + keptPages.push_back(page); + } + std::vector before(keptPages.size() * GSMem::TexturePageCache::kPageSize); + for (size_t i = 0; i < keptPages.size(); ++i) + std::memcpy(before.data() + i * GSMem::TexturePageCache::kPageSize, + m_vram + keptPages[i] * GSMem::TexturePageCache::kPageSize, GSMem::TexturePageCache::kPageSize); + + if (!m_workers || m_workers->participants() != RasterParticipants()) + m_workers = std::make_unique(RasterParticipants()); + const uint32_t participants = m_workers->participants(); + std::vector caches(participants); + std::vector traces(participants); + for (GSMem::TexturePageCache &cache : caches) + cache.EnableTracking(); + const std::function job = [&](uint32_t index) + { + RasterContext raster{}; + raster.workers = participants; + raster.index = index; + raster.cache = &caches[index]; + raster.trace = &traces[index]; + raster.trackReads = true; + DrawPrimitiveRows(batch, raster); + }; + m_workers->run(job); + + // The page of the last texel read in raster order is where the sequential cache ends. + int lastBand = -1; + for (uint32_t i = 0; i < participants; ++i) + { + const auto &track = caches[i].Track(); + if (!track.last.valid) + continue; + if (lastBand < 0) + { + lastBand = static_cast(i); + continue; + } + const auto &best = caches[static_cast(lastBand)].Track().last; + if (track.last.y > best.y || (track.last.y == best.y && track.last.x > best.x)) + lastBand = static_cast(i); + } + if (lastBand < 0) + return true; + const uint32_t lastPage = caches[static_cast(lastBand)].Track().lastPage; + const uint32_t lastPageIndex = lastPage / GSMem::TexturePageCache::kPageSize; + const auto kept = std::find(keptPages.begin(), keptPages.end(), lastPageIndex); + if (kept == keptPages.end()) + { + // Not drawn over: the sequential cache would read it in place. + m_texturePageCache.SetLivePage(lastPage); + return true; + } + + // The sequential cache copied lastPage after its latest read of any other page. Find that read. + GSMem::TexturePageCache::ReadPosition switchRead{}; + bool switchInsidePixel = false; + auto later = [](const GSMem::TexturePageCache::ReadPosition &a, const GSMem::TexturePageCache::ReadPosition &b) + { + if (!b.valid) + return a.valid; + if (!a.valid) + return false; + if (a.y != b.y) + return a.y > b.y; + if (a.x != b.x) + return a.x > b.x; + return a.texel > b.texel; + }; + for (uint32_t i = 0; i < participants; ++i) + { + const auto &track = caches[i].Track(); + if (!track.last.valid) + continue; + // A band ending on another page read that page last; one ending on lastPage read others before. + const GSMem::TexturePageCache::ReadPosition candidate = track.lastPage != lastPage ? track.last : track.other; + const bool inside = track.lastPage != lastPage ? false : track.switchedInsidePixel; + if (later(candidate, switchRead)) + { + switchRead = candidate; + switchInsidePixel = inside; + } + } + + // Words drawn by pixels before the copy are as drawn; the rest still hold what they held before. + const size_t keptIndex = static_cast(kept - keptPages.begin()); + const uint8_t *beforePage = before.data() + keptIndex * GSMem::TexturePageCache::kPageSize; + if (!switchRead.valid) + { + m_texturePageCache.SetCopy(beforePage, lastPage); + return true; + } + if (m_pageRebuildScratch.size() != m_vramSize) + m_pageRebuildScratch.assign(m_vramSize, 0u); + std::memcpy(m_pageRebuildScratch.data() + lastPage, beforePage, GSMem::TexturePageCache::kPageSize); + auto drawnBeforeCopy = [&](int y, int x) + { + if (y != switchRead.y) + return y < switchRead.y; + return switchInsidePixel ? x < switchRead.x : x <= switchRead.x; + }; + auto rebuildFrom = [&](const PageSpan &span, uint32_t baseBlock, uint32_t psm) + { + if (!spanHasPage(span, lastPageIndex)) + return; + uint32_t widthShift = 6u; + uint32_t heightShift = 5u; + psmPageShift(psm, widthShift, heightShift); + const uint32_t stride = std::max((fbw * 64u) >> widthShift, 1u); + const uint32_t relative = (lastPageIndex + kVramPageCount - (baseBlock / 32u) % kVramPageCount) % kVramPageCount; + const int tileX = static_cast((relative % stride) << widthShift); + const int tileY = static_cast((relative / stride) << heightShift); + for (int y = tileY; y < tileY + (1 << heightShift); ++y) + for (int x = tileX; x < tileX + (1 << widthShift); ++x) + if (drawnBeforeCopy(y, x)) + m_writeVramFuncs[psm & 0x3Fu](m_pageRebuildScratch.data(), baseBlock, fbw, static_cast(x), + static_cast(y), ReadVramUnlocked(psm, baseBlock, fbw, + static_cast(x), + static_cast(y))); + }; + rebuildFrom(frame, frameBlock, ctx.frame.psm); + rebuildFrom(depth, depthBlock, ctx.zbuf.psm); + m_texturePageCache.SetCopy(m_pageRebuildScratch.data() + lastPage, lastPage); + return true; +} + void GSCpuBackend::FlushPendingUnlocked() { if (m_pending.empty()) @@ -1858,6 +2050,7 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext for (int x = drawX0; x <= drawX1; ++x) { + raster.beginSample(y, x); const uint32_t texel = SampleTextureAxes(sampler, columns[static_cast(x - drawX0)], row); raster.sampled(y, x); @@ -1980,6 +2173,7 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte iv = 0; } + raster.beginSample(y, x); uint32_t texel = SampleTexture(sampler, is, it, iq, iu, iv); raster.sampled(y, x); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index ffb6381cac..7b2dfa7561 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -2947,6 +2947,139 @@ void register_ps2_gs_tests() t.IsTrue(serial == parallel, "four row bands must produce byte-identical VRAM to one thread"); }); + tc.Run("GS primitives sampling the top byte of their own RGB24 target draw the same in parallel", [](TestCase &t) + { + // T8H/T4HL/T4HH textures stored in the unused byte of the RGB24 frame they draw into, each followed + // by an RGB24 read of the same texels: that read hits the page the texture buffer ended on, so it + // shows whether parallel drawing left the buffer exactly as a serial draw would. + auto render = [](uint32_t threads) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0x2468ACEu; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + GSTex0Reg clutTex{}; + clutTex.psm = GS_PSM_T8H; + clutTex.cpsm = GS_PSM_CT32; + clutTex.cbp = 13000u; + clutTex.cld = 1u; + gs.LoadClut(clutTex, GSTexClutReg{}); + + auto baseState = [&](GSPrimitiveBatch &batch) + { + GSDrawState &state = batch.state; + auto &ctx = state.context; + ctx.frame.fbp = 0u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT24; + ctx.zbuf.zbp = 160u; + ctx.zbuf.psm = GS_PSM_Z24; + ctx.zbuf.zmask = (next() % 3u) == 0u; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.test = (static_cast(1u + next() % 3u) << 17u) | (1ull << 16u); + ctx.alpha = 0x80ull << 32u | 0x44u; + ctx.tex0.tbw = 10u; + ctx.tex0.tcc = 1u; + ctx.tex0.cpsm = GS_PSM_CT32; + state.textureWidth = 1024u; + state.textureHeight = 512u; + state.prim.tme = true; + }; + + for (int i = 0; i < 160; ++i) + { + const uint32_t x0 = next() % 500u; + const uint32_t y0 = next() % 380u; + const uint32_t w = 64u + next() % 120u; + const uint32_t h = 64u + next() % 60u; + // Mostly draw over the very texels being sampled, so the buffered page's timing shows. + const bool overlay = (next() % 3u) != 0u; + const uint32_t u0 = overlay ? x0 : next() % 500u; + const uint32_t v0 = overlay ? y0 : next() % 380u; + const uint32_t blockOffset = (next() % 3u) * 32u * 10u; + + GSPrimitiveBatch alias{}; + baseState(alias); + const bool sprite = (next() % 2u) != 0u; + alias.state.prim.type = sprite ? GS_PRIM_SPRITE : GS_PRIM_TRIANGLE; + alias.state.prim.fst = true; + alias.state.prim.abe = (next() % 2u) != 0u; + alias.state.prim.iip = (next() % 2u) != 0u; + alias.state.linearFilter = (next() % 3u) == 0u; + const uint32_t topFormats[3] = {GS_PSM_T8H, GS_PSM_T4HL, GS_PSM_T4HH}; + alias.state.context.tex0.psm = static_cast(topFormats[next() % 3u]); + alias.state.context.tex0.tbp0 = blockOffset; + alias.state.context.tex0.tfx = static_cast(next() % 2u); + alias.vertexCount = sprite ? 2u : 3u; + const uint32_t xs[3] = {x0, x0 + w, sprite ? 0u : x0}; + const uint32_t ys[3] = {y0, y0 + h, sprite ? 0u : y0 + h}; + const uint32_t us[3] = {u0, u0 + w, u0}; + const uint32_t vs[3] = {v0, v0 + h, v0 + h}; + for (int v = 0; v < 3; ++v) + { + GSVertex &vertex = alias.vertices[static_cast(v)]; + vertex.x = static_cast(sprite || v != 1 ? xs[v] : x0 + w); + vertex.y = static_cast(sprite || v != 1 ? ys[v] : y0); + vertex.z = static_cast(next() % 0xFFFFFFu); + vertex.r = static_cast(next()); + vertex.g = static_cast(next()); + vertex.b = static_cast(next()); + vertex.a = static_cast(next()); + vertex.u = static_cast(us[v] * 16u); + vertex.v = static_cast(vs[v] * 16u); + } + gs.Submit(alias); + + // Read back texels the alias primitive sampled last, and some at random, through RGB24. + for (int k = 0; k < 3; ++k) + { + GSPrimitiveBatch probe{}; + baseState(probe); + probe.state.prim.type = GS_PRIM_SPRITE; + probe.state.prim.fst = true; + probe.state.context.tex0.psm = GS_PSM_CT24; + probe.state.context.tex0.tbp0 = blockOffset; + probe.state.context.zbuf.zmask = true; + probe.state.context.test = 0x30000ull; + probe.vertexCount = 2u; + const uint32_t pu = k == 0 ? u0 + w - 1u : u0 + next() % w; + const uint32_t pv = k == 0 ? v0 + h - 1u : v0 + next() % h; + const uint32_t px = 600u + static_cast(k); + const uint32_t py = static_cast(i) % 440u; + probe.vertices[0].x = static_cast(px); + probe.vertices[0].y = static_cast(py); + probe.vertices[1].x = static_cast(px + 1u); + probe.vertices[1].y = static_cast(py + 1u); + probe.vertices[1].r = probe.vertices[1].g = probe.vertices[1].b = probe.vertices[1].a = 0x80u; + probe.vertices[0].u = static_cast(pu * 16u + 8u); + probe.vertices[0].v = static_cast(pv * 16u + 8u); + probe.vertices[1].u = static_cast(pu * 16u + 24u); + probe.vertices[1].v = static_cast(pv * 16u + 24u); + probe.state.context.tex0.tfx = 1u; + gs.Submit(probe); + } + if (i % 37 == 36) + gs.TextureFlush(); + } + std::vector out; + gs.SnapshotVram(out); + return out; + }; + + const std::vector serial = render(1u); + const std::vector parallel = render(4u); + t.IsTrue(serial == parallel, "self-sampling RGB24 draws must leave VRAM and the texture buffer as serially"); + }); + tc.Run("GS texture page buffer also hides draws into the cached page until TEXFLUSH", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From 19521364c3641b1bd26bbf608c31fb9b841b5632 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:51:58 -0300 Subject: [PATCH 42/91] Inline GS pixel writes, resolve texel fetchers per primitive and address colour/depth pixels once --- .../include/runtime/gs/gs_cpu_backend.h | 1 + .../runtime/gs/gs_texture_page_cache.h | 2 + .../include/runtime/gs/ps2_gs_memory.h | 8 ++ ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 117 ++++++++++++++++-- ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp | 84 +++++++++++++ ps2xTest/src/ps2_gs_tests.cpp | 54 ++++++++ 6 files changed, 258 insertions(+), 8 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 9ac6f39a12..e10abba87d 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -122,6 +122,7 @@ class GSCpuBackend final : public GSRasterBackend uint32_t tbw = 0; const uint32_t *clut = nullptr; GSMem::TexturePageCache *cache = nullptr; + GSMem::TextureFetchFn fetch = nullptr; int texW = 1; int texH = 1; uint8_t wrapU = 0; diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h index b7d3d619ee..9ae99dc7b8 100644 --- a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -171,4 +171,6 @@ namespace GSMem uint32_t m_copyFirst[2] = {0u, 0u}; uint32_t m_copyCount[2] = {0u, 0u}; }; + + using TextureFetchFn = uint32_t (*)(TexturePageCache& cache, const uint8_t* data, uint32_t bp, uint32_t bw, uint32_t x, uint32_t y); } diff --git a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h index c784ff7f5d..11ad8d03f8 100644 --- a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h +++ b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h @@ -543,6 +543,14 @@ namespace GSMem // 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); + // ReadTexture with the format resolved once, for samplers that fetch many texels of one texture. + TextureFetchFn TextureFetcher(u32 psm); + + // Byte offset of a colour or depth pixel, so a read-modify-write addresses it once. + // Null for formats that are not drawable targets. + using PixelOffsetFn = u32 (*)(u32 bp, u32 bw, u32 x, u32 y); + PixelOffsetFn TargetPixelOffset(u32 psm); + 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 3673baba1d..bfc0c7263d 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1523,6 +1523,9 @@ struct GSCpuBackend::PixelPipeline uint8_t csel = 0; uint8_t dsel = 0; uint8_t fix = 0; + // Direct addressing for colour and depth; null falls back to the generic VRAM accessors. + GSMem::PixelOffsetFn frameOffset = nullptr; + GSMem::PixelOffsetFn depthOffset = nullptr; }; void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) @@ -1530,7 +1533,67 @@ void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uin WritePixel(MakePixelPipeline(state), x, y, z, r, g, b, a, fog); } -void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) +namespace +{ + inline uint32_t readTarget(uint32_t psm, const uint8_t *pixel) + { + switch (psm) + { + case GS_PSM_CT24: + case GS_PSM_Z24: + { + uint32_t value; + std::memcpy(&value, pixel, sizeof(value)); + return value & 0x00FFFFFFu; + } + case GS_PSM_CT16: + case GS_PSM_CT16S: + case GS_PSM_Z16: + case GS_PSM_Z16S: + { + uint16_t value; + std::memcpy(&value, pixel, sizeof(value)); + return value; + } + default: + { + uint32_t value; + std::memcpy(&value, pixel, sizeof(value)); + return value; + } + } + } + + inline void writeTarget(uint32_t psm, uint8_t *pixel, uint32_t value) + { + switch (psm) + { + case GS_PSM_CT24: + case GS_PSM_Z24: + { + uint32_t old; + std::memcpy(&old, pixel, sizeof(old)); + value = (old & 0xFF000000u) | (value & 0x00FFFFFFu); + std::memcpy(pixel, &value, sizeof(value)); + break; + } + case GS_PSM_CT16: + case GS_PSM_CT16S: + case GS_PSM_Z16: + case GS_PSM_Z16S: + { + const uint16_t half = static_cast(value); + std::memcpy(pixel, &half, sizeof(half)); + break; + } + default: + std::memcpy(pixel, &value, sizeof(value)); + break; + } + } +} + +__attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) { const GSDrawState &state = *pipe.state; const auto &ctx = state.context; @@ -1573,9 +1636,18 @@ void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, ui u32 rawFramebufferPixel = 0; u32 fbrgba = 0; + uint8_t *framePixel = nullptr; if (frmw) { - rawFramebufferPixel = ReadVramUnlocked(fpsm, fbp, fbw, x, y); + if (pipe.frameOffset) + { + framePixel = m_vram + pipe.frameOffset(fbp, fbw, static_cast(x), static_cast(y)); + rawFramebufferPixel = readTarget(fpsm, framePixel); + } + else + { + rawFramebufferPixel = ReadVramUnlocked(fpsm, fbp, fbw, x, y); + } fbrgba = rawFramebufferPixel; if (pipe.framebuffer16) @@ -1596,6 +1668,14 @@ void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, ui bool zpass = false; uint32_t storedZ = 0u; + uint8_t *depthPixel = nullptr; + auto readDepth = [&]() -> uint32_t + { + if (!pipe.depthOffset) + return ReadVramUnlocked(zpsm, zbp, fbw, x, y); + depthPixel = m_vram + pipe.depthOffset(zbp, fbw, static_cast(x), static_cast(y)); + return readTarget(zpsm, depthPixel); + }; switch (ztestMethod) { case 0: @@ -1605,11 +1685,11 @@ void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, ui zpass = true; break; case 2: - storedZ = ReadVramUnlocked(zpsm, zbp, fbw, x, y); + storedZ = readDepth(); zpass = static_cast(z) >= storedZ; break; case 3: - storedZ = ReadVramUnlocked(zpsm, zbp, fbw, x, y); + storedZ = readDepth(); zpass = static_cast(z) > storedZ; break; } @@ -1688,12 +1768,30 @@ void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, ui } // PrepareTexturePageCache already made the texture page cache safe for this primitive's writes. - m_writeVramFuncs[fpsm & 0x3Fu](m_vram, fbp, fbw, x, y, pixel); + if (pipe.frameOffset) + { + if (!framePixel) + framePixel = m_vram + pipe.frameOffset(fbp, fbw, static_cast(x), static_cast(y)); + writeTarget(fpsm, framePixel, pixel); + } + else + { + m_writeVramFuncs[fpsm & 0x3Fu](m_vram, fbp, fbw, x, y, pixel); + } } if (writeMask.writeDepth && !ctx.zbuf.zmask) { - m_writeVramFuncs[zpsm & 0x3Fu](m_vram, zbp, fbw, x, y, z); + if (pipe.depthOffset) + { + if (!depthPixel) + depthPixel = m_vram + pipe.depthOffset(zbp, fbw, static_cast(x), static_cast(y)); + writeTarget(zpsm, depthPixel, static_cast(z)); + } + else + { + m_writeVramFuncs[zpsm & 0x3Fu](m_vram, zbp, fbw, x, y, z); + } } } @@ -1719,6 +1817,8 @@ GSCpuBackend::PixelPipeline GSCpuBackend::MakePixelPipeline(const GSDrawState &s pipe.csel = (alphaReg >> 4) & 3; pipe.dsel = (alphaReg >> 6) & 3; pipe.fix = static_cast((alphaReg >> 32) & 0xFF); + pipe.frameOffset = GSMem::TargetPixelOffset(pipe.fpsm); + pipe.depthOffset = GSMem::TargetPixelOffset(pipe.zpsm); return pipe; } @@ -1792,6 +1892,7 @@ GSCpuBackend::TextureSampler GSCpuBackend::MakeTextureSampler(const GSDrawState sampler.maxV = static_cast((clamp >> 34) & 0x3FFu); sampler.linear = state.linearFilter; sampler.cache = &cache; + sampler.fetch = GSMem::TextureFetcher(tex.psm); if (clut && (isFourBitIndexedPsm(tex.psm) || isEightBitIndexedPsm(tex.psm))) { @@ -1867,9 +1968,9 @@ GSCpuBackend::TextureAxis GSCpuBackend::PrepareTextureAxis(float coordinate, int return axis; } -uint32_t GSCpuBackend::SampleTexel(const TextureSampler &sampler, int u, int v) +__attribute__((always_inline)) inline uint32_t GSCpuBackend::SampleTexel(const TextureSampler &sampler, int u, int v) { - const u32 out = GSMem::ReadTexture(*sampler.cache, m_vram, sampler.psm, sampler.tbp0, sampler.tbw, u, v); + const u32 out = sampler.fetch(*sampler.cache, m_vram, sampler.tbp0, sampler.tbw, static_cast(u), static_cast(v)); if (sampler.clut) return sampler.clut[static_cast(out)]; diff --git a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp index a8f9fa8194..4743c543c5 100644 --- a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp +++ b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp @@ -255,6 +255,90 @@ namespace GSMem } } + namespace + { + template + u32 FetchTexel(TexturePageCache& cache, const u8* data, u32 bp, u32 bw, u32 x, u32 y) + { + return PixelStorageTraits::Read(table, data, bp, bw, x, y, &cache); + } + + // The address arithmetic of PixelStorageTraits::Read/Write, without the access. + template + u32 PixelOffset(u32 bp, u32 bw, u32 x, u32 y) + { + using Traits = PixelStorageTraits; + const u32 pixel_addr = Traits::Address(table, bp, bw, x, y); + const u32 bits = pixel_addr * UnpackedBitWidth(psm) + Traits::BitOffset(); + return (bits / 8) & (MEMORY_SIZE - sizeof(typename Traits::PackedT)); + } + + u32 FetchNothing(TexturePageCache&, const u8*, u32, u32, u32, u32) + { + return 0u; + } + } + + TextureFetchFn TextureFetcher(u32 psm) + { + switch (static_cast(psm & 0x3Fu)) + { + case C32: + return FetchTexel; + case C24: + return FetchTexel; + case C16: + return FetchTexel; + case C16S: + return FetchTexel; + case P8: + return FetchTexel; + case P4: + return FetchTexel; + case P8H: + return FetchTexel; + case P4HL: + return FetchTexel; + case P4HH: + return FetchTexel; + case Z32: + return FetchTexel; + case Z24: + return FetchTexel; + case Z16: + return FetchTexel; + case Z16S: + return FetchTexel; + default: + return FetchNothing; + } + } + + PixelOffsetFn TargetPixelOffset(u32 psm) + { + switch (static_cast(psm & 0x3Fu)) + { + case C32: + return PixelOffset; + case C24: + return PixelOffset; + case C16: + return PixelOffset; + case C16S: + return PixelOffset; + case Z32: + return PixelOffset; + case Z24: + return PixelOffset; + case Z16: + return PixelOffset; + case Z16S: + return PixelOffset; + default: + return nullptr; + } + } + 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/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 7b2dfa7561..0f57f9634f 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -2947,6 +2947,60 @@ void register_ps2_gs_tests() t.IsTrue(serial == parallel, "four row bands must produce byte-identical VRAM to one thread"); }); + tc.Run("GS direct target addressing matches the generic pixel accessors", [](TestCase &t) + { + GSMem::InitLookupTables(); + struct Format + { + uint32_t psm; + void (*write)(uint8_t *, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); + uint32_t (*read)(uint8_t *, uint32_t, uint32_t, uint32_t, uint32_t); + uint32_t bytes; + uint32_t mask; + }; + const Format formats[] = { + {GS_PSM_CT32, GSMem::WriteCT32, GSMem::ReadCT32, 4u, 0xFFFFFFFFu}, + {GS_PSM_CT24, GSMem::WriteCT24, GSMem::ReadCT24, 4u, 0x00FFFFFFu}, + {GS_PSM_CT16, GSMem::WriteCT16, GSMem::ReadCT16, 2u, 0xFFFFu}, + {GS_PSM_CT16S, GSMem::WriteCT16S, GSMem::ReadCT16S, 2u, 0xFFFFu}, + {GS_PSM_Z32, GSMem::WriteZ32, GSMem::ReadZ32, 4u, 0xFFFFFFFFu}, + {GS_PSM_Z24, GSMem::WriteZ24, GSMem::ReadZ24, 4u, 0x00FFFFFFu}, + {GS_PSM_Z16, GSMem::WriteZ16, GSMem::ReadZ16, 2u, 0xFFFFu}, + {GS_PSM_Z16S, GSMem::WriteZ16S, GSMem::ReadZ16S, 2u, 0xFFFFu}, + }; + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 99u; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + bool matches = true; + for (const Format &format : formats) + { + const GSMem::PixelOffsetFn offset = GSMem::TargetPixelOffset(format.psm); + if (!offset) + { + matches = false; + continue; + } + for (int i = 0; i < 400; ++i) + { + const uint32_t bp = next() % 16384u; + const uint32_t bw = 1u + next() % 16u; + const uint32_t x = next() % 1024u; + const uint32_t y = next() % 1024u; + const uint32_t value = next() * 2654435761u; + format.write(vram.data(), bp, bw, x, y, value); + uint32_t stored = 0u; + std::memcpy(&stored, vram.data() + offset(bp, bw, x, y), format.bytes); + if ((stored & format.mask) != (value & format.mask) || format.read(vram.data(), bp, bw, x, y) != (value & format.mask)) + matches = false; + } + } + t.IsTrue(matches, "the byte offset of every drawable format must be where the generic writer stores the pixel"); + }); + tc.Run("GS primitives sampling the top byte of their own RGB24 target draw the same in parallel", [](TestCase &t) { // T8H/T4HL/T4HH textures stored in the unused byte of the RGB24 frame they draw into, each followed From 145e05b63afe23dd270f704da9220384bf79e74a Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:15:16 -0300 Subject: [PATCH 43/91] Run the GS CPU backend on its own thread so the EE keeps running while primitives are drawn --- .../include/runtime/gs/gs_cpu_backend.h | 60 ++++ ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 307 +++++++++++++++++- ps2xRuntime/src/lib/ps2_runtime.cpp | 14 + ps2xTest/src/ps2_gs_tests.cpp | 27 +- 4 files changed, 387 insertions(+), 21 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index e10abba87d..6dd8fca383 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -5,8 +5,11 @@ #include #include +#include +#include #include #include +#include #include class GSCpuBackend final : public GSRasterBackend @@ -19,6 +22,10 @@ class GSCpuBackend final : public GSRasterBackend // (PS2X_GS_THREADS, else half the cores). 1 draws every primitive immediately. void SetRasterThreads(uint32_t participants); + // Runs commands that only change local memory on a GS thread, in submission order, while the caller + // continues; anything that reads local memory or transfer state first waits for the queue to drain. + void SetAsync(bool enabled); + void Initialize(uint8_t *vram, uint32_t vramSize) override; void Reset() override; @@ -41,6 +48,49 @@ class GSCpuBackend final : public GSRasterBackend GSTransferSnapshot GetTransferSnapshot() const override; private: + struct Command + { + enum class Kind : uint8_t + { + Submit, + LoadClut, + BeginTransfer, + UploadImage, + TextureFlush, + ClearFramebuffer, + WriteVram, + }; + Kind kind = Kind::Submit; + GSPrimitiveBatch batch{}; + GSTex0Reg tex0{}; + GSTexClutReg texclut{}; + GSTransferCommand transfer{}; + GSContext context{}; + uint32_t args[6]{}; + std::vector data; + }; + + void Enqueue(Command &&command); + void WaitForQueue() const; + void RunQueue(); + void Execute(Command &command); + + void InitializeNow(uint8_t *vram, uint32_t vramSize); + void ResetNow(); + void SubmitNow(const GSPrimitiveBatch &batch); + void LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclut); + void BeginTransferNow(const GSTransferCommand &command); + void UploadImageNow(const uint8_t *data, uint32_t sizeBytes); + void FlushNow(); + void TextureFlushNow(); + void SyncNow(GSSyncReason reason); + bool ClearFramebufferNow(const GSContext &context, uint32_t rgba); + uint32_t ConsumeLocalToHostBytesNow(uint8_t *dst, uint32_t maxBytes); + uint32_t ReadVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const; + void WriteVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value); + void SnapshotVramNow(std::vector &out) const; + GSTransferSnapshot GetTransferSnapshotNow() const; + void ResetUnlocked(); void LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &texclut); uint32_t ReadVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const; @@ -193,6 +243,16 @@ class GSCpuBackend final : public GSRasterBackend uint64_t m_pendingTargetKey = 0; uint64_t m_pendingPixels = 0; std::unique_ptr m_workers; + + bool m_async = false; + std::thread m_gsThread; + mutable std::mutex m_queueMutex; + mutable std::condition_variable m_queueReady; + mutable std::condition_variable m_queueProgress; + std::deque m_queue; + uint64_t m_queueSubmitted = 0; + uint64_t m_queueCompleted = 0; + bool m_queueStop = false; uint32_t m_rasterParticipants = 0; // Rebuilds the texture cache's copied page after a parallel draw into the page it holds. std::vector m_pageRebuildScratch; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index bfc0c7263d..15da57d11d 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1,4 +1,5 @@ #include "runtime/gs/gs_cpu_backend.h" +#include "ThreadNaming.h" #include "runtime/gs/ps2_gs_common.h" #include "runtime/gs/ps2_gs_psmct16.h" #include "runtime/gs/ps2_gs_psmct32.h" @@ -714,10 +715,14 @@ namespace } } -GSCpuBackend::~GSCpuBackend() = default; +GSCpuBackend::~GSCpuBackend() +{ + SetAsync(false); +} void GSCpuBackend::SetRasterThreads(uint32_t participants) { + WaitForQueue(); std::lock_guard lock(m_mutex); FlushPendingUnlocked(); m_rasterParticipants = participants; @@ -1085,7 +1090,279 @@ void GSCpuBackend::RasterizePendingInParallel() m_texturePageCache.SetLivePage(lastPage); } +// ---- GS thread ------------------------------------------------------------------------------------- + +namespace +{ + // Bounds the backlog: the producer waits rather than queueing several frames of geometry. + constexpr size_t kMaxQueuedCommands = 8192u; +} + +void GSCpuBackend::SetAsync(bool enabled) +{ + if (enabled == m_async) + return; + if (enabled) + { + { + std::lock_guard lock(m_queueMutex); + m_queueStop = false; + } + m_async = true; + m_gsThread = std::thread([this]() { RunQueue(); }); + return; + } + WaitForQueue(); + { + std::lock_guard lock(m_queueMutex); + m_queueStop = true; + } + m_queueReady.notify_all(); + m_gsThread.join(); + m_async = false; +} + +void GSCpuBackend::Enqueue(Command &&command) +{ + { + std::unique_lock lock(m_queueMutex); + m_queueProgress.wait(lock, [this]() { return m_queue.size() < kMaxQueuedCommands; }); + m_queue.push_back(std::move(command)); + ++m_queueSubmitted; + } + m_queueReady.notify_one(); +} + +void GSCpuBackend::WaitForQueue() const +{ + if (!m_async) + return; + std::unique_lock lock(m_queueMutex); + const uint64_t target = m_queueSubmitted; + m_queueProgress.wait(lock, [this, target]() { return m_queueCompleted >= target; }); +} + +void GSCpuBackend::RunQueue() +{ + ThreadNaming::SetCurrentThreadName("GSThread"); + for (;;) + { + std::deque work; + { + std::unique_lock lock(m_queueMutex); + m_queueReady.wait(lock, [this]() { return m_queueStop || !m_queue.empty(); }); + if (m_queue.empty()) + return; + work.swap(m_queue); + } + m_queueProgress.notify_all(); + for (Command &command : work) + { + try + { + Execute(command); + } + catch (const std::exception &error) + { + // Nothing on this thread may unwind past the queue, or readers would wait forever. + std::cerr << "[GS] command " << static_cast(command.kind) << " failed on the GS thread: " << error.what() << std::endl; + } + // Readers wait on completed commands, so batches still queued would hold them up needlessly. + std::lock_guard lock(m_queueMutex); + ++m_queueCompleted; + } + bool idle = false; + { + std::lock_guard lock(m_queueMutex); + idle = m_queue.empty(); + } + m_queueProgress.notify_all(); + // Nothing else to do: rasterize what is batched now instead of when someone next reads. + if (idle) + FlushNow(); + } +} + +void GSCpuBackend::Execute(Command &command) +{ + switch (command.kind) + { + case Command::Kind::Submit: + SubmitNow(command.batch); + break; + case Command::Kind::LoadClut: + LoadClutNow(command.tex0, command.texclut); + break; + case Command::Kind::BeginTransfer: + BeginTransferNow(command.transfer); + break; + case Command::Kind::UploadImage: + UploadImageNow(command.data.data(), static_cast(command.data.size())); + break; + case Command::Kind::TextureFlush: + TextureFlushNow(); + break; + case Command::Kind::ClearFramebuffer: + (void)ClearFramebufferNow(command.context, command.args[0]); + break; + case Command::Kind::WriteVram: + WriteVramNow(command.args[0], command.args[1], command.args[2], command.args[3], command.args[4], command.args[5]); + break; + } +} + void GSCpuBackend::Initialize(uint8_t *vram, uint32_t vramSize) +{ + WaitForQueue(); + InitializeNow(vram, vramSize); +} + +void GSCpuBackend::Reset() +{ + WaitForQueue(); + ResetNow(); +} + +void GSCpuBackend::Submit(const GSPrimitiveBatch &batch) +{ + if (!m_async) + { + SubmitNow(batch); + return; + } + if (batch.vertexCount == 0u) + return; + Command command; + command.kind = Command::Kind::Submit; + command.batch = batch; + Enqueue(std::move(command)); +} + +void GSCpuBackend::LoadClut(const GSTex0Reg &tex0, const GSTexClutReg &texclut) +{ + if (!m_async) + { + LoadClutNow(tex0, texclut); + return; + } + Command command; + command.kind = Command::Kind::LoadClut; + command.tex0 = tex0; + command.texclut = texclut; + Enqueue(std::move(command)); +} + +void GSCpuBackend::BeginTransfer(const GSTransferCommand &transfer) +{ + if (!m_async) + { + BeginTransferNow(transfer); + return; + } + Command command; + command.kind = Command::Kind::BeginTransfer; + command.transfer = transfer; + Enqueue(std::move(command)); +} + +void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) +{ + if (!m_async) + { + UploadImageNow(data, sizeBytes); + return; + } + if (!data || sizeBytes == 0u) + return; + Command command; + command.kind = Command::Kind::UploadImage; + command.data.assign(data, data + sizeBytes); + Enqueue(std::move(command)); +} + +void GSCpuBackend::Flush() +{ + WaitForQueue(); + FlushNow(); +} + +void GSCpuBackend::TextureFlush() +{ + if (!m_async) + { + TextureFlushNow(); + return; + } + Command command; + command.kind = Command::Kind::TextureFlush; + Enqueue(std::move(command)); +} + +void GSCpuBackend::Sync(GSSyncReason reason) +{ + WaitForQueue(); + SyncNow(reason); +} + +bool GSCpuBackend::ClearFramebuffer(const GSContext &context, uint32_t rgba) +{ + if (!m_async) + return ClearFramebufferNow(context, rgba); + // The result depends only on the target format, so the clear itself can run later. + const uint8_t psm = context.frame.psm; + const bool clears = m_vram && context.frame.fbw != 0u && + (psm == GS_PSM_CT32 || psm == GS_PSM_CT24 || psm == GS_PSM_CT16 || psm == GS_PSM_CT16S); + Command command; + command.kind = Command::Kind::ClearFramebuffer; + command.context = context; + command.args[0] = rgba; + Enqueue(std::move(command)); + return clears; +} + +uint32_t GSCpuBackend::ConsumeLocalToHostBytes(uint8_t *dst, uint32_t maxBytes) +{ + WaitForQueue(); + return ConsumeLocalToHostBytesNow(dst, maxBytes); +} + +uint32_t GSCpuBackend::ReadVram(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const +{ + WaitForQueue(); + return ReadVramNow(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) +{ + if (!m_async) + { + WriteVramNow(psm, base, bw, x, y, value); + return; + } + Command command; + command.kind = Command::Kind::WriteVram; + command.args[0] = psm; + command.args[1] = base; + command.args[2] = bw; + command.args[3] = x; + command.args[4] = y; + command.args[5] = value; + Enqueue(std::move(command)); +} + +void GSCpuBackend::SnapshotVram(std::vector &out) const +{ + WaitForQueue(); + SnapshotVramNow(out); +} + +GSTransferSnapshot GSCpuBackend::GetTransferSnapshot() const +{ + WaitForQueue(); + return GetTransferSnapshotNow(); +} + +void GSCpuBackend::InitializeNow(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"); @@ -1105,7 +1382,7 @@ void GSCpuBackend::Initialize(uint8_t *vram, uint32_t vramSize) ResetUnlocked(); } -void GSCpuBackend::Reset() +void GSCpuBackend::ResetNow() { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -1127,7 +1404,7 @@ void GSCpuBackend::ResetUnlocked() m_uploadCarryBytes = 0u; } -void GSCpuBackend::Submit(const GSPrimitiveBatch &batch) +void GSCpuBackend::SubmitNow(const GSPrimitiveBatch &batch) { std::lock_guard lock(m_mutex); if (!m_vram || batch.vertexCount == 0u) @@ -1138,7 +1415,7 @@ void GSCpuBackend::Submit(const GSPrimitiveBatch &batch) DrawPrimitive(batch); } -void GSCpuBackend::LoadClut(const GSTex0Reg &tex0, const GSTexClutReg &texclut) +void GSCpuBackend::LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclut) { std::lock_guard lock(m_mutex); if (!m_pending.empty()) @@ -1238,26 +1515,26 @@ void GSCpuBackend::LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &t } } -void GSCpuBackend::Flush() +void GSCpuBackend::FlushNow() { std::lock_guard lock(m_mutex); FlushPendingUnlocked(); } -void GSCpuBackend::TextureFlush() +void GSCpuBackend::TextureFlushNow() { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); m_texturePageCache.Invalidate(); } -void GSCpuBackend::Sync(GSSyncReason) +void GSCpuBackend::SyncNow(GSSyncReason) { std::lock_guard lock(m_mutex); FlushPendingUnlocked(); } -uint32_t GSCpuBackend::ReadVram(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const +uint32_t GSCpuBackend::ReadVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -1279,7 +1556,7 @@ uint32_t GSCpuBackend::ReadTextureVramUnlocked(uint32_t psm, uint32_t base, uint 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) +void GSCpuBackend::WriteVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value) { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -1297,7 +1574,7 @@ void GSCpuBackend::WriteVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, u m_writeVramFuncs[psm & 0x3Fu](m_vram, base, bw, x, y, value); } -void GSCpuBackend::SnapshotVram(std::vector &out) const +void GSCpuBackend::SnapshotVramNow(std::vector &out) const { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -1310,7 +1587,7 @@ void GSCpuBackend::SnapshotVram(std::vector &out) const std::memcpy(out.data(), m_vram, m_vramSize); } -GSTransferSnapshot GSCpuBackend::GetTransferSnapshot() const +GSTransferSnapshot GSCpuBackend::GetTransferSnapshotNow() const { std::lock_guard lock(m_mutex); GSTransferSnapshot result = m_transferState; @@ -2371,7 +2648,7 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch, const RasterContext & } } -void GSCpuBackend::BeginTransfer(const GSTransferCommand &command) +void GSCpuBackend::BeginTransferNow(const GSTransferCommand &command) { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -2390,7 +2667,7 @@ void GSCpuBackend::BeginTransfer(const GSTransferCommand &command) PerformLocalToHostTransfer(); } -void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) +void GSCpuBackend::UploadImageNow(const uint8_t *data, uint32_t sizeBytes) { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -2635,7 +2912,7 @@ void GSCpuBackend::PerformLocalToHostTransfer() m_transferState.localToHostPendingBytes = m_localToHostBuffer.size(); } -uint32_t GSCpuBackend::ConsumeLocalToHostBytes(uint8_t *dst, uint32_t maxBytes) +uint32_t GSCpuBackend::ConsumeLocalToHostBytesNow(uint8_t *dst, uint32_t maxBytes) { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); @@ -2648,7 +2925,7 @@ uint32_t GSCpuBackend::ConsumeLocalToHostBytes(uint8_t *dst, uint32_t maxBytes) return static_cast(count); } -bool GSCpuBackend::ClearFramebuffer(const GSContext &context, uint32_t rgba) +bool GSCpuBackend::ClearFramebufferNow(const GSContext &context, uint32_t rgba) { std::lock_guard lock(m_mutex); const_cast(this)->FlushPendingUnlocked(); diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index bfc18ee6d1..225366fee2 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -5,6 +5,7 @@ #include "game_overrides.h" #include "ps2_runtime_macros.h" #include "runtime/gs/gs_frontend.h" +#include "runtime/gs/gs_cpu_backend.h" #include "runtime/ee_scheduler.h" #include "ThreadNaming.h" #include "Kernel/Stubs/Audio.h" @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -2386,6 +2388,18 @@ void PS2Runtime::run() std::atomic gameThreadFinished{false}; + // Rasterize on a GS thread so the EE keeps running while primitives are drawn; PS2X_GS_ASYNC=0 keeps + // drawing on the game thread. + { + const char *asyncSetting = std::getenv("PS2X_GS_ASYNC"); + if (!asyncSetting || asyncSetting[0] != '0') + { + auto backend = std::make_unique(); + backend->SetAsync(true); + m_gs.setRasterBackend(std::move(backend)); + } + } + std::thread gameThread([&]() { ThreadNaming::SetCurrentThreadName("GameThread"); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 0f57f9634f..55c5bdea2e 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -2844,7 +2844,7 @@ void register_ps2_gs_tests() { // A mixed stream: CT32 and T8/CLUT textures, point and bilinear sampling, STQ and UV // coordinates, blending, Z tests, primitives that sample their own target, uploads, TEXFLUSH. - auto render = [](uint32_t threads) + auto render = [](uint32_t threads, bool async) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); uint32_t seed = 0x1234567u; @@ -2856,9 +2856,11 @@ void register_ps2_gs_tests() for (uint8_t &byte : vram) byte = static_cast(next()); + std::vector readback; GSCpuBackend gs; gs.Initialize(vram.data(), static_cast(vram.size())); gs.SetRasterThreads(threads); + gs.SetAsync(async); GSTex0Reg clutTex{}; clutTex.psm = GS_PSM_T8; @@ -2918,6 +2920,14 @@ void register_ps2_gs_tests() } gs.Submit(batch); + // Reads in mid-stream must see every earlier draw, even when drawing runs on a GS thread. + if (i % 41 == 7) + { + const uint32_t x = next() % 640u; + const uint32_t y = next() % 448u; + readback.push_back(gs.ReadVram(GS_PSM_CT32, 0u, 10u, x, y)); + } + if (i % 97 == 50) { // Rewrite part of a texture without TEXFLUSH, then flush on alternate rounds. @@ -2939,12 +2949,15 @@ void register_ps2_gs_tests() } std::vector out; gs.SnapshotVram(out); + for (uint32_t value : readback) + out.insert(out.end(), reinterpret_cast(&value), reinterpret_cast(&value) + 4); return out; }; - const std::vector serial = render(1u); - const std::vector parallel = render(4u); + const std::vector serial = render(1u, false); + const std::vector parallel = render(4u, false); t.IsTrue(serial == parallel, "four row bands must produce byte-identical VRAM to one thread"); + t.IsTrue(serial == render(4u, true), "a GS thread must produce byte-identical VRAM to drawing in place"); }); tc.Run("GS direct target addressing matches the generic pixel accessors", [](TestCase &t) @@ -3006,7 +3019,7 @@ void register_ps2_gs_tests() // T8H/T4HL/T4HH textures stored in the unused byte of the RGB24 frame they draw into, each followed // by an RGB24 read of the same texels: that read hits the page the texture buffer ended on, so it // shows whether parallel drawing left the buffer exactly as a serial draw would. - auto render = [](uint32_t threads) + auto render = [](uint32_t threads, bool async) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); uint32_t seed = 0x2468ACEu; @@ -3021,6 +3034,7 @@ void register_ps2_gs_tests() GSCpuBackend gs; gs.Initialize(vram.data(), static_cast(vram.size())); gs.SetRasterThreads(threads); + gs.SetAsync(async); GSTex0Reg clutTex{}; clutTex.psm = GS_PSM_T8H; clutTex.cpsm = GS_PSM_CT32; @@ -3129,9 +3143,10 @@ void register_ps2_gs_tests() return out; }; - const std::vector serial = render(1u); - const std::vector parallel = render(4u); + const std::vector serial = render(1u, false); + const std::vector parallel = render(4u, false); t.IsTrue(serial == parallel, "self-sampling RGB24 draws must leave VRAM and the texture buffer as serially"); + t.IsTrue(serial == render(4u, true), "a GS thread must leave VRAM and the texture buffer as serially"); }); tc.Run("GS texture page buffer also hides draws into the cached page until TEXFLUSH", [](TestCase &t) From b019f7f451219e947cc3ee90b50490b75c581d16 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:44:37 -0300 Subject: [PATCH 44/91] Let presentation show what the GS thread has drawn instead of waiting for its backlog --- ps2xRuntime/include/runtime/gs/gs_cpu_backend.h | 3 ++- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 6dd8fca383..94b2f4bda6 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -23,7 +23,8 @@ class GSCpuBackend final : public GSRasterBackend void SetRasterThreads(uint32_t participants); // Runs commands that only change local memory on a GS thread, in submission order, while the caller - // continues; anything that reads local memory or transfer state first waits for the queue to drain. + // continues; anything that reads local memory or transfer state first waits for the queue to drain, + // except presentation, which shows what has been drawn so far. void SetAsync(bool enabled); void Initialize(uint8_t *vram, uint32_t vramSize) override; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 15da57d11d..8031f2f93c 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1095,7 +1095,7 @@ void GSCpuBackend::RasterizePendingInParallel() namespace { // Bounds the backlog: the producer waits rather than queueing several frames of geometry. - constexpr size_t kMaxQueuedCommands = 8192u; + constexpr size_t kMaxQueuedCommands = 2048u; } void GSCpuBackend::SetAsync(bool enabled) @@ -1282,8 +1282,10 @@ void GSCpuBackend::UploadImage(const uint8_t *data, uint32_t sizeBytes) void GSCpuBackend::Flush() { - WaitForQueue(); - FlushNow(); + // Queued commands are already on their way and the GS thread rasterizes batches when it runs dry; + // waiting is left to Sync and to the readers. + if (!m_async) + FlushNow(); } void GSCpuBackend::TextureFlush() @@ -1300,6 +1302,10 @@ void GSCpuBackend::TextureFlush() void GSCpuBackend::Sync(GSSyncReason reason) { + // Presentation shows whatever has been drawn; holding the host thread until the guest's backlog + // drains would only stall the window. + if (m_async && reason == GSSyncReason::Presentation) + return; WaitForQueue(); SyncNow(reason); } @@ -3068,7 +3074,7 @@ PresentationFrame GSCpuBackend::Present(const GSPresentationRequest &request) // Snapshot local memory under the backend lock, then perform the expensive // display conversion without holding the producer-side raster lock. thread_local std::vector snapshot; - SnapshotVram(snapshot); + SnapshotVramNow(snapshot); if (snapshot.empty()) return {}; From a51042aaae0abb3572fb0a69344cf90e5b271add Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:21:31 -0300 Subject: [PATCH 45/91] Add PS2X_ENABLE_LTO to build Release targets without interprocedural optimization --- ps2xRuntime/cmake/ReleaseMode.cmake | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ps2xRuntime/cmake/ReleaseMode.cmake b/ps2xRuntime/cmake/ReleaseMode.cmake index 92ef0d54d5..ba22f9b604 100644 --- a/ps2xRuntime/cmake/ReleaseMode.cmake +++ b/ps2xRuntime/cmake/ReleaseMode.cmake @@ -1,5 +1,7 @@ include(CheckIPOSupported) +option(PS2X_ENABLE_LTO "Link Release targets with interprocedural optimization" ON) + check_ipo_supported(RESULT IPO_SUPPORTED OUTPUT IPO_ERROR) function(EnableFastReleaseMode TargetName) @@ -34,6 +36,11 @@ function(EnableFastReleaseMode TargetName) endif() endif() + # ThinLTO over thousands of generated functions needs many GB per link; iteration builds turn it off. + if(NOT PS2X_ENABLE_LTO) + return() + endif() + if(IPO_SUPPORTED) set_property(TARGET ${TargetName} PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) else() From 5fdfdf0586d8f668adf7087ec512e2573a83c902 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:18:15 -0300 Subject: [PATCH 46/91] Run IOP threads that outrank an RPC server's thread before executing its handler --- ps2xIOP/src/emulator/core/iop_kernel.cpp | 10 ++++++ ps2xIOP/src/emulator/core/iop_kernel.h | 8 +++++ ps2xIOP/src/emulator/iop_emulator.cpp | 18 +++++++++- ps2xIOP/src/emulator/services/iop_rpc.cpp | 7 ++++ ps2xIOP/src/emulator/services/iop_rpc.h | 3 ++ ps2xIOP/tests/iop_import_tests.cpp | 41 ++++++++++++++++++++++- 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_kernel.cpp b/ps2xIOP/src/emulator/core/iop_kernel.cpp index e383fe225f..7651ab1eb5 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.cpp +++ b/ps2xIOP/src/emulator/core/iop_kernel.cpp @@ -479,6 +479,16 @@ namespace ps2x::iop::detail return event == m_eventFlags.end() || bits == 0u || eventSatisfied(event->second, bits, mode); } + bool IopKernel::hasReadyAtOrAbove(int priority) const noexcept + { + for (const auto &[id, thread] : m_threads) + { + if (thread.state == IopThreadState::Ready && static_cast(thread.priority) <= priority) + return true; + } + return false; + } + bool IopKernel::semaphoreReady(int id) const { const auto semaphore = m_semaphores.find(id); diff --git a/ps2xIOP/src/emulator/core/iop_kernel.h b/ps2xIOP/src/emulator/core/iop_kernel.h index 9a7499fb10..69f8e94dd8 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.h +++ b/ps2xIOP/src/emulator/core/iop_kernel.h @@ -73,6 +73,14 @@ namespace ps2x::iop::detail [[nodiscard]] size_t threadCount() const noexcept { return m_threads.size(); } [[nodiscard]] bool inThread() const noexcept { return m_currentThread != nullptr; } + // Priority of the thread now running, or -1 outside any thread. + [[nodiscard]] int currentPriority() const noexcept + { + return m_currentThread != nullptr ? static_cast(m_currentThread->priority) : -1; + } + // Whether a thread that would run before one of `priority` is ready (lower value = more urgent; + // without time-slicing an equal-priority thread that already runs keeps the CPU). + [[nodiscard]] bool hasReadyAtOrAbove(int priority) const noexcept; // Whether a WaitEventFlag / WaitSema issued now could return without blocking (unknown IDs count as // ready so the regular import reports the error). [[nodiscard]] bool eventFlagReady(int id, uint32_t bits, uint32_t mode) const; diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index 19947dad55..48e4d8ece3 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -678,6 +678,22 @@ namespace ps2x::iop::detail totalInstructions = instructionsBefore; } + // The handler stands in for the server's own thread, which only gets the CPU once every ready + // thread that outranks it has blocked. Running it at once lets a stop request tear down objects + // a more urgent thread is still iterating (CRI's PSM thread then calls through a freed vtable). + RpcResult handleRpc(const RpcRequest &request) + { + const int priority = rpc.serverPriority(request.sid); + if (priority >= 0) + { + constexpr uint64_t kSliceCycles = 2048u; + const uint64_t giveUp = totalCycles + kIopClockHz; // a busy-looping thread must not hang the EE + while (kernel.hasReadyAtOrAbove(priority) && totalCycles < giveUp) + runCycles(kSliceCycles); + } + return rpc.handleRpc(request, *this); + } + void advanceSpu2() { constexpr size_t kFlushFrames = 512u; @@ -909,7 +925,7 @@ namespace ps2x::iop::detail RpcResult IopEmulator::handleRpc(const RpcRequest &request) { - return m_impl->rpc.handleRpc(request, *m_impl); + return m_impl->handleRpc(request); } bool IopEmulator::hasRpcServer(uint32_t sid) const noexcept diff --git a/ps2xIOP/src/emulator/services/iop_rpc.cpp b/ps2xIOP/src/emulator/services/iop_rpc.cpp index 7e5c9cd64d..a95a463018 100644 --- a/ps2xIOP/src/emulator/services/iop_rpc.cpp +++ b/ps2xIOP/src/emulator/services/iop_rpc.cpp @@ -220,6 +220,7 @@ namespace ps2x::iop::detail server.callback = m_memory.read32(stackPointer + 16u); server.callbackBuffer = m_memory.read32(stackPointer + 20u); server.queue = m_memory.read32(stackPointer + 24u); + server.priority = m_kernel.currentPriority(); m_servers[server.sid] = server; if (server.serverData != 0u) { @@ -357,6 +358,12 @@ namespace ps2x::iop::detail } } + int IopRpcBridge::serverPriority(uint32_t sid) const noexcept + { + const auto server = m_servers.find(sid); + return server != m_servers.end() ? server->second.priority : -1; + } + bool IopRpcBridge::hasServer(uint32_t sid) const noexcept { const auto server = m_servers.find(sid); diff --git a/ps2xIOP/src/emulator/services/iop_rpc.h b/ps2xIOP/src/emulator/services/iop_rpc.h index a4286ba3c2..698b3eba60 100644 --- a/ps2xIOP/src/emulator/services/iop_rpc.h +++ b/ps2xIOP/src/emulator/services/iop_rpc.h @@ -53,6 +53,8 @@ namespace ps2x::iop::detail void removeServersInRange(uint32_t base, uint32_t size); [[nodiscard]] bool hasServer(uint32_t sid) const noexcept; + // Priority of the thread that registered the server, or -1 if it was registered outside a thread. + [[nodiscard]] int serverPriority(uint32_t sid) const noexcept; [[nodiscard]] size_t serverCount() const noexcept { return m_servers.size(); } private: @@ -66,6 +68,7 @@ namespace ps2x::iop::detail uint32_t callback = 0; uint32_t callbackBuffer = 0; uint32_t queue = 0; + int priority = -1; }; IopHost &m_host; diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index b33902e64c..28010fee46 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -363,6 +363,44 @@ namespace "once interrupts are back on, the higher-priority thread must run"); } + bool testRpcServerKeepsItsThreadPriority() + { + // An RPC handler stands in for its server thread, so the emulator must know that thread's + // priority to let more urgent threads finish first (CRI's PSM thread at 39 vs its RPC thread at 48). + NullHost host; + IopMemory memory; + IopKernel kernel(memory); + kernel.reset(); + IopRpcBridge rpc(host, memory, kernel); + rpc.reset(); + + const int server = startTestThread(kernel, memory, 0x3000u, 0x10000u, 48u); + IopThread *running = kernel.beginNextReady(0u); + if (!expect(running != nullptr && running->id == server, "the server thread should run")) + return false; + IopCpuState cpu{}; + cpu.gpr[4] = 0x4000u; // server data + cpu.gpr[5] = 0x90000200u; // sid + cpu.gpr[6] = 0x10100u; // function + cpu.gpr[29] = 0x5000u; + if (!expect(rpc.dispatchSifCmdImport(17u, cpu), "sceSifRegisterRpc was not handled") || + !expect(rpc.serverPriority(0x90000200u) == 48, "the server should take its thread's priority")) + return false; + kernel.endTimeslice(*running, kThreadReturnSentinel); + + cpu.gpr[5] = 0x90000201u; + if (!expect(rpc.dispatchSifCmdImport(17u, cpu), "sceSifRegisterRpc was not handled") || + !expect(rpc.serverPriority(0x90000201u) == -1, "a server registered outside a thread has no priority")) + return false; + + if (!expect(kernel.hasReadyAtOrAbove(48), "the server thread itself is ready at 48")) + return false; + const int mixer = startTestThread(kernel, memory, 0x3040u, 0x20000u, 39u); + return expect(mixer > 0, "the mixer thread should start") && + expect(kernel.hasReadyAtOrAbove(39), "a ready priority-39 thread outranks a priority-48 server") && + expect(!kernel.hasReadyAtOrAbove(30), "nothing ready outranks priority 30"); + } + uint64_t startSpu2Dma(IopMemory &memory, bool autoDma) { memory.write16(0x1F9001B0u, static_cast(autoDma ? 1u : 0u)); // core 0 ADMAS @@ -695,7 +733,8 @@ int main() if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdSearchFile() || !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || !testSliceEndDoesNotHandTheCpuToAnEqualPriorityThread() || !testSliceEndInsideCpuSuspendIntrResumesSameThread() || !testSpu2AutoDmaCompletesAtPlaybackRate() || - !testDmacmanDrivesSio2Dma() || !testSio2DualShock2() || !testSifGetOtherData() || !testUnalignedLoadPair()) + !testDmacmanDrivesSio2Dma() || !testSio2DualShock2() || !testSifGetOtherData() || !testUnalignedLoadPair() || + !testRpcServerKeepsItsThreadPriority()) return 1; std::cout << "ps2xIOP import tests passed\n"; return 0; From 9b9bc1ba06de96d26a3f75fef63b0d1e20023324 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:25:50 -0300 Subject: [PATCH 47/91] Hold an EE RPC while an IOP thread stopped inside CpuSuspendIntr still holds interrupts off --- ps2xIOP/src/emulator/core/iop_kernel.cpp | 10 ++ ps2xIOP/src/emulator/core/iop_kernel.h | 2 + ps2xIOP/src/emulator/iop_emulator.cpp | 15 +-- ps2xIOP/tests/iop_emulator_tests.cpp | 117 +++++++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/ps2xIOP/src/emulator/core/iop_kernel.cpp b/ps2xIOP/src/emulator/core/iop_kernel.cpp index 7651ab1eb5..7a8f8560f2 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.cpp +++ b/ps2xIOP/src/emulator/core/iop_kernel.cpp @@ -489,6 +489,16 @@ namespace ps2x::iop::detail return false; } + bool IopKernel::hasCriticalSectionInProgress() const noexcept + { + for (const auto &[id, thread] : m_threads) + { + if (thread.state == IopThreadState::Ready && thread.sliceEndedWithInterruptsOff) + return true; + } + return false; + } + bool IopKernel::semaphoreReady(int id) const { const auto semaphore = m_semaphores.find(id); diff --git a/ps2xIOP/src/emulator/core/iop_kernel.h b/ps2xIOP/src/emulator/core/iop_kernel.h index 69f8e94dd8..96a5dd2aae 100644 --- a/ps2xIOP/src/emulator/core/iop_kernel.h +++ b/ps2xIOP/src/emulator/core/iop_kernel.h @@ -81,6 +81,8 @@ namespace ps2x::iop::detail // Whether a thread that would run before one of `priority` is ready (lower value = more urgent; // without time-slicing an equal-priority thread that already runs keeps the CPU). [[nodiscard]] bool hasReadyAtOrAbove(int priority) const noexcept; + // Whether the emulator stopped a thread mid-run while it held interrupts off (CpuSuspendIntr). + [[nodiscard]] bool hasCriticalSectionInProgress() const noexcept; // Whether a WaitEventFlag / WaitSema issued now could return without blocking (unknown IDs count as // ready so the regular import reports the error). [[nodiscard]] bool eventFlagReady(int id, uint32_t bits, uint32_t mode) const; diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index 48e4d8ece3..216ff556fb 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -684,13 +684,14 @@ namespace ps2x::iop::detail RpcResult handleRpc(const RpcRequest &request) { const int priority = rpc.serverPriority(request.sid); - if (priority >= 0) - { - constexpr uint64_t kSliceCycles = 2048u; - const uint64_t giveUp = totalCycles + kIopClockHz; // a busy-looping thread must not hang the EE - while (kernel.hasReadyAtOrAbove(priority) && totalCycles < giveUp) - runCycles(kSliceCycles); - } + // The request arrives as a SIF interrupt, so a thread stopped inside CpuSuspendIntr also keeps the + // server out until it re-enables interrupts, whatever its priority. + const auto mustWait = [&]() + { return kernel.hasCriticalSectionInProgress() || (priority >= 0 && kernel.hasReadyAtOrAbove(priority)); }; + constexpr uint64_t kSliceCycles = 2048u; + const uint64_t giveUp = totalCycles + kIopClockHz; // a busy-looping thread must not hang the EE + while (mustWait() && totalCycles < giveUp) + runCycles(kSliceCycles); return rpc.handleRpc(request, *this); } diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index a5cd66b3c6..4c38378015 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -838,6 +838,101 @@ namespace std::memcpy(host.guest.data() + address + codeOffset, segment.data(), segment.size()); } + // Registers an RPC server from module start, then starts a low-priority thread that sets 0x102F0 to 1, + // spins inside CpuSuspendIntr for a long while, sets the marker to 2, resumes interrupts and sleeps. + // The server function reports the marker it sees. + void writeCriticalSectionRpcIrx(TestHost &host, uint32_t address, uint32_t sid) + { + constexpr uint32_t codeOffset = 0x100u; + constexpr uint32_t loadAddress = 0x00010000u; + constexpr uint32_t registerRpcStub = loadAddress + 0x114u; + constexpr uint32_t createThreadStub = loadAddress + 0x154u; + constexpr uint32_t startThreadStub = loadAddress + 0x15Cu; + constexpr uint32_t sleepThreadStub = loadAddress + 0x164u; + constexpr uint32_t suspendIntrStub = loadAddress + 0x184u; + constexpr uint32_t resumeIntrStub = loadAddress + 0x18Cu; + + ElfHeader header{}; + header.ident[0] = 0x7Fu; header.ident[1] = 'E'; header.ident[2] = 'L'; header.ident[3] = 'F'; + header.ident[4] = 1u; header.ident[5] = 1u; header.ident[6] = 1u; + header.type = 2u; header.machine = 8u; header.version = 1u; + header.entry = loadAddress; header.phoff = sizeof(ElfHeader); + header.ehsize = sizeof(ElfHeader); header.phentsize = sizeof(ProgramHeader); header.phnum = 1u; + + ProgramHeader program{}; + program.type = 1u; program.offset = codeOffset; program.vaddr = loadAddress; program.paddr = loadAddress; + program.filesz = 0x300u; program.memsz = 0x500u; program.flags = 7u; program.align = 4u; + + const auto jal = [](uint32_t target) { return 0x0C000000u | ((target >> 2u) & 0x03FFFFFFu); }; + const uint32_t entry[] = { + 0x27BDFFE0u, 0xAFBF001Cu, // addiu sp, -0x20; sw ra, 0x1c(sp) + 0x3C040001u, 0x34840480u, // a0 = server data, clear of the thread code + 0x3C050000u | (sid >> 16u), 0x34A50000u | (sid & 0xFFFFu), + 0x3C060001u, 0x34C60300u, // a2 = server function + 0x3C070001u, 0x34E70400u, // a3 = server buffer + 0xAFA00010u, 0xAFA00014u, 0xAFA00018u, + jal(registerRpcStub), 0x00000000u, + 0x3C040001u, 0x348401A0u, // a0 = thread parameters + jal(createThreadStub), 0x00000000u, + 0x00402021u, 0x00002821u, // a0 = thread id, a1 = 0 + jal(startThreadStub), 0x00000000u, + 0x8FBF001Cu, 0x00001021u, 0x27BD0020u, 0x03E00008u, 0x00000000u, + }; + const uint32_t thread[] = { + 0x3C080001u, 0x24090001u, 0xAD0902F0u, // marker = 1 + 0x3C040001u, 0x348402F8u, // a0 = &state + jal(suspendIntrStub), 0x00000000u, + 0x3C0A0002u, // t2 = 0x20000 + 0x254AFFFFu, 0x1540FFFEu, 0x00000000u, // spin until t2 reaches zero + 0x3C080001u, 0x24090002u, 0xAD0902F0u, // marker = 2 + 0x3C040001u, 0x8C8402F8u, 0x00000000u, // a0 = state + jal(resumeIntrStub), 0x00000000u, + jal(sleepThreadStub), 0x00000000u, + 0x1000FFFDu, 0x00000000u, // sleep again if woken + }; + const uint32_t imports[] = { + 0x41E00000u, 0u, 0x00000101u, 0x63666973u, 0x0000646Du, // "sifcmd" + 0x03E00008u, 0x24000011u, // sceSifRegisterRpc + 0u, 0u, + }; + const uint32_t threadImports[] = { + 0x41E00000u, 0u, 0x00000101u, 0x61626874u, 0x00006573u, // "thbase" + 0x03E00008u, 0x24000004u, // CreateThread + 0x03E00008u, 0x24000006u, // StartThread + 0x03E00008u, 0x24000018u, // SleepThread + 0u, 0u, + }; + const uint32_t interruptImports[] = { + 0x41E00000u, 0u, 0x00000101u, 0x72746E69u, 0x006E616Du, // "intrman" + 0x03E00008u, 0x24000011u, // CpuSuspendIntr + 0x03E00008u, 0x24000012u, // CpuResumeIntr + 0u, 0u, + }; + // Priority 0x70: by priority alone the server's request would go first. + const uint32_t threadParameters[] = {0x02000000u, 0u, loadAddress + 0x200u, 0x800u, 0x70u}; + const uint32_t handler[] = { + 0x3C020001u, 0x8C4802F0u, 0x00000000u, // t0 = marker + 0xAC480400u, 0x34420400u, // reply word = marker; v0 = buffer + 0x03E00008u, 0x00000000u, + }; + + std::vector segment(program.filesz, 0u); + const auto put = [&](uint32_t offset, const void *data, size_t size) + { std::memcpy(segment.data() + offset, data, size); }; + put(0x000u, entry, sizeof(entry)); + put(0x100u, imports, sizeof(imports)); + put(0x140u, threadImports, sizeof(threadImports)); + put(0x170u, interruptImports, sizeof(interruptImports)); + put(0x1A0u, threadParameters, sizeof(threadParameters)); + put(0x200u, thread, sizeof(thread)); + std::memset(host.guest.data() + address, 0, codeOffset + 0x400u); + std::memcpy(host.guest.data() + address, &header, sizeof(header)); + std::memcpy(host.guest.data() + address + sizeof(header), &program, sizeof(program)); + std::memcpy(host.guest.data() + address + codeOffset, segment.data(), segment.size()); + // The server function sits past the file image, in zero-filled memory, so it is installed after load. + std::memcpy(host.guest.data() + 0xF00u, handler, sizeof(handler)); + } + void writeCdvdLifecycleIrx(TestHost &host, uint32_t address) { constexpr uint32_t codeOffset = 0x100u; @@ -1262,6 +1357,28 @@ int main() if (!expect(!emittedUnhandledImport, "MCMAN registration still emitted an unhandled IOP import")) return 1; + iop.reset(); + { + // An EE request is a SIF interrupt: it cannot reach its server while a thread holds interrupts off. + constexpr uint32_t criticalSid = 0x0001234Eu; + writeCriticalSectionRpcIrx(host, 0x100u, criticalSid); + if (!expect(iop.loadModuleBuffer(0x100u).startResult == 0, "Critical-section RPC module did not start")) return 1; + if (!expect(iop.writeMemory(0x00010300u, host.guest.data() + 0xF00u, 7u * sizeof(uint32_t)), + "Could not install the critical-section RPC handler")) return 1; + iop.runEeCycles(8u * 2000u); + uint32_t markerBefore = 0u; + (void)iop.readMemory(0x000102F0u, &markerBefore, sizeof(markerBefore)); + if (!expect(markerBefore == 1u, "The thread should be stopped inside its critical section before the request")) return 1; + RpcRequest criticalRequest{}; + criticalRequest.sid = criticalSid; + criticalRequest.receive = {0xA00u, sizeof(uint32_t)}; + const RpcResult criticalResult = iop.handleRpc(criticalRequest); + uint32_t seenMarker = 0u; + std::memcpy(&seenMarker, host.guest.data() + 0xA00u, sizeof(seenMarker)); + if (!expect(criticalResult.handled && seenMarker == 2u, + "The RPC server must wait until a preempted thread leaves CpuSuspendIntr")) return 1; + } + iop.reset(); host.logs.clear(); writeSecrAuthCardIrx(host, 0x100u); From 1477c73786177183ba95de831b629d10e51f53da Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:08:04 -0300 Subject: [PATCH 48/91] Batch GS primitives that sample their own RGB24 target into one parallel run and split its rows by estimated pixels --- .../include/runtime/gs/gs_cpu_backend.h | 61 +- .../runtime/gs/gs_texture_page_cache.h | 36 +- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 608 +++++++++++------- ps2xTest/src/ps2_gs_tests.cpp | 267 ++++++++ 4 files changed, 718 insertions(+), 254 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 94b2f4bda6..f4c2514c92 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -98,7 +99,7 @@ class GSCpuBackend final : public GSRasterBackend uint32_t ReadTextureVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y); void WriteVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value); - // Where a rasterizer pass writes and samples: all rows, or every Nth band of 8 rows on a worker. + // Where a rasterizer pass writes and samples: all rows, or one band (a contiguous range of rows). struct RasterTrace { uint32_t primitive = 0; @@ -106,26 +107,46 @@ class GSCpuBackend final : public GSRasterBackend int x = -1; bool sampled = false; }; + // A word a tracked batch overwrote in a page it also samples, with the pixel that overwrote it. + struct UndoEntry + { + uint32_t primitive = 0; + int y = 0; + int x = 0; + uint32_t offset = 0; + uint32_t old = 0; + }; struct RasterContext { - uint32_t workers = 1; - uint32_t index = 0; + int rowBegin = std::numeric_limits::min(); + int rowEnd = std::numeric_limits::max(); GSMem::TexturePageCache *cache = nullptr; RasterTrace *trace = nullptr; uint32_t primitive = 0; // The palette as it was when a queued primitive was submitted; null uses the current CLUT. const uint32_t *clut = nullptr; - // Set when the band's cache records read positions (see RasterizeTopByteAliasInParallel). + // Set when the band's cache records read positions (see RasterizeTrackedInParallel). bool trackReads = false; + // Where a tracked batch logs what it overwrites in undoPages, so a cached copy can be rebuilt. + std::vector *undo = nullptr; + const std::bitset<512> *undoPages = nullptr; bool ownsRow(int y) const { - return workers == 1u || ((static_cast(y) >> 3u) % workers) == index; + return y >= rowBegin && y < rowEnd; + } + int firstRow(int y) const + { + return y > rowBegin ? y : rowBegin; + } + int lastRow(int y) const + { + return y < rowEnd - 1 ? y : rowEnd - 1; } void beginSample(int y, int x) const { if (trackReads) - cache->BeginPixel(y, x); + cache->BeginPixel(primitive, y, x); } void sampled(int y, int x) const { @@ -139,6 +160,15 @@ class GSCpuBackend final : public GSRasterBackend } }; class RasterWorkers; + // Estimated pixels per group of 8 rows, which is the unit row ranges are split at. + static constexpr uint32_t kRowGroups = 256u; + using RowWeights = std::array; + struct RowRange + { + int begin = 0; + int end = 0; + }; + static void BalanceRows(const RowWeights &weights, uint32_t participants, std::vector &ranges); void DrawPrimitive(const GSPrimitiveBatch &batch, const uint32_t *clut = nullptr); void DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster); @@ -146,7 +176,8 @@ class GSCpuBackend final : public GSRasterBackend void QueuePrimitive(const GSPrimitiveBatch &batch); void FlushPendingUnlocked(); void RasterizePendingInParallel(); - bool RasterizeTopByteAliasInParallel(const GSPrimitiveBatch &batch); + void RasterizeTrackedInParallel(); + void ClearPendingUnlocked(); uint32_t RasterParticipants() const; void DrawSprite(const GSPrimitiveBatch &batch, const RasterContext &raster); void DrawTriangle(const GSPrimitiveBatch &batch, const RasterContext &raster); @@ -162,6 +193,8 @@ class GSCpuBackend final : public GSRasterBackend }; static PixelPipeline MakePixelPipeline(const GSDrawState &state); + static PixelPipeline MakePixelPipeline(const GSDrawState &state, const RasterContext &raster); + void RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, int x, int y); void WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); void WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); // Texture registers decoded once per primitive, with the CLUT expanded to RGBA for indexed formats. @@ -241,8 +274,21 @@ class GSCpuBackend final : public GSRasterBackend size_t m_pendingClutReadIndex = 0; std::bitset<512> m_pendingWritePages; std::bitset<512> m_pendingTexturePages; + std::bitset<512> m_pendingFramePages; + std::bitset<512> m_pendingDepthPages; + // Pages sampled whole (not just the top byte of 32-bit words), and pages written in formats that + // change that top byte. + std::bitset<512> m_pendingFullTexturePages; + std::bitset<512> m_pendingTopWritePages; + // A tracked batch samples pages it also draws (top bytes under RGB24/Z24 writes only), so it records + // reads and overwritten words to leave the texture page cache exactly as a serial draw would. + bool m_pendingTracked = false; + // Every queued primitive could join a tracked batch: textured sprites or triangles writing the same pages. + bool m_pendingTrackable = true; + uint64_t m_pendingSpanKey = 0; uint64_t m_pendingTargetKey = 0; uint64_t m_pendingPixels = 0; + RowWeights m_pendingRowWeights{}; std::unique_ptr m_workers; bool m_async = false; @@ -257,4 +303,5 @@ class GSCpuBackend final : public GSRasterBackend uint32_t m_rasterParticipants = 0; // Rebuilds the texture cache's copied page after a parallel draw into the page it holds. std::vector m_pageRebuildScratch; + std::vector> m_undoLogs; }; diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h index 9ae99dc7b8..b50ce02a51 100644 --- a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -102,12 +102,33 @@ namespace GSMem // Read tracking for a rasterizer band: remembers the page of the latest texel read and the position of // the latest read of any other page, which is where a sequential cache last switched pages. + // Positions order as a serial draw reads: by primitive, row, column, then texel within the pixel. struct ReadPosition { + uint32_t primitive = 0; int y = -1; int x = -1; uint32_t texel = 0; bool valid = false; + + bool samePixel(const ReadPosition& other) const noexcept + { + return primitive == other.primitive && y == other.y && x == other.x; + } + bool after(const ReadPosition& other) const noexcept + { + if (!other.valid) + return valid; + if (!valid) + return false; + if (primitive != other.primitive) + return primitive > other.primitive; + if (y != other.y) + return y > other.y; + if (x != other.x) + return x > other.x; + return texel > other.texel; + } }; struct ReadTrack { @@ -117,14 +138,18 @@ namespace GSMem bool switchedInsidePixel = false; }; - void EnableTracking() noexcept + // Starts a band's pass with nothing cached, reading in place, and optionally tracking reads. + void ResetForBand(bool tracking) noexcept { - m_tracking = true; + Invalidate(); + m_copyOnMiss = false; + m_tracking = tracking; m_track = {}; } - void BeginPixel(int y, int x) noexcept + void BeginPixel(uint32_t primitive, int y, int x) noexcept { + m_tagPrimitive = primitive; m_tagY = y; m_tagX = x; m_tagTexel = 0u; @@ -138,13 +163,13 @@ namespace GSMem private: void NoteRead(uint32_t page) noexcept { - const ReadPosition position{m_tagY, m_tagX, m_tagTexel++, true}; + const ReadPosition position{m_tagPrimitive, m_tagY, m_tagX, m_tagTexel++, true}; if (page != m_track.lastPage) { if (m_track.lastPage != UINT32_MAX) { m_track.other = m_track.last; - m_track.switchedInsidePixel = m_track.last.y == m_tagY && m_track.last.x == m_tagX; + m_track.switchedInsidePixel = m_track.last.samePixel(position); } m_track.lastPage = page; } @@ -164,6 +189,7 @@ namespace GSMem bool m_hasCopy = false; bool m_copyOnMiss = false; bool m_tracking = false; + uint32_t m_tagPrimitive = 0u; int m_tagY = -1; int m_tagX = -1; uint32_t m_tagTexel = 0u; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 8031f2f93c..378145d85c 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -615,6 +615,19 @@ class GSCpuBackend::RasterWorkers uint32_t participants() const { return m_participants; } + // Runs band(i) for i in [0, bands), each once, with every participant taking the next band as it + // finishes one, so a band that was estimated low does not leave the others waiting. + void runBands(uint32_t bands, const std::function &band) + { + std::atomic next{0u}; + const std::function job = [&](uint32_t) + { + for (uint32_t i = next.fetch_add(1u, std::memory_order_relaxed); i < bands; i = next.fetch_add(1u, std::memory_order_relaxed)) + band(i); + }; + run(job); + } + // Runs job(i) for every participant; the caller is participant 0. void run(const std::function &job) { @@ -681,6 +694,9 @@ namespace } constexpr size_t kMaxPendingPrimitives = 8192u; + // Row ranges per participant: pixel estimates are only bounding boxes, so finer ranges taken on + // demand even out what the estimate gets wrong. + constexpr uint32_t kBandsPerWorker = 4u; constexpr uint64_t kMaxPendingPixels = 1u << 22u; // Below this, waking the workers costs more than drawing the batch on the calling thread. constexpr uint64_t kMinParallelPixels = 16384u; @@ -697,9 +713,6 @@ namespace return psm == GS_PSM_CT24 || psm == GS_PSM_Z24; } - // Below this, a self-sampling primitive is cheaper to draw on the calling thread. - constexpr uint64_t kMinParallelAliasPixels = 4096u; - void markPages(std::bitset<512> &pages, const PageSpan &span) { for (uint32_t i = 0; i < span.count; ++i) @@ -713,6 +726,95 @@ namespace return true; return false; } + + struct PixelBounds + { + int x0 = 0; + int y0 = 0; + int x1 = -1; + int y1 = -1; + + bool empty() const { return x1 < x0 || y1 < y0; } + uint64_t area() const { return empty() ? 0u : static_cast(x1 - x0 + 1) * static_cast(y1 - y0 + 1); } + }; + + // Pixels a primitive can draw: its vertex box (rounded outwards, as every rasterizer stays inside it) + // within the scissor. + PixelBounds primitiveBounds(const GSPrimitiveBatch &batch) + { + const auto &ctx = batch.state.context; + float minX = batch.vertices[0].x, maxX = minX, minY = batch.vertices[0].y, maxY = minY; + for (uint32_t i = 1; i < std::min(batch.vertexCount, 3u); ++i) + { + minX = std::min(minX, batch.vertices[i].x); + maxX = std::max(maxX, batch.vertices[i].x); + minY = std::min(minY, batch.vertices[i].y); + maxY = std::max(maxY, batch.vertices[i].y); + } + auto toPixel = [](double value, int offset, int lo, int hi) + { + const double pixel = value - static_cast(offset); + if (!(pixel >= static_cast(lo))) + return lo; + if (pixel > static_cast(hi)) + return hi; + return static_cast(pixel); + }; + const int ofx = static_cast(ctx.xyoffset.ofx >> 4); + const int ofy = static_cast(ctx.xyoffset.ofy >> 4); + const int sx0 = static_cast(ctx.scissor.x0), sx1 = static_cast(ctx.scissor.x1); + const int sy0 = static_cast(ctx.scissor.y0), sy1 = static_cast(ctx.scissor.y1); + PixelBounds bounds; + if (std::isnan(minX) || std::isnan(maxX) || std::isnan(minY) || std::isnan(maxY)) + return {sx0, sy0, sx1, sy1}; + if (maxX - ofx < sx0 || minX - ofx > sx1 + 1 || maxY - ofy < sy0 || minY - ofy > sy1 + 1) + return bounds; + bounds.x0 = toPixel(std::floor(static_cast(minX)), ofx, sx0, sx1); + bounds.x1 = toPixel(std::ceil(static_cast(maxX)), ofx, sx0, sx1); + bounds.y0 = toPixel(std::floor(static_cast(minY)), ofy, sy0, sy1); + bounds.y1 = toPixel(std::ceil(static_cast(maxY)), ofy, sy0, sy1); + return bounds; + } + + // Each row also costs some setup besides its pixels. + constexpr uint64_t kRowCost = 8u; + + void addRowWeights(std::array &weights, const PixelBounds &bounds) + { + if (bounds.empty()) + return; + const uint64_t width = static_cast(bounds.x1 - bounds.x0 + 1) + kRowCost; + for (int group = bounds.y0 >> 3; group <= (bounds.y1 >> 3) && group < 256; ++group) + { + const int rows = std::min(bounds.y1, group * 8 + 7) - std::max(bounds.y0, group * 8) + 1; + weights[static_cast(group)] += width * static_cast(rows); + } + } +} + +void GSCpuBackend::BalanceRows(const RowWeights &weights, uint32_t participants, std::vector &ranges) +{ + // Contiguous ranges keep each band's writes together; cuts fall between 8-row groups, which no + // swizzled block straddles. + ranges.assign(participants, RowRange{}); + uint64_t total = 0u; + for (uint64_t weight : weights) + total += weight; + uint64_t sum = 0u; + uint32_t band = 0u; + int begin = std::numeric_limits::min(); + for (uint32_t group = 0; group < kRowGroups && band + 1u < participants; ++group) + { + sum += weights[group]; + while (band + 1u < participants && sum * participants >= total * (band + 1u)) + { + const int end = static_cast((group + 1u) * 8u); + ranges[band] = {begin, end}; + begin = end; + ++band; + } + } + ranges[band] = {begin, std::numeric_limits::max()}; } GSCpuBackend::~GSCpuBackend() @@ -740,49 +842,91 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) const uint32_t maxX = ctx.scissor.x1; const uint32_t maxY = ctx.scissor.y1; const uint32_t fbw = std::max(ctx.frame.fbw, 1u); - const PageSpan frameSpan = pageSpan(GSInternal::framePageBaseToBlock(ctx.frame.fbp), fbw, ctx.frame.psm, 0u, 0u, maxX, maxY); - const PageSpan depthSpan = pageSpan(GSInternal::framePageBaseToBlock(ctx.zbuf.zbp), fbw, ctx.zbuf.psm, 0u, 0u, maxX, maxY); - const PageSpan &frame = frameSpan; - const PageSpan &depth = depthSpan; + const PageSpan frame = pageSpan(GSInternal::framePageBaseToBlock(ctx.frame.fbp), fbw, ctx.frame.psm, 0u, 0u, maxX, maxY); + const PageSpan depth = pageSpan(GSInternal::framePageBaseToBlock(ctx.zbuf.zbp), fbw, ctx.zbuf.psm, 0u, 0u, maxX, maxY); PageSpan texture{}; if (state.prim.tme) { const auto &tex = ctx.tex0; const uint32_t extentU = (ctx.clamp & 0x2u) != 0u ? 1024u : std::max(state.textureWidth, 1u); const uint32_t extentV = ((ctx.clamp >> 2) & 0x2u) != 0u ? 1024u : std::max(state.textureHeight, 1u); - const PageSpan span = pageSpan(tex.tbp0, tex.tbw, tex.psm, 0u, 0u, extentU - 1u, extentV - 1u); - texture = span; + texture = pageSpan(tex.tbp0, tex.tbw, tex.psm, 0u, 0u, extentU - 1u, extentV - 1u); } + // T8H/T4HL/T4HH sample only the top byte of 32-bit words, which RGB24/Z24 writes leave alone, so + // such reads do not depend on the order of those writes. + const bool topReader = texture.count != 0u && readsTopByteOnly(ctx.tex0.psm); + const PageSpan frameTop = keepsTopByte(ctx.frame.psm) ? PageSpan{} : frame; + const PageSpan depthTop = keepsTopByte(ctx.zbuf.psm) ? PageSpan{} : depth; + auto readsChanged = [&](const PageSpan &written, const PageSpan &writtenTop) + { + return topReader ? spansOverlap(texture, writtenTop) : spansOverlap(texture, written); + }; - // Pixels of one primitive map to distinct memory only if colour and depth do not alias each other, - // and rows can be split only if the primitive never samples what it draws. - const bool selfOverlap = spansOverlap(frameSpan, depthSpan) || - (texture.count != 0u && (spansOverlap(texture, frameSpan) || spansOverlap(texture, depthSpan))); - if (selfOverlap) + // A pixel's colour and depth must not share memory, and the primitive must not sample anything its + // own writes change; otherwise it is drawn alone, in order. + if (spansOverlap(frame, depth) || (texture.count != 0u && (readsChanged(frame, frameTop) || readsChanged(depth, depthTop)))) { FlushPendingUnlocked(); - const bool topByteAlias = !spansOverlap(frameSpan, depthSpan) && readsTopByteOnly(ctx.tex0.psm) && - (!spansOverlap(texture, frameSpan) || keepsTopByte(ctx.frame.psm)) && - (!spansOverlap(texture, depthSpan) || keepsTopByte(ctx.zbuf.psm)); - if (!topByteAlias || !RasterizeTopByteAliasInParallel(batch)) - DrawPrimitive(batch); + DrawPrimitive(batch); + return; + } + const bool selfAlias = texture.count != 0u && (spansOverlap(texture, frame) || spansOverlap(texture, depth)); + // Tracked batches log overwritten words through direct target addressing, and rely on every + // primitive reading a texel before writing a pixel and on all of them writing the same pages. + const bool samplesEveryPixel = state.prim.type == GS_PRIM_SPRITE || state.prim.type == GS_PRIM_TRIANGLE || + state.prim.type == GS_PRIM_TRISTRIP || state.prim.type == GS_PRIM_TRIFAN; + const bool trackable = state.prim.tme && samplesEveryPixel && GSMem::TargetPixelOffset(ctx.frame.psm) && GSMem::TargetPixelOffset(ctx.zbuf.psm); + if (selfAlias && !trackable) + { + FlushPendingUnlocked(); + DrawPrimitive(batch); return; } // Every queued primitive must address colour and depth the same way, so a pixel's bytes stay on the - // row band that owns the pixel. + // row range that owns the pixel. const uint64_t targetKey = (static_cast(ctx.frame.fbp) << 0u) ^ (static_cast(fbw) << 12u) ^ (static_cast(ctx.frame.psm) << 20u) ^ (static_cast(ctx.zbuf.zbp) << 28u) ^ (static_cast(ctx.zbuf.psm) << 40u) ^ (1ull << 63u); - if (!m_pending.empty() && - (targetKey != m_pendingTargetKey || anyPage(m_pendingWritePages, texture) || - anyPage(m_pendingTexturePages, frame) || anyPage(m_pendingTexturePages, depth))) - FlushPendingUnlocked(); + const uint64_t spanKey = (static_cast(frame.first) << 0u) | (static_cast(frame.count) << 16u) | + (static_cast(depth.first) << 32u) | (static_cast(depth.count) << 48u); + auto aliasesPending = [&]() + { + return selfAlias || anyPage(m_pendingWritePages, texture) || anyPage(m_pendingTexturePages, frame) || + anyPage(m_pendingTexturePages, depth); + }; + if (!m_pending.empty()) + { + const bool readsPendingWrites = topReader ? anyPage(m_pendingTopWritePages, texture) : anyPage(m_pendingWritePages, texture); + const bool writesPendingReads = anyPage(m_pendingFullTexturePages, frame) || anyPage(m_pendingFullTexturePages, depth) || + anyPage(m_pendingTexturePages, frameTop) || anyPage(m_pendingTexturePages, depthTop); + const bool tracked = m_pendingTracked || aliasesPending(); + // An untracked batch that already let a palette load read VRAM cannot become tracked (see LoadClutNow). + const bool canTrack = m_pendingTrackable && trackable && spanKey == m_pendingSpanKey && + (m_pendingTracked || m_pendingClutReadIndex == 0u); + if (targetKey != m_pendingTargetKey || anyPage(m_pendingDepthPages, frame) || anyPage(m_pendingFramePages, depth) || + readsPendingWrites || writesPendingReads || (tracked && !canTrack)) + FlushPendingUnlocked(); + } + if (m_pending.empty()) + { + m_pendingSpanKey = spanKey; + m_pendingTrackable = trackable; + } + m_pendingTrackable = m_pendingTrackable && trackable && spanKey == m_pendingSpanKey; + if (aliasesPending()) + m_pendingTracked = true; m_pendingTargetKey = targetKey; markPages(m_pendingWritePages, frame); markPages(m_pendingWritePages, depth); + markPages(m_pendingFramePages, frame); + markPages(m_pendingDepthPages, depth); + markPages(m_pendingTopWritePages, frameTop); + markPages(m_pendingTopWritePages, depthTop); markPages(m_pendingTexturePages, texture); + if (!topReader) + markPages(m_pendingFullTexturePages, texture); int32_t clutIndex = -1; if (state.prim.tme && (isFourBitIndexedPsm(ctx.tex0.psm) || isEightBitIndexedPsm(ctx.tex0.psm))) { @@ -801,237 +945,188 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) } m_pendingClutIndex.push_back(clutIndex); m_pending.push_back(batch); - const uint64_t width = static_cast(maxX >= ctx.scissor.x0 ? maxX - ctx.scissor.x0 + 1u : 1u); - const uint64_t height = static_cast(maxY >= ctx.scissor.y0 ? maxY - ctx.scissor.y0 + 1u : 1u); - uint64_t estimate = width * height; - if (batch.vertexCount >= 2u) - { - float minX = batch.vertices[0].x, maxXf = batch.vertices[0].x, minY = batch.vertices[0].y, maxYf = batch.vertices[0].y; - for (uint32_t i = 1; i < std::min(batch.vertexCount, 3u); ++i) - { - minX = std::min(minX, batch.vertices[i].x); - maxXf = std::max(maxXf, batch.vertices[i].x); - minY = std::min(minY, batch.vertices[i].y); - maxYf = std::max(maxYf, batch.vertices[i].y); - } - const double area = (static_cast(maxXf) - minX + 1.0) * (static_cast(maxYf) - minY + 1.0); - if (area >= 0.0 && area < static_cast(estimate)) - estimate = static_cast(area); - } - m_pendingPixels += estimate; + const PixelBounds bounds = primitiveBounds(batch); + addRowWeights(m_pendingRowWeights, bounds); + m_pendingPixels += bounds.area(); if (m_pending.size() >= kMaxPendingPrimitives || m_pendingPixels >= kMaxPendingPixels) FlushPendingUnlocked(); } -bool GSCpuBackend::RasterizeTopByteAliasInParallel(const GSPrimitiveBatch &batch) +void GSCpuBackend::ClearPendingUnlocked() { - // The primitive samples top bytes of words it rewrites below them, so every pixel comes out the same - // drawn in any order. What a sequential draw also leaves behind is the texture cache: it copies each - // drawn-over page when loading it, so the page it ends on can hold words as they were mid-primitive. - const GSDrawState &state = batch.state; - const auto &ctx = state.context; - float minX = batch.vertices[0].x, maxX = minX, minY = batch.vertices[0].y, maxY = minY; - for (uint32_t i = 1; i < std::min(batch.vertexCount, 3u); ++i) - { - minX = std::min(minX, batch.vertices[i].x); - maxX = std::max(maxX, batch.vertices[i].x); - minY = std::min(minY, batch.vertices[i].y); - maxY = std::max(maxY, batch.vertices[i].y); - } - const double area = (static_cast(maxX) - minX + 1.0) * (static_cast(maxY) - minY + 1.0); - if (RasterParticipants() <= 1u || !(area >= static_cast(kMinParallelAliasPixels))) - return false; - if (!m_texturePageCache.MatchesVram(m_vram)) - return false; + m_pending.clear(); + m_pendingCluts.clear(); + m_pendingClutIndex.clear(); + m_pendingClutGeneration = UINT64_MAX; + m_pendingClutReadIndex = 0u; + m_pendingWritePages.reset(); + m_pendingTexturePages.reset(); + m_pendingFramePages.reset(); + m_pendingDepthPages.reset(); + m_pendingFullTexturePages.reset(); + m_pendingTopWritePages.reset(); + m_pendingTracked = false; + m_pendingTrackable = true; + m_pendingPixels = 0u; + m_pendingRowWeights.fill(0u); +} - const uint32_t scissorX = ctx.scissor.x1; - const uint32_t scissorY = ctx.scissor.y1; - const uint32_t fbw = std::max(ctx.frame.fbw, 1u); - const uint32_t frameBlock = GSInternal::framePageBaseToBlock(ctx.frame.fbp); - const uint32_t depthBlock = GSInternal::framePageBaseToBlock(ctx.zbuf.zbp); - const PageSpan frame = pageSpan(frameBlock, fbw, ctx.frame.psm, 0u, 0u, scissorX, scissorY); - const PageSpan depth = pageSpan(depthBlock, fbw, ctx.zbuf.psm, 0u, 0u, scissorX, scissorY); - const uint32_t extentU = (ctx.clamp & 0x2u) != 0u ? 1024u : std::max(state.textureWidth, 1u); - const uint32_t extentV = ((ctx.clamp >> 2) & 0x2u) != 0u ? 1024u : std::max(state.textureHeight, 1u); - const PageSpan texture = pageSpan(ctx.tex0.tbp0, ctx.tex0.tbw, ctx.tex0.psm, 0u, 0u, extentU - 1u, extentV - 1u); - - // Sequentially, a cached page in place that is about to be drawn over is snapshotted first. +void GSCpuBackend::FlushPendingUnlocked() +{ + if (m_pending.empty()) + return; + + bool parallel = m_pendingPixels >= kMinParallelPixels && RasterParticipants() > 1u; uint32_t livePage = 0u; - if (m_texturePageCache.LivePage(livePage)) + if (parallel && m_pendingTracked) { - const uint32_t page = livePage / GSMem::TexturePageCache::kPageSize; - if (spanHasPage(frame, page) || spanHasPage(depth, page)) - m_texturePageCache.Snapshot(m_vram); + // Bands read pages in place; the serial cache state is rebuilt afterwards from VRAM. + parallel = m_texturePageCache.MatchesVram(m_vram); + } + else if (parallel && !m_texturePageCache.LivePage(livePage) && m_texturePageCache.CachedPage(livePage)) + { + // Workers read pages in place, which matches a copied page only if VRAM still holds the same bytes. + if (!m_texturePageCache.MatchesVram(m_vram)) + parallel = false; + else + m_texturePageCache.MakeLive(); } - // Keep the pages that are both sampled and drawn, as they were before drawing. - std::vector keptPages; - for (uint32_t i = 0; i < texture.count; ++i) + if (parallel && m_pendingTracked) { - const uint32_t page = (texture.first + i) % kVramPageCount; - if (spanHasPage(frame, page) || spanHasPage(depth, page)) - keptPages.push_back(page); + RasterizeTrackedInParallel(); + } + else if (parallel) + { + RasterizePendingInParallel(); } - std::vector before(keptPages.size() * GSMem::TexturePageCache::kPageSize); - for (size_t i = 0; i < keptPages.size(); ++i) - std::memcpy(before.data() + i * GSMem::TexturePageCache::kPageSize, - m_vram + keptPages[i] * GSMem::TexturePageCache::kPageSize, GSMem::TexturePageCache::kPageSize); + else + { + for (size_t i = 0; i < m_pending.size(); ++i) + { + const int32_t clutIndex = m_pendingClutIndex[i]; + DrawPrimitive(m_pending[i], clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr); + } + } + ClearPendingUnlocked(); +} + +void GSCpuBackend::RasterizeTrackedInParallel() +{ + // Every primitive samples only top bytes of the pages the batch draws into with RGB24/Z24, so every + // pixel comes out the same drawn in any order. What a serial draw also leaves behind is the texture + // cache: it copies a drawn-over page when loading it, so the page it ends on can hold words as they + // were mid-batch. + uint32_t livePage = 0u; + if (m_texturePageCache.LivePage(livePage) && + m_pendingWritePages.test((livePage / GSMem::TexturePageCache::kPageSize) % 512u)) + m_texturePageCache.Snapshot(m_vram); + uint32_t cachedBefore = UINT32_MAX; + m_texturePageCache.CachedPage(cachedBefore); if (!m_workers || m_workers->participants() != RasterParticipants()) m_workers = std::make_unique(RasterParticipants()); - const uint32_t participants = m_workers->participants(); - std::vector caches(participants); - std::vector traces(participants); - for (GSMem::TexturePageCache &cache : caches) - cache.EnableTracking(); - const std::function job = [&](uint32_t index) + const uint32_t bands = m_workers->participants() * kBandsPerWorker; + std::vector rows; + BalanceRows(m_pendingRowWeights, bands, rows); + const std::bitset<512> kept = m_pendingTexturePages & m_pendingWritePages; + std::vector caches(bands); + m_undoLogs.resize(bands); + for (uint32_t i = 0; i < bands; ++i) + { + caches[i].ResetForBand(true); + m_undoLogs[i].clear(); + } + const std::function band = [&](uint32_t index) { RasterContext raster{}; - raster.workers = participants; - raster.index = index; + raster.rowBegin = rows[index].begin; + raster.rowEnd = rows[index].end; raster.cache = &caches[index]; - raster.trace = &traces[index]; raster.trackReads = true; - DrawPrimitiveRows(batch, raster); + raster.undo = &m_undoLogs[index]; + raster.undoPages = &kept; + for (size_t i = 0; i < m_pending.size(); ++i) + { + raster.primitive = static_cast(i); + const int32_t clutIndex = m_pendingClutIndex[i]; + raster.clut = clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr; + DrawPrimitiveRows(m_pending[i], raster); + } }; - m_workers->run(job); + m_workers->runBands(bands, band); - // The page of the last texel read in raster order is where the sequential cache ends. - int lastBand = -1; - for (uint32_t i = 0; i < participants; ++i) + // The page of the last texel read in serial order is where the cache ends. + GSMem::TexturePageCache::ReadPosition lastRead{}; + uint32_t lastPage = UINT32_MAX; + for (uint32_t i = 0; i < bands; ++i) { - const auto &track = caches[i].Track(); - if (!track.last.valid) - continue; - if (lastBand < 0) + if (caches[i].Track().last.after(lastRead)) { - lastBand = static_cast(i); - continue; + lastRead = caches[i].Track().last; + lastPage = caches[i].Track().lastPage; } - const auto &best = caches[static_cast(lastBand)].Track().last; - if (track.last.y > best.y || (track.last.y == best.y && track.last.x > best.x)) - lastBand = static_cast(i); } - if (lastBand < 0) - return true; - const uint32_t lastPage = caches[static_cast(lastBand)].Track().lastPage; - const uint32_t lastPageIndex = lastPage / GSMem::TexturePageCache::kPageSize; - const auto kept = std::find(keptPages.begin(), keptPages.end(), lastPageIndex); - if (kept == keptPages.end()) + // A palette load from VRAM between queued primitives also read through the cache (a page the batch + // does not write); it ran before the batch was drawn and left the cache where it ends if nothing + // after it samples. + GSMem::TexturePageCache::ReadPosition clutRead{}; + if (m_pendingClutReadIndex > 0u) + clutRead = {static_cast(m_pendingClutReadIndex), std::numeric_limits::min(), std::numeric_limits::min(), 0u, true}; + if (!lastRead.after(clutRead)) + return; + if (!kept.test(lastPage / GSMem::TexturePageCache::kPageSize)) { - // Not drawn over: the sequential cache would read it in place. + // Not drawn over: the serial cache reads it in place. m_texturePageCache.SetLivePage(lastPage); - return true; + return; } - // The sequential cache copied lastPage after its latest read of any other page. Find that read. + // The serial cache copied lastPage when it read it right after its latest read of any other page. GSMem::TexturePageCache::ReadPosition switchRead{}; bool switchInsidePixel = false; - auto later = [](const GSMem::TexturePageCache::ReadPosition &a, const GSMem::TexturePageCache::ReadPosition &b) - { - if (!b.valid) - return a.valid; - if (!a.valid) - return false; - if (a.y != b.y) - return a.y > b.y; - if (a.x != b.x) - return a.x > b.x; - return a.texel > b.texel; - }; - for (uint32_t i = 0; i < participants; ++i) + for (uint32_t i = 0; i < bands; ++i) { const auto &track = caches[i].Track(); if (!track.last.valid) continue; // A band ending on another page read that page last; one ending on lastPage read others before. const GSMem::TexturePageCache::ReadPosition candidate = track.lastPage != lastPage ? track.last : track.other; - const bool inside = track.lastPage != lastPage ? false : track.switchedInsidePixel; - if (later(candidate, switchRead)) + if (candidate.after(switchRead)) { switchRead = candidate; - switchInsidePixel = inside; + switchInsidePixel = track.lastPage == lastPage && track.switchedInsidePixel; } } - - // Words drawn by pixels before the copy are as drawn; the rest still hold what they held before. - const size_t keptIndex = static_cast(kept - keptPages.begin()); - const uint8_t *beforePage = before.data() + keptIndex * GSMem::TexturePageCache::kPageSize; - if (!switchRead.valid) + if (clutRead.after(switchRead)) { - m_texturePageCache.SetCopy(beforePage, lastPage); - return true; + switchRead = clutRead; + switchInsidePixel = false; } - if (m_pageRebuildScratch.size() != m_vramSize) - m_pageRebuildScratch.assign(m_vramSize, 0u); - std::memcpy(m_pageRebuildScratch.data() + lastPage, beforePage, GSMem::TexturePageCache::kPageSize); - auto drawnBeforeCopy = [&](int y, int x) - { - if (y != switchRead.y) - return y < switchRead.y; - return switchInsidePixel ? x < switchRead.x : x <= switchRead.x; - }; - auto rebuildFrom = [&](const PageSpan &span, uint32_t baseBlock, uint32_t psm) - { - if (!spanHasPage(span, lastPageIndex)) - return; - uint32_t widthShift = 6u; - uint32_t heightShift = 5u; - psmPageShift(psm, widthShift, heightShift); - const uint32_t stride = std::max((fbw * 64u) >> widthShift, 1u); - const uint32_t relative = (lastPageIndex + kVramPageCount - (baseBlock / 32u) % kVramPageCount) % kVramPageCount; - const int tileX = static_cast((relative % stride) << widthShift); - const int tileY = static_cast((relative / stride) << heightShift); - for (int y = tileY; y < tileY + (1 << heightShift); ++y) - for (int x = tileX; x < tileX + (1 << widthShift); ++x) - if (drawnBeforeCopy(y, x)) - m_writeVramFuncs[psm & 0x3Fu](m_pageRebuildScratch.data(), baseBlock, fbw, static_cast(x), - static_cast(y), ReadVramUnlocked(psm, baseBlock, fbw, - static_cast(x), - static_cast(y))); - }; - rebuildFrom(frame, frameBlock, ctx.frame.psm); - rebuildFrom(depth, depthBlock, ctx.zbuf.psm); - m_texturePageCache.SetCopy(m_pageRebuildScratch.data() + lastPage, lastPage); - return true; -} - -void GSCpuBackend::FlushPendingUnlocked() -{ - if (m_pending.empty()) + // With no switch in the batch, a cache already holding the page keeps its copy (snapshotted above if + // it was read in place); otherwise the page was copied on the batch's first read, before any write. + if (!switchRead.valid && cachedBefore == lastPage) return; - bool parallel = m_pendingPixels >= kMinParallelPixels && RasterParticipants() > 1u; - uint32_t livePage = 0u; - if (parallel && !m_texturePageCache.LivePage(livePage) && m_texturePageCache.CachedPage(livePage)) - { - // Workers read pages in place, which matches a copied page only if VRAM still holds the same bytes. - if (!m_texturePageCache.MatchesVram(m_vram)) - parallel = false; - else - m_texturePageCache.MakeLive(); - } - - if (parallel) + // Take what VRAM holds now and undo, newest first, every write to the page made after the copy. Each + // word belongs to one pixel, which one band drew in serial order. + auto drawnBeforeCopy = [&](const UndoEntry &entry) { - RasterizePendingInParallel(); - } - else - { - for (size_t i = 0; i < m_pending.size(); ++i) - { - const int32_t clutIndex = m_pendingClutIndex[i]; - DrawPrimitive(m_pending[i], clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr); - } - } - m_pending.clear(); - m_pendingCluts.clear(); - m_pendingClutIndex.clear(); - m_pendingClutGeneration = UINT64_MAX; - m_pendingClutReadIndex = 0u; - m_pendingWritePages.reset(); - m_pendingTexturePages.reset(); - m_pendingPixels = 0u; + if (!switchRead.valid) + return false; + if (entry.primitive != switchRead.primitive) + return entry.primitive < switchRead.primitive; + if (entry.y != switchRead.y) + return entry.y < switchRead.y; + return switchInsidePixel ? entry.x < switchRead.x : entry.x <= switchRead.x; + }; + m_pageRebuildScratch.resize(GSMem::TexturePageCache::kPageSize); + std::memcpy(m_pageRebuildScratch.data(), m_vram + lastPage, GSMem::TexturePageCache::kPageSize); + for (const std::vector &log : m_undoLogs) + for (auto entry = log.rbegin(); entry != log.rend(); ++entry) + if (entry->offset - lastPage < GSMem::TexturePageCache::kPageSize && !drawnBeforeCopy(*entry)) + std::memcpy(m_pageRebuildScratch.data() + (entry->offset - lastPage), &entry->old, sizeof(entry->old)); + m_texturePageCache.SetCopy(m_pageRebuildScratch.data(), lastPage); } void GSCpuBackend::RasterizePendingInParallel() @@ -1045,15 +1140,16 @@ void GSCpuBackend::RasterizePendingInParallel() if (!m_workers || m_workers->participants() != RasterParticipants()) m_workers = std::make_unique(RasterParticipants()); - const uint32_t participants = m_workers->participants(); - - std::vector caches(participants); - std::vector traces(participants); - const std::function job = [&](uint32_t index) + const uint32_t bands = m_workers->participants() * kBandsPerWorker; + std::vector rows; + BalanceRows(m_pendingRowWeights, bands, rows); + std::vector caches(bands); + std::vector traces(bands); + const std::function band = [&](uint32_t index) { RasterContext raster{}; - raster.workers = participants; - raster.index = index; + raster.rowBegin = rows[index].begin; + raster.rowEnd = rows[index].end; raster.cache = &caches[index]; raster.trace = &traces[index]; for (size_t i = 0; i < m_pending.size(); ++i) @@ -1064,12 +1160,12 @@ void GSCpuBackend::RasterizePendingInParallel() DrawPrimitiveRows(m_pending[i], raster); } }; - m_workers->run(job); + m_workers->runBands(bands, band); // Sequentially, the cache would end on the page of the last texel read: the last sampled pixel in // primitive then raster order, which lies on exactly one band. int best = -1; - for (uint32_t i = 0; i < participants; ++i) + for (uint32_t i = 0; i < bands; ++i) { const RasterTrace &trace = traces[i]; if (!trace.sampled) @@ -1375,14 +1471,7 @@ void GSCpuBackend::InitializeNow(uint8_t *vram, uint32_t vramSize) std::lock_guard lock(m_mutex); // A new VRAM binding makes queued primitives meaningless; nothing may still refer to the old memory. - m_pending.clear(); - m_pendingCluts.clear(); - m_pendingClutIndex.clear(); - m_pendingClutGeneration = UINT64_MAX; - m_pendingClutReadIndex = 0u; - m_pendingWritePages.reset(); - m_pendingTexturePages.reset(); - m_pendingPixels = 0u; + ClearPendingUnlocked(); m_vram = vram; m_vramSize = vramSize; ResetUnlocked(); @@ -1466,9 +1555,14 @@ void GSCpuBackend::LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclu return; } + // LoadClutUnlocked reads VRAM (moving the texture page cache) only for 16- and 32-bit palettes. A tracked + // batch accounts for that read only when it is drawn in parallel, which it will be if it is big enough + // already and the cache stays a faithful copy; otherwise it is drawn before the load. + const bool readsVram = tex0.cpsm == GS_PSM_CT16 || tex0.cpsm == GS_PSM_CT16S || tex0.cpsm == GS_PSM_CT32 || tex0.cpsm == GS_PSM_CT24; + if (readsVram && m_pendingTracked && (m_pendingPixels < kMinParallelPixels || !m_texturePageCache.MatchesVram(m_vram))) + FlushPendingUnlocked(); LoadClutUnlocked(tex0, texclut); - // LoadClutUnlocked reads VRAM (moving the texture page cache) only for 16- and 32-bit palettes. - if (tex0.cpsm == GS_PSM_CT16 || tex0.cpsm == GS_PSM_CT16S || tex0.cpsm == GS_PSM_CT32 || tex0.cpsm == GS_PSM_CT24) + if (readsVram) m_pendingClutReadIndex = m_pending.size(); } @@ -1809,6 +1903,10 @@ struct GSCpuBackend::PixelPipeline // Direct addressing for colour and depth; null falls back to the generic VRAM accessors. GSMem::PixelOffsetFn frameOffset = nullptr; GSMem::PixelOffsetFn depthOffset = nullptr; + // Set in tracked batches: words overwritten in undoPages are logged for the pixel being drawn. + std::vector *undo = nullptr; + const std::bitset<512> *undoPages = nullptr; + uint32_t primitive = 0; }; void GSCpuBackend::WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) @@ -1876,6 +1974,21 @@ namespace } } +void GSCpuBackend::RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, int x, int y) +{ + // Only RGB24/Z24 targets write tracked pages, so the whole word is the pixel's. + const uint32_t offset = static_cast(pixel - m_vram); + if (!pipe.undoPages->test(offset / GSMem::TexturePageCache::kPageSize)) + return; + UndoEntry entry; + entry.primitive = pipe.primitive; + entry.y = y; + entry.x = x; + entry.offset = offset; + std::memcpy(&entry.old, pixel, sizeof(entry.old)); + pipe.undo->push_back(entry); +} + __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) { const GSDrawState &state = *pipe.state; @@ -2055,6 +2168,8 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP { if (!framePixel) framePixel = m_vram + pipe.frameOffset(fbp, fbw, static_cast(x), static_cast(y)); + if (pipe.undo) + RecordUndo(pipe, framePixel, x, y); writeTarget(fpsm, framePixel, pixel); } else @@ -2069,6 +2184,8 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP { if (!depthPixel) depthPixel = m_vram + pipe.depthOffset(zbp, fbw, static_cast(x), static_cast(y)); + if (pipe.undo) + RecordUndo(pipe, depthPixel, x, y); writeTarget(zpsm, depthPixel, static_cast(z)); } else @@ -2078,6 +2195,15 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP } } +GSCpuBackend::PixelPipeline GSCpuBackend::MakePixelPipeline(const GSDrawState &state, const RasterContext &raster) +{ + PixelPipeline pipe = MakePixelPipeline(state); + pipe.undo = raster.undo; + pipe.undoPages = raster.undoPages; + pipe.primitive = raster.primitive; + return pipe; +} + GSCpuBackend::PixelPipeline GSCpuBackend::MakePixelPipeline(const GSDrawState &state) { const auto &ctx = state.context; @@ -2352,6 +2478,8 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext const int drawY0 = clampInt(unclippedY0, ctx.scissor.y0, ctx.scissor.y1); const int drawX1 = clampInt(unclippedX1, ctx.scissor.x0, ctx.scissor.x1); const int drawY1 = clampInt(unclippedY1, ctx.scissor.y0, ctx.scissor.y1); + if (raster.firstRow(drawY0) > raster.lastRow(drawY1)) + return; const uint64_t alphaReg = ctx.alpha; const uint8_t alphaMode = static_cast(alphaReg & 0xFFu); @@ -2422,12 +2550,10 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext PrepareTextureAxis(axisCoordinate(texUf, texW), texW, wrapU, minU, maxU, state.linearFilter); } - const PixelPipeline pipe = MakePixelPipeline(state); + const PixelPipeline pipe = MakePixelPipeline(state, raster); const TextureSampler sampler = MakeTextureSampler(state, *raster.cache, raster.clut); - for (int y = drawY0; y <= drawY1; ++y) + for (int y = raster.firstRow(drawY0); y <= raster.lastRow(drawY1); ++y) { - if (!raster.ownsRow(y)) - continue; float ty = (static_cast(y - unclippedY0) + 0.5f) / spriteH; float texVf = v0f + (v1f - v0f) * ty; const TextureAxis row = PrepareTextureAxis(axisCoordinate(texVf, texH), texH, wrapV, minV, maxV, state.linearFilter); @@ -2450,11 +2576,9 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext } else { - const PixelPipeline pipe = MakePixelPipeline(state); - for (int y = drawY0; y <= drawY1; ++y) + const PixelPipeline pipe = MakePixelPipeline(state, raster); + for (int y = raster.firstRow(drawY0); y <= raster.lastRow(drawY1); ++y) { - if (!raster.ownsRow(y)) - continue; for (int x = drawX0; x <= drawX1; ++x) WritePixel(pipe, x, y, z1, r, g, b, a, v1.fog); } @@ -2468,8 +2592,6 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte const GSVertex &v1 = batch.vertices[1]; const GSVertex &v2 = batch.vertices[2]; const auto &ctx = state.context; - const PixelPipeline pipe = MakePixelPipeline(state); - const TextureSampler sampler = state.prim.tme ? MakeTextureSampler(state, *raster.cache, raster.clut) : TextureSampler{}; int ofx = ctx.xyoffset.ofx >> 4; int ofy = ctx.xyoffset.ofy >> 4; @@ -2490,6 +2612,10 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte maxX = clampInt(maxX, ctx.scissor.x0, ctx.scissor.x1); minY = clampInt(minY, ctx.scissor.y0, ctx.scissor.y1); maxY = clampInt(maxY, ctx.scissor.y0, ctx.scissor.y1); + if (raster.firstRow(minY) > raster.lastRow(maxY)) + return; + const PixelPipeline pipe = MakePixelPipeline(state, raster); + const TextureSampler sampler = state.prim.tme ? MakeTextureSampler(state, *raster.cache, raster.clut) : TextureSampler{}; float denom = (fy1 - fy2) * (fx0 - fx2) + (fx2 - fx1) * (fy0 - fy2); if (std::fabs(denom) < 0.001f) @@ -2499,10 +2625,8 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte const float invAbsDenom = 1.0f / std::fabs(denom); constexpr float kEdgeEpsilon = 1.0e-4f; - for (int y = minY; y <= maxY; ++y) + for (int y = raster.firstRow(minY); y <= raster.lastRow(maxY); ++y) { - if (!raster.ownsRow(y)) - continue; float py = static_cast(y) + 0.5f; for (int x = minX; x <= maxX; ++x) { @@ -2591,7 +2715,7 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch, const RasterContext & const GSVertex &v0 = batch.vertices[0]; const GSVertex &v1 = batch.vertices[1]; const auto &ctx = state.context; - const PixelPipeline pipe = MakePixelPipeline(state); + const PixelPipeline pipe = MakePixelPipeline(state, raster); int ofx = ctx.xyoffset.ofx >> 4; int ofy = ctx.xyoffset.ofy >> 4; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 55c5bdea2e..d3c9c986ec 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -2960,6 +2960,78 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(4u, true), "a GS thread must produce byte-identical VRAM to drawing in place"); }); + tc.Run("GS row ranges balanced by pixel count draw the same as one thread", [](TestCase &t) + { + // Work piled into a few rows (and one primitive spanning all of them) moves the cuts between + // workers' row ranges off the even split; every split must still give the serial result. + auto render = [](uint32_t threads) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0x5EEDu; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + for (int i = 0; i < 400; ++i) + { + GSPrimitiveBatch batch{}; + GSDrawState &state = batch.state; + const bool sprite = (next() % 2u) == 0u; + state.prim.type = sprite ? GS_PRIM_SPRITE : GS_PRIM_TRIANGLE; + state.prim.tme = (next() % 2u) != 0u; + state.prim.abe = true; + state.prim.iip = true; + state.prim.fst = true; + auto &ctx = state.context; + ctx.frame.fbp = 0u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT32; + ctx.zbuf.zbp = 150u; + ctx.zbuf.psm = GS_PSM_Z24; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.test = (2ull << 17u) | (1ull << 16u); + ctx.alpha = 0x80ull << 32u | 0x44u; + ctx.tex0.psm = GS_PSM_CT32; + ctx.tex0.tbp0 = 10000u; + ctx.tex0.tbw = 2u; + ctx.tex0.tcc = 1u; + state.textureWidth = 128u; + state.textureHeight = 128u; + batch.vertexCount = sprite ? 2u : 3u; + const bool tall = i % 50 == 0; + const float band = static_cast(i % 3 == 0 ? 96u : 300u); + for (int v = 0; v < 3; ++v) + { + GSVertex &vertex = batch.vertices[static_cast(v)]; + vertex.x = static_cast(next() % 640u); + vertex.y = tall ? static_cast(next() % 448u) : band + static_cast(next() % 13u); + vertex.z = static_cast(next() % 0xFFFFFFu); + vertex.r = static_cast(next()); + vertex.g = static_cast(next()); + vertex.b = static_cast(next()); + vertex.a = static_cast(next()); + vertex.u = static_cast(next() % (128u * 16u)); + vertex.v = static_cast(next() % (128u * 16u)); + } + gs.Submit(batch); + } + std::vector out; + gs.SnapshotVram(out); + return out; + }; + + const std::vector serial = render(1u); + for (uint32_t threads = 2u; threads <= 5u; ++threads) + t.IsTrue(serial == render(threads), "every row split must produce byte-identical VRAM to one thread"); + }); + tc.Run("GS direct target addressing matches the generic pixel accessors", [](TestCase &t) { GSMem::InitLookupTables(); @@ -3149,6 +3221,201 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(4u, true), "a GS thread must leave VRAM and the texture buffer as serially"); }); + tc.Run("GS runs of self-sampling RGB24 primitives batched together draw the same in parallel", [](TestCase &t) + { + // Runs of overlapping T8H/T4HL/T4HH sprites and triangles that sample the top byte of the RGB24 + // frame they draw into, mixed with ordinary textured primitives and palette loads from VRAM, and + // ended by an RGB24 read of the sampled texels that shows which page the texture buffer holds. + auto render = [](uint32_t threads, bool async) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0x13579BDu; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + gs.SetAsync(async); + auto loadClut = [&](uint32_t psm) + { + GSTex0Reg clutTex{}; + clutTex.psm = static_cast(psm); + clutTex.cpsm = GS_PSM_CT32; + clutTex.cbp = 13000u + (next() % 4u) * 64u; + clutTex.cld = 1u; + gs.LoadClut(clutTex, GSTexClutReg{}); + }; + loadClut(GS_PSM_T8H); + + auto baseState = [&](GSPrimitiveBatch &batch) + { + GSDrawState &state = batch.state; + auto &ctx = state.context; + ctx.frame.fbp = 0u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT24; + ctx.zbuf.zbp = 160u; + ctx.zbuf.psm = GS_PSM_Z24; + ctx.zbuf.zmask = (next() % 3u) == 0u; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.test = (static_cast(1u + next() % 3u) << 17u) | (1ull << 16u); + ctx.alpha = 0x80ull << 32u | 0x44u; + ctx.tex0.tbw = 10u; + ctx.tex0.tcc = 1u; + ctx.tex0.cpsm = GS_PSM_CT32; + state.textureWidth = 1024u; + state.textureHeight = 512u; + state.prim.tme = true; + state.prim.fst = true; + }; + auto setVertices = [&](GSPrimitiveBatch &batch, bool sprite, uint32_t x0, uint32_t y0, uint32_t w, uint32_t h, + uint32_t u0, uint32_t v0) + { + batch.state.prim.type = sprite ? GS_PRIM_SPRITE : GS_PRIM_TRIANGLE; + batch.vertexCount = sprite ? 2u : 3u; + const uint32_t xs[3] = {x0, x0 + w, sprite ? 0u : x0}; + const uint32_t ys[3] = {y0, y0 + h, sprite ? 0u : y0 + h}; + const uint32_t us[3] = {u0, u0 + w, u0}; + const uint32_t vs[3] = {v0, v0 + h, v0 + h}; + for (int v = 0; v < 3; ++v) + { + GSVertex &vertex = batch.vertices[static_cast(v)]; + vertex.x = static_cast(sprite || v != 1 ? xs[v] : x0 + w); + vertex.y = static_cast(sprite || v != 1 ? ys[v] : y0); + vertex.z = static_cast(next() % 0xFFFFFFu); + vertex.r = static_cast(next()); + vertex.g = static_cast(next()); + vertex.b = static_cast(next()); + vertex.a = static_cast(next()); + vertex.u = static_cast(us[v] * 16u); + vertex.v = static_cast(vs[v] * 16u); + } + }; + + for (int round = 0; round < 48; ++round) + { + const uint32_t blockOffset = (next() % 3u) * 32u * 10u; + const uint32_t topFormats[3] = {GS_PSM_T8H, GS_PSM_T4HL, GS_PSM_T4HH}; + const uint32_t run = 1u + next() % 8u; + // The texel each run samples last, which is on the page the texture buffer ends on. + uint32_t lastU = 0u, lastV = 0u; + auto drawAlias = [&](uint32_t texturePsm, bool sprite, uint32_t x0, uint32_t y0, uint32_t w, uint32_t h, + uint32_t u0, uint32_t v0, bool linear) + { + GSPrimitiveBatch alias{}; + baseState(alias); + alias.state.prim.abe = (next() % 2u) != 0u; + alias.state.prim.iip = (next() % 2u) != 0u; + alias.state.linearFilter = linear; + alias.state.context.tex0.psm = static_cast(texturePsm); + alias.state.context.tex0.tbp0 = blockOffset; + alias.state.context.tex0.tfx = static_cast(next() % 2u); + setVertices(alias, sprite, x0, y0, w, h, u0, v0); + gs.Submit(alias); + // Sprites end bottom right; these triangles end on their bottom-left corner. + lastU = sprite ? u0 + w - 1u : u0; + lastV = v0 + h - 1u; + }; + uint32_t texturePsm = GS_PSM_T8H; + for (uint32_t k = 0; k < run; ++k) + { + texturePsm = topFormats[next() % 3u]; + if ((next() % 3u) == 0u) + loadClut(texturePsm); + if ((next() % 4u) == 0u) + { + // An ordinary primitive sampling a texture far from the frame joins the run. + GSPrimitiveBatch plain{}; + baseState(plain); + plain.state.context.tex0.psm = GS_PSM_CT32; + plain.state.context.tex0.tbp0 = 11000u; + plain.state.context.tex0.tbw = 2u; + plain.state.textureWidth = 128u; + plain.state.textureHeight = 128u; + plain.state.prim.abe = true; + setVertices(plain, (next() % 2u) != 0u, next() % 500u, next() % 380u, 64u + next() % 64u, + 64u + next() % 32u, next() % 60u, next() % 60u); + gs.Submit(plain); + } + const uint32_t x0 = next() % 500u; + const uint32_t y0 = next() % 380u; + const uint32_t w = 64u + next() % 120u; + const uint32_t h = 64u + next() % 60u; + const bool overlay = (next() % 3u) != 0u; + drawAlias(texturePsm, (next() % 2u) != 0u, x0, y0, w, h, overlay ? x0 : next() % 500u, overlay ? y0 : next() % 380u, + (next() % 3u) == 0u); + } + const uint32_t tileX = lastU & ~63u; + const uint32_t tileY = lastV & ~31u; + const uint32_t ending = next() % 3u; + if (ending == 0u) + { + // A palette load, then a sprite that samples only the page the run ended on: the load + // is the cache's last switch away from that page. + loadClut(texturePsm); + drawAlias(texturePsm, true, tileX + 8u, tileY + 4u, 40u, 20u, tileX + 8u, tileY + 4u, false); + } + else if (ending == 1u && tileX >= 64u) + { + // A filtered sprite across the left edge of that page, sampling one texel to the left of + // each pixel: the pixel on the edge reads the page to the left, then this one, and draws + // into this one, so the last switch happens inside a pixel. + drawAlias(texturePsm, true, tileX - 8u, tileY + 4u, 40u, 20u, tileX - 9u, tileY + 4u, true); + lastU = tileX; + } + + // Copy, through RGB24, the whole page holding the texel the run sampled last (where the + // texture buffer ends) and one at random into a buffer of their own, one row of pages per round. + for (int k = 0; k < 2; ++k) + { + GSPrimitiveBatch probe{}; + baseState(probe); + probe.state.prim.type = GS_PRIM_SPRITE; + probe.state.context.frame.fbp = 415u; + probe.state.context.frame.fbw = 2u; + probe.state.context.tex0.psm = GS_PSM_CT24; + probe.state.context.tex0.tbp0 = blockOffset; + probe.state.context.tex0.tfx = 1u; + probe.state.context.zbuf.zmask = true; + probe.state.context.test = 0x30000ull; + probe.state.context.scissor = {0u, 127u, 0u, 1535u}; + probe.state.prim.abe = false; + probe.vertexCount = 2u; + const uint32_t pu = (k == 0 ? lastU : next() % 640u) & ~63u; + const uint32_t pv = (k == 0 ? lastV : next() % 448u) & ~31u; + const uint32_t px = static_cast(k) * 64u; + const uint32_t py = static_cast(round) * 32u; + probe.vertices[0].x = static_cast(px); + probe.vertices[0].y = static_cast(py); + probe.vertices[1].x = static_cast(px + 64u); + probe.vertices[1].y = static_cast(py + 32u); + probe.vertices[1].r = probe.vertices[1].g = probe.vertices[1].b = probe.vertices[1].a = 0x80u; + probe.vertices[0].u = static_cast(pu * 16u); + probe.vertices[0].v = static_cast(pv * 16u); + probe.vertices[1].u = static_cast((pu + 64u) * 16u); + probe.vertices[1].v = static_cast((pv + 32u) * 16u); + gs.Submit(probe); + } + if (round % 13 == 12) + gs.TextureFlush(); + } + std::vector out; + gs.SnapshotVram(out); + return out; + }; + + const std::vector serial = render(1u, false); + t.IsTrue(serial == render(4u, false), "batched self-sampling draws must leave VRAM and the texture buffer as serially"); + t.IsTrue(serial == render(3u, false), "any number of row ranges must leave VRAM and the texture buffer as serially"); + t.IsTrue(serial == render(4u, true), "a GS thread must leave VRAM and the texture buffer as serially"); + }); + tc.Run("GS texture page buffer also hides draws into the cached page until TEXFLUSH", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From d282cd728283bc7b32c13df766efcf7a1aa9b7aa Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:49:23 -0300 Subject: [PATCH 49/91] Answer IOP sceCdReadClock so memory card saves get real timestamps --- ps2xIOP/src/emulator/imports/iop_cdvd.cpp | 31 +++++++++++++++++ ps2xIOP/tests/iop_import_tests.cpp | 42 ++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/ps2xIOP/src/emulator/imports/iop_cdvd.cpp b/ps2xIOP/src/emulator/imports/iop_cdvd.cpp index ad58fb4e43..8589e09c78 100644 --- a/ps2xIOP/src/emulator/imports/iop_cdvd.cpp +++ b/ps2xIOP/src/emulator/imports/iop_cdvd.cpp @@ -7,6 +7,7 @@ #include "ps2x/iop/ps2_path.h" #include +#include #include #include #include @@ -280,6 +281,10 @@ namespace ps2x::iop::detail cpu.gpr[2] = kCdvdReadyComplete; return true; + case 24: // sceCdReadClock + cpu.gpr[2] = readClock(a0) ? 1u : 0u; + return true; + case 28: // sceCdStatus cpu.gpr[2] = kCdvdStatusPause; return true; @@ -356,6 +361,32 @@ namespace ps2x::iop::detail } private: + // sceCdCLOCK: status, then BCD second, minute, hour, a pad byte, day, month and two-digit year. Memory + // card drivers stamp saved files with it, so an unanswered call leaves garbage dates on the card. + bool readClock(uint32_t address) + { + const std::time_t now = std::time(nullptr); + std::tm local{}; +#ifdef _WIN32 + localtime_s(&local, &now); +#else + localtime_r(&now, &local); +#endif + const auto bcd = [](int value) + { return static_cast(((value / 10) << 4) | (value % 10)); }; + const uint8_t clock[8] = { + 0u, + bcd(local.tm_sec), + bcd(local.tm_min), + bcd(local.tm_hour), + 0u, + bcd(local.tm_mday), + bcd(local.tm_mon + 1), + bcd((local.tm_year + 1900) % 100), + }; + return address != 0u && memory.writeRam(address, clock, sizeof(clock)); + } + int ensureInterruptEventFlag() { if (interruptEventFlagId == 0) diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index 28010fee46..a8e42c529a 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -165,6 +166,45 @@ namespace expect(static_cast(cpu.gpr[2]) == expected, "PollEventFlag returned an unexpected result"); } + // Memory card drivers stamp saved files with sceCdReadClock; it must report the host time in BCD. + bool testCdvdReadClock() + { + NullHost host; + IopMemory memory; + IopKernel kernel(memory); + kernel.reset(); + IopCdvd cdvd(host, memory, kernel); + cdvd.reset(); + + constexpr uint32_t clockAddress = 0x2000u; + const uint8_t garbage[8] = {0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5}; + (void)memory.writeRam(clockAddress, garbage, sizeof(garbage)); + IopCpuState cpu{}; + cpu.gpr[4] = clockAddress; + const std::time_t before = std::time(nullptr); + if (!expect(cdvd.dispatchImport(24u, cpu), "cdvdman:24 (sceCdReadClock) was not handled") || + !expect(cpu.gpr[2] == 1u, "sceCdReadClock should succeed")) + return false; + + uint8_t clock[8] = {}; + (void)memory.readRam(clockAddress, clock, sizeof(clock)); + const auto fromBcd = [](uint8_t value) { return (value >> 4) * 10 + (value & 0xF); }; + const auto isBcd = [](uint8_t value) { return (value >> 4) <= 9 && (value & 0xF) <= 9; }; + for (int i = 1; i < 8; ++i) + { + if (!expect(isBcd(clock[i]), "sceCdReadClock fields must be BCD")) + return false; + } + std::tm local{}; + localtime_r(&before, &local); + return expect(clock[0] == 0u, "the clock status byte should report success") && + expect(fromBcd(clock[1]) < 60 && fromBcd(clock[2]) < 60 && fromBcd(clock[3]) < 24, + "time of day should be in range") && + expect(fromBcd(clock[6]) >= 1 && fromBcd(clock[6]) <= 12 && fromBcd(clock[5]) >= 1 && fromBcd(clock[5]) <= 31, + "date should be in range") && + expect(fromBcd(clock[7]) == (local.tm_year + 1900) % 100, "the year should be the host's"); + } + bool testCdvdSpecialControl() { NullHost host; @@ -730,7 +770,7 @@ namespace int main() { - if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdSearchFile() || + if (!testLoadcoreRebootLibraryMode() || !testCdvdSpecialControl() || !testCdvdReadClock() || !testCdvdSearchFile() || !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || !testSliceEndDoesNotHandTheCpuToAnEqualPriorityThread() || !testSliceEndInsideCpuSuspendIntrResumesSameThread() || !testSpu2AutoDmaCompletesAtPlaybackRate() || !testDmacmanDrivesSio2Dma() || !testSio2DualShock2() || !testSifGetOtherData() || !testUnalignedLoadPair() || From e5f35ca391494cd6e26426c6b9babc27f4badc0c Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:57:42 -0300 Subject: [PATCH 50/91] Fetch the four texels of a bilinear GS sample with one page lookup and blend their channels together --- .../include/runtime/gs/gs_cpu_backend.h | 2 + .../runtime/gs/gs_texture_page_cache.h | 11 +++ .../include/runtime/gs/ps2_gs_memory.h | 3 + ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 53 +++++++++- ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp | 99 ++++++++++++++++++- ps2xTest/src/ps2_gs_tests.cpp | 53 ++++++++++ 6 files changed, 215 insertions(+), 6 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index f4c2514c92..29b779569d 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -207,6 +207,7 @@ class GSCpuBackend final : public GSRasterBackend const uint32_t *clut = nullptr; GSMem::TexturePageCache *cache = nullptr; GSMem::TextureFetchFn fetch = nullptr; + GSMem::TextureQuadFetchFn fetchQuad = nullptr; int texW = 1; int texH = 1; uint8_t wrapU = 0; @@ -222,6 +223,7 @@ class GSCpuBackend final : public GSRasterBackend uint32_t SampleTexture(const TextureSampler &sampler, float s, float t, float q, uint16_t u, uint16_t v); static TextureAxis PrepareTextureAxis(float coordinate, int size, uint8_t mode, uint16_t regionMin, uint16_t regionMax, bool linear); uint32_t SampleTexel(const TextureSampler &sampler, int u, int v); + static uint32_t ConvertTexel(const TextureSampler &sampler, uint32_t raw); uint32_t SampleTextureAxes(const TextureSampler &sampler, const TextureAxis &u, const TextureAxis &v); uint32_t LookupCLUT(const GSDrawState &state, uint8_t index, uint8_t cpsm, uint8_t csm, uint8_t csa, uint8_t sourcePsm); diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h index b50ce02a51..33944b2926 100644 --- a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -160,6 +160,14 @@ namespace GSMem return m_track; } + // What Resolve does for further reads of the page it just resolved: they hit, and are recorded. + void ResolveAgain(uint32_t count) noexcept + { + if (m_tracking) + for (uint32_t i = 0; i < count; ++i) + NoteRead(m_pageBase); + } + private: void NoteRead(uint32_t page) noexcept { @@ -199,4 +207,7 @@ namespace GSMem }; using TextureFetchFn = uint32_t (*)(TexturePageCache& cache, const uint8_t* data, uint32_t bp, uint32_t bw, uint32_t x, uint32_t y); + // Reads the 2x2 texels of a bilinear sample in the order (u0,v0) (u1,v0) (u0,v1) (u1,v1). + using TextureQuadFetchFn = void (*)(TexturePageCache& cache, const uint8_t* data, uint32_t bp, uint32_t bw, uint32_t u0, uint32_t u1, + uint32_t v0, uint32_t v1, uint32_t* out); } diff --git a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h index 11ad8d03f8..ba535f53f4 100644 --- a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h +++ b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h @@ -546,6 +546,9 @@ namespace GSMem // ReadTexture with the format resolved once, for samplers that fetch many texels of one texture. TextureFetchFn TextureFetcher(u32 psm); + // TextureFetcher for the 2x2 texels of a bilinear sample. + TextureQuadFetchFn TextureQuadFetcher(u32 psm); + // Byte offset of a colour or depth pixel, so a read-modify-write addresses it once. // Null for formats that are not drawable targets. using PixelOffsetFn = u32 (*)(u32 bp, u32 bw, u32 x, u32 y); diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 378145d85c..fec1f69418 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -9,6 +9,9 @@ #include "ps2_log.h" #include #include +#if defined(__ARM_NEON) +#include +#endif #include #include #include @@ -375,6 +378,36 @@ namespace const float bottom = static_cast(c01) + (static_cast(c11) - static_cast(c01)) * fx; return clampU8(static_cast(std::lround(top + (bottom - top) * fy))); } + + // lerpChannel for all four channels at once. Only for weights in [0, 1], where every intermediate stays + // within the channels' range and the vector rounding (fused multiply-adds, then round half away from + // zero) gives what the scalar code computes. + uint32_t lerpTexels(uint32_t c00, uint32_t c10, uint32_t c01, uint32_t c11, float fx, float fy) + { +#if defined(__ARM_NEON) + auto channels = [](uint32_t texel) + { + const uint8x8_t bytes = vreinterpret_u8_u32(vdup_n_u32(texel)); + return vcvtq_f32_u32(vmovl_u16(vget_low_u16(vmovl_u8(bytes)))); + }; + const float32x4_t f00 = channels(c00); + const float32x4_t f10 = channels(c10); + const float32x4_t f01 = channels(c01); + const float32x4_t f11 = channels(c11); + const float32x4_t top = vfmaq_n_f32(f00, vsubq_f32(f10, f00), fx); + const float32x4_t bottom = vfmaq_n_f32(f01, vsubq_f32(f11, f01), fx); + const uint32x4_t rounded = vreinterpretq_u32_s32(vcvtaq_s32_f32(vfmaq_n_f32(top, vsubq_f32(bottom, top), fy))); + const uint8x8_t packed = vmovn_u16(vcombine_u16(vmovn_u32(rounded), vdup_n_u16(0))); + return vget_lane_u32(vreinterpret_u32_u8(packed), 0); +#else + uint32_t out = 0u; + for (uint32_t shift = 0; shift < 32u; shift += 8u) + out |= static_cast(lerpChannel(static_cast(c00 >> shift), static_cast(c10 >> shift), + static_cast(c01 >> shift), static_cast(c11 >> shift), fx, fy)) + << shift; + return out; +#endif + } } namespace @@ -2302,6 +2335,7 @@ GSCpuBackend::TextureSampler GSCpuBackend::MakeTextureSampler(const GSDrawState sampler.linear = state.linearFilter; sampler.cache = &cache; sampler.fetch = GSMem::TextureFetcher(tex.psm); + sampler.fetchQuad = GSMem::TextureQuadFetcher(tex.psm); if (clut && (isFourBitIndexedPsm(tex.psm) || isEightBitIndexedPsm(tex.psm))) { @@ -2379,7 +2413,11 @@ GSCpuBackend::TextureAxis GSCpuBackend::PrepareTextureAxis(float coordinate, int __attribute__((always_inline)) inline uint32_t GSCpuBackend::SampleTexel(const TextureSampler &sampler, int u, int v) { - const u32 out = sampler.fetch(*sampler.cache, m_vram, sampler.tbp0, sampler.tbw, static_cast(u), static_cast(v)); + return ConvertTexel(sampler, sampler.fetch(*sampler.cache, m_vram, sampler.tbp0, sampler.tbw, static_cast(u), static_cast(v))); +} + +__attribute__((always_inline)) inline uint32_t GSCpuBackend::ConvertTexel(const TextureSampler &sampler, uint32_t out) +{ if (sampler.clut) return sampler.clut[static_cast(out)]; @@ -2409,10 +2447,15 @@ uint32_t GSCpuBackend::SampleTextureAxes(const TextureSampler &sampler, const Te const float fx = u.frac; const float fy = v.frac; - const uint32_t c00 = SampleTexel(sampler, u.c0, v.c0); - const uint32_t c10 = SampleTexel(sampler, u.c1, v.c0); - const uint32_t c01 = SampleTexel(sampler, u.c0, v.c1); - const uint32_t c11 = SampleTexel(sampler, u.c1, v.c1); + uint32_t quad[4]; + sampler.fetchQuad(*sampler.cache, m_vram, sampler.tbp0, sampler.tbw, static_cast(u.c0), static_cast(u.c1), + static_cast(v.c0), static_cast(v.c1), quad); + const uint32_t c00 = ConvertTexel(sampler, quad[0]); + const uint32_t c10 = ConvertTexel(sampler, quad[1]); + const uint32_t c01 = ConvertTexel(sampler, quad[2]); + const uint32_t c11 = ConvertTexel(sampler, quad[3]); + if (fx >= 0.0f && fx <= 1.0f && fy >= 0.0f && fy <= 1.0f) + return lerpTexels(c00, c10, c01, c11, fx, fy); const uint8_t r = lerpChannel(static_cast(c00 & 0xFFu), static_cast(c10 & 0xFFu), diff --git a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp index 4743c543c5..3c4e00cce1 100644 --- a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp +++ b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp @@ -258,7 +258,7 @@ namespace GSMem namespace { template - u32 FetchTexel(TexturePageCache& cache, const u8* data, u32 bp, u32 bw, u32 x, u32 y) + __attribute__((flatten)) u32 FetchTexel(TexturePageCache& cache, const u8* data, u32 bp, u32 bw, u32 x, u32 y) { return PixelStorageTraits::Read(table, data, bp, bw, x, y, &cache); } @@ -277,6 +277,68 @@ namespace GSMem { return 0u; } + + template + __attribute__((flatten)) void FetchQuad(TexturePageCache& cache, const u8* data, u32 bp, u32 bw, u32 u0, u32 u1, u32 v0, u32 v1, u32* out) + { + using Traits = PixelStorageTraits; + constexpr Extent2D extent = Traits::PageExtent(); + const u32 x = u0 % extent.x; + const u32 y = v0 % extent.y; + if (u1 == u0 + 1u && v1 == v0 + 1u && x + 1u < extent.x && y + 1u < extent.y) + { + // Neighbours within one page tile share the page lookup; a base pointer that is not page + // aligned can still spread them over two 8 KB pages, so check before resolving once. + const auto& block = table[bp % Traits::BlocksPerPage()]; + const u32 page = static_cast(Traits::PageId(bp, bw, u0, v0)) * static_cast(Traits::PixelsPerPage()); + const u32 addresses[4] = {page + block[y][x], page + block[y][x + 1u], page + block[y + 1u][x], page + block[y + 1u][x + 1u]}; + u32 bits[4]; + u32 bytes[4]; + for (int i = 0; i < 4; ++i) + { + bits[i] = static_cast(addresses[i] * UnpackedBitWidth(psm) + Traits::BitOffset()); + bytes[i] = (bits[i] / 8) & (MEMORY_SIZE - sizeof(typename Traits::PackedT)); + } + const u32 page0 = bytes[0] / TexturePageCache::kPageSize; + if (bytes[1] / TexturePageCache::kPageSize == page0 && bytes[2] / TexturePageCache::kPageSize == page0 && + bytes[3] / TexturePageCache::kPageSize == page0) + { + // The other three reads would hit the page without changing anything but the recorded reads. + const u8* pageBytes = cache.Resolve(data, bytes[0]) - (bytes[0] % TexturePageCache::kPageSize); + cache.ResolveAgain(3u); + for (int i = 0; i < 4; ++i) + { + typename Traits::PackedT v; + std::memcpy(&v, pageBytes + (bytes[i] % TexturePageCache::kPageSize), sizeof(v)); + switch (psm) + { + case C24: + case Z24: + out[i] = v & 0x00FFFFFFu; + break; + case P4: + case P4HL: + case P4HH: + out[i] = (v >> (bits[i] % 8)) & 0x0Fu; + break; + default: + out[i] = v; + break; + } + } + return; + } + } + out[0] = Traits::Read(table, data, bp, bw, u0, v0, &cache); + out[1] = Traits::Read(table, data, bp, bw, u1, v0, &cache); + out[2] = Traits::Read(table, data, bp, bw, u0, v1, &cache); + out[3] = Traits::Read(table, data, bp, bw, u1, v1, &cache); + } + + void FetchNoQuad(TexturePageCache&, const u8*, u32, u32, u32, u32, u32, u32, u32* out) + { + out[0] = out[1] = out[2] = out[3] = 0u; + } } TextureFetchFn TextureFetcher(u32 psm) @@ -314,6 +376,41 @@ namespace GSMem } } + TextureQuadFetchFn TextureQuadFetcher(u32 psm) + { + switch (static_cast(psm & 0x3Fu)) + { + case C32: + return FetchQuad; + case C24: + return FetchQuad; + case C16: + return FetchQuad; + case C16S: + return FetchQuad; + case P8: + return FetchQuad; + case P4: + return FetchQuad; + case P8H: + return FetchQuad; + case P4HL: + return FetchQuad; + case P4HH: + return FetchQuad; + case Z32: + return FetchQuad; + case Z24: + return FetchQuad; + case Z16: + return FetchQuad; + case Z16S: + return FetchQuad; + default: + return FetchNoQuad; + } + } + PixelOffsetFn TargetPixelOffset(u32 psm) { switch (static_cast(psm & 0x3Fu)) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index d3c9c986ec..917a85ec1c 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -3032,6 +3032,59 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(threads), "every row split must produce byte-identical VRAM to one thread"); }); + tc.Run("GS bilinear quad fetch reads and records what four texel fetches do", [](TestCase &t) + { + GSMem::InitLookupTables(); + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0xBEEFu; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + const uint32_t formats[] = {GS_PSM_CT32, GS_PSM_CT24, GS_PSM_CT16, GS_PSM_CT16S, GS_PSM_T8, GS_PSM_T4, GS_PSM_T8H, + GS_PSM_T4HL, GS_PSM_T4HH, GS_PSM_Z32, GS_PSM_Z24, GS_PSM_Z16, GS_PSM_Z16S}; + bool same = true; + for (uint32_t psm : formats) + { + const GSMem::TextureFetchFn fetch = GSMem::TextureFetcher(psm); + const GSMem::TextureQuadFetchFn fetchQuad = GSMem::TextureQuadFetcher(psm); + GSMem::TexturePageCache single; + GSMem::TexturePageCache quad; + const bool tracking = psm % 2u == 0u; + single.ResetForBand(tracking); + quad.ResetForBand(tracking); + for (int i = 0; i < 2000; ++i) + { + const uint32_t bp = next() % 16384u; + const uint32_t bw = 1u + next() % 10u; + // Mostly neighbours, often across a page edge, sometimes wrapped apart. + const uint32_t u0 = (next() % 4u == 0u) ? (next() % 8u) * 64u + 63u : next() % 1024u; + const uint32_t v0 = (next() % 4u == 0u) ? (next() % 8u) * 32u + 31u : next() % 1024u; + const uint32_t u1 = (next() % 8u == 0u) ? next() % 1024u : u0 + 1u; + const uint32_t v1 = (next() % 8u == 0u) ? next() % 1024u : v0 + 1u; + single.BeginPixel(static_cast(i), 0, i); + quad.BeginPixel(static_cast(i), 0, i); + uint32_t expected[4] = {fetch(single, vram.data(), bp, bw, u0, v0), fetch(single, vram.data(), bp, bw, u1, v0), + fetch(single, vram.data(), bp, bw, u0, v1), fetch(single, vram.data(), bp, bw, u1, v1)}; + uint32_t actual[4] = {}; + fetchQuad(quad, vram.data(), bp, bw, u0, u1, v0, v1, actual); + uint32_t singlePage = 0u, quadPage = 0u; + single.CachedPage(singlePage); + quad.CachedPage(quadPage); + const auto &a = single.Track(); + const auto &b = quad.Track(); + if (std::memcmp(expected, actual, sizeof(expected)) != 0 || singlePage != quadPage || a.lastPage != b.lastPage || + a.last.texel != b.last.texel || a.last.valid != b.last.valid || a.other.texel != b.other.texel || + a.other.x != b.other.x || a.switchedInsidePixel != b.switchedInsidePixel) + same = false; + } + } + t.IsTrue(same, "a quad fetch must return the texels, cached page and recorded reads of four single fetches"); + }); + tc.Run("GS direct target addressing matches the generic pixel accessors", [](TestCase &t) { GSMem::InitLookupTables(); From cd663fef2c607d3fa91213f826f54d645cb89725 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:36:12 -0300 Subject: [PATCH 51/91] Present the frame the GS thread captured at the latest vblank instead of a buffer it is still drawing --- .../include/runtime/gs/gs_cpu_backend.h | 19 ++++- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 78 ++++++++++++++++++- ps2xRuntime/src/lib/ps2_runtime.cpp | 1 + ps2xTest/src/ps2_gs_tests.cpp | 63 +++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 29b779569d..514439f752 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -13,6 +13,8 @@ #include #include +struct GSRegisters; + class GSCpuBackend final : public GSRasterBackend { public: @@ -25,9 +27,14 @@ class GSCpuBackend final : public GSRasterBackend // Runs commands that only change local memory on a GS thread, in submission order, while the caller // continues; anything that reads local memory or transfer state first waits for the queue to drain, - // except presentation, which shows what has been drawn so far. + // except presentation, which shows the frame captured at the latest vblank the GS thread has reached. void SetAsync(bool enabled); + // The guest's privileged GS registers. While the GS thread lags behind the EE, DISPFB already names a + // buffer it is still clearing or drawing; local memory and the display registers are captured in + // stream order at each vblank so presentation shows a frame the guest had finished. + void SetDisplayRegisters(const GSRegisters *regs); + void Initialize(uint8_t *vram, uint32_t vramSize) override; void Reset() override; @@ -61,6 +68,7 @@ class GSCpuBackend final : public GSRasterBackend TextureFlush, ClearFramebuffer, WriteVram, + CaptureFrame, }; Kind kind = Kind::Submit; GSPrimitiveBatch batch{}; @@ -69,6 +77,7 @@ class GSCpuBackend final : public GSRasterBackend GSTransferCommand transfer{}; GSContext context{}; uint32_t args[6]{}; + GSPresentationRequest display{}; std::vector data; }; @@ -91,6 +100,7 @@ class GSCpuBackend final : public GSRasterBackend uint32_t ReadVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const; void WriteVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value); void SnapshotVramNow(std::vector &out) const; + void CaptureFrameNow(const GSPresentationRequest &display); GSTransferSnapshot GetTransferSnapshotNow() const; void ResetUnlocked(); @@ -302,6 +312,13 @@ class GSCpuBackend final : public GSRasterBackend uint64_t m_queueSubmitted = 0; uint64_t m_queueCompleted = 0; bool m_queueStop = false; + const GSRegisters *m_displayRegs = nullptr; + uint64_t m_lastQueuedVSync = 0; + // Written by the GS thread at each vblank in the stream, read by presentation. + mutable std::mutex m_captureMutex; + std::vector m_capturedVram; + GSPresentationRequest m_capturedDisplay{}; + std::vector m_captureScratch; uint32_t m_rasterParticipants = 0; // Rebuilds the texture cache's copied page after a parallel draw into the page it holds. std::vector m_pageRebuildScratch; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index fec1f69418..50e7b6dbc2 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -6,6 +6,7 @@ #include "runtime/gs/ps2_gs_psmt4.h" #include "runtime/gs/ps2_gs_psmt8.h" #include "runtime/gs/ps2_gs_memory.h" +#include "runtime/ps2_memory.h" #include "ps2_log.h" #include #include @@ -1251,11 +1252,39 @@ void GSCpuBackend::SetAsync(bool enabled) m_async = false; } +void GSCpuBackend::SetDisplayRegisters(const GSRegisters *regs) +{ + std::lock_guard lock(m_queueMutex); + m_displayRegs = regs; + m_lastQueuedVSync = regs ? regs->vsyncTick.load(std::memory_order_acquire) : 0u; +} + void GSCpuBackend::Enqueue(Command &&command) { { std::unique_lock lock(m_queueMutex); m_queueProgress.wait(lock, [this]() { return m_queue.size() < kMaxQueuedCommands; }); + // Nothing was queued since the vblank, so this stream position is the vblank itself. + if (m_displayRegs) + { + const uint64_t tick = m_displayRegs->vsyncTick.load(std::memory_order_acquire); + if (tick != m_lastQueuedVSync) + { + m_lastQueuedVSync = tick; + Command capture{}; + capture.kind = Command::Kind::CaptureFrame; + capture.display.pmode = m_displayRegs->pmode; + capture.display.smode2 = m_displayRegs->smode2; + capture.display.dispfb1 = m_displayRegs->dispfb1; + capture.display.display1 = m_displayRegs->display1; + capture.display.dispfb2 = m_displayRegs->dispfb2; + capture.display.display2 = m_displayRegs->display2; + capture.display.bgcolor = m_displayRegs->bgcolor; + capture.display.vsyncTick = tick; + m_queue.push_back(std::move(capture)); + ++m_queueSubmitted; + } + } m_queue.push_back(std::move(command)); ++m_queueSubmitted; } @@ -1337,6 +1366,9 @@ void GSCpuBackend::Execute(Command &command) case Command::Kind::WriteVram: WriteVramNow(command.args[0], command.args[1], command.args[2], command.args[3], command.args[4], command.args[5]); break; + case Command::Kind::CaptureFrame: + CaptureFrameNow(command.display); + break; } } @@ -1720,6 +1752,21 @@ void GSCpuBackend::SnapshotVramNow(std::vector &out) const std::memcpy(out.data(), m_vram, m_vramSize); } +void GSCpuBackend::CaptureFrameNow(const GSPresentationRequest &display) +{ + { + std::lock_guard lock(m_mutex); + FlushPendingUnlocked(); + if (!m_vram || m_vramSize == 0u) + return; + m_captureScratch.resize(m_vramSize); + std::memcpy(m_captureScratch.data(), m_vram, m_vramSize); + } + std::lock_guard lock(m_captureMutex); + m_capturedVram.swap(m_captureScratch); + m_capturedDisplay = display; +} + GSTransferSnapshot GSCpuBackend::GetTransferSnapshotNow() const { std::lock_guard lock(m_mutex); @@ -3241,13 +3288,40 @@ PresentationFrame GSCpuBackend::Present(const GSPresentationRequest &request) // Snapshot local memory under the backend lock, then perform the expensive // display conversion without holding the producer-side raster lock. thread_local std::vector snapshot; - SnapshotVramNow(snapshot); + GSPresentationRequest shown = request; + bool behind = false; + if (m_async) + { + std::lock_guard lock(m_queueMutex); + behind = m_queueCompleted < m_queueSubmitted; + } + bool captured = false; + if (behind) + { + // Live memory would pair the guest's current DISPFB with a buffer still being drawn. + std::lock_guard lock(m_captureMutex); + if (!m_capturedVram.empty()) + { + snapshot = m_capturedVram; + shown.pmode = m_capturedDisplay.pmode; + shown.smode2 = m_capturedDisplay.smode2; + shown.dispfb1 = m_capturedDisplay.dispfb1; + shown.display1 = m_capturedDisplay.display1; + shown.dispfb2 = m_capturedDisplay.dispfb2; + shown.display2 = m_capturedDisplay.display2; + shown.bgcolor = m_capturedDisplay.bgcolor; + shown.vsyncTick = m_capturedDisplay.vsyncTick; + captured = true; + } + } + if (!captured) + SnapshotVramNow(snapshot); if (snapshot.empty()) return {}; thread_local GSCpuBackend snapshotBackend; snapshotBackend.Initialize(snapshot.data(), static_cast(snapshot.size())); - return snapshotBackend.PresentFromLocalMemory(request); + return snapshotBackend.PresentFromLocalMemory(shown); } PresentationFrame GSCpuBackend::PresentFromLocalMemory(const GSPresentationRequest &request) diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 225366fee2..34adffb792 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -2396,6 +2396,7 @@ void PS2Runtime::run() { auto backend = std::make_unique(); backend->SetAsync(true); + backend->SetDisplayRegisters(&m_memory.gs()); m_gs.setRasterBackend(std::move(backend)); } } diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 917a85ec1c..87c186359b 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -3469,6 +3469,69 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(4u, true), "a GS thread must leave VRAM and the texture buffer as serially"); }); + tc.Run("GS thread presentation shows the frame finished at the latest vblank it reached", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GSRegisters regs{}; + regs.pmode = 0x1ull; + regs.dispfb1 = 10ull << 9u; + regs.display1 = (639ull << 32u) | (447ull << 44u); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetAsync(true); + gs.SetDisplayRegisters(®s); + + auto sprite = [&gs](uint32_t fbp, uint8_t r, bool blend) + { + GSPrimitiveBatch batch{}; + batch.state.prim.type = GS_PRIM_SPRITE; + batch.state.prim.abe = blend; + batch.state.context.frame.fbp = fbp; + batch.state.context.frame.fbw = 10u; + batch.state.context.frame.psm = GS_PSM_CT32; + batch.state.context.scissor = {0u, 639u, 0u, 447u}; + batch.state.context.zbuf.zmask = 1u; + batch.state.context.test = (1ull << 17u) | (1ull << 16u); + batch.state.context.alpha = 0x80ull << 32u | 0x44u; + batch.vertexCount = 2u; + batch.vertices[0].x = 0.0f; + batch.vertices[0].y = 0.0f; + batch.vertices[1].x = 640.0f; + batch.vertices[1].y = 448.0f; + for (GSVertex &vertex : batch.vertices) + { + vertex.r = r; + vertex.a = 0x40u; + } + gs.Submit(batch); + }; + + sprite(0u, 0xFFu, false); + regs.vsyncTick.store(1u); + sprite(150u, 0x00u, false); + std::vector settled; + gs.SnapshotVram(settled); + + // After the vblank the guest redraws the displayed buffer while the GS thread has a backlog. + sprite(0u, 0x00u, false); + for (int i = 0; i < 400; ++i) + sprite(150u, static_cast(i), true); + + GSPresentationRequest request{}; + request.pmode = regs.pmode; + request.dispfb1 = regs.dispfb1; + request.display1 = regs.display1; + const PresentationFrame during = gs.Present(request); + t.IsTrue(!during.pixels.empty(), "presentation should produce a frame while the GS thread is busy"); + t.Equals(static_cast(during.pixels[0]), 0xFFu, + "a lagging GS thread must present the buffer as it was at the vblank, not the one being redrawn"); + + gs.SnapshotVram(settled); + const PresentationFrame after = gs.Present(request); + t.Equals(static_cast(after.pixels[0]), 0x00u, "once drained, presentation should show live memory"); + }); + tc.Run("GS texture page buffer also hides draws into the cached page until TEXFLUSH", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From 01c07f7e14c12abc7b66c0940c1d71f6f70948f5 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:36:12 -0300 Subject: [PATCH 52/91] Rasterize with all cores but two by default since 3D scenes are GS fill-rate bound --- ps2xRuntime/include/runtime/gs/gs_cpu_backend.h | 2 +- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 514439f752..9760a2ffb1 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -22,7 +22,7 @@ class GSCpuBackend final : public GSRasterBackend ~GSCpuBackend() override; // Threads (including the caller) that rasterize batched primitives; 0 picks the default - // (PS2X_GS_THREADS, else half the cores). 1 draws every primitive immediately. + // (PS2X_GS_THREADS, else all cores but two). 1 draws every primitive immediately. void SetRasterThreads(uint32_t participants); // Runs commands that only change local memory on a GS thread, in submission order, while the caller diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 50e7b6dbc2..47c4103267 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -721,8 +721,10 @@ namespace { if (const char *value = std::getenv("PS2X_GS_THREADS")) return static_cast(std::clamp(std::atoi(value), 1, 16)); + // The EE game thread and the host window each need a core; 3D scenes are fill-rate bound and + // keep scaling with every raster thread up to that (20 -> 27 vblanks/s on 8 cores going 4 -> 6). const uint32_t cores = std::max(1u, std::thread::hardware_concurrency()); - return std::clamp(cores / 2u, 1u, 6u); + return std::clamp(cores > 2u ? cores - 2u : 1u, 1u, 8u); }(); return participants; } From b7a994d04cef8e854fcf1606f51a4c85957e0a55 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:40:32 -0300 Subject: [PATCH 53/91] Log guest vblanks and host frames per second when PS2X_PERF_LOG is set --- ps2xRuntime/src/lib/ps2_runtime.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 34adffb792..d0815b5b86 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -389,6 +390,30 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint static std::vector s_uploadBuffer(DEFAULT_FB_SIZE, 0u); const uint64_t currentTick = rt->eeScheduler().currentVSyncTick(); + // PS2X_PERF_LOG=1 prints guest vblanks/s against host frames/s every 5 s, so a + // scripted run's log shows where the game drops below full speed. + { + static const bool s_perfLog = std::getenv("PS2X_PERF_LOG") != nullptr; + if (s_perfLog) + { + static auto s_lastReport = std::chrono::steady_clock::now(); + static uint64_t s_lastTick = currentTick; + static uint32_t s_hostFrames = 0u; + ++s_hostFrames; + const auto now = std::chrono::steady_clock::now(); + const double elapsed = std::chrono::duration(now - s_lastReport).count(); + if (elapsed >= 5.0) + { + std::fprintf(stderr, "[perf] vsync=%llu guest_vps=%.1f host_fps=%.1f\n", + static_cast(currentTick), + static_cast(currentTick - s_lastTick) / elapsed, + static_cast(s_hostFrames) / elapsed); + s_lastReport = now; + s_lastTick = currentTick; + s_hostFrames = 0u; + } + } + } const bool needsLatch = !s_hasLatchedInitialFrame || currentTick != s_lastPresentationTick; if (needsLatch) { From b0ba2a894006f157abd694e4fd2887f1c683d868 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:41:00 -0300 Subject: [PATCH 54/91] Keep switch jump table labels inside their function instead of treating the table as function pointers --- ps2xRecomp/src/lib/elf_parser.cpp | 200 +++++++++++++++++++++++++- ps2xTest/src/ps2_recompiler_tests.cpp | 168 ++++++++++++++++++++++ 2 files changed, 366 insertions(+), 2 deletions(-) diff --git a/ps2xRecomp/src/lib/elf_parser.cpp b/ps2xRecomp/src/lib/elf_parser.cpp index 9d3b9f00e8..e8d61a1445 100644 --- a/ps2xRecomp/src/lib/elf_parser.cpp +++ b/ps2xRecomp/src/lib/elf_parser.cpp @@ -4,6 +4,7 @@ #include "ps2recomp/types.h" #include #include +#include #include #define NOMINMAX @@ -1405,7 +1406,193 @@ namespace return name == ".ctors" || name == ".dtors" || name == ".init_array" || name == ".fini_array"; } - void ScanDataFunctionPointerTables(const std::vector §ions, std::unordered_set &starts) + // Code range a switch jump table may dispatch into, keyed by table address. + using SwitchJumpTables = std::unordered_map>; + + // A C switch compiles to `lw rt, off(base + (index << 2)); jr rt` with the + // table in .rodata. Its entries are case labels inside the dispatching + // function, and an epilogue label (`jr ra; addiu sp,sp,N`) passes the leaf + // entry probe, so reading the table as function pointers would split the + // function at its own case label. This mirrors the dispatch shape the + // control-flow analyzer resolves, so both passes agree on what a table is. + SwitchJumpTables CollectSwitchJumpTables(const std::vector §ions, + const std::unordered_set &knownStarts) + { + constexpr uint32_t kLoadLookbackWords = 20; + constexpr uint32_t kAddLookbackWords = 10; + constexpr uint32_t kUpperLookbackWords = 20; + + SwitchJumpTables tables; + for (const auto §ion : sections) + { + if (!section.isCode || !section.data || section.size < MIPS_INSTRUCTION_SIZE) + { + continue; + } + + // A switch only dispatches within its own function; known call + // targets bound that function, so a neighbouring pointer table that + // happens to follow the jump table is not swallowed by it. + std::vector boundaries; + for (uint32_t start : knownStarts) + { + if (start >= section.address && start - section.address < section.size) + { + boundaries.push_back(start); + } + } + std::sort(boundaries.begin(), boundaries.end()); + + const auto wordAt = [&](uint32_t index) + { + uint32_t raw = 0; + ReadSectionWord(section, index * MIPS_INSTRUCTION_SIZE, raw); + return raw; + }; + const auto lookbackBegin = [](uint32_t index, uint32_t words) + { + return index > words ? index - words : 0u; + }; + + const uint32_t wordCount = section.size / MIPS_INSTRUCTION_SIZE; + for (uint32_t jrIndex = 1; jrIndex < wordCount; ++jrIndex) + { + const uint32_t jrRaw = wordAt(jrIndex); + if (OPCODE(jrRaw) != OPCODE_SPECIAL || FUNCTION(jrRaw) != SPECIAL_JR || RS(jrRaw) == GPR_RA) + { + continue; + } + + const uint32_t targetReg = RS(jrRaw); + uint32_t loadIndex = jrIndex; + for (uint32_t i = jrIndex; i-- > lookbackBegin(jrIndex, kLoadLookbackWords);) + { + const uint32_t raw = wordAt(i); + if ((OPCODE(raw) == OPCODE_LW || OPCODE(raw) == OPCODE_LWU) && RT(raw) == targetReg) + { + loadIndex = i; + break; + } + } + if (loadIndex == jrIndex) + { + continue; + } + + const uint32_t loadRaw = wordAt(loadIndex); + const uint32_t addressReg = RS(loadRaw); + uint32_t addIndex = loadIndex; + for (uint32_t i = loadIndex; i-- > lookbackBegin(loadIndex, kAddLookbackWords);) + { + const uint32_t raw = wordAt(i); + if (OPCODE(raw) == OPCODE_SPECIAL && + (FUNCTION(raw) == SPECIAL_ADDU || FUNCTION(raw) == SPECIAL_DADDU) && + RD(raw) == addressReg) + { + addIndex = i; + break; + } + } + if (addIndex == loadIndex) + { + continue; + } + + // Either addend may hold the table base depending on register allocation. + const uint32_t addRaw = wordAt(addIndex); + bool foundTable = false; + uint32_t tableAddress = 0; + for (uint32_t i = addIndex; i-- > lookbackBegin(addIndex, kUpperLookbackWords);) + { + const uint32_t raw = wordAt(i); + const uint32_t upperReg = RT(raw); + if (OPCODE(raw) != OPCODE_LUI || upperReg == GPR_ZERO || + (upperReg != RS(addRaw) && upperReg != RT(addRaw))) + { + continue; + } + + uint32_t low = 0; + for (uint32_t j = i + 1; j < addIndex; ++j) + { + const uint32_t lowRaw = wordAt(j); + if (RS(lowRaw) != upperReg || RT(lowRaw) != upperReg) + { + continue; + } + if (OPCODE(lowRaw) == OPCODE_ADDIU) + { + low = static_cast(SIMMEDIATE(lowRaw)); + } + else if (OPCODE(lowRaw) == OPCODE_ORI) + { + low = IMMEDIATE(lowRaw); + } + } + // The low half may instead be folded into the load offset. + tableAddress = (IMMEDIATE(raw) << 16) + low + static_cast(SIMMEDIATE(loadRaw)); + foundTable = true; + break; + } + if (!foundTable) + { + continue; + } + + const ps2recomp::Section *tableSection = FindSectionByAddress(sections, tableAddress); + if (!tableSection || tableSection->isCode || (tableAddress % sizeof(uint32_t)) != 0) + { + continue; + } + + const uint32_t jrAddress = section.address + jrIndex * MIPS_INSTRUCTION_SIZE; + const auto next = std::upper_bound(boundaries.begin(), boundaries.end(), jrAddress); + const uint32_t regionBegin = next == boundaries.begin() ? section.address : *std::prev(next); + const uint32_t regionEnd = next == boundaries.end() ? section.address + section.size : *next; + + auto [it, inserted] = tables.try_emplace(tableAddress, regionBegin, regionEnd); + if (!inserted) + { + it->second.first = std::min(it->second.first, regionBegin); + it->second.second = std::max(it->second.second, regionEnd); + } + } + } + return tables; + } + + // Source addresses of jump-table words: the run of case labels starting at + // each table base, ending at the first word that cannot be a case label of + // the dispatching function. + std::unordered_set CollectSwitchJumpTableWords(const std::vector §ions, + const SwitchJumpTables &tables) + { + std::unordered_set words; + for (const auto &[tableAddress, region] : tables) + { + const ps2recomp::Section *tableSection = FindSectionByAddress(sections, tableAddress); + if (!tableSection || !tableSection->data) + { + continue; + } + + for (uint32_t offset = tableAddress - tableSection->address;; offset += sizeof(uint32_t)) + { + uint32_t target = 0; + if (!ReadSectionWord(*tableSection, offset, target) || + (target % MIPS_INSTRUCTION_SIZE) != 0 || + target < region.first || target >= region.second) + { + break; + } + words.insert(tableSection->address + offset); + } + } + return words; + } + + void ScanDataFunctionPointerTables(const std::vector §ions, std::unordered_set &starts, + const std::unordered_set &switchJumpTableWords) { struct PointerCandidate { @@ -1428,6 +1615,11 @@ namespace for (uint32_t offset = 0; offset + MIPS_INSTRUCTION_SIZE <= section.size; offset += MIPS_INSTRUCTION_SIZE) { + if (switchJumpTableWords.contains(section.address + offset)) + { + continue; + } + uint32_t target = 0; if (ReadSectionWord(section, offset, target) && (target % MIPS_INSTRUCTION_SIZE) == 0 && FindCodeSectionByAddress(sections, target)) { @@ -1649,8 +1841,12 @@ namespace } } + // Snapshot before address-taken scans so only call targets bound a switch. + const std::unordered_set switchJumpTableWords = + CollectSwitchJumpTableWords(sections, CollectSwitchJumpTables(sections, starts)); + ScanMaterializedCodeAddresses(sections, starts); - ScanDataFunctionPointerTables(sections, starts); + ScanDataFunctionPointerTables(sections, starts, switchJumpTableWords); ScanAdjacentLeafThunkRuns(sections, starts); std::vector sortedStarts(starts.begin(), starts.end()); diff --git a/ps2xTest/src/ps2_recompiler_tests.cpp b/ps2xTest/src/ps2_recompiler_tests.cpp index 65a0a243f4..9a4eaebb61 100644 --- a/ps2xTest/src/ps2_recompiler_tests.cpp +++ b/ps2xTest/src/ps2_recompiler_tests.cpp @@ -156,6 +156,107 @@ static bool writeMinimalMipsElfWithJalFallbackTarget(const std::filesystem::path return writer.save(elfPath.string()); } +static bool writeMinimalMipsElfWithSwitchJumpTables(const std::filesystem::path &elfPath) +{ + ELFIO::elfio writer; + writer.create(ELFIO::ELFCLASS32, ELFIO::ELFDATA2LSB); + writer.set_os_abi(ELFIO::ELFOSABI_NONE); + writer.set_type(ELFIO::ET_EXEC); + writer.set_machine(ELFIO::EM_MIPS); + writer.set_entry(0x00100000u); + + ELFIO::section *text = writer.sections.add(".text"); + text->set_type(ELFIO::SHT_PROGBITS); + text->set_flags(ELFIO::SHF_ALLOC | ELFIO::SHF_EXECINSTR); + text->set_addr_align(4); + text->set_address(0x00100000u); + + std::array textWords{}; + textWords[0] = 0x0C040008u; // jal 0x00100020 (switch with lui/addiu base) + textWords[2] = 0x0C040020u; // jal 0x00100080 (switch with offset folded into lw) + textWords[4] = 0x03E00008u; // jr ra + + // Function at 0x00100020 whose table repeats its epilogue label, which + // on its own looks exactly like a leaf function entry. + textWords[8] = 0x27BDFFF0u; // addiu sp,sp,-0x10 + textWords[9] = 0xFFBF0000u; // sd ra,0(sp) + textWords[10] = 0x3C0E0020u; // lui t6,0x20 + textWords[11] = 0x00047880u; // sll t7,a0,2 + textWords[12] = 0x25CE0010u; // addiu t6,t6,0x10 (table at 0x00200010) + textWords[13] = 0x01EE7821u; // addu t7,t7,t6 + textWords[14] = 0x8DEF0000u; // lw t7,0(t7) + textWords[15] = 0x01E00008u; // jr t7 + textWords[17] = 0x24020001u; // case 0x00100044: addiu v0,zero,1 + textWords[18] = 0x24020002u; // case 0x00100048: addiu v0,zero,2 + textWords[19] = 0xDFBF0000u; // ld ra,0(sp) + textWords[20] = 0x03E00008u; // case 0x00100050 (epilogue): jr ra + textWords[21] = 0x27BD0010u; // addiu sp,sp,0x10 (delay slot) + textWords[22] = 0x24020003u; // case 0x00100058: addiu v0,zero,3 + textWords[23] = 0x1000FFFBu; // b 0x0010004C + + // Function at 0x00100080 addressing its table as lw t7,0x40(base). + textWords[32] = 0x3C0E0020u; // lui t6,0x20 + textWords[33] = 0x00047880u; // sll t7,a0,2 + textWords[34] = 0x01CF7821u; // addu t7,t6,t7 + textWords[35] = 0x8DEF0040u; // lw t7,0x40(t7) (table at 0x00200040) + textWords[36] = 0x01E00008u; // jr t7 + textWords[38] = 0x03E00008u; // case 0x00100098 (epilogue): jr ra + textWords[40] = 0x24020005u; // case 0x001000A0: addiu v0,zero,5 + textWords[41] = 0x03E00008u; // jr ra + + // Callbacks reachable only through the pointer table after the first switch table. + textWords[48] = 0x27BDFFF0u; // 0x001000C0: addiu sp,sp,-0x10 + textWords[49] = 0xFFBF0000u; // sd ra,0(sp) + textWords[50] = 0xDFBF0000u; // ld ra,0(sp) + textWords[51] = 0x03E00008u; // jr ra + textWords[52] = 0x27BD0010u; // addiu sp,sp,0x10 + textWords[56] = 0x27BDFFF0u; // 0x001000E0: addiu sp,sp,-0x10 + textWords[57] = 0xFFBF0000u; // sd ra,0(sp) + textWords[58] = 0xDFBF0000u; // ld ra,0(sp) + textWords[59] = 0x03E00008u; // jr ra + textWords[60] = 0x27BD0010u; // addiu sp,sp,0x10 + + text->set_data(reinterpret_cast(textWords.data()), + static_cast(textWords.size() * sizeof(uint32_t))); + + ELFIO::section *rodata = writer.sections.add(".rodata"); + rodata->set_type(ELFIO::SHT_PROGBITS); + rodata->set_flags(ELFIO::SHF_ALLOC); + rodata->set_addr_align(4); + rodata->set_address(0x00200000u); + + std::array rodataWords{}; + rodataWords[4] = 0x00100044u; // switch table at 0x00200010 + rodataWords[5] = 0x00100048u; + rodataWords[6] = 0x00100058u; + rodataWords[7] = 0x00100050u; + rodataWords[8] = 0x00100050u; + rodataWords[9] = 0x00100050u; + rodataWords[10] = 0x00100050u; + rodataWords[11] = 0x001000C0u; // callback table directly after it + rodataWords[12] = 0x001000E0u; + rodataWords[16] = 0x001000A0u; // switch table at 0x00200040 + rodataWords[17] = 0x00100098u; + rodataWords[18] = 0x00100098u; + rodataWords[19] = 0x00100098u; + rodata->set_data(reinterpret_cast(rodataWords.data()), + static_cast(rodataWords.size() * sizeof(uint32_t))); + + ELFIO::segment *textSegment = writer.segments.add(); + textSegment->set_type(ELFIO::PT_LOAD); + textSegment->set_flags(ELFIO::PF_R | ELFIO::PF_X); + textSegment->set_align(0x1000); + textSegment->add_section_index(text->get_index(), text->get_addr_align()); + + ELFIO::segment *rodataSegment = writer.segments.add(); + rodataSegment->set_type(ELFIO::PT_LOAD); + rodataSegment->set_flags(ELFIO::PF_R); + rodataSegment->set_align(0x1000); + rodataSegment->add_section_index(rodata->get_index(), rodata->get_addr_align()); + + return writer.save(elfPath.string()); +} + static bool writeMinimalMipsElfWithVuMicroprogramSection(const std::filesystem::path &elfPath) { ELFIO::elfio writer; @@ -1766,6 +1867,73 @@ void register_ps2_recompiler_tests() std::filesystem::remove(elfPath, removeError); }); + tc.Run("elf parser keeps switch jump table labels inside their function", [](TestCase &t) { + const auto uniqueSuffix = std::to_string( + static_cast(std::chrono::steady_clock::now().time_since_epoch().count())); + const std::filesystem::path elfPath = + std::filesystem::temp_directory_path() / ("ps2recomp-switch-table-" + uniqueSuffix + ".elf"); + + const bool writeOk = writeMinimalMipsElfWithSwitchJumpTables(elfPath); + t.IsTrue(writeOk, "temporary stripped ELF should be generated"); + if (!writeOk) + { + return; + } + + ElfParser parser(elfPath.string()); + const bool parseOk = parser.parse(); + t.IsTrue(parseOk, "generated ELF should parse"); + if (parseOk) + { + const auto functions = parser.extractFunctions(); + auto findStart = [&functions](uint32_t start) + { + return std::find_if(functions.begin(), functions.end(), + [start](const Function &function) + { return function.start == start; }); + }; + auto containsAll = [](const Function &function, std::initializer_list targets) + { + return std::all_of(targets.begin(), targets.end(), [&function](uint32_t target) + { return target >= function.start && target < function.end; }); + }; + + const auto addiuSwitch = findStart(0x00100020u); + t.IsTrue(addiuSwitch != functions.end(), "the lui/addiu switch function should be discovered"); + if (addiuSwitch != functions.end()) + { + t.IsTrue(containsAll(*addiuSwitch, {0x00100044u, 0x00100048u, 0x00100050u, 0x00100058u}), + "every case label of the lui/addiu switch should stay inside its function"); + } + for (uint32_t label : {0x00100044u, 0x00100048u, 0x00100050u, 0x00100058u}) + { + t.IsTrue(findStart(label) == functions.end(), + "a lui/addiu switch case label must not become a function start"); + } + + const auto foldedSwitch = findStart(0x00100080u); + t.IsTrue(foldedSwitch != functions.end(), "the folded-offset switch function should be discovered"); + if (foldedSwitch != functions.end()) + { + t.IsTrue(containsAll(*foldedSwitch, {0x00100098u, 0x001000A0u}), + "every case label of the folded-offset switch should stay inside its function"); + } + for (uint32_t label : {0x00100098u, 0x001000A0u}) + { + t.IsTrue(findStart(label) == functions.end(), + "a folded-offset switch case label must not become a function start"); + } + + t.IsTrue(findStart(0x001000C0u) != functions.end(), + "a callback table right after a switch table should still discover its first entry"); + t.IsTrue(findStart(0x001000E0u) != functions.end(), + "a callback table right after a switch table should still discover its second entry"); + } + + std::error_code removeError; + std::filesystem::remove(elfPath, removeError); + }); + tc.Run("elf parser supplements partial DWARF with address-taken callbacks", [](TestCase &t) { const auto uniqueSuffix = std::to_string( static_cast(std::chrono::steady_clock::now().time_since_epoch().count())); From df1e7835e3189c87e2d2aca0093138439411c033 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:46:29 -0300 Subject: [PATCH 55/91] Re-anchor a vblank that fires more than a period late so a slow stretch is not repaid by running faster than real time --- ps2xRuntime/src/lib/Kernel/EeScheduler.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp index 7269603e92..2ded06e7cb 100644 --- a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp +++ b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp @@ -1875,11 +1875,17 @@ void EeScheduler::processDueDeadlines() { if (scheduled.event.type == EeEventType::VBlankStart) { + // A vblank that fired more than a period late (the GS or EE fell behind real + // time) is re-anchored to now. Chaining from the missed deadline would leave a + // backlog of past deadlines that fire back-to-back once the load drops, running + // the game faster than real time until the debt is repaid. + const auto now = std::chrono::steady_clock::now(); + const auto hostBase = (now - scheduled.hostDeadline > kVBlankPeriod) ? now : scheduled.hostDeadline; scheduleEvent(scheduled.deadlineCycle + kVBlankDurationCycles, - scheduled.hostDeadline + kVBlankDuration, + hostBase + kVBlankDuration, EeEvent{EeEventType::VBlankEnd, 0, m_vsyncTick + 1u}); scheduleEvent(scheduled.deadlineCycle + kVBlankPeriodCycles, - scheduled.hostDeadline + kVBlankPeriod, + hostBase + kVBlankPeriod, EeEvent{EeEventType::VBlankStart, 0, 0}); } processEvent(scheduled.event); From dd5ff819c0a47f839a050ceb308e727d4a5156e2 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:23:21 -0300 Subject: [PATCH 56/91] Match libvu0 operand order in sceVu0MulMatrix and use xyz only in Normalize/InnerProduct --- ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp | 22 ++++++---- ps2xTest/src/ps2_vu_tests.cpp | 58 ++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp index b7840d4474..30130e6072 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp @@ -245,12 +245,14 @@ namespace ps2_stubs o[2] = (l[0] * r[1]) - (l[1] * r[0]); o[3] = 0.0f; }; + // Same as sceVu0Normalize: xyz length, w cleared. auto normalize = [](const float (&s)[4], float (&o)[4]) { - const float len = std::sqrt((s[0] * s[0]) + (s[1] * s[1]) + (s[2] * s[2]) + (s[3] * s[3])); + const float len = std::sqrt((s[0] * s[0]) + (s[1] * s[1]) + (s[2] * s[2])); const float inv = (len > 1.0e-6f) ? (1.0f / len) : 0.0f; - for (int i = 0; i < 4; ++i) + for (int i = 0; i < 3; ++i) o[i] = s[i] * inv; + o[3] = 0.0f; }; float rawCross[4]{}, row0[4]{}, row1[4]{}, row2[4]{}; cross(up, fwd, rawCross); @@ -553,7 +555,8 @@ namespace ps2_stubs float dot = 0.0f; if (readVuVec4f(rdram, lhsAddr, lhs) && readVuVec4f(rdram, rhsAddr, rhs)) { - dot = (lhs[0] * rhs[0]) + (lhs[1] * rhs[1]) + (lhs[2] * rhs[2]) + (lhs[3] * rhs[3]); + // libvu0 multiplies .xyz only, so w never contributes. + dot = (lhs[0] * rhs[0]) + (lhs[1] * rhs[1]) + (lhs[2] * rhs[2]); } if (ctx) @@ -699,10 +702,10 @@ namespace ps2_stubs float m0[16]{}, m1[16]{}, out[16]{}; if (readVuMatrix4f(rdram, m0Addr, m0) && readVuMatrix4f(rdram, m1Addr, m1)) { - // out = m0 * m1 (first source . second source), matching the - // file's mulVuMatrix(lhs,rhs)=lhs.rhs convention and the RotMatrix - // / ViewScreenMatrix siblings (first operand on the left). - mulVuMatrix(m0, m1, out); + // libvu0 loads the first source into vf4-vf7 and pushes each row of the second + // through it (vmulax/vmadday/vmaddaz/vmaddw), so the second source is applied + // first: ApplyMatrix(out, v) == ApplyMatrix(m0, ApplyMatrix(m1, v)). + mulVuMatrix(m1, m0, out); (void)writeVuMatrix4f(rdram, dstAddr, out); } setReturnS32(ctx, 0); @@ -732,11 +735,12 @@ namespace ps2_stubs float src[4]{}, out[4]{}; if (readVuVec4f(rdram, srcAddr, src)) { - const float len = std::sqrt((src[0] * src[0]) + (src[1] * src[1]) + (src[2] * src[2]) + (src[3] * src[3])); + // libvu0 takes the length of .xyz, scales .xyz only and clears w. + const float len = std::sqrt((src[0] * src[0]) + (src[1] * src[1]) + (src[2] * src[2])); if (len > 1.0e-6f) { const float invLen = 1.0f / len; - for (int i = 0; i < 4; ++i) + for (int i = 0; i < 3; ++i) { out[i] = src[i] * invLen; } diff --git a/ps2xTest/src/ps2_vu_tests.cpp b/ps2xTest/src/ps2_vu_tests.cpp index b96befe656..ae0d391dc1 100644 --- a/ps2xTest/src/ps2_vu_tests.cpp +++ b/ps2xTest/src/ps2_vu_tests.cpp @@ -296,8 +296,10 @@ void register_ps2_vu_tests() t.IsTrue(allMatch, "MulMatrix(dst, I, A) should equal A"); }); - tc.Run("MulMatrix_equals_arg1_times_arg2", [](TestCase &t) + tc.Run("MulMatrix_pushes_arg2_rows_through_arg1_like_libvu0", [](TestCase &t) { + // libvu0 loads arg1 into vf4-vf7 and transforms each row of arg2 with it: + // out[i] = sum_k arg2[i][k] * arg1[k]. VuEnv env; float m0[16] = { 1, 3, 2, 4, @@ -317,9 +319,7 @@ void register_ps2_vu_tests() float sum = 0.0f; for (int k = 0; k < 4; ++k) { - // expected = mulVuMatrix(m0, m1) = m0 * m1 (arg1 * arg2); - // mulVuMatrix is file-local to VU.cpp, so mirror its formula here. - sum += m1[4 * k + j] * m0[4 * i + k]; + sum += m1[4 * i + k] * m0[4 * k + j]; } expected[4 * i + j] = sum; } @@ -340,7 +340,55 @@ void register_ps2_vu_tests() allMatch = false; } } - t.IsTrue(allMatch, "MulMatrix(dst, m0, m1) should equal m0*m1 (mulVuMatrix(m0,m1), arg1*arg2)"); + t.IsTrue(allMatch, "MulMatrix(dst, m0, m1) should transform each row of m1 by m0, as libvu0 does"); + }); + + tc.Run("MulMatrix_product_applies_second_operand_first", [](TestCase &t) + { + // Games compose screen * view * world with MulMatrix and expect + // ApplyMatrix(MulMatrix(A, B), v) == ApplyMatrix(A, ApplyMatrix(B, v)). + VuEnv env; + float scale[16]{}; + makeIdentity(scale); + scale[0] = 2.0f; // x *= 2 + float translate[16]{}; + makeIdentity(translate); + translate[12] = 10.0f; // x += 10 + writeMat4(env, kA, scale); + writeMat4(env, kB, translate); + SET_GPR_U32(&env.ctx, 4, kDst); + SET_GPR_U32(&env.ctx, 5, kA); + SET_GPR_U32(&env.ctx, 6, kB); + ps2_stubs::sceVu0MulMatrix(env.rdram.data(), &env.ctx, &env.runtime); + + writeVec4(env, kC, 1.0f, 0.0f, 0.0f, 1.0f); + SET_GPR_U32(&env.ctx, 4, kOut); + SET_GPR_U32(&env.ctx, 5, kDst); + SET_GPR_U32(&env.ctx, 6, kC); + ps2_stubs::sceVu0ApplyMatrix(env.rdram.data(), &env.ctx, &env.runtime); + float out[4]{}; + readVec4f(env, kOut, out); + t.IsTrue(nearlyEqual(out[0], 22.0f) && nearlyEqual(out[3], 1.0f), + "MulMatrix(scale, translate) should translate first, then scale: (1+10)*2 = 22"); + }); + + tc.Run("Normalize_and_InnerProduct_ignore_w", [](TestCase &t) + { + VuEnv env; + writeVec4(env, kA, 3.0f, 0.0f, 4.0f, 1.0f); + SET_GPR_U32(&env.ctx, 4, kDst); + SET_GPR_U32(&env.ctx, 5, kA); + ps2_stubs::sceVu0Normalize(env.rdram.data(), &env.ctx, &env.runtime); + float n[4]{}; + readVec4f(env, kDst, n); + t.IsTrue(nearlyEqual(n[0], 0.6f) && nearlyEqual(n[1], 0.0f) && nearlyEqual(n[2], 0.8f) && nearlyEqual(n[3], 0.0f), + "Normalize should use the xyz length and clear w"); + + writeVec4(env, kB, 1.0f, 2.0f, 3.0f, 5.0f); + SET_GPR_U32(&env.ctx, 4, kA); + SET_GPR_U32(&env.ctx, 5, kB); + ps2_stubs::sceVu0InnerProduct(env.rdram.data(), &env.ctx, &env.runtime); + t.IsTrue(nearlyEqual(env.ctx.f[0], 15.0f), "InnerProduct should sum xyz products only"); }); tc.Run("RotMatrix_Z_matches_RotMatrixZ", [](TestCase &t) From 8b4e181e813932a3f737f90bd361827c636f64b6 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:35:26 -0300 Subject: [PATCH 57/91] Count GS primitives, batches, flush reasons, pixels and GS thread time under PS2X_GS_STATS --- .../include/runtime/gs/gs_cpu_backend.h | 37 +- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 338 ++++++++++++++++-- 2 files changed, 342 insertions(+), 33 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 9760a2ffb1..538a508cb6 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -126,6 +126,37 @@ class GSCpuBackend final : public GSRasterBackend uint32_t offset = 0; uint32_t old = 0; }; + // PS2X_GS_STATS counters a rasterizer pass keeps locally and adds to the totals once it ends. + struct RasterStats + { + uint64_t boxPixels = 0; + uint64_t insidePixels = 0; + uint64_t writtenPixels = 0; + uint64_t texSamples = 0; + // Triangles whose clipped bounding box is at least 64K pixels. + uint64_t bigBoxPixels = 0; + uint64_t bigInsidePixels = 0; + }; + static void AddRasterStats(const RasterStats &stats); + // Why queued primitives were rasterized; indexes the PS2X_GS_STATS flush counters. + enum class FlushReason : uint8_t + { + Full, + TargetChange, + TargetOverlap, + ReadsPendingWrites, + WritesPendingReads, + TrackMismatch, + DrawAlone, + Clut, + Transfer, + TextureFlush, + Capture, + Idle, + Readback, + Other, + Count, + }; struct RasterContext { int rowBegin = std::numeric_limits::min(); @@ -140,6 +171,7 @@ class GSCpuBackend final : public GSRasterBackend // Where a tracked batch logs what it overwrites in undoPages, so a cached copy can be rebuilt. std::vector *undo = nullptr; const std::bitset<512> *undoPages = nullptr; + RasterStats *stats = nullptr; bool ownsRow(int y) const { @@ -184,7 +216,7 @@ class GSCpuBackend final : public GSRasterBackend void DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster); void PrepareTexturePageCache(const GSDrawState &state); void QueuePrimitive(const GSPrimitiveBatch &batch); - void FlushPendingUnlocked(); + void FlushPendingUnlocked(FlushReason reason = FlushReason::Other); void RasterizePendingInParallel(); void RasterizeTrackedInParallel(); void ClearPendingUnlocked(); @@ -206,7 +238,8 @@ class GSCpuBackend final : public GSRasterBackend static PixelPipeline MakePixelPipeline(const GSDrawState &state, const RasterContext &raster); void RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, int x, int y); void WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); - void WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); + // True when the pixel passed every test and something was stored. + bool WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); // Texture registers decoded once per primitive, with the CLUT expanded to RGBA for indexed formats. struct TextureSampler { diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 47c4103267..b721c386fd 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -10,6 +10,8 @@ #include "ps2_log.h" #include #include +#include +#include #if defined(__ARM_NEON) #include #endif @@ -27,6 +29,147 @@ using namespace GSInternal; namespace { + // PS2X_GS_STATS=1 prints where GS time goes every 5 s. Rasterizer bands add their counters once per + // pass, so leaving this compiled in costs a predictable branch per primitive. + bool gsStatsEnabled() + { + static const bool enabled = std::getenv("PS2X_GS_STATS") != nullptr; + return enabled; + } + + enum GSTimeCategory : uint32_t + { + kTimeTracked, + kTimeUntracked, + kTimeSerialBatch, + kTimeAlone, + kTimeClut, + kTimeTransfer, + kTimeUpload, + kTimeCapture, + kTimeClear, + kTimeSubmitOther, + kTimeOther, + kTimeIdle, + kTimeCount, + }; + const char *const kTimeNames[kTimeCount] = {"tracked", "untracked", "serialBatch", "alone", "clut", "transfer", + "upload", "capture", "clear", "submitOther", "other", "idle"}; + const char *const kFlushNames[] = {"full", "targetChange", "targetOverlap", "readsPendingWrites", "writesPendingReads", + "trackMismatch", "drawAlone", "clut", "transfer", "textureFlush", "capture", "idle", + "readback", "other"}; + + struct GSStatCounters + { + std::atomic prims[8]{}; + std::atomic texturedPrims{}; + std::atomic batches[3]{}; // tracked, untracked parallel, serial + std::atomic batchPrims[3]{}; + std::atomic batchPixels[3]{}; + std::atomic alonePrims{}; + std::atomic trackStart[2]{}; // a primitive sampling its own target, or a page queued ones draw + std::atomic trackSample[7]{}; + std::atomic flushes[16]{}; + std::atomic undoEntries{}; + std::atomic boxPixels{}; + std::atomic insidePixels{}; + std::atomic writtenPixels{}; + std::atomic texSamples{}; + std::atomic bigBoxPixels{}; + std::atomic bigInsidePixels{}; + std::atomic timeNs[kTimeCount]{}; + std::chrono::steady_clock::time_point last = std::chrono::steady_clock::now(); + }; + + GSStatCounters &gsStats() + { + static GSStatCounters counters; + return counters; + } + + uint64_t take(std::atomic &counter) + { + return counter.exchange(0u, std::memory_order_relaxed); + } + + void printGsStatsIfDue() + { + GSStatCounters &s = gsStats(); + const auto now = std::chrono::steady_clock::now(); + const double seconds = std::chrono::duration(now - s.last).count(); + if (seconds < 5.0) + return; + s.last = now; + const double perSec = 1.0 / seconds; + std::fprintf(stderr, "[gsstats] prims/s point=%.0f line=%.0f tri=%.0f tristrip=%.0f trifan=%.0f sprite=%.0f textured=%.0f alone=%.0f\n", + take(s.prims[0]) * perSec, (take(s.prims[1]) + take(s.prims[2])) * perSec, take(s.prims[3]) * perSec, + take(s.prims[4]) * perSec, take(s.prims[5]) * perSec, take(s.prims[6]) * perSec, take(s.texturedPrims) * perSec, + take(s.alonePrims) * perSec); + const char *batchNames[3] = {"tracked", "untracked", "serial"}; + for (int i = 0; i < 3; ++i) + { + const uint64_t batches = take(s.batches[i]); + const uint64_t prims = take(s.batchPrims[i]); + const uint64_t pixels = take(s.batchPixels[i]); + std::fprintf(stderr, "[gsstats] batches %s/s=%.1f prims/batch=%.1f boxpx/batch=%.0f\n", batchNames[i], batches * perSec, + batches ? double(prims) / batches : 0.0, batches ? double(pixels) / batches : 0.0); + } + std::string flushes; + for (size_t i = 0; i < sizeof(kFlushNames) / sizeof(kFlushNames[0]); ++i) + { + const uint64_t count = take(s.flushes[i]); + if (count) + flushes += " " + std::string(kFlushNames[i]) + "=" + std::to_string(static_cast(count * perSec)); + } + std::fprintf(stderr, "[gsstats] flushes/s%s\n", flushes.c_str()); + std::fprintf(stderr, "[gsstats] tracked starts/s self=%.1f cross=%.1f; last: fbp=%u fpsm=0x%x zbp=%u zpsm=0x%x tbp0=%u tpsm=0x%x prim=%u\n", + take(s.trackStart[0]) * perSec, take(s.trackStart[1]) * perSec, s.trackSample[0].load(), s.trackSample[1].load(), + s.trackSample[2].load(), s.trackSample[3].load(), s.trackSample[4].load(), s.trackSample[5].load(), s.trackSample[6].load()); + std::fprintf(stderr, "[gsstats] Mpx/s box=%.2f inside=%.2f written=%.2f texsamples=%.2f bigbox=%.2f biginside=%.2f undo=%.2f\n", + take(s.boxPixels) * perSec * 1e-6, take(s.insidePixels) * perSec * 1e-6, take(s.writtenPixels) * perSec * 1e-6, + take(s.texSamples) * perSec * 1e-6, take(s.bigBoxPixels) * perSec * 1e-6, take(s.bigInsidePixels) * perSec * 1e-6, + take(s.undoEntries) * perSec * 1e-6); + std::string times; + for (uint32_t i = 0; i < kTimeCount; ++i) + { + char item[64]; + std::snprintf(item, sizeof(item), " %s=%.1f%%", kTimeNames[i], take(s.timeNs[i]) * 1e-9 * perSec * 100.0); + times += item; + } + std::fprintf(stderr, "[gsstats] gs-thread time%s\n", times.c_str()); + } + + // Adds wall time to one category while in scope, excluding nested timers so categories sum to the total. + thread_local uint64_t t_nestedNs = 0u; + class GSStatTimer + { + public: + explicit GSStatTimer(uint32_t category) + : m_category(category), m_enabled(gsStatsEnabled()) + { + if (!m_enabled) + return; + m_outerNested = t_nestedNs; + t_nestedNs = 0u; + m_start = std::chrono::steady_clock::now(); + } + ~GSStatTimer() + { + if (!m_enabled) + return; + const uint64_t elapsed = static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now() - m_start).count()); + gsStats().timeNs[m_category].fetch_add(elapsed - std::min(elapsed, t_nestedNs), std::memory_order_relaxed); + t_nestedNs = m_outerNested + elapsed; + } + + private: + uint32_t m_category; + bool m_enabled; + uint64_t m_outerNested = 0u; + std::chrono::steady_clock::time_point m_start; + }; + float fabsQ(float q) { return (std::fabs(q) > 1.0e-8f) ? q : 1.0f; @@ -862,7 +1005,7 @@ void GSCpuBackend::SetRasterThreads(uint32_t participants) { WaitForQueue(); std::lock_guard lock(m_mutex); - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Other); m_rasterParticipants = participants; } @@ -902,7 +1045,10 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) // own writes change; otherwise it is drawn alone, in order. if (spansOverlap(frame, depth) || (texture.count != 0u && (readsChanged(frame, frameTop) || readsChanged(depth, depthTop)))) { - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::DrawAlone); + GSStatTimer timer(kTimeAlone); + if (gsStatsEnabled()) + gsStats().alonePrims.fetch_add(1u, std::memory_order_relaxed); DrawPrimitive(batch); return; } @@ -914,7 +1060,10 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) const bool trackable = state.prim.tme && samplesEveryPixel && GSMem::TargetPixelOffset(ctx.frame.psm) && GSMem::TargetPixelOffset(ctx.zbuf.psm); if (selfAlias && !trackable) { - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::DrawAlone); + GSStatTimer timer(kTimeAlone); + if (gsStatsEnabled()) + gsStats().alonePrims.fetch_add(1u, std::memory_order_relaxed); DrawPrimitive(batch); return; } @@ -940,9 +1089,19 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) // An untracked batch that already let a palette load read VRAM cannot become tracked (see LoadClutNow). const bool canTrack = m_pendingTrackable && trackable && spanKey == m_pendingSpanKey && (m_pendingTracked || m_pendingClutReadIndex == 0u); - if (targetKey != m_pendingTargetKey || anyPage(m_pendingDepthPages, frame) || anyPage(m_pendingFramePages, depth) || - readsPendingWrites || writesPendingReads || (tracked && !canTrack)) - FlushPendingUnlocked(); + FlushReason reason = FlushReason::Count; + if (targetKey != m_pendingTargetKey) + reason = FlushReason::TargetChange; + else if (anyPage(m_pendingDepthPages, frame) || anyPage(m_pendingFramePages, depth)) + reason = FlushReason::TargetOverlap; + else if (readsPendingWrites) + reason = FlushReason::ReadsPendingWrites; + else if (writesPendingReads) + reason = FlushReason::WritesPendingReads; + else if (tracked && !canTrack) + reason = FlushReason::TrackMismatch; + if (reason != FlushReason::Count) + FlushPendingUnlocked(reason); } if (m_pending.empty()) { @@ -950,8 +1109,22 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) m_pendingTrackable = trackable; } m_pendingTrackable = m_pendingTrackable && trackable && spanKey == m_pendingSpanKey; - if (aliasesPending()) + if (!m_pendingTracked && aliasesPending()) + { m_pendingTracked = true; + if (gsStatsEnabled()) + { + GSStatCounters &stats = gsStats(); + stats.trackStart[selfAlias ? 0 : 1].fetch_add(1u, std::memory_order_relaxed); + stats.trackSample[0] = ctx.frame.fbp; + stats.trackSample[1] = ctx.frame.psm; + stats.trackSample[2] = ctx.zbuf.zbp; + stats.trackSample[3] = ctx.zbuf.psm; + stats.trackSample[4] = ctx.tex0.tbp0; + stats.trackSample[5] = ctx.tex0.psm; + stats.trackSample[6] = state.prim.type; + } + } m_pendingTargetKey = targetKey; markPages(m_pendingWritePages, frame); @@ -985,7 +1158,7 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) addRowWeights(m_pendingRowWeights, bounds); m_pendingPixels += bounds.area(); if (m_pending.size() >= kMaxPendingPrimitives || m_pendingPixels >= kMaxPendingPixels) - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Full); } void GSCpuBackend::ClearPendingUnlocked() @@ -1007,10 +1180,24 @@ void GSCpuBackend::ClearPendingUnlocked() m_pendingRowWeights.fill(0u); } -void GSCpuBackend::FlushPendingUnlocked() +void GSCpuBackend::AddRasterStats(const RasterStats &stats) +{ + GSStatCounters &s = gsStats(); + s.boxPixels.fetch_add(stats.boxPixels, std::memory_order_relaxed); + s.insidePixels.fetch_add(stats.insidePixels, std::memory_order_relaxed); + s.writtenPixels.fetch_add(stats.writtenPixels, std::memory_order_relaxed); + s.texSamples.fetch_add(stats.texSamples, std::memory_order_relaxed); + s.bigBoxPixels.fetch_add(stats.bigBoxPixels, std::memory_order_relaxed); + s.bigInsidePixels.fetch_add(stats.bigInsidePixels, std::memory_order_relaxed); +} + +void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) { if (m_pending.empty()) return; + const bool statsEnabled = gsStatsEnabled(); + if (statsEnabled) + gsStats().flushes[static_cast(reason)].fetch_add(1u, std::memory_order_relaxed); bool parallel = m_pendingPixels >= kMinParallelPixels && RasterParticipants() > 1u; uint32_t livePage = 0u; @@ -1028,6 +1215,15 @@ void GSCpuBackend::FlushPendingUnlocked() m_texturePageCache.MakeLive(); } + const int kind = parallel ? (m_pendingTracked ? 0 : 1) : 2; + if (statsEnabled) + { + GSStatCounters &stats = gsStats(); + stats.batches[kind].fetch_add(1u, std::memory_order_relaxed); + stats.batchPrims[kind].fetch_add(m_pending.size(), std::memory_order_relaxed); + stats.batchPixels[kind].fetch_add(m_pendingPixels, std::memory_order_relaxed); + } + GSStatTimer timer(kind == 0 ? kTimeTracked : kind == 1 ? kTimeUntracked : kTimeSerialBatch); if (parallel && m_pendingTracked) { RasterizeTrackedInParallel(); @@ -1067,6 +1263,7 @@ void GSCpuBackend::RasterizeTrackedInParallel() BalanceRows(m_pendingRowWeights, bands, rows); const std::bitset<512> kept = m_pendingTexturePages & m_pendingWritePages; std::vector caches(bands); + std::vector bandStats(gsStatsEnabled() ? bands : 0u); m_undoLogs.resize(bands); for (uint32_t i = 0; i < bands; ++i) { @@ -1082,6 +1279,7 @@ void GSCpuBackend::RasterizeTrackedInParallel() raster.trackReads = true; raster.undo = &m_undoLogs[index]; raster.undoPages = &kept; + raster.stats = bandStats.empty() ? nullptr : &bandStats[index]; for (size_t i = 0; i < m_pending.size(); ++i) { raster.primitive = static_cast(i); @@ -1091,6 +1289,11 @@ void GSCpuBackend::RasterizeTrackedInParallel() } }; m_workers->runBands(bands, band); + for (uint32_t i = 0; i < bandStats.size(); ++i) + { + AddRasterStats(bandStats[i]); + gsStats().undoEntries.fetch_add(m_undoLogs[i].size(), std::memory_order_relaxed); + } // The page of the last texel read in serial order is where the cache ends. GSMem::TexturePageCache::ReadPosition lastRead{}; @@ -1181,6 +1384,7 @@ void GSCpuBackend::RasterizePendingInParallel() BalanceRows(m_pendingRowWeights, bands, rows); std::vector caches(bands); std::vector traces(bands); + std::vector bandStats(gsStatsEnabled() ? bands : 0u); const std::function band = [&](uint32_t index) { RasterContext raster{}; @@ -1188,6 +1392,7 @@ void GSCpuBackend::RasterizePendingInParallel() raster.rowEnd = rows[index].end; raster.cache = &caches[index]; raster.trace = &traces[index]; + raster.stats = bandStats.empty() ? nullptr : &bandStats[index]; for (size_t i = 0; i < m_pending.size(); ++i) { raster.primitive = static_cast(i); @@ -1197,6 +1402,8 @@ void GSCpuBackend::RasterizePendingInParallel() } }; m_workers->runBands(bands, band); + for (const RasterStats &stats : bandStats) + AddRasterStats(stats); // Sequentially, the cache would end on the page of the last texel read: the last sampled pixel in // primitive then raster order, which lies on exactly one band. @@ -1310,6 +1517,7 @@ void GSCpuBackend::RunQueue() std::deque work; { std::unique_lock lock(m_queueMutex); + GSStatTimer idleTimer(kTimeIdle); m_queueReady.wait(lock, [this]() { return m_queueStop || !m_queue.empty(); }); if (m_queue.empty()) return; @@ -1340,11 +1548,38 @@ void GSCpuBackend::RunQueue() // Nothing else to do: rasterize what is batched now instead of when someone next reads. if (idle) FlushNow(); + if (gsStatsEnabled()) + printGsStatsIfDue(); } } void GSCpuBackend::Execute(Command &command) { + uint32_t category = kTimeOther; + switch (command.kind) + { + case Command::Kind::Submit: + category = kTimeSubmitOther; + break; + case Command::Kind::LoadClut: + category = kTimeClut; + break; + case Command::Kind::BeginTransfer: + category = kTimeTransfer; + break; + case Command::Kind::UploadImage: + category = kTimeUpload; + break; + case Command::Kind::CaptureFrame: + category = kTimeCapture; + break; + case Command::Kind::ClearFramebuffer: + category = kTimeClear; + break; + default: + break; + } + GSStatTimer timer(category); switch (command.kind) { case Command::Kind::Submit: @@ -1547,7 +1782,7 @@ void GSCpuBackend::InitializeNow(uint8_t *vram, uint32_t vramSize) void GSCpuBackend::ResetNow() { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Other); ResetUnlocked(); } @@ -1571,6 +1806,12 @@ void GSCpuBackend::SubmitNow(const GSPrimitiveBatch &batch) std::lock_guard lock(m_mutex); if (!m_vram || batch.vertexCount == 0u) return; + if (gsStatsEnabled()) + { + gsStats().prims[batch.state.prim.type & 7u].fetch_add(1u, std::memory_order_relaxed); + if (batch.state.prim.tme) + gsStats().texturedPrims.fetch_add(1u, std::memory_order_relaxed); + } if (RasterParticipants() > 1u) QueuePrimitive(batch); else @@ -1589,7 +1830,7 @@ void GSCpuBackend::LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclu const uint32_t maxY = tex0.csm == 0u ? 15u : texclut.cov; if (anyPage(m_pendingWritePages, pageSpan(tex0.cbp, sourceWidth, tex0.cpsm, 0u, 0u, maxX, maxY)) || !m_texturePageCache.MatchesVram(m_vram)) - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Clut); } if (!m_vram || (!isFourBitIndexedPsm(tex0.psm) && !isEightBitIndexedPsm(tex0.psm))) return; @@ -1627,7 +1868,7 @@ void GSCpuBackend::LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclu // already and the cache stays a faithful copy; otherwise it is drawn before the load. const bool readsVram = tex0.cpsm == GS_PSM_CT16 || tex0.cpsm == GS_PSM_CT16S || tex0.cpsm == GS_PSM_CT32 || tex0.cpsm == GS_PSM_CT24; if (readsVram && m_pendingTracked && (m_pendingPixels < kMinParallelPixels || !m_texturePageCache.MatchesVram(m_vram))) - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Clut); LoadClutUnlocked(tex0, texclut); if (readsVram) m_pendingClutReadIndex = m_pending.size(); @@ -1685,26 +1926,26 @@ void GSCpuBackend::LoadClutUnlocked(const GSTex0Reg &tex0, const GSTexClutReg &t void GSCpuBackend::FlushNow() { std::lock_guard lock(m_mutex); - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Idle); } void GSCpuBackend::TextureFlushNow() { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::TextureFlush); m_texturePageCache.Invalidate(); } void GSCpuBackend::SyncNow(GSSyncReason) { std::lock_guard lock(m_mutex); - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Readback); } uint32_t GSCpuBackend::ReadVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y) const { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Readback); return ReadVramUnlocked(psm, base, bw, x, y); } @@ -1726,7 +1967,7 @@ uint32_t GSCpuBackend::ReadTextureVramUnlocked(uint32_t psm, uint32_t base, uint void GSCpuBackend::WriteVramNow(uint32_t psm, uint32_t base, uint32_t bw, uint32_t x, uint32_t y, uint32_t value) { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Readback); WriteVramUnlocked(psm, base, bw, x, y, value); } @@ -1744,7 +1985,7 @@ void GSCpuBackend::WriteVramUnlocked(uint32_t psm, uint32_t base, uint32_t bw, u void GSCpuBackend::SnapshotVramNow(std::vector &out) const { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Readback); if (!m_vram || m_vramSize == 0u) { out.clear(); @@ -1758,7 +1999,7 @@ void GSCpuBackend::CaptureFrameNow(const GSPresentationRequest &display) { { std::lock_guard lock(m_mutex); - FlushPendingUnlocked(); + FlushPendingUnlocked(FlushReason::Capture); if (!m_vram || m_vramSize == 0u) return; m_captureScratch.resize(m_vramSize); @@ -1929,7 +2170,12 @@ void GSCpuBackend::DrawPrimitive(const GSPrimitiveBatch &batch, const uint32_t * RasterContext raster{}; raster.cache = &m_texturePageCache; raster.clut = clut; + RasterStats stats; + if (gsStatsEnabled()) + raster.stats = &stats; DrawPrimitiveRows(batch, raster); + if (raster.stats) + AddRasterStats(stats); } void GSCpuBackend::DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster) @@ -2071,12 +2317,12 @@ void GSCpuBackend::RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, i pipe.undo->push_back(entry); } -__attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) +__attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) { const GSDrawState &state = *pipe.state; const auto &ctx = state.context; if (x < ctx.scissor.x0 || x > ctx.scissor.x1 || y < ctx.scissor.y0 || y > ctx.scissor.y1) - return; + return false; if (pipe.fge) { @@ -2100,7 +2346,7 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP const PixelWriteMask writeMask = classifyAlphaTest(ctx.test, a, static_cast(fpsm)); if (!writeMask.writesAnything()) { - return; + return false; } const uint32_t ztestMethod = pipe.ztestMethod; @@ -2141,7 +2387,7 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP if (!passesDestinationAlphaTest(ctx.test, static_cast(fpsm), rawFramebufferPixel)) { - return; + return false; } bool zpass = false; @@ -2174,7 +2420,7 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP if (!zpass) { - return; + return false; } if (writeMask.writesFramebuffer()) @@ -2275,6 +2521,7 @@ __attribute__((always_inline)) inline void GSCpuBackend::WritePixel(const PixelP m_writeVramFuncs[zpsm & 0x3Fu](m_vram, zbp, fbw, x, y, z); } } + return true; } GSCpuBackend::PixelPipeline GSCpuBackend::MakePixelPipeline(const GSDrawState &state, const RasterContext &raster) @@ -2578,6 +2825,7 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext const uint8_t alphaFix = static_cast((alphaReg >> 32) & 0xFFu); uint8_t r = v1.r, g = v1.g, b = v1.b, a = v1.a; + uint64_t writtenPixels = 0u; if (state.prim.tme) { @@ -2662,7 +2910,7 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext uint8_t ta = static_cast((texel >> 24) & 0xFF); const TextureCombineResult color = combineTexture(tex, r, g, b, a, tr, tg, tb, ta); - WritePixel(pipe, x, y, z1, color.r, color.g, color.b, color.a, v1.fog); + writtenPixels += WritePixel(pipe, x, y, z1, color.r, color.g, color.b, color.a, v1.fog) ? 1u : 0u; } } } @@ -2672,9 +2920,19 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext for (int y = raster.firstRow(drawY0); y <= raster.lastRow(drawY1); ++y) { for (int x = drawX0; x <= drawX1; ++x) - WritePixel(pipe, x, y, z1, r, g, b, a, v1.fog); + writtenPixels += WritePixel(pipe, x, y, z1, r, g, b, a, v1.fog) ? 1u : 0u; } } + if (raster.stats) + { + const uint64_t box = static_cast(raster.lastRow(drawY1) - raster.firstRow(drawY0) + 1) * + static_cast(drawX1 - drawX0 + 1); + raster.stats->boxPixels += box; + raster.stats->insidePixels += box; + raster.stats->writtenPixels += writtenPixels; + if (state.prim.tme) + raster.stats->texSamples += box; + } } void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterContext &raster) @@ -2717,6 +2975,8 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte const float invAbsDenom = 1.0f / std::fabs(denom); constexpr float kEdgeEpsilon = 1.0e-4f; + uint64_t insidePixels = 0u; + uint64_t writtenPixels = 0u; for (int y = raster.firstRow(minY); y <= raster.lastRow(maxY); ++y) { float py = static_cast(y) + 0.5f; @@ -2730,6 +2990,7 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte if (w0 < -kEdgeEpsilon || w1 < -kEdgeEpsilon || w2 < -kEdgeEpsilon) continue; + ++insidePixels; double z = v0.z * w0 + v1.z * w1 + v2.z * w2; @@ -2796,7 +3057,22 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte } const uint8_t fog = clampU8(static_cast(v0.fog * w0 + v1.fog * w1 + v2.fog * w2)); - WritePixel(pipe, x, y, static_cast(z + 0.5), r, g, b, a, fog); + writtenPixels += WritePixel(pipe, x, y, static_cast(z + 0.5), r, g, b, a, fog) ? 1u : 0u; + } + } + if (raster.stats) + { + const int rows = raster.lastRow(maxY) - raster.firstRow(minY) + 1; + const uint64_t box = static_cast(rows) * static_cast(maxX - minX + 1); + raster.stats->boxPixels += box; + raster.stats->insidePixels += insidePixels; + raster.stats->writtenPixels += writtenPixels; + if (state.prim.tme) + raster.stats->texSamples += insidePixels; + if (static_cast(maxY - minY + 1) * static_cast(maxX - minX + 1) >= 65536u) + { + raster.stats->bigBoxPixels += box; + raster.stats->bigInsidePixels += insidePixels; } } } @@ -2873,7 +3149,7 @@ void GSCpuBackend::DrawLine(const GSPrimitiveBatch &batch, const RasterContext & void GSCpuBackend::BeginTransferNow(const GSTransferCommand &command) { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Transfer); m_transfer = command; m_transferState.x = command.trxpos.dsax; m_transferState.y = command.trxpos.dsay; @@ -2892,7 +3168,7 @@ void GSCpuBackend::BeginTransferNow(const GSTransferCommand &command) void GSCpuBackend::UploadImageNow(const uint8_t *data, uint32_t sizeBytes) { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Transfer); if (!data || sizeBytes == 0u || !m_vram || m_transferState.direction != 0u) return; if (m_transfer.trxreg.rrw == 0u || m_transfer.trxreg.rrh == 0u || m_transferState.totalPixels == 0u) @@ -3137,7 +3413,7 @@ void GSCpuBackend::PerformLocalToHostTransfer() uint32_t GSCpuBackend::ConsumeLocalToHostBytesNow(uint8_t *dst, uint32_t maxBytes) { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Readback); if (!dst || maxBytes == 0u || m_localToHostReadPos >= m_localToHostBuffer.size()) return 0u; const size_t count = std::min(maxBytes, m_localToHostBuffer.size() - m_localToHostReadPos); @@ -3150,7 +3426,7 @@ uint32_t GSCpuBackend::ConsumeLocalToHostBytesNow(uint8_t *dst, uint32_t maxByte bool GSCpuBackend::ClearFramebufferNow(const GSContext &context, uint32_t rgba) { std::lock_guard lock(m_mutex); - const_cast(this)->FlushPendingUnlocked(); + const_cast(this)->FlushPendingUnlocked(FlushReason::Other); if (!m_vram || context.frame.fbw == 0u) return false; From ca39f666e4e9039d8d172ea06aa785eee3fc944a Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:23:36 -0300 Subject: [PATCH 58/91] Split GS batches into 8-row bands taken on demand and reuse band texture caches --- ps2xRuntime/include/runtime/gs/gs_cpu_backend.h | 2 ++ ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 538a508cb6..f42c0a3409 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -356,4 +356,6 @@ class GSCpuBackend final : public GSRasterBackend // Rebuilds the texture cache's copied page after a parallel draw into the page it holds. std::vector m_pageRebuildScratch; std::vector> m_undoLogs; + // One texture page cache per band, kept between batches: each holds an 8 KB page copy. + std::vector m_bandCaches; }; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index b721c386fd..bf45ff4db4 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -873,9 +873,10 @@ namespace } constexpr size_t kMaxPendingPrimitives = 8192u; - // Row ranges per participant: pixel estimates are only bounding boxes, so finer ranges taken on - // demand even out what the estimate gets wrong. - constexpr uint32_t kBandsPerWorker = 4u; + // Row ranges per participant: pixel estimates are only bounding boxes, and a participant can run on a + // slow core or be preempted, so ranges down to single 8-row groups taken on demand keep every participant + // busy until the batch ends (4 per participant left workers 16% idle in 3D scenes). + constexpr uint32_t kBandsPerWorker = 32u; constexpr uint64_t kMaxPendingPixels = 1u << 22u; // Below this, waking the workers costs more than drawing the batch on the calling thread. constexpr uint64_t kMinParallelPixels = 16384u; @@ -1262,7 +1263,8 @@ void GSCpuBackend::RasterizeTrackedInParallel() std::vector rows; BalanceRows(m_pendingRowWeights, bands, rows); const std::bitset<512> kept = m_pendingTexturePages & m_pendingWritePages; - std::vector caches(bands); + std::vector &caches = m_bandCaches; + caches.resize(bands); std::vector bandStats(gsStatsEnabled() ? bands : 0u); m_undoLogs.resize(bands); for (uint32_t i = 0; i < bands; ++i) @@ -1382,7 +1384,10 @@ void GSCpuBackend::RasterizePendingInParallel() const uint32_t bands = m_workers->participants() * kBandsPerWorker; std::vector rows; BalanceRows(m_pendingRowWeights, bands, rows); - std::vector caches(bands); + std::vector &caches = m_bandCaches; + caches.resize(bands); + for (GSMem::TexturePageCache &cache : caches) + cache.ResetForBand(false); std::vector traces(bands); std::vector bandStats(gsStatsEnabled() ? bands : 0u); const std::function band = [&](uint32_t index) From b4e4230aba3582f5ce5079053b7eeed53711fbee Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:30:28 -0300 Subject: [PATCH 59/91] Rasterize batched textured triangles in row chunks bounded by their edges --- .../include/runtime/gs/gs_cpu_backend.h | 2 + ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 163 +++++++++++++++++- ps2xTest/src/ps2_gs_tests.cpp | 86 +++++++++ 3 files changed, 248 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index f42c0a3409..a26e6e18b6 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -172,6 +172,8 @@ class GSCpuBackend final : public GSRasterBackend std::vector *undo = nullptr; const std::bitset<512> *undoPages = nullptr; RasterStats *stats = nullptr; + // Set for a band of a queued batch, whose primitives never sample memory the batch writes. + bool batched = false; bool ownsRow(int y) const { diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index bf45ff4db4..83bf86febc 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1279,6 +1279,7 @@ void GSCpuBackend::RasterizeTrackedInParallel() raster.rowEnd = rows[index].end; raster.cache = &caches[index]; raster.trackReads = true; + raster.batched = true; raster.undo = &m_undoLogs[index]; raster.undoPages = &kept; raster.stats = bandStats.empty() ? nullptr : &bandStats[index]; @@ -1397,6 +1398,7 @@ void GSCpuBackend::RasterizePendingInParallel() raster.rowEnd = rows[index].end; raster.cache = &caches[index]; raster.trace = &traces[index]; + raster.batched = true; raster.stats = bandStats.empty() ? nullptr : &bandStats[index]; for (size_t i = 0; i < m_pending.size(); ++i) { @@ -2980,12 +2982,167 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte const float invAbsDenom = 1.0f / std::fabs(denom); constexpr float kEdgeEpsilon = 1.0e-4f; + // Each weight is linear in px along a row. Solving the three edge tests in double, widened by more than + // the float evaluation can be off, bounds the columns worth testing; the exact per-pixel test below still + // decides coverage, so the drawn pixels are unchanged while most of the bounding box is skipped. + struct EdgeBound + { + double a = 0.0; // weight change per pixel + double b = 0.0; // weight at px = 0 for the current row + double err = 0.0; // bound on the float evaluation error of the weight + }; + const double scale = static_cast(winding) * static_cast(invAbsDenom); + const double extentX = static_cast(std::max(std::fabs(static_cast(minX) - fx2), std::fabs(static_cast(maxX) + 1.0 - fx2))); + auto rowEdge = [&](double A, double B, double py) -> EdgeBound + { + const double C = B * (py - fy2); + EdgeBound edge; + edge.a = A * scale; + edge.b = (C - A * fx2) * scale; + edge.err = (std::fabs(A) * extentX + std::fabs(C) + 1.0) * 1.0e-6 * std::fabs(scale) + 1.0e-6; + return edge; + }; + // Narrows [lo, hi] to the columns where a*px + b >= -eps could hold, px = x + 0.5. + auto clipToEdge = [&](const EdgeBound &edge, int &lo, int &hi) + { + const double threshold = -static_cast(kEdgeEpsilon) - edge.err - edge.b; + if (std::fabs(edge.a) < 1.0e-12) + { + if (threshold > 0.0) + hi = lo - 1; + return; + } + const double boundary = threshold / edge.a - 0.5; + if (!(std::fabs(boundary) < 1.0e9)) + { + // Beyond any column: the whole row is on one side of this edge. + if ((edge.a > 0.0) == (boundary > 0.0)) + hi = lo - 1; + return; + } + const double margin = 2.0; + if (edge.a > 0.0) + lo = std::max(lo, static_cast(std::floor(boundary - margin))); + else + hi = std::min(hi, static_cast(std::ceil(boundary + margin))); + }; + uint64_t insidePixels = 0u; uint64_t writtenPixels = 0u; + uint64_t testedPixels = 0u; for (int y = raster.firstRow(minY); y <= raster.lastRow(maxY); ++y) { float py = static_cast(y) + 0.5f; - for (int x = minX; x <= maxX; ++x) + int spanX0 = minX; + int spanX1 = maxX; + { + const double pyd = static_cast(py); + const EdgeBound e0 = rowEdge(static_cast(fy1 - fy2), static_cast(fx2 - fx1), pyd); + const EdgeBound e1 = rowEdge(static_cast(fy2 - fy0), static_cast(fx0 - fx2), pyd); + EdgeBound e2; + e2.a = -(e0.a + e1.a); + e2.b = 1.0 - e0.b - e1.b; + e2.err = e0.err + e1.err + 1.0e-6; + clipToEdge(e0, spanX0, spanX1); + clipToEdge(e1, spanX0, spanX1); + clipToEdge(e2, spanX0, spanX1); + } + if (spanX0 > spanX1) + continue; + testedPixels += static_cast(spanX1 - spanX0 + 1); + if (raster.batched && state.prim.tme) + { + // Pixel by pixel, every texel waits on its own interpolation, division and fetch, and the write + // waits on the texel: one long dependency chain per pixel. Running each stage over a chunk of the + // row lets independent pixels overlap. Batched primitives never sample anything their batch's + // writes change, so sampling a chunk before writing it reads the same texels, in the same order. + constexpr int kChunk = 32; + int xs[kChunk]; + float w0s[kChunk]; + float w1s[kChunk]; + float w2s[kChunk]; + uint32_t texels[kChunk]; + TextureAxis axesU[kChunk]; + TextureAxis axesV[kChunk]; + for (int chunkX = spanX0; chunkX <= spanX1; chunkX += kChunk) + { + const int chunkEnd = std::min(spanX1, chunkX + kChunk - 1); + int count = 0; + for (int x = chunkX; x <= chunkEnd; ++x) + { + float px = static_cast(x) + 0.5f; + float w0 = (((fy1 - fy2) * (px - fx2) + (fx2 - fx1) * (py - fy2)) * winding) * invAbsDenom; + float w1 = (((fy2 - fy0) * (px - fx2) + (fx0 - fx2) * (py - fy2)) * winding) * invAbsDenom; + float w2 = 1.0f - w0 - w1; + xs[count] = x; + w0s[count] = w0; + w1s[count] = w1; + w2s[count] = w2; + count += (w0 < -kEdgeEpsilon || w1 < -kEdgeEpsilon || w2 < -kEdgeEpsilon) ? 0 : 1; + } + insidePixels += static_cast(count); + // Texel coordinates exactly as SampleTexture derives them. + const float texWf = static_cast(sampler.texW); + const float texHf = static_cast(sampler.texH); + for (int i = 0; i < count; ++i) + { + const float w0 = w0s[i], w1 = w1s[i], w2 = w2s[i]; + float texUf, texVf; + if (state.prim.fst) + { + const uint16_t iu = static_cast(v0.u * w0 + v1.u * w1 + v2.u * w2); + const uint16_t iv = static_cast(v0.v * w0 + v1.v * w1 + v2.v * w2); + texUf = static_cast(iu) / 16.0f; + texVf = static_cast(iv) / 16.0f; + } + else + { + const float is = v0.s * w0 + v1.s * w1 + v2.s * w2; + const float it = v0.t * w0 + v1.t * w1 + v2.t * w2; + const float iq = v0.q * w0 + v1.q * w1 + v2.q * w2; + const float invQ = 1.0f / fabsQ(iq); + texUf = is * invQ * texWf; + texVf = it * invQ * texHf; + } + axesU[i] = PrepareTextureAxis(texUf, sampler.texW, sampler.wrapU, sampler.minU, sampler.maxU, sampler.linear); + axesV[i] = PrepareTextureAxis(texVf, sampler.texH, sampler.wrapV, sampler.minV, sampler.maxV, sampler.linear); + } + for (int i = 0; i < count; ++i) + { + raster.beginSample(y, xs[i]); + texels[i] = SampleTextureAxes(sampler, axesU[i], axesV[i]); + raster.sampled(y, xs[i]); + } + for (int i = 0; i < count; ++i) + { + const float w0 = w0s[i], w1 = w1s[i], w2 = w2s[i]; + const double z = v0.z * w0 + v1.z * w1 + v2.z * w2; + uint8_t r, g, b, a; + if (state.prim.iip) + { + r = clampU8(static_cast(v0.r * w0 + v1.r * w1 + v2.r * w2)); + g = clampU8(static_cast(v0.g * w0 + v1.g * w1 + v2.g * w2)); + b = clampU8(static_cast(v0.b * w0 + v1.b * w1 + v2.b * w2)); + a = clampU8(static_cast(v0.a * w0 + v1.a * w1 + v2.a * w2)); + } + else + { + r = v2.r; + g = v2.g; + b = v2.b; + a = v2.a; + } + const uint32_t texel = texels[i]; + const TextureCombineResult color = combineTexture(ctx.tex0, r, g, b, a, + static_cast(texel & 0xFF), static_cast((texel >> 8) & 0xFF), + static_cast((texel >> 16) & 0xFF), static_cast((texel >> 24) & 0xFF)); + const uint8_t fog = clampU8(static_cast(v0.fog * w0 + v1.fog * w1 + v2.fog * w2)); + writtenPixels += WritePixel(pipe, xs[i], y, static_cast(z + 0.5), color.r, color.g, color.b, color.a, fog) ? 1u : 0u; + } + } + continue; + } + for (int x = spanX0; x <= spanX1; ++x) { float px = static_cast(x) + 0.5f; @@ -3067,8 +3224,8 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte } if (raster.stats) { - const int rows = raster.lastRow(maxY) - raster.firstRow(minY) + 1; - const uint64_t box = static_cast(rows) * static_cast(maxX - minX + 1); + // Pixels the edge test ran on, after the per-row column bounds. + const uint64_t box = testedPixels; raster.stats->boxPixels += box; raster.stats->insidePixels += insidePixels; raster.stats->writtenPixels += writtenPixels; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 87c186359b..fc62da16a8 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -3274,6 +3274,92 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(4u, true), "a GS thread must leave VRAM and the texture buffer as serially"); }); + tc.Run("GS batched perspective triangles draw the same as serially, including slivers and off-screen vertices", [](TestCase &t) + { + // Batched triangles take the chunked path with per-row column bounds; drawn one by one they take the + // per-pixel path. Thin slivers and vertices far outside the scissor stress the column bounds, + // filtered STQ sampling, shading, fog and depth the chunked stages. + auto render = [](uint32_t threads) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0x2468ACEu; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + for (int i = 0; i < 400; ++i) + { + GSPrimitiveBatch batch{}; + GSDrawState &state = batch.state; + auto &ctx = state.context; + ctx.frame.fbp = 0u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT32; + ctx.zbuf.zbp = 160u; + ctx.zbuf.psm = GS_PSM_Z16S; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.xyoffset.ofx = 2048u << 4u; + ctx.xyoffset.ofy = 2048u << 4u; + ctx.test = (static_cast(1u + next() % 3u) << 17u) | (1ull << 16u); + ctx.alpha = 0x80ull << 32u | 0x44u; + ctx.clamp = next() % 2u; + ctx.tex0.tbp0 = 11000u; + ctx.tex0.tbw = 2u; + ctx.tex0.psm = GS_PSM_CT32; + ctx.tex0.tcc = 1u; + ctx.tex0.tfx = static_cast(next() % 4u); + state.textureWidth = 128u; + state.textureHeight = 128u; + state.linearFilter = (next() % 2u) != 0u; + state.prim.type = GS_PRIM_TRIANGLE; + state.prim.tme = true; + state.prim.fst = (next() % 4u) == 0u; + state.prim.iip = (next() % 2u) != 0u; + state.prim.abe = (next() % 2u) != 0u; + state.prim.fge = (next() % 3u) == 0u; + batch.vertexCount = 3u; + const bool sliver = (next() % 4u) == 0u; + for (int v = 0; v < 3; ++v) + { + GSVertex &vertex = batch.vertices[static_cast(v)]; + // Mostly on screen, sometimes hundreds of pixels outside it. + const float spread = (next() % 5u) == 0u ? 1600.0f : 700.0f; + vertex.x = 2048.0f - 200.0f + static_cast(next() % 1000u) / 1000.0f * spread + static_cast(next() % 16u) / 16.0f; + vertex.y = 2048.0f - 150.0f + static_cast(next() % 1000u) / 1000.0f * spread * 0.7f; + if (sliver && v == 2) + { + vertex.x = batch.vertices[0].x + static_cast(next() % 3u) * 0.37f; + vertex.y = batch.vertices[1].y + 0.5f; + } + vertex.z = static_cast(next() % 0xFFFFu); + vertex.r = static_cast(next()); + vertex.g = static_cast(next()); + vertex.b = static_cast(next()); + vertex.a = static_cast(next()); + vertex.fog = static_cast(next()); + vertex.q = 0.5f + static_cast(next() % 1000u) / 500.0f; + vertex.s = static_cast(next() % 1000u) / 500.0f * vertex.q; + vertex.t = static_cast(next() % 1000u) / 500.0f * vertex.q; + vertex.u = static_cast(next() % (128u * 16u)); + vertex.v = static_cast(next() % (128u * 16u)); + } + gs.Submit(batch); + } + gs.Flush(); + return vram; + }; + + const std::vector serial = render(1u); + t.IsTrue(serial == render(4u), "batched triangles must draw exactly what serial drawing does"); + }); + tc.Run("GS runs of self-sampling RGB24 primitives batched together draw the same in parallel", [](TestCase &t) { // Runs of overlapping T8H/T4HL/T4HH sprites and triangles that sample the top byte of the RGB24 From e694d318ef340c38d2e30a1ba0eb48c05d6adcd2 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:32:58 -0300 Subject: [PATCH 60/91] Test and mark queued GS page spans as word masks instead of bit by bit --- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 24 +++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 83bf86febc..6f316508f8 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -893,18 +893,30 @@ namespace return psm == GS_PSM_CT24 || psm == GS_PSM_Z24; } + // The pages of a span as a mask. Queueing tests and marks several spans of a whole frame per primitive, + // which bit by bit cost the GS thread more than 5% of its time in 3D scenes. + std::bitset<512> spanMask(const PageSpan &span) + { + std::bitset<512> mask; + if (span.count == 0u) + return mask; + if (span.count >= 512u) + return mask.set(); + mask.set(); + mask >>= 512u - span.count; + if (span.first + span.count <= 512u) + return mask << span.first; + return (mask << span.first) | (mask >> (512u - span.first)); + } + void markPages(std::bitset<512> &pages, const PageSpan &span) { - for (uint32_t i = 0; i < span.count; ++i) - pages.set((span.first + i) % 512u); + pages |= spanMask(span); } bool anyPage(const std::bitset<512> &pages, const PageSpan &span) { - for (uint32_t i = 0; i < span.count; ++i) - if (pages.test((span.first + i) % 512u)) - return true; - return false; + return (pages & spanMask(span)).any(); } struct PixelBounds From 352f3fec2da5e585eea450fbf7a68d3596eff09f Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:32:58 -0300 Subject: [PATCH 61/91] Skip queued primitives outside a band before their per-band setup --- ps2xRuntime/include/runtime/gs/gs_cpu_backend.h | 2 ++ ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index a26e6e18b6..413e0ee1b7 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -315,6 +315,8 @@ class GSCpuBackend final : public GSRasterBackend // Indexed textures keep the palette they were queued with: CLUT loads run immediately. std::vector> m_pendingCluts; std::vector m_pendingClutIndex; + // Rows each queued primitive can draw (inclusive, padded), so bands skip the others cheaply. + std::vector m_pendingRows; uint64_t m_pendingClutGeneration = UINT64_MAX; uint64_t m_pendingClutKey = 0; // First queued primitive after the latest CLUT load; earlier samples no longer decide the cached page. diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 6f316508f8..8572ada8d5 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1168,6 +1168,8 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) m_pendingClutIndex.push_back(clutIndex); m_pending.push_back(batch); const PixelBounds bounds = primitiveBounds(batch); + // A row of slack on each side: bands only use this to skip primitives that cannot reach them. + m_pendingRows.push_back(bounds.empty() ? RowRange{0, -1} : RowRange{bounds.y0 - 1, bounds.y1 + 1}); addRowWeights(m_pendingRowWeights, bounds); m_pendingPixels += bounds.area(); if (m_pending.size() >= kMaxPendingPrimitives || m_pendingPixels >= kMaxPendingPixels) @@ -1179,6 +1181,7 @@ void GSCpuBackend::ClearPendingUnlocked() m_pending.clear(); m_pendingCluts.clear(); m_pendingClutIndex.clear(); + m_pendingRows.clear(); m_pendingClutGeneration = UINT64_MAX; m_pendingClutReadIndex = 0u; m_pendingWritePages.reset(); @@ -1300,6 +1303,9 @@ void GSCpuBackend::RasterizeTrackedInParallel() raster.primitive = static_cast(i); const int32_t clutIndex = m_pendingClutIndex[i]; raster.clut = clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr; + // Most primitives miss a given band; skipping them here avoids each one's setup per band. + if (m_pendingRows[i].end < raster.rowBegin || m_pendingRows[i].begin >= raster.rowEnd) + continue; DrawPrimitiveRows(m_pending[i], raster); } }; @@ -1417,6 +1423,9 @@ void GSCpuBackend::RasterizePendingInParallel() raster.primitive = static_cast(i); const int32_t clutIndex = m_pendingClutIndex[i]; raster.clut = clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr; + // Most primitives miss a given band; skipping them here avoids each one's setup per band. + if (m_pendingRows[i].end < raster.rowBegin || m_pendingRows[i].begin >= raster.rowEnd) + continue; DrawPrimitiveRows(m_pending[i], raster); } }; From 9bb909ca44a3be2e4264cc8ade852c5765d2eed6 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:32:58 -0300 Subject: [PATCH 62/91] Record the repeated reads of a filtered texel quad in constant time --- ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h index 33944b2926..7397a14e65 100644 --- a/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h +++ b/ps2xRuntime/include/runtime/gs/gs_texture_page_cache.h @@ -163,9 +163,12 @@ namespace GSMem // What Resolve does for further reads of the page it just resolved: they hit, and are recorded. void ResolveAgain(uint32_t count) noexcept { - if (m_tracking) - for (uint32_t i = 0; i < count; ++i) - NoteRead(m_pageBase); + // Resolve just noted a read of this page, so each further read only becomes the latest one. + if (m_tracking && count != 0u) + { + m_tagTexel += count; + m_track.last = {m_tagPrimitive, m_tagY, m_tagX, m_tagTexel - 1u, true}; + } } private: From d4f993537f34e3afff5c543ded7de32680527706 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:50:58 -0300 Subject: [PATCH 63/91] Draw small self-sampling GS batches as one band so palette loads from VRAM no longer end them --- .../include/runtime/gs/gs_cpu_backend.h | 2 +- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 32 +++++-- ps2xTest/src/ps2_gs_tests.cpp | 92 +++++++++++++++++++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 413e0ee1b7..b2ed8a8a2b 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -220,7 +220,7 @@ class GSCpuBackend final : public GSRasterBackend void QueuePrimitive(const GSPrimitiveBatch &batch); void FlushPendingUnlocked(FlushReason reason = FlushReason::Other); void RasterizePendingInParallel(); - void RasterizeTrackedInParallel(); + void RasterizeTrackedInParallel(bool inlineBand); void ClearPendingUnlocked(); uint32_t RasterParticipants() const; void DrawSprite(const GSPrimitiveBatch &batch, const RasterContext &raster); diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 8572ada8d5..8c76f59e7c 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1216,11 +1216,19 @@ void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) gsStats().flushes[static_cast(reason)].fetch_add(1u, std::memory_order_relaxed); bool parallel = m_pendingPixels >= kMinParallelPixels && RasterParticipants() > 1u; + // A tracked batch too small to wake the workers is still drawn as a band, on this thread: drawn serially + // it would get palette loads queued in between wrong (see LoadClutNow). + bool inlineBand = false; uint32_t livePage = 0u; - if (parallel && m_pendingTracked) + if (m_pendingTracked && m_texturePageCache.MatchesVram(m_vram)) { // Bands read pages in place; the serial cache state is rebuilt afterwards from VRAM. - parallel = m_texturePageCache.MatchesVram(m_vram); + inlineBand = !parallel; + parallel = true; + } + else if (parallel && m_pendingTracked) + { + parallel = false; } else if (parallel && !m_texturePageCache.LivePage(livePage) && m_texturePageCache.CachedPage(livePage)) { @@ -1242,7 +1250,7 @@ void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) GSStatTimer timer(kind == 0 ? kTimeTracked : kind == 1 ? kTimeUntracked : kTimeSerialBatch); if (parallel && m_pendingTracked) { - RasterizeTrackedInParallel(); + RasterizeTrackedInParallel(inlineBand); } else if (parallel) { @@ -1259,7 +1267,7 @@ void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) ClearPendingUnlocked(); } -void GSCpuBackend::RasterizeTrackedInParallel() +void GSCpuBackend::RasterizeTrackedInParallel(bool inlineBand) { // Every primitive samples only top bytes of the pages the batch draws into with RGB24/Z24, so every // pixel comes out the same drawn in any order. What a serial draw also leaves behind is the texture @@ -1272,9 +1280,9 @@ void GSCpuBackend::RasterizeTrackedInParallel() uint32_t cachedBefore = UINT32_MAX; m_texturePageCache.CachedPage(cachedBefore); - if (!m_workers || m_workers->participants() != RasterParticipants()) + if (!inlineBand && (!m_workers || m_workers->participants() != RasterParticipants())) m_workers = std::make_unique(RasterParticipants()); - const uint32_t bands = m_workers->participants() * kBandsPerWorker; + const uint32_t bands = inlineBand ? 1u : m_workers->participants() * kBandsPerWorker; std::vector rows; BalanceRows(m_pendingRowWeights, bands, rows); const std::bitset<512> kept = m_pendingTexturePages & m_pendingWritePages; @@ -1309,7 +1317,10 @@ void GSCpuBackend::RasterizeTrackedInParallel() DrawPrimitiveRows(m_pending[i], raster); } }; - m_workers->runBands(bands, band); + if (inlineBand) + band(0u); + else + m_workers->runBands(bands, band); for (uint32_t i = 0; i < bandStats.size(); ++i) { AddRasterStats(bandStats[i]); @@ -1892,10 +1903,11 @@ void GSCpuBackend::LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclu } // LoadClutUnlocked reads VRAM (moving the texture page cache) only for 16- and 32-bit palettes. A tracked - // batch accounts for that read only when it is drawn in parallel, which it will be if it is big enough - // already and the cache stays a faithful copy; otherwise it is drawn before the load. + // batch accounts for that read when it is drawn by bands (in parallel, or as one band on this thread when + // small), which it will be while the cache stays a faithful copy; otherwise it is drawn before the load. + // Drawing small tracked batches before every load ended them after about 4 primitives in 3D scenes. const bool readsVram = tex0.cpsm == GS_PSM_CT16 || tex0.cpsm == GS_PSM_CT16S || tex0.cpsm == GS_PSM_CT32 || tex0.cpsm == GS_PSM_CT24; - if (readsVram && m_pendingTracked && (m_pendingPixels < kMinParallelPixels || !m_texturePageCache.MatchesVram(m_vram))) + if (readsVram && m_pendingTracked && !m_texturePageCache.MatchesVram(m_vram)) FlushPendingUnlocked(FlushReason::Clut); LoadClutUnlocked(tex0, texclut); if (readsVram) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index fc62da16a8..84f8686a78 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -3274,6 +3274,98 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(4u, true), "a GS thread must leave VRAM and the texture buffer as serially"); }); + tc.Run("GS small self-sampling RGB24 batches with palette loads between them draw the same as serially", [](TestCase &t) + { + // Runs too small for the workers are drawn as one band on the queueing thread, so a palette load + // from VRAM no longer ends them. Each round ends with an RGB24 sprite drawn alone that samples the + // page the run ended on, which shows what the texture buffer holds. + auto render = [](uint32_t threads) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0x5EED1234u; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + auto loadClut = [&](uint32_t psm) + { + GSTex0Reg clutTex{}; + clutTex.psm = static_cast(psm); + clutTex.cpsm = GS_PSM_CT32; + clutTex.cbp = 13000u + (next() % 4u) * 64u; + clutTex.cld = 1u; + gs.LoadClut(clutTex, GSTexClutReg{}); + }; + auto sprite = [&](uint32_t texturePsm, uint32_t x0, uint32_t y0, uint32_t w, uint32_t h, uint32_t u0, uint32_t v0) + { + GSPrimitiveBatch batch{}; + GSDrawState &state = batch.state; + auto &ctx = state.context; + ctx.frame.fbp = 0u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT24; + ctx.zbuf.zbp = 160u; + ctx.zbuf.psm = GS_PSM_Z16S; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.test = 1ull << 17u; + ctx.tex0.psm = static_cast(texturePsm); + ctx.tex0.tbp0 = 0u; + ctx.tex0.tbw = 10u; + ctx.tex0.tcc = 1u; + ctx.tex0.tfx = 1u; + ctx.tex0.cpsm = GS_PSM_CT32; + state.textureWidth = 1024u; + state.textureHeight = 512u; + state.prim.type = GS_PRIM_SPRITE; + state.prim.tme = true; + state.prim.fst = true; + state.prim.abe = (next() % 2u) != 0u; + ctx.alpha = 0x80ull << 32u | 0x44u; + batch.vertexCount = 2u; + batch.vertices[0].x = static_cast(x0); + batch.vertices[0].y = static_cast(y0); + batch.vertices[1].x = static_cast(x0 + w); + batch.vertices[1].y = static_cast(y0 + h); + batch.vertices[0].u = static_cast(u0 * 16u); + batch.vertices[0].v = static_cast(v0 * 16u); + batch.vertices[1].u = static_cast((u0 + w) * 16u); + batch.vertices[1].v = static_cast((v0 + h) * 16u); + batch.vertices[1].r = batch.vertices[1].g = batch.vertices[1].b = batch.vertices[1].a = 0x80u; + gs.Submit(batch); + }; + loadClut(GS_PSM_T8H); + for (int round = 0; round < 64; ++round) + { + const uint32_t topFormats[3] = {GS_PSM_T8H, GS_PSM_T4HL, GS_PSM_T4HH}; + const uint32_t run = 1u + next() % 5u; + uint32_t lastX = 0u, lastY = 0u; + for (uint32_t k = 0; k < run; ++k) + { + const uint32_t psm = topFormats[next() % 3u]; + if ((next() % 2u) == 0u) + loadClut(psm); + lastX = next() % 600u; + lastY = next() % 420u; + sprite(psm, lastX, lastY, 8u + next() % 24u, 8u + next() % 20u, next() % 600u, next() % 420u); + } + // Drawn alone: an RGB24 sample of its own frame reads the words through the texture buffer. + sprite(GS_PSM_CT24, next() % 600u, next() % 420u, 16u, 8u, lastX, lastY); + } + gs.Flush(); + return vram; + }; + + const std::vector serial = render(1u); + t.IsTrue(serial == render(4u), "small batches across palette loads must leave VRAM as serial drawing does"); + }); + tc.Run("GS batched perspective triangles draw the same as serially, including slivers and off-screen vertices", [](TestCase &t) { // Batched triangles take the chunked path with per-row column bounds; drawn one by one they take the From 1368cf0ffea329ab95ca666bbda4eeb1b1bbf4c4 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:54:01 -0300 Subject: [PATCH 64/91] Let the GS thread queue the next batch while the raster workers draw the current one --- .../include/runtime/gs/gs_cpu_backend.h | 34 ++- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 247 +++++++++++------- 2 files changed, 182 insertions(+), 99 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index b2ed8a8a2b..c407dbb9dd 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -4,9 +4,11 @@ #include "runtime/gs/gs_texture_page_cache.h" #include +#include #include #include #include +#include #include #include #include @@ -218,9 +220,16 @@ class GSCpuBackend final : public GSRasterBackend void DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster); void PrepareTexturePageCache(const GSDrawState &state); void QueuePrimitive(const GSPrimitiveBatch &batch); + // Rasterizes everything queued and waits for it, so VRAM and the texture cache are current. void FlushPendingUnlocked(FlushReason reason = FlushReason::Other); - void RasterizePendingInParallel(); - void RasterizeTrackedInParallel(bool inlineBand); + // Hands the queued batch to the raster workers and returns while they draw it, leaving the caller free + // to queue the next batch; anything that touches VRAM or the texture cache waits first. + void FlushPendingAsyncUnlocked(FlushReason reason); + void WaitInFlightUnlocked(); + void LaunchInFlight(bool tracked, bool inlineBand); + void RasterizeInFlightBand(uint32_t index); + void FinishTracked(); + void FinishUntracked(); void ClearPendingUnlocked(); uint32_t RasterParticipants() const; void DrawSprite(const GSPrimitiveBatch &batch, const RasterContext &raster); @@ -362,4 +371,25 @@ class GSCpuBackend final : public GSRasterBackend std::vector> m_undoLogs; // One texture page cache per band, kept between batches: each holds an 8 KB page copy. std::vector m_bandCaches; + // The batch the workers are drawing, moved out of the pending state so the next one can queue. + struct InFlight + { + bool active = false; + bool tracked = false; + // Already drawn as one band on the launching thread: nothing to wait for. + bool inlineBand = false; + std::vector prims; + std::vector> cluts; + std::vector clutIndex; + std::vector primRows; + size_t clutReadIndex = 0; + uint32_t cachedBefore = UINT32_MAX; + std::vector bands; + std::bitset<512> kept; + std::vector traces; + std::vector bandStats; + std::atomic next{0u}; + std::function job; + }; + InFlight m_inflight; }; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 8c76f59e7c..fc916732fb 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -792,6 +792,27 @@ class GSCpuBackend::RasterWorkers uint32_t participants() const { return m_participants; } + // Starts job on the worker threads only; the caller joins it with finish(), which also runs it on the + // calling thread so the caller helps with whatever is left instead of waiting. + void start(const std::function *job) + { + { + std::lock_guard lock(m_mutex); + m_job = job; + m_remaining = m_participants - 1u; + ++m_generation; + } + m_start.notify_all(); + } + + void finish() + { + (*m_job)(0u); + std::unique_lock lock(m_mutex); + m_done.wait(lock, [this]() { return m_remaining == 0u; }); + m_job = nullptr; + } + // Runs band(i) for i in [0, bands), each once, with every participant taking the next band as it // finishes one, so a band that was estimated low does not leave the others waiting. void runBands(uint32_t bands, const std::function &band) @@ -1012,6 +1033,9 @@ void GSCpuBackend::BalanceRows(const RowWeights &weights, uint32_t participants, GSCpuBackend::~GSCpuBackend() { SetAsync(false); + // Workers must not outlive the batch they draw, nor the batch the backend that owns it. + std::lock_guard lock(m_mutex); + WaitInFlightUnlocked(); } void GSCpuBackend::SetRasterThreads(uint32_t participants) @@ -1114,7 +1138,7 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) else if (tracked && !canTrack) reason = FlushReason::TrackMismatch; if (reason != FlushReason::Count) - FlushPendingUnlocked(reason); + FlushPendingAsyncUnlocked(reason); } if (m_pending.empty()) { @@ -1173,7 +1197,7 @@ void GSCpuBackend::QueuePrimitive(const GSPrimitiveBatch &batch) addRowWeights(m_pendingRowWeights, bounds); m_pendingPixels += bounds.area(); if (m_pending.size() >= kMaxPendingPrimitives || m_pendingPixels >= kMaxPendingPixels) - FlushPendingUnlocked(FlushReason::Full); + FlushPendingAsyncUnlocked(FlushReason::Full); } void GSCpuBackend::ClearPendingUnlocked() @@ -1209,6 +1233,37 @@ void GSCpuBackend::AddRasterStats(const RasterStats &stats) void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) { + FlushPendingAsyncUnlocked(reason); + WaitInFlightUnlocked(); +} + +void GSCpuBackend::WaitInFlightUnlocked() +{ + if (!m_inflight.active) + return; + InFlight &f = m_inflight; + if (!f.inlineBand) + { + GSStatTimer timer(f.tracked ? kTimeTracked : kTimeUntracked); + m_workers->finish(); + } + f.active = false; + for (uint32_t i = 0; i < f.bandStats.size(); ++i) + { + AddRasterStats(f.bandStats[i]); + if (f.tracked) + gsStats().undoEntries.fetch_add(m_undoLogs[i].size(), std::memory_order_relaxed); + } + if (f.tracked) + FinishTracked(); + else + FinishUntracked(); +} + +void GSCpuBackend::FlushPendingAsyncUnlocked(FlushReason reason) +{ + // One batch in flight at a time: the next one may sample what this one draws. + WaitInFlightUnlocked(); if (m_pending.empty()) return; const bool statsEnabled = gsStatsEnabled(); @@ -1248,13 +1303,9 @@ void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) stats.batchPixels[kind].fetch_add(m_pendingPixels, std::memory_order_relaxed); } GSStatTimer timer(kind == 0 ? kTimeTracked : kind == 1 ? kTimeUntracked : kTimeSerialBatch); - if (parallel && m_pendingTracked) - { - RasterizeTrackedInParallel(inlineBand); - } - else if (parallel) + if (parallel) { - RasterizePendingInParallel(); + LaunchInFlight(m_pendingTracked, inlineBand); } else { @@ -1267,65 +1318,104 @@ void GSCpuBackend::FlushPendingUnlocked(FlushReason reason) ClearPendingUnlocked(); } -void GSCpuBackend::RasterizeTrackedInParallel(bool inlineBand) +void GSCpuBackend::LaunchInFlight(bool tracked, bool inlineBand) { - // Every primitive samples only top bytes of the pages the batch draws into with RGB24/Z24, so every - // pixel comes out the same drawn in any order. What a serial draw also leaves behind is the texture - // cache: it copies a drawn-over page when loading it, so the page it ends on can hold words as they - // were mid-batch. + InFlight &f = m_inflight; + // The page cached before the batch keeps whatever a sequential draw would leave in it: a page about to + // be drawn over is snapshotted first, exactly as PrepareTexturePageCache does for the first writer. uint32_t livePage = 0u; if (m_texturePageCache.LivePage(livePage) && m_pendingWritePages.test((livePage / GSMem::TexturePageCache::kPageSize) % 512u)) m_texturePageCache.Snapshot(m_vram); - uint32_t cachedBefore = UINT32_MAX; - m_texturePageCache.CachedPage(cachedBefore); + f.cachedBefore = UINT32_MAX; + m_texturePageCache.CachedPage(f.cachedBefore); if (!inlineBand && (!m_workers || m_workers->participants() != RasterParticipants())) m_workers = std::make_unique(RasterParticipants()); const uint32_t bands = inlineBand ? 1u : m_workers->participants() * kBandsPerWorker; - std::vector rows; - BalanceRows(m_pendingRowWeights, bands, rows); - const std::bitset<512> kept = m_pendingTexturePages & m_pendingWritePages; - std::vector &caches = m_bandCaches; - caches.resize(bands); - std::vector bandStats(gsStatsEnabled() ? bands : 0u); - m_undoLogs.resize(bands); + BalanceRows(m_pendingRowWeights, bands, f.bands); + f.tracked = tracked; + f.kept = m_pendingTexturePages & m_pendingWritePages; + // Grown only: one-band batches alternate with full ones, and each cache holds an 8 KB page. + if (m_bandCaches.size() < bands) + m_bandCaches.resize(bands); for (uint32_t i = 0; i < bands; ++i) + m_bandCaches[i].ResetForBand(tracked); + if (tracked) { - caches[i].ResetForBand(true); - m_undoLogs[i].clear(); + if (m_undoLogs.size() < bands) + m_undoLogs.resize(bands); + for (std::vector &log : m_undoLogs) + log.clear(); } - const std::function band = [&](uint32_t index) + else { - RasterContext raster{}; - raster.rowBegin = rows[index].begin; - raster.rowEnd = rows[index].end; - raster.cache = &caches[index]; - raster.trackReads = true; - raster.batched = true; - raster.undo = &m_undoLogs[index]; - raster.undoPages = &kept; - raster.stats = bandStats.empty() ? nullptr : &bandStats[index]; - for (size_t i = 0; i < m_pending.size(); ++i) - { - raster.primitive = static_cast(i); - const int32_t clutIndex = m_pendingClutIndex[i]; - raster.clut = clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr; - // Most primitives miss a given band; skipping them here avoids each one's setup per band. - if (m_pendingRows[i].end < raster.rowBegin || m_pendingRows[i].begin >= raster.rowEnd) - continue; - DrawPrimitiveRows(m_pending[i], raster); - } + f.traces.assign(bands, RasterTrace{}); + } + f.bandStats.assign(gsStatsEnabled() ? bands : 0u, RasterStats{}); + // The batch leaves the pending state, which the caller refills while the workers draw this one. + f.prims.swap(m_pending); + f.cluts.swap(m_pendingCluts); + f.clutIndex.swap(m_pendingClutIndex); + f.primRows.swap(m_pendingRows); + f.clutReadIndex = m_pendingClutReadIndex; + f.next.store(0u, std::memory_order_relaxed); + f.job = [this, bands](uint32_t) + { + for (uint32_t i = m_inflight.next.fetch_add(1u, std::memory_order_relaxed); i < bands; + i = m_inflight.next.fetch_add(1u, std::memory_order_relaxed)) + RasterizeInFlightBand(i); }; + f.active = true; + f.inlineBand = inlineBand; if (inlineBand) - band(0u); + RasterizeInFlightBand(0u); + else + m_workers->start(&f.job); +} + +void GSCpuBackend::RasterizeInFlightBand(uint32_t index) +{ + const InFlight &f = m_inflight; + RasterContext raster{}; + raster.rowBegin = f.bands[index].begin; + raster.rowEnd = f.bands[index].end; + raster.cache = &m_bandCaches[index]; + raster.batched = true; + if (f.tracked) + { + raster.trackReads = true; + raster.undo = &m_undoLogs[index]; + raster.undoPages = &f.kept; + } else - m_workers->runBands(bands, band); - for (uint32_t i = 0; i < bandStats.size(); ++i) { - AddRasterStats(bandStats[i]); - gsStats().undoEntries.fetch_add(m_undoLogs[i].size(), std::memory_order_relaxed); + raster.trace = const_cast(&f.traces[index]); + } + raster.stats = f.bandStats.empty() ? nullptr : const_cast(&f.bandStats[index]); + for (size_t i = 0; i < f.prims.size(); ++i) + { + // Most primitives miss a given band; skipping them here avoids each one's setup per band. + if (f.primRows[i].end < raster.rowBegin || f.primRows[i].begin >= raster.rowEnd) + continue; + raster.primitive = static_cast(i); + const int32_t clutIndex = f.clutIndex[i]; + raster.clut = clutIndex >= 0 ? f.cluts[static_cast(clutIndex)].data() : nullptr; + DrawPrimitiveRows(f.prims[i], raster); } +} + +void GSCpuBackend::FinishTracked() +{ + // Every primitive samples only top bytes of the pages the batch draws into with RGB24/Z24, so every + // pixel comes out the same drawn in any order. What a serial draw also leaves behind is the texture + // cache: it copies a drawn-over page when loading it, so the page it ends on can hold words as they + // were mid-batch. + const InFlight &f = m_inflight; + const std::vector &caches = m_bandCaches; + const uint32_t bands = static_cast(f.bands.size()); + const std::bitset<512> &kept = f.kept; + const uint32_t cachedBefore = f.cachedBefore; // The page of the last texel read in serial order is where the cache ends. GSMem::TexturePageCache::ReadPosition lastRead{}; @@ -1342,8 +1432,8 @@ void GSCpuBackend::RasterizeTrackedInParallel(bool inlineBand) // does not write); it ran before the batch was drawn and left the cache where it ends if nothing // after it samples. GSMem::TexturePageCache::ReadPosition clutRead{}; - if (m_pendingClutReadIndex > 0u) - clutRead = {static_cast(m_pendingClutReadIndex), std::numeric_limits::min(), std::numeric_limits::min(), 0u, true}; + if (f.clutReadIndex > 0u) + clutRead = {static_cast(f.clutReadIndex), std::numeric_limits::min(), std::numeric_limits::min(), 0u, true}; if (!lastRead.after(clutRead)) return; if (!kept.test(lastPage / GSMem::TexturePageCache::kPageSize)) @@ -1400,54 +1490,14 @@ void GSCpuBackend::RasterizeTrackedInParallel(bool inlineBand) m_texturePageCache.SetCopy(m_pageRebuildScratch.data(), lastPage); } -void GSCpuBackend::RasterizePendingInParallel() +void GSCpuBackend::FinishUntracked() { - // The page cached before the batch keeps whatever a sequential draw would leave in it: a page about to - // be drawn over is snapshotted first, exactly as PrepareTexturePageCache does for the first writer. - uint32_t livePage = 0u; - if (m_texturePageCache.LivePage(livePage) && - m_pendingWritePages.test((livePage / GSMem::TexturePageCache::kPageSize) % 512u)) - m_texturePageCache.Snapshot(m_vram); - - if (!m_workers || m_workers->participants() != RasterParticipants()) - m_workers = std::make_unique(RasterParticipants()); - const uint32_t bands = m_workers->participants() * kBandsPerWorker; - std::vector rows; - BalanceRows(m_pendingRowWeights, bands, rows); - std::vector &caches = m_bandCaches; - caches.resize(bands); - for (GSMem::TexturePageCache &cache : caches) - cache.ResetForBand(false); - std::vector traces(bands); - std::vector bandStats(gsStatsEnabled() ? bands : 0u); - const std::function band = [&](uint32_t index) - { - RasterContext raster{}; - raster.rowBegin = rows[index].begin; - raster.rowEnd = rows[index].end; - raster.cache = &caches[index]; - raster.trace = &traces[index]; - raster.batched = true; - raster.stats = bandStats.empty() ? nullptr : &bandStats[index]; - for (size_t i = 0; i < m_pending.size(); ++i) - { - raster.primitive = static_cast(i); - const int32_t clutIndex = m_pendingClutIndex[i]; - raster.clut = clutIndex >= 0 ? m_pendingCluts[static_cast(clutIndex)].data() : nullptr; - // Most primitives miss a given band; skipping them here avoids each one's setup per band. - if (m_pendingRows[i].end < raster.rowBegin || m_pendingRows[i].begin >= raster.rowEnd) - continue; - DrawPrimitiveRows(m_pending[i], raster); - } - }; - m_workers->runBands(bands, band); - for (const RasterStats &stats : bandStats) - AddRasterStats(stats); - // Sequentially, the cache would end on the page of the last texel read: the last sampled pixel in // primitive then raster order, which lies on exactly one band. + const InFlight &f = m_inflight; + const std::vector &traces = f.traces; int best = -1; - for (uint32_t i = 0; i < bands; ++i) + for (uint32_t i = 0; i < traces.size(); ++i) { const RasterTrace &trace = traces[i]; if (!trace.sampled) @@ -1463,8 +1513,8 @@ void GSCpuBackend::RasterizePendingInParallel() best = static_cast(i); } uint32_t lastPage = 0u; - if (best >= 0 && traces[static_cast(best)].primitive >= m_pendingClutReadIndex && - caches[static_cast(best)].CachedPage(lastPage)) + if (best >= 0 && traces[static_cast(best)].primitive >= f.clutReadIndex && + m_bandCaches[static_cast(best)].CachedPage(lastPage)) m_texturePageCache.SetLivePage(lastPage); } @@ -1812,6 +1862,7 @@ void GSCpuBackend::InitializeNow(uint8_t *vram, uint32_t vramSize) std::lock_guard lock(m_mutex); // A new VRAM binding makes queued primitives meaningless; nothing may still refer to the old memory. + WaitInFlightUnlocked(); ClearPendingUnlocked(); m_vram = vram; m_vramSize = vramSize; @@ -1860,6 +1911,8 @@ void GSCpuBackend::SubmitNow(const GSPrimitiveBatch &batch) void GSCpuBackend::LoadClutNow(const GSTex0Reg &tex0, const GSTexClutReg &texclut) { std::lock_guard lock(m_mutex); + // The load reads VRAM through the texture cache, which a batch in flight still draws and settles. + WaitInFlightUnlocked(); if (!m_pending.empty()) { // Queued primitives captured their palettes, so the load can run now unless it reads memory they From 303117ad7adbee4074064941f9de4d742702e299 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:58:51 -0300 Subject: [PATCH 65/91] Clear GS framebuffers on the raster threads and hand out the heaviest bands first --- .../include/runtime/gs/gs_cpu_backend.h | 3 + ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 86 ++++++++++++------- ps2xTest/src/ps2_gs_tests.cpp | 78 +++++++++++++++++ 3 files changed, 137 insertions(+), 30 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index c407dbb9dd..8bf7b18ebd 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -215,6 +215,8 @@ class GSCpuBackend final : public GSRasterBackend int end = 0; }; static void BalanceRows(const RowWeights &weights, uint32_t participants, std::vector &ranges); + // Band indices in the order participants should take them. + static void OrderBands(const RowWeights &weights, const std::vector &ranges, std::vector &order); void DrawPrimitive(const GSPrimitiveBatch &batch, const uint32_t *clut = nullptr); void DrawPrimitiveRows(const GSPrimitiveBatch &batch, const RasterContext &raster); @@ -385,6 +387,7 @@ class GSCpuBackend final : public GSRasterBackend size_t clutReadIndex = 0; uint32_t cachedBefore = UINT32_MAX; std::vector bands; + std::vector order; std::bitset<512> kept; std::vector traces; std::vector bandStats; diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index fc916732fb..468c79ecba 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1030,6 +1030,24 @@ void GSCpuBackend::BalanceRows(const RowWeights &weights, uint32_t participants, ranges[band] = {begin, std::numeric_limits::max()}; } +void GSCpuBackend::OrderBands(const RowWeights &weights, const std::vector &ranges, std::vector &order) +{ + // Heaviest first: a heavy band taken last keeps every other participant waiting for it at the end of + // the batch. + std::vector load(ranges.size(), 0u); + for (size_t i = 0; i < ranges.size(); ++i) + { + const int64_t begin = std::max(ranges[i].begin, 0); + const int64_t end = std::min(ranges[i].end, static_cast(kRowGroups) * 8); + for (int64_t group = begin / 8; group < (end + 7) / 8; ++group) + load[i] += weights[static_cast(group)]; + } + order.resize(ranges.size()); + for (uint32_t i = 0; i < order.size(); ++i) + order[i] = i; + std::stable_sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) { return load[a] > load[b]; }); +} + GSCpuBackend::~GSCpuBackend() { SetAsync(false); @@ -1334,6 +1352,7 @@ void GSCpuBackend::LaunchInFlight(bool tracked, bool inlineBand) m_workers = std::make_unique(RasterParticipants()); const uint32_t bands = inlineBand ? 1u : m_workers->participants() * kBandsPerWorker; BalanceRows(m_pendingRowWeights, bands, f.bands); + OrderBands(m_pendingRowWeights, f.bands, f.order); f.tracked = tracked; f.kept = m_pendingTexturePages & m_pendingWritePages; // Grown only: one-band batches alternate with full ones, and each cache holds an 8 KB page. @@ -1364,7 +1383,7 @@ void GSCpuBackend::LaunchInFlight(bool tracked, bool inlineBand) { for (uint32_t i = m_inflight.next.fetch_add(1u, std::memory_order_relaxed); i < bands; i = m_inflight.next.fetch_add(1u, std::memory_order_relaxed)) - RasterizeInFlightBand(i); + RasterizeInFlightBand(m_inflight.order[i]); }; f.active = true; f.inlineBand = inlineBand; @@ -3691,44 +3710,51 @@ bool GSCpuBackend::ClearFramebufferNow(const GSContext &context, uint32_t rgba) const uint32_t fbp = GSInternal::framePageBaseToBlock(context.frame.fbp); const uint32_t fbw = std::max(context.frame.fbw, 1u); - if (context.frame.psm == GS_PSM_CT32 || context.frame.psm == GS_PSM_CT24) + if (context.frame.psm != GS_PSM_CT32 && context.frame.psm != GS_PSM_CT24 && context.frame.psm != GS_PSM_CT16 && + context.frame.psm != GS_PSM_CT16S) + return false; + // WriteVramUnlocked snapshots the texture cache's live page before the first write to it. A row writes + // exactly the pages of its span and the live page keeps its bytes until then, so checking every row up + // front is the same, and leaves the rows free to be written directly and in parallel. + for (uint32_t y = y0; y <= y1; ++y) { - const uint32_t source = static_cast(r) | - (static_cast(g) << 8u) | - (static_cast(b) << 16u) | - (static_cast(a) << 24u); - for (uint32_t y = y0; y <= y1; ++y) - for (uint32_t x = x0; x <= x1; ++x) - { - uint32_t pixel = source; - if (context.frame.fbmsk != 0u) - { - const uint32_t old = ReadVramUnlocked(context.frame.psm, fbp, fbw, x, y); - pixel = (pixel & ~context.frame.fbmsk) | (old & context.frame.fbmsk); - } - WriteVramUnlocked(context.frame.psm, fbp, fbw, x, y, pixel); - } - return true; + uint32_t livePage = 0u; + if (!m_texturePageCache.LivePage(livePage)) + break; + if (spanHasPage(pageSpan(fbp, fbw, context.frame.psm, x0, y, x1, y), livePage / GSMem::TexturePageCache::kPageSize)) + m_texturePageCache.Snapshot(m_vram); } - - if (context.frame.psm == GS_PSM_CT16 || context.frame.psm == GS_PSM_CT16S) + const WriteVramFunc write = m_writeVramFuncs[context.frame.psm & 0x3Fu]; + const ReadVramFunc read = m_readVramFuncs[context.frame.psm & 0x3Fu]; + const bool sixteen = context.frame.psm == GS_PSM_CT16 || context.frame.psm == GS_PSM_CT16S; + const uint32_t mask = sixteen ? static_cast(context.frame.fbmsk) : context.frame.fbmsk; + const uint32_t source = sixteen ? encodeFramePixelPSMCT16(r, g, b, a) + : static_cast(r) | (static_cast(g) << 8u) | + (static_cast(b) << 16u) | (static_cast(a) << 24u); + auto clearRows = [&](uint32_t first, uint32_t last) { - const uint16_t source = encodeFramePixelPSMCT16(r, g, b, a); - const uint16_t mask = static_cast(context.frame.fbmsk); - for (uint32_t y = y0; y <= y1; ++y) + for (uint32_t y = first; y <= last; ++y) for (uint32_t x = x0; x <= x1; ++x) { - uint16_t pixel = source; + uint32_t pixel = source; if (mask != 0u) - { - const uint16_t old = static_cast(ReadVramUnlocked(context.frame.psm, fbp, fbw, x, y)); - pixel = static_cast((pixel & ~mask) | (old & mask)); - } - WriteVramUnlocked(context.frame.psm, fbp, fbw, x, y, pixel); + pixel = (pixel & ~mask) | (read(m_vram, fbp, fbw, x, y) & mask); + write(m_vram, fbp, fbw, x, y, sixteen ? (pixel & 0xFFFFu) : pixel); } + }; + const uint64_t pixels = static_cast(y1 - y0 + 1u) * static_cast(x1 - x0 + 1u); + if (pixels < kMinParallelPixels || RasterParticipants() <= 1u) + { + clearRows(y0, y1); return true; } - return false; + // A pixel's bytes belong to its row alone, so rows clear independently. + if (!m_workers || m_workers->participants() != RasterParticipants()) + m_workers = std::make_unique(RasterParticipants()); + const uint32_t chunks = (y1 - y0) / 8u + 1u; + m_workers->runBands(chunks, [&](uint32_t chunk) + { clearRows(y0 + chunk * 8u, std::min(y1, y0 + chunk * 8u + 7u)); }); + return true; } bool GSCpuBackend::CopyFrameToHostRgba(const GSFrameReg &frame, diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 84f8686a78..8cd886a3d4 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -3366,6 +3366,84 @@ void register_ps2_gs_tests() t.IsTrue(serial == render(4u), "small batches across palette loads must leave VRAM as serial drawing does"); }); + tc.Run("GS framebuffer clears split across raster threads match a serial clear", [](TestCase &t) + { + // Full-frame and partial clears with and without a frame mask, in 32-, 24- and 16-bit formats, + // between textured sprites whose sampling leaves a page in the texture buffer that a clear + // overwrites; the next sprite samples it again. + auto render = [](uint32_t threads) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + uint32_t seed = 0xC1EA12u; + auto next = [&seed]() + { + seed = seed * 1664525u + 1013904223u; + return seed >> 8u; + }; + for (uint8_t &byte : vram) + byte = static_cast(next()); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetRasterThreads(threads); + const uint8_t formats[4] = {GS_PSM_CT32, GS_PSM_CT24, GS_PSM_CT16, GS_PSM_CT16S}; + for (int round = 0; round < 24; ++round) + { + GSPrimitiveBatch batch{}; + GSDrawState &state = batch.state; + auto &ctx = state.context; + ctx.frame.fbp = 200u; + ctx.frame.fbw = 10u; + ctx.frame.psm = GS_PSM_CT32; + ctx.zbuf.zbp = 400u; + ctx.zbuf.psm = GS_PSM_Z32; + ctx.scissor = {0u, 639u, 0u, 447u}; + ctx.test = 1ull << 17u; + // Samples the frame the clears below write, so the texture buffer holds one of its pages. + ctx.tex0.tbp0 = 0u; + ctx.tex0.tbw = 10u; + ctx.tex0.psm = GS_PSM_CT32; + ctx.tex0.tcc = 1u; + ctx.tex0.tfx = 1u; + state.textureWidth = 1024u; + state.textureHeight = 512u; + state.prim.type = GS_PRIM_SPRITE; + state.prim.tme = true; + state.prim.fst = true; + batch.vertexCount = 2u; + const uint32_t x0 = next() % 500u, y0 = next() % 380u; + batch.vertices[0].x = static_cast(x0); + batch.vertices[0].y = static_cast(y0); + batch.vertices[1].x = static_cast(x0 + 64u); + batch.vertices[1].y = static_cast(y0 + 32u); + const uint32_t u0 = next() % 500u, v0 = next() % 380u; + batch.vertices[0].u = static_cast(u0 * 16u); + batch.vertices[0].v = static_cast(v0 * 16u); + batch.vertices[1].u = static_cast((u0 + 64u) * 16u); + batch.vertices[1].v = static_cast((v0 + 32u) * 16u); + gs.Submit(batch); + + GSContext clear{}; + clear.frame.fbp = 0u; + clear.frame.fbw = 10u; + clear.frame.psm = formats[next() % 4u]; + clear.frame.fbmsk = (next() % 2u) != 0u ? (next() & 0xFF00FF0Fu) : 0u; + clear.fba = next() % 2u; + const bool full = (next() % 2u) != 0u; + const uint32_t cx0 = full ? 0u : next() % 300u; + const uint32_t cy0 = full ? 0u : next() % 200u; + clear.scissor = {static_cast(cx0), static_cast(full ? 639u : cx0 + 40u + next() % 300u), + static_cast(cy0), static_cast(full ? 447u : cy0 + 30u + next() % 240u)}; + gs.ClearFramebuffer(clear, next()); + } + gs.Flush(); + return vram; + }; + + const std::vector serial = render(1u); + t.IsTrue(serial == render(4u), "clears drawn by several threads must leave VRAM as a serial clear does"); + }); + tc.Run("GS batched perspective triangles draw the same as serially, including slivers and off-screen vertices", [](TestCase &t) { // Batched triangles take the chunked path with per-row column bounds; drawn one by one they take the From 6149ea762673fa02367f800e424e3b54eb046432 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:08:27 -0300 Subject: [PATCH 66/91] Implement sceGsSetDefClear so offscreen targets get cleared before games draw into them --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 41 ++++++++++++++++++++-- ps2xTest/src/ps2_gs_tests.cpp | 45 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index ab7cdf84f0..c361e8e781 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -889,10 +889,45 @@ namespace ps2_stubs void sceGsSetDefClear(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - (void)rdram; - (void)ctx; (void)runtime; - setReturnS32(ctx, 0); + // Mirrors libgraph: an A+D list (no GIFtag) of TEST(ZTST=ALWAYS), PRIM sprite, RGBAQ, two XYZ2 + // corners and TEST(ztest). Games send it to wipe offscreen targets; without it they keep stale VRAM. + // Args: a0 clear, a1 ztest, a2 x, a3 y, t0 w, t1 h, t2 r, t3 g; b, a and z sit at sp+0, sp+8, sp+16. + const uint32_t clearAddr = getRegU32(ctx, 4); + const int16_t ztest = static_cast(getRegU32(ctx, 5)); + const int16_t x = static_cast(getRegU32(ctx, 6)); + const int16_t y = static_cast(getRegU32(ctx, 7)); + const int16_t w = static_cast(getRegU32(ctx, 8)); + const int16_t h = static_cast(getRegU32(ctx, 9)); + const uint64_t r = getRegU32(ctx, 10) & 0xFFu; + const uint64_t g = getRegU32(ctx, 11) & 0xFFu; + const uint64_t b = readStackU32(rdram, ctx, 0) & 0xFFu; + const uint64_t a = readStackU32(rdram, ctx, 8) & 0xFFu; + const uint64_t z = readStackU32(rdram, ctx, 16); + + auto xyz = [z](int32_t px, int32_t py) + { + return (static_cast(static_cast(px << 4)) & 0xFFFFu) | + ((static_cast(static_cast(py << 4)) & 0xFFFFu) << 16) | (z << 32); + }; + + GsClearMem clear{}; + clear.testa = {0x30000ULL, GS_REG_TEST_1}; + clear.prim = {static_cast(GS_PRIM_SPRITE), GS_REG_PRIM}; + clear.rgbaq = {r | (g << 8) | (b << 16) | (a << 24) | (0x3F800000ULL << 32), GS_REG_RGBAQ}; + clear.xyz2a = {xyz(x, y), GS_REG_XYZ2}; + clear.xyz2b = {xyz(x + w, y + h), GS_REG_XYZ2}; + const uint64_t restoreTest = ztest == 0 ? 0x30000ULL : ((static_cast(ztest & 3) << 17) | 0x10000ULL); + clear.testb = {restoreTest, GS_REG_TEST_1}; + uint8_t *dst = getMemPtr(rdram, clearAddr); + if (!dst) + { + setReturnS32(ctx, -1); + return; + } + std::memcpy(dst, &clear, sizeof(clear)); + // libgraph returns the packet length in qwords. + setReturnS32(ctx, static_cast(sizeof(clear) / 16u)); } void sceGsSetDefDBuffDc(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 8cd886a3d4..4a3bb2f3d2 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -784,6 +784,51 @@ void register_ps2_gs_tests() "context-targeted clear should leave the other context framebuffer untouched"); }); + tc.Run("sceGsSetDefClear builds a clear packet that wipes the target and restores TEST", [](TestCase &t) + { + PS2Runtime runtime; + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kClearAddr = 0x4000u; + constexpr uint32_t kSp = 0x8000u; + + // libgraph ABI as compiled into the game: x..g in a2..t3, then b, a, z at sp+0, sp+8, sp+16. + R5900Context ctx{}; + setRegU32(ctx, 4, kClearAddr); + setRegU32(ctx, 5, 2u); // ztest GEQUAL + setRegU32(ctx, 6, 0u); + setRegU32(ctx, 7, 0u); + setRegU32(ctx, 8, 8u); + setRegU32(ctx, 9, 8u); + setRegU32(ctx, 10, 0x11u); + setRegU32(ctx, 11, 0x22u); + setRegU32(ctx, 29, kSp); + const uint32_t b = 0x33u, a = 0x44u, z = 0x1234u; + std::memcpy(rdram.data() + kSp + 0u, &b, 4); + std::memcpy(rdram.data() + kSp + 8u, &a, 4); + std::memcpy(rdram.data() + kSp + 16u, &z, 4); + ps2_stubs::sceGsSetDefClear(rdram.data(), &ctx, &runtime); + t.Equals(getRegU32Test(ctx, 2), 6u, "sceGsSetDefClear should return the packet length in qwords"); + + uint64_t pairs[12]{}; + std::memcpy(pairs, rdram.data() + kClearAddr, sizeof(pairs)); + t.Equals(pairs[0], 0x30000ull, "the clear should draw with ZTST ALWAYS"); + t.Equals(pairs[10], 0x50000ull, "the trailing TEST should restore the caller's ztest"); + t.Equals(pairs[11], static_cast(GS_REG_TEST_1), "TEST is written to context 1"); + + std::vector vram(PS2_GS_VRAM_SIZE, 0xABu); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + gs.writeRegister(GS_REG_FRAME_1, (1ull << 16) | (static_cast(GS_PSM_CT32) << 24)); + gs.writeRegister(GS_REG_ZBUF_1, (64ull) | (1ull << 32)); + gs.writeRegister(GS_REG_SCISSOR_1, (63ull << 16) | (63ull << 48)); + gs.writeRegister(GS_REG_XYOFFSET_1, 0ull); + for (int i = 0; i < 6; ++i) + gs.writeRegister(static_cast(pairs[i * 2 + 1]), pairs[i * 2]); + + t.Equals(gs.ReadVram(GS_PSM_CT32, 0u, 1u, 3u, 3u), 0x44332211u, "pixels inside the clear rectangle take the clear colour"); + t.Equals(gs.ReadVram(GS_PSM_CT32, 0u, 1u, 12u, 12u), 0xABABABABu, "pixels outside the rectangle are untouched"); + }); + tc.Run("XYZ3 culls a triangle strip primitive without desynchronizing the vertex queue", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From fdc9a750f4bdaed1a6c812896417c1b9823b6409 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:22:46 -0300 Subject: [PATCH 67/91] Write batched GS spans with colour and depth formats fixed at compile time, loads ahead of stores --- .../include/runtime/gs/gs_cpu_backend.h | 20 ++ .../include/runtime/gs/ps2_gs_memory.h | 34 +++ ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 262 +++++++++++++++++- ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp | 12 +- 4 files changed, 309 insertions(+), 19 deletions(-) diff --git a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h index 8bf7b18ebd..f7274a9812 100644 --- a/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h +++ b/ps2xRuntime/include/runtime/gs/gs_cpu_backend.h @@ -253,6 +253,26 @@ class GSCpuBackend final : public GSRasterBackend void WritePixel(const GSDrawState &state, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); // True when the pixel passed every test and something was stored. bool WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); + // WritePixel for colour and depth formats fixed at compile time; kAnyTarget reads them from the pipeline. + static constexpr uint32_t kAnyTarget = 0xFFu; + template + bool WritePixelFor(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog); + // Pixels of one row of a batched primitive, as WritePixelFor writes them one by one; returns how many + // were stored. + struct SpanPixels + { + static constexpr int kMax = 32; + int count = 0; + int x[kMax]; + int z[kMax]; + uint8_t r[kMax]; + uint8_t g[kMax]; + uint8_t b[kMax]; + uint8_t a[kMax]; + uint8_t fog[kMax]; + }; + template + uint32_t WriteSpanFor(const PixelPipeline &pipe, int y, SpanPixels &span); // Texture registers decoded once per primitive, with the CLUT expanded to RGBA for indexed formats. struct TextureSampler { diff --git a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h index ba535f53f4..b27b099475 100644 --- a/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h +++ b/ps2xRuntime/include/runtime/gs/ps2_gs_memory.h @@ -554,6 +554,40 @@ namespace GSMem using PixelOffsetFn = u32 (*)(u32 bp, u32 bw, u32 x, u32 y); PixelOffsetFn TargetPixelOffset(u32 psm); + // Page tables of the drawable targets (filled by InitLookupTables). + extern PixelStorageTraits::PageLookupTableT PageTableC32; + extern PixelStorageTraits::PageLookupTableT PageTableZ32; + extern PixelStorageTraits::PageLookupTableT PageTableC16; + extern PixelStorageTraits::PageLookupTableT PageTableC16S; + extern PixelStorageTraits::PageLookupTableT PageTableZ16; + extern PixelStorageTraits::PageLookupTableT PageTableZ16S; + + // What TargetPixelOffset(psm) returns, for a format known at compile time, so the rasterizer's + // per-pixel addressing inlines instead of going through a function pointer. + template + inline u32 TargetOffset(u32 bp, u32 bw, u32 x, u32 y) + { + using Traits = PixelStorageTraits; + const auto &table = []() -> const typename Traits::PageLookupTableT & + { + if constexpr (psm == C32 || psm == C24) + return PageTableC32; + else if constexpr (psm == Z32 || psm == Z24) + return PageTableZ32; + else if constexpr (psm == C16) + return PageTableC16; + else if constexpr (psm == C16S) + return PageTableC16S; + else if constexpr (psm == Z16) + return PageTableZ16; + else + return PageTableZ16S; + }(); + const u32 pixel_addr = Traits::Address(table, bp, bw, x, y); + const u32 bits = pixel_addr * UnpackedBitWidth(psm) + Traits::BitOffset(); + return (bits / 8) & (MEMORY_SIZE - sizeof(typename Traits::PackedT)); + } + 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 468c79ecba..6ef97e36f6 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -2322,6 +2322,50 @@ void GSCpuBackend::DrawPrimitiveRows(const GSPrimitiveBatch &batch, const Raster } } +namespace +{ + template + using FormatTag = std::integral_constant; + + // Calls write(frameTag, depthTag) with the target formats as compile-time constants for the + // combinations 3D scenes draw with, and as GSCpuBackend::kAnyTarget otherwise. + template + __attribute__((always_inline)) inline void dispatchTargetFormats(uint32_t fpsm, uint32_t zpsm, Write &&write) + { + constexpr uint32_t kAny = 0xFFu; + auto withDepth = [&](auto frameTag) + { + switch (zpsm) + { + case GS_PSM_Z32: + write(frameTag, FormatTag{}); + return; + case GS_PSM_Z24: + write(frameTag, FormatTag{}); + return; + case GS_PSM_Z16S: + write(frameTag, FormatTag{}); + return; + default: + write(FormatTag{}, FormatTag{}); + return; + } + }; + switch (fpsm) + { + case GS_PSM_CT32: + withDepth(FormatTag{}); + return; + case GS_PSM_CT24: + withDepth(FormatTag{}); + return; + default: + write(FormatTag{}, FormatTag{}); + return; + } + } +} + struct GSCpuBackend::PixelPipeline { const GSDrawState *state = nullptr; @@ -2431,6 +2475,15 @@ void GSCpuBackend::RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, i __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) { + return WritePixelFor(pipe, x, y, z, r, g, b, a, fog); +} + +template +__attribute__((always_inline)) inline bool GSCpuBackend::WritePixelFor(const PixelPipeline &pipe, int x, int y, int z, uint8_t r, uint8_t g, uint8_t b, uint8_t a, uint8_t fog) +{ + // With the formats fixed, the format switches below fold away and addressing inlines. + constexpr bool knownFrame = FramePsm != kAnyTarget; + constexpr bool knownDepth = DepthPsm != kAnyTarget; const GSDrawState &state = *pipe.state; const auto &ctx = state.context; if (x < ctx.scissor.x0 || x > ctx.scissor.x1 || y < ctx.scissor.y0 || y > ctx.scissor.y1) @@ -2451,9 +2504,26 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP const u32 fbp = pipe.fbp; const u32 fbw = pipe.fbw; - const u32 fpsm = pipe.fpsm; + const u32 fpsm = knownFrame ? FramePsm : pipe.fpsm; const u32 zbp = pipe.zbp; - const u32 zpsm = pipe.zpsm; + const u32 zpsm = knownDepth ? DepthPsm : pipe.zpsm; + auto frameOffset = [&]() -> u32 + { + if constexpr (knownFrame) + return GSMem::TargetOffset(FramePsm)>(fbp, fbw, static_cast(x), static_cast(y)); + else + return pipe.frameOffset(fbp, fbw, static_cast(x), static_cast(y)); + }; + auto depthOffset = [&]() -> u32 + { + if constexpr (knownDepth) + return GSMem::TargetOffset(DepthPsm)>(zbp, fbw, static_cast(x), static_cast(y)); + else + return pipe.depthOffset(zbp, fbw, static_cast(x), static_cast(y)); + }; + const bool hasFrameOffset = knownFrame || pipe.frameOffset != nullptr; + const bool hasDepthOffset = knownDepth || pipe.depthOffset != nullptr; + const bool framebuffer16 = knownFrame ? bitsPerPixel(FramePsm) == 16 : pipe.framebuffer16; const PixelWriteMask writeMask = classifyAlphaTest(ctx.test, a, static_cast(fpsm)); if (!writeMask.writesAnything()) @@ -2475,9 +2545,9 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP uint8_t *framePixel = nullptr; if (frmw) { - if (pipe.frameOffset) + if (hasFrameOffset) { - framePixel = m_vram + pipe.frameOffset(fbp, fbw, static_cast(x), static_cast(y)); + framePixel = m_vram + frameOffset(); rawFramebufferPixel = readTarget(fpsm, framePixel); } else @@ -2486,7 +2556,7 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP } fbrgba = rawFramebufferPixel; - if (pipe.framebuffer16) + if (framebuffer16) { fbrgba = Rgba5551ToRgba8888(fbrgba); } @@ -2507,9 +2577,9 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP uint8_t *depthPixel = nullptr; auto readDepth = [&]() -> uint32_t { - if (!pipe.depthOffset) + if (!hasDepthOffset) return ReadVramUnlocked(zpsm, zbp, fbw, x, y); - depthPixel = m_vram + pipe.depthOffset(zbp, fbw, static_cast(x), static_cast(y)); + depthPixel = m_vram + depthOffset(); return readTarget(zpsm, depthPixel); }; switch (ztestMethod) @@ -2598,16 +2668,16 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP } // format conversion - if (pipe.framebuffer16) + if (framebuffer16) { pixel = Rgba8888ToRgba5551(pixel); } // PrepareTexturePageCache already made the texture page cache safe for this primitive's writes. - if (pipe.frameOffset) + if (hasFrameOffset) { if (!framePixel) - framePixel = m_vram + pipe.frameOffset(fbp, fbw, static_cast(x), static_cast(y)); + framePixel = m_vram + frameOffset(); if (pipe.undo) RecordUndo(pipe, framePixel, x, y); writeTarget(fpsm, framePixel, pixel); @@ -2620,10 +2690,10 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP if (writeMask.writeDepth && !ctx.zbuf.zmask) { - if (pipe.depthOffset) + if (hasDepthOffset) { if (!depthPixel) - depthPixel = m_vram + pipe.depthOffset(zbp, fbw, static_cast(x), static_cast(y)); + depthPixel = m_vram + depthOffset(); if (pipe.undo) RecordUndo(pipe, depthPixel, x, y); writeTarget(zpsm, depthPixel, static_cast(z)); @@ -2636,6 +2706,146 @@ __attribute__((always_inline)) inline bool GSCpuBackend::WritePixel(const PixelP return true; } +template +uint32_t GSCpuBackend::WriteSpanFor(const PixelPipeline &pipe, int y, SpanPixels &span) +{ + // WritePixelFor pixel by pixel waits on each pixel's address lookups and loads before the next pixel + // starts. A batched primitive's pixels in one row are distinct colour and depth words that nothing else + // in the chunk touches (colour and depth never share pages in a batch), so the addresses and loads of + // the whole span go first and only the stores keep their order. + const GSDrawState &state = *pipe.state; + const auto &ctx = state.context; + const int n = span.count; + constexpr uint32_t fpsm = FramePsm; + constexpr uint32_t zpsm = DepthPsm; + const u32 fbp = pipe.fbp; + const u32 fbw = pipe.fbw; + const u32 zbp = pipe.zbp; + const bool rowInScissor = y >= ctx.scissor.y0 && y <= ctx.scissor.y1; + + uint32_t frameOff[SpanPixels::kMax]; + uint32_t depthOff[SpanPixels::kMax]; + uint32_t frameRaw[SpanPixels::kMax]; + uint32_t depthRaw[SpanPixels::kMax]; + uint8_t mask[SpanPixels::kMax]; // bit0 rgb, bit1 alpha, bit2 depth; 0 skips the pixel + for (int i = 0; i < n; ++i) + { + const int x = span.x[i]; + if (pipe.fge) + { + const uint8_t fog = span.fog[i]; + const uint32_t inverseFog = 255u - fog; + auto applyFog = [&](uint8_t input, uint8_t fogColor) -> uint8_t + { + return static_cast(((static_cast(fog) * input) >> 8) + ((inverseFog * fogColor) >> 8)); + }; + span.r[i] = applyFog(span.r[i], state.fogR); + span.g[i] = applyFog(span.g[i], state.fogG); + span.b[i] = applyFog(span.b[i], state.fogB); + } + const PixelWriteMask writeMask = classifyAlphaTest(ctx.test, span.a[i], static_cast(fpsm)); + const bool inside = rowInScissor && x >= ctx.scissor.x0 && x <= ctx.scissor.x1; + mask[i] = inside ? static_cast((writeMask.writeRgb ? 1u : 0u) | (writeMask.writeAlpha ? 2u : 0u) | (writeMask.writeDepth ? 4u : 0u)) : 0u; + frameOff[i] = GSMem::TargetOffset(FramePsm)>(fbp, fbw, static_cast(x), static_cast(y)); + depthOff[i] = GSMem::TargetOffset(DepthPsm)>(zbp, fbw, static_cast(x), static_cast(y)); + } + for (int i = 0; i < n; ++i) + { + frameRaw[i] = readTarget(fpsm, m_vram + frameOff[i]); + depthRaw[i] = readTarget(zpsm, m_vram + depthOff[i]); + } + + uint32_t written = 0u; + const uint32_t ztestMethod = pipe.ztestMethod; + for (int i = 0; i < n; ++i) + { + if (mask[i] == 0u) + continue; + const bool writeRgb = (mask[i] & 1u) != 0u; + const bool writeAlpha = (mask[i] & 2u) != 0u; + const bool writeDepth = (mask[i] & 4u) != 0u; + const bool writesFramebuffer = writeRgb || writeAlpha; + const int x = span.x[i]; + const int z = span.z[i]; + uint8_t r = span.r[i], g = span.g[i], b = span.b[i], a = span.a[i]; + const bool preserveDestinationAlpha = writeRgb && !writeAlpha && fpsm == GS_PSM_CT32; + const bool frmw = pipe.destinationAlphaTestNeedsRead || + (writesFramebuffer && ((ctx.frame.fbmsk != 0) || state.prim.abe || preserveDestinationAlpha)); + u32 rawFramebufferPixel = 0; + u32 fbrgba = 0; + if (frmw) + { + rawFramebufferPixel = frameRaw[i]; + fbrgba = rawFramebufferPixel; + if (fpsm == GS_PSM_CT24) + fbrgba |= 0x80000000u; + } + if (!passesDestinationAlphaTest(ctx.test, static_cast(fpsm), rawFramebufferPixel)) + continue; + bool zpass = false; + switch (ztestMethod) + { + case 0: + zpass = false; + break; + case 1: + zpass = true; + break; + case 2: + zpass = static_cast(z) >= depthRaw[i]; + break; + case 3: + zpass = static_cast(z) > depthRaw[i]; + break; + } + if (!zpass) + continue; + + if (writesFramebuffer) + { + if (state.prim.abe && !(state.pabe && (a & 0x80u) == 0u)) + { + const int dr = static_cast(fbrgba & 0xFF); + const int dg = static_cast((fbrgba >> 8) & 0xFF); + const int db = static_cast((fbrgba >> 16) & 0xFF); + const int da = static_cast((fbrgba >> 24) & 0xFF); + auto pickRGB = [](uint8_t sel, int cs, int cd) -> int + { + if (sel == 0) + return cs; + if (sel == 1) + return cd; + return 0; + }; + const int cAlpha = (pipe.csel == 0) ? a : (pipe.csel == 1) ? da : pipe.fix; + r = clampU8(((pickRGB(pipe.asel, r, dr) - pickRGB(pipe.bsel, r, dr)) * cAlpha >> 7) + pickRGB(pipe.dsel, r, dr)); + g = clampU8(((pickRGB(pipe.asel, g, dg) - pickRGB(pipe.bsel, g, dg)) * cAlpha >> 7) + pickRGB(pipe.dsel, g, dg)); + b = clampU8(((pickRGB(pipe.asel, b, db) - pickRGB(pipe.bsel, b, db)) * cAlpha >> 7) + pickRGB(pipe.dsel, b, db)); + } + if (writeAlpha && pipe.fbaSetsAlpha) + a = static_cast(a | 0x80u); + u32 pixel = pack32(r, g, b, a); + if (ctx.frame.fbmsk != 0) + pixel = (pixel & ~ctx.frame.fbmsk) | (fbrgba & ctx.frame.fbmsk); + if (preserveDestinationAlpha) + pixel = (pixel & 0x00FFFFFFu) | (fbrgba & 0xFF000000u); + uint8_t *framePixel = m_vram + frameOff[i]; + if (pipe.undo) + RecordUndo(pipe, framePixel, x, y); + writeTarget(fpsm, framePixel, pixel); + } + if (writeDepth && !ctx.zbuf.zmask) + { + uint8_t *depthPixel = m_vram + depthOff[i]; + if (pipe.undo) + RecordUndo(pipe, depthPixel, x, y); + writeTarget(zpsm, depthPixel, static_cast(z)); + } + ++written; + } + return written; +} + GSCpuBackend::PixelPipeline GSCpuBackend::MakePixelPipeline(const GSDrawState &state, const RasterContext &raster) { PixelPipeline pipe = MakePixelPipeline(state); @@ -3218,6 +3428,13 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte texels[i] = SampleTextureAxes(sampler, axesU[i], axesV[i]); raster.sampled(y, xs[i]); } + auto writeChunk = [&](auto frameFormat, auto depthFormat) + { + constexpr uint32_t kFrame = decltype(frameFormat)::value; + constexpr uint32_t kDepth = decltype(depthFormat)::value; + constexpr bool staged = kFrame != kAnyTarget && kDepth != kAnyTarget; + SpanPixels span; + span.count = 0; for (int i = 0; i < count; ++i) { const float w0 = w0s[i], w1 = w1s[i], w2 = w2s[i]; @@ -3242,8 +3459,27 @@ void GSCpuBackend::DrawTriangle(const GSPrimitiveBatch &batch, const RasterConte static_cast(texel & 0xFF), static_cast((texel >> 8) & 0xFF), static_cast((texel >> 16) & 0xFF), static_cast((texel >> 24) & 0xFF)); const uint8_t fog = clampU8(static_cast(v0.fog * w0 + v1.fog * w1 + v2.fog * w2)); - writtenPixels += WritePixel(pipe, xs[i], y, static_cast(z + 0.5), color.r, color.g, color.b, color.a, fog) ? 1u : 0u; + if constexpr (staged) + { + const int k = span.count++; + span.x[k] = xs[i]; + span.z[k] = static_cast(static_cast(z + 0.5)); + span.r[k] = color.r; + span.g[k] = color.g; + span.b[k] = color.b; + span.a[k] = color.a; + span.fog[k] = fog; + } + else + { + writtenPixels += WritePixelFor(pipe, xs[i], y, static_cast(z + 0.5), color.r, color.g, color.b, + color.a, fog) ? 1u : 0u; + } } + if constexpr (staged) + writtenPixels += WriteSpanFor(pipe, y, span); + }; + dispatchTargetFormats(pipe.fpsm, pipe.zpsm, writeChunk); } continue; } diff --git a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp index 3c4e00cce1..ec69e25aa0 100644 --- a/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp +++ b/ps2xRuntime/src/lib/gs/ps2_gs_memory.cpp @@ -192,12 +192,12 @@ namespace GSMem }}; // this is going to be massive (an entire page of addess lookups) - static C32PageLookupTable PageTableC32{ }; - static Z32PageLookupTableT PageTableZ32{ }; - static C16PageLookupTable PageTableC16{ }; - static C16SPageLookupTable PageTableC16S{ }; - static Z16PageLookupTable PageTableZ16{ }; - static Z16SPageLookupTable PageTableZ16S{ }; + C32PageLookupTable PageTableC32{ }; + Z32PageLookupTableT PageTableZ32{ }; + C16PageLookupTable PageTableC16{ }; + C16SPageLookupTable PageTableC16S{ }; + Z16PageLookupTable PageTableZ16{ }; + Z16SPageLookupTable PageTableZ16S{ }; static P8PageLookupTable PageTableP8{ }; static P4PageLookupTable PageTableP4{ }; From a60ad5783e1e4fa378603c8774733d54fce1d263 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:22:46 -0300 Subject: [PATCH 68/91] Inline the tracked-batch undo log append --- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 6ef97e36f6..ffd8e4bf17 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -2458,7 +2458,8 @@ namespace } } -void GSCpuBackend::RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, int x, int y) +// Runs for most pixels of a tracked batch; as a call it cost about 5% of their raster time. +__attribute__((always_inline)) inline void GSCpuBackend::RecordUndo(const PixelPipeline &pipe, const uint8_t *pixel, int x, int y) { // Only RGB24/Z24 targets write tracked pages, so the whole word is the pixel's. const uint32_t offset = static_cast(pixel - m_vram); From 99bdd5875ac2303003d655163f97360ca1a31162 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:32:41 -0300 Subject: [PATCH 69/91] Blend batched GS spans four pixels at a time with NEON --- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 115 ++++++++++++++++++---- 1 file changed, 98 insertions(+), 17 deletions(-) diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index ffd8e4bf17..870c8dbfe3 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -2756,6 +2756,99 @@ uint32_t GSCpuBackend::WriteSpanFor(const PixelPipeline &pipe, int y, SpanPixels depthRaw[i] = readTarget(zpsm, m_vram + depthOff[i]); } + // Blended colour for every pixel, as if it passes and blends; the ordered loop below decides which + // pixels use it. Integer arithmetic only, so lanes compute exactly what the scalar formula does. + uint8_t blendR[SpanPixels::kMax]; + uint8_t blendG[SpanPixels::kMax]; + uint8_t blendB[SpanPixels::kMax]; + const bool blends = state.prim.abe; + if (blends) + { + int i = 0; +#if defined(__ARM_NEON) + auto selectMasks = [](uint8_t sel, int32x4_t &source, int32x4_t &dest) + { + source = vdupq_n_s32(sel == 0 ? -1 : 0); + dest = vdupq_n_s32(sel == 1 ? -1 : 0); + }; + int32x4_t aS, aD, bS, bD, dS, dD; + selectMasks(pipe.asel, aS, aD); + selectMasks(pipe.bsel, bS, bD); + selectMasks(pipe.dsel, dS, dD); + const int32x4_t zero = vdupq_n_s32(0); + const int32x4_t max = vdupq_n_s32(255); + auto pick = [](int32x4_t s, int32x4_t d, int32x4_t cs, int32x4_t cd) + { + return vorrq_s32(vandq_s32(s, cs), vandq_s32(d, cd)); + }; + auto widen = [](const uint8_t *p) + { + const uint32_t word = static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | (static_cast(p[3]) << 24); + return vreinterpretq_s32_u32(vmovl_u16(vget_low_u16(vmovl_u8(vreinterpret_u8_u32(vdup_n_u32(word)))))); + }; + for (; i + 4 <= n; i += 4) + { + const uint32x4_t fb = vld1q_u32(frameRaw + i); + // Destination channels as WritePixelFor sees them (RGB24 reads alpha as 0x80). + const int32x4_t dr = vreinterpretq_s32_u32(vandq_u32(fb, vdupq_n_u32(0xFFu))); + const int32x4_t dg = vreinterpretq_s32_u32(vandq_u32(vshrq_n_u32(fb, 8), vdupq_n_u32(0xFFu))); + const int32x4_t db = vreinterpretq_s32_u32(vandq_u32(vshrq_n_u32(fb, 16), vdupq_n_u32(0xFFu))); + const int32x4_t da = fpsm == GS_PSM_CT24 ? vdupq_n_s32(0x80) : vreinterpretq_s32_u32(vshrq_n_u32(fb, 24)); + const int32x4_t sr = widen(span.r + i); + const int32x4_t sg = widen(span.g + i); + const int32x4_t sb = widen(span.b + i); + const int32x4_t sa = widen(span.a + i); + const int32x4_t cAlpha = pipe.csel == 0 ? sa : pipe.csel == 1 ? da : vdupq_n_s32(pipe.fix); + auto channel = [&](int32x4_t cs, int32x4_t cd) + { + const int32x4_t a = pick(aS, aD, cs, cd); + const int32x4_t b = pick(bS, bD, cs, cd); + const int32x4_t d = pick(dS, dD, cs, cd); + const int32x4_t v = vaddq_s32(vshrq_n_s32(vmulq_s32(vsubq_s32(a, b), cAlpha), 7), d); + return vminq_s32(vmaxq_s32(v, zero), max); + }; + const int32x4_t br = channel(sr, dr); + const int32x4_t bg = channel(sg, dg); + const int32x4_t bb = channel(sb, db); + const uint16x4_t r16 = vmovn_u32(vreinterpretq_u32_s32(br)); + const uint16x4_t g16 = vmovn_u32(vreinterpretq_u32_s32(bg)); + const uint16x4_t b16 = vmovn_u32(vreinterpretq_u32_s32(bb)); + uint16_t rr[4], gg[4], bbv[4]; + vst1_u16(rr, r16); + vst1_u16(gg, g16); + vst1_u16(bbv, b16); + for (int k = 0; k < 4; ++k) + { + blendR[i + k] = static_cast(rr[k]); + blendG[i + k] = static_cast(gg[k]); + blendB[i + k] = static_cast(bbv[k]); + } + } +#endif + for (; i < n; ++i) + { + const u32 fbrgba = fpsm == GS_PSM_CT24 ? (frameRaw[i] | 0x80000000u) : frameRaw[i]; + const int dr = static_cast(fbrgba & 0xFF); + const int dg = static_cast((fbrgba >> 8) & 0xFF); + const int db = static_cast((fbrgba >> 16) & 0xFF); + const int da = static_cast((fbrgba >> 24) & 0xFF); + auto pickRGB = [](uint8_t sel, int cs, int cd) -> int + { + if (sel == 0) + return cs; + if (sel == 1) + return cd; + return 0; + }; + const int a = span.a[i]; + const int cAlpha = (pipe.csel == 0) ? a : (pipe.csel == 1) ? da : pipe.fix; + blendR[i] = clampU8(((pickRGB(pipe.asel, span.r[i], dr) - pickRGB(pipe.bsel, span.r[i], dr)) * cAlpha >> 7) + pickRGB(pipe.dsel, span.r[i], dr)); + blendG[i] = clampU8(((pickRGB(pipe.asel, span.g[i], dg) - pickRGB(pipe.bsel, span.g[i], dg)) * cAlpha >> 7) + pickRGB(pipe.dsel, span.g[i], dg)); + blendB[i] = clampU8(((pickRGB(pipe.asel, span.b[i], db) - pickRGB(pipe.bsel, span.b[i], db)) * cAlpha >> 7) + pickRGB(pipe.dsel, span.b[i], db)); + } + } + uint32_t written = 0u; const uint32_t ztestMethod = pipe.ztestMethod; for (int i = 0; i < n; ++i) @@ -2804,24 +2897,12 @@ uint32_t GSCpuBackend::WriteSpanFor(const PixelPipeline &pipe, int y, SpanPixels if (writesFramebuffer) { - if (state.prim.abe && !(state.pabe && (a & 0x80u) == 0u)) + // A blending primitive reads the frame (frmw), so the blend above used this pixel's destination. + if (blends && !(state.pabe && (a & 0x80u) == 0u)) { - const int dr = static_cast(fbrgba & 0xFF); - const int dg = static_cast((fbrgba >> 8) & 0xFF); - const int db = static_cast((fbrgba >> 16) & 0xFF); - const int da = static_cast((fbrgba >> 24) & 0xFF); - auto pickRGB = [](uint8_t sel, int cs, int cd) -> int - { - if (sel == 0) - return cs; - if (sel == 1) - return cd; - return 0; - }; - const int cAlpha = (pipe.csel == 0) ? a : (pipe.csel == 1) ? da : pipe.fix; - r = clampU8(((pickRGB(pipe.asel, r, dr) - pickRGB(pipe.bsel, r, dr)) * cAlpha >> 7) + pickRGB(pipe.dsel, r, dr)); - g = clampU8(((pickRGB(pipe.asel, g, dg) - pickRGB(pipe.bsel, g, dg)) * cAlpha >> 7) + pickRGB(pipe.dsel, g, dg)); - b = clampU8(((pickRGB(pipe.asel, b, db) - pickRGB(pipe.bsel, b, db)) * cAlpha >> 7) + pickRGB(pipe.dsel, b, db)); + r = blendR[i]; + g = blendG[i]; + b = blendB[i]; } if (writeAlpha && pipe.fbaSetsAlpha) a = static_cast(a | 0x80u); From 2278c45098ccb2b14465f60ac04891864ee2cfb8 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:32:41 -0300 Subject: [PATCH 70/91] Sample batched textured sprites a row chunk at a time and write them as spans --- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 54 ++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 870c8dbfe3..0f4b821e73 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -346,7 +346,7 @@ namespace } } - bool passesAlphaTest(uint64_t testReg, uint8_t alpha) + __attribute__((always_inline)) inline bool passesAlphaTest(uint64_t testReg, uint8_t alpha) { if ((testReg & 0x1u) == 0u) return true; @@ -394,7 +394,8 @@ namespace } }; - PixelWriteMask classifyAlphaTest(uint64_t testReg, uint8_t alpha, uint8_t framePsm) + // Runs for every pixel; as a call it showed up on its own in raster profiles. + __attribute__((always_inline)) inline PixelWriteMask classifyAlphaTest(uint64_t testReg, uint8_t alpha, uint8_t framePsm) { const bool pass = passesAlphaTest(testReg, alpha); if (pass) @@ -3302,6 +3303,55 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch, const RasterContext float texVf = v0f + (v1f - v0f) * ty; const TextureAxis row = PrepareTextureAxis(axisCoordinate(texVf, texH), texH, wrapV, minV, maxV, state.linearFilter); + if (raster.batched) + { + // As for batched triangles: a chunk is sampled, then written as one span. + auto writeRow = [&](auto frameFormat, auto depthFormat) + { + constexpr uint32_t kFrame = decltype(frameFormat)::value; + constexpr uint32_t kDepth = decltype(depthFormat)::value; + SpanPixels span; + for (int chunkX = drawX0; chunkX <= drawX1; chunkX += SpanPixels::kMax) + { + const int count = std::min(SpanPixels::kMax, drawX1 - chunkX + 1); + uint32_t texels[SpanPixels::kMax]; + for (int i = 0; i < count; ++i) + { + const int x = chunkX + i; + raster.beginSample(y, x); + texels[i] = SampleTextureAxes(sampler, columns[static_cast(x - drawX0)], row); + raster.sampled(y, x); + } + span.count = count; + for (int i = 0; i < count; ++i) + { + const uint32_t texel = texels[i]; + const TextureCombineResult color = combineTexture(tex, r, g, b, a, + static_cast(texel & 0xFF), static_cast((texel >> 8) & 0xFF), + static_cast((texel >> 16) & 0xFF), static_cast((texel >> 24) & 0xFF)); + if constexpr (kFrame != kAnyTarget && kDepth != kAnyTarget) + { + span.x[i] = chunkX + i; + span.z[i] = static_cast(z1); + span.r[i] = color.r; + span.g[i] = color.g; + span.b[i] = color.b; + span.a[i] = color.a; + span.fog[i] = v1.fog; + } + else + { + writtenPixels += WritePixelFor(pipe, chunkX + i, y, z1, color.r, color.g, color.b, color.a, v1.fog) ? 1u : 0u; + } + } + if constexpr (kFrame != kAnyTarget && kDepth != kAnyTarget) + writtenPixels += WriteSpanFor(pipe, y, span); + } + }; + dispatchTargetFormats(pipe.fpsm, pipe.zpsm, writeRow); + continue; + } + for (int x = drawX0; x <= drawX1; ++x) { raster.beginSample(y, x); From ec32f4f0d2b931f47f0ad500009e8a3af748a9e6 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:22:10 -0300 Subject: [PATCH 71/91] Decode the delay slot of a branch in a function's last word even when the next function starts on it --- ps2xRecomp/src/lib/ps2_recompiler.cpp | 10 ++- ps2xTest/src/ps2_recompiler_tests.cpp | 95 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/ps2xRecomp/src/lib/ps2_recompiler.cpp b/ps2xRecomp/src/lib/ps2_recompiler.cpp index 0827a88a33..bf36a726e6 100644 --- a/ps2xRecomp/src/lib/ps2_recompiler.cpp +++ b/ps2xRecomp/src/lib/ps2_recompiler.cpp @@ -2149,7 +2149,15 @@ namespace ps2recomp uint32_t start = function.start; uint32_t end = function.end; - for (uint32_t address = start; address < end; address += 4) + // A branch in the last word still owns the word after it: when the next function starts on that delay + // slot, decode it here too so the branch executes the real instruction instead of a NOP. + auto endsInBranch = [&](uint32_t address) + { + return address == end && !instructions.empty() && instructions.back().hasDelaySlot && + instructions.back().address + 4u == end && m_elfParser->isValidAddress(address); + }; + + for (uint32_t address = start; address < end || endsInBranch(address); address += 4) { try { diff --git a/ps2xTest/src/ps2_recompiler_tests.cpp b/ps2xTest/src/ps2_recompiler_tests.cpp index 9a4eaebb61..0718c8809a 100644 --- a/ps2xTest/src/ps2_recompiler_tests.cpp +++ b/ps2xTest/src/ps2_recompiler_tests.cpp @@ -430,6 +430,61 @@ static bool writeMinimalMipsElfWithStandaloneEntryBlock(const std::filesystem::p return writer.save(elfPath.string()); } +static bool writeMinimalMipsElfWithDelaySlotOnNextFunction(const std::filesystem::path &elfPath) +{ + ELFIO::elfio writer; + writer.create(ELFIO::ELFCLASS32, ELFIO::ELFDATA2LSB); + writer.set_os_abi(ELFIO::ELFOSABI_NONE); + writer.set_type(ELFIO::ET_EXEC); + writer.set_machine(ELFIO::EM_MIPS); + writer.set_entry(0x00100000u); + + ELFIO::section *text = writer.sections.add(".text"); + text->set_type(ELFIO::SHT_PROGBITS); + text->set_flags(ELFIO::SHF_ALLOC | ELFIO::SHF_EXECINSTR); + text->set_addr_align(4); + text->set_address(0x00100000u); + + const std::array textWords = { + 0x27BDFFF0u, // wrapper: addiu $sp, $sp, -0x10 + 0x08040006u, // j 0x00100018 + 0x27BD0010u, // slot_entry (also wrapper's delay slot): addiu $sp, $sp, 0x10 + 0x03E00008u, // jr $ra + 0x00000000u, // nop + 0x00000000u, // nop + 0x03E00008u, // target at 0x00100018: jr $ra + 0x00000000u // nop + }; + text->set_data(reinterpret_cast(textWords.data()), + static_cast(textWords.size() * sizeof(uint32_t))); + + ELFIO::section *strtab = writer.sections.add(".strtab"); + strtab->set_type(ELFIO::SHT_STRTAB); + strtab->set_addr_align(1); + + ELFIO::section *symtab = writer.sections.add(".symtab"); + symtab->set_type(ELFIO::SHT_SYMTAB); + symtab->set_info(1); + symtab->set_link(strtab->get_index()); + symtab->set_addr_align(4); + symtab->set_entry_size(writer.get_default_entry_size(ELFIO::SHT_SYMTAB)); + + ELFIO::symbol_section_accessor symbols(writer, symtab); + ELFIO::string_section_accessor strings(strtab); + symbols.add_symbol(strings, "", 0, 0, ELFIO::STB_LOCAL, ELFIO::STT_NOTYPE, 0, ELFIO::SHN_UNDEF); + symbols.add_symbol(strings, "wrapper", 0x00100000u, 8u, ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + symbols.add_symbol(strings, "slot_entry", 0x00100008u, 12u, ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + symbols.add_symbol(strings, "target", 0x00100018u, 8u, ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + + ELFIO::segment *textSegment = writer.segments.add(); + textSegment->set_type(ELFIO::PT_LOAD); + textSegment->set_flags(ELFIO::PF_R | ELFIO::PF_X); + textSegment->set_align(0x1000); + textSegment->add_section_index(text->get_index(), text->get_addr_align()); + + return writer.save(elfPath.string()); +} + static bool writeMinimalMipsElfWithAddressTakenCallbacks(const std::filesystem::path &elfPath, bool includePartialDwarf = false) { @@ -1653,6 +1708,46 @@ void register_ps2_recompiler_tests() std::filesystem::remove_all(tempRoot, removeError); }); + tc.Run("a jump in a function's last word executes its delay slot even when the next function starts there", [](TestCase &t) { + const std::string uniqueSuffix = + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + const std::filesystem::path tempRoot = + std::filesystem::temp_directory_path() / ("ps2recomp-delay-slot-boundary-" + uniqueSuffix); + const std::filesystem::path elfPath = tempRoot / "delay-slot.elf"; + const std::filesystem::path configPath = tempRoot / "delay-slot.toml"; + const std::filesystem::path outputPath = tempRoot / "output"; + std::filesystem::create_directories(tempRoot); + + const bool elfWritten = writeMinimalMipsElfWithDelaySlotOnNextFunction(elfPath); + const bool configWritten = writeRecompilerTestConfig(configPath, elfPath, outputPath, {}); + t.IsTrue(elfWritten && configWritten, "delay slot boundary inputs should be generated"); + + if (elfWritten && configWritten) + { + PS2Recompiler recompiler(configPath.string()); + t.IsTrue(recompiler.initialize(), "delay slot boundary config should initialize"); + t.IsTrue(recompiler.recompile(), "delay slot boundary ELF should recompile"); + recompiler.generateOutput(); + + std::string wrapperSource; + for (const auto &entry : std::filesystem::directory_iterator(outputPath)) + { + if (entry.path().filename().string().rfind("wrapper", 0) != 0) + continue; + std::ifstream file(entry.path()); + wrapperSource.assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); + } + t.IsTrue(!wrapperSource.empty(), "wrapper should be emitted to its own file"); + // Dropping the delay slot left $sp 0x10 low after the tail jump, so the caller reloaded $ra from + // the wrong stack slot (Mana Khemia froze in the synthesis menu that way). + t.IsTrue(wrapperSource.find("ADD32(GPR_U32(ctx, 29), 16)") != std::string::npos, + "the J at the end of wrapper should run its delay slot (addiu $sp, $sp, 0x10)"); + } + + std::error_code removeError; + std::filesystem::remove_all(tempRoot, removeError); + }); + tc.Run("elf parser ignores STT_FUNC symbols in non-executable sections", [](TestCase &t) { const auto uniqueSuffix = std::to_string( static_cast(std::chrono::steady_clock::now().time_since_epoch().count())); From e22a617b3e683bf626c71e788b13c14926c1bdd2 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:50:42 -0300 Subject: [PATCH 72/91] Pass argc/argv to IRX entries, implement look_ctype_table and expand IOP printf/sprintf args --- ps2xIOP/CMakeLists.txt | 1 + ps2xIOP/src/emulator/imports/iop_format.cpp | 108 ++++++++++++++++++ ps2xIOP/src/emulator/imports/iop_format.h | 16 +++ ps2xIOP/src/emulator/imports/iop_stdio.cpp | 33 +++++- ps2xIOP/src/emulator/imports/iop_sysclib.cpp | 64 +++++++++-- ps2xIOP/src/emulator/iop_emulator.cpp | 31 +++-- .../emulator/services/iop_module_loader.cpp | 31 +++++ .../src/emulator/services/iop_module_loader.h | 7 ++ ps2xIOP/tests/iop_emulator_tests.cpp | 37 ++++++ ps2xIOP/tests/iop_import_tests.cpp | 106 ++++++++++++++++- ps2xRuntime/src/lib/ps2_audio_stream.cpp | 70 ++++++++++++ 11 files changed, 483 insertions(+), 21 deletions(-) create mode 100644 ps2xIOP/src/emulator/imports/iop_format.cpp create mode 100644 ps2xIOP/src/emulator/imports/iop_format.h diff --git a/ps2xIOP/CMakeLists.txt b/ps2xIOP/CMakeLists.txt index c78cee8aab..660c9b87df 100644 --- a/ps2xIOP/CMakeLists.txt +++ b/ps2xIOP/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(ps2_iop STATIC src/emulator/imports/iop_ioman.cpp src/emulator/imports/iop_loadcore.cpp src/emulator/imports/iop_stdio.cpp + src/emulator/imports/iop_format.cpp src/emulator/imports/iop_sysclib.cpp src/emulator/imports/iop_sysmem.cpp src/emulator/imports/iop_timrman.cpp diff --git a/ps2xIOP/src/emulator/imports/iop_format.cpp b/ps2xIOP/src/emulator/imports/iop_format.cpp new file mode 100644 index 0000000000..3d217cf808 --- /dev/null +++ b/ps2xIOP/src/emulator/imports/iop_format.cpp @@ -0,0 +1,108 @@ +#include "iop_format.h" + +#include "../core/iop_memory.h" + +#include +#include +#include + +namespace ps2x::iop::detail +{ + std::string formatIopString(const IopMemory &memory, + const std::string &format, + const std::function &nextArg) + { + std::string out; + out.reserve(format.size() + 32u); + for (size_t i = 0; i < format.size(); ++i) + { + const char c = format[i]; + if (c != '%') + { + out.push_back(c); + continue; + } + + // Rebuild the conversion spec for the host snprintf, dropping length modifiers since + // every guest argument is a 32-bit word. + std::string spec = "%"; + size_t j = i + 1u; + while (j < format.size() && std::string_view("-+ #0").find(format[j]) != std::string_view::npos) + spec.push_back(format[j++]); + const auto readCount = [&]() + { + if (j < format.size() && format[j] == '*') + { + spec += std::to_string(static_cast(nextArg())); + ++j; + return; + } + while (j < format.size() && format[j] >= '0' && format[j] <= '9') + spec.push_back(format[j++]); + }; + readCount(); + if (j < format.size() && format[j] == '.') + { + spec.push_back(format[j++]); + readCount(); + } + while (j < format.size() && std::string_view("hlLqjzt").find(format[j]) != std::string_view::npos) + ++j; + if (j >= format.size()) + { + out.append(format, i, std::string::npos); + break; + } + + const char conv = format[j]; + char buffer[128]; + int written = 0; + switch (conv) + { + case '%': + out.push_back('%'); + break; + case 'd': + case 'i': + spec.push_back('d'); + written = std::snprintf(buffer, sizeof(buffer), spec.c_str(), static_cast(static_cast(nextArg()))); + break; + case 'u': + case 'x': + case 'X': + case 'o': + case 'c': + spec.push_back(conv); + written = std::snprintf(buffer, sizeof(buffer), spec.c_str(), static_cast(nextArg())); + break; + case 'p': + spec.push_back('x'); + written = std::snprintf(buffer, sizeof(buffer), spec.c_str(), static_cast(nextArg())); + break; + case 's': + { + const uint32_t address = nextArg(); + const std::string text = address ? memory.readString(address, 1024u) : std::string("(null)"); + spec.push_back('s'); + const int needed = std::snprintf(nullptr, 0, spec.c_str(), text.c_str()); + if (needed > 0) + { + std::string formatted(static_cast(needed) + 1u, '\0'); + std::snprintf(formatted.data(), formatted.size(), spec.c_str(), text.c_str()); + formatted.resize(static_cast(needed)); + out += formatted; + } + break; + } + default: + // Unknown conversion: keep it verbatim so the log still shows what was intended. + out.append(format, i, j - i + 1u); + break; + } + if (written > 0) + out.append(buffer, static_cast(std::min(written, static_cast(sizeof(buffer)) - 1))); + i = j; + } + return out; + } +} diff --git a/ps2xIOP/src/emulator/imports/iop_format.h b/ps2xIOP/src/emulator/imports/iop_format.h new file mode 100644 index 0000000000..5535ba0169 --- /dev/null +++ b/ps2xIOP/src/emulator/imports/iop_format.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace ps2x::iop::detail +{ + class IopMemory; + + // Expands a guest printf-style format. IOP code is 32-bit integer only, so every argument is + // one word fetched in order through `nextArg` (registers first, then the caller's stack). + [[nodiscard]] std::string formatIopString(const IopMemory &memory, + const std::string &format, + const std::function &nextArg); +} diff --git a/ps2xIOP/src/emulator/imports/iop_stdio.cpp b/ps2xIOP/src/emulator/imports/iop_stdio.cpp index eed2b619fa..1cacc591e6 100644 --- a/ps2xIOP/src/emulator/imports/iop_stdio.cpp +++ b/ps2xIOP/src/emulator/imports/iop_stdio.cpp @@ -1,4 +1,5 @@ #include "iop_stdio.h" +#include "iop_format.h" #include "../core/iop_cpu.h" #include "../core/iop_memory.h" @@ -28,10 +29,36 @@ namespace ps2x::iop::detail setV0(static_cast(text.size()) + resultBias); }; + // o32 varargs: argument words after the format continue in a1..a3, then at sp+16+4n. + const auto logFormatted = [&](std::string_view prefix, uint32_t formatAddress, uint32_t firstArgSlot) + { + uint32_t slot = firstArgSlot; + const auto nextArg = [&]() -> uint32_t + { + const uint32_t index = slot++; + return index < 4u ? cpu.gpr[4u + index] : m_memory.read32(cpu.gpr[29] + index * 4u); + }; + const std::string text = formatIopString(m_memory, m_memory.readString(formatAddress, 2048u), nextArg); + m_host.log(LogLevel::Info, std::string(prefix) + text); + setV0(static_cast(text.size())); + }; + const auto logFormattedList = [&](std::string_view prefix, uint32_t formatAddress, uint32_t argList) + { + const auto nextArg = [&]() -> uint32_t + { + const uint32_t value = m_memory.read32(argList); + argList += 4u; + return value; + }; + const std::string text = formatIopString(m_memory, m_memory.readString(formatAddress, 2048u), nextArg); + m_host.log(LogLevel::Info, std::string(prefix) + text); + setV0(static_cast(text.size())); + }; + switch (ordinal) { case 4: // printf - logString("[IOP printf] ", a0); + logFormatted("[IOP printf] ", a0, 1u); return true; case 5: // getchar case 10: @@ -49,7 +76,7 @@ namespace ps2x::iop::detail setV0(0u); return true; case 9: // fdprintf - logString("[IOP fdprintf] ", a1); + logFormatted("[IOP fdprintf] ", a1, 2u); return true; case 11: setV0(a0 & 0xFFu); @@ -58,7 +85,7 @@ namespace ps2x::iop::detail logString("[IOP fdputs] ", a0); return true; case 14: // vfdprintf - logString("[IOP vfdprintf] ", a1); + logFormattedList("[IOP vfdprintf] ", a1, cpu.gpr[6]); return true; default: return false; diff --git a/ps2xIOP/src/emulator/imports/iop_sysclib.cpp b/ps2xIOP/src/emulator/imports/iop_sysclib.cpp index 0517478495..d19f45d32c 100644 --- a/ps2xIOP/src/emulator/imports/iop_sysclib.cpp +++ b/ps2xIOP/src/emulator/imports/iop_sysclib.cpp @@ -1,4 +1,5 @@ #include "iop_sysclib.h" +#include "iop_format.h" #include "../core/iop_cpu.h" #include "../core/iop_memory.h" @@ -11,6 +12,38 @@ namespace ps2x::iop::detail { + namespace + { + // newlib/sysclib ctype flags: upper, lower, digit, space, punct, control, hex, blank. + constexpr uint8_t kCtypeUpper = 0x01u; + constexpr uint8_t kCtypeLower = 0x02u; + constexpr uint8_t kCtypeDigit = 0x04u; + constexpr uint8_t kCtypeSpace = 0x08u; + constexpr uint8_t kCtypePunct = 0x10u; + constexpr uint8_t kCtypeControl = 0x20u; + constexpr uint8_t kCtypeHex = 0x40u; + constexpr uint8_t kCtypeBlank = 0x80u; + + uint8_t lookCtype(uint8_t c) + { + if (c >= '0' && c <= '9') + return kCtypeDigit; + if (c >= 'A' && c <= 'Z') + return static_cast(kCtypeUpper | (c <= 'F' ? kCtypeHex : 0u)); + if (c >= 'a' && c <= 'z') + return static_cast(kCtypeLower | (c <= 'f' ? kCtypeHex : 0u)); + if (c == ' ') + return static_cast(kCtypeSpace | kCtypeBlank); + if (c >= '\t' && c <= '\r') + return static_cast(kCtypeControl | kCtypeSpace); + if (c < 0x20u || c == 0x7Fu) + return kCtypeControl; + if (c < 0x7Fu) + return kCtypePunct; + return 0u; + } + } + IopSysclib::IopSysclib(IopMemory &memory) noexcept : m_memory(memory) { @@ -73,8 +106,10 @@ namespace ps2x::iop::detail case 7: setV0(static_cast(std::tolower(static_cast(a0)))); return true; - case 8: - case 9: // ctype table is optional for most IRXs. + case 8: // look_ctype_table: isdigit()/isspace() in IRXs test these flags, e.g. when parsing module args. + setV0(lookCtype(static_cast(a0))); + return true; + case 9: // get_ctype_table: no guest-resident table yet. setV0(0); return true; case 10: // memchr @@ -124,15 +159,28 @@ namespace ps2x::iop::detail case 18: // prnt setV0(0); return true; - case 19: // sprintf: preserve useful literal formats even before full vararg formatting. - case 42: // vsprintf fallback: copy format literal. + case 19: // sprintf + case 42: // vsprintf { - const std::string format = m_memory.readString(a1, 4096u); - for (size_t i = 0; i <= format.size(); ++i) + uint32_t slot = 2u; + uint32_t argList = a2; + const auto nextArg = [&]() -> uint32_t + { + if (ordinal == 42u) + { + const uint32_t value = m_memory.read32(argList); + argList += 4u; + return value; + } + const uint32_t index = slot++; + return index < 4u ? cpu.gpr[4u + index] : m_memory.read32(cpu.gpr[29] + index * 4u); + }; + const std::string text = formatIopString(m_memory, m_memory.readString(a1, 4096u), nextArg); + for (size_t i = 0; i <= text.size(); ++i) { - m_memory.write8(a0 + static_cast(i), i < format.size() ? static_cast(format[i]) : 0u); + m_memory.write8(a0 + static_cast(i), i < text.size() ? static_cast(text[i]) : 0u); } - setV0(static_cast(format.size())); + setV0(static_cast(text.size())); return true; } case 20: diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index 216ff556fb..0baa4cadfe 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -763,17 +763,23 @@ namespace ps2x::iop::detail module.gp = loaded.gp; module.reserved = loaded.reserved; - uint32_t args = 0u; - if (arguments && argumentSize) + // Module entries take (argc, argv); drivers such as CRI ADX read their configuration + // ("spucore=1", thread priorities) from argv and silently fall back to defaults otherwise. + const std::span rawArguments(static_cast(arguments), + arguments ? argumentSize : 0u); + uint32_t argc = 0u; + const size_t argvBytes = IopModuleLoader::buildStartArguments(module.name, rawArguments, 0u, argc).size(); + uint32_t args = allocate(static_cast(argvBytes), 16u); + if (args) { - args = allocate(argumentSize + 1u, 16u); - if (args) - { - writeRam(args, arguments, argumentSize); - write8(args + argumentSize, 0u); - } + const std::vector argv = IopModuleLoader::buildStartArguments(module.name, rawArguments, args, argc); + writeRam(args, argv.data(), argv.size()); + } + else + { + argc = 0u; } - const uint32_t startResult = callFunction(module.entry, argumentSize, args, 0u, 0u, module.gp); + const uint32_t startResult = callFunction(module.entry, argc, args, 0u, 0u, module.gp); if (args) freeAllocation(args); module.resident = startResult == 0u || startResult == 2u; @@ -788,6 +794,13 @@ namespace ps2x::iop::detail << " start=" << std::dec << result.startResult; if (const std::string library = exportedLibraryName(modules[result.moduleId]); !library.empty()) out << " exports=" << library; + if (argc > 1u) + { + out << " args=\""; + for (const uint8_t c : rawArguments) + out << (c == 0u ? ' ' : static_cast(c)); + out << '"'; + } log(LogLevel::Info, out.str()); return result; } diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.cpp b/ps2xIOP/src/emulator/services/iop_module_loader.cpp index aa454143de..e079455abd 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.cpp +++ b/ps2xIOP/src/emulator/services/iop_module_loader.cpp @@ -588,4 +588,35 @@ namespace ps2x::iop::detail reservation.address = 0u; return result; } + + std::vector IopModuleLoader::buildStartArguments(std::string_view moduleName, + std::span arguments, + uint32_t base, + uint32_t &argc) + { + std::vector strings{moduleName}; + // The EE sends "a\0b\0..."; a trailing piece without its NUL still counts as an argument. + size_t start = 0u; + for (size_t i = 0u; i < arguments.size(); ++i) + { + if (arguments[i] != 0u) + continue; + strings.emplace_back(reinterpret_cast(arguments.data()) + start, i - start); + start = i + 1u; + } + if (start < arguments.size()) + strings.emplace_back(reinterpret_cast(arguments.data()) + start, arguments.size() - start); + + argc = static_cast(strings.size()); + const uint32_t tableBytes = (argc + 1u) * 4u; + std::vector block(tableBytes, 0u); + for (uint32_t i = 0; i < argc; ++i) + { + const uint32_t pointer = base + static_cast(block.size()); + std::memcpy(block.data() + i * 4u, &pointer, sizeof(pointer)); + block.insert(block.end(), strings[i].begin(), strings[i].end()); + block.push_back(0u); + } + return block; + } } diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.h b/ps2xIOP/src/emulator/services/iop_module_loader.h index a96b21f58e..3c6eadf71f 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.h +++ b/ps2xIOP/src/emulator/services/iop_module_loader.h @@ -45,5 +45,12 @@ namespace ps2x::iop::detail [[nodiscard]] static bool readElfFromGuest(IopHost &host, uint32_t guestAddress, std::vector &bytes); [[nodiscard]] static bool readElfFromIopRam(const IopMemory &memory, uint32_t iopAddress, std::vector &bytes); [[nodiscard]] static IopImageLoadResult load(std::span image, IopMemory &memory); + // Lays out the (argc, argv) a module entry receives, as loadcore does: argv[0] is the module + // name, then each NUL-terminated string of the SIF argument block, then a NULL terminator. + // The block is meant to be copied to IOP address `base`; argv pointers are absolute. + [[nodiscard]] static std::vector buildStartArguments(std::string_view moduleName, + std::span arguments, + uint32_t base, + uint32_t &argc); }; } diff --git a/ps2xIOP/tests/iop_emulator_tests.cpp b/ps2xIOP/tests/iop_emulator_tests.cpp index 4c38378015..483edbef4e 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -182,6 +182,35 @@ namespace std::memcpy(host.guest.data() + address + codeOffset, code, sizeof(code)); } + // Entry returns (argc << 16) | (argv[1][0] << 8) | argv[2][0], or -1 when argv[argc] is not NULL. + void writeArgvProbeIrx(TestHost &host, uint32_t address) + { + writeMinimalIrx(host, address); + const uint32_t code[] = { + 0x00041400u, // sll v0, a0, 16 + 0x8CA80004u, // lw t0, 4(a1) + 0x8CAA0008u, // lw t2, 8(a1) + 0x8CAC000Cu, // lw t4, 12(a1) + 0x91090000u, // lbu t1, 0(t0) + 0x914B0000u, // lbu t3, 0(t2) + 0x00000000u, // nop + 0x00094A00u, // sll t1, t1, 8 + 0x00491025u, // or v0, v0, t1 + 0x004B1025u, // or v0, v0, t3 + 0x11800002u, // beq t4, zero, +2 + 0x00000000u, // nop + 0x2402FFFFu, // addiu v0, zero, -1 + 0x03E00008u, // jr ra + 0x00000000u, // nop + }; + ProgramHeader program{}; + std::memcpy(&program, host.guest.data() + address + sizeof(ElfHeader), sizeof(program)); + program.filesz = sizeof(code); + program.memsz = sizeof(code); + std::memcpy(host.guest.data() + address + sizeof(ElfHeader), &program, sizeof(program)); + std::memcpy(host.guest.data() + address + 0x100u, code, sizeof(code)); + } + void writeRpcServerIrx(TestHost &host, uint32_t address, uint32_t sid = 0xF00DCAFEu, @@ -1459,6 +1488,14 @@ int main() if (!expect(!emittedUnhandledSeek, "sceCdSeek still emitted an unhandled IOP import")) return 1; + // loadcore hands module entries (argc, argv), not the raw SIF argument block. + iop.reset(); + writeArgvProbeIrx(host, 0x100u); + const char probeArgs[] = "a=1\0bcd"; + const ModuleLoadResult argvProbe = iop.loadModuleBuffer(0x100u, probeArgs, sizeof(probeArgs)); + if (!expect(argvProbe.handled && argvProbe.startResult == static_cast((3u << 16) | ('a' << 8) | 'b'), + "IRX entry did not receive argc/argv built from the SIF arguments")) return 1; + std::cout << "ps2xIOP emulator smoke tests passed\n"; return 0; } diff --git a/ps2xIOP/tests/iop_import_tests.cpp b/ps2xIOP/tests/iop_import_tests.cpp index a8e42c529a..a0b54ed5dd 100644 --- a/ps2xIOP/tests/iop_import_tests.cpp +++ b/ps2xIOP/tests/iop_import_tests.cpp @@ -6,7 +6,10 @@ #include "emulator/imports/iop_dmacman.h" #include "emulator/imports/iop_imports.h" #include "emulator/imports/iop_loadcore.h" +#include "emulator/imports/iop_stdio.h" +#include "emulator/imports/iop_sysclib.h" #include "emulator/imports/iop_timrman.h" +#include "emulator/services/iop_module_loader.h" #include "emulator/services/iop_rpc.h" #include "ps2x/iop/iop_host.h" @@ -766,6 +769,106 @@ namespace expect(call(9u, kChannelSio2Out) == memory.read32(0x1F801558u), "dmac_ch_get_chcr should read the channel register") && expect(call(28u, 6u, kSendBuffer, 1u, 1u) == 0u, "sceSetSliceDMA must reject the OTC channel"); } + + // Module entries get (argc, argv): CRI ADX reads "spucore=1" from argv and otherwise streams on + // core 0, where its looping block transfer then blocks every voice transfer on that DMA channel. + bool testModuleStartArgumentsBuildArgv() + { + const uint8_t raw[] = {'s', 'p', 'u', 'c', 'o', 'r', 'e', '=', '1', 0, 't', 'h', 'p', 'r', 'i', '=', '3', '9', 0}; + constexpr uint32_t base = 0x40000u; + uint32_t argc = 0u; + const std::vector block = IopModuleLoader::buildStartArguments("cri_adxi.irx", raw, base, argc); + const auto word = [&](uint32_t index) + { + uint32_t value = 0u; + std::memcpy(&value, block.data() + index * 4u, sizeof(value)); + return value; + }; + const auto string = [&](uint32_t index) + { + const uint32_t offset = word(index) - base; + return offset < block.size() ? std::string(reinterpret_cast(block.data() + offset)) : std::string(); + }; + if (!expect(argc == 3u, "argc must count argv[0] plus each NUL-terminated argument") || + !expect(string(0) == "cri_adxi.irx", "argv[0] must be the module name") || + !expect(string(1) == "spucore=1" && string(2) == "thpri=39", "argv[1..] must be the SIF argument strings") || + !expect(word(3) == 0u, "argv must be NULL-terminated")) + return false; + + const uint8_t unterminated[] = {'a', 0, 'b', 'c'}; + uint32_t tailArgc = 0u; + (void)IopModuleLoader::buildStartArguments("m", unterminated, 0u, tailArgc); + uint32_t emptyArgc = 0u; + const std::vector empty = IopModuleLoader::buildStartArguments("m", {}, 0x100u, emptyArgc); + return expect(tailArgc == 3u, "a trailing argument without NUL must still be passed") && + expect(emptyArgc == 1u && empty.size() == 8u + 2u, "no arguments must still pass argv[0] and a NULL"); + } + + // Drivers log their state through printf; an unexpanded "%d" hides the values needed to debug them. + bool testPrintfAndSprintfExpandArguments() + { + class LogHost final : public NullHost + { + public: + void log(LogLevel, std::string_view message) override { last = std::string(message); } + std::string last; + }; + + LogHost host; + IopMemory memory; + IopStdio stdio(host, memory); + IopSysclib sysclib(memory); + const char format[] = "Repeat %s %d %d %x %05u|%-3s|%c%%"; + const char name[] = "Midi"; + (void)memory.writeRam(0x30000u, format, sizeof(format)); + (void)memory.writeRam(0x30100u, name, sizeof(name)); + + IopCpuState cpu{}; + cpu.gpr[29] = 0x31000u; + cpu.gpr[4] = 0x30000u; + cpu.gpr[5] = 0x30100u; + cpu.gpr[6] = static_cast(-2); + cpu.gpr[7] = 1u; + // Arguments past a3 live in the caller's outgoing area, starting at sp+16. + const uint32_t stackArgs[] = {0xBEEFu, 42u, 0x30102u, 'Z'}; + (void)memory.writeRam(0x31010u, stackArgs, sizeof(stackArgs)); + if (!expect(stdio.dispatchImport(4u, cpu), "printf was not handled") || + !expect(host.last == "[IOP printf] Repeat Midi -2 1 beef 00042|di |Z%", "printf must expand register and stack arguments")) + return false; + + // sprintf(buffer, format, ...) starts its arguments at a2. + const char sformat[] = "%s=%d"; + (void)memory.writeRam(0x30200u, sformat, sizeof(sformat)); + cpu.gpr[4] = 0x30300u; + cpu.gpr[5] = 0x30200u; + cpu.gpr[6] = 0x30100u; + cpu.gpr[7] = 7u; + char out[16] = {}; + return expect(sysclib.dispatchImport(19u, cpu), "sprintf was not handled") && + expect(memory.readRam(0x30300u, out, sizeof(out)) && std::string(out) == "Midi=7", "sprintf must expand its arguments") && + expect(cpu.gpr[2] == 6u, "sprintf must return the formatted length"); + } + + // CRI ADX only accepts "spucore=1" when look_ctype_table reports argv[i][8] as a digit; a stub + // returning 0 left it streaming on core 0 and starved every voice transfer on that DMA channel. + bool testLookCtypeTableClassifiesCharacters() + { + IopMemory memory; + IopSysclib sysclib(memory); + const auto look = [&](char c) + { + IopCpuState cpu{}; + cpu.gpr[4] = static_cast(static_cast(c)); + cpu.gpr[2] = 0xFFFFu; + return sysclib.dispatchImport(8u, cpu) ? cpu.gpr[2] : 0xFFFFu; + }; + return expect(look('1') == 0x04u, "digits must report _N") && + expect(look('A') == 0x41u && look('Z') == 0x01u, "upper case must report _U (and _X for A-F)") && + expect(look('f') == 0x42u && look('g') == 0x02u, "lower case must report _L (and _X for a-f)") && + expect(look(' ') == 0x88u && look('\n') == 0x28u, "white space must report _S") && + expect(look('=') == 0x10u && look('\x01') == 0x20u, "punctuation and control characters must be classified") && + expect(look('\xC0') == 0u, "characters above ASCII have no class"); + } } int main() @@ -774,7 +877,8 @@ int main() !testTimrmanPeriodicCallback() || !testSio2MemoryCard() || !testSliceEndDoesNotHandTheCpuToAnEqualPriorityThread() || !testSliceEndInsideCpuSuspendIntrResumesSameThread() || !testSpu2AutoDmaCompletesAtPlaybackRate() || !testDmacmanDrivesSio2Dma() || !testSio2DualShock2() || !testSifGetOtherData() || !testUnalignedLoadPair() || - !testRpcServerKeepsItsThreadPriority()) + !testRpcServerKeepsItsThreadPriority() || !testModuleStartArgumentsBuildArgv() || + !testPrintfAndSprintfExpandArguments() || !testLookCtypeTableClassifiesCharacters()) return 1; std::cout << "ps2xIOP import tests passed\n"; return 0; diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.cpp b/ps2xRuntime/src/lib/ps2_audio_stream.cpp index 821e3908bc..cc29dfef70 100644 --- a/ps2xRuntime/src/lib/ps2_audio_stream.cpp +++ b/ps2xRuntime/src/lib/ps2_audio_stream.cpp @@ -3,6 +3,9 @@ #include "ps2_host_backend.h" #include +#include +#include +#include #include Spu2FrameRing::Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames) @@ -149,8 +152,75 @@ Spu2AudioOutput::Spu2AudioOutput() : m_impl(std::make_unique()) Spu2AudioOutput::~Spu2AudioOutput() = default; +namespace +{ + // PS2X_AUDIO_CAPTURE= records the SPU2 mix as the runtime produces it, independent + // of the host device, so audio regressions can be compared offline. + class Spu2WavCapture + { + public: + Spu2WavCapture() + { + const char *path = std::getenv("PS2X_AUDIO_CAPTURE"); + if (!path || !*path) + return; + m_file = std::fopen(path, "wb"); + if (!m_file) + { + std::fprintf(stderr, "[audio] PS2X_AUDIO_CAPTURE: cannot open '%s' for writing\n", path); + return; + } + writeHeader(); + } + + ~Spu2WavCapture() + { + if (m_file) + std::fclose(m_file); + } + + void append(const int16_t *samples, size_t frames) + { + if (!m_file || frames == 0u) + return; + std::fwrite(samples, sizeof(int16_t) * 2u, frames, m_file); + m_dataBytes += static_cast(frames * sizeof(int16_t) * 2u); + // Keep the header valid so a killed process still leaves a playable file. + writeHeader(); + std::fseek(m_file, 0, SEEK_END); + } + + private: + static void put32(uint8_t *p, uint32_t v) + { + p[0] = static_cast(v); + p[1] = static_cast(v >> 8u); + p[2] = static_cast(v >> 16u); + p[3] = static_cast(v >> 24u); + } + + void writeHeader() + { + uint8_t h[44] = {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'A', 'V', 'E', 'f', 'm', 't', ' ', + 16, 0, 0, 0, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 16, 0, + 'd', 'a', 't', 'a', 0, 0, 0, 0}; + put32(h + 4, 36u + m_dataBytes); + put32(h + 24, kSpu2SampleRate); + put32(h + 28, kSpu2SampleRate * 4u); + put32(h + 40, m_dataBytes); + std::fseek(m_file, 0, SEEK_SET); + std::fwrite(h, 1, sizeof(h), m_file); + } + + std::FILE *m_file = nullptr; + uint32_t m_dataBytes = 0u; + }; +} + void Spu2AudioOutput::submit(const int16_t *samples, size_t frames) { + static Spu2WavCapture capture; + capture.append(samples, frames); if (!m_impl->started && !m_impl->unavailable) m_impl->start(); if (m_impl->started) From 46d49670a54024928d9a03509afeac0163c6074f Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:47:33 -0300 Subject: [PATCH 73/91] sceGszbufaddr: read psm/w/h from a0-a2 and count one buffer only in interlaced field mode, like libgraph --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 81 ++++++++----------------- ps2xTest/src/ps2_gs_tests.cpp | 61 +++++++++++++++++++ 2 files changed, 85 insertions(+), 57 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index c361e8e781..7c418ea607 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -51,6 +51,19 @@ namespace ps2_stubs clear.testb = {restoreTest, testReg}; } + // libgraph's sceGszbufaddr(psm, w, h): the page where Z starts. It counts one frame buffer when the + // CRT is interlaced in field mode and two otherwise; 16-bit formats use 64-line pages. + int32_t libgraphZbufAddr(uint32_t psm, int32_t w, int32_t h) + { + const int32_t widthPages = ((w + 63 < 0) ? (w + 126) : (w + 63)) >> 6; + const int32_t heightPages = ((psm & 2u) != 0u) + ? (((h + 63 < 0) ? (h + 126) : (h + 63)) >> 6) + : (((h + 31 < 0) ? (h + 62) : (h + 31)) >> 5); + const int32_t pages = widthPages * heightPages; + const bool fieldInterlace = g_gparam.interlace == 1u && g_gparam.ffmode == 0u; + return static_cast(fieldInterlace ? pages : pages * 2); + } + bool hasSeededGsClearPacket(const GsClearMem &clear) { return clear.rgbaq.reg == GS_REG_RGBAQ && @@ -960,12 +973,8 @@ namespace ps2_stubs const int32_t drawWidth = static_cast(w); const int32_t drawHeight = static_cast(h); - uint32_t zbufAddr = 0u; - { - R5900Context temp = *ctx; - sceGszbufaddr(rdram, &temp, runtime); - zbufAddr = getRegU32(&temp, 2); - } + const uint32_t zbufAddr = static_cast( + libgraphZbufAddr(psm, static_cast(w), static_cast(h))); // sceGszbufaddr places Z after both frame buffers, so the second buffer starts halfway there. const uint32_t fbp1 = zbufAddr / 2u; @@ -1033,12 +1042,8 @@ namespace ps2_stubs const int32_t drawWidth = static_cast(w); const int32_t drawHeight = static_cast(h); - uint32_t zbufAddr = 0u; - { - R5900Context temp = *ctx; - sceGszbufaddr(rdram, &temp, runtime); - zbufAddr = getRegU32(&temp, 2); - } + const uint32_t zbufAddr = static_cast( + libgraphZbufAddr(psm, static_cast(w), static_cast(h))); GsDBuffMem db{}; db.disp[0].pmode = pmode; @@ -1101,8 +1106,7 @@ namespace ps2_stubs uint32_t psm = param_2 & 0xFU; uint32_t fbw = ((static_cast(w) + 63u) >> 6) & 0x3FU; - sceGszbufaddr(rdram, ctx, runtime); - int32_t zbuf = static_cast(static_cast(getRegU32(ctx, 2) & 0xFFFF)); + const int32_t zbuf = libgraphZbufAddr(static_cast(static_cast(param_2)), w, h); GsDrawEnv1Mem env{}; seedGsDrawEnv1(env, @@ -1144,8 +1148,7 @@ namespace ps2_stubs uint32_t psm = param_2 & 0xFU; uint32_t fbw = ((static_cast(w) + 63u) >> 6) & 0x3FU; - sceGszbufaddr(rdram, ctx, runtime); - int32_t zbuf = static_cast(static_cast(getRegU32(ctx, 2) & 0xFFFF)); + const int32_t zbuf = libgraphZbufAddr(static_cast(static_cast(param_2)), w, h); GsDrawEnv2Mem env{}; seedGsDrawEnv2(env, @@ -1404,47 +1407,11 @@ namespace ps2_stubs void sceGszbufaddr(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { (void)rdram; - uint32_t param_1 = getRegU32(ctx, 4); - int32_t w = static_cast(static_cast(getRegU32(ctx, 6) & 0xFFFF)); - int32_t h = static_cast(static_cast(getRegU32(ctx, 7) & 0xFFFF)); - - int32_t width_blocks = (w + 63) >> 6; - if (w + 63 < 0) - width_blocks = (w + 126) >> 6; - - int32_t height_blocks; - if ((param_1 & 2) != 0) - { - int32_t v = (h + 63) >> 6; - if (h + 63 < 0) - v = (h + 126) >> 6; - height_blocks = v; - } - else - { - int32_t v = (h + 31) >> 5; - if (h + 31 < 0) - v = (h + 62) >> 5; - height_blocks = v; - } - - int32_t product = width_blocks * height_blocks; - - uint64_t gparam_val = 0; - if (runtime) - { - uint8_t *scratch = runtime->memory().getScratchpad(); - if (scratch) - { - std::memcpy(&gparam_val, scratch + 0x100, sizeof(gparam_val)); - } - } - if ((gparam_val & 0xFFFF0000FFFFULL) == 1ULL) - product = (product * 0x10000) >> 16; - else - product = (product * 0x20000) >> 16; - - setReturnS32(ctx, product); + (void)runtime; + const uint32_t psm = static_cast(static_cast(getRegU32(ctx, 4))); + const int32_t w = static_cast(getRegU32(ctx, 5)); + const int32_t h = static_cast(getRegU32(ctx, 6)); + setReturnS32(ctx, libgraphZbufAddr(psm, w, h)); } void Ps2SwapDBuff(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 4a3bb2f3d2..1a1eee57bc 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -362,6 +362,17 @@ namespace return readReferencePSMCT32Pixel(vram, frameBaseToBlock(fbp), fbw, x, y); } + // Sets libgraph's CRT parameters the way the game does, through the HLE sceGsResetGraph. + void resetGraphForTest(std::vector &rdram, PS2Runtime &runtime, uint32_t interlace, uint32_t ffmode) + { + R5900Context ctx{}; + setRegU32(ctx, 4, 0u); + setRegU32(ctx, 5, interlace); + setRegU32(ctx, 6, 2u); // NTSC + setRegU32(ctx, 7, ffmode); + ps2_stubs::sceGsResetGraph(rdram.data(), &ctx, &runtime); + } + void expectGuestHeapReusable(TestCase &t, PS2Runtime &runtime, const char *message) { const uint32_t expectedBase = runtime.guestHeapBase(); @@ -829,6 +840,56 @@ void register_ps2_gs_tests() t.Equals(gs.ReadVram(GS_PSM_CT32, 0u, 1u, 12u, 12u), 0xABABABABu, "pixels outside the rectangle are untouched"); }); + tc.Run("sceGszbufaddr takes psm, width and height in a0-a2 and counts one buffer in field mode", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + auto zbufaddr = [&](uint32_t psm, uint32_t w, uint32_t h) + { + R5900Context ctx{}; + setRegU32(ctx, 4, psm); + setRegU32(ctx, 5, w); + setRegU32(ctx, 6, h); + setRegU32(ctx, 7, 0x1234u); // not an argument + ps2_stubs::sceGszbufaddr(rdram.data(), &ctx, &runtime); + return getRegU32Test(ctx, 2); + }; + + resetGraphForTest(rdram, runtime, 0u, 0u); + t.Equals(zbufaddr(GS_PSM_CT32, 640u, 448u), 280u, "progressive: Z follows two 140-page CT32 buffers"); + t.Equals(zbufaddr(GS_PSM_CT16, 640u, 448u), 140u, "CT16 uses 64-line pages: two 70-page buffers"); + + resetGraphForTest(rdram, runtime, 1u, 0u); + t.Equals(zbufaddr(GS_PSM_CT32, 640u, 448u), 140u, "interlaced field mode counts a single buffer"); + + resetGraphForTest(rdram, runtime, 1u, 1u); + t.Equals(zbufaddr(GS_PSM_CT32, 640u, 448u), 280u, "interlaced frame mode counts both buffers"); + }); + + tc.Run("sceGsSetDefDrawEnv places ZBUF with the draw psm, not the env pointer", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + resetGraphForTest(rdram, runtime, 1u, 0u); + + // Mana Khemia's 2D layer: SetDefDrawEnv(env, PSMCT16, 640, 448, ztest, zpsm 0). + constexpr uint32_t kEnvAddr = 0x4000u; + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, GS_PSM_CT16); + setRegU32(ctx, 6, 640u); + setRegU32(ctx, 7, 448u); + setRegU32(ctx, 8, 1u); + setRegU32(ctx, 9, 0u); + ps2_stubs::sceGsSetDefDrawEnv(rdram.data(), &ctx, &runtime); + + GsDrawEnv1Mem env{}; + std::memcpy(&env, rdram.data() + kEnvAddr, sizeof(env)); + t.Equals(env.zbuf1.value & 0x1FFull, 70ull, "ZBP should be libgraph's sceGszbufaddr(CT16, 640, 448)"); + }); + tc.Run("XYZ3 culls a triangle strip primitive without desynchronizing the vertex queue", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From 0a8b9b6c44aa42308df6e3f7bb73b466a54a413c Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:50:30 -0300 Subject: [PATCH 74/91] sceVu0ScaleVector: take the scale from f12 only so a 0.0f scale zeroes the vector --- ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp | 12 ++---------- ps2xTest/src/ps2_vu_tests.cpp | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp index 30130e6072..4058d9864e 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp @@ -934,16 +934,8 @@ namespace ps2_stubs const uint32_t dstAddr = getRegU32(ctx, 4); const uint32_t srcAddr = getRegU32(ctx, 5); float src[4]{}, out[4]{}; - float scale = ctx ? ctx->f[12] : 0.0f; - if (scale == 0.0f) - { - uint32_t raw = getRegU32(ctx, 6); - std::memcpy(&scale, &raw, sizeof(scale)); - if (scale == 0.0f) - { - scale = static_cast(getRegU32(ctx, 6)); - } - } + // libvu0 takes the scale from f12 only; a2 is not an argument, so a 0.0f scale must yield zero. + const float scale = ctx ? ctx->f[12] : 0.0f; if (readVuVec4f(rdram, srcAddr, src)) { diff --git a/ps2xTest/src/ps2_vu_tests.cpp b/ps2xTest/src/ps2_vu_tests.cpp index ae0d391dc1..52e55d584d 100644 --- a/ps2xTest/src/ps2_vu_tests.cpp +++ b/ps2xTest/src/ps2_vu_tests.cpp @@ -97,6 +97,22 @@ void register_ps2_vu_tests() "MulVector should multiply lanes component-wise"); }); + tc.Run("ScaleVector_zero_scale_ignores_a2", [](TestCase &t) + { + // libvu0 reads the scale from f12 only; a stale a2 must not stand in for a 0.0f scale. + VuEnv env; + writeVec4(env, kA, 2.0f, 4.0f, 6.0f, 8.0f); + env.ctx.f[12] = 0.0f; + SET_GPR_U32(&env.ctx, 4, kDst); + SET_GPR_U32(&env.ctx, 5, kA); + SET_GPR_U32(&env.ctx, 6, 0x3F800000u); + ps2_stubs::sceVu0ScaleVector(env.rdram.data(), &env.ctx, &env.runtime); + float out[4]{}; + readVec4f(env, kDst, out); + t.IsTrue(out[0] == 0.0f && out[1] == 0.0f && out[2] == 0.0f && out[3] == 0.0f, + "ScaleVector by 0.0f should zero every lane"); + }); + tc.Run("ScaleVectorXYZ_scales_xyz", [](TestCase &t) { VuEnv env; From 1a20e009b69616599e01fabbb80589302cb41e17 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:50:48 -0300 Subject: [PATCH 75/91] sceVu0NormalLightMatrix: normalize light xyz only and keep w out of the matrix, like libvu0 --- ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp | 7 +++++-- ps2xTest/src/ps2_vu_tests.cpp | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp index 4058d9864e..bd16ff6ea1 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp @@ -761,12 +761,15 @@ namespace ps2_stubs float l0[4]{}, l1[4]{}, l2[4]{}; if (readVuVec4f(rdram, l0Addr, l0) && readVuVec4f(rdram, l1Addr, l1) && readVuVec4f(rdram, l2Addr, l2)) { + // libvu0 runs ScaleVector(-1) then Normalize, which measures and scales xyz only and clears w, + // so a light's w (often 1.0) neither shortens the direction nor leaks into the matrix. auto negNormalize = [](const float (&s)[4], float (&o)[4]) { - const float len = std::sqrt((s[0] * s[0]) + (s[1] * s[1]) + (s[2] * s[2]) + (s[3] * s[3])); + const float len = std::sqrt((s[0] * s[0]) + (s[1] * s[1]) + (s[2] * s[2])); const float inv = (len > 1.0e-6f) ? (1.0f / len) : 0.0f; - for (int i = 0; i < 4; ++i) + for (int i = 0; i < 3; ++i) o[i] = -s[i] * inv; + o[3] = 0.0f; }; float r0[4]{}, r1[4]{}, r2[4]{}; negNormalize(l0, r0); diff --git a/ps2xTest/src/ps2_vu_tests.cpp b/ps2xTest/src/ps2_vu_tests.cpp index 52e55d584d..c744d8b2c7 100644 --- a/ps2xTest/src/ps2_vu_tests.cpp +++ b/ps2xTest/src/ps2_vu_tests.cpp @@ -784,6 +784,26 @@ void register_ps2_vu_tests() "NormalLightMatrix should transpose the light-direction rows into columns"); }); + tc.Run("NormalLightMatrix_ignores_light_w", [](TestCase &t) + { + // Lights stored as positions carry w=1; libvu0's Normalize uses xyz only and clears w. + VuEnv env; + writeVec4(env, kA, 0.0f, 0.0f, 2.0f, 1.0f); + writeVec4(env, kB, 0.0f, 3.0f, 0.0f, 1.0f); + writeVec4(env, kC, 4.0f, 0.0f, 0.0f, 1.0f); + SET_GPR_U32(&env.ctx, 4, kDst); + SET_GPR_U32(&env.ctx, 5, kA); + SET_GPR_U32(&env.ctx, 6, kB); + SET_GPR_U32(&env.ctx, 7, kC); + ps2_stubs::sceVu0NormalLightMatrix(env.rdram.data(), &env.ctx, &env.runtime); + float out[16]{}; + readMat4(env, kDst, out); + t.IsTrue(nearlyEqual(out[8], -1.0f) && nearlyEqual(out[5], -1.0f) && nearlyEqual(out[2], -1.0f), + "each light column should be the unit -xyz direction"); + t.IsTrue(out[12] == 0.0f && out[13] == 0.0f && out[14] == 0.0f && out[15] == 1.0f, + "row 3 should be (0,0,0,1) whatever the lights' w"); + }); + tc.Run("LightColorMatrix_verbatim_rows", [](TestCase &t) { VuEnv env; From 9d6ab15b0857a71d89db8282041c51703b178cbc Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:51:08 -0300 Subject: [PATCH 76/91] sceVu0RotTransPers(N): mode 0 converts all lanes with FTOI4, nonzero mode truncates z/w with FTOI0 --- ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp | 5 +++-- ps2xTest/src/ps2_vu_tests.cpp | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp index bd16ff6ea1..bb547c7273 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp @@ -895,7 +895,8 @@ namespace ps2_stubs const uint32_t dstAddr = getRegU32(ctx, 4); const uint32_t matAddr = getRegU32(ctx, 5); const uint32_t vAddr = getRegU32(ctx, 6); - const bool fullFtoi4 = (getRegU32(ctx, 7) != 0); + // libvu0 applies VFTOI0 to z/w only when mode is nonzero; mode 0 keeps all four lanes in 12.4. + const bool fullFtoi4 = (getRegU32(ctx, 7) == 0); float m[16]{}, v[4]{}; if (readVuMatrix4f(rdram, matAddr, m) && readVuVec4f(rdram, vAddr, v)) { @@ -912,7 +913,7 @@ namespace ps2_stubs const uint32_t matAddr = getRegU32(ctx, 5); uint32_t vAddr = getRegU32(ctx, 6); const int32_t count = static_cast(getRegU32(ctx, 7)); - const bool fullFtoi4 = (getRegU32(ctx, 8) != 0); + const bool fullFtoi4 = (getRegU32(ctx, 8) == 0); float m[16]{}; if (readVuMatrix4f(rdram, matAddr, m)) { diff --git a/ps2xTest/src/ps2_vu_tests.cpp b/ps2xTest/src/ps2_vu_tests.cpp index c744d8b2c7..4d79b9d9ba 100644 --- a/ps2xTest/src/ps2_vu_tests.cpp +++ b/ps2xTest/src/ps2_vu_tests.cpp @@ -833,7 +833,7 @@ void register_ps2_vu_tests() SET_GPR_U32(&env.ctx, 4, kDst); SET_GPR_U32(&env.ctx, 5, kA); SET_GPR_U32(&env.ctx, 6, kB); - SET_GPR_U32(&env.ctx, 7, 1u); // fullFtoi4 = true + SET_GPR_U32(&env.ctx, 7, 0u); // mode 0: all four lanes VFTOI4 ps2_stubs::sceVu0RotTransPers(env.rdram.data(), &env.ctx, &env.runtime); int32_t out[4]{}; readVec4i(env, kDst, out); @@ -841,7 +841,7 @@ void register_ps2_vu_tests() "RotTransPers should perspective-divide x/y/z then FTOI4, with w taking the un-divided FTOI4 value"); }); - tc.Run("RotTransPers_ftoi0_z_when_flag0", [](TestCase &t) + tc.Run("RotTransPers_ftoi0_zw_when_mode_nonzero", [](TestCase &t) { VuEnv env; float ident[16]{}; @@ -851,12 +851,12 @@ void register_ps2_vu_tests() SET_GPR_U32(&env.ctx, 4, kDst); SET_GPR_U32(&env.ctx, 5, kA); SET_GPR_U32(&env.ctx, 6, kB); - SET_GPR_U32(&env.ctx, 7, 0u); // fullFtoi4 = false + SET_GPR_U32(&env.ctx, 7, 1u); // libvu0: mode != 0 runs VFTOI0 on z/w (integer Z for 32-bit Z buffers) ps2_stubs::sceVu0RotTransPers(env.rdram.data(), &env.ctx, &env.runtime); int32_t out[4]{}; readVec4i(env, kDst, out); - t.IsTrue(out[2] == 8 && out[3] == 2, - "RotTransPers should FTOI0-truncate z/w instead of FTOI4 when fullFtoi4 is clear"); + t.IsTrue(out[0] == 256 && out[1] == 512 && out[2] == 8 && out[3] == 2, + "RotTransPers should keep x/y in 12.4 and FTOI0-truncate z/w when mode is nonzero"); }); tc.Run("RotTransPers_persp_wzero_zero", [](TestCase &t) @@ -889,7 +889,7 @@ void register_ps2_vu_tests() // t1 = m1*1 + m5*2 + m9*3 + m13*1 = 3 + 2 + 0 + 20 = 25 // t2 = m2*1 + m6*2 + m10*3 + m14*1 = 0 + 8 + 3 + 30 = 41 // t3 = m3*1 + m7*2 + m11*3 + m15*1 = 1 - // fullFtoi4=true => every lane x16: (432, 400, 656, 16). + // mode 0 => every lane x16: (432, 400, 656, 16). VuEnv env; float m[16] = { 2.0f, 3.0f, 0.0f, 0.0f, @@ -902,7 +902,7 @@ void register_ps2_vu_tests() SET_GPR_U32(&env.ctx, 4, kDst); SET_GPR_U32(&env.ctx, 5, kA); SET_GPR_U32(&env.ctx, 6, kB); - SET_GPR_U32(&env.ctx, 7, 1u); // fullFtoi4 = true + SET_GPR_U32(&env.ctx, 7, 0u); // mode 0: all four lanes VFTOI4 ps2_stubs::sceVu0RotTransPers(env.rdram.data(), &env.ctx, &env.runtime); int32_t out[4]{}; readVec4i(env, kDst, out); @@ -922,7 +922,7 @@ void register_ps2_vu_tests() SET_GPR_U32(&env.ctx, 5, kA); SET_GPR_U32(&env.ctx, 6, kArr); SET_GPR_U32(&env.ctx, 7, 2u); - SET_GPR_U32(&env.ctx, 8, 1u); + SET_GPR_U32(&env.ctx, 8, 0u); // mode 0: all four lanes VFTOI4 ps2_stubs::sceVu0RotTransPersN(env.rdram.data(), &env.ctx, &env.runtime); int32_t out0[4]{}, out1[4]{}; readVec4i(env, kDst, out0); From ef27296655db82fdf43c50475b99f1a7469c8a37 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:51:14 -0300 Subject: [PATCH 77/91] sceVu0ClipScreen/ClipScreen3/ClipAll: return libvu0's sticky sign/zero flags and test w against its bounds --- ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp | 41 ++++++++++------- ps2xTest/src/ps2_vu_tests.cpp | 60 ++++++++++++++++++------- 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp index bb547c7273..ce19accb83 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/VU.cpp @@ -168,21 +168,28 @@ namespace ps2_stubs out[3] = fullFtoi4 ? static_cast(t[3] * 16.0f) : static_cast(t[3]); } - // Guard-band proxy for the COP2 sticky clip flags: nonzero => the - // vertex is offscreen. Not the hardware per-plane flag layout. - constexpr float kScreenClipGuard = 4096.0f; + // libvu0's clip tests VSUB the bounds from the vertex and return the VU0 status flags masked to the + // sticky sign (0x80) and zero (0x40) bits, so a vertex counts as inside only when every difference + // is strictly positive. + constexpr uint32_t kVuStickySign = 0x80u; + constexpr uint32_t kVuStickyZero = 0x40u; + uint32_t vuStickyFlags(float difference) + { + uint32_t flags = 0u; + if (std::signbit(difference)) + flags |= kVuStickySign; + if (difference == 0.0f) + flags |= kVuStickyZero; + return flags; + } + + // sceVu0ClipScreen: x and y must lie in (0, 4096) and w must be positive. + constexpr float kScreenClipLimit = 4096.0f; int32_t screenClipCode(const float (&v)[4]) { - int32_t code = 0; - if (v[0] > kScreenClipGuard) - code |= 0x1; - if (v[0] < -kScreenClipGuard) - code |= 0x2; - if (v[1] > kScreenClipGuard) - code |= 0x4; - if (v[1] < -kScreenClipGuard) - code |= 0x8; - return code; + const uint32_t flags = vuStickyFlags(v[0]) | vuStickyFlags(v[1]) | vuStickyFlags(v[3]) | + vuStickyFlags(kScreenClipLimit - v[0]) | vuStickyFlags(kScreenClipLimit - v[1]); + return static_cast(flags); } } @@ -321,9 +328,11 @@ namespace ps2_stubs const float tx = (m[0] * v[0]) + (m[4] * v[1]) + (m[8] * v[2]) + (m[12] * v[3]); const float ty = (m[1] * v[0]) + (m[5] * v[1]) + (m[9] * v[2]) + (m[13] * v[3]); const float tw = (m[3] * v[0]) + (m[7] * v[1]) + (m[11] * v[2]) + (m[15] * v[3]); - const bool outsideX = (tx < lo[0] * tw) || (tx > hi[0] * tw); - const bool outsideY = (ty < lo[1] * tw) || (ty > hi[1] * tw); - if (!outsideX && !outsideY) + // x/y bounds scale with w; w itself is checked against the raw lo.w/hi.w (near/far). + const uint32_t flags = vuStickyFlags(tx - (lo[0] * tw)) | vuStickyFlags(ty - (lo[1] * tw)) | + vuStickyFlags(tw - lo[3]) | vuStickyFlags((hi[0] * tw) - tx) | + vuStickyFlags((hi[1] * tw) - ty) | vuStickyFlags(hi[3] - tw); + if (flags == 0u) { result = 0; break; diff --git a/ps2xTest/src/ps2_vu_tests.cpp b/ps2xTest/src/ps2_vu_tests.cpp index 4d79b9d9ba..cea28e233a 100644 --- a/ps2xTest/src/ps2_vu_tests.cpp +++ b/ps2xTest/src/ps2_vu_tests.cpp @@ -948,7 +948,7 @@ void register_ps2_vu_tests() tc.Run("ClipScreen_inside_zero", [](TestCase &t) { VuEnv env; - writeVec4(env, kA, 100.0f, 100.0f, 0.0f, 0.0f); + writeVec4(env, kA, 100.0f, 100.0f, 0.0f, 1.0f); SET_GPR_U32(&env.ctx, 4, kA); ps2_stubs::sceVu0ClipScreen(env.rdram.data(), &env.ctx, &env.runtime); t.Equals(getRegU32(&env.ctx, 2), 0u, "ClipScreen should return 0 for an in-bounds vertex"); @@ -957,22 +957,33 @@ void register_ps2_vu_tests() tc.Run("ClipScreen_offscreen_nonzero", [](TestCase &t) { VuEnv env; - writeVec4(env, kA, 5000.0f, 100.0f, 0.0f, 0.0f); + writeVec4(env, kA, 5000.0f, 100.0f, 0.0f, 1.0f); SET_GPR_U32(&env.ctx, 4, kA); ps2_stubs::sceVu0ClipScreen(env.rdram.data(), &env.ctx, &env.runtime); - t.IsTrue(getRegU32(&env.ctx, 2) != 0u, "ClipScreen should return nonzero when x exceeds the guard band"); + t.Equals(getRegU32(&env.ctx, 2), 0x80u, "ClipScreen should report the sticky sign flag when x exceeds 4096"); }); tc.Run("ClipScreen_negx_nonzero", [](TestCase &t) { - // Pins the negative-x guard-band clause (v[0] < -kScreenClipGuard => 0x2), - // the mirror of ClipScreen_offscreen_nonzero's +x clause. Without this - // case, deleting the negative-x clause leaves the suite green. + // libvu0 tests x against 0, not a negative guard band: any negative x is offscreen. VuEnv env; - writeVec4(env, kA, -5000.0f, 100.0f, 0.0f, 0.0f); + writeVec4(env, kA, -100.0f, 100.0f, 0.0f, 1.0f); SET_GPR_U32(&env.ctx, 4, kA); ps2_stubs::sceVu0ClipScreen(env.rdram.data(), &env.ctx, &env.runtime); - t.IsTrue(getRegU32(&env.ctx, 2) != 0u, "ClipScreen should return nonzero when x is below the negative guard band"); + t.Equals(getRegU32(&env.ctx, 2), 0x80u, "ClipScreen should flag a vertex left of x=0"); + }); + + tc.Run("ClipScreen_w_and_edges", [](TestCase &t) + { + VuEnv env; + writeVec4(env, kA, 100.0f, 100.0f, 0.0f, -1.0f); + SET_GPR_U32(&env.ctx, 4, kA); + ps2_stubs::sceVu0ClipScreen(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegU32(&env.ctx, 2), 0x80u, "a vertex behind the eye (w < 0) is offscreen"); + + writeVec4(env, kA, 4096.0f, 100.0f, 0.0f, 1.0f); + ps2_stubs::sceVu0ClipScreen(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(getRegU32(&env.ctx, 2), 0x40u, "x exactly on the 4096 edge sets the sticky zero flag"); }); tc.Run("ClipScreen3_ors_three", [](TestCase &t) @@ -980,14 +991,14 @@ void register_ps2_vu_tests() // Both offscreen vertices trip the y-guard clauses (not x) so this // test does not overlap ClipScreen_offscreen_nonzero's x-guard mutation. VuEnv env; - writeVec4(env, kA, 0.0f, 5000.0f, 0.0f, 0.0f); - writeVec4(env, kB, 0.0f, 0.0f, 0.0f, 0.0f); - writeVec4(env, kC, 0.0f, -5000.0f, 0.0f, 0.0f); + writeVec4(env, kA, 10.0f, 5000.0f, 0.0f, 1.0f); + writeVec4(env, kB, 10.0f, 10.0f, 0.0f, 0.0f); + writeVec4(env, kC, 10.0f, 10.0f, 0.0f, 1.0f); SET_GPR_U32(&env.ctx, 4, kA); SET_GPR_U32(&env.ctx, 5, kB); SET_GPR_U32(&env.ctx, 6, kC); ps2_stubs::sceVu0ClipScreen3(env.rdram.data(), &env.ctx, &env.runtime); - t.Equals(getRegU32(&env.ctx, 2), 0xCu, "ClipScreen3 should OR the guard-band codes across all 3 vertices"); + t.Equals(getRegU32(&env.ctx, 2), 0xC0u, "ClipScreen3 should OR the sticky flags of all 3 vertices (y>4096 sign, w=0 zero)"); }); tc.Run("ClipAll_all_out_returns_1", [](TestCase &t) @@ -996,7 +1007,7 @@ void register_ps2_vu_tests() float ident[16]{}; makeIdentity(ident); writeVec4(env, kA, -1.0f, -1.0f, 0.0f, 0.0f); // lo - writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 0.0f); // hi + writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 10.0f); // hi (w = far bound) writeMat4(env, kC, ident); writeVec4(env, kArr, 5.0f, 5.0f, 0.0f, 1.0f); writeVec4(env, kArr + 16u, -5.0f, -5.0f, 0.0f, 1.0f); @@ -1015,7 +1026,7 @@ void register_ps2_vu_tests() float ident[16]{}; makeIdentity(ident); writeVec4(env, kA, -1.0f, -1.0f, 0.0f, 0.0f); // lo - writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 0.0f); // hi + writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 10.0f); // hi (w = far bound) writeMat4(env, kC, ident); writeVec4(env, kArr, 5.0f, 5.0f, 0.0f, 1.0f); writeVec4(env, kArr + 16u, 0.0f, 0.0f, 0.0f, 1.0f); @@ -1034,7 +1045,7 @@ void register_ps2_vu_tests() float ident[16]{}; makeIdentity(ident); writeVec4(env, kA, -1.0f, -1.0f, 0.0f, 0.0f); // lo - writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 0.0f); // hi + writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 10.0f); // hi (w = far bound) writeMat4(env, kC, ident); // Vertex x=2 sits OUTSIDE the raw [-1,1] band but INSIDE the // homogeneous band [-w,w] = [-4,4] once scaled by w=4. With the @@ -1051,6 +1062,25 @@ void register_ps2_vu_tests() "ClipAll must scale the lo/hi bounds by the vertex w (homogeneous guard planes)"); }); + tc.Run("ClipAll_w_outside_far_bound_returns_1", [](TestCase &t) + { + // libvu0 also VSUBs w against lo.w/hi.w, so a vertex centred on screen but past hi.w is culled. + VuEnv env; + float ident[16]{}; + makeIdentity(ident); + writeVec4(env, kA, -1.0f, -1.0f, 0.0f, 0.0f); + writeVec4(env, kB, 1.0f, 1.0f, 0.0f, 10.0f); + writeMat4(env, kC, ident); + writeVec4(env, kArr, 0.0f, 0.0f, 0.0f, 20.0f); + SET_GPR_U32(&env.ctx, 4, kA); + SET_GPR_U32(&env.ctx, 5, kB); + SET_GPR_U32(&env.ctx, 6, kC); + SET_GPR_U32(&env.ctx, 7, kArr); + SET_GPR_U32(&env.ctx, 8, 1u); + ps2_stubs::sceVu0ClipAll(env.rdram.data(), &env.ctx, &env.runtime); + t.Equals(static_cast(getRegU32(&env.ctx, 2)), 1, "w beyond hi.w should count as outside"); + }); + tc.Run("ViewScreenMatrix_zscale", [](TestCase &t) { VuEnv env; From f3ce70bcae77d1de656c33c76c67d129c8c65328 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:52:23 -0300 Subject: [PATCH 78/91] libgraph stubs: read arguments 5-7 from t0-t2 only instead of falling back to the caller's stack --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 35 +++++-------------------- ps2xTest/src/ps2_gs_tests.cpp | 30 +++++++++++++++++++-- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index 7c418ea607..60eb405a73 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -84,40 +84,19 @@ namespace ps2_stubs uint32_t arg2 = 0u; }; + // The EE ABI passes arguments 5-8 in t0-t3; libgraph reads them only from there. Falling back to + // the stack when they are zero picked up the caller's frame (sceGsSetDefDrawEnv(..., 0, 0) got a + // garbage ztest/zpsm). GsTrailingArgs2 decodeGsTrailingArgs2(uint8_t *rdram, R5900Context *ctx) { - const uint32_t reg8 = getRegU32(ctx, 8); - const uint32_t reg9 = getRegU32(ctx, 9); - const uint32_t stack0 = readStackU32(rdram, ctx, 16); - const uint32_t stack1 = readStackU32(rdram, ctx, 20); - - const bool hasRegArgs = (reg8 != 0u || reg9 != 0u); - const bool hasStackArgs = (stack0 != 0u || stack1 != 0u); - if (hasRegArgs || !hasStackArgs) - { - return {reg8, reg9}; - } - - return {stack0, stack1}; + (void)rdram; + return {getRegU32(ctx, 8), getRegU32(ctx, 9)}; } GsTrailingArgs3 decodeGsTrailingArgs3(uint8_t *rdram, R5900Context *ctx) { - const uint32_t reg8 = getRegU32(ctx, 8); - const uint32_t reg9 = getRegU32(ctx, 9); - const uint32_t reg10 = getRegU32(ctx, 10); - const uint32_t stack0 = readStackU32(rdram, ctx, 16); - const uint32_t stack1 = readStackU32(rdram, ctx, 20); - const uint32_t stack2 = readStackU32(rdram, ctx, 24); - - const bool hasRegArgs = (reg8 != 0u || reg9 != 0u || reg10 != 0u); - const bool hasStackArgs = (stack0 != 0u || stack1 != 0u || stack2 != 0u); - if (hasRegArgs || !hasStackArgs) - { - return {reg8, reg9, reg10}; - } - - return {stack0, stack1, stack2}; + (void)rdram; + return {getRegU32(ctx, 8), getRegU32(ctx, 9), getRegU32(ctx, 10)}; } void applyGsClearPacket(PS2Runtime *runtime, const GsClearMem &clear) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 1a1eee57bc..01ad2748f6 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -654,10 +654,10 @@ void register_ps2_gs_tests() setRegU32(ctx, 5, 0u); setRegU32(ctx, 6, 640u); setRegU32(ctx, 7, 448u); + setRegU32(ctx, 8, kZTest); + setRegU32(ctx, 10, kEnableClear); setRegU32(ctx, 29, kStackAddr); std::memset(rdram.data() + kEnvAddr, 0xCD, kDBuffSize); - std::memcpy(rdram.data() + kStackAddr + 16u, &kZTest, sizeof(kZTest)); - std::memcpy(rdram.data() + kStackAddr + 24u, &kEnableClear, sizeof(kEnableClear)); std::memset(runtime.memory().getGSVRAM(), 0xAB, 16u); ps2_stubs::sceGsSetDefDBuffDc(rdram.data(), &ctx, &runtime); @@ -890,6 +890,32 @@ void register_ps2_gs_tests() t.Equals(env.zbuf1.value & 0x1FFull, 70ull, "ZBP should be libgraph's sceGszbufaddr(CT16, 640, 448)"); }); + tc.Run("sceGsSetDefDrawEnv reads ztest and zpsm from t0/t1 even when both are zero", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x4000u; + constexpr uint32_t kSp = 0x8000u; + const uint32_t staleFrame[2] = {2u, 0xAu}; // whatever the caller left at sp+16/sp+20 + std::memcpy(rdram.data() + kSp + 16u, staleFrame, sizeof(staleFrame)); + + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, GS_PSM_CT16); + setRegU32(ctx, 6, 640u); + setRegU32(ctx, 7, 448u); + setRegU32(ctx, 8, 0u); + setRegU32(ctx, 9, 0u); + setRegU32(ctx, 29, kSp); + ps2_stubs::sceGsSetDefDrawEnv(rdram.data(), &ctx, &runtime); + + GsDrawEnv1Mem env{}; + std::memcpy(&env, rdram.data() + kEnvAddr, sizeof(env)); + t.Equals(env.test1.value, 0x30000ull, "ztest 0 should draw with ZTST ALWAYS"); + t.Equals(env.zbuf1.value >> 24, 0x100ull, "ztest 0 should mask Z writes with zpsm 0"); + }); + tc.Run("XYZ3 culls a triangle strip primitive without desynchronizing the vertex queue", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From d65f213f097d76126a40d524d830f56aa6ae9a5c Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:53:19 -0300 Subject: [PATCH 79/91] sceGsSetDefDrawEnv2: truncate FRAME_2 FBW to w >> 6 like libgraph --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 8 +++++--- ps2xTest/src/ps2_gs_tests.cpp | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index 60eb405a73..cd3ae93ee9 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -943,6 +943,7 @@ namespace ps2_stubs } const uint32_t fbw = std::max(1u, (w + 63u) / 64u); + const uint32_t fbw2 = (w >> 6) & 0x3Fu; // sceGsSetDefDrawEnv2 truncates const uint64_t pmode = makePmode(1u, 1u, 0u, 0u, 0u, 0x80u); const uint64_t smode2 = (static_cast(g_gparam.interlace & 0x1u) << 0) | @@ -972,10 +973,10 @@ namespace ps2_stubs const bool seedClear = clear != 0u; db.giftag0 = {makeGiftagAplusD(seedClear ? 22u : 16u), 0xEULL}; seedGsDrawEnv1(db.draw01, drawWidth, drawHeight, 0u, fbw, psm, zbufAddr, zpsm, ztest, false); - seedGsDrawEnv2(db.draw02, drawWidth, drawHeight, 0u, fbw, psm, zbufAddr, zpsm, ztest, false); + seedGsDrawEnv2(db.draw02, drawWidth, drawHeight, 0u, fbw2, psm, zbufAddr, zpsm, ztest, false); db.giftag1 = db.giftag0; seedGsDrawEnv1(db.draw11, drawWidth, drawHeight, fbp1, fbw, psm, zbufAddr, zpsm, ztest, false); - seedGsDrawEnv2(db.draw12, drawWidth, drawHeight, fbp1, fbw, psm, zbufAddr, zpsm, ztest, false); + seedGsDrawEnv2(db.draw12, drawWidth, drawHeight, fbp1, fbw2, psm, zbufAddr, zpsm, ztest, false); if (seedClear) { seedGsClearPacket(db.clear0, drawWidth, drawHeight, 0u, ztest, false); @@ -1126,7 +1127,8 @@ namespace ps2_stubs h = 448; uint32_t psm = param_2 & 0xFU; - uint32_t fbw = ((static_cast(w) + 63u) >> 6) & 0x3FU; + // libgraph rounds FRAME_1 FBW up but truncates FRAME_2 FBW (w >> 6). + uint32_t fbw = static_cast(w >> 6) & 0x3FU; const int32_t zbuf = libgraphZbufAddr(static_cast(static_cast(param_2)), w, h); GsDrawEnv2Mem env{}; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 01ad2748f6..b778cfa850 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -890,6 +890,30 @@ void register_ps2_gs_tests() t.Equals(env.zbuf1.value & 0x1FFull, 70ull, "ZBP should be libgraph's sceGszbufaddr(CT16, 640, 448)"); }); + tc.Run("sceGsSetDefDrawEnv2 truncates FBW where sceGsSetDefDrawEnv rounds it up", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnv1 = 0x4000u; + constexpr uint32_t kEnv2 = 0x4100u; + R5900Context ctx{}; + setRegU32(ctx, 4, kEnv1); + setRegU32(ctx, 5, GS_PSM_CT32); + setRegU32(ctx, 6, 600u); + setRegU32(ctx, 7, 448u); + ps2_stubs::sceGsSetDefDrawEnv(rdram.data(), &ctx, &runtime); + setRegU32(ctx, 4, kEnv2); + ps2_stubs::sceGsSetDefDrawEnv2(rdram.data(), &ctx, &runtime); + + GsDrawEnv1Mem env1{}; + GsDrawEnv2Mem env2{}; + std::memcpy(&env1, rdram.data() + kEnv1, sizeof(env1)); + std::memcpy(&env2, rdram.data() + kEnv2, sizeof(env2)); + t.Equals((env1.frame1.value >> 16) & 0x3Full, 10ull, "FRAME_1 FBW is (600 + 63) >> 6"); + t.Equals((env2.frame2.value >> 16) & 0x3Full, 9ull, "FRAME_2 FBW is 600 >> 6"); + }); + tc.Run("sceGsSetDefDrawEnv reads ztest and zpsm from t0/t1 even when both are zero", [](TestCase &t) { PS2Runtime runtime; From 988ff607c31aa4436c2f3b7881639d0ef6700f86 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:55:45 -0300 Subject: [PATCH 80/91] sceGsSetDefDBuffDc: assign frame pages, dithering and clear packets the way libgraph does --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 25 +++++++----- ps2xTest/src/ps2_gs_tests.cpp | 54 +++++++++++++++++++------ 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index cd3ae93ee9..4b125f332f 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -956,10 +956,13 @@ namespace ps2_stubs const uint32_t zbufAddr = static_cast( libgraphZbufAddr(psm, static_cast(w), static_cast(h))); - // sceGszbufaddr places Z after both frame buffers, so the second buffer starts halfway there. - const uint32_t fbp1 = zbufAddr / 2u; - const uint64_t dispfb0 = makeDispFb(fbp1, fbw, psm, 0u, 0u); - const uint64_t dispfb1 = makeDispFb(0u, fbw, psm, 0u, 0u); + // Z sits after both frame buffers, so libgraph puts the second buffer halfway there. Buffer 0 + // (shown by disp[0], drawn through draw1x) stays at page 0; disp[1] and draw0x get the second page, + // so SwapDBuffDc(0) shows page 0 while drawing the other. Interlaced field mode shares one buffer. + const bool fieldInterlace = g_gparam.interlace != 0u && !(g_gparam.interlace == 1u && g_gparam.ffmode == 1u); + const uint32_t fbp1 = fieldInterlace ? 0u : (static_cast(static_cast(zbufAddr) >> 1) & 0x1FFu); + const uint64_t dispfb0 = makeDispFb(0u, fbw, psm, 0u, 0u); + const uint64_t dispfb1 = makeDispFb(fbp1, fbw, psm, 0u, 0u); GsDBuffDcMem db{}; db.disp[0].pmode = pmode; @@ -971,16 +974,20 @@ namespace ps2_stubs db.disp[1].dispfb = dispfb1; const bool seedClear = clear != 0u; + const bool dithered = (psm & 2u) != 0u; db.giftag0 = {makeGiftagAplusD(seedClear ? 22u : 16u), 0xEULL}; - seedGsDrawEnv1(db.draw01, drawWidth, drawHeight, 0u, fbw, psm, zbufAddr, zpsm, ztest, false); - seedGsDrawEnv2(db.draw02, drawWidth, drawHeight, 0u, fbw2, psm, zbufAddr, zpsm, ztest, false); + seedGsDrawEnv1(db.draw01, drawWidth, drawHeight, fbp1, fbw, psm, zbufAddr, zpsm, ztest, dithered); + seedGsDrawEnv2(db.draw02, drawWidth, drawHeight, fbp1, fbw2, psm, zbufAddr, zpsm, ztest, dithered); db.giftag1 = db.giftag0; - seedGsDrawEnv1(db.draw11, drawWidth, drawHeight, fbp1, fbw, psm, zbufAddr, zpsm, ztest, false); - seedGsDrawEnv2(db.draw12, drawWidth, drawHeight, fbp1, fbw2, psm, zbufAddr, zpsm, ztest, false); + seedGsDrawEnv1(db.draw11, drawWidth, drawHeight, 0u, fbw, psm, zbufAddr, zpsm, ztest, dithered); + seedGsDrawEnv2(db.draw12, drawWidth, drawHeight, 0u, fbw2, psm, zbufAddr, zpsm, ztest, dithered); if (seedClear) { + // Both are sceGsSetDefClear packets: context 1, Q = 1.0, black, Z 0. seedGsClearPacket(db.clear0, drawWidth, drawHeight, 0u, ztest, false); - seedGsClearPacket(db.clear1, drawWidth, drawHeight, 0u, ztest, true); + seedGsClearPacket(db.clear1, drawWidth, drawHeight, 0u, ztest, false); + db.clear0.rgbaq.value |= 0x3F800000ull << 32; + db.clear1.rgbaq.value |= 0x3F800000ull << 32; } if (!writeGsDBuffDc(rdram, envAddr, db)) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index b778cfa850..ba575c16af 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -619,13 +619,43 @@ void register_ps2_gs_tests() setRegU32(ctx, 5, GS_PSM_CT24); setRegU32(ctx, 6, 640u); setRegU32(ctx, 7, 448u); + setRegU32(ctx, 10, 1u); // clear ps2_stubs::sceGsSetDefDBuffDc(rdram.data(), &ctx, &runtime); GsDBuffDcMem db{}; std::memcpy(&db, rdram.data() + kEnvAddr, sizeof(db)); - t.Equals(db.draw11.frame1.value & 0x1FFull, 140ull, "the second draw buffer should start after the first"); t.Equals(db.draw11.zbuf1.value & 0x1FFull, 280ull, "Z should follow both frame buffers"); - t.Equals(db.disp[0].dispfb & 0x1FFull, 140ull, "the first display env should show the second buffer"); + // libgraph: SwapDBuffDc(0) shows page 0 (disp[0]) and draws through draw0x into page 140. + t.Equals(db.disp[0].dispfb & 0x1FFull, 0ull, "disp[0] shows page 0"); + t.Equals(db.disp[1].dispfb & 0x1FFull, 140ull, "disp[1] shows the second buffer"); + t.Equals(db.draw01.frame1.value & 0x1FFull, 140ull, "draw01 renders into the second buffer"); + t.Equals(db.draw02.frame2.value & 0x1FFull, 140ull, "draw02 renders into the second buffer"); + t.Equals(db.draw11.frame1.value & 0x1FFull, 0ull, "draw11 renders into page 0"); + t.Equals(db.draw12.frame2.value & 0x1FFull, 0ull, "draw12 renders into page 0"); + t.Equals(db.clear1.prim.value, static_cast(GS_PRIM_SPRITE), "both clears are context-1 sprites"); + }); + + tc.Run("sceGsSetDefDBuffDc shares one buffer in interlaced field mode", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x4000u; + resetGraphForTest(rdram, runtime, 1u, 0u); + + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, GS_PSM_CT24); + setRegU32(ctx, 6, 640u); + setRegU32(ctx, 7, 224u); + ps2_stubs::sceGsSetDefDBuffDc(rdram.data(), &ctx, &runtime); + + GsDBuffDcMem db{}; + std::memcpy(&db, rdram.data() + kEnvAddr, sizeof(db)); + t.Equals(db.disp[1].dispfb & 0x1FFull, 0ull, "field mode leaves disp[1] on page 0"); + t.Equals(db.draw01.frame1.value & 0x1FFull, 0ull, "field mode leaves draw01 on page 0"); + t.Equals(db.draw01.zbuf1.value & 0x1FFull, 70ull, "Z follows the single 70-page buffer"); + resetGraphForTest(rdram, runtime, 1u, 1u); }); tc.Run("sceGsSetDefDBuffDc seeds a clear packet and swap clears the draw buffer", [](TestCase &t) @@ -639,11 +669,11 @@ void register_ps2_gs_tests() constexpr uint32_t kClear0Offset = 0x160u; constexpr uint32_t kTestAAddrOffset = kClear0Offset + 0x08u; constexpr uint32_t kPrimAddrOffset = kClear0Offset + 0x18u; - constexpr uint32_t kRgbaqOffset = kClear0Offset + 0x20u; constexpr uint32_t kRgbaqAddrOffset = kClear0Offset + 0x28u; constexpr uint32_t kXyz2AAddrOffset = kClear0Offset + 0x38u; constexpr uint32_t kXyz2BAddrOffset = kClear0Offset + 0x48u; constexpr uint32_t kTestBAddrOffset = kClear0Offset + 0x58u; + constexpr uint32_t kClear1RgbaqOffset = 0x2D0u + 0x20u; constexpr uint32_t kClearColor = 0x80402010u; constexpr uint32_t kStackAddr = 0x900u; const uint32_t kZTest = 2u; @@ -682,11 +712,11 @@ void register_ps2_gs_tests() t.Equals(testBAddr, 0x47ull, "dbuff clear packet should restore TEST_1 after clearing"); uint64_t rgbaq = static_cast(kClearColor); - std::memcpy(rdram.data() + kEnvAddr + kRgbaqOffset, &rgbaq, sizeof(rgbaq)); + std::memcpy(rdram.data() + kEnvAddr + kClear1RgbaqOffset, &rgbaq, sizeof(rgbaq)); std::memset(&ctx, 0, sizeof(ctx)); setRegU32(ctx, 4, kEnvAddr); - setRegU32(ctx, 5, 0u); + setRegU32(ctx, 5, 1u); // buffer 1 draws into page 0 ps2_stubs::sceGsSwapDBuffDc(rdram.data(), &ctx, &runtime); uint32_t clearedPixel = 0u; @@ -703,16 +733,16 @@ void register_ps2_gs_tests() "sceGsSwapDBuffDc should clear the interior of the active draw buffer, not just the first pixel"); // A GIF tag that stops after the two draw environments (NLOOP 16) sends no clear. - constexpr uint32_t kGiftag0Offset = 0x50u; + constexpr uint32_t kGiftag1Offset = 0x1C0u; uint64_t giftag = 0u; - std::memcpy(&giftag, rdram.data() + kEnvAddr + kGiftag0Offset, sizeof(giftag)); + std::memcpy(&giftag, rdram.data() + kEnvAddr + kGiftag1Offset, sizeof(giftag)); t.Equals(giftag & 0x7FFFull, 22ull, "a clearing dbuff should send 22 A+D registers"); giftag = (giftag & ~0x7FFFull) | 16ull; - std::memcpy(rdram.data() + kEnvAddr + kGiftag0Offset, &giftag, sizeof(giftag)); + std::memcpy(rdram.data() + kEnvAddr + kGiftag1Offset, &giftag, sizeof(giftag)); std::memset(runtime.memory().getGSVRAM(), 0x5A, 16u); std::memset(&ctx, 0, sizeof(ctx)); setRegU32(ctx, 4, kEnvAddr); - setRegU32(ctx, 5, 0u); + setRegU32(ctx, 5, 1u); ps2_stubs::sceGsSwapDBuffDc(rdram.data(), &ctx, &runtime); uint32_t keptPixel = 0u; std::memcpy(&keptPixel, runtime.memory().getGSVRAM(), sizeof(keptPixel)); @@ -728,8 +758,8 @@ void register_ps2_gs_tests() constexpr uint32_t kEnvAddr = 0x5400u; constexpr uint32_t kDBuffSize = 0x330u; constexpr uint32_t kClear0Offset = 0x160u; - constexpr uint32_t kRgbaqOffset = kClear0Offset + 0x20u; constexpr uint32_t kRgbaqAddrOffset = kClear0Offset + 0x28u; + constexpr uint32_t kClear1RgbaqOffset = 0x2D0u + 0x20u; constexpr uint32_t kStackAddr = 0xA00u; constexpr uint32_t kClearColor = 0x40201008u; @@ -753,11 +783,11 @@ void register_ps2_gs_tests() "sceGsSetDefDBuffDc should seed the clear packet when trailing args arrive in t0-t2"); const uint64_t rgbaq = static_cast(kClearColor); - std::memcpy(rdram.data() + kEnvAddr + kRgbaqOffset, &rgbaq, sizeof(rgbaq)); + std::memcpy(rdram.data() + kEnvAddr + kClear1RgbaqOffset, &rgbaq, sizeof(rgbaq)); std::memset(&ctx, 0, sizeof(ctx)); setRegU32(ctx, 4, kEnvAddr); - setRegU32(ctx, 5, 0u); + setRegU32(ctx, 5, 1u); // buffer 1 draws into page 0 ps2_stubs::sceGsSwapDBuffDc(rdram.data(), &ctx, &runtime); uint32_t clearedPixel = 0u; From 3cb1d13a9dff154eb8811b98f7fb706e68af3523 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:57:44 -0300 Subject: [PATCH 81/91] sceGsSetDefDispEnv/SetDefDBuffDc: write PMODE, SMODE2, DISPLAY and BGCOLOR the way libgraph does --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 75 ++++++++++++++++--------- ps2xTest/src/ps2_gs_tests.cpp | 37 +++++++++++- 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index 4b125f332f..8372d35160 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -64,6 +64,36 @@ namespace ps2_stubs return static_cast(fieldInterlace ? pages : pages * 2); } + // libgraph's sceGsSetDefDispEnv: read-out circuit 2 only (PMODE 0x66), SMODE2 from the CRT mode, + // and a DISPLAY positioned for the video mode with MAGH stretching the width to ~2560 VCK. An + // unsupported video mode leaves DISPLAY as it was, as libgraph does after printing its error. + void libgraphDefDispEnv(GsDispEnvMem &env, uint32_t psm, int32_t w, int32_t h, int32_t dx, int32_t dy) + { + const bool interlaced = g_gparam.interlace == 1u; + env.pmode = 0x66u; + env.smode2 = (g_gparam.interlace == 0u) ? 2u : ((g_gparam.ffmode != 0u) ? 3u : 1u); + env.dispfb = (static_cast(psm & 0xFu) << 15) | + (static_cast(((w + 63) >> 6) & 0x3F) << 9); + if ((g_gparam.omode == 2u || g_gparam.omode == 3u) && w > 0) + { + const bool pal = g_gparam.omode == 3u; + const int32_t mag = (w + 2559) / w; + const int32_t displayX = (dx * mag) + (pal ? 656 : 636); + const int32_t displayY = dy + (pal ? (interlaced ? 72 : 36) : (interlaced ? 50 : 25)); + const int32_t displayH = (interlaced && g_gparam.ffmode != 0u) ? ((h * 2) - 1) : (h - 1); + env.display = makeDisplay(static_cast(displayX), static_cast(displayY), + static_cast(mag - 1), 0u, static_cast((mag * w) - 1), + static_cast(displayH)); + } + else if (g_gparam.omode == 0x50u) + { + const int32_t displayX = (((720 - w) / 2) * 2) + (dx * 2) + 232; + env.display = makeDisplay(static_cast(displayX), static_cast(dy + 35), 1u, 0u, + static_cast((w * 2) - 1), static_cast(h - 1)); + } + env.bgcolor = 0u; + } + bool hasSeededGsClearPacket(const GsClearMem &clear) { return clear.rgbaq.reg == GS_REG_RGBAQ && @@ -944,11 +974,6 @@ namespace ps2_stubs const uint32_t fbw = std::max(1u, (w + 63u) / 64u); const uint32_t fbw2 = (w >> 6) & 0x3Fu; // sceGsSetDefDrawEnv2 truncates - const uint64_t pmode = makePmode(1u, 1u, 0u, 0u, 0u, 0x80u); - const uint64_t smode2 = - (static_cast(g_gparam.interlace & 0x1u) << 0) | - (static_cast(g_gparam.ffmode & 0x1u) << 1); - const uint64_t display = makeDisplay(636u, 32u, 0u, 0u, w - 1u, h - 1u); const int32_t drawWidth = static_cast(w); const int32_t drawHeight = static_cast(h); @@ -961,17 +986,10 @@ namespace ps2_stubs // so SwapDBuffDc(0) shows page 0 while drawing the other. Interlaced field mode shares one buffer. const bool fieldInterlace = g_gparam.interlace != 0u && !(g_gparam.interlace == 1u && g_gparam.ffmode == 1u); const uint32_t fbp1 = fieldInterlace ? 0u : (static_cast(static_cast(zbufAddr) >> 1) & 0x1FFu); - const uint64_t dispfb0 = makeDispFb(0u, fbw, psm, 0u, 0u); - const uint64_t dispfb1 = makeDispFb(fbp1, fbw, psm, 0u, 0u); - GsDBuffDcMem db{}; - db.disp[0].pmode = pmode; - db.disp[0].smode2 = smode2; - db.disp[0].dispfb = dispfb0; - db.disp[0].display = display; - db.disp[0].bgcolor = 0u; + libgraphDefDispEnv(db.disp[0], psm, drawWidth, drawHeight, 0, 0); db.disp[1] = db.disp[0]; - db.disp[1].dispfb = dispfb1; + db.disp[1].dispfb |= fbp1; const bool seedClear = clear != 0u; const bool dithered = (psm & 2u) != 0u; @@ -1055,24 +1073,27 @@ namespace ps2_stubs void sceGsSetDefDispEnv(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - uint32_t envAddr = getRegU32(ctx, 4); - uint32_t psm = getRegU32(ctx, 5); - uint32_t w = getRegU32(ctx, 6); - uint32_t h = getRegU32(ctx, 7); + const uint32_t envAddr = getRegU32(ctx, 4); + const uint32_t psm = static_cast(static_cast(getRegU32(ctx, 5))); + int32_t w = static_cast(getRegU32(ctx, 6)); + int32_t h = static_cast(getRegU32(ctx, 7)); const GsTrailingArgs2 trailing = decodeGsTrailingArgs2(rdram, ctx); - uint32_t dx = trailing.arg0; - uint32_t dy = trailing.arg1; + const int32_t dx = static_cast(trailing.arg0); + const int32_t dy = static_cast(trailing.arg1); - if (w == 0) + if (w <= 0) w = 640; - if (h == 0) + if (h <= 0) h = 448; - uint32_t fbw = (w + 63) / 64; - uint64_t dispfb = makeDispFb(0, fbw, psm, 0, 0); - uint64_t display = makeDisplay(dx, dy, 0, 0, w - 1, h - 1); - - writeGsDispEnv(rdram, envAddr, display, dispfb); + GsDispEnvMem env{}; + if (!readGsDispEnv(rdram, envAddr, env)) + { + setReturnS32(ctx, -1); + return; + } + libgraphDefDispEnv(env, psm, w, h, dx, dy); + std::memcpy(getMemPtr(rdram, envAddr), &env, sizeof(env)); setReturnS32(ctx, 0); } diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index ba575c16af..ca03b2c849 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -547,6 +547,7 @@ void register_ps2_gs_tests() t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); std::vector rdram(PS2_RAM_SIZE, 0u); + resetGraphForTest(rdram, runtime, 0u, 0u); constexpr uint32_t kEnvAddr = 0x4000u; constexpr uint32_t kDispEnvSize = 40u; constexpr uint32_t kDBuffSize = 0x330u; @@ -580,7 +581,10 @@ void register_ps2_gs_tests() std::memcpy(&xyoffset10Addr, rdram.data() + kEnvAddr + kXYOffset1AddrOffset, sizeof(xyoffset10Addr)); t.Equals((dispfb0 >> 9) & 0x3Fu, 10ull, "dbuff display env should seed FBW from width"); - t.Equals((display0 >> 32) & 0x0FFFull, 639ull, "dbuff display env should seed DW from width"); + t.Equals((display0 >> 32) & 0x0FFFull, 2559ull, "libgraph stretches DW to 4 x 640 VCK"); + t.Equals((display0 >> 23) & 0xFull, 3ull, "MAGH 3 maps the 2560 VCK back to 640 pixels"); + t.Equals(display0 & 0xFFFull, 636ull, "NTSC DX"); + t.Equals((display0 >> 12) & 0x7FFull, 25ull, "progressive NTSC DY"); t.Equals((display0 >> 44) & 0x07FFull, 447ull, "dbuff display env should seed DH from height"); t.Equals((frame10 >> 16) & 0x3Full, 10ull, "dbuff draw env should seed FRAME FBW from width"); t.Equals(frame10Addr, 0x4Cull, "dbuff draw env should seed FRAME_1 register id"); @@ -602,8 +606,37 @@ void register_ps2_gs_tests() t.Equals(runtime.memory().gs().dispfb1 & 0x1FFull, 151ull, "sceGsSwapDBuffDc should program GS to the selected display page"); - t.Equals((runtime.memory().gs().display1 >> 32) & 0x0FFFull, 639ull, + t.Equals((runtime.memory().gs().display1 >> 32) & 0x0FFFull, 2559ull, "sceGsSwapDBuffDc should preserve the display width from the seeded env"); + resetGraphForTest(rdram, runtime, 1u, 1u); + }); + + tc.Run("sceGsSetDefDispEnv writes the whole display env like libgraph", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x4000u; + std::memset(rdram.data() + kEnvAddr, 0xCD, sizeof(GsDispEnvMem)); + + // Mana Khemia: ResetGraph(0, 1, NTSC, 0) then SetDefDispEnv(disp, psm, 640, 448, 0, 0). + resetGraphForTest(rdram, runtime, 1u, 0u); + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, GS_PSM_CT24); + setRegU32(ctx, 6, 640u); + setRegU32(ctx, 7, 448u); + ps2_stubs::sceGsSetDefDispEnv(rdram.data(), &ctx, &runtime); + + GsDispEnvMem env{}; + std::memcpy(&env, rdram.data() + kEnvAddr, sizeof(env)); + t.Equals(env.pmode, 0x66ull, "PMODE enables read circuit 2 only"); + t.Equals(env.smode2, 1ull, "SMODE2: interlaced, field mode"); + t.Equals(env.dispfb, (static_cast(GS_PSM_CT24) << 15) | (10ull << 9), "DISPFB: page 0, FBW 10"); + t.Equals(env.display, 636ull | (50ull << 12) | (3ull << 23) | (2559ull << 32) | (447ull << 44), + "DISPLAY: interlaced NTSC position and 4x horizontal magnification"); + t.Equals(env.bgcolor, 0ull, "BGCOLOR is black"); + resetGraphForTest(rdram, runtime, 1u, 1u); }); tc.Run("sceGsSetDefDBuffDc keeps the second frame buffer out of the Z buffer", [](TestCase &t) From 787d4a74574ad7999275e26cfb0245da303c87c5 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:59:16 -0300 Subject: [PATCH 82/91] sceGsResetGraph: call SetGsCrt and honour mode 5 instead of sending display values to SCISSOR_2/ALPHA_1 --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 78 ++++++------------------- ps2xTest/src/ps2_gs_tests.cpp | 30 ++++++++++ 2 files changed, 47 insertions(+), 61 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index 8372d35160..f17ac13a32 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -836,69 +836,25 @@ namespace ps2_stubs void sceGsResetGraph(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - uint32_t mode = getRegU32(ctx, 4); - uint32_t interlace = getRegU32(ctx, 5); - uint32_t omode = getRegU32(ctx, 6); - uint32_t ffmode = getRegU32(ctx, 7); - - if (mode == 0) - { - if (runtime && !runtime->syncCoreSubsystems()) - { - setReturnS32(ctx, -1); - return; - } - - g_gparam.interlace = static_cast(interlace & 0x1); - g_gparam.omode = static_cast(omode & 0xFF); - g_gparam.ffmode = static_cast(ffmode & 0x1); + // libgraph: mode 1 only flushes the GS; modes 0 (after a GS reset) and 5 record the CRT parameters + // and tail-call SetGsCrt. PMODE/DISPFB/DISPLAY are left to the display env the game puts later. + const int16_t mode = static_cast(getRegU32(ctx, 4)); + const int16_t interlace = static_cast(getRegU32(ctx, 5)); + const int16_t omode = static_cast(getRegU32(ctx, 6)); + const int16_t ffmode = static_cast(getRegU32(ctx, 7)); + + if (mode == 0 || mode == 5) + { + g_gparam.interlace = static_cast(interlace); + g_gparam.omode = static_cast(omode); + g_gparam.ffmode = ffmode != 0 ? 1u : 0u; writeGsGParamToScratch(runtime); - uint64_t pmode = makePmode(1, 0, 0, 0, 0, 0x80); - uint64_t smode2 = (interlace & 0x1) | ((ffmode & 0x1) << 1); - uint64_t dispfb = makeDispFb(0, 10, 0, 0, 0); - uint64_t display = makeDisplay(0, 0, 0, 0, 639, 447); - uint64_t bgcolor = 0ULL; - if (runtime) - { - uint32_t pktAddr = runtime->guestMalloc(128u, 16u); - if (pktAddr != 0u) - { - uint8_t *pkt = getMemPtr(rdram, pktAddr); - if (pkt) - { - uint64_t *q = reinterpret_cast(pkt); - q[0] = makeGiftagAplusD(7u); - q[1] = 0xEULL; - q[2] = pmode; - q[3] = 0x41ULL; - q[4] = smode2; - q[5] = 0x42ULL; - q[6] = dispfb; - q[7] = 0x59ULL; - q[8] = display; - q[9] = 0x5aULL; - q[10] = dispfb; - q[11] = 0x5bULL; - q[12] = display; - q[13] = 0x5cULL; - q[14] = bgcolor; - q[15] = 0x5fULL; - constexpr uint32_t GIF_CHANNEL = 0x1000A000; - constexpr uint32_t CHCR_STR_MODE0 = 0x101u; - auto &mem = runtime->memory(); - mem.writeIORegister(GIF_CHANNEL + 0x10u, pktAddr); - mem.writeIORegister(GIF_CHANNEL + 0x20u, 8u); - mem.writeIORegister(GIF_CHANNEL + 0x00u, CHCR_STR_MODE0); - mem.processPendingTransfers(); - runtime->guestFree(pktAddr); - } - else - { - runtime->guestFree(pktAddr); - } - } - } + R5900Context crt{}; + SET_GPR_U32(&crt, 4, static_cast(interlace & 1)); + SET_GPR_U32(&crt, 5, static_cast(omode & 0xFF)); + SET_GPR_U32(&crt, 6, static_cast(ffmode & 1)); + ps2_syscalls::GsSetCrt(rdram, &crt, runtime); } setReturnS32(ctx, 0); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index ca03b2c849..3237b175df 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -5711,6 +5711,36 @@ void register_ps2_gs_tests() "sceGsResetGraph should free its temporary GIF packet"); }); + tc.Run("sceGsResetGraph sets the CRT through SetGsCrt and honours mode 5", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + auto &gs = runtime.memory().gs(); + gs.smode2 = 0u; + gs.display2 = 0x1234ull; + + resetGraphForTest(rdram, runtime, 1u, 0u); + t.Equals(gs.smode2, 1ull, "ResetGraph(0, interlace, NTSC, field) should program SMODE2 like SetGsCrt"); + t.Equals(gs.display2, 0x1234ull, "ResetGraph should not invent a DISPLAY; the display env sets it"); + + // Mode 5 changes the CRT parameters without a reset; libgraph's zbuf placement follows them. + R5900Context ctx{}; + setRegU32(ctx, 4, 5u); + setRegU32(ctx, 5, 0u); + setRegU32(ctx, 6, 2u); + setRegU32(ctx, 7, 0u); + ps2_stubs::sceGsResetGraph(rdram.data(), &ctx, &runtime); + R5900Context zctx{}; + setRegU32(zctx, 4, GS_PSM_CT32); + setRegU32(zctx, 5, 640u); + setRegU32(zctx, 6, 448u); + ps2_stubs::sceGszbufaddr(rdram.data(), &zctx, &runtime); + t.Equals(getRegU32Test(zctx, 2), 280u, "mode 5 should switch libgraph to progressive (two buffers)"); + t.Equals(gs.smode2, 0ull, "mode 5 should reprogram SMODE2 too"); + resetGraphForTest(rdram, runtime, 1u, 1u); + }); + tc.Run("sceGsSyncV resumes through the scheduler with deterministic field parity", [](TestCase &t) { PS2Runtime runtime; From bb8956980f2e6e614f359a705c36e69b226c0841 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:01:08 -0300 Subject: [PATCH 83/91] sceDmaSend/SendN/RecvN: keep the channel's CHCR bits instead of forcing TIE, like libdma --- .../src/lib/Kernel/Stubs/Helpers/Support.h | 11 +++++--- ps2xTest/src/ps2_memory_tests.cpp | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index 6a0accd6f3..6eaafc79ff 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1361,9 +1361,10 @@ namespace uint32_t tadr = payloadPhys; PS2Memory &mem = runtime->memory(); + // libdma only replaces MOD and sets DIR|STR, keeping the channel's TTE/TIE/ASP bits. Forcing TIE + // would end a chain at the first tag whose IRQ bit is set. const uint32_t configuredChcr = mem.readIORegister(channelBase + 0x00u); - const uint32_t transferTagEnable = configuredChcr & 0x00000040u; - uint32_t chcr = 0x00000181u | transferTagEnable; // DIR=1, TIE=1, STR=1 (normal mode). + uint32_t chcr = (configuredChcr & ~0x0000000Cu) | 0x00000101u; // normal mode, DIR=1, STR=1 if (preferNormalCount) { @@ -1372,7 +1373,7 @@ namespace } else { - chcr = 0x00000185u | transferTagEnable; // MODE=1 chain, DIR=1, TIE=1, STR=1. + chcr |= 0x00000004u; // MOD=1 chain } mem.writeIORegister(channelBase + 0x20u, qwc & 0xFFFFu); @@ -1458,7 +1459,9 @@ namespace PS2Memory &mem = runtime->memory(); mem.writeIORegister(channelBase + 0x20u, normalizeQwcFromArg(getRegU32(ctx, 6)) & 0xFFFFu); mem.writeIORegister(channelBase + 0x10u, toDmaPhys(getRegU32(ctx, 5))); - mem.writeIORegister(channelBase + 0x00u, 0x00000180u); // DIR=0, TIE=1, STR=1 (normal mode). + // libdma: normal mode, DIR=0, STR=1, other CHCR bits kept. + const uint32_t configuredChcr = mem.readIORegister(channelBase + 0x00u); + mem.writeIORegister(channelBase + 0x00u, (configuredChcr & ~0x0000000Du) | 0x00000100u); mem.processPendingTransfers(); { diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index 27ce44aa62..fc8d86b668 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -2681,6 +2681,34 @@ void register_ps2_memory_tests() "preserved TTE should deliver the tag high-half VIFcode"); }); + tc.Run("sceDmaSend leaves CHCR.TIE alone so an IRQ tag does not end the chain", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "PS2Memory initialize should succeed"); + + constexpr uint32_t kVif1Ch = 0x10009000u; + constexpr uint32_t kTag = 0x00028500u; + + PS2Memory &mem = runtime.memory(); + uint8_t *rdram = mem.getRDRAM(); + // CNT with its IRQ bit set, then END; each carries an ITOP in the tag's high half (TTE). + writeDmaTag(rdram, kTag, makeDmaTag(0u, 1u, 0u, true)); + const uint32_t firstItop = makeVifCmd(0x04u, 0u, 0x11u); + std::memcpy(rdram + kTag + 12u, &firstItop, sizeof(firstItop)); + writeDmaTag(rdram, kTag + 16u, makeDmaTag(0u, 7u, 0u, false)); + const uint32_t lastItop = makeVifCmd(0x04u, 0u, 0x66u); + std::memcpy(rdram + kTag + 16u + 12u, &lastItop, sizeof(lastItop)); + + // libdma keeps the guest's CHCR bits: TTE set, TIE clear. + t.IsTrue(mem.writeIORegister(kVif1Ch + 0x00u, 0x40u), "guest should be able to set CHCR.TTE"); + R5900Context ctx{}; + setRegU32(ctx, 4, 1u); + setRegU32(ctx, 5, kTag); + ps2_stubs::sceDmaSend(rdram, &ctx, &runtime); + + t.Equals(mem.vif1_regs.itops, 0x66u, "with TIE clear the IRQ tag must not stop the chain"); + }); + tc.Run("sceDmaRecvN copies scratchpad data back to RDRAM", [](TestCase &t) { PS2Runtime runtime; From 5325cbd81f9909680095e32a8a0b0121bc6f274a Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:03:42 -0300 Subject: [PATCH 84/91] rand/srand: implement newlib's 31-bit LCG instead of the host's 15-bit rand --- ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp | 10 ++++++++-- ps2xTest/src/ps2_runtime_expansion_tests.cpp | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp index 94f46a4b03..743ede17a6 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/LibC.cpp @@ -1111,14 +1111,20 @@ namespace ps2_stubs setReturnU32(ctx, 0u); } + // newlib's rand(): a 64-bit LCG seeded with 1 that returns bits 32..62, so RAND_MAX is 0x7FFFFFFF. + // Games scale by that range (Mana Khemia takes rand() >> 8 before every modulo), so a 15-bit host + // rand() collapsed their results to 0..127. + static uint64_t g_newlibRandNext = 1u; + void rand(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - setReturnS32(ctx, std::rand() & 0x7FFF); + g_newlibRandNext = (g_newlibRandNext * 6364136223846793005ull) + 1u; + setReturnS32(ctx, static_cast((g_newlibRandNext >> 32) & 0x7FFFFFFFu)); } void srand(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - std::srand(getRegU32(ctx, 4)); + g_newlibRandNext = getRegU32(ctx, 4); setReturnS32(ctx, 0); } diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index fa442c4afd..2175c842b5 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -995,6 +995,22 @@ void register_ps2_runtime_expansion_tests() "sprintf should return the rendered length"); }); + tc.Run("rand follows newlib's 31-bit LCG", [](TestCase &t) + { + PS2Runtime runtime; + std::vector rdram(PS2_RAM_SIZE, 0u); + R5900Context ctx{}; + + setRegU32(ctx, 4, 1u); + ps2_stubs::srand(rdram.data(), &ctx, &runtime); + const int32_t expected[3] = {1481765933, 1085377743, 1270216262}; + for (int32_t value : expected) + { + ps2_stubs::rand(rdram.data(), &ctx, &runtime); + t.Equals(getRegS32(ctx, 2), value, "rand should return newlib's sequence for seed 1"); + } + }); + tc.Run("vfprintf fills guest newlib string FILEs from doubleword va_list slots", [](TestCase &t) { PS2Runtime runtime; From 216de1051c17801533d945a3690910f61234b3ce Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:32:13 -0300 Subject: [PATCH 85/91] sceGsPutDrawEnv: send the NLOOP A+D pairs behind the GIF tag instead of treating the tag as a pair --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 19 ++++++++++++---- ps2xTest/src/ps2_gs_tests.cpp | 30 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index f17ac13a32..f5caf9d5fb 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -823,14 +823,25 @@ namespace ps2_stubs void sceGsPutDrawEnv(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { - uint32_t envAddr = getRegU32(ctx, 4); - GsRegPairMem pairs[8]{}; - if (!readGsRegPairs(rdram, envAddr, pairs, 8u)) + // libgraph DMAs the GIF tag at a0 plus the NLOOP A+D qwords behind it (sceGsSwapDBuffDc passes the + // dbuff's giftag), so the first qword is the tag, not a register pair. + const uint32_t tagAddr = getRegU32(ctx, 4); + GsGiftagMem tag{}; + const uint8_t *tagPtr = getConstMemPtr(rdram, tagAddr); + if (!tagPtr) + { + setReturnS32(ctx, -1); + return; + } + std::memcpy(&tag, tagPtr, sizeof(tag)); + const uint32_t nloop = static_cast(tag.lo & 0x7FFFu); + std::vector pairs(nloop); + if (nloop != 0u && !readGsRegPairs(rdram, tagAddr + 16u, pairs.data(), nloop)) { setReturnS32(ctx, -1); return; } - applyGsRegPairs(runtime, pairs, 8u); + applyGsRegPairs(runtime, pairs.data(), nloop); setReturnS32(ctx, 0); } diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 3237b175df..d8fd49b76f 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -953,6 +953,36 @@ void register_ps2_gs_tests() t.Equals(env.zbuf1.value & 0x1FFull, 70ull, "ZBP should be libgraph's sceGszbufaddr(CT16, 640, 448)"); }); + tc.Run("sceGsPutDrawEnv sends the NLOOP A+D pairs that follow the GIF tag", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kTagAddr = 0x4000u; + constexpr uint32_t kColor = 0x80123456u; + const uint64_t pairs[][2] = { + {(1ull << 16) | (static_cast(GS_PSM_CT32) << 24), GS_REG_FRAME_1}, + {64ull | (1ull << 32), GS_REG_ZBUF_1}, + {(7ull << 16) | (7ull << 48), GS_REG_SCISSOR_1}, + {0ull, GS_REG_XYOFFSET_1}, + {0x30000ull, GS_REG_TEST_1}, + {static_cast(GS_PRIM_SPRITE), GS_REG_PRIM}, + {kColor, GS_REG_RGBAQ}, + {0ull, GS_REG_XYZ2}, + {(8ull << 4) | ((8ull << 4) << 16), GS_REG_XYZ2}, + }; + const uint64_t tag[2] = {makeGiftagAplusD(9u), 0xEull}; + std::memcpy(rdram.data() + kTagAddr, tag, sizeof(tag)); + std::memcpy(rdram.data() + kTagAddr + 16u, pairs, sizeof(pairs)); + + R5900Context ctx{}; + setRegU32(ctx, 4, kTagAddr); + ps2_stubs::sceGsPutDrawEnv(rdram.data(), &ctx, &runtime); + t.Equals(getRegU32Test(ctx, 2), 0u, "sceGsPutDrawEnv should return 0"); + t.Equals(runtime.gs().ReadVram(GS_PSM_CT32, 0u, 1u, 3u, 3u), kColor, + "every pair after the tag, including the closing XYZ2, should reach the GS"); + }); + tc.Run("sceGsSetDefDrawEnv2 truncates FBW where sceGsSetDefDrawEnv rounds it up", [](TestCase &t) { PS2Runtime runtime; From 2561fa9de334e231caace0df4ef62869d3907669 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:43:40 -0300 Subject: [PATCH 86/91] Present the whole frame buffer in interlaced field mode instead of line-doubling one field --- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 26 ++++------------------- ps2xTest/src/ps2_gs_tests.cpp | 19 ++++++----------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 0f4b821e73..170f44b81b 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -649,22 +649,6 @@ namespace return {(smode2 & 0x1ull) != 0ull, ((smode2 >> 1) & 0x1ull) != 0ull}; } - void applyFieldPresentation(std::vector &pixels, uint32_t width, uint32_t height, bool oddField) - { - if (pixels.empty() || width == 0u || height < 2u) - return; - const std::vector source = pixels; - for (uint32_t y = 0; y < height; ++y) - { - uint32_t sourceY = ((y >> 1u) << 1u) + (oddField ? 1u : 0u); - if (sourceY >= height) - sourceY = height - 1u; - std::memcpy(pixels.data() + y * kHostFrameWidth * 4u, - source.data() + sourceY * kHostFrameWidth * 4u, - width * 4u); - } - } - void normalizePresentationAlpha(std::vector &pixels, uint32_t width, uint32_t height) { for (uint32_t y = 0; y < height; ++y) @@ -4249,8 +4233,10 @@ PresentationFrame GSCpuBackend::PresentFromLocalMemory(const GSPresentationReque PresentationFrame result{}; const GSPmodeState pmode = decodePmode(request.pmode); const GSSmode2State smode2 = decodeSMode2(request.smode2); - const bool fieldMode = smode2.interlaced && !smode2.frameMode; - const bool oddField = (request.vsyncTick & 1ull) != 0ull; + // In interlaced field mode (SMODE2 INT=1, FFMD=0) each field reads alternate lines of the same + // full-height buffer and a TV shows both, so the host presents the whole buffer instead of + // line-doubling one field, which halved the vertical resolution. + (void)smode2; const GSFrameReg displayFrame1 = decodeDisplayFrame(request.dispfb1); const GSFrameReg displayFrame2 = decodeDisplayFrame(request.dispfb2); const GSDisplayReadOrigin origin1 = decodeDisplayReadOrigin(request.dispfb1); @@ -4343,8 +4329,6 @@ PresentationFrame GSCpuBackend::PresentFromLocalMemory(const GSPresentationReque dst[3] = pmode.amod ? dst[3] : src[3]; } normalizePresentationAlpha(result.pixels, result.width, result.height); - if (fieldMode) - applyFieldPresentation(result.pixels, result.width, result.height, oddField); result.displayFbp = displayFrame1.fbp; result.sourceFbp = selected1.fbp; return result; @@ -4358,8 +4342,6 @@ PresentationFrame GSCpuBackend::PresentFromLocalMemory(const GSPresentationReque GSFrameReg selected = displayFrame; if (!copySource(displayFrame, origin, result.width, result.height, true, false, selected, result.pixels, result.usedPreferred)) return {}; - if (fieldMode) - applyFieldPresentation(result.pixels, result.width, result.height, oddField); normalizePresentationAlpha(result.pixels, result.width, result.height); result.displayFbp = displayFrame.fbp; result.sourceFbp = selected.fbp; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index d8fd49b76f..f6da9d57db 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -1981,8 +1981,10 @@ void register_ps2_gs_tests() "single-circuit presentation should normalize the last row alpha"); }); - tc.Run("latched host presentation line-doubles interlaced field output", [](TestCase &t) + tc.Run("latched host presentation shows the whole buffer in interlaced field mode", [](TestCase &t) { + // SMODE2 INT=1 FFMD=0 reads alternate lines of one full-height buffer into each field, and a TV + // shows both fields, so the host frame must keep every scanline rather than doubling one field. std::vector vram(PS2_GS_VRAM_SIZE, 0u); GSRegisters regs{}; regs.pmode = 0x0001ull; @@ -2027,17 +2029,10 @@ void register_ps2_gs_tests() (static_cast(latchedFrame[off + 2u]) << 16); }; - const uint32_t row0 = pixelAtRow(0u); - const uint32_t row1 = pixelAtRow(1u); - const uint32_t row2 = pixelAtRow(2u); - const uint32_t row3 = pixelAtRow(3u); - - t.Equals(row0, row1, - "field presentation should duplicate the active field into the next scanline"); - t.Equals(row2, row3, - "field presentation should duplicate later field scanlines as well"); - t.IsTrue(row0 != row2, - "field presentation should still preserve different source content across field rows"); + t.Equals(pixelAtRow(0u), kLine0, "row 0 should come from buffer line 0"); + t.Equals(pixelAtRow(1u), kLine1, "row 1 should come from buffer line 1, not a copy of line 0"); + t.Equals(pixelAtRow(2u), kLine2, "row 2 should come from buffer line 2"); + t.Equals(pixelAtRow(3u), kLine3, "row 3 should come from buffer line 3"); }); tc.Run("GIF PACKED A+D writes DISPFB1 and DISPLAY1 privileged registers", [](TestCase &t) From 813c01edd8f0e37059e5fd0ad0870a23a869c255 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:49:36 -0300 Subject: [PATCH 87/91] Discover handler-table functions laid out at a fixed stride in data --- ps2xRecomp/src/lib/elf_parser.cpp | 86 ++++++++++++++++++++++++++- ps2xTest/src/ps2_recompiler_tests.cpp | 35 ++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/ps2xRecomp/src/lib/elf_parser.cpp b/ps2xRecomp/src/lib/elf_parser.cpp index e8d61a1445..565ccfe8ea 100644 --- a/ps2xRecomp/src/lib/elf_parser.cpp +++ b/ps2xRecomp/src/lib/elf_parser.cpp @@ -1591,6 +1591,36 @@ namespace return words; } + // True when the two words before `address` end a basic block (`jr`/`j`/unconditional `b` plus its + // delay slot), so the address can only be entered fresh. A function that follows another function's + // return looks like this; a label inside a function normally does not. + bool FollowsControlTransfer(const std::vector §ions, uint32_t address) + { + const ps2recomp::Section *section = FindCodeSectionByAddress(sections, address); + if (!section || !section->data) + { + return false; + } + + const uint32_t offset = address - section->address; + if (offset < 2u * MIPS_INSTRUCTION_SIZE) + { + return true; + } + + uint32_t raw = 0; + if (!ReadSectionWord(*section, offset - 2u * MIPS_INSTRUCTION_SIZE, raw)) + { + return false; + } + + const uint32_t opcode = OPCODE(raw); + const bool isJr = opcode == OPCODE_SPECIAL && FUNCTION(raw) == SPECIAL_JR; + const bool isJ = opcode == OPCODE_J; + const bool isUnconditionalBranch = opcode == OPCODE_BEQ && RS(raw) == GPR_ZERO && RT(raw) == GPR_ZERO; + return isJr || isJ || isUnconditionalBranch; + } + void ScanDataFunctionPointerTables(const std::vector §ions, std::unordered_set &starts, const std::unordered_set &switchJumpTableWords) { @@ -1728,11 +1758,62 @@ namespace return false; }; + // Handler tables keep one code pointer per fixed-size record, so their candidates sit at a + // constant stride. Only the shortest handlers pass the conservative probe, which left the + // battle command handlers of Mana Khemia (records of six words, returns 6 to 11 words in) + // merged into the function before them and unreachable through jalr. Accept a candidate + // when it and two equally spaced neighbours all start right after a control transfer and + // reach a return: arrays of small integer pairs or ASCII text also produce equally spaced + // code-range words, but three such words that all land on block starts do not happen. + constexpr uint32_t kMaxRecordStrideBytes = 64u; + std::vector entryLikeCache(candidates.size(), -1); + auto entryLike = [&](size_t candidateIndex) + { + int8_t &cached = entryLikeCache[candidateIndex]; + if (cached < 0) + { + const uint32_t target = candidates[candidateIndex].target; + uint32_t firstWord = 0; + const ps2recomp::Section *codeSection = FindCodeSectionByAddress(sections, target); + const bool startsWithNop = !codeSection || !codeSection->data || + !ReadSectionWord(*codeSection, target - codeSection->address, firstWord) || + firstWord == 0u; + cached = (!startsWithNop && + FollowsControlTransfer(sections, target) && + LooksLikeCallableEntry(sections, target, true, true)) + ? 1 + : 0; + } + return cached == 1; + }; + auto isPeriodicTableEntry = [&](size_t candidateIndex) + { + const auto stride = [&](size_t from, size_t to) -> uint32_t + { return candidates[to].sourceOffset - candidates[from].sourceOffset; }; + const auto isRun = [&](size_t first, size_t second, size_t third) + { + const uint32_t step = stride(first, second); + // A struct field repeating one constant is a run of identical words, not a table. + const bool distinctTargets = candidates[first].target != candidates[second].target || + candidates[second].target != candidates[third].target; + return step >= MIPS_INSTRUCTION_SIZE && step <= kMaxRecordStrideBytes && + stride(second, third) == step && distinctTargets && + entryLike(first) && entryLike(second) && entryLike(third); + }; + const size_t count = candidates.size(); + return (candidateIndex >= 2u && isRun(candidateIndex - 2u, candidateIndex - 1u, candidateIndex)) || + (candidateIndex >= 1u && candidateIndex + 1u < count && + isRun(candidateIndex - 1u, candidateIndex, candidateIndex + 1u)) || + (candidateIndex + 2u < count && isRun(candidateIndex, candidateIndex + 1u, candidateIndex + 2u)); + }; + for (size_t index = 0; index < candidates.size(); ++index) { if (candidates[index].hasConservativeShape) { - const bool clustered = dedicatedPointerSection || hasConservativeNeighborWithin(index, kClusterDistanceBytes); + const bool clustered = dedicatedPointerSection || + hasConservativeNeighborWithin(index, kClusterDistanceBytes) || + isPeriodicTableEntry(index); if (clustered) { starts.insert(candidates[index].target); @@ -1741,7 +1822,8 @@ namespace } const bool hasConservativeAnchor = dedicatedPointerSection || hasClassDescriptorShape(index) || hasAlternatingPointerIdShape(index); - if (hasConservativeAnchor && LooksLikeCallableEntry(sections, candidates[index].target, true, true)) + if ((hasConservativeAnchor && LooksLikeCallableEntry(sections, candidates[index].target, true, true)) || + isPeriodicTableEntry(index)) { starts.insert(candidates[index].target); } diff --git a/ps2xTest/src/ps2_recompiler_tests.cpp b/ps2xTest/src/ps2_recompiler_tests.cpp index 0718c8809a..c8a3dde9e1 100644 --- a/ps2xTest/src/ps2_recompiler_tests.cpp +++ b/ps2xTest/src/ps2_recompiler_tests.cpp @@ -501,7 +501,7 @@ static bool writeMinimalMipsElfWithAddressTakenCallbacks(const std::filesystem:: text->set_addr_align(4); text->set_address(0x00100000u); - std::array textWords{}; + std::array textWords{}; textWords[0] = 0x3C040010u; // lui a0,0x10 textWords[1] = 0xAC800000u; // sw zero,0(a0) textWords[2] = 0x0C040008u; // jal 0x00100020 (callback registrar) @@ -734,6 +734,22 @@ static bool writeMinimalMipsElfWithAddressTakenCallbacks(const std::filesystem:: textWords[373] = 0x03E00008u; // jr ra textWords[374] = 0x27BD0010u; // addiu sp,sp,0x10 (delay slot) + // Three leaf handlers in a row after a return, each with the next one's entry right behind its + // delay slot. Only the first returns inside the eight-word probe; the others return 8 and 11 words + // in, so nothing but the record table below names them (a real battle command table). + textWords[382] = 0x03E00008u; // jr ra + textWords[383] = 0x00000000u; // nop (delay slot) + const std::array shortHandler = {0x8C8F002Cu, 0x8DEE0000u, 0x95CF0010u, 0x25EDFFD4u, + 0x25EFFF8Eu, 0xA5CD0064u, 0x03E00008u, 0xA5CF0034u}; + const std::array mediumHandler = {0x8C8F002Cu, 0x240E000Au, 0x8DED0000u, 0x95AF0010u, 0xA5AE0094u, + 0x25EEFFD4u, 0x25EFFF8Eu, 0xA5AE0064u, 0x03E00008u, 0xA5AF0034u}; + const std::array longHandler = {0x8C8F002Cu, 0x8C8E25B4u, 0x8DED0000u, 0x8C8C0030u, 0x8DAF0010u, + 0xADAE0048u, 0x000F77C2u, 0x01EE7821u, 0x000F7843u, 0xA5AF0034u, + 0x858F0004u, 0x03E00008u, 0xADAF0070u}; + std::copy(shortHandler.begin(), shortHandler.end(), textWords.begin() + 384); // 0x00100600 + std::copy(mediumHandler.begin(), mediumHandler.end(), textWords.begin() + 392); // 0x00100620 + std::copy(longHandler.begin(), longHandler.end(), textWords.begin() + 402); // 0x00100648 + text->set_data(reinterpret_cast(textWords.data()), static_cast(textWords.size() * sizeof(uint32_t))); @@ -766,9 +782,18 @@ static bool writeMinimalMipsElfWithAddressTakenCallbacks(const std::filesystem:: data->set_flags(ELFIO::SHF_ALLOC | ELFIO::SHF_WRITE); data->set_addr_align(4); data->set_address(0x00201000u); - std::array singletonCallbacks{}; + // Eight zero words keep the records outside the 32-byte cluster window of the delay-slot pointer above. + std::array singletonCallbacks{}; singletonCallbacks[0] = 0x00100580u; singletonCallbacks[16] = 0x00100588u; + // Six-word records {name, id, id, id, flags, handler}: code pointers 24 bytes apart, no entry + // inside the 32-byte cluster window with a conservative shape besides the first. + const std::array handlerRecords = { + 0x00201000u, 0x3u, 0xEu, 0xDu, 0x10020u, 0x00100600u, + 0x00201000u, 0x4u, 0xEu, 0xDu, 0x10020u, 0x00100620u, + 0x00201000u, 0x3u, 0xEu, 0xDu, 0x20020u, 0x00100648u, + 0x00201000u, 0x4u, 0xEu, 0xDu, 0x10014u, 0x0u}; + std::copy(handlerRecords.begin(), handlerRecords.end(), singletonCallbacks.begin() + 25); data->set_data(reinterpret_cast(singletonCallbacks.data()), static_cast(singletonCallbacks.size() * sizeof(uint32_t))); @@ -1957,6 +1982,12 @@ void register_ps2_recompiler_tests() "a data pointer must not split a function at a control-transfer delay slot"); t.IsFalse(hasStart(0x00100300u), "an isolated data pointer or non-callback code materialization must not become a function"); + t.IsTrue(hasStart(0x00100600u), + "a strided record table should discover its short leaf handler"); + t.IsTrue(hasStart(0x00100620u), + "a strided record table should discover a handler that returns beyond the probe window"); + t.IsTrue(hasStart(0x00100648u), + "a strided record table should discover its longest leaf handler"); std::error_code removeError; std::filesystem::remove(elfPath, removeError); From cf78823bde1c1ef9da669eab87518ed7ba3ea38f Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:49:36 -0300 Subject: [PATCH 88/91] Present the vblank capture whenever one exists for the requested tick --- ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp | 11 +++-- ps2xTest/src/ps2_gs_tests.cpp | 59 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index 170f44b81b..010bda4345 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -4200,11 +4200,16 @@ PresentationFrame GSCpuBackend::Present(const GSPresentationRequest &request) behind = m_queueCompleted < m_queueSubmitted; } bool captured = false; - if (behind) { - // Live memory would pair the guest's current DISPFB with a buffer still being drawn. + // The capture taken at this vblank pairs the display registers with the memory the game had + // finished drawing by then. Live memory races the EE: between building the request and copying + // VRAM, the game can clear the buffer DISPFB still names (it clears the next draw buffer before + // moving DISPFB off it), which showed as a black frame every few seconds in Mana Khemia's maps. + // Only when nothing was queued since the vblank is live memory still the vblank state, unless + // the GS thread is behind, in which case the previous capture is the newest consistent frame. std::lock_guard lock(m_captureMutex); - if (!m_capturedVram.empty()) + const bool captureIsCurrent = !m_capturedVram.empty() && m_capturedDisplay.vsyncTick == request.vsyncTick; + if (!m_capturedVram.empty() && (captureIsCurrent || behind)) { snapshot = m_capturedVram; shown.pmode = m_capturedDisplay.pmode; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index f6da9d57db..6c1bdf0e53 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -4032,6 +4032,65 @@ void register_ps2_gs_tests() t.Equals(static_cast(after.pixels[0]), 0x00u, "once drained, presentation should show live memory"); }); + tc.Run("GS thread presentation prefers the capture taken at the requested vblank even when drained", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GSRegisters regs{}; + regs.pmode = 0x1ull; + regs.dispfb1 = 10ull << 9u; + regs.display1 = (639ull << 32u) | (447ull << 44u); + + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + gs.SetAsync(true); + gs.SetDisplayRegisters(®s); + + auto sprite = [&gs](uint32_t fbp, uint8_t r) + { + GSPrimitiveBatch batch{}; + batch.state.prim.type = GS_PRIM_SPRITE; + batch.state.context.frame.fbp = fbp; + batch.state.context.frame.fbw = 10u; + batch.state.context.frame.psm = GS_PSM_CT32; + batch.state.context.scissor = {0u, 639u, 0u, 447u}; + batch.state.context.zbuf.zmask = 1u; + batch.state.context.test = (1ull << 17u) | (1ull << 16u); + batch.vertexCount = 2u; + batch.vertices[0].x = 0.0f; + batch.vertices[0].y = 0.0f; + batch.vertices[1].x = 640.0f; + batch.vertices[1].y = 448.0f; + for (GSVertex &vertex : batch.vertices) + { + vertex.r = r; + vertex.a = 0x80u; + } + gs.Submit(batch); + }; + + // Frame drawn before the vblank, then the game clears the still-displayed buffer right after it. + sprite(0u, 0xFFu); + regs.vsyncTick.store(1u); + sprite(0u, 0x00u); + std::vector settled; + gs.SnapshotVram(settled); + + GSPresentationRequest request{}; + request.pmode = regs.pmode; + request.dispfb1 = regs.dispfb1; + request.display1 = regs.display1; + request.vsyncTick = 1u; + const PresentationFrame atVblank = gs.Present(request); + t.IsTrue(!atVblank.pixels.empty(), "presentation should produce a frame"); + t.Equals(static_cast(atVblank.pixels[0]), 0xFFu, + "the capture taken at the requested vblank must win over memory the game already cleared"); + + request.vsyncTick = 2u; + const PresentationFrame later = gs.Present(request); + t.Equals(static_cast(later.pixels[0]), 0x00u, + "a tick with no capture yet and a drained queue presents live memory"); + }); + tc.Run("GS texture page buffer also hides draws into the cached page until TEXFLUSH", [](TestCase &t) { std::vector vram(PS2_GS_VRAM_SIZE, 0u); From 1a50c194883064c6954e8c82a2d704f1330f1ab9 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:49:36 -0300 Subject: [PATCH 89/91] Count black frames and audio ring underruns and drops in the perf log --- ps2xRuntime/src/lib/ps2_audio_stream.cpp | 13 ++++++ ps2xRuntime/src/lib/ps2_audio_stream.h | 7 +++ ps2xRuntime/src/lib/ps2_runtime.cpp | 56 ++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.cpp b/ps2xRuntime/src/lib/ps2_audio_stream.cpp index cc29dfef70..e384fa0875 100644 --- a/ps2xRuntime/src/lib/ps2_audio_stream.cpp +++ b/ps2xRuntime/src/lib/ps2_audio_stream.cpp @@ -55,6 +55,7 @@ size_t Spu2FrameRing::pop(int16_t *out, size_t frames) if (queued > m_maxLatency) { + m_latencyDroppedFrames.fetch_add(queued - m_maxLatency, std::memory_order_relaxed); read = (read + queued - m_maxLatency) % m_capacity; queued = m_maxLatency; } @@ -103,6 +104,18 @@ namespace #endif } +uint64_t spu2AudioUnderruns() +{ + const Spu2FrameRing *ring = g_activeRing.load(std::memory_order_acquire); + return ring ? ring->underruns() : 0u; +} + +uint64_t spu2AudioLatencyDroppedFrames() +{ + const Spu2FrameRing *ring = g_activeRing.load(std::memory_order_acquire); + return ring ? ring->latencyDroppedFrames() : 0u; +} + struct Spu2AudioOutput::Impl { Spu2FrameRing ring{kRingFrames, kPrebufferFrames, kMaxLatencyFrames}; diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.h b/ps2xRuntime/src/lib/ps2_audio_stream.h index 3fba8f50db..3fe49da9cd 100644 --- a/ps2xRuntime/src/lib/ps2_audio_stream.h +++ b/ps2xRuntime/src/lib/ps2_audio_stream.h @@ -23,6 +23,7 @@ class Spu2FrameRing [[nodiscard]] size_t available() const noexcept; [[nodiscard]] uint64_t underruns() const noexcept { return m_underruns.load(std::memory_order_relaxed); } + [[nodiscard]] uint64_t latencyDroppedFrames() const noexcept { return m_latencyDroppedFrames.load(std::memory_order_relaxed); } private: std::vector m_samples; @@ -33,8 +34,14 @@ class Spu2FrameRing std::atomic m_writeIndex{0}; bool m_starving = true; std::atomic m_underruns{0}; + std::atomic m_latencyDroppedFrames{0}; }; +// Counters of the active host audio ring for the perf log: callbacks that found it empty, and frames +// skipped because the game ran more than the latency budget ahead of the device. Both are audible. +uint64_t spu2AudioUnderruns(); +uint64_t spu2AudioLatencyDroppedFrames(); + // Plays SPU2 output through the host audio device. class Spu2AudioOutput { diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index d0815b5b86..94f40d2711 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -13,6 +13,7 @@ #include "Kernel/Stubs/MPEG.h" #include "ps2_host_backend.h" #include "ps2_iop_host.h" +#include "ps2_audio_stream.h" #include "ps2x/iop/iop_subsystem.h" #include @@ -391,26 +392,46 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint const uint64_t currentTick = rt->eeScheduler().currentVSyncTick(); // PS2X_PERF_LOG=1 prints guest vblanks/s against host frames/s every 5 s, so a - // scripted run's log shows where the game drops below full speed. + // scripted run's log shows where the game drops below full speed. The same line counts + // all-black frames latched for display (a black flash a player reports is one of these) + // and the host audio ring's underruns and latency drops (audible as skips or crackle). + static const bool s_perfLog = std::getenv("PS2X_PERF_LOG") != nullptr; + static uint32_t s_blackFrames = 0u; + static uint64_t s_lastBlackTick = 0u; + static uint32_t s_lastBlackDisplayFbp = 0u; + static uint32_t s_lastBlackSourceFbp = 0u; { - static const bool s_perfLog = std::getenv("PS2X_PERF_LOG") != nullptr; if (s_perfLog) { static auto s_lastReport = std::chrono::steady_clock::now(); static uint64_t s_lastTick = currentTick; static uint32_t s_hostFrames = 0u; + static uint64_t s_lastUnderruns = 0u; + static uint64_t s_lastAudioDrops = 0u; ++s_hostFrames; const auto now = std::chrono::steady_clock::now(); const double elapsed = std::chrono::duration(now - s_lastReport).count(); if (elapsed >= 5.0) { - std::fprintf(stderr, "[perf] vsync=%llu guest_vps=%.1f host_fps=%.1f\n", + const uint64_t underruns = spu2AudioUnderruns(); + const uint64_t audioDrops = spu2AudioLatencyDroppedFrames(); + std::fprintf(stderr, + "[perf] vsync=%llu guest_vps=%.1f host_fps=%.1f black_frames=%u last_black(tick=%llu dispfbp=0x%x srcfbp=0x%x) audio_underruns=+%llu audio_dropped_frames=+%llu\n", static_cast(currentTick), static_cast(currentTick - s_lastTick) / elapsed, - static_cast(s_hostFrames) / elapsed); + static_cast(s_hostFrames) / elapsed, + s_blackFrames, + static_cast(s_lastBlackTick), + s_lastBlackDisplayFbp, + s_lastBlackSourceFbp, + static_cast(underruns - s_lastUnderruns), + static_cast(audioDrops - s_lastAudioDrops)); s_lastReport = now; s_lastTick = currentTick; s_hostFrames = 0u; + s_blackFrames = 0u; + s_lastUnderruns = underruns; + s_lastAudioDrops = audioDrops; } } } @@ -452,6 +473,33 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint return; } + if (s_perfLog && needsLatch && !s_scratch.empty() && width != 0u && height != 0u) + { + bool allBlack = true; + const size_t rowBytes = static_cast(width) * 4u; + for (uint32_t y = 0; y < height && allBlack; ++y) + { + const uint8_t *row = s_scratch.data() + static_cast(y) * rowBytes; + if (static_cast(y) * rowBytes + rowBytes > s_scratch.size()) + break; + for (uint32_t x = 0; x < width; ++x) + { + if (row[x * 4u] != 0u || row[x * 4u + 1u] != 0u || row[x * 4u + 2u] != 0u) + { + allBlack = false; + break; + } + } + } + if (allBlack) + { + ++s_blackFrames; + s_lastBlackTick = currentTick; + s_lastBlackDisplayFbp = displayFbp; + s_lastBlackSourceFbp = sourceFbp; + } + } + PS2_IF_AGRESSIVE_LOGS({ static uint32_t s_uploadDebugCount = 0u; if (s_uploadDebugCount < 128u || From fb50e228329beed3cec41839f25105b01897e006 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:07:07 -0300 Subject: [PATCH 90/91] Register block starts after unconditional transfers as resume entries and report the first 16 missing targets --- ps2xRecomp/include/ps2recomp/ps2_recompiler.h | 4 ++ ps2xRecomp/src/lib/ps2_recompiler.cpp | 66 +++++++++++++++++++ ps2xRuntime/include/ps2_runtime.h | 2 +- ps2xRuntime/src/lib/ps2_runtime.cpp | 11 +++- ps2xTest/src/ps2_recompiler_tests.cpp | 44 +++++++++++++ 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/ps2xRecomp/include/ps2recomp/ps2_recompiler.h b/ps2xRecomp/include/ps2recomp/ps2_recompiler.h index dd622753fe..c74ae517ab 100644 --- a/ps2xRecomp/include/ps2recomp/ps2_recompiler.h +++ b/ps2xRecomp/include/ps2recomp/ps2_recompiler.h @@ -43,6 +43,10 @@ namespace ps2recomp std::unordered_map> &decodedFunctions, const std::vector
§ions); static size_t ResliceEntryFunctions(std::vector &functions, std::unordered_map> &decodedFunctions); + static size_t CollectPostTransferEntryTargets( + const std::vector &functions, + const std::unordered_map> &decodedFunctions, + std::unordered_map> &targetsByOwner); static size_t CollectInternalEntryTargets( const std::vector &functions, const std::unordered_map> &decodedFunctions, diff --git a/ps2xRecomp/src/lib/ps2_recompiler.cpp b/ps2xRecomp/src/lib/ps2_recompiler.cpp index bf36a726e6..c22ad9514f 100644 --- a/ps2xRecomp/src/lib/ps2_recompiler.cpp +++ b/ps2xRecomp/src/lib/ps2_recompiler.cpp @@ -825,6 +825,55 @@ namespace ps2recomp } } + // Every instruction that follows an unconditional transfer and its delay slot starts a block no + // fallthrough reaches, which is where a routine the function map missed begins (leaf handlers + // named only by data tables). Registering those as resume entries lets jalr/jr land on them + // through the owning function instead of leaving the thread dormant on a missing target. + size_t collectPostTransferEntryTargetsImpl( + const std::vector &functions, + const std::unordered_map> &decodedFunctions, + std::unordered_map> &targetsByOwner) + { + const auto isUnconditionalTransfer = [](const Instruction &inst) + { + if (!inst.hasDelaySlot) + return false; + const bool jumpRegister = inst.opcode == OPCODE_SPECIAL && inst.function == SPECIAL_JR; + const bool jumpAbsolute = inst.opcode == OPCODE_J; + const bool branchAlways = inst.opcode == OPCODE_BEQ && inst.rs == 0u && inst.rt == 0u; + return jumpRegister || jumpAbsolute || branchAlways; + }; + + size_t addedCount = 0u; + for (const auto &function : functions) + { + if (!function.isRecompiled || function.isStub || function.isSkipped) + continue; + const auto decodedIt = decodedFunctions.find(function.start); + if (decodedIt == decodedFunctions.end()) + continue; + const auto &instructions = decodedIt->second; + std::vector *targets = nullptr; + for (size_t i = 0; i + 2 < instructions.size(); ++i) + { + if (!isUnconditionalTransfer(instructions[i])) + continue; + const Instruction &candidate = instructions[i + 2]; + // Padding between functions is zero words; a routine never starts with one. + if (candidate.raw == 0u || candidate.address == function.start) + continue; + if (!targets) + targets = &targetsByOwner[function.start]; + if (std::find(targets->begin(), targets->end(), candidate.address) == targets->end()) + { + targets->push_back(candidate.address); + ++addedCount; + } + } + } + return addedCount; + } + PS2Recompiler::PS2Recompiler(const std::string &configPath) : m_configManager(configPath) { @@ -2111,6 +2160,15 @@ namespace ps2recomp } } + const size_t postTransferTargets = + collectPostTransferEntryTargetsImpl(m_functions, m_decodedFunctions, m_resumeEntryTargetsByOwner); + if (postTransferTargets > 0u) + { + std::ostringstream msg; + msg << "registered " << postTransferTargets << " block start(s) after unconditional transfers as resume entries"; + m_reporter.progress(msg.str()); + } + size_t totalTargets = 0u; for (auto it = m_resumeEntryTargetsByOwner.begin(); it != m_resumeEntryTargetsByOwner.end();) { @@ -2465,6 +2523,14 @@ namespace ps2recomp return resliceEntryFunctionsImpl(functions, decodedFunctions); } + size_t PS2Recompiler::CollectPostTransferEntryTargets( + const std::vector &functions, + const std::unordered_map> &decodedFunctions, + std::unordered_map> &targetsByOwner) + { + return collectPostTransferEntryTargetsImpl(functions, decodedFunctions, targetsByOwner); + } + size_t PS2Recompiler::CollectInternalEntryTargets( const std::vector &functions, const std::unordered_map> &decodedFunctions, diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index 86639c7d83..06d1dc16f3 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -520,7 +520,7 @@ class PS2Runtime uint32_t m_asyncCallbackStackTop = PS2_RAM_SIZE; std::atomic m_missingFunctionPolicy{static_cast(MissingFunctionPolicy::ContinueToTarget)}; - std::atomic m_missingFunctionReported{false}; + std::atomic m_missingFunctionReports{0}; std::atomic m_stopRequested{false}; DebugUiCallback m_debugUiInitCallback = nullptr; DebugUiCallback m_debugUiDrawCallback = nullptr; diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 94f40d2711..32a4a864a6 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -1261,7 +1261,7 @@ PS2Runtime::MissingFunctionPolicy PS2Runtime::missingFunctionPolicy() const void PS2Runtime::resetMissingFunctionReportOnce() { - m_missingFunctionReported.store(false, std::memory_order_release); + m_missingFunctionReports.store(0u, std::memory_order_release); } void PS2Runtime::reportMissingFunction(uint8_t *rdram, @@ -1272,7 +1272,12 @@ void PS2Runtime::reportMissingFunction(uint8_t *rdram, const char *debugName) { const MissingFunctionPolicy policy = missingFunctionPolicy(); - const bool firstReport = !m_missingFunctionReported.exchange(true, std::memory_order_acq_rel); + // One line per distinct failure is what a play log needs; a game that keeps hitting missing + // targets after the first would otherwise hide every later one, so report the first few. + constexpr uint32_t kMaxMissingFunctionReports = 16u; + const uint32_t reportIndex = m_missingFunctionReports.fetch_add(1u, std::memory_order_acq_rel); + const bool firstReport = reportIndex < kMaxMissingFunctionReports; + const bool breakOnce = reportIndex == 0u; const uint32_t pc = ctx->pc; const uint32_t ra = static_cast(_mm_extract_epi32(ctx->r[31], 0)); @@ -1404,7 +1409,7 @@ void PS2Runtime::reportMissingFunction(uint8_t *rdram, } } - if (firstReport && policy == MissingFunctionPolicy::BreakOnce) + if (breakOnce && policy == MissingFunctionPolicy::BreakOnce) { #if defined(_MSC_VER) __debugbreak(); diff --git a/ps2xTest/src/ps2_recompiler_tests.cpp b/ps2xTest/src/ps2_recompiler_tests.cpp index c8a3dde9e1..77a770657f 100644 --- a/ps2xTest/src/ps2_recompiler_tests.cpp +++ b/ps2xTest/src/ps2_recompiler_tests.cpp @@ -1333,6 +1333,50 @@ void register_ps2_recompiler_tests() } }); + tc.Run("block starts after unconditional transfers become resume entries of their function", [](TestCase &t) { + // A merged leaf after `jr ra; sh` (as the battle handlers were), zero padding after the + // next return, and a call whose successor is plain fallthrough. + std::vector body; + body.push_back(makeJrRa(0x1000u)); + Instruction store = makeNopLike(0x1004u); + store.raw = 0xA58E0034u; + body.push_back(store); + Instruction leaf = makeNopLike(0x1008u); + leaf.raw = 0x8C8F002Cu; + body.push_back(leaf); + body.push_back(makeJrRa(0x100Cu)); + body.push_back(makeNopLike(0x1010u)); + body.push_back(makeNopLike(0x1014u)); // zero padding, raw == 0 + Instruction call = makeAbsJump(0x1018u, 0x2000u, OPCODE_JAL); + call.isCall = true; + body.push_back(call); + body.push_back(makeNopLike(0x101Cu)); + Instruction after = makeNopLike(0x1020u); + after.raw = 0x24420001u; + body.push_back(after); + Instruction tail = makeAbsJump(0x1024u, 0x1008u, OPCODE_J); + body.push_back(tail); + body.push_back(makeNopLike(0x1028u)); + Instruction last = makeNopLike(0x102Cu); + last.raw = 0x24420001u; + body.push_back(last); + + std::vector functions{makeFunction("container", 0x1000u, 0x1030u)}; + std::unordered_map> decoded; + decoded.emplace(0x1000u, body); + std::unordered_map> targets; + const size_t added = PS2Recompiler::CollectPostTransferEntryTargets(functions, decoded, targets); + + const auto &owner = targets[0x1000u]; + auto has = [&owner](uint32_t address) + { return std::find(owner.begin(), owner.end(), address) != owner.end(); }; + t.Equals(added, static_cast(2), "two block starts follow a transfer and its delay slot"); + t.IsTrue(has(0x1008u), "the instruction after `jr ra` and its delay slot is a resume entry"); + t.IsTrue(has(0x102Cu), "the instruction after `j` and its delay slot is a resume entry"); + t.IsFalse(has(0x1014u), "zero padding after a return is not an entry"); + t.IsFalse(has(0x1020u), "the fallthrough after a call is not an entry"); + }); + tc.Run("discovery ignores synthetic entry wrappers", [](TestCase &t) { std::vector
sections = { {".text", 0x1000u, 0x2000u, 0u, true, false, false, true, nullptr} From d4b4b0504d11dcde9e7142712c22773f48faa404 Mon Sep 17 00:00:00 2001 From: David Alen <26146736+AlenDavid@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:07:07 -0300 Subject: [PATCH 91/91] Resample host audio toward a 100 ms queue instead of skipping or starving when the IOP clock drifts --- ps2xRuntime/src/lib/ps2_audio_stream.cpp | 78 +++++++++++++++++++----- ps2xRuntime/src/lib/ps2_audio_stream.h | 12 +++- ps2xRuntime/src/lib/ps2_runtime.cpp | 6 +- ps2xTest/src/ps2_audio_stream_tests.cpp | 23 +++++++ 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.cpp b/ps2xRuntime/src/lib/ps2_audio_stream.cpp index e384fa0875..70af41d106 100644 --- a/ps2xRuntime/src/lib/ps2_audio_stream.cpp +++ b/ps2xRuntime/src/lib/ps2_audio_stream.cpp @@ -3,16 +3,19 @@ #include "ps2_host_backend.h" #include +#include +#include #include #include #include #include -Spu2FrameRing::Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames) +Spu2FrameRing::Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames, size_t targetLatencyFrames) : m_samples((capacityFrames + 1u) * 2u), m_capacity(capacityFrames + 1u), m_prebuffer(std::min(prebufferFrames, capacityFrames)), - m_maxLatency(std::min(maxLatencyFrames, capacityFrames)) + m_maxLatency(std::min(maxLatencyFrames, capacityFrames)), + m_targetLatency(std::min(targetLatencyFrames, capacityFrames)) { } @@ -60,32 +63,64 @@ size_t Spu2FrameRing::pop(int16_t *out, size_t frames) queued = m_maxLatency; } - const size_t count = std::min(frames, queued); - for (size_t i = 0; i < count;) + // Playback rate: 1.0 within 10% of the target, otherwise up to 5% faster (queue long) or slower + // (queue short). The rate only moves with the fill level, so steady producers converge quietly. + double ratio = 1.0; + if (m_targetLatency != 0u) { - const size_t index = (read + i) % m_capacity; - const size_t run = std::min(count - i, m_capacity - index); - std::memcpy(out + i * 2u, &m_samples[index * 2u], run * 2u * sizeof(int16_t)); - i += run; + constexpr double kDeadBand = 0.1; + constexpr double kMaxDeviation = 0.05; + const double deviation = (static_cast(queued) - static_cast(m_targetLatency)) / + static_cast(m_targetLatency); + if (deviation > kDeadBand || deviation < -kDeadBand) + ratio = 1.0 + std::clamp(deviation, -1.0, 1.0) * kMaxDeviation; } - m_readIndex.store((read + count) % m_capacity, std::memory_order_release); - if (count < frames) + size_t produced = 0u; + double phase = (ratio == 1.0) ? 0.0 : m_phase; + while (produced < frames) { - std::memset(out + count * 2u, 0, (frames - count) * 2u * sizeof(int16_t)); + const size_t base = static_cast(phase); + const double frac = phase - static_cast(base); + if (base >= queued || (frac > 0.0 && base + 1u >= queued)) + break; + const size_t index0 = (read + base) % m_capacity; + const size_t index1 = (read + base + 1u) % m_capacity; + for (size_t channel = 0; channel < 2u; ++channel) + { + const int16_t s0 = m_samples[index0 * 2u + channel]; + const int16_t s1 = (frac > 0.0) ? m_samples[index1 * 2u + channel] : s0; + out[produced * 2u + channel] = static_cast(std::lrint(s0 + (s1 - s0) * frac)); + } + ++produced; + phase += ratio; + } + size_t consumed = static_cast(phase); + if (consumed > queued) + consumed = queued; + m_phase = (ratio == 1.0) ? 0.0 : phase - static_cast(consumed); + m_readIndex.store((read + consumed) % m_capacity, std::memory_order_release); + + if (produced < frames) + { + std::memset(out + produced * 2u, 0, (frames - produced) * 2u * sizeof(int16_t)); m_starving = true; + m_phase = 0.0; m_underruns.fetch_add(1u, std::memory_order_relaxed); } - return count; + return produced; } namespace { constexpr unsigned int kSpu2SampleRate = 48000u; - // 250 ms of headroom, 40 ms to (re)start, and never more than 150 ms behind the game. + // 250 ms of headroom, 40 ms to (re)start, playback rate held around 100 ms of queue, and never more + // than 200 ms behind the game. A 5% rate swing covers the few percent the IOP's emulated 48 kHz + // drifts from the device (fast at the title, slow when the EE misses vblanks) without cutting audio. constexpr size_t kRingFrames = kSpu2SampleRate / 4u; constexpr size_t kPrebufferFrames = kSpu2SampleRate / 25u; - constexpr size_t kMaxLatencyFrames = kSpu2SampleRate * 3u / 20u; + constexpr size_t kTargetLatencyFrames = kSpu2SampleRate / 10u; + constexpr size_t kMaxLatencyFrames = kSpu2SampleRate / 5u; // raylib audio callbacks carry no user pointer. std::atomic g_activeRing{nullptr}; @@ -104,6 +139,16 @@ namespace #endif } +namespace +{ + std::atomic g_pushedFrames{0}; +} + +uint64_t spu2AudioPushedFrames() +{ + return g_pushedFrames.load(std::memory_order_relaxed); +} + uint64_t spu2AudioUnderruns() { const Spu2FrameRing *ring = g_activeRing.load(std::memory_order_acquire); @@ -118,7 +163,7 @@ uint64_t spu2AudioLatencyDroppedFrames() struct Spu2AudioOutput::Impl { - Spu2FrameRing ring{kRingFrames, kPrebufferFrames, kMaxLatencyFrames}; + Spu2FrameRing ring{kRingFrames, kPrebufferFrames, kMaxLatencyFrames, kTargetLatencyFrames}; bool started = false; bool unavailable = false; #if !defined(PLATFORM_VITA) @@ -237,5 +282,8 @@ void Spu2AudioOutput::submit(const int16_t *samples, size_t frames) if (!m_impl->started && !m_impl->unavailable) m_impl->start(); if (m_impl->started) + { + g_pushedFrames.fetch_add(frames, std::memory_order_relaxed); m_impl->ring.push(samples, frames); + } } diff --git a/ps2xRuntime/src/lib/ps2_audio_stream.h b/ps2xRuntime/src/lib/ps2_audio_stream.h index 3fe49da9cd..e6282b5bfa 100644 --- a/ps2xRuntime/src/lib/ps2_audio_stream.h +++ b/ps2xRuntime/src/lib/ps2_audio_stream.h @@ -11,14 +11,19 @@ class Spu2FrameRing { public: - Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames); + // `targetLatencyFrames` (0 disables it) makes the consumer resample toward that fill level: the IOP + // mixes at 48 kHz of *emulated* time, which runs a few percent fast or slow against the device, + // so a fixed rate would alternate between skipping audio and running dry. + Spu2FrameRing(size_t capacityFrames, size_t prebufferFrames, size_t maxLatencyFrames, size_t targetLatencyFrames = 0u); // Producer side. Frames that do not fit are dropped: the host is behind and old audio is stale anyway. size_t push(const int16_t *samples, size_t frames); // Consumer side. Always fills `frames`, padding with silence. After running dry it waits for // `prebufferFrames` before resuming, so a slow producer gives gaps instead of a buzz of tiny chunks; - // beyond `maxLatencyFrames` queued, the oldest frames are skipped to keep audio in sync. + // beyond `maxLatencyFrames` queued, the oldest frames are skipped to keep audio in sync. With a + // target latency, playback speeds up or slows down by up to 5% to hold the queue near it. + // Returns the frames produced. size_t pop(int16_t *out, size_t frames); [[nodiscard]] size_t available() const noexcept; @@ -30,6 +35,8 @@ class Spu2FrameRing size_t m_capacity; size_t m_prebuffer; size_t m_maxLatency; + size_t m_targetLatency; + double m_phase = 0.0; // fractional read position for resampling std::atomic m_readIndex{0}; std::atomic m_writeIndex{0}; bool m_starving = true; @@ -39,6 +46,7 @@ class Spu2FrameRing // Counters of the active host audio ring for the perf log: callbacks that found it empty, and frames // skipped because the game ran more than the latency budget ahead of the device. Both are audible. +uint64_t spu2AudioPushedFrames(); uint64_t spu2AudioUnderruns(); uint64_t spu2AudioLatencyDroppedFrames(); diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 32a4a864a6..e60e0baa3b 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -408,6 +408,7 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint static uint32_t s_hostFrames = 0u; static uint64_t s_lastUnderruns = 0u; static uint64_t s_lastAudioDrops = 0u; + static uint64_t s_lastPushed = 0u; ++s_hostFrames; const auto now = std::chrono::steady_clock::now(); const double elapsed = std::chrono::duration(now - s_lastReport).count(); @@ -415,8 +416,9 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint { const uint64_t underruns = spu2AudioUnderruns(); const uint64_t audioDrops = spu2AudioLatencyDroppedFrames(); + const uint64_t pushed = spu2AudioPushedFrames(); std::fprintf(stderr, - "[perf] vsync=%llu guest_vps=%.1f host_fps=%.1f black_frames=%u last_black(tick=%llu dispfbp=0x%x srcfbp=0x%x) audio_underruns=+%llu audio_dropped_frames=+%llu\n", + "[perf] vsync=%llu guest_vps=%.1f host_fps=%.1f black_frames=%u last_black(tick=%llu dispfbp=0x%x srcfbp=0x%x) audio_rate=%.0f audio_underruns=+%llu audio_dropped_frames=+%llu\n", static_cast(currentTick), static_cast(currentTick - s_lastTick) / elapsed, static_cast(s_hostFrames) / elapsed, @@ -424,6 +426,7 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint static_cast(s_lastBlackTick), s_lastBlackDisplayFbp, s_lastBlackSourceFbp, + static_cast(pushed - s_lastPushed) / elapsed, static_cast(underruns - s_lastUnderruns), static_cast(audioDrops - s_lastAudioDrops)); s_lastReport = now; @@ -432,6 +435,7 @@ static void UploadFrame(Texture2D &tex, PS2Runtime *rt, uint32_t &outWidth, uint s_blackFrames = 0u; s_lastUnderruns = underruns; s_lastAudioDrops = audioDrops; + s_lastPushed = pushed; } } } diff --git a/ps2xTest/src/ps2_audio_stream_tests.cpp b/ps2xTest/src/ps2_audio_stream_tests.cpp index 08919ff58e..b9fe8fab4e 100644 --- a/ps2xTest/src/ps2_audio_stream_tests.cpp +++ b/ps2xTest/src/ps2_audio_stream_tests.cpp @@ -56,6 +56,29 @@ void register_ps2_audio_stream_tests() t.Equals(ring.pop(out.data(), 2u), size_t{0}, "a trickle after an underrun must wait for the prebuffer"); }); + tc.Run("a queue far above its target plays faster, far below it slower, and near it unchanged", [](TestCase &t) + { + Spu2FrameRing ring(4096u, 8u, 4096u, 64u); + const std::vector frames = rampFrames(256u, 0); + ring.push(frames.data(), 256u); + std::vector out(200u); + t.Equals(ring.pop(out.data(), 100u), size_t{100}, "the callback is always filled"); + const size_t afterFast = ring.available(); + t.IsTrue(afterFast >= 150u && afterFast <= 152u, "a queue four times the target is consumed 5% faster than it plays"); + t.IsTrue(out[0] == 0 && out[2] > out[0] && out[198] > out[2], "faster playback keeps the ramp monotonic"); + + Spu2FrameRing low(4096u, 8u, 4096u, 64u); + low.push(frames.data(), 40u); + t.Equals(low.pop(out.data(), 32u), size_t{32}, "the callback is filled from a short queue"); + t.Equals(low.available(), size_t{9}, "a queue below the target is stretched: 32 frames out, 31 consumed"); + + Spu2FrameRing near(4096u, 8u, 4096u, 64u); + near.push(frames.data(), 66u); + t.Equals(near.pop(out.data(), 16u), size_t{16}, "playback starts"); + t.Equals(out[30], int16_t{15}, "within the dead band samples pass through untouched"); + t.Equals(near.available(), size_t{50}, "within the dead band one frame is consumed per frame played"); + }); + tc.Run("a full ring drops new frames and a long queue is trimmed to the latency cap", [](TestCase &t) { Spu2FrameRing ring(16u, 1u, 8u);