From d1c0db40baa144390bb2c8ede845c2fab89d9d7e Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:34:42 -0700 Subject: [PATCH 01/17] fix(recomp): align LQ/SQ/LQC2/SQC2 addresses to 16 bytes The R5900 ignores the low four bits of the effective address for quadword loads and stores. The translator passed the raw address through, so code that relies on the implicit alignment read and wrote the wrong bytes. The common case is an unaligned memcpy: libc-style copies load aligned quadwords with LQ from an unaligned source pointer and splice them with QFSRV. In FIFA Street 2 (SLES-53797) the memcpy at 0x3cbdd8 copied a config struct from an 8-byte-aligned source this way, zeroed its size field, and left a later allocator callback NULL. Mask the address in both the expression path and the constant-address (memory hint) path. Co-Authored-By: Claude Opus 5.5 --- ps2xRecomp/src/lib/instruction_translator.cpp | 14 +++++++------ ps2xTest/src/code_generator_tests.cpp | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/ps2xRecomp/src/lib/instruction_translator.cpp b/ps2xRecomp/src/lib/instruction_translator.cpp index 7684097593..3494451220 100644 --- a/ps2xRecomp/src/lib/instruction_translator.cpp +++ b/ps2xRecomp/src/lib/instruction_translator.cpp @@ -80,7 +80,7 @@ namespace ps2recomp { if (memoryHint.hasAddress) { - const uint32_t resolvedAddress = memoryHint.address; + const uint32_t resolvedAddress = (width == 128) ? (memoryHint.address & ~0xFu) : memoryHint.address; const std::string resolvedAddressExpr = addressLiteral(resolvedAddress); if (inst.isMmio || Ps2IsSpecialAddress(resolvedAddress)) { @@ -104,7 +104,7 @@ namespace ps2recomp { if (memoryHint.hasAddress) { - const uint32_t resolvedAddress = memoryHint.address; + const uint32_t resolvedAddress = (width == 128) ? (memoryHint.address & ~0xFu) : memoryHint.address; const std::string resolvedAddressExpr = addressLiteral(resolvedAddress); if (inst.isMmio || Ps2IsSpecialAddress(resolvedAddress)) { @@ -195,10 +195,12 @@ namespace ps2recomp return genWrite(16, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate), fmt::format("(uint16_t)GPR_U32(ctx, {})", inst.rt)) + ";"; case OPCODE_SW: return genWrite(32, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate), fmt::format("GPR_U32(ctx, {})", inst.rt)) + ";"; + // The R5900 ignores the low four address bits of quadword accesses + // (LQ/SQ/LQC2/SQC2); memcpy-style code relies on this with QFSRV. case OPCODE_LQ: - return fmt::format("SET_GPR_VEC(ctx, {}, {});", inst.rt, genRead(128, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate))); + return fmt::format("SET_GPR_VEC(ctx, {}, {});", inst.rt, genRead(128, fmt::format("(ADD32(GPR_U32(ctx, {}), {}) & ~0xFu)", inst.rs, inst.simmediate))); case OPCODE_SQ: - return genWrite(128, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate), fmt::format("GPR_VEC(ctx, {})", inst.rt)) + ";"; + return genWrite(128, fmt::format("(ADD32(GPR_U32(ctx, {}), {}) & ~0xFu)", inst.rs, inst.simmediate), fmt::format("GPR_VEC(ctx, {})", inst.rt)) + ";"; case OPCODE_LD: return fmt::format("SET_GPR_U64(ctx, {}, {});", inst.rt, genRead(64, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate))); case OPCODE_SD: @@ -211,9 +213,9 @@ namespace ps2recomp inst.rt, genWrite(32, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate), "bits")); case OPCODE_LDC2: - return fmt::format("ctx->vu0_vf[{}] = _mm_castsi128_ps({});", inst.rt, genRead(128, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate))); + return fmt::format("ctx->vu0_vf[{}] = _mm_castsi128_ps({});", inst.rt, genRead(128, fmt::format("(ADD32(GPR_U32(ctx, {}), {}) & ~0xFu)", inst.rs, inst.simmediate))); case OPCODE_SDC2: - return genWrite(128, fmt::format("ADD32(GPR_U32(ctx, {}), {})", inst.rs, inst.simmediate), fmt::format("_mm_castps_si128(ctx->vu0_vf[{}])", inst.rt)) + ";"; + return genWrite(128, fmt::format("(ADD32(GPR_U32(ctx, {}), {}) & ~0xFu)", inst.rs, inst.simmediate), fmt::format("_mm_castps_si128(ctx->vu0_vf[{}])", inst.rt)) + ";"; case OPCODE_DADDI: return fmt::format( "{{ int64_t src = (int64_t)GPR_S64(ctx, {}); " diff --git a/ps2xTest/src/code_generator_tests.cpp b/ps2xTest/src/code_generator_tests.cpp index 463b6cff1a..4db0bc27fe 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -205,6 +205,26 @@ void register_code_generator_tests() "LWU must not sign-extend bit 31 into the allocator bitmap value"); }); + tc.Run("quadword loads and stores ignore the low four address bits", [](TestCase &t) { + CodeGenerator gen({}, {}); + const std::string lq = gen.translateInstruction(makeIType(0x8F20, OPCODE_LQ, 5, 8, 0x8)); + const std::string sq = gen.translateInstruction(makeIType(0x8F24, OPCODE_SQ, 4, 8, 0x4)); + const std::string lqc2 = gen.translateInstruction(makeIType(0x8F28, OPCODE_LQC2, 5, 1, 0x8)); + const std::string sqc2 = gen.translateInstruction(makeIType(0x8F2C, OPCODE_SQC2, 4, 1, 0x4)); + const std::string lw = gen.translateInstruction(makeLw(0x8F30, 2, 5, 0x8)); + + t.IsTrue(lq.find("(ADD32(GPR_U32(ctx, 5), 8) & ~0xFu)") != std::string::npos, + "LQ must align its effective address down to 16 bytes like the R5900"); + t.IsTrue(sq.find("(ADD32(GPR_U32(ctx, 4), 4) & ~0xFu)") != std::string::npos, + "SQ must align its effective address down to 16 bytes like the R5900"); + t.IsTrue(lqc2.find("(ADD32(GPR_U32(ctx, 5), 8) & ~0xFu)") != std::string::npos, + "LQC2 must align its effective address down to 16 bytes like the R5900"); + t.IsTrue(sqc2.find("(ADD32(GPR_U32(ctx, 4), 4) & ~0xFu)") != std::string::npos, + "SQC2 must align its effective address down to 16 bytes like the R5900"); + t.IsTrue(lw.find("& ~0xFu") == std::string::npos, + "narrower accesses must keep their exact effective address"); + }); + tc.Run("SYSCALL publishes its continuation before entering the runtime", [](TestCase &t) { Function func; func.name = "syscall_resume"; From cd5feaf23fc5ee82ef4269670bcdd64510636de9 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:35:00 -0700 Subject: [PATCH 02/17] fix(runtime): match cdrom0 paths case-insensitively ISO9660 names are case-insensitive and games spell them freely; FIFA Street 2, for example, opens lowercase names that the disc stores in uppercase. The sceCd* layer already falls back to a case-insensitive lookup, but fio/VFS opens resolved cdrom0: paths verbatim, so they failed on case-sensitive host filesystems such as Linux. When the exact host path does not exist, resolve cdrom0: paths one component at a time, ignoring ASCII case. Other devices (host0:, mc0:) keep their exact-match behaviour. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/src/lib/ps2_vfs.cpp | 46 +++++++++++++++++++++++++++ ps2xTest/src/ps2_runtime_io_tests.cpp | 36 +++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/ps2xRuntime/src/lib/ps2_vfs.cpp b/ps2xRuntime/src/lib/ps2_vfs.cpp index aff25e6607..0385e895b1 100644 --- a/ps2xRuntime/src/lib/ps2_vfs.cpp +++ b/ps2xRuntime/src/lib/ps2_vfs.cpp @@ -4,6 +4,7 @@ #include "runtime/ps2_rom_device.h" #include +#include #include #include #include @@ -150,6 +151,49 @@ namespace const auto systemValue = std::chrono::time_point_cast(value - std::filesystem::file_time_type::clock::now() + std::chrono::system_clock::now()); return std::chrono::system_clock::to_time_t(systemValue); } + + // ISO9660 names are case-insensitive but games spell them freely; on hosts + // with case-sensitive filesystems match each component ignoring ASCII case. + void resolveCaseInsensitive(const std::filesystem::path &base, + const std::filesystem::path &relative, + std::filesystem::path &result) + { + std::error_code error; + if (std::filesystem::exists(result, error)) + return; + + auto lower = [](std::string text) + { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) + { return static_cast(std::tolower(ch)); }); + return text; + }; + + std::filesystem::path current = base; + for (const auto &component : relative) + { + const std::filesystem::path direct = current / component; + if (std::filesystem::exists(direct, error)) + { + current = direct; + continue; + } + const std::string wanted = lower(component.string()); + bool matched = false; + for (const auto &entry : std::filesystem::directory_iterator(current, error)) + { + if (lower(entry.path().filename().string()) == wanted) + { + current = entry.path(); + matched = true; + break; + } + } + if (!matched) + return; + } + result = current.lexically_normal(); + } } PS2Vfs::~PS2Vfs() = default; @@ -298,6 +342,8 @@ bool PS2Vfs::resolveHostPath(std::string_view path, const PS2VfsMounts &mounts, if (base.empty() || !safeRelativePath(parsed.path, relative)) return false; result = (base / relative).lexically_normal(); + if (parsed.device == ps2x::iop::Ps2PathDevice::Cdrom) + resolveCaseInsensitive(base, relative, result); return true; } diff --git a/ps2xTest/src/ps2_runtime_io_tests.cpp b/ps2xTest/src/ps2_runtime_io_tests.cpp index 4f979be0d6..75e444b28c 100644 --- a/ps2xTest/src/ps2_runtime_io_tests.cpp +++ b/ps2xTest/src/ps2_runtime_io_tests.cpp @@ -274,6 +274,42 @@ void register_ps2_runtime_io_tests() fioClose(owner.rdram.data(), &owner.ctx, &owner.runtime); }); + tc.Run("cdrom0 paths match disc files regardless of letter case", [](TestCase &t) + { + TestContext test; + std::filesystem::create_directories(test.paths.cdRoot / "DATA"); + { + std::ofstream file(test.paths.cdRoot / "DATA" / "IOP.BIG", std::ios::binary); + file << "BIGF"; + } + + constexpr uint32_t kPathAddr = GUEST_STRING_AREA_START; + constexpr uint32_t kBufferAddr = GUEST_BUFFER_AREA_START; + writeGuestString(test.rdram.data(), kPathAddr, "cdrom0:\\data\\iop.big;1"); + setRegU32(test.ctx, 4, kPathAddr); + setRegU32(test.ctx, 5, PS2_FIO_O_RDONLY); + fioOpen(test.rdram.data(), &test.ctx, &test.runtime); + const int32_t fd = getRegS32(&test.ctx, 2); + t.IsTrue(fd >= 0, "a lowercase cdrom0 path should open an uppercase ISO9660 file"); + + setRegU32(test.ctx, 4, static_cast(fd)); + setRegU32(test.ctx, 5, kBufferAddr); + setRegU32(test.ctx, 6, 4u); + fioRead(test.rdram.data(), &test.ctx, &test.runtime); + t.Equals(getRegS32(&test.ctx, 2), 4, "the case-insensitively matched file should be readable"); + t.IsTrue(std::memcmp(test.rdram.data() + kBufferAddr, "BIGF", 4u) == 0, + "the matched file contents should reach the guest"); + + setRegU32(test.ctx, 4, static_cast(fd)); + fioClose(test.rdram.data(), &test.ctx, &test.runtime); + + writeGuestString(test.rdram.data(), kPathAddr, "cdrom0:\\DATA\\MISSING.BIG;1"); + setRegU32(test.ctx, 4, kPathAddr); + setRegU32(test.ctx, 5, PS2_FIO_O_RDONLY); + fioOpen(test.rdram.data(), &test.ctx, &test.runtime); + t.IsTrue(getRegS32(&test.ctx, 2) < 0, "a file that does not exist in any case should still fail"); + }); + tc.Run("mc0 directory creation", [](TestCase &t) { TestContext test; From 3de2e99ef03f8baeebaf771c4bd0acdd5c77c058 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:35:00 -0700 Subject: [PATCH 03/17] feat(runtime): take the CD image path from PS2X_CD_IMAGE IoPaths::cdImage exists and readCdSectors falls back to it for LBNs that are not backed by a sceCdSearchFile entry, but nothing on desktop ever set it. Games that parse ISO9660 themselves (EA's ASYNCFILE, used by FIFA Street 2, reads sector 16 and then raw file extents) could therefore never read anything. Set cdImage from the PS2X_CD_IMAGE environment variable when the ELF's I/O paths are configured. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/src/lib/ps2_runtime.cpp | 8 +++++ ps2xTest/src/ps2_runtime_io_tests.cpp | 42 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 45cb9d267e..9766080b98 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -26,6 +26,7 @@ #include #include #include +#include namespace ps2_stubs { @@ -1065,6 +1066,13 @@ void PS2Runtime::configureIoPathsFromElf(const std::string &elfPath) paths.mcRoot = paths.elfDirectory / "mc0"; } + // Games with their own ISO9660 reader (e.g. EA titles) issue raw sector + // reads, which need the original disc image rather than extracted files. + if (const char *image = std::getenv("PS2X_CD_IMAGE"); image && *image) + { + paths.cdImage = std::filesystem::path(image); + } + setIoPaths(paths); } diff --git a/ps2xTest/src/ps2_runtime_io_tests.cpp b/ps2xTest/src/ps2_runtime_io_tests.cpp index 75e444b28c..4afbaea4b4 100644 --- a/ps2xTest/src/ps2_runtime_io_tests.cpp +++ b/ps2xTest/src/ps2_runtime_io_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include using namespace ps2_syscalls; @@ -104,6 +105,18 @@ namespace std::memset(&ctx, 0, sizeof(ctx)); } + void setEnvVar(const char *name, const char *value) + { +#ifdef _WIN32 + _putenv_s(name, value ? value : ""); +#else + if (value) + setenv(name, value, 1); + else + unsetenv(name); +#endif + } + int32_t syncMc(std::vector &rdram, int32_t *cmdOut = nullptr) { R5900Context syncCtx{}; @@ -310,6 +323,35 @@ void register_ps2_runtime_io_tests() t.IsTrue(getRegS32(&test.ctx, 2) < 0, "a file that does not exist in any case should still fail"); }); + tc.Run("PS2X_CD_IMAGE supplies the image for raw sector reads", [](TestCase &t) + { + TestContext test; + constexpr uint32_t kSectorSize = 2048u; + const std::filesystem::path image = test.paths.base / "disc.iso"; + { + std::string bytes(kSectorSize * 4u, '\0'); + std::memcpy(bytes.data() + kSectorSize * 2u, "SECTOR02", 8u); + std::ofstream file(image, std::ios::binary); + file.write(bytes.data(), static_cast(bytes.size())); + } + + setEnvVar("PS2X_CD_IMAGE", image.string().c_str()); + PS2Runtime::configureIoPathsFromElf((test.paths.cdRoot / "SLES_000.00").string()); + setEnvVar("PS2X_CD_IMAGE", nullptr); + t.Equals(PS2Runtime::getIoPaths().cdImage.string(), + std::filesystem::absolute(image).lexically_normal().string(), + "PS2X_CD_IMAGE should become the runtime CD image"); + + constexpr uint32_t kBufferAddr = GUEST_BUFFER_AREA_START; + setRegU32(test.ctx, 4, 2u); + setRegU32(test.ctx, 5, 1u); + setRegU32(test.ctx, 6, kBufferAddr); + ps2_stubs::sceCdRead(test.rdram.data(), &test.ctx, &test.runtime); + t.Equals(getRegS32(&test.ctx, 2), 1, "sceCdRead should accept an LBN inside the image"); + t.IsTrue(std::memcmp(test.rdram.data() + kBufferAddr, "SECTOR02", 8u) == 0, + "sceCdRead should copy the requested sector from the image"); + }); + tc.Run("mc0 directory creation", [](TestCase &t) { TestContext test; From 067146c9c12906218a62fa3b2f2ae9fc78a2ffe1 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:35:00 -0700 Subject: [PATCH 04/17] fix(kernel): use 50 Hz VBlanks when SetGsCrt selects PAL The VBlank period was fixed at 16.667 ms, so PAL games ran their VSync-paced logic 20% fast. SetGsCrt now picks the rate from the video mode argument (3 = PAL, 50 Hz; anything else keeps the previous 60 Hz), effective from the next scheduled VBlank. Also correct the mode comment: SetGsCrt uses 2 for NTSC and 3 for PAL. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/include/runtime/ee_scheduler.h | 5 ++++ ps2xRuntime/src/lib/Kernel/EeScheduler.cpp | 19 ++++++++++----- .../src/lib/Kernel/Syscalls/System.cpp | 3 ++- ps2xTest/src/ps2_runtime_interrupt_tests.cpp | 23 +++++++++++++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/ps2xRuntime/include/runtime/ee_scheduler.h b/ps2xRuntime/include/runtime/ee_scheduler.h index f247ff998d..85de972e2f 100644 --- a/ps2xRuntime/include/runtime/ee_scheduler.h +++ b/ps2xRuntime/include/runtime/ee_scheduler.h @@ -334,6 +334,9 @@ class EeScheduler void dispatchIrq(bool dmac, uint32_t cause); void setVSyncFlag(uint32_t flagAddress, uint32_t tickAddress); [[nodiscard]] uint64_t currentVSyncTick() const noexcept; + // Takes effect from the next scheduled VBlank (SetGsCrt picks the mode). + void setPalVBlankRate(bool pal) noexcept; + [[nodiscard]] std::chrono::microseconds vblankPeriod() const noexcept { return m_vblankPeriod; } uint32_t setGsVSyncCallback(uint32_t callback, uint32_t gp, uint32_t sp); [[noreturn]] void waitVSync(uint64_t afterTick, int fixedResult = -1, std::function completion = {}); @@ -442,6 +445,8 @@ class EeScheduler uint64_t m_eventSequence = 0; uint64_t m_invocationSequence = 0; uint64_t m_vsyncTick = 0; + std::chrono::microseconds m_vblankPeriod{16667}; + uint64_t m_vblankPeriodCycles = (16667ull * kEeClockHz + 999999ull) / 1000000ull; uint32_t m_vsyncFlagAddress = 0; uint32_t m_vsyncTickAddress = 0; uint32_t m_gsVSyncCallback = 0; diff --git a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp index c5e31c48d8..e44230b522 100644 --- a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp +++ b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp @@ -31,7 +31,8 @@ namespace constexpr uint32_t WEF_OR = 0x01u; constexpr uint32_t WEF_CLEAR = 0x10u; constexpr uint32_t WEF_CLEAR_ALL = 0x20u; - constexpr auto kVBlankPeriod = std::chrono::microseconds(16667); + constexpr uint32_t kNtscVBlankMicroseconds = 16667u; // 60 Hz + constexpr uint32_t kPalVBlankMicroseconds = 20000u; // 50 Hz constexpr auto kVBlankDuration = std::chrono::microseconds(500); constexpr uint64_t kAlarmTickMicroseconds = 64u; constexpr uint32_t kDebugPublishDispatchInterval = 4096u; @@ -50,7 +51,6 @@ namespace return std::chrono::seconds(wholeSeconds) + std::chrono::nanoseconds(remainingNanoseconds); } - constexpr uint64_t kVBlankPeriodCycles = microsecondsToEeCycles(16667u); constexpr uint64_t kVBlankDurationCycles = microsecondsToEeCycles(500u); constexpr uint64_t kAlarmTickCycles = microsecondsToEeCycles(kAlarmTickMicroseconds); @@ -146,8 +146,8 @@ void EeScheduler::reset(uint8_t *rdram, const R5900Context &mainContext) main.status = EeThreadStatus::Ready; m_threads.emplace(main.id, std::move(main)); m_readyQueues[0].push_back(kMainThreadId); - scheduleEvent(m_eeCycle + kVBlankPeriodCycles, - std::chrono::steady_clock::now() + kVBlankPeriod, + scheduleEvent(m_eeCycle + m_vblankPeriodCycles, + std::chrono::steady_clock::now() + m_vblankPeriod, EeEvent{EeEventType::VBlankStart, 0, 0}); publishSnapshot(); } @@ -1304,6 +1304,13 @@ void EeScheduler::setVSyncFlag(uint32_t flagAddress, uint32_t tickAddress) } } +void EeScheduler::setPalVBlankRate(bool pal) noexcept +{ + const uint32_t microseconds = pal ? kPalVBlankMicroseconds : kNtscVBlankMicroseconds; + m_vblankPeriod = std::chrono::microseconds(microseconds); + m_vblankPeriodCycles = microsecondsToEeCycles(microseconds); +} + uint64_t EeScheduler::currentVSyncTick() const noexcept { return m_vsyncTick; @@ -1850,8 +1857,8 @@ void EeScheduler::processDueDeadlines() scheduleEvent(scheduled.deadlineCycle + kVBlankDurationCycles, scheduled.hostDeadline + kVBlankDuration, EeEvent{EeEventType::VBlankEnd, 0, m_vsyncTick + 1u}); - scheduleEvent(scheduled.deadlineCycle + kVBlankPeriodCycles, - scheduled.hostDeadline + kVBlankPeriod, + scheduleEvent(scheduled.deadlineCycle + m_vblankPeriodCycles, + scheduled.hostDeadline + m_vblankPeriod, EeEvent{EeEventType::VBlankStart, 0, 0}); } processEvent(scheduled.event); diff --git a/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp b/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp index 961401614d..df31011b76 100644 --- a/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp +++ b/ps2xRuntime/src/lib/Kernel/Syscalls/System.cpp @@ -6,11 +6,12 @@ namespace ps2_syscalls void GsSetCrt(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { int interlaced = getRegU32(ctx, 4); // $a0 - 0=non-interlaced, 1=interlaced - int videoMode = getRegU32(ctx, 5); // $a1 - 0=NTSC, 1=PAL, 2=VESA, 3=HiVision + int videoMode = getRegU32(ctx, 5); // $a1 - 2=NTSC, 3=PAL, 0x1A+=VESA, 0x50+=HDTV int frameMode = getRegU32(ctx, 6); // $a2 - 0=field, 1=frame if (runtime) { + runtime->eeScheduler().setPalVBlankRate(videoMode == 3); auto &gs = runtime->memory().gs(); const uint64_t smode2 = (static_cast(interlaced) & 0x1ull) | diff --git a/ps2xTest/src/ps2_runtime_interrupt_tests.cpp b/ps2xTest/src/ps2_runtime_interrupt_tests.cpp index 4261bface1..eb7b1ffff7 100644 --- a/ps2xTest/src/ps2_runtime_interrupt_tests.cpp +++ b/ps2xTest/src/ps2_runtime_interrupt_tests.cpp @@ -379,6 +379,29 @@ void register_ps2_runtime_interrupt_tests() { MiniTest::Case("PS2RuntimeInterrupt", [](TestCase &tc) { + tc.Run("SetGsCrt selects the VBlank rate of the video mode", [](TestCase &t) + { + TestEnv env; + constexpr uint32_t kModeNtsc = 2u; + constexpr uint32_t kModePal = 3u; + EeScheduler &scheduler = env.runtime.eeScheduler(); + t.Equals(scheduler.vblankPeriod().count(), static_cast(16667), + "the default VBlank rate should stay NTSC"); + + R5900Context ctx{}; + setRegU32(ctx, 4, 1u); + setRegU32(ctx, 5, kModePal); + setRegU32(ctx, 6, 0u); + GsSetCrt(env.rdram.data(), &ctx, &env.runtime); + t.Equals(scheduler.vblankPeriod().count(), static_cast(20000), + "PAL mode should produce 50 Hz VBlanks"); + + setRegU32(ctx, 5, kModeNtsc); + GsSetCrt(env.rdram.data(), &ctx, &env.runtime); + t.Equals(scheduler.vblankPeriod().count(), static_cast(16667), + "NTSC mode should restore 60 Hz VBlanks"); + }); + tc.Run("negative interrupt-safe EE syscall ids dispatch", [](TestCase &t) { TestEnv env; From 1d7825441ba2e20f2d951de055fc567bbeeeb1a0 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:38:32 -0700 Subject: [PATCH 05/17] fix(iop): load sceSifLoadModuleBuffer images from IOP RAM sceSifLoadModuleBuffer takes an IOP address: the EE first copies the IRX into IOP memory (sceSifAllocIopHeap + sceSifSetDma) and then asks the IOP to load it from there. The emulator read the image from EE RAM at that address instead, found no ELF header, and failed the load silently. FIFA Street 2 loads SIO2MAN, MTAPMAN, PADMAN, LIBSD, SNDDRV, MCMAN and MCSERV this way from DATA/IOP.BIG, so none of them started and sceMtapInit spun forever trying to bind SID 0x80000900. Read the image from IOP RAM first and fall back to EE RAM, so callers that still hand over an EE address keep working. The ELF reader is shared between both sources. Co-Authored-By: Claude Opus 5.5 --- ps2xIOP/src/emulator/iop_emulator.cpp | 5 +- .../emulator/services/iop_module_loader.cpp | 102 ++++++++++-------- .../src/emulator/services/iop_module_loader.h | 3 + ps2xIOP/tests/iop_emulator_tests.cpp | 17 +++ 4 files changed, 84 insertions(+), 43 deletions(-) diff --git a/ps2xIOP/src/emulator/iop_emulator.cpp b/ps2xIOP/src/emulator/iop_emulator.cpp index e5f63a9fe2..ffb8693067 100644 --- a/ps2xIOP/src/emulator/iop_emulator.cpp +++ b/ps2xIOP/src/emulator/iop_emulator.cpp @@ -654,8 +654,11 @@ namespace ps2x::iop::detail ModuleLoadResult loadModuleBuffer(uint32_t guestAddress, const void *arguments, uint32_t argumentSize) { + // Real sceSifLoadModuleBuffer addresses IOP RAM; keep accepting EE + // addresses for callers that hand over an image still in EE RAM. std::vector image; - if (!IopModuleLoader::readElfFromGuest(host, guestAddress, image)) + if (!IopModuleLoader::readElfFromIop(memory, guestAddress, image) && + !IopModuleLoader::readElfFromGuest(host, guestAddress, image)) return {true, -1, -1}; std::ostringstream tag; tag << "buffer@0x" << std::hex << guestAddress; diff --git a/ps2xIOP/src/emulator/services/iop_module_loader.cpp b/ps2xIOP/src/emulator/services/iop_module_loader.cpp index 80e59d6bc3..306bf0702a 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.cpp +++ b/ps2xIOP/src/emulator/services/iop_module_loader.cpp @@ -298,6 +298,57 @@ namespace ps2x::iop::detail } } + namespace + { + template + bool readElfImage(ReadFn &&read, uint32_t guestAddress, std::vector &bytes) + { + Elf32Ehdr header{}; + if (!read(guestAddress, &header, sizeof(header)) || !validElfHeader(header)) + return false; + + uint64_t required = sizeof(header); + required = std::max(required, static_cast(header.phoff) + static_cast(header.phentsize) * header.phnum); + required = std::max(required, static_cast(header.shoff) + static_cast(header.shentsize) * header.shnum); + + if (required > kMaxImageSize) + return false; // Should we log an error here? TODO check later + + bytes.resize(static_cast(required)); + if (!read(guestAddress, bytes.data(), bytes.size())) + return false; + + if (header.shnum != 0u && header.shentsize >= sizeof(Elf32Shdr)) + { + for (uint16_t i = 0; i < header.shnum; ++i) + { + Elf32Shdr section{}; + const size_t offset = static_cast(header.shoff) + static_cast(i) * header.shentsize; + std::memcpy(§ion, bytes.data() + offset, sizeof(section)); + if (section.type != SHT_NOBITS) + { + required = std::max(required, static_cast(section.offset) + section.size); + } + } + } + if (header.phnum != 0u && header.phentsize >= sizeof(Elf32Phdr)) + { + for (uint16_t i = 0; i < header.phnum; ++i) + { + Elf32Phdr program{}; + const size_t offset = static_cast(header.phoff) + static_cast(i) * header.phentsize; + std::memcpy(&program, bytes.data() + offset, sizeof(program)); + required = std::max(required, static_cast(program.offset) + program.filesz); + } + } + if (required > kMaxImageSize) + return false; + + bytes.resize(static_cast(required)); + return read(guestAddress, bytes.data(), bytes.size()); + } + } + bool IopModuleLoader::readWholeHostFile(IopHost &host, std::string_view guestPath, std::vector &bytes) { const std::string translated = host.translateGuestPath(guestPath); @@ -322,49 +373,16 @@ namespace ps2x::iop::detail bool IopModuleLoader::readElfFromGuest(IopHost &host, uint32_t guestAddress, std::vector &bytes) { - Elf32Ehdr header{}; - if (!host.readGuest(guestAddress, &header, sizeof(header)) || !validElfHeader(header)) - return false; - - uint64_t required = sizeof(header); - required = std::max(required, static_cast(header.phoff) + static_cast(header.phentsize) * header.phnum); - required = std::max(required, static_cast(header.shoff) + static_cast(header.shentsize) * header.shnum); - - if (required > kMaxImageSize) - return false; // Should we log an error here? TODO check later - - bytes.resize(static_cast(required)); - if (!host.readGuest(guestAddress, bytes.data(), bytes.size())) - return false; - - if (header.shnum != 0u && header.shentsize >= sizeof(Elf32Shdr)) - { - for (uint16_t i = 0; i < header.shnum; ++i) - { - Elf32Shdr section{}; - const size_t offset = static_cast(header.shoff) + static_cast(i) * header.shentsize; - std::memcpy(§ion, bytes.data() + offset, sizeof(section)); - if (section.type != SHT_NOBITS) - { - required = std::max(required, static_cast(section.offset) + section.size); - } - } - } - if (header.phnum != 0u && header.phentsize >= sizeof(Elf32Phdr)) - { - for (uint16_t i = 0; i < header.phnum; ++i) - { - Elf32Phdr program{}; - const size_t offset = static_cast(header.phoff) + static_cast(i) * header.phentsize; - std::memcpy(&program, bytes.data() + offset, sizeof(program)); - required = std::max(required, static_cast(program.offset) + program.filesz); - } - } - if (required > kMaxImageSize) - return false; + return readElfImage([&host](uint32_t address, void *destination, size_t size) + { return host.readGuest(address, destination, size); }, + guestAddress, bytes); + } - bytes.resize(static_cast(required)); - return host.readGuest(guestAddress, bytes.data(), bytes.size()); + bool IopModuleLoader::readElfFromIop(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..753e02dd67 100644 --- a/ps2xIOP/src/emulator/services/iop_module_loader.h +++ b/ps2xIOP/src/emulator/services/iop_module_loader.h @@ -43,6 +43,9 @@ 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); + // sceSifLoadModuleBuffer takes an IOP address: the EE first copies the + // image into IOP RAM with sceSifSetDma. + [[nodiscard]] static bool readElfFromIop(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..b8b6fc3761 100644 --- a/ps2xIOP/tests/iop_emulator_tests.cpp +++ b/ps2xIOP/tests/iop_emulator_tests.cpp @@ -911,6 +911,23 @@ 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; + // sceSifLoadModuleBuffer takes an IOP address: the EE DMAs the image into + // IOP RAM first, so the EE copy may be gone by the time the load runs. + { + constexpr uint32_t imageBytes = 0x118u; // header, program header, code + writeMinimalIrx(host, 0x100u); + const std::vector image(host.guest.begin() + 0x100, host.guest.begin() + 0x100 + imageBytes); + std::fill(host.guest.begin() + 0x100, host.guest.begin() + 0x100 + imageBytes, uint8_t{0}); + const uint32_t iopImage = iop.allocateMemory(imageBytes, 64u); + if (!expect(iopImage != 0u && iop.writeMemory(iopImage, image.data(), image.size()), + "Could not stage the IRX image in IOP RAM")) return 1; + const ModuleLoadResult fromIop = iop.loadModuleBuffer(iopImage); + if (!expect(fromIop.handled && fromIop.moduleId > 0 && fromIop.startResult == 7, + "sceSifLoadModuleBuffer must load an image that lives in IOP RAM")) return 1; + if (!expect(iop.stopModule(fromIop.moduleId, &stopResult), "IOP-RAM module stop failed")) return 1; + if (!expect(iop.freeMemory(iopImage), "IOP image buffer free failed")) return 1; + } + constexpr uint32_t rpcSid = 0xF00DCAFEu; writeRpcServerIrx(host, 0x100u); const ModuleLoadResult rpcModule = iop.loadModuleBuffer(0x100u); From aa20b03794663367288b80396cd8d3bd2d825cfc Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:11:13 -0700 Subject: [PATCH 06/17] fix(recomp): compare sign-testing branches on the full 64-bit register BLEZ, BGTZ, BLTZ and BGEZ (and their likely and linking forms) test bit 63 of the 64-bit GPR. The generated conditions only looked at the low 32 bits, so a value such as 0x0000000080000000 (from DSUBU, LD or a 64-bit shift) branched as if it were negative. In FIFA Street 2 this broke a comparison in EA's file system and its movies failed to open. Co-Authored-By: Claude Opus 5.5 --- ps2xRecomp/src/lib/control_flow_emitter.cpp | 8 ++--- ps2xTest/src/code_generator_tests.cpp | 39 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/ps2xRecomp/src/lib/control_flow_emitter.cpp b/ps2xRecomp/src/lib/control_flow_emitter.cpp index 4bf8dc550c..95f69d7a3f 100644 --- a/ps2xRecomp/src/lib/control_flow_emitter.cpp +++ b/ps2xRecomp/src/lib/control_flow_emitter.cpp @@ -406,10 +406,10 @@ namespace ps2recomp return fmt::format("GPR_U64(ctx, {}) != GPR_U64(ctx, {})", rsReg, rtReg); 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 +417,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 4db0bc27fe..b6d7ce1235 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -498,6 +498,45 @@ void register_code_generator_tests() t.IsTrue(generated.find("label_1008:") == std::string::npos, "delay slot without incoming branch should not get a label"); }); + tc.Run("sign-testing branches compare the full 64-bit register", [](TestCase &t) { + // EE GPRs are 64 bits wide and BLEZ/BGTZ/BLTZ/BGEZ test bit 63, so a + // value like 0x0000000080000000 is positive even though its low word + // is negative. + Function func; + func.name = "test_func"; + func.start = 0x1000; + func.end = 0x1040; + func.isRecompiled = true; + func.isStub = false; + + const auto signBranch = [](uint32_t address, uint32_t opcode, uint32_t rt) + { + Instruction inst = makeBranch(address, 1); + inst.opcode = opcode; + inst.rs = 4; + inst.rt = rt; + return inst; + }; + std::vector instructions; + instructions.push_back(signBranch(0x1000, OPCODE_BLEZ, 0)); + instructions.push_back(makeNop(0x1004)); + instructions.push_back(signBranch(0x1008, OPCODE_BGTZ, 0)); + instructions.push_back(makeNop(0x100c)); + instructions.push_back(signBranch(0x1010, OPCODE_REGIMM, REGIMM_BLTZ)); + instructions.push_back(makeNop(0x1014)); + instructions.push_back(signBranch(0x1018, OPCODE_REGIMM, REGIMM_BGEZ)); + instructions.push_back(makeNop(0x101c)); + instructions.push_back(makeNop(0x1020)); + + CodeGenerator gen({}, {}); + const std::string generated = gen.generateFunction(func, instructions, false); + printGeneratedCode("sign-testing branches compare the full 64-bit register", generated); + + for (const char *condition : {"GPR_S64(ctx, 4) <= 0", "GPR_S64(ctx, 4) > 0", "GPR_S64(ctx, 4) < 0", "GPR_S64(ctx, 4) >= 0"}) + t.IsTrue(generated.find(condition) != std::string::npos, std::string("missing branch condition ") + condition); + t.IsTrue(generated.find("GPR_S32(ctx, 4)") == std::string::npos, "branch conditions should not truncate to 32 bits"); + }); + tc.Run("labels delay slot when it is a branch target", [](TestCase &t) { Function func; func.name = "delay_slot_label"; From af0bc665808143e32616d2d718343e993a5490c0 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:12:01 -0700 Subject: [PATCH 07/17] fix(recomp): read the SQRT.S operand from ft SQRT.S fd, ft takes its source in the ft field and encodes fs as 0. The translator used fs, so every SQRT.S returned sqrt(f0). Co-Authored-By: Claude Opus 5.5 --- ps2xRecomp/src/lib/fpu_translator.cpp | 3 ++- ps2xTest/src/ps2_float_semantics_tests.cpp | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ps2xRecomp/src/lib/fpu_translator.cpp b/ps2xRecomp/src/lib/fpu_translator.cpp index 435db3aa93..e5a2e7acb1 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); + // SQRT.S fd, ft: the source is in the ft field (fs is 0). + 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/ps2_float_semantics_tests.cpp b/ps2xTest/src/ps2_float_semantics_tests.cpp index 2866d2bff3..cbe5b10a70 100644 --- a/ps2xTest/src/ps2_float_semantics_tests.cpp +++ b/ps2xTest/src/ps2_float_semantics_tests.cpp @@ -210,6 +210,15 @@ void register_ps2_float_semantics_tests() t.Equals(laneBits(flushed, 3), bitsOf(0.5f), "lane 3: 1 * 0.5 should be 0.5"); }); + tc.Run("SQRT.S reads its operand from ft", [](TestCase &t) + { + CodeGenerator gen({}, {}); + // SQRT.S fd, ft is encoded with fs = 0. + const std::string sqrt = gen.translateInstruction(makeCop1S(COP1_S_SQRT, 2, 0, 4)); + t.IsTrue(sqrt.find("ctx->f[2] = FPU_SQRT_S(ctx->f[4])") != std::string::npos, + "SQRT.S should write sqrt(ft) to fd but got: " + sqrt); + }); + tc.Run("DIV.S and RSQRT.S translate to the PS2 semantics helpers", [](TestCase &t) { CodeGenerator gen({}, {}); From ed1565b04b08e15ddd282240fada3402108a02f2 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:13:04 -0700 Subject: [PATCH 08/17] fix(runtime): truncate CVT.W.S toward zero and saturate The EE's CVT.W.S always rounds toward zero, whatever the FCR31 rounding mode, and saturates to INT32_MAX/INT32_MIN from 2^31 up. FPU_CVT_W_S used nearbyintf (round to nearest). Games that compute log2 with (int)(log(w) / log(2)) got 7 instead of 6 for a width of 112, and FIFA Street 2 sized its text textures twice too wide, cutting its menu text in half. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/include/ps2_runtime_macros.h | 12 +++++++++++- ps2xTest/src/ps2_float_semantics_tests.cpp | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/include/ps2_runtime_macros.h b/ps2xRuntime/include/ps2_runtime_macros.h index 0485d50060..0016942856 100644 --- a/ps2xRuntime/include/ps2_runtime_macros.h +++ b/ps2xRuntime/include/ps2_runtime_macros.h @@ -853,7 +853,17 @@ inline __m128i ps2_u64_to_epi64_pair(uint64_t value) #define FPU_FLOOR_W_S(a) ((int32_t)floorf((float)(a))) #define FPU_CVT_S_W(a) ((float)(int32_t)(a)) #define FPU_CVT_S_L(a) ((float)(int64_t)(a)) -#define FPU_CVT_W_S(a) ((int32_t)nearbyintf((float)(a))) +// CVT.W.S on the EE always truncates toward zero; magnitudes of 2^31 and +// above saturate to INT32_MAX / INT32_MIN by sign. +static inline int32_t ps2_cvt_w_s(float value) +{ + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + if ((bits & 0x7F800000u) <= 0x4E800000u) + return static_cast(value); + return (bits & 0x80000000u) ? INT32_MIN : INT32_MAX; +} +#define FPU_CVT_W_S(a) ps2_cvt_w_s((float)(a)) #define FPU_CVT_L_S(a) ((int64_t)(float)(a)) #define FPU_C_F_S(a, b) (0) #define FPU_C_UN_S(a, b) (isnan((float)(a)) || isnan((float)(b))) diff --git a/ps2xTest/src/ps2_float_semantics_tests.cpp b/ps2xTest/src/ps2_float_semantics_tests.cpp index cbe5b10a70..87a60ef3bd 100644 --- a/ps2xTest/src/ps2_float_semantics_tests.cpp +++ b/ps2xTest/src/ps2_float_semantics_tests.cpp @@ -210,6 +210,17 @@ void register_ps2_float_semantics_tests() t.Equals(laneBits(flushed, 3), bitsOf(0.5f), "lane 3: 1 * 0.5 should be 0.5"); }); + tc.Run("CVT.W.S truncates toward zero and saturates", [](TestCase &t) + { + t.Equals(FPU_CVT_W_S(6.807f), 6, "6.807 should truncate to 6 (games compute log2 this way)"); + t.Equals(FPU_CVT_W_S(8.58f), 8, "8.58 should truncate to 8"); + t.Equals(FPU_CVT_W_S(-2.5f), -2, "-2.5 should truncate toward zero to -2"); + t.Equals(FPU_CVT_W_S(0.999f), 0, "0.999 should truncate to 0"); + t.Equals(FPU_CVT_W_S(floatOf(0x4F000000u)), INT32_MAX, "2^31 should saturate to INT32_MAX"); + t.Equals(FPU_CVT_W_S(floatOf(0xCF000000u)), INT32_MIN, "-2^31 should saturate to INT32_MIN"); + t.Equals(FPU_CVT_W_S(floatOf(kTwoPow128)), INT32_MAX, "2^128 should saturate to INT32_MAX"); + }); + tc.Run("SQRT.S reads its operand from ft", [](TestCase &t) { CodeGenerator gen({}, {}); From 45cdcaf0bcde30ef6df2a1d261b0f39b4b7d1912 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:15:15 -0700 Subject: [PATCH 09/17] fix(runtime): advance COP0 Count with the EE cycle counter Count increments every EE cycle, but MFC0 read a field nothing updated, so code that waits or measures with Count saw no time pass. Count is now derived from the scheduler's EE cycle counter; MTC0 rebases it. Co-Authored-By: Claude Opus 5.5 --- ps2xRecomp/src/lib/cop0_translator.cpp | 4 ++-- ps2xRuntime/include/ps2_runtime.h | 6 ++++++ ps2xRuntime/include/runtime/ee_scheduler.h | 2 ++ ps2xRuntime/src/lib/ps2_runtime.cpp | 14 ++++++++++++++ ps2xTest/src/code_generator_tests.cpp | 10 ++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ps2xRecomp/src/lib/cop0_translator.cpp b/ps2xRecomp/src/lib/cop0_translator.cpp index 1823a39462..9f375bbb75 100644 --- a/ps2xRecomp/src/lib/cop0_translator.cpp +++ b/ps2xRecomp/src/lib/cop0_translator.cpp @@ -44,7 +44,7 @@ namespace ps2recomp case COP0_REG_BADVADDR: return fmt::format("SET_GPR_S32(ctx, {}, (int32_t)ctx->cop0_badvaddr);", rt); case COP0_REG_COUNT: - return fmt::format("SET_GPR_S32(ctx, {}, (int32_t)ctx->cop0_count);", rt); + return fmt::format("SET_GPR_S32(ctx, {}, (int32_t)runtime->readCop0Count(ctx));", rt); case COP0_REG_ENTRYHI: return fmt::format("SET_GPR_S32(ctx, {}, (int32_t)ctx->cop0_entryhi);", rt); case COP0_REG_COMPARE: @@ -94,7 +94,7 @@ namespace ps2recomp case COP0_REG_BADVADDR: return "// MTC0 to BADVADDR register ignored (read-only)"; case COP0_REG_COUNT: - return fmt::format("ctx->cop0_count = GPR_U32(ctx, {});", rt); + return fmt::format("runtime->writeCop0Count(ctx, GPR_U32(ctx, {}));", rt); case COP0_REG_ENTRYHI: return fmt::format("ctx->cop0_entryhi = GPR_U32(ctx, {}) & 0xC00000FF;", rt); case COP0_REG_COMPARE: diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index 4b9242cb4c..1e7c382983 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -392,6 +392,11 @@ class PS2Runtime EeScheduler &eeScheduler(); const EeScheduler &eeScheduler() const; + // COP0 Count increments every EE cycle. It is derived from the + // scheduler's cycle counter so guest code that times itself with Count + // sees time pass; a write rebases it. + uint32_t readCop0Count(R5900Context *ctx) noexcept; + void writeCop0Count(R5900Context *ctx, uint32_t value) noexcept; void postEeEvent(EeEvent event); bool eeCheckpointDue(uint32_t cycles = 32u) noexcept; [[noreturn]] void eeWaitVSyncTicks(uint32_t ticks, uint32_t resumePc); @@ -543,6 +548,7 @@ class PS2Runtime std::vector m_loadedModules; uint8_t *m_boundRdram = nullptr; uint8_t *m_boundGSVram = nullptr; + uint32_t m_cop0CountBias = 0u; }; // Generated by ps2xRecomp in ps2xRuntime/src/runner/register_functions.cpp. diff --git a/ps2xRuntime/include/runtime/ee_scheduler.h b/ps2xRuntime/include/runtime/ee_scheduler.h index 85de972e2f..d37df54340 100644 --- a/ps2xRuntime/include/runtime/ee_scheduler.h +++ b/ps2xRuntime/include/runtime/ee_scheduler.h @@ -337,6 +337,8 @@ class EeScheduler // Takes effect from the next scheduled VBlank (SetGsCrt picks the mode). void setPalVBlankRate(bool pal) noexcept; [[nodiscard]] std::chrono::microseconds vblankPeriod() const noexcept { return m_vblankPeriod; } + // EE cycles of guest time elapsed so far (advances at every checkpoint). + [[nodiscard]] uint64_t eeCycle() const noexcept { return m_eeCycle; } uint32_t setGsVSyncCallback(uint32_t callback, uint32_t gp, uint32_t sp); [[noreturn]] void waitVSync(uint64_t afterTick, int fixedResult = -1, std::function completion = {}); diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 9766080b98..789fe9a576 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -606,6 +606,20 @@ void PS2Runtime::notifyIopSifTransfer(uint8_t *rdram, const ps2x::iop::SifTransf m_iopSubsystem->onSifTransfer(transfer); } +uint32_t PS2Runtime::readCop0Count(R5900Context *ctx) noexcept +{ + if (m_eeScheduler) + ctx->cop0_count = static_cast(m_eeScheduler->eeCycle()) + m_cop0CountBias; + return ctx->cop0_count; +} + +void PS2Runtime::writeCop0Count(R5900Context *ctx, uint32_t value) noexcept +{ + ctx->cop0_count = value; + if (m_eeScheduler) + m_cop0CountBias = value - static_cast(m_eeScheduler->eeCycle()); +} + void PS2Runtime::advanceIopEeCycles(uint64_t eeCycles) noexcept { m_iopSubsystem->runEeCycles(eeCycles); diff --git a/ps2xTest/src/code_generator_tests.cpp b/ps2xTest/src/code_generator_tests.cpp index b6d7ce1235..89b0188ca7 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -1049,6 +1049,16 @@ void register_code_generator_tests() t.IsTrue(mtc0Code.find("GPR_U32(ctx, 7)") != std::string::npos, "MTC0 should read from rt"); t.IsTrue(mtc0Code.find("Unimplemented MTC0") == std::string::npos, "MTC0 should not hit unimplemented path"); t.IsTrue(mtc0Code.find("Unhandled COP0") == std::string::npos, "MTC0 should not hit unhandled COP0 path"); + + // Count runs with the EE clock, so it goes through the runtime. + mfc0.rd = COP0_REG_COUNT; + const std::string countRead = gen.translateInstruction(mfc0); + t.IsTrue(countRead.find("SET_GPR_S32(ctx, 5, (int32_t)runtime->readCop0Count(ctx));") != std::string::npos, + "MFC0 Count should read the runtime's cycle-based Count but got: " + countRead); + mtc0.rd = COP0_REG_COUNT; + const std::string countWrite = gen.translateInstruction(mtc0); + t.IsTrue(countWrite.find("runtime->writeCop0Count(ctx, GPR_U32(ctx, 7));") != std::string::npos, + "MTC0 Count should rebase the runtime's Count but got: " + countWrite); }); tc.Run("FCR access uses CFC1/CTC1", [](TestCase &t) { From 9eadc205b8f9c111d026a57e894d55c258ce3440 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:20:08 -0700 Subject: [PATCH 10/17] fix(recomp): keep resume entries from taking other functions' addresses Resume points are registered after the normal functions but before stubs and library functions. When a resume point fell on the first instruction of a stub (a final branch whose delay slot is the next function's first instruction), the table sent calls to that stub into the middle of the previous function. Resume points that are another function's start are now skipped. Co-Authored-By: Claude Opus 5.5 --- ps2xRecomp/src/lib/function_table_emitter.cpp | 14 ++++++++++ ps2xTest/src/code_generator_tests.cpp | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/ps2xRecomp/src/lib/function_table_emitter.cpp b/ps2xRecomp/src/lib/function_table_emitter.cpp index b82b259747..4c4dcb6f44 100644 --- a/ps2xRecomp/src/lib/function_table_emitter.cpp +++ b/ps2xRecomp/src/lib/function_table_emitter.cpp @@ -105,6 +105,16 @@ namespace ps2recomp addEntry(address, name); } + // A resume point inside one function must never replace another + // function's entry (e.g. a final branch whose delay slot is the first + // instruction of the next function). Stubs and library functions are + // registered below, after the resume points. + std::unordered_set functionStarts; + for (const auto &function : functions) + { + functionStarts.insert(function.start); + } + for (const auto &[ownerStart, targets] : cg.m_resumeEntryTargetsByOwner) { const std::string ownerName = cg.getFunctionName(ownerStart); @@ -115,6 +125,10 @@ namespace ps2recomp for (uint32_t target : targets) { + if (target != ownerStart && functionStarts.contains(target)) + { + continue; + } addEntry(target, ownerName); } } diff --git a/ps2xTest/src/code_generator_tests.cpp b/ps2xTest/src/code_generator_tests.cpp index 89b0188ca7..9229a5d9ba 100644 --- a/ps2xTest/src/code_generator_tests.cpp +++ b/ps2xTest/src/code_generator_tests.cpp @@ -289,6 +289,34 @@ void register_code_generator_tests() "the syscall continuation must register to the owner wrapper"); }); + tc.Run("a resume entry never replaces another function's entry", [](TestCase &t) { + // A resume point can land on the first instruction of the next + // function (e.g. a final branch whose delay slot is that + // instruction). Stubs and library functions are registered after + // resume points, so without care the owner would take that address. + Function owner; + owner.name = "resume_owner"; + owner.start = 0x9100; + owner.end = 0x9108; + owner.isRecompiled = true; + Function next; + next.name = "next_function"; + next.start = 0x9108; + next.end = 0x9110; + next.isStub = true; + + CodeGenerator gen({}, {}); + gen.setRenamedFunctions({{0x9100u, "resume_owner_0x9100"}, {0x9108u, "next_function_0x9108"}}); + gen.setResumeEntryTargets({{0x9100u, std::vector{0x9104u, 0x9108u}}}); + const std::string registration = gen.generateFunctionRegistration({owner, next}, {}); + t.IsTrue(registration.find("g_ps2RecompiledFunctionTable[1] = resume_owner_0x9100; // 0x9104") != std::string::npos, + "a resume point inside the owner should still register to the owner"); + t.IsTrue(registration.find("g_ps2RecompiledFunctionTable[2] = next_function_0x9108; // 0x9108") != std::string::npos, + "the next function's entry should stay registered to that function"); + t.IsTrue(registration.find("resume_owner_0x9100; // 0x9108") == std::string::npos, + "the owner must not take over the next function's entry"); + }); + tc.Run("R5900 MULT writes rd when rd is non-zero", [](TestCase &t) { CodeGenerator gen({}, {}); From 704458ba33cbca36055aa1381c64c2b46f048b32 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:24:26 -0700 Subject: [PATCH 11/17] fix(runtime): keep unwinding nested calls after a scheduler checkpoint When a checkpoint or jump unwinds to the scheduler, every enclosing dispatchGuestBranch call has to return as well. A call whose callee came back with ctx->pc equal to the callee's entry was instead treated as "the callee returned without setting pc" and continued at its fallthrough. That happens with recursion when the inner call unwinds at a loop on the function's first instruction: the outer frame then ran on with the inner frame's registers (FIFA Street 2 crashed with a jalr to 0). An unwind flag, set on every unwinding path and cleared by the scheduler before each dispatch, now keeps all frames returning. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/include/ps2_runtime.h | 4 +++ ps2xRuntime/src/lib/Kernel/EeScheduler.cpp | 1 + ps2xRuntime/src/lib/ps2_runtime.cpp | 36 +++++++++++++++----- ps2xTest/src/ps2_runtime_expansion_tests.cpp | 36 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index 1e7c382983..acd2d535e2 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -338,6 +338,9 @@ class PS2Runtime bool registerFunction(uint32_t address, RecompiledFunction func); RecompiledFunction lookupFunction(uint32_t address); bool hasFunction(uint32_t address) const; + // The scheduler calls this before dispatching guest code (see + // dispatchGuestBranch). + void clearGuestUnwind() noexcept { m_guestUnwinding = false; } bool dispatchGuestBranch(uint8_t *rdram, R5900Context *ctx, uint32_t targetPc, @@ -549,6 +552,7 @@ class PS2Runtime uint8_t *m_boundRdram = nullptr; uint8_t *m_boundGSVram = nullptr; uint32_t m_cop0CountBias = 0u; + bool m_guestUnwinding = false; }; // Generated by ps2xRecomp in ps2xRuntime/src/runner/register_functions.cpp. diff --git a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp index e44230b522..0b740e5f0b 100644 --- a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp +++ b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp @@ -285,6 +285,7 @@ void EeScheduler::run() } continue; } + m_runtime.clearGuestUnwind(); PS2Runtime::RecompiledFunction function = m_runtime.lookupFunction(context.pc); if (checkpointDue(kGuestDispatchCycles)) diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 789fe9a576..95bebc87f2 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -1375,12 +1375,23 @@ bool PS2Runtime::dispatchGuestBranch(uint8_t *rdram, ctx->pc = targetPc; const bool isCall = (kind == GuestBranchKind::DirectCall || kind == GuestBranchKind::IndirectCall); + // Returning false makes the generated caller return at once, and so on up + // to the scheduler, which then dispatches ctx->pc. Remember that an unwind + // is in progress: an enclosing call must keep unwinding even if ctx->pc + // happens to equal its own callee's entry (recursion), instead of taking + // that for "callee returned without setting pc". + auto unwindToScheduler = [this]() + { + m_guestUnwinding = true; + return false; + }; + // Every inter-function transfer is also a deterministic EE safe point. // Backward edges inside generated functions use eeCheckpointDue(), while // this charge bounds straight-line call chains that have no local loop. if (m_eeScheduler && m_eeScheduler->checkpointDue(EeScheduler::kGuestDispatchCycles)) { - return false; + return unwindToScheduler(); } if (!isCall) @@ -1391,7 +1402,7 @@ bool PS2Runtime::dispatchGuestBranch(uint8_t *rdram, } ctx->pc = targetPc; - return false; + return unwindToScheduler(); } if (!hasFunction(targetPc)) @@ -1410,27 +1421,32 @@ bool PS2Runtime::dispatchGuestBranch(uint8_t *rdram, { ctx->pc = targetPc; // if you need the app to keep open to open debug pannel change this to false - return false; + return unwindToScheduler(); } - return false; + return unwindToScheduler(); } RecompiledFunction targetFn = lookupFunction(targetPc); const uint32_t entryPc = ctx->pc; targetFn(rdram, ctx, this); - if (isStopRequested() || ctx->pc == 0u) + if (m_guestUnwinding || isStopRequested() || ctx->pc == 0u) { - return false; + return unwindToScheduler(); } + // HLE handlers may return without setting ctx->pc. if (ctx->pc == entryPc) { ctx->pc = fallthroughPc; } - return ctx->pc == fallthroughPc; + if (ctx->pc != fallthroughPc) + { + return unwindToScheduler(); + } + return true; } void PS2Runtime::SignalException(R5900Context *ctx, PS2Exception exception) @@ -2233,7 +2249,11 @@ void PS2Runtime::postEeEvent(EeEvent event) bool PS2Runtime::eeCheckpointDue(uint32_t cycles) noexcept { - return m_eeScheduler->checkpointDue(cycles); + // A due checkpoint makes the generated function return to the scheduler. + if (!m_eeScheduler->checkpointDue(cycles)) + return false; + m_guestUnwinding = true; + return true; } [[noreturn]] void PS2Runtime::eeWaitVSyncTicks(uint32_t ticks, uint32_t resumePc) diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index 4772f3fb77..12ee21a593 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -157,6 +157,26 @@ namespace } } + // A function at 0x3000 that calls itself; the inner call loops back to + // the function's first instruction, which unwinds to the scheduler. + int gRecursionDepth = 0; + bool gRecursiveCallReturned = false; + + void testRecursiveUnwindHandler(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + if (gRecursionDepth++ == 0) + { + gRecursiveCallReturned = runtime->dispatchGuestBranch( + rdram, ctx, 0x3000u, 0x3004u, 0x3008u, PS2Runtime::GuestBranchKind::DirectCall, "test-recursive-jal"); + } + else + { + runtime->dispatchGuestBranch( + rdram, ctx, 0x3000u, 0x3010u, 0x3014u, PS2Runtime::GuestBranchKind::DirectJump, "test-loop"); + } + --gRecursionDepth; + } + void testGuestBranchTransferHandler(uint8_t *, R5900Context *ctx, PS2Runtime *) { if (ctx) @@ -406,6 +426,22 @@ void register_ps2_runtime_expansion_tests() "callee should still execute normally"); }); + tc.Run("dispatchGuestBranch keeps unwinding when the resume PC is the callee's entry", [](TestCase &t) + { + PS2Runtime runtime; + runtime.registerFunction(0x3000u, &testRecursiveUnwindHandler); + gRecursionDepth = 0; + gRecursiveCallReturned = true; + + R5900Context ctx{}; + ctx.pc = 0x3000u; + testRecursiveUnwindHandler(nullptr, &ctx, &runtime); + + t.IsTrue(!gRecursiveCallReturned, + "the outer call must unwind too, not treat pc == entry as an implicit return"); + t.Equals(ctx.pc, 0x3000u, "the scheduler should resume at the inner loop's target"); + }); + tc.Run("dispatchGuestBranch jump returns to central dispatcher without nesting", [](TestCase &t) { PS2Runtime runtime; From 887ed24f83471d63bf7df95d185c7a38c6159009 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:25:32 -0700 Subject: [PATCH 12/17] fix(pad): report the DualShock 2 pressure mode ID 0x79 The second byte of the pad data is the terminal ID and the payload length in halfwords. In pressure mode a DualShock 2 sends 9 halfwords, so it reports 0x79, not the analog 0x73. EA's pad library checks this after entering pressure mode and restarts its setup forever while it still reads 0x73. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp | 7 ++++++- ps2xTest/src/pad_input_tests.cpp | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp index b50a4cf6aa..21672666f2 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp @@ -7,6 +7,7 @@ namespace ps2_stubs { constexpr uint8_t kPadModeDigital = 0x41; constexpr uint8_t kPadModeDualShock = 0x73; + constexpr uint8_t kPadModeDualShock2Pressure = 0x79; constexpr uint8_t kPadAnalogCenter = 0x80; constexpr int32_t kPadTypeDigital = 4; constexpr int32_t kPadTypeDualShock = 7; @@ -245,7 +246,11 @@ namespace ps2_stubs void fillPadStatus(uint8_t *data, const PadInputState &state, const PadPortState &portState) { std::memset(data, 0, 32); - data[1] = portState.analogMode ? kPadModeDualShock : kPadModeDigital; + // Terminal ID (high nibble) and payload length in halfwords: in + // pressure mode a DualShock 2 sends 9 halfwords (0x79), so games + // that enabled it re-run pad setup if they still see 0x73. + data[1] = !portState.analogMode ? kPadModeDigital + : (portState.pressureEnabled ? kPadModeDualShock2Pressure : kPadModeDualShock); data[2] = static_cast(state.buttons & 0xFFu); data[3] = static_cast((state.buttons >> 8) & 0xFFu); data[4] = state.rx; diff --git a/ps2xTest/src/pad_input_tests.cpp b/ps2xTest/src/pad_input_tests.cpp index 07f9b7a29d..d742ed2841 100644 --- a/ps2xTest/src/pad_input_tests.cpp +++ b/ps2xTest/src/pad_input_tests.cpp @@ -351,6 +351,18 @@ void register_pad_input_tests() runPadRead(ctx, rdram); t.Equals(data[1], static_cast(0x73), "mode byte should be 0x73 (analog) after SetMainMode"); + setRegU32(ctx, 4, 0u); + setRegU32(ctx, 5, 0u); + ps2_stubs::scePadEnterPressMode(rdram.data(), &ctx, nullptr); + runPadRead(ctx, rdram); + t.Equals(data[1], static_cast(0x79), "mode byte should be 0x79 (9 halfwords) in pressure mode"); + + setRegU32(ctx, 4, 0u); + setRegU32(ctx, 5, 0u); + ps2_stubs::scePadExitPressMode(rdram.data(), &ctx, nullptr); + runPadRead(ctx, rdram); + t.Equals(data[1], static_cast(0x73), "mode byte should return to 0x73 after leaving pressure mode"); + closePadPort(ctx, rdram); }); From 25244c25753c8c6f0584ef97db70f8a9c1937585 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:27:34 -0700 Subject: [PATCH 13/17] fix(iop): complete SPU2 auto-DMA transfers at playback speed With auto-DMA (ADMAS) the SPU2 takes PCM data only as fast as it plays it, 48 kHz stereo 16-bit or 192 IOP cycles per byte. The IOP DMA completed such transfers at bus speed, so software mixers that stream through sceSdBlockTrans (EA's snddrv) refilled back-to-back and kept the IOP busy 100% of the time. Adds ps2_iop_memory_tests for the DMA timing. Co-Authored-By: Claude Opus 5.5 --- ps2xIOP/CMakeLists.txt | 5 +++ ps2xIOP/src/emulator/core/iop_memory.cpp | 16 ++++++++- ps2xIOP/tests/iop_memory_tests.cpp | 46 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 ps2xIOP/tests/iop_memory_tests.cpp diff --git a/ps2xIOP/CMakeLists.txt b/ps2xIOP/CMakeLists.txt index f8d3e4437a..6a17f0dd1e 100644 --- a/ps2xIOP/CMakeLists.txt +++ b/ps2xIOP/CMakeLists.txt @@ -62,6 +62,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_memory_tests tests/iop_memory_tests.cpp) + target_link_libraries(ps2_iop_memory_tests PRIVATE ps2_iop) + target_include_directories(ps2_iop_memory_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + add_test(NAME ps2_iop_memory_tests COMMAND ps2_iop_memory_tests) + endif() install(TARGETS ps2_iop diff --git a/ps2xIOP/src/emulator/core/iop_memory.cpp b/ps2xIOP/src/emulator/core/iop_memory.cpp index 25e9b36d19..7c872fe20a 100644 --- a/ps2xIOP/src/emulator/core/iop_memory.cpp +++ b/ps2xIOP/src/emulator/core/iop_memory.cpp @@ -269,9 +269,23 @@ 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; + + // With auto-DMA (ADMAS at 0x1F9001B0 / 0x1F9005B0) the SPU2 pulls PCM + // only as fast as it plays it: 48 kHz stereo 16-bit = 192000 bytes/s, + // i.e. 192 IOP cycles per byte. Completing at bus speed instead makes + // sceSdBlockTrans streams (software mixers) refill back-to-back and + // keeps the IOP 100% busy. + const uint32_t admasAddress = secondCore ? 0x1F9005B0u : 0x1F9001B0u; + uint32_t admas = 0u; + if (const auto current = m_hardware.find(admasAddress); current != m_hardware.end()) + admas = current->second & 0xFFFFu; + constexpr uint64_t kAutoDmaCyclesPerByte = 192u; + const uint64_t delayCycles = (admas & 3u) != 0u + ? transferWords * 4u * kAutoDmaCyclesPerByte + : std::max(transferWords * 2u, 64u); m_dmaStart = DmaStart{ secondCore ? kDmaSpu1Irq : kDmaSpu0Irq, - std::max(transferWords * 2u, 64u), + delayCycles, }; } diff --git a/ps2xIOP/tests/iop_memory_tests.cpp b/ps2xIOP/tests/iop_memory_tests.cpp new file mode 100644 index 0000000000..e906d4ac93 --- /dev/null +++ b/ps2xIOP/tests/iop_memory_tests.cpp @@ -0,0 +1,46 @@ +#include "iop_compat_test_support.h" + +#include "emulator/core/iop_memory.h" + +namespace +{ + using namespace iop_test; + using namespace ps2x::iop::detail; + + constexpr uint32_t kSpu0Bcr = 0x1F8010C4u; + constexpr uint32_t kSpu0Chcr = 0x1F8010C8u; + constexpr uint32_t kSpu0Admas = 0x1F9001B0u; + constexpr uint32_t kChcrStartToSpu = 0x01000201u; + + void spu2DmaCompletesAtBusSpeed() + { + IopMemory memory; + memory.write32(kSpu0Bcr, (1u << 16u) | 16u); // one block of 16 words + memory.write32(kSpu0Chcr, kChcrStartToSpu); + const auto start = memory.takeDmaStart(); + require(start.has_value(), "SPU2 DMA did not start"); + require(start->delayCycles == 64u, "a plain SPU2 DMA should complete at bus speed"); + } + + void spu2AutoDmaCompletesAtPlaybackSpeed() + { + // Auto-DMA feeds the SPU2 at its playback rate: 48 kHz stereo 16-bit + // is 192000 bytes/s, 192 IOP cycles per byte. + IopMemory memory; + memory.write16(kSpu0Admas, 1u); + memory.write32(kSpu0Bcr, (1u << 16u) | 16u); + memory.write32(kSpu0Chcr, kChcrStartToSpu); + const auto start = memory.takeDmaStart(); + require(start.has_value(), "SPU2 auto-DMA did not start"); + require(start->delayCycles == 16u * 4u * 192u, "SPU2 auto-DMA should complete at playback speed"); + } +} + +int main() +{ + const Test tests[] = { + {"SPU2 DMA completes at bus speed", spu2DmaCompletesAtBusSpeed}, + {"SPU2 auto-DMA completes at playback speed", spu2AutoDmaCompletesAtPlaybackSpeed}, + }; + return run(tests); +} From e010fdf4fa00fcebe60afe1fc3ebbe7711f66a6b Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:31:23 -0700 Subject: [PATCH 14/17] fix(vif): stall VIF1 after a VIFcode with the interrupt bit A VIFcode with the I bit sets STAT.INT, raises the INTC VIF1 interrupt and stops VIF1 once the command is done, until the CPU writes VIF1_FBRST.STC (unless ERR.MII masks it). VIF1 only set STAT.INT and went on. EA's renderer syncs PATH3 texture uploads with VIF1 this way: its handlers restart GIF and VIF1 when both have stopped, and without the stall and interrupt it waited forever on the first loading screen. The remaining DMA data waits until FBRST.STC (a further stall inside it only moves an offset), the VIF1 channel completes after that data has run, and INTC causes queued by PS2Memory reach the scheduler's INTC dispatch, also from the sceDmaSend path. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/include/ps2_runtime.h | 3 ++ ps2xRuntime/include/runtime/ps2_memory.h | 14 +++++++ .../src/lib/Kernel/Stubs/Helpers/Support.h | 2 +- ps2xRuntime/src/lib/ps2_memory.cpp | 37 +++++++++++++++- ps2xRuntime/src/lib/ps2_runtime.cpp | 12 +++++- ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp | 42 +++++++++++++++++++ ps2xTest/src/ps2_memory_tests.cpp | 20 +++++++++ 7 files changed, 127 insertions(+), 3 deletions(-) diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index acd2d535e2..bdaf903063 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -389,6 +389,9 @@ class PS2Runtime uint32_t reserveAsyncCallbackStack(uint32_t size, uint32_t alignment = 16u); void drainCompletedDmacHandlers(uint8_t *rdram); + // Runs the handlers for one cause from PS2Memory::consumeCompletedDmacCauses + // (INTC handlers for causes flagged with kQueuedIntcCauseFlag). + void dispatchCompletedDmacCause(uint8_t *rdram, uint32_t cause); void requestStop(); bool isStopRequested() const; diff --git a/ps2xRuntime/include/runtime/ps2_memory.h b/ps2xRuntime/include/runtime/ps2_memory.h index ea86a9cb25..d17ac2dd6a 100644 --- a/ps2xRuntime/include/runtime/ps2_memory.h +++ b/ps2xRuntime/include/runtime/ps2_memory.h @@ -345,6 +345,8 @@ class PS2Memory void processVIF1Data(const uint8_t *data, uint32_t sizeBytes); void processPendingTransfers(); std::vector consumeCompletedDmacCauses(); + // Set on queued causes that are INTC interrupts (e.g. VIF1) rather than DMAC. + static constexpr uint32_t kQueuedIntcCauseFlag = 0x100u; int pollDmaRegisters(); @@ -409,6 +411,16 @@ class PS2Memory bool m_path3Masked = false; uint32_t m_vif1PendingPath2ImageQwc = 0u; bool m_vif1PendingPath2DirectHl = false; + // A VIFcode with the interrupt (I) bit stalls VIF1 after it executes: the + // rest of the DMA data (from m_vif1StalledOffset on) waits here until + // VIF1_FBRST.STC, and the channel's completion is deferred until that data + // has been consumed. Resuming processes the buffer in place, so stalling + // again inside it (m_vif1ResumeData) only moves the offset. + std::vector m_vif1StalledData; + size_t m_vif1StalledOffset = 0; + std::vector *m_vif1ResumeData = nullptr; + bool m_vif1Stalled = false; + bool m_vif1DmaCompletionDeferred = false; std::vector> m_path3MaskedFifo; struct PendingTransfer @@ -422,6 +434,7 @@ class PS2Memory std::vector m_pendingVif0Transfers; std::vector m_pendingVif1Transfers; std::mutex m_completedDmacMutex; + // DMAC channel causes; INTC causes are queued with kQueuedIntcCauseFlag set. std::vector m_completedDmacCauses; struct CodeRegion @@ -452,6 +465,7 @@ class PS2Memory bool tryProcessScratchpadDma(uint32_t channelBase, uint32_t chcr); void completeDmacChannel(uint32_t channelBase, uint32_t cause); void queueCompletedDmacCause(uint32_t cause); + void resumeStalledVif1(); }; #endif // PS2_MEMORY_H diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index b1bb6d8515..2d4c6f6eb3 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1431,7 +1431,7 @@ namespace for (const uint32_t completedCause : completedCauses) { - ps2_syscalls::dispatchDmacHandlersForCause(rdram, runtime, completedCause); + runtime->dispatchCompletedDmacCause(rdram, completedCause); } return 0; diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index 37b6a4decc..46fff192ac 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -3,6 +3,7 @@ #include "runtime/gs/gs_frontend.h" #include "ps2_log.h" #include +#include #include #include #include @@ -1231,6 +1232,10 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) std::memset(&vif1_regs, 0, sizeof(vif1_regs)); m_vif1PendingPath2ImageQwc = 0u; m_vif1PendingPath2DirectHl = false; + m_vif1Stalled = false; + m_vif1StalledData.clear(); + m_vif1StalledOffset = 0; + m_vif1DmaCompletionDeferred = false; m_path3Masked = false; if (wasPath3Masked) flushMaskedPath3Packets(); @@ -1238,6 +1243,7 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) if (value & 0x8u) // STC { vif1_regs.stat &= ~((1u << 8) | (1u << 9) | (1u << 10) | (1u << 11) | (1u << 12) | (1u << 13)); + resumeStalledVif1(); } break; case 0x10003C30u: @@ -1896,7 +1902,13 @@ void PS2Memory::processPendingTransfers() m_ioRegisters[VIF0_CHANNEL + 0x00] &= ~0x100u; m_ioRegisters[VIF0_CHANNEL + 0x20] = 0; } - if (hadVif1) + if (hadVif1 && m_vif1Stalled) + { + // VIF1 stopped on a VIFcode interrupt; the channel finishes once the + // stalled data has been consumed after VIF1_FBRST.STC. + m_vif1DmaCompletionDeferred = true; + } + else if (hadVif1) { raiseDStatChannel(1u); // VIF1 channel queueCompletedDmacCause(1u); @@ -1905,6 +1917,29 @@ void PS2Memory::processPendingTransfers() } } +void PS2Memory::resumeStalledVif1() +{ + if (!m_vif1Stalled) + return; + m_vif1Stalled = false; + std::vector pending; + pending.swap(m_vif1StalledData); + const size_t offset = std::exchange(m_vif1StalledOffset, size_t{0}); + if (offset < pending.size()) + { + m_vif1ResumeData = &pending; + processVIF1Data(pending.data() + offset, static_cast(pending.size() - offset)); + m_vif1ResumeData = nullptr; + } + if (!m_vif1Stalled && m_vif1DmaCompletionDeferred) + { + static constexpr uint32_t VIF1_CHANNEL = 0x10009000u; + m_vif1DmaCompletionDeferred = false; + m_ioRegisters[VIF1_CHANNEL + 0x20] = 0; + completeDmacChannel(VIF1_CHANNEL, 1u); + } +} + void PS2Memory::queueCompletedDmacCause(uint32_t cause) { std::lock_guard lock(m_completedDmacMutex); diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 95bebc87f2..36b298f6b7 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -1525,10 +1525,20 @@ void PS2Runtime::drainCompletedDmacHandlers(uint8_t *rdram) { for (uint32_t cause : m_memory.consumeCompletedDmacCauses()) { - ps2_syscalls::dispatchDmacHandlersForCause(rdram, this, cause); + dispatchCompletedDmacCause(rdram, cause); } } +void PS2Runtime::dispatchCompletedDmacCause(uint8_t *rdram, uint32_t cause) +{ + if ((cause & PS2Memory::kQueuedIntcCauseFlag) != 0u) + { + m_eeScheduler->dispatchIrq(false, cause & ~PS2Memory::kQueuedIntcCauseFlag); + return; + } + ps2_syscalls::dispatchDmacHandlersForCause(rdram, this, cause); +} + void PS2Runtime::handleTrap(uint8_t *rdram, R5900Context *ctx) { raiseCop0Exception(ctx, EXCEPTION_TRAP); diff --git a/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp b/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp index f872edd156..e0b648dded 100644 --- a/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp +++ b/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp @@ -1,5 +1,6 @@ // Based on Blackline Interactive implementation #include "runtime/ps2_memory.h" +#include #include enum VIFCmd : uint8_t @@ -300,10 +301,40 @@ void PS2Memory::processVIF1Data(const uint8_t *data, uint32_t sizeBytes) if (sizeBytes == 0u) return; + if (m_vif1Stalled) + { + // The FIFO is blocked behind an unacknowledged VIFcode interrupt. + m_vif1StalledData.insert(m_vif1StalledData.end(), data, data + sizeBytes); + return; + } + uint32_t pos = 0; + bool stallAfterCommand = false; + auto stall = [&]() + { + if (m_vif1ResumeData && data >= m_vif1ResumeData->data() && + data < m_vif1ResumeData->data() + m_vif1ResumeData->size()) + { + // Stalled again inside the data being resumed: keep that buffer. + m_vif1StalledOffset = static_cast(data - m_vif1ResumeData->data()) + pos; + m_vif1StalledData.swap(*m_vif1ResumeData); + } + else + { + m_vif1StalledData.assign(data + pos, data + sizeBytes); + m_vif1StalledOffset = 0; + } + m_vif1Stalled = true; + queueCompletedDmacCause(kQueuedIntcCauseFlag | 5u); // INTC VIF1 + }; while (pos + 4 <= sizeBytes) { + if (stallAfterCommand) + { + stall(); + return; + } if (m_vif1PendingPath2ImageQwc != 0u) { const uint32_t availableQw = (sizeBytes - pos) / 16u; @@ -344,7 +375,12 @@ void PS2Memory::processVIF1Data(const uint8_t *data, uint32_t sizeBytes) vif1_regs.code = cmd; vif1_regs.num = num; if (irq) + { vif1_regs.stat |= (1u << 11); // INT + // Unless ERR.MII masks it, the VIF stalls once this command is done. + if ((vif1_regs.err & 0x1u) == 0u) + stallAfterCommand = true; + } if (opcode == VIF_NOP) { @@ -780,4 +816,10 @@ void PS2Memory::processVIF1Data(const uint8_t *data, uint32_t sizeBytes) continue; } } + + if (stallAfterCommand) + { + pos = std::min(pos, sizeBytes); + stall(); + } } diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index 0b22fe03c6..e2389258f2 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -769,6 +769,26 @@ void register_ps2_memory_tests() t.IsTrue((mem.vif1_regs.stat & (1u << 11)) == 0u, "FBRST.STC should clear VIF1 STAT.INT"); }); + tc.Run("VIF1 stalls after an interrupt command until FBRST.STC", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + const uint32_t words[] = { + 0x80000000u | makeVifCmd(0x00u, 0x00u, 0x0000u), // NOP with the I bit + makeVifCmd(0x07u, 0x00u, 0x1234u), // MARK 0x1234 + }; + mem.processVIF1Data(reinterpret_cast(words), sizeof(words)); + t.Equals(mem.vif1_regs.mark, 0u, "commands after the interrupt should wait for the CPU"); + + const std::vector causes = mem.consumeCompletedDmacCauses(); + t.IsTrue(std::find(causes.begin(), causes.end(), PS2Memory::kQueuedIntcCauseFlag | 5u) != causes.end(), + "the interrupt command should raise the INTC VIF1 interrupt"); + + t.IsTrue(mem.writeIORegister(0x10003C10u, 0x8u), "FBRST STC write should succeed"); + t.Equals(mem.vif1_regs.mark, 0x1234u, "FBRST.STC should resume the stalled commands"); + }); + tc.Run("VIF FBRST RST clears VIF1 command state", [](TestCase &t) { PS2Memory mem; From 3b5472966e8ab3413add8faaf4786803a201aff9 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:34:24 -0700 Subject: [PATCH 15/17] fix(gif): let PATH2 data resumed after a VIF1 stall beat PATH3 PATH3 has the lowest GIF priority. Games that sync GIF and VIF1 with a VIF1 interrupt restart both from the handler, GIF first, and rely on the PATH2 draws behind the stall winning the arbitration: the PATH3 uploads are for the next batch and may reuse VRAM those draws still read. The runtime ran the whole GIF chain at the CHCR write, so in FIFA Street 2 a UI texture overwrote a flag's shading data before the flag was drawn. A GIF chain started while VIF1 is stalled with data left now waits for the next I/O register write. If that write is VIF1_FBRST.STC, the resumed VIF1 data goes first. Any other write, a read of GIF_STAT, GIF_CHCR or D_STAT, or VIF1 FLUSHA runs the chain right away, so code that waits for the GIF transfer cannot hang. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/include/runtime/ps2_memory.h | 4 +++ ps2xRuntime/src/lib/ps2_memory.cpp | 36 +++++++++++++++++++- ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp | 3 ++ ps2xTest/src/ps2_memory_tests.cpp | 36 ++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/ps2xRuntime/include/runtime/ps2_memory.h b/ps2xRuntime/include/runtime/ps2_memory.h index d17ac2dd6a..78ba1597f4 100644 --- a/ps2xRuntime/include/runtime/ps2_memory.h +++ b/ps2xRuntime/include/runtime/ps2_memory.h @@ -431,6 +431,9 @@ class PS2Memory std::vector chainData; }; std::vector m_pendingGifTransfers; + // GIF (PATH3) chains started while VIF1 is stalled on a VIFcode interrupt + // with data left; see runDeferredGifTransfers(). + std::vector m_deferredGifTransfers; std::vector m_pendingVif0Transfers; std::vector m_pendingVif1Transfers; std::mutex m_completedDmacMutex; @@ -466,6 +469,7 @@ class PS2Memory void completeDmacChannel(uint32_t channelBase, uint32_t cause); void queueCompletedDmacCause(uint32_t cause); void resumeStalledVif1(); + void runDeferredGifTransfers(); }; #endif // PS2_MEMORY_H diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index 46fff192ac..dbbbffce4a 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -330,6 +330,7 @@ bool PS2Memory::initialize(size_t ramSize) m_codeRegions.clear(); m_path3Masked = false; m_path3MaskedFifo.clear(); + m_deferredGifTransfers.clear(); m_vif1PendingPath2ImageQwc = 0u; m_vif1PendingPath2DirectHl = false; resetEeTimers(); @@ -1125,6 +1126,11 @@ void PS2Memory::write128(uint32_t address, __m128i value) bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) { + // A deferred GIF chain waits only for the I/O write that follows its start: + // when that write resumes VIF1 (FBRST.STC), VIF1 goes first. + if (!m_deferredGifTransfers.empty() && !(address == 0x10003C10u && (value & 0x8u) != 0u)) + runDeferredGifTransfers(); + size_t timerIndex = 0u; uint32_t timerOffset = 0u; if (decodeEeTimerRegister(address, timerIndex, timerOffset)) @@ -1566,7 +1572,21 @@ bool PS2Memory::writeIORegister(uint32_t address, uint32_t value) const bool autoProcessTransfers = (channelBase == 0x1000A000u) ? (m_gifPacketCallback || m_gifArbiter != nullptr) : true; - if (autoProcessTransfers) + if (channelBase == 0x1000A000u && autoProcessTransfers && + m_vif1Stalled && m_vif1StalledData.size() > m_vif1StalledOffset) + { + // PATH3 has the lowest GIF priority. Games that sync PATH2 and + // PATH3 with a VIF1 interrupt resume both from the handler (GIF + // first) and rely on the PATH2 draws that follow the stall + // winning the arbitration: the uploads are for the next batch + // and may reuse VRAM those draws still read. The chain runs at + // the next I/O write (see writeIORegister), after the resumed + // VIF1 data if that write is VIF1_FBRST.STC. + for (auto &transfer : m_pendingGifTransfers) + m_deferredGifTransfers.push_back(std::move(transfer)); + m_pendingGifTransfers.clear(); + } + else if (autoProcessTransfers) { processPendingTransfers(); } @@ -1931,6 +1951,7 @@ void PS2Memory::resumeStalledVif1() processVIF1Data(pending.data() + offset, static_cast(pending.size() - offset)); m_vif1ResumeData = nullptr; } + runDeferredGifTransfers(); if (!m_vif1Stalled && m_vif1DmaCompletionDeferred) { static constexpr uint32_t VIF1_CHANNEL = 0x10009000u; @@ -1940,6 +1961,16 @@ void PS2Memory::resumeStalledVif1() } } +void PS2Memory::runDeferredGifTransfers() +{ + if (m_deferredGifTransfers.empty()) + return; + for (auto &transfer : m_deferredGifTransfers) + m_pendingGifTransfers.push_back(std::move(transfer)); + m_deferredGifTransfers.clear(); + processPendingTransfers(); +} + void PS2Memory::queueCompletedDmacCause(uint32_t cause) { std::lock_guard lock(m_completedDmacMutex); @@ -2374,6 +2405,9 @@ uint32_t PS2Memory::readIORegister(uint32_t address) return val; } + if (address == 0x10003020u || address == 0x1000A000u || address == 0x1000E010u) // GIF_STAT, GIF_CHCR, D_STAT + runDeferredGifTransfers(); + if (address == 0x10003020u) // GIF_STAT { uint32_t stat = m_ioRegisters.count(address) ? m_ioRegisters[address] : 0u; diff --git a/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp b/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp index e0b648dded..a4b04324fb 100644 --- a/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp +++ b/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp @@ -434,6 +434,9 @@ void PS2Memory::processVIF1Data(const uint8_t *data, uint32_t sizeBytes) } else if (opcode == VIF_FLUSHE || opcode == VIF_FLUSH || opcode == VIF_FLUSHA) { + // FLUSHA waits for PATH3 to finish. + if (opcode == VIF_FLUSHA) + runDeferredGifTransfers(); continue; } else if (opcode == VIF_MSCAL || opcode == VIF_MSCALF) diff --git a/ps2xTest/src/ps2_memory_tests.cpp b/ps2xTest/src/ps2_memory_tests.cpp index e2389258f2..43efb17b27 100644 --- a/ps2xTest/src/ps2_memory_tests.cpp +++ b/ps2xTest/src/ps2_memory_tests.cpp @@ -976,6 +976,42 @@ void register_ps2_memory_tests() t.IsTrue(secondOk, "second queued PATH3 packet should flush in-order"); }); + tc.Run("PATH3 started during a VIF1 interrupt stall waits for the resumed PATH2 data", [](TestCase &t) + { + PS2Memory mem; + t.IsTrue(mem.initialize(), "PS2Memory initialize should succeed"); + + std::vector firstBytes; + mem.setGifPacketCallback([&](const uint8_t *data, uint32_t) + { + firstBytes.push_back(data[0]); + }); + + // VIF1: NOP with the I bit, then a DIRECT (PATH2) packet. + uint32_t vif1[2u + 4u] = { + 0x80000000u | makeVifCmd(0x00u, 0x00u, 0x0000u), + makeVifCmd(0x50u, 0x00u, 0x0001u), + }; + vif1[2] = 0xA2u; + mem.processVIF1Data(reinterpret_cast(vif1), sizeof(vif1)); + + // The interrupt handler restarts GIF (PATH3) before VIF1. + constexpr uint32_t kGifCh = 0x1000A000u; + constexpr uint32_t kSrc = 0x00022000u; + uint8_t *rdram = mem.getRDRAM(); + std::memset(rdram + kSrc, 0, 16u); + rdram[kSrc] = 0xA3u; + t.IsTrue(mem.writeIORegister(kGifCh + 0x10u, kSrc), "write MADR should succeed"); + t.IsTrue(mem.writeIORegister(kGifCh + 0x20u, 1u), "write QWC should succeed"); + t.IsTrue(mem.writeIORegister(kGifCh + 0x00u, 0x100u), "write CHCR STR should succeed"); + t.IsTrue(firstBytes.empty(), "PATH3 should wait while VIF1 holds PATH2 data behind the interrupt"); + + t.IsTrue(mem.writeIORegister(0x10003C10u, 0x8u), "FBRST STC write should succeed"); + t.Equals(firstBytes.size(), static_cast(2u), "both paths should run after FBRST.STC"); + t.IsTrue(firstBytes.size() == 2u && firstBytes[0] == 0xA2u && firstBytes[1] == 0xA3u, + "the resumed PATH2 packet should reach the GS before the PATH3 upload"); + }); + tc.Run("GIF arbiter prioritizes PATH1 then PATH2 then PATH3", [](TestCase &t) { std::vector order; From 8b7e67e229b4f3c42412a3a5606add33b2f63806 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:35:27 -0700 Subject: [PATCH 16/17] fix(kernel): set the VBlank rate in sceGsResetGraph too The libgraph sceGsResetGraph ends with SetGsCrt(interlace, omode, ffmode), so a PAL game that only calls sceGsResetGraph also runs at 50 Hz. The HLE version did not select the VBlank rate; it now does, like the SetGsCrt syscall. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 4 ++++ ps2xTest/src/ps2_gs_tests.cpp | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index aed4cb5437..06e2a29feb 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -802,6 +802,10 @@ namespace ps2_stubs g_gparam.omode = static_cast(omode & 0xFF); g_gparam.ffmode = static_cast(ffmode & 0x1); writeGsGParamToScratch(runtime); + // The real sceGsResetGraph ends with SetGsCrt(interlace, omode, + // ffmode), which selects the CRT timing: omode 3 is PAL (50 Hz). + if (runtime) + runtime->eeScheduler().setPalVBlankRate((omode & 0xFFu) == 3u); 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); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 113cc88813..e9c68507dd 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -4135,6 +4135,28 @@ void register_ps2_gs_tests() "sceGsResetGraph should free its temporary GIF packet"); }); + tc.Run("sceGsResetGraph selects the VBlank rate of its video mode", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + std::vector rdram(PS2_RAM_SIZE, 0u); + + R5900Context ctx{}; + setRegU32(ctx, 4, 0u); // reset + setRegU32(ctx, 5, 1u); // interlaced + setRegU32(ctx, 6, 3u); // PAL + setRegU32(ctx, 7, 1u); + ps2_stubs::sceGsResetGraph(rdram.data(), &ctx, &runtime); + t.Equals(runtime.eeScheduler().vblankPeriod().count(), static_cast(20000), + "a PAL reset should produce 50 Hz VBlanks"); + + setRegU32(ctx, 4, 0u); + setRegU32(ctx, 6, 2u); // NTSC + ps2_stubs::sceGsResetGraph(rdram.data(), &ctx, &runtime); + t.Equals(runtime.eeScheduler().vblankPeriod().count(), static_cast(16667), + "an NTSC reset should restore 60 Hz VBlanks"); + }); + tc.Run("sceGsSyncV resumes through the scheduler with deterministic field parity", [](TestCase &t) { PS2Runtime runtime; From 7f2d07bd2caee7a85b5e6c5b7ef17530852d9270 Mon Sep 17 00:00:00 2001 From: 1lawl <117039652+1lawl@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:49:38 -0700 Subject: [PATCH 17/17] feat(runtime): interpret EE code that has no recompiled function Some games build or load MIPS code in RAM at run time, which a static recompilation cannot see. FIFA Street 2's sound engine (EA AEMS) ships code modules inside its .ABK sound banks, links them into the heap and calls them every frame. Such calls stopped the runtime with a missing function. User RAM (1 MB and up) outside the recompiled code regions now runs in an EE interpreter that follows the recompiler's semantics. It hands control back as soon as it reaches recompiled code or leaves that RAM, calls recompiled functions through dispatchGuestBranch, and takes a scheduler checkpoint every 32 instructions. hasFunction() keeps meaning "has a recompiled function" (HLE code probes optional helpers with it); the new canRunGuestCode() also accepts interpretable addresses and gates dispatch. PS2X_NO_INTERPRETER=1 turns the interpreter off. Co-Authored-By: Claude Opus 5.5 --- ps2xRuntime/CMakeLists.txt | 1 + ps2xRuntime/include/ps2_runtime.h | 4 + .../include/runtime/ps2_ee_interpreter.h | 16 + ps2xRuntime/src/lib/Kernel/EeScheduler.cpp | 2 +- ps2xRuntime/src/lib/ps2_ee_interpreter.cpp | 662 ++++++++++++++++++ ps2xRuntime/src/lib/ps2_runtime.cpp | 42 +- ps2xTest/src/ps2_runtime_expansion_tests.cpp | 26 + 7 files changed, 749 insertions(+), 4 deletions(-) create mode 100644 ps2xRuntime/include/runtime/ps2_ee_interpreter.h create mode 100644 ps2xRuntime/src/lib/ps2_ee_interpreter.cpp diff --git a/ps2xRuntime/CMakeLists.txt b/ps2xRuntime/CMakeLists.txt index 589d76850a..633d501b1a 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_ee_interpreter.cpp src/lib/ps2_pad.cpp src/lib/ps2_rom_device.cpp src/lib/ps2_vfs.cpp diff --git a/ps2xRuntime/include/ps2_runtime.h b/ps2xRuntime/include/ps2_runtime.h index bdaf903063..97a6dcf9e3 100644 --- a/ps2xRuntime/include/ps2_runtime.h +++ b/ps2xRuntime/include/ps2_runtime.h @@ -337,7 +337,11 @@ class PS2Runtime // TODO remove this later need to update all tests bool registerFunction(uint32_t address, RecompiledFunction func); RecompiledFunction lookupFunction(uint32_t address); + // True if a recompiled function starts at the address. bool hasFunction(uint32_t address) const; + // True if the address can be dispatched: a recompiled function, or RAM + // the EE interpreter runs (code the game builds at run time). + bool canRunGuestCode(uint32_t address) const; // The scheduler calls this before dispatching guest code (see // dispatchGuestBranch). void clearGuestUnwind() noexcept { m_guestUnwinding = false; } diff --git a/ps2xRuntime/include/runtime/ps2_ee_interpreter.h b/ps2xRuntime/include/runtime/ps2_ee_interpreter.h new file mode 100644 index 0000000000..67c2db1635 --- /dev/null +++ b/ps2xRuntime/include/runtime/ps2_ee_interpreter.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +struct R5900Context; +class PS2Runtime; + +// Executes EE code that has no recompiled function: code a game loads or +// builds in RAM at run time (overlays, code modules linked from data files). +// Runs from ctx->pc and returns once control reaches recompiled code (with +// ctx->pc set to it), when the scheduler needs a checkpoint, or when a call +// into recompiled code unwinds. Same signature as a recompiled function. +void ps2InterpretGuestCode(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); + +// True if the function table has recompiled code for this exact address. +bool ps2HasRecompiledFunction(uint32_t address); diff --git a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp index 0b740e5f0b..a089d7fbd5 100644 --- a/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp +++ b/ps2xRuntime/src/lib/Kernel/EeScheduler.cpp @@ -266,7 +266,7 @@ void EeScheduler::run() continue; } - if (!m_runtime.hasFunction(context.pc)) + if (!m_runtime.canRunGuestCode(context.pc)) { if (!running->invocations.empty()) { diff --git a/ps2xRuntime/src/lib/ps2_ee_interpreter.cpp b/ps2xRuntime/src/lib/ps2_ee_interpreter.cpp new file mode 100644 index 0000000000..9bbaeb2f00 --- /dev/null +++ b/ps2xRuntime/src/lib/ps2_ee_interpreter.cpp @@ -0,0 +1,662 @@ +#include "runtime/ps2_ee_interpreter.h" + +#include "ps2_runtime.h" +#include "ps2_runtime_macros.h" + +#include +#include +#include +#include + +// Instruction semantics mirror what ps2xRecomp emits for the same opcode, so +// interpreted and recompiled code behave identically (register extension +// rules, HI/LO handling, PS2 FPU helpers, MMIO-aware memory access). + +namespace +{ + // How often (in instructions) the interpreter offers the scheduler a + // checkpoint; also the cycle count charged for it. + constexpr uint32_t kCheckpointInterval = 32u; + + struct Fields + { + uint32_t word; + uint32_t rs, rt, rd, sa, funct; + int32_t simm; + uint32_t uimm; + }; + + Fields decode(uint32_t word) + { + return {word, (word >> 21) & 31u, (word >> 16) & 31u, (word >> 11) & 31u, (word >> 6) & 31u, word & 63u, + static_cast(static_cast(word & 0xFFFFu)), word & 0xFFFFu}; + } + + void reportUnimplemented(uint32_t pc, uint32_t word, const char *what) + { + static std::unordered_set reported; + if (!reported.insert(word & 0xFC1F07FFu).second) + return; + std::cerr << "[ee-interp] unimplemented " << what << " 0x" << std::hex << word << " at pc=0x" << pc + << std::dec << " (treated as NOP)" << std::endl; + } + + enum class Flow + { + Next, // fall through to pc + 4 + Branch, // control transfer with a delay slot (handled by caller) + Stop, // leave the interpreter (ctx->pc already set) + }; + + bool isControlTransfer(uint32_t word) + { + const uint32_t op = word >> 26; + if (op == 0x00) + { + const uint32_t funct = word & 63u; + return funct == 0x08 || funct == 0x09; // JR, JALR + } + if (op == 0x01) + { + const uint32_t rt = (word >> 16) & 31u; + return rt <= 0x03 || (rt >= 0x10 && rt <= 0x13); // BLTZ/BGEZ(L)(AL) + } + if (op == 0x02 || op == 0x03) + return true; // J, JAL + if ((op >= 0x04 && op <= 0x07) || (op >= 0x14 && op <= 0x17)) + return true; // BEQ..BGTZ(L) + if (op == 0x11 && ((word >> 21) & 31u) == 0x08) + return true; // BC1x + return false; + } + + // Executes one instruction that is not a control transfer. + Flow executeSimple(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime, uint32_t pc, const Fields &f) + { + const uint32_t op = f.word >> 26; + switch (op) + { + case 0x00: // SPECIAL + switch (f.funct) + { + case 0x00: SET_GPR_S32(ctx, f.rd, (int32_t)SLL32(GPR_U32(ctx, f.rt), f.sa)); return Flow::Next; + case 0x02: SET_GPR_S32(ctx, f.rd, (int32_t)SRL32(GPR_U32(ctx, f.rt), f.sa)); return Flow::Next; + case 0x03: SET_GPR_S32(ctx, f.rd, SRA32(GPR_S32(ctx, f.rt), f.sa)); return Flow::Next; + case 0x04: SET_GPR_S32(ctx, f.rd, (int32_t)SLL32(GPR_U32(ctx, f.rt), GPR_U32(ctx, f.rs) & 0x1F)); return Flow::Next; + case 0x06: SET_GPR_S32(ctx, f.rd, (int32_t)SRL32(GPR_U32(ctx, f.rt), GPR_U32(ctx, f.rs) & 0x1F)); return Flow::Next; + case 0x07: SET_GPR_S32(ctx, f.rd, SRA32(GPR_S32(ctx, f.rt), GPR_U32(ctx, f.rs) & 0x1F)); return Flow::Next; + case 0x0A: if (GPR_U64(ctx, f.rt) == 0) SET_GPR_VEC(ctx, f.rd, GPR_VEC(ctx, f.rs)); return Flow::Next; // MOVZ + case 0x0B: if (GPR_U64(ctx, f.rt) != 0) SET_GPR_VEC(ctx, f.rd, GPR_VEC(ctx, f.rs)); return Flow::Next; // MOVN + case 0x0C: // SYSCALL (generated code continues with the next instruction) + ctx->pc = pc + 4u; + runtime->handleSyscall(rdram, ctx, (f.word >> 6) & 0xFFFFFu); + return Flow::Next; + case 0x0D: runtime->handleBreak(rdram, ctx); return Flow::Next; + case 0x0F: return Flow::Next; // SYNC + case 0x10: SET_GPR_U64(ctx, f.rd, ctx->hi); return Flow::Next; + case 0x11: ctx->hi = GPR_U64(ctx, f.rs); return Flow::Next; + case 0x12: SET_GPR_U64(ctx, f.rd, ctx->lo); return Flow::Next; + case 0x13: ctx->lo = GPR_U64(ctx, f.rs); return Flow::Next; + case 0x14: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rt) << (GPR_U32(ctx, f.rs) & 0x3F)); return Flow::Next; + case 0x16: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rt) >> (GPR_U32(ctx, f.rs) & 0x3F)); return Flow::Next; + case 0x17: SET_GPR_S64(ctx, f.rd, GPR_S64(ctx, f.rt) >> (GPR_U32(ctx, f.rs) & 0x3F)); return Flow::Next; + case 0x18: // MULT + { + const int64_t result = (int64_t)GPR_S32(ctx, f.rs) * (int64_t)GPR_S32(ctx, f.rt); + ctx->lo = (uint64_t)(int64_t)(int32_t)result; + ctx->hi = (uint64_t)(int64_t)(int32_t)(result >> 32); + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x19: // MULTU + { + const uint64_t result = (uint64_t)GPR_U32(ctx, f.rs) * (uint64_t)GPR_U32(ctx, f.rt); + ctx->lo = (uint64_t)(int64_t)(int32_t)result; + ctx->hi = (uint64_t)(int64_t)(int32_t)(result >> 32); + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x1A: // DIV + { + const int32_t divisor = GPR_S32(ctx, f.rt); + const int32_t dividend = GPR_S32(ctx, f.rs); + if (divisor != 0) + { + if (divisor == -1 && dividend == INT32_MIN) + { + ctx->lo = (uint64_t)(int64_t)INT32_MIN; + ctx->hi = 0; + } + else + { + ctx->lo = (uint64_t)(int64_t)(dividend / divisor); + ctx->hi = (uint64_t)(int64_t)(dividend % divisor); + } + } + else + { + ctx->lo = (dividend < 0) ? 1ull : 0xFFFFFFFFFFFFFFFFull; + ctx->hi = (uint64_t)(int64_t)dividend; + } + return Flow::Next; + } + case 0x1B: // DIVU + { + const uint32_t divisor = GPR_U32(ctx, f.rt); + if (divisor != 0) + { + ctx->lo = (uint64_t)(int64_t)(int32_t)(GPR_U32(ctx, f.rs) / divisor); + ctx->hi = (uint64_t)(int64_t)(int32_t)(GPR_U32(ctx, f.rs) % divisor); + } + else + { + ctx->lo = 0xFFFFFFFFFFFFFFFFull; + ctx->hi = (uint64_t)(int64_t)(int32_t)GPR_U32(ctx, f.rs); + } + return Flow::Next; + } + case 0x20: // ADD + { + const int64_t result = (int64_t)GPR_S32(ctx, f.rs) + (int64_t)GPR_S32(ctx, f.rt); + if (result > INT32_MAX || result < INT32_MIN) + runtime->SignalException(ctx, EXCEPTION_INTEGER_OVERFLOW); + else + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x21: SET_GPR_S32(ctx, f.rd, (int32_t)ADD32(GPR_U32(ctx, f.rs), GPR_U32(ctx, f.rt))); return Flow::Next; + case 0x22: // SUB + { + uint32_t tmp; + bool ov; + SUB32_OV(GPR_U32(ctx, f.rs), GPR_U32(ctx, f.rt), tmp, ov); + if (ov) + runtime->SignalException(ctx, EXCEPTION_INTEGER_OVERFLOW); + else + SET_GPR_S32(ctx, f.rd, (int32_t)tmp); + return Flow::Next; + } + case 0x23: SET_GPR_S32(ctx, f.rd, (int32_t)SUB32(GPR_U32(ctx, f.rs), GPR_U32(ctx, f.rt))); return Flow::Next; + case 0x24: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rs) & GPR_U64(ctx, f.rt)); return Flow::Next; + case 0x25: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rs) | GPR_U64(ctx, f.rt)); return Flow::Next; + case 0x26: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rs) ^ GPR_U64(ctx, f.rt)); return Flow::Next; + case 0x27: SET_GPR_U64(ctx, f.rd, ~(GPR_U64(ctx, f.rs) | GPR_U64(ctx, f.rt))); return Flow::Next; + case 0x28: SET_GPR_U32(ctx, f.rd, ctx->sa); return Flow::Next; // MFSA + case 0x29: ctx->sa = GPR_U32(ctx, f.rs) & 0x7F; return Flow::Next; // MTSA + case 0x2A: SET_GPR_U64(ctx, f.rd, ((int64_t)GPR_S64(ctx, f.rs) < (int64_t)GPR_S64(ctx, f.rt)) ? 1 : 0); return Flow::Next; + case 0x2B: SET_GPR_U64(ctx, f.rd, ((uint64_t)GPR_U64(ctx, f.rs) < (uint64_t)GPR_U64(ctx, f.rt)) ? 1 : 0); return Flow::Next; + case 0x2C: // DADD + { + const int64_t a = GPR_S64(ctx, f.rs), b = GPR_S64(ctx, f.rt), r = (int64_t)((uint64_t)a + (uint64_t)b); + if (((a ^ b) >= 0) && ((a ^ r) < 0)) + runtime->SignalException(ctx, EXCEPTION_INTEGER_OVERFLOW); + else + SET_GPR_S64(ctx, f.rd, r); + return Flow::Next; + } + case 0x2D: SET_GPR_U64(ctx, f.rd, (uint64_t)GPR_U64(ctx, f.rs) + (uint64_t)GPR_U64(ctx, f.rt)); return Flow::Next; + case 0x2E: // DSUB + { + const int64_t a = GPR_S64(ctx, f.rs), b = GPR_S64(ctx, f.rt), r = (int64_t)((uint64_t)a - (uint64_t)b); + if (((a ^ b) < 0) && ((a ^ r) < 0)) + runtime->SignalException(ctx, EXCEPTION_INTEGER_OVERFLOW); + else + SET_GPR_S64(ctx, f.rd, r); + return Flow::Next; + } + case 0x2F: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rs) - GPR_U64(ctx, f.rt)); return Flow::Next; + case 0x30: if (GPR_S64(ctx, f.rs) >= GPR_S64(ctx, f.rt)) runtime->handleTrap(rdram, ctx); return Flow::Next; + case 0x31: if (GPR_U64(ctx, f.rs) >= GPR_U64(ctx, f.rt)) runtime->handleTrap(rdram, ctx); return Flow::Next; + case 0x32: if (GPR_S64(ctx, f.rs) < GPR_S64(ctx, f.rt)) runtime->handleTrap(rdram, ctx); return Flow::Next; + case 0x33: if (GPR_U64(ctx, f.rs) < GPR_U64(ctx, f.rt)) runtime->handleTrap(rdram, ctx); return Flow::Next; + case 0x34: if (GPR_U64(ctx, f.rs) == GPR_U64(ctx, f.rt)) runtime->handleTrap(rdram, ctx); return Flow::Next; + case 0x36: if (GPR_U64(ctx, f.rs) != GPR_U64(ctx, f.rt)) runtime->handleTrap(rdram, ctx); return Flow::Next; + case 0x38: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rt) << f.sa); return Flow::Next; + case 0x3A: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rt) >> f.sa); return Flow::Next; + case 0x3B: SET_GPR_S64(ctx, f.rd, GPR_S64(ctx, f.rt) >> f.sa); return Flow::Next; + case 0x3C: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rt) << (32 + f.sa)); return Flow::Next; + case 0x3E: SET_GPR_U64(ctx, f.rd, GPR_U64(ctx, f.rt) >> (32 + f.sa)); return Flow::Next; + case 0x3F: SET_GPR_S64(ctx, f.rd, GPR_S64(ctx, f.rt) >> (32 + f.sa)); return Flow::Next; + default: + reportUnimplemented(pc, f.word, "SPECIAL"); + return Flow::Next; + } + + case 0x08: // ADDI + { + const int64_t result = (int64_t)GPR_S32(ctx, f.rs) + (int64_t)f.simm; + if (result > INT32_MAX || result < INT32_MIN) + runtime->SignalException(ctx, EXCEPTION_INTEGER_OVERFLOW); + else + SET_GPR_S32(ctx, f.rt, (int32_t)result); + return Flow::Next; + } + case 0x09: SET_GPR_S32(ctx, f.rt, (int32_t)ADD32(GPR_U32(ctx, f.rs), f.simm)); return Flow::Next; + case 0x0A: SET_GPR_U64(ctx, f.rt, ((int64_t)GPR_S64(ctx, f.rs) < (int64_t)(int32_t)f.simm) ? 1 : 0); return Flow::Next; + case 0x0B: SET_GPR_U64(ctx, f.rt, ((uint64_t)GPR_U64(ctx, f.rs) < (uint64_t)(int64_t)(int32_t)f.simm) ? 1 : 0); return Flow::Next; + case 0x0C: SET_GPR_U64(ctx, f.rt, GPR_U64(ctx, f.rs) & (uint64_t)f.uimm); return Flow::Next; + case 0x0D: SET_GPR_U64(ctx, f.rt, GPR_U64(ctx, f.rs) | (uint64_t)f.uimm); return Flow::Next; + case 0x0E: SET_GPR_U64(ctx, f.rt, GPR_U64(ctx, f.rs) ^ (uint64_t)f.uimm); return Flow::Next; + case 0x0F: SET_GPR_S32(ctx, f.rt, (int32_t)(f.uimm << 16)); return Flow::Next; + + case 0x10: // COP0 + if (f.rs == 0x00) // MFC0 + { + if (f.rd == 9) + SET_GPR_S32(ctx, f.rt, (int32_t)runtime->readCop0Count(ctx)); + else if (f.rd == 12) + SET_GPR_S32(ctx, f.rt, (int32_t)ctx->cop0_status); + else if (f.rd == 13) + SET_GPR_S32(ctx, f.rt, (int32_t)ctx->cop0_cause); + else if (f.rd == 14) + SET_GPR_S32(ctx, f.rt, (int32_t)ctx->cop0_epc); + else + reportUnimplemented(pc, f.word, "MFC0"); + return Flow::Next; + } + if (f.rs == 0x04) // MTC0 + { + if (f.rd == 9) + runtime->writeCop0Count(ctx, GPR_U32(ctx, f.rt)); + else if (f.rd == 12) + ctx->cop0_status = GPR_U32(ctx, f.rt) & 0xFF57FFFF; + else + reportUnimplemented(pc, f.word, "MTC0"); + return Flow::Next; + } + if (f.rs == 0x10 && f.funct == 0x38) // EI + { + ctx->cop0_status |= 0x10000; + return Flow::Next; + } + if (f.rs == 0x10 && f.funct == 0x39) // DI + { + ctx->cop0_status &= ~0x10000u; + return Flow::Next; + } + reportUnimplemented(pc, f.word, "COP0"); + return Flow::Next; + + case 0x11: // COP1 + switch (f.rs) + { + case 0x00: { uint32_t bits; std::memcpy(&bits, &ctx->f[f.rd], sizeof(bits)); SET_GPR_U32(ctx, f.rt, bits); return Flow::Next; } + case 0x02: SET_GPR_U32(ctx, f.rt, f.rd == 31 ? ctx->fcr31 : 0u); return Flow::Next; // CFC1 + case 0x04: { uint32_t bits = GPR_U32(ctx, f.rt); std::memcpy(&ctx->f[f.rd], &bits, sizeof(bits)); return Flow::Next; } + case 0x06: if (f.rd == 31) ctx->fcr31 = GPR_U32(ctx, f.rt) & 0x0183FFFF; return Flow::Next; // CTC1 + case 0x10: // S format: fs = rd field, ft = rt field, fd = sa field + { + const uint32_t fs = f.rd, ft = f.rt, fd = f.sa; + auto compare = [&](bool result) { ctx->fcr31 = result ? (ctx->fcr31 | 0x800000) : (ctx->fcr31 & ~0x800000u); }; + switch (f.funct) + { + case 0x00: ctx->f[fd] = FPU_ADD_S(ctx->f[fs], ctx->f[ft]); return Flow::Next; + case 0x01: ctx->f[fd] = FPU_SUB_S(ctx->f[fs], ctx->f[ft]); return Flow::Next; + case 0x02: ctx->f[fd] = FPU_MUL_S(ctx->f[fs], ctx->f[ft]); return Flow::Next; + case 0x03: + if (ctx->f[ft] == 0.0f) + ctx->fcr31 |= 0x100000; + ctx->f[fd] = FPU_DIV_S(ctx->f[fs], ctx->f[ft]); + return Flow::Next; + case 0x04: ctx->f[fd] = FPU_SQRT_S(ctx->f[ft]); return Flow::Next; + case 0x05: ctx->f[fd] = FPU_ABS_S(ctx->f[fs]); return Flow::Next; + case 0x06: ctx->f[fd] = FPU_MOV_S(ctx->f[fs]); return Flow::Next; + case 0x07: ctx->f[fd] = FPU_NEG_S(ctx->f[fs]); return Flow::Next; + case 0x16: ctx->f[fd] = FPU_RSQRT_S(ctx->f[fs], ctx->f[ft]); return Flow::Next; + case 0x18: FPU_SET_ACC(ctx, FPU_ADD_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x19: FPU_SET_ACC(ctx, FPU_SUB_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x1A: FPU_SET_ACC(ctx, FPU_MUL_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x1C: ctx->f[fd] = FPU_ADD_S(ctx->f_acc, FPU_MUL_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x1D: ctx->f[fd] = FPU_SUB_S(ctx->f_acc, FPU_MUL_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x1E: FPU_SET_ACC(ctx, FPU_ADD_S(ctx->f_acc, FPU_MUL_S(ctx->f[fs], ctx->f[ft]))); return Flow::Next; + case 0x1F: FPU_SET_ACC(ctx, FPU_SUB_S(ctx->f_acc, FPU_MUL_S(ctx->f[fs], ctx->f[ft]))); return Flow::Next; + case 0x24: { int32_t tmp = FPU_CVT_W_S(ctx->f[fs]); std::memcpy(&ctx->f[fd], &tmp, sizeof(tmp)); return Flow::Next; } + case 0x28: ctx->f[fd] = std::max(ctx->f[fs], ctx->f[ft]); return Flow::Next; + case 0x29: ctx->f[fd] = std::min(ctx->f[fs], ctx->f[ft]); return Flow::Next; + case 0x30: ctx->fcr31 &= ~0x800000u; return Flow::Next; + case 0x32: compare(FPU_C_EQ_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x34: compare(FPU_C_OLT_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x36: compare(FPU_C_OLE_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x3C: compare(FPU_C_LT_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + case 0x3E: compare(FPU_C_LE_S(ctx->f[fs], ctx->f[ft])); return Flow::Next; + default: + reportUnimplemented(pc, f.word, "COP1.S"); + return Flow::Next; + } + } + case 0x14: // W format + if (f.funct == 0x20) + { + int32_t tmp; + std::memcpy(&tmp, &ctx->f[f.rd], sizeof(tmp)); + ctx->f[f.sa] = FPU_CVT_S_W(tmp); + return Flow::Next; + } + reportUnimplemented(pc, f.word, "COP1.W"); + return Flow::Next; + default: + reportUnimplemented(pc, f.word, "COP1"); + return Flow::Next; + } + + case 0x18: // DADDI + { + const int64_t a = GPR_S64(ctx, f.rs), b = f.simm, r = (int64_t)((uint64_t)a + (uint64_t)b); + if (((a ^ b) >= 0) && ((a ^ r) < 0)) + runtime->SignalException(ctx, EXCEPTION_INTEGER_OVERFLOW); + else + SET_GPR_S64(ctx, f.rt, r); + return Flow::Next; + } + case 0x19: SET_GPR_S64(ctx, f.rt, GPR_S64(ctx, f.rs) + (int64_t)f.simm); return Flow::Next; // DADDIU + + case 0x1C: // MMI (the subset compilers emit for scalar code) + switch (f.funct) + { + case 0x10: SET_GPR_U64(ctx, f.rd, ctx->hi1); return Flow::Next; + case 0x11: ctx->hi1 = GPR_U64(ctx, f.rs); return Flow::Next; + case 0x12: SET_GPR_U64(ctx, f.rd, ctx->lo1); return Flow::Next; + case 0x13: ctx->lo1 = GPR_U64(ctx, f.rs); return Flow::Next; + case 0x18: // MULT1 + { + const int64_t result = (int64_t)GPR_S32(ctx, f.rs) * (int64_t)GPR_S32(ctx, f.rt); + ctx->lo1 = (uint64_t)(int64_t)(int32_t)result; + ctx->hi1 = (uint64_t)(int64_t)(int32_t)(result >> 32); + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x19: // MULTU1 + { + const uint64_t result = (uint64_t)GPR_U32(ctx, f.rs) * (uint64_t)GPR_U32(ctx, f.rt); + ctx->lo1 = (uint64_t)(int64_t)(int32_t)result; + ctx->hi1 = (uint64_t)(int64_t)(int32_t)(result >> 32); + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x00: // MADD + { + const uint64_t acc = Ps2HiLoToU64(ctx->hi, ctx->lo); + const int64_t result = (int64_t)acc + (int64_t)GPR_S32(ctx, f.rs) * (int64_t)GPR_S32(ctx, f.rt); + ctx->lo = Ps2SignExt32ToU64((uint32_t)result); + ctx->hi = Ps2SignExt32ToU64((uint32_t)(result >> 32)); + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x01: // MADDU + { + const uint64_t acc = Ps2HiLoToU64(ctx->hi, ctx->lo); + const uint64_t result = acc + (uint64_t)GPR_U32(ctx, f.rs) * (uint64_t)GPR_U32(ctx, f.rt); + ctx->lo = Ps2SignExt32ToU64((uint32_t)result); + ctx->hi = Ps2SignExt32ToU64((uint32_t)(result >> 32)); + SET_GPR_S32(ctx, f.rd, (int32_t)result); + return Flow::Next; + } + case 0x09: // MMI2 + if (f.sa == 0x0E) { SET_GPR_VEC(ctx, f.rd, PS2_PCPYLD(GPR_VEC(ctx, f.rs), GPR_VEC(ctx, f.rt))); return Flow::Next; } + if (f.sa == 0x12) { SET_GPR_VEC(ctx, f.rd, PS2_PAND(GPR_VEC(ctx, f.rs), GPR_VEC(ctx, f.rt))); return Flow::Next; } + if (f.sa == 0x13) { SET_GPR_VEC(ctx, f.rd, PS2_PXOR(GPR_VEC(ctx, f.rs), GPR_VEC(ctx, f.rt))); return Flow::Next; } + break; + case 0x29: // MMI3 + if (f.sa == 0x0E) { SET_GPR_VEC(ctx, f.rd, _mm_unpackhi_epi64(GPR_VEC(ctx, f.rs), GPR_VEC(ctx, f.rt))); return Flow::Next; } // PCPYUD + if (f.sa == 0x12) { SET_GPR_VEC(ctx, f.rd, PS2_POR(GPR_VEC(ctx, f.rs), GPR_VEC(ctx, f.rt))); return Flow::Next; } + if (f.sa == 0x13) { SET_GPR_VEC(ctx, f.rd, PS2_PNOR(GPR_VEC(ctx, f.rs), GPR_VEC(ctx, f.rt))); return Flow::Next; } + break; + default: + break; + } + reportUnimplemented(pc, f.word, "MMI"); + return Flow::Next; + + case 0x1A: // LDL + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~7u, offset = addr & 7u; + const uint64_t mem = READ64(aligned); + const uint32_t shift = (7u - offset) << 3; + const uint64_t keepMask = (shift == 0) ? 0ull : ((1ull << shift) - 1ull); + SET_GPR_U64(ctx, f.rt, (GPR_U64(ctx, f.rt) & keepMask) | (mem << shift)); + return Flow::Next; + } + case 0x1B: // LDR + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~7u, offset = addr & 7u; + const uint64_t mem = READ64(aligned); + const uint32_t shift = offset << 3; + const uint64_t keepMask = (offset == 0) ? 0ull : (0xFFFFFFFFFFFFFFFFull << ((8u - offset) << 3)); + SET_GPR_U64(ctx, f.rt, (GPR_U64(ctx, f.rt) & keepMask) | (mem >> shift)); + return Flow::Next; + } + case 0x1E: SET_GPR_VEC(ctx, f.rt, READ128(ADD32(GPR_U32(ctx, f.rs), f.simm) & ~0xFu)); return Flow::Next; // LQ + case 0x1F: WRITE128(ADD32(GPR_U32(ctx, f.rs), f.simm) & ~0xFu, GPR_VEC(ctx, f.rt)); return Flow::Next; // SQ + case 0x20: SET_GPR_S32(ctx, f.rt, (int32_t)(int8_t)READ8(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; + case 0x21: SET_GPR_S32(ctx, f.rt, (int32_t)(int16_t)READ16(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; + case 0x22: // LWL + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~3u, offset = addr & 3u; + const uint32_t mem = READ32(aligned); + const uint32_t shift = (3u - offset) << 3; + const uint32_t keepMask = (shift == 0) ? 0u : ((1u << shift) - 1u); + SET_GPR_S32(ctx, f.rt, (int32_t)((GPR_U32(ctx, f.rt) & keepMask) | (mem << shift))); + return Flow::Next; + } + case 0x23: SET_GPR_S32(ctx, f.rt, (int32_t)READ32(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; + case 0x24: SET_GPR_U32(ctx, f.rt, (uint32_t)READ8(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; + case 0x25: SET_GPR_U32(ctx, f.rt, (uint32_t)READ16(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; + case 0x26: // LWR + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~3u, offset = addr & 3u; + const uint32_t mem = READ32(aligned); + const uint32_t shift = offset << 3; + const uint32_t keepMask = (offset == 0) ? 0u : (0xFFFFFFFFu << ((4u - offset) << 3)); + const uint32_t merged32 = (GPR_U32(ctx, f.rt) & keepMask) | (mem >> shift); + uint64_t merged64 = (GPR_U64(ctx, f.rt) & 0xFFFFFFFF00000000ull) | (uint64_t)merged32; + if (offset == 0) + merged64 = (uint64_t)(int64_t)(int32_t)merged32; + SET_GPR_U64(ctx, f.rt, merged64); + return Flow::Next; + } + case 0x27: SET_GPR_ZE32(ctx, f.rt, READ32(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; // LWU + case 0x28: WRITE8(ADD32(GPR_U32(ctx, f.rs), f.simm), (uint8_t)GPR_U32(ctx, f.rt)); return Flow::Next; + case 0x29: WRITE16(ADD32(GPR_U32(ctx, f.rs), f.simm), (uint16_t)GPR_U32(ctx, f.rt)); return Flow::Next; + case 0x2A: // SWL + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~3u, offset = addr & 3u; + const uint32_t shift = (3u - offset) << 3; + const uint32_t mask = 0xFFFFFFFFu >> shift; + const uint32_t oldData = READ32(aligned); + const uint32_t newData = (oldData & ~mask) | ((GPR_U32(ctx, f.rt) >> shift) & mask); + WRITE32(aligned, newData); + return Flow::Next; + } + case 0x2B: WRITE32(ADD32(GPR_U32(ctx, f.rs), f.simm), GPR_U32(ctx, f.rt)); return Flow::Next; + case 0x2C: // SDL + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~7u, offset = addr & 7u; + const uint32_t shift = (7u - offset) << 3; + const uint64_t mask = 0xFFFFFFFFFFFFFFFFull >> shift; + const uint64_t oldData = READ64(aligned); + const uint64_t newData = (oldData & ~mask) | ((GPR_U64(ctx, f.rt) >> shift) & mask); + WRITE64(aligned, newData); + return Flow::Next; + } + case 0x2D: // SDR + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~7u, offset = addr & 7u; + const uint32_t shift = offset << 3; + const uint64_t mask = 0xFFFFFFFFFFFFFFFFull << shift; + const uint64_t oldData = READ64(aligned); + const uint64_t newData = (oldData & ~mask) | ((GPR_U64(ctx, f.rt) << shift) & mask); + WRITE64(aligned, newData); + return Flow::Next; + } + case 0x2E: // SWR + { + const uint32_t addr = ADD32(GPR_U32(ctx, f.rs), f.simm), aligned = addr & ~3u, offset = addr & 3u; + const uint32_t shift = offset << 3; + const uint32_t mask = 0xFFFFFFFFu << shift; + const uint32_t oldData = READ32(aligned); + const uint32_t newData = (oldData & ~mask) | ((GPR_U32(ctx, f.rt) << shift) & mask); + WRITE32(aligned, newData); + return Flow::Next; + } + case 0x2F: return Flow::Next; // CACHE + case 0x31: // LWC1 + { + const uint32_t bits = READ32(ADD32(GPR_U32(ctx, f.rs), f.simm)); + std::memcpy(&ctx->f[f.rt], &bits, sizeof(bits)); + return Flow::Next; + } + case 0x33: return Flow::Next; // PREF + case 0x37: SET_GPR_U64(ctx, f.rt, READ64(ADD32(GPR_U32(ctx, f.rs), f.simm))); return Flow::Next; // LD + case 0x39: // SWC1 + { + uint32_t bits; + std::memcpy(&bits, &ctx->f[f.rt], sizeof(bits)); + WRITE32(ADD32(GPR_U32(ctx, f.rs), f.simm), bits); + return Flow::Next; + } + case 0x3F: WRITE64(ADD32(GPR_U32(ctx, f.rs), f.simm), GPR_U64(ctx, f.rt)); return Flow::Next; // SD + default: + reportUnimplemented(pc, f.word, "opcode"); + return Flow::Next; + } + reportUnimplemented(pc, f.word, "instruction"); + return Flow::Next; + } + + struct BranchInfo + { + bool taken = false; + bool likely = false; // delay slot only runs when taken + bool link = false; + uint32_t linkReg = 31; + bool call = false; + uint32_t target = 0; + }; + + // Decodes a control transfer; registers are read before the delay slot. + BranchInfo evaluateBranch(R5900Context *ctx, uint32_t pc, const Fields &f) + { + BranchInfo b; + const uint32_t op = f.word >> 26; + const uint32_t branchTarget = pc + 4u + (static_cast(f.simm) << 2); + switch (op) + { + case 0x00: + b.taken = true; + b.target = GPR_U32(ctx, f.rs); + if (f.funct == 0x09) // JALR + { + b.link = true; + b.linkReg = f.rd; + b.call = true; + } + return b; + case 0x01: + { + const int64_t value = GPR_S64(ctx, f.rs); + const bool lessThanZero = value < 0; + b.target = branchTarget; + switch (f.rt) + { + case 0x00: b.taken = lessThanZero; break; + case 0x01: b.taken = !lessThanZero; break; + case 0x02: b.taken = lessThanZero; b.likely = true; break; + case 0x03: b.taken = !lessThanZero; b.likely = true; break; + case 0x10: b.taken = lessThanZero; b.link = true; b.call = true; break; + case 0x11: b.taken = !lessThanZero; b.link = true; b.call = true; break; + case 0x12: b.taken = lessThanZero; b.link = true; b.call = true; b.likely = true; break; + case 0x13: b.taken = !lessThanZero; b.link = true; b.call = true; b.likely = true; break; + } + return b; + } + case 0x02: + case 0x03: + b.taken = true; + b.target = ((pc + 4u) & 0xF0000000u) | ((f.word & 0x03FFFFFFu) << 2); + b.link = b.call = (op == 0x03); + return b; + case 0x04: case 0x14: b.taken = GPR_U64(ctx, f.rs) == GPR_U64(ctx, f.rt); break; + case 0x05: case 0x15: b.taken = GPR_U64(ctx, f.rs) != GPR_U64(ctx, f.rt); break; + case 0x06: case 0x16: b.taken = GPR_S64(ctx, f.rs) <= 0; break; + case 0x07: case 0x17: b.taken = GPR_S64(ctx, f.rs) > 0; break; + case 0x11: // BC1F/BC1T/BC1FL/BC1TL + { + const bool condition = (ctx->fcr31 & 0x800000) != 0; + b.taken = (f.rt & 1u) ? condition : !condition; + b.likely = (f.rt & 2u) != 0; + b.target = branchTarget; + return b; + } + } + b.target = branchTarget; + b.likely = op >= 0x14; + return b; + } +} + +void ps2InterpretGuestCode(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) +{ + uint32_t pc = ctx->pc; + uint32_t sinceCheckpoint = 0; + for (;;) + { + // Control reached code that has a recompiled function, or left the + // RAM this interpreter runs: let the caller (or the scheduler) + // continue there. + if (ps2HasRecompiledFunction(pc) || !runtime->canRunGuestCode(pc)) + { + ctx->pc = pc; + return; + } + if (++sinceCheckpoint >= kCheckpointInterval) + { + sinceCheckpoint = 0; + ctx->pc = pc; + if (runtime->eeCheckpointDue(kCheckpointInterval)) + return; + } + + ctx->pc = pc; + const Fields f = decode(READ32(pc)); + if (!isControlTransfer(f.word)) + { + if (executeSimple(rdram, ctx, runtime, pc, f) == Flow::Stop) + return; + pc += 4u; + continue; + } + + const BranchInfo b = evaluateBranch(ctx, pc, f); + if (b.link) + SET_GPR_U32(ctx, b.linkReg, pc + 8u); + if (b.taken || !b.likely) + { + const uint32_t delaySlotPc = pc + 4u; + const Fields slot = decode(READ32(delaySlotPc)); + ctx->pc = delaySlotPc; + ctx->in_delay_slot = true; + ctx->branch_pc = pc; + if (isControlTransfer(slot.word)) + reportUnimplemented(delaySlotPc, slot.word, "branch in delay slot"); + else + executeSimple(rdram, ctx, runtime, delaySlotPc, slot); + ctx->in_delay_slot = false; + } + if (!b.taken) + { + pc += 8u; + continue; + } + + if (b.call && ps2HasRecompiledFunction(b.target)) + { + const PS2Runtime::GuestBranchKind kind = (f.word >> 26) == 0x00 ? PS2Runtime::GuestBranchKind::IndirectCall + : PS2Runtime::GuestBranchKind::DirectCall; + if (!runtime->dispatchGuestBranch(rdram, ctx, b.target, pc, pc + 8u, kind, "interpreter")) + return; + pc = pc + 8u; + continue; + } + pc = b.target; + } +} diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index 36b298f6b7..0b53bdc082 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -1,4 +1,5 @@ #include "ps2_runtime.h" +#include "runtime/ps2_ee_interpreter.h" #include "ps2_log.h" #include "ps2_stubs.h" #include "ps2_syscalls.h" @@ -1131,12 +1132,35 @@ bool PS2Runtime::registerFunction(uint32_t address, RecompiledFunction func) return replaceFunction(address, func); } -bool PS2Runtime::hasFunction(uint32_t address) const +bool ps2HasRecompiledFunction(uint32_t address) { uint32_t slot = 0u; return generatedFunctionTableSlot(address, slot) && g_ps2RecompiledFunctionTable[slot] != nullptr; } +// Code the game places in RAM at run time (e.g. modules linked from data +// files) has no recompiled function; such addresses run in the interpreter. +// PS2X_NO_INTERPRETER=1 disables this (missing-function reports instead). +static bool isInterpretableGuestCode(const PS2Memory &memory, uint32_t address) +{ + // User RAM only: below 1 MB is the kernel's, and kernel-segment + // addresses are where interrupt handlers return (the scheduler ends an + // invocation when it reaches an address without a function). + static const bool disabled = std::getenv("PS2X_NO_INTERPRETER") != nullptr; + return !disabled && (address & 3u) == 0u && address >= 0x00100000u && address < PS2_RAM_SIZE && + !memory.isCodeAddress(address); +} + +bool PS2Runtime::hasFunction(uint32_t address) const +{ + return ps2HasRecompiledFunction(address); +} + +bool PS2Runtime::canRunGuestCode(uint32_t address) const +{ + return ps2HasRecompiledFunction(address) || isInterpretableGuestCode(m_memory, address); +} + const char *describeGuestBranchKind(PS2Runtime::GuestBranchKind kind) { switch (kind) @@ -1170,6 +1194,18 @@ PS2Runtime::RecompiledFunction PS2Runtime::lookupFunction(uint32_t address) } } + if (isInterpretableGuestCode(m_memory, address)) + { + static bool announced = false; + if (!announced) + { + announced = true; + std::cerr << "[ee-interp] running guest code at 0x" << std::hex << address << std::dec + << " in the interpreter (no recompiled function)" << std::endl; + } + return &ps2InterpretGuestCode; + } + std::cerr << "Error: No exact recompiled function for guest PC 0x" << std::hex << address << " tableBase=0x" << g_ps2RecompiledFunctionTableBase << " tableEnd=0x" << g_ps2RecompiledFunctionTableEnd @@ -1396,7 +1432,7 @@ bool PS2Runtime::dispatchGuestBranch(uint8_t *rdram, if (!isCall) { - if (!hasFunction(targetPc)) + if (!canRunGuestCode(targetPc)) { reportMissingFunction(rdram, ctx, targetPc, sourcePc, kind, debugName); } @@ -1405,7 +1441,7 @@ bool PS2Runtime::dispatchGuestBranch(uint8_t *rdram, return unwindToScheduler(); } - if (!hasFunction(targetPc)) + if (!canRunGuestCode(targetPc)) { reportMissingFunction(rdram, ctx, targetPc, sourcePc, kind, debugName); diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index 12ee21a593..31369c8d7c 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -442,6 +442,32 @@ void register_ps2_runtime_expansion_tests() t.Equals(ctx.pc, 0x3000u, "the scheduler should resume at the inner loop's target"); }); + tc.Run("calls into RAM code without a recompiled function run in the EE interpreter", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "PS2Memory initialize should succeed"); + uint8_t *rdram = runtime.memory().getRDRAM(); + + // A routine the game would have loaded or built in RAM at run time. + constexpr uint32_t kCode = 0x00200000u; + const uint32_t code[] = { + 0x24020005u, // addiu v0, zero, 5 + 0x24420007u, // addiu v0, v0, 7 + 0x03E00008u, // jr ra + 0x00000000u, // nop + }; + std::memcpy(rdram + kCode, code, sizeof(code)); + + R5900Context ctx{}; + setRegU32(ctx, 31, 0x2008u); + t.IsTrue(runtime.canRunGuestCode(kCode), "user RAM outside the code regions should be runnable"); + t.IsFalse(runtime.hasFunction(kCode), "hasFunction should still mean a recompiled function"); + const bool returned = runtime.dispatchGuestBranch( + rdram, &ctx, kCode, 0x2000u, 0x2008u, PS2Runtime::GuestBranchKind::DirectCall, "test-jal"); + t.IsTrue(returned, "the interpreted routine should return to its caller"); + t.Equals(::getRegU32(&ctx, 2), 12u, "the interpreter should run the routine"); + }); + tc.Run("dispatchGuestBranch jump returns to central dispatcher without nesting", [](TestCase &t) { PS2Runtime runtime;