From e05101e7a2463a06fcee53f6523acd900286eab9 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Wed, 12 Aug 2026 23:14:15 -0300 Subject: [PATCH 01/29] test(input): pin PadRead port0-low-half layout against retail PadUpdate disasm --- ps1Test/runtime/test_psyq_pad.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ps1Test/runtime/test_psyq_pad.cpp b/ps1Test/runtime/test_psyq_pad.cpp index 32d9e68..3a1cc21 100644 --- a/ps1Test/runtime/test_psyq_pad.cpp +++ b/ps1Test/runtime/test_psyq_pad.cpp @@ -97,6 +97,32 @@ TEST_F(PsyqPadTest, PadReadPressedBitsAreCleared) { EXPECT_EQ(ctx.r[V0], expected); } +// Ground truth verified directly against the Crash Bandicoot (SCUS-94900) +// retail binary: `PadUpdate` at VA 0x800167A4 loops the port index in $s1 and +// picks the half with `bnez $s1, 0x80016818` before a delay-slot `srl +// $v1,$v0,0x10` -- port 0 (s1==0) falls through to `andi $v1,$v0,0xffff` +// (low half), port 1 (s1!=0) keeps the `srl` result (high half). This +// matches CRASH_BANDICOOT_RECOMP.md Sec 2.1's account of the reference +// implementation's "wrong halfword" trap (port 0 must be the low 16 bits), +// and confirms our packing already has it right -- this test pins that so a +// future change to hle_libetc_PadRead can't silently swap the halves. +TEST_F(PsyqPadTest, PadReadPort0IsLowHalfPerRetailPadUpdateDisassembly) { + input.press(input::BTN_CROSS, 0); // port 0 (low half) + input.press(input::BTN_SQUARE, 1); // port 1 (high half) + hle_libetc_PadRead(&ctx); + + uint16_t lowHalf = static_cast(ctx.r[V0] & 0xFFFFu); + uint16_t highHalf = static_cast((ctx.r[V0] >> 16) & 0xFFFFu); + + // CROSS (port 0) must land in the low half, not the high half. + EXPECT_EQ(lowHalf, static_cast(0xFFFFu & ~input::BTN_CROSS)); + EXPECT_NE(highHalf, static_cast(0xFFFFu & ~input::BTN_CROSS)); + + // SQUARE (port 1) must land in the high half, not the low half. + EXPECT_EQ(highHalf, static_cast(0xFFFFu & ~input::BTN_SQUARE)); + EXPECT_NE(lowHalf, static_cast(0xFFFFu & ~input::BTN_SQUARE)); +} + TEST_F(PsyqPadTest, PadReadReflectsRelease) { input.press(input::BTN_CIRCLE, 0); hle_libetc_PadRead(&ctx); From 6d32c9ce08ede65260ddaf668cb8c3fa1e02151c Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Thu, 13 Aug 2026 11:25:07 -0300 Subject: [PATCH 02/29] feat(analyzer): detect computed jump-array slots as entry points --- .../include/ps1recomp/function_finder.h | 16 ++ ps1Analyzer/src/config_generator.cpp | 1 + ps1Analyzer/src/function_finder.cpp | 176 +++++++++++++++++- ps1Test/analyzer/test_function_finder.cpp | 129 +++++++++++++ 4 files changed, 319 insertions(+), 3 deletions(-) diff --git a/ps1Analyzer/include/ps1recomp/function_finder.h b/ps1Analyzer/include/ps1recomp/function_finder.h index 72fcea0..e0d3aab 100644 --- a/ps1Analyzer/include/ps1recomp/function_finder.h +++ b/ps1Analyzer/include/ps1recomp/function_finder.h @@ -24,11 +24,16 @@ constexpr uint32_t OP_BEQ = 0x04; constexpr uint32_t OP_BNE = 0x05; constexpr uint32_t OP_BLEZ = 0x06; constexpr uint32_t OP_BGTZ = 0x07; +constexpr uint32_t OP_ADDI = 0x08; constexpr uint32_t OP_ADDIU = 0x09; +constexpr uint32_t OP_LUI = 0x0F; // SPECIAL function codes (bits 5-0) +constexpr uint32_t FUNC_SLL = 0x00; constexpr uint32_t FUNC_JR = 0x08; constexpr uint32_t FUNC_JALR = 0x09; +constexpr uint32_t FUNC_ADDU = 0x21; +constexpr uint32_t FUNC_SUBU = 0x23; // Register numbers constexpr uint32_t REG_SP = 29; @@ -39,6 +44,7 @@ inline uint32_t getOpcode(uint32_t instr) { return (instr >> 26) & 0x3F; } inline uint32_t getRs(uint32_t instr) { return (instr >> 21) & 0x1F; } inline uint32_t getRt(uint32_t instr) { return (instr >> 16) & 0x1F; } inline uint32_t getRd(uint32_t instr) { return (instr >> 11) & 0x1F; } +inline uint32_t getShamt(uint32_t instr) { return (instr >> 6) & 0x1F; } inline uint32_t getFunction(uint32_t instr) { return instr & 0x3F; } inline int16_t getImm16(uint32_t instr) { return static_cast(instr & 0xFFFF); @@ -92,6 +98,14 @@ inline bool isBranch(uint32_t instr) { op == OP_REGIMM; } +/// Does `instr` carry a branch delay slot? True for every control transfer: +/// the word that follows one is never a function entry point, it is that +/// instruction's delay slot. +inline bool hasDelaySlot(uint32_t instr) { + return isBranch(instr) || isJ(instr) || isJAL(instr) || isJR(instr) || + (getOpcode(instr) == OP_SPECIAL && getFunction(instr) == FUNC_JALR); +} + /// Compute a PC-relative branch target: PC + 4 + (signed imm16 << 2) inline uint32_t branchTarget(uint32_t pc, uint32_t instr) { return pc + 4 + (static_cast(static_cast(getImm16(instr))) @@ -143,6 +157,7 @@ enum class FunctionSource { Symbol, // From ELF symbol table (STT_FUNC) JALTarget, // Target of a JAL instruction Prologue, // Detected by ADDIU $sp, $sp, -N pattern + JumpArray, // Slot of a computed jump into an array of fixed-size bodies }; // FunctionInfo @@ -215,6 +230,7 @@ class FunctionFinder { void addSymbolFunctions(const ElfParser &elf); void scanJALTargets(const Section &text); void scanPrologues(const Section &text); + void scanJumpArrays(const Section &text); void computeBoundaries(const Section &text); // Helpers diff --git a/ps1Analyzer/src/config_generator.cpp b/ps1Analyzer/src/config_generator.cpp index 2151a86..1be1f7a 100644 --- a/ps1Analyzer/src/config_generator.cpp +++ b/ps1Analyzer/src/config_generator.cpp @@ -87,6 +87,7 @@ static toml::value buildConfig(const ElfParser& elf, case FunctionSource::EntryPoint: f["source"] = "entry_point"; break; case FunctionSource::JALTarget: f["source"] = "jal_target"; break; case FunctionSource::Prologue: f["source"] = "prologue"; break; + case FunctionSource::JumpArray: f["source"] = "jump_array"; break; default: f["source"] = "heuristic"; break; } diff --git a/ps1Analyzer/src/function_finder.cpp b/ps1Analyzer/src/function_finder.cpp index f57102d..e01833b 100644 --- a/ps1Analyzer/src/function_finder.cpp +++ b/ps1Analyzer/src/function_finder.cpp @@ -120,14 +120,15 @@ void FunctionFinder::findFunctions(const ElfParser& elf) { // Pass 2: ELF symbol table addSymbolFunctions(elf); - // Pass 3 & 4: Heuristic scans on .text section + // Pass 3, 4 & 5: Heuristic scans on .text section const Section* text = elf.getTextSection(); if (text != nullptr && text->data != nullptr && text->size >= 4) { scanJALTargets(*text); scanPrologues(*text); + scanJumpArrays(*text); } - // Pass 5: Compute sizes from sorted addresses + // Pass 6: Compute sizes from sorted addresses if (text != nullptr) { computeBoundaries(*text); } @@ -217,7 +218,176 @@ void FunctionFinder::scanPrologues(const Section& text) { } } -// Pass 5: Compute Boundaries +// Pass 5: Computed Jump Arrays + +/// How far back the operands of the computed jump are traced. +static constexpr uint32_t kJumpArrayTraceWindow = 16; +/// A slot has to hold at least `jr $ra` and its delay slot to be a function. +static constexpr uint32_t kJumpArrayMinSlot = 8; +/// Above this a "slot size" is far likelier to be a mis-traced shift. +static constexpr uint32_t kJumpArrayMaxSlot = 1024; + +/// Register the slots of an array of bodies reached by a computed jump. +/// +/// A switch does not always dispatch through a table of pointers. The other +/// shape -- the one Crash's memcpy and its decompressor use -- computes the +/// target address arithmetically: +/// +/// lui $t, hi(base) +/// addiu $t, $t, lo(base) ; base is a .text address, not a table +/// sll $i, $idx, k ; scale the index by the slot size +/// addu $t, $t, $i +/// jr $t +/// +/// There is no table anywhere to read: the target is `base + idx * 2^k`, and +/// each slot is a body of its own. Nothing else in the binary points at those +/// bodies -- no `jal`, no stack prologue -- so without this pass the function +/// ahead of the array simply extends over all of them, and every branch that +/// lands in one becomes a dispatch to an address nobody emitted. +/// +/// What separates a slot that is a *function* from a slot that is a *label* is +/// whether it returns on its own. The same computed jump also builds jump +/// islands (`bgez $zero, far_label` + delay slot in each slot), and those +/// belong to the function around them. Requiring `jr $ra` inside the slot +/// keeps the islands out. +void FunctionFinder::scanJumpArrays(const Section& text) { + const uint32_t numInstructions = text.size / 4; + if (numInstructions < 4) { + return; + } + + auto wordAt = [&](uint32_t addr) { + return readInstruction(text, addr - text.vaddr); + }; + + // `reg` holds a LUI (+ ADDIU) constant at instruction `from`, walking back. + // Any other write to it first means the value is not a link-time constant. + auto traceConstant = [&](uint32_t reg, uint32_t from, uint32_t& out) { + int32_t addend = 0; + const uint32_t stop = + (from >= kJumpArrayTraceWindow) ? from - kJumpArrayTraceWindow : 0; + for (uint32_t j = from + 1; j-- > stop;) { + const uint32_t instr = readInstruction(text, j * 4); + if (!mips::writesRegister(instr, reg)) { + continue; + } + const uint32_t op = mips::getOpcode(instr); + if (op == mips::OP_LUI) { + out = ((instr & 0xFFFFu) << 16) + static_cast(addend); + return true; + } + if ((op == mips::OP_ADDIU || op == mips::OP_ADDI) && + mips::getRs(instr) == reg) { + addend = mips::getImm16(instr); + continue; + } + return false; + } + return false; + }; + + // `reg` is an index scaled by a left shift; the shift amount is the slot + // size, which is the whole point of the pattern. + auto traceShift = [&](uint32_t reg, uint32_t from, uint32_t& shamt) { + const uint32_t stop = + (from >= kJumpArrayTraceWindow) ? from - kJumpArrayTraceWindow : 0; + for (uint32_t j = from + 1; j-- > stop;) { + const uint32_t instr = readInstruction(text, j * 4); + if (!mips::writesRegister(instr, reg)) { + continue; + } + if (mips::getOpcode(instr) != mips::OP_SPECIAL || + mips::getFunction(instr) != mips::FUNC_SLL) { + return false; + } + shamt = mips::getShamt(instr); + return shamt > 0; + } + return false; + }; + + auto returnsWithin = [&](uint32_t addr, uint32_t slot) { + for (uint32_t off = 0; off + 4 <= slot; off += 4) { + if (!text.containsAddress(addr + off)) { + return false; + } + if (mips::isJR_RA(wordAt(addr + off))) { + return true; + } + } + return false; + }; + + const uint32_t textEnd = text.vaddr + text.size; + + for (uint32_t i = 1; i < numInstructions; ++i) { + const uint32_t jr = readInstruction(text, i * 4); + if (!mips::isJR(jr) || mips::getRs(jr) == mips::REG_RA) { + continue; + } + + // ADDU $target, $base, $scaled -- either operand may be the base. + const uint32_t targetReg = mips::getRs(jr); + const uint32_t stop = + (i >= kJumpArrayTraceWindow) ? i - kJumpArrayTraceWindow : 0; + uint32_t base = 0; + uint32_t shamt = 0; + bool matched = false; + for (uint32_t j = i; j-- > stop;) { + const uint32_t instr = readInstruction(text, j * 4); + if (!mips::writesRegister(instr, targetReg)) { + continue; + } + if (j == 0 || mips::getOpcode(instr) != mips::OP_SPECIAL || + mips::getFunction(instr) != mips::FUNC_ADDU) { + break; + } + const uint32_t lhs = mips::getRs(instr); + const uint32_t rhs = mips::getRt(instr); + matched = + (traceConstant(lhs, j - 1, base) && traceShift(rhs, j - 1, shamt)) || + (traceConstant(rhs, j - 1, base) && traceShift(lhs, j - 1, shamt)); + break; + } + if (!matched) { + continue; + } + + const uint32_t slot = 1u << shamt; + if (slot < kJumpArrayMinSlot || slot > kJumpArrayMaxSlot || + !text.containsAddress(base) || !returnsWithin(base, slot)) { + continue; + } + + // Never run past an entry point another pass already found: that one + // is better evidence than this arithmetic. + uint32_t limit = textEnd; + for (const auto& f : m_functions) { + if (f.address > base && f.address < limit) { + limit = f.address; + } + } + + for (uint32_t n = 0;; ++n) { + const uint32_t addr = base + n * slot; + if (addr >= limit || !text.containsAddress(addr)) { + break; + } + if (n > 0) { + // The array ends where a slot stops returning, and a slot that + // begins on a delay slot is the tail of the one before it. + if (!returnsWithin(addr - slot, slot) || + mips::hasDelaySlot(wordAt(addr - 4))) { + break; + } + } + addFunction(addr, fmt::format("func_{:08X}", addr), + FunctionSource::JumpArray); + } + } +} + +// Pass 6: Compute Boundaries void FunctionFinder::computeBoundaries(const Section& text) { // Sort functions by address diff --git a/ps1Test/analyzer/test_function_finder.cpp b/ps1Test/analyzer/test_function_finder.cpp index 6261afc..53abad9 100644 --- a/ps1Test/analyzer/test_function_finder.cpp +++ b/ps1Test/analyzer/test_function_finder.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include using namespace ps1recomp; @@ -36,6 +37,44 @@ static uint32_t makeADDIU(uint32_t rs, uint32_t rt, int16_t imm) { | (static_cast(imm) & 0xFFFF); } +static uint32_t makeLUI(uint32_t rt, uint16_t imm) { + return (mips::OP_LUI << 26) | (rt << 16) | imm; +} + +static uint32_t makeSLL(uint32_t rd, uint32_t rt, uint32_t shamt) { + return (mips::OP_SPECIAL << 26) | (rt << 16) | (rd << 11) | (shamt << 6) + | mips::FUNC_SLL; +} + +static uint32_t makeADDU(uint32_t rd, uint32_t rs, uint32_t rt) { + return (mips::OP_SPECIAL << 26) | (rs << 21) | (rt << 16) | (rd << 11) + | mips::FUNC_ADDU; +} + +static uint32_t makeJR(uint32_t rs) { + return (mips::OP_SPECIAL << 26) | (rs << 21) | mips::FUNC_JR; +} + +// BGEZ $zero, target -- an unconditional PC-relative branch +static uint32_t makeBGEZ(uint32_t pc, uint32_t target) { + const int16_t off = static_cast( + (static_cast(target) - static_cast(pc + 4)) / 4); + return (mips::OP_REGIMM << 26) | (1u << 16) + | (static_cast(off) & 0xFFFF); +} + +/// The dispatcher half of a computed jump into a code array: +/// lui $t0, hi(base) / addiu $t0, $t0, lo(base) / sll $t1, $a0, k / +/// addu $t0, $t0, $t1 / jr $t0 / nop +static void writeJumpArrayDispatch(std::vector& code, uint32_t base, + uint32_t shamt) { + code[0] = makeLUI(8, static_cast(base >> 16)); + code[1] = makeADDIU(8, 8, static_cast(base & 0xFFFF)); + code[2] = makeSLL(9, 4, shamt); + code[3] = makeADDU(8, 8, 9); + code[4] = makeJR(8); +} + // Helper: write LE 32-bit word to buffer static void writeLE32(std::vector& buf, uint32_t val) { buf.push_back(val & 0xFF); @@ -273,6 +312,96 @@ TEST(FunctionFinder, DetectsProloguePatterns) { cleanupFile(path); } +// Function Finder -- Computed Jump Arrays + +TEST(FunctionFinder, DetectsComputedJumpArraySlots) { + const std::string path = "/tmp/ps1recomp_test_ff_jump_array.elf"; + + // `jr $t0` with $t0 = 0x80010020 + idx*8. Each 8-byte slot returns on its + // own, so each is a function -- and nothing else in the image points at + // them. The prologue at +0x38 caps the array. + std::vector code(18, makeNOP()); + writeJumpArrayDispatch(code, 0x80010020, 3); + code[8] = makeJR_RA(); // slot 0 at +0x20 + code[10] = makeJR_RA(); // slot 1 at +0x28 + code[12] = makeJR_RA(); // slot 2 at +0x30 + code[14] = makeADDIU_SP(-16); // next function at +0x38 + code[16] = makeJR_RA(); + + createElfWithCode(path, code); + + ElfParser elf; + ASSERT_TRUE(elf.load(path)); + + FunctionFinder finder; + finder.findFunctions(elf); + + for (uint32_t addr : {0x80010020u, 0x80010028u, 0x80010030u}) { + auto* fn = finder.findByAddress(addr); + ASSERT_NE(fn, nullptr) << fmt::format("no entry at 0x{:08X}", addr); + EXPECT_EQ(fn->source, FunctionSource::JumpArray); + EXPECT_EQ(fn->size, 8u); + } + + cleanupFile(path); +} + +TEST(FunctionFinder, JumpIslandsAreNotEntryPoints) { + const std::string path = "/tmp/ps1recomp_test_ff_jump_island.elf"; + + // Same computed jump, but each slot is `bgez $zero, far` + delay slot -- + // a jump island, which belongs to the function around it. No slot + // returns, so none of them is a function. + std::vector code(18, makeNOP()); + writeJumpArrayDispatch(code, 0x80010020, 3); + code[8] = makeBGEZ(0x80010020, 0x80010040); + code[10] = makeBGEZ(0x80010028, 0x80010040); + code[12] = makeBGEZ(0x80010030, 0x80010040); + code[16] = makeJR_RA(); + + createElfWithCode(path, code); + + ElfParser elf; + ASSERT_TRUE(elf.load(path)); + + FunctionFinder finder; + finder.findFunctions(elf); + + EXPECT_EQ(finder.findByAddress(0x80010020), nullptr); + EXPECT_EQ(finder.findByAddress(0x80010028), nullptr); + EXPECT_EQ(finder.findByAddress(0x80010030), nullptr); + + cleanupFile(path); +} + +TEST(FunctionFinder, JumpArrayStopsAtADelaySlot) { + const std::string path = "/tmp/ps1recomp_test_ff_jump_array_tail.elf"; + + // The last slot is longer than the nominal stride, so `base + 2*8` lands + // on the delay slot of its `jr $ra`. That word is not an entry point and + // the array ends there. + std::vector code(18, makeNOP()); + writeJumpArrayDispatch(code, 0x80010020, 3); + code[8] = makeJR_RA(); // slot 0 at +0x20 + code[10] = makeADDIU(0, 2, 1); // slot 1 at +0x28 runs long + code[11] = makeJR_RA(); + // code[12] at +0x30 is that JR's delay slot, not a slot start + + createElfWithCode(path, code); + + ElfParser elf; + ASSERT_TRUE(elf.load(path)); + + FunctionFinder finder; + finder.findFunctions(elf); + + ASSERT_NE(finder.findByAddress(0x80010020), nullptr); + ASSERT_NE(finder.findByAddress(0x80010028), nullptr); + EXPECT_EQ(finder.findByAddress(0x80010030), nullptr); + + cleanupFile(path); +} + // Function Finder -- Boundary Computation TEST(FunctionFinder, ComputesSizes) { From c8df47bd186ab1c604786c1e9184d8f893ff51eb Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 15 Aug 2026 18:40:54 -0300 Subject: [PATCH 03/29] fix(analyzer): only mark a PsyQ match HLE when the runtime registers it --- ps1Analyzer/CMakeLists.txt | 1 + ps1Analyzer/src/config_generator.cpp | 14 +- ps1Analyzer/src/psyq_hle_allowlist.cpp | 275 +++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 ps1Analyzer/src/psyq_hle_allowlist.cpp diff --git a/ps1Analyzer/CMakeLists.txt b/ps1Analyzer/CMakeLists.txt index 7f3e4fd..805a97c 100644 --- a/ps1Analyzer/CMakeLists.txt +++ b/ps1Analyzer/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(ps1Analyzer_lib src/elf_parser.cpp src/function_finder.cpp src/psyq_signatures.cpp + src/psyq_hle_allowlist.cpp src/config_generator.cpp src/disc_reader.cpp src/overlay_scanner.cpp diff --git a/ps1Analyzer/src/config_generator.cpp b/ps1Analyzer/src/config_generator.cpp index 1be1f7a..f793e44 100644 --- a/ps1Analyzer/src/config_generator.cpp +++ b/ps1Analyzer/src/config_generator.cpp @@ -4,6 +4,7 @@ #include "ps1recomp/config_generator.h" #include "ps1recomp/elf_parser.h" #include "ps1recomp/function_finder.h" +#include "ps1recomp/psyq_hle_allowlist.h" #include "ps1recomp/psyq_signatures.h" #include @@ -88,6 +89,7 @@ static toml::value buildConfig(const ElfParser& elf, case FunctionSource::JALTarget: f["source"] = "jal_target"; break; case FunctionSource::Prologue: f["source"] = "prologue"; break; case FunctionSource::JumpArray: f["source"] = "jump_array"; break; + case FunctionSource::LinearSweep: f["source"] = "linear_sweep"; break; default: f["source"] = "heuristic"; break; } @@ -143,14 +145,22 @@ static toml::value buildConfig(const ElfParser& elf, // `_` identifier the recompiler will use to look up // the C++ HLE stub. Only emitted when the `library` field is filled // (i.e., the match came from the hash-based pass). + // + // `hle` only goes true for names the runtime registry actually answers + // for. Recognising a PsyQ routine is not the same as having an HLE body + // for it: marking one true without the body makes ps1Recomp emit a + // `psyq_dispatch` the registry aborts on the first time it is called. The + // rest stay in the config as identification and get recompiled from the + // game's own MIPS, which is always a correct answer. { toml::array hle; for (const auto& m : matcher.getMatches()) { if (m.library.empty()) continue; + const std::string name = fmt::format("{}_{}", m.library, m.name); toml::table f; f["address"] = hexAddr(m.address); - f["hle"] = true; - f["name"] = fmt::format("{}_{}", m.library, m.name); + f["hle"] = psyqHleIsImplemented(name); + f["name"] = name; f["library"] = m.library; f["subsystem"] = PsyQMatcher::subsystemName(m.subsystem); f["stub_type"] = PsyQMatcher::stubTypeName(m.stubType); diff --git a/ps1Analyzer/src/psyq_hle_allowlist.cpp b/ps1Analyzer/src/psyq_hle_allowlist.cpp new file mode 100644 index 0000000..b1a4588 --- /dev/null +++ b/ps1Analyzer/src/psyq_hle_allowlist.cpp @@ -0,0 +1,275 @@ +#include "ps1recomp/psyq_hle_allowlist.h" + +#include + +namespace ps1recomp { + +namespace { +/// Every key `ps1Runtime/src/psyq/*.cpp` hands to `psyq_register`, including +/// the ones `registerAllPrefixes` fans out across library prefixes. +/// +/// This is the analyzer's only view of what the runtime can actually answer +/// for. It has to stay in step with the registry: a name listed here that +/// nobody registers turns into a fatal dispatch the first time the game calls +/// it, and a registered name missing from here loses its HLE and gets +/// recompiled instead -- correct, but slower and without the shortcut. +const std::unordered_set& implementedNames() { + static const std::unordered_set kNames = { + "libapi_ChangeClearPAD", + "libapi_ChangeClearRCnt", + "libapi_CloseEvent", + "libapi_DeliverEvent", + "libapi_DisableEvent", + "libapi_EnableEvent", + "libapi_EnterCriticalSection", + "libapi_ExitCriticalSection", + "libapi_GPU_cw", + "libapi_HookEntryInt", + "libapi_InitHeap", + "libapi_InitPAD2", + "libapi_OpenEvent", + "libapi_ReturnFromException", + "libapi_SetRCnt", + "libapi_StartPAD2", + "libapi_StartRCnt", + "libapi_StopPAD2", + "libapi_StopRCnt", + "libapi_TestEvent", + "libapi__96_remove", + "libapi__bu_init", + "libapi__memmove", + "libapi_abs", + "libapi_atoi", + "libapi_close", + "libapi_erase", + "libapi_firstfile2", + "libapi_format", + "libapi_labs", + "libapi_memcmp", + "libapi_memcpy", + "libapi_memmove", + "libapi_memset", + "libapi_nextfile", + "libapi_open", + "libapi_printf", + "libapi_rand", + "libapi_read", + "libapi_sprintf", + "libapi_srand", + "libapi_strcat", + "libapi_strcmp", + "libapi_strcpy", + "libapi_strlen", + "libapi_strncmp", + "libapi_strncpy", + "libapi_write", + "libc__memmove", + "libc_abs", + "libc_atoi", + "libc_labs", + "libc_memcmp", + "libc_memcpy", + "libc_memmove", + "libc_memset", + "libc_printf", + "libc_rand", + "libc_sprintf", + "libc_srand", + "libc_strcat", + "libc_strcmp", + "libc_strcpy", + "libc_strlen", + "libc_strncmp", + "libc_strncpy", + "libcd_CD_datasync", + "libcd_CdControl", + "libcd_CdControlF", + "libcd_CdDataCallback", + "libcd_CdGetSector", + "libcd_CdInit", + "libcd_CdMix", + "libcd_CdRead", + "libcd_CdReadBreak", + "libcd_CdReadCallback", + "libcd_CdReadSync", + "libcd_CdReady", + "libcd_CdReadyCallback", + "libcd_CdSync", + "libcd_StCdInterrupt2", + "libcd_StSetMask", + "libcd_abs", + "libcd_atoi", + "libcd_labs", + "libcd_memcmp", + "libcd_memcpy", + "libcd_memmove", + "libcd_memset", + "libcd_printf", + "libcd_rand", + "libcd_sprintf", + "libcd_srand", + "libcd_strcat", + "libcd_strcmp", + "libcd_strcpy", + "libcd_strlen", + "libcd_strncmp", + "libcd_strncpy", + "libetc_CheckCallback", + "libetc_DMACallback", + "libetc_GetIntrMask", + "libetc_GetVideoMode", + "libetc_InterruptCallback", + "libetc_PadGetState", + "libetc_PadInit", + "libetc_PadInitDirect", + "libetc_PadRead", + "libetc_PadStartCom", + "libetc_PadStopCom", + "libetc_ResetCallback", + "libetc_RestartCallback", + "libetc_SetIntrMask", + "libetc_SetVideoMode", + "libetc_StopCallback", + "libetc_VSync", + "libetc_VSyncCallback", + "libetc_VSyncCallbacks", + "libetc_abs", + "libetc_atoi", + "libetc_labs", + "libetc_memcmp", + "libetc_memcpy", + "libetc_memmove", + "libetc_memset", + "libetc_printf", + "libetc_rand", + "libetc_restartIntr", + "libetc_sprintf", + "libetc_srand", + "libetc_startIntr", + "libetc_stopIntr", + "libetc_strcat", + "libetc_strcmp", + "libetc_strcpy", + "libetc_strlen", + "libetc_strncmp", + "libetc_strncpy", + "libgpu_ClearImage", + "libgpu_ClearOTag", + "libgpu_ClearOTagR", + "libgpu_DrawOTag", + "libgpu_DrawPrim", + "libgpu_DrawSync", + "libgpu_DrawSyncCallback", + "libgpu_FntFlush", + "libgpu_FntLoad", + "libgpu_FntOpen", + "libgpu_FntPrint", + "libgpu_FntSystem", + "libgpu_GetClut", + "libgpu_GetTPage", + "libgpu_LoadImage", + "libgpu_MoveImage", + "libgpu_PutDispEnv", + "libgpu_PutDrawEnv", + "libgpu_ResetGraph", + "libgpu_SetDefDispEnv", + "libgpu_SetDefDrawEnv", + "libgpu_SetDispMask", + "libgpu_SetDrawMode", + "libgpu_SetDumpFnt", + "libgpu_SetPolyF4", + "libgpu_SetPolyFT4", + "libgpu_SetShadeTex", + "libgpu_SetSprt", + "libgpu_SetSprt16", + "libgpu_SetSprt8", + "libgpu_StoreImage", + "libgpu_TermPrim", + "libgpu__addque", + "libgpu__addque2", + "libgpu__clr", + "libgpu__drs", + "libgpu__dws", + "libgpu__reset", + "libgpu_abs", + "libgpu_atoi", + "libgpu_checkRECT", + "libgpu_labs", + "libgpu_memcmp", + "libgpu_memcpy", + "libgpu_memmove", + "libgpu_memset", + "libgpu_printf", + "libgpu_rand", + "libgpu_sprintf", + "libgpu_srand", + "libgpu_strcat", + "libgpu_strcmp", + "libgpu_strcpy", + "libgpu_strlen", + "libgpu_strncmp", + "libgpu_strncpy", + "libgs_GsDefDispBuff", + "libgs_GsInitGraph", + "libgs_GsSetWorkBase", + "libgs_GsSortClear", + "libgte_InitGeom", + "libgte_MulMatrix", + "libgte_RotMatrix", + "libgte_RotTrans", + "libgte_RotTransPers", + "libgte_ScaleMatrix", + "libgte_SetBackColor", + "libgte_SetColorMatrix", + "libgte_SetDQA", + "libgte_SetFarColor", + "libgte_SetGeomOffset", + "libgte_SetGeomScreen", + "libgte_SetLightMatrix", + "libgte_SetRotMatrix", + "libgte_SetTransMatrix", + "libgte_TransMatrix", + "libgte_abs", + "libgte_atoi", + "libgte_labs", + "libgte_memcmp", + "libgte_memcpy", + "libgte_memmove", + "libgte_memset", + "libgte_printf", + "libgte_rand", + "libgte_sprintf", + "libgte_srand", + "libgte_strcat", + "libgte_strcmp", + "libgte_strcpy", + "libgte_strlen", + "libgte_strncmp", + "libgte_strncpy", + "libsn_abs", + "libsn_atoi", + "libsn_labs", + "libsn_memcmp", + "libsn_memcpy", + "libsn_memmove", + "libsn_memset", + "libsn_printf", + "libsn_rand", + "libsn_sprintf", + "libsn_srand", + "libsn_strcat", + "libsn_strcmp", + "libsn_strcpy", + "libsn_strlen", + "libsn_strncmp", + "libsn_strncpy", + }; + return kNames; +} +} // namespace + +bool psyqHleIsImplemented(const std::string& name) { + return implementedNames().count(name) != 0; +} + +} // namespace ps1recomp From 2c1c25dbb30c8da3f1375f1ba7e6a69e97f6a244 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 15 Aug 2026 21:58:35 -0300 Subject: [PATCH 04/29] feat(analyzer): linear sweep with validation for undetected functions --- .../include/ps1recomp/function_finder.h | 55 +++- ps1Analyzer/src/config_generator.cpp | 24 +- ps1Analyzer/src/function_finder.cpp | 276 +++++++++++++++++- 3 files changed, 343 insertions(+), 12 deletions(-) diff --git a/ps1Analyzer/include/ps1recomp/function_finder.h b/ps1Analyzer/include/ps1recomp/function_finder.h index e0d3aab..fcc5623 100644 --- a/ps1Analyzer/include/ps1recomp/function_finder.h +++ b/ps1Analyzer/include/ps1recomp/function_finder.h @@ -122,6 +122,18 @@ inline bool isLoad(uint32_t instr) { /// Conservative: covers R-type rd writes, loads, and the immediate ALU forms. bool writesRegister(uint32_t instr, uint32_t reg); +/// Is `instr` an encoding the R3000A actually implements? +/// +/// A whitelist, not a decoder. Every heuristic that walks over unclaimed bytes +/// has to answer "is this code at all?", and the only cheap answer that does +/// not fabricate functions out of data is: every word in the candidate has to +/// be a real instruction. One reserved encoding invalidates the whole slice. +/// +/// Deliberately excludes COP1 (the PS1 has no FPU), COP3, the MIPS-II/III +/// opcodes the R3000A never had, and the reserved SPECIAL function codes -- +/// those are the encodings data most often lands on. +bool isKnownInstruction(uint32_t instr); + } // namespace mips /// Find where a function actually ends. @@ -148,16 +160,33 @@ bool writesRegister(uint32_t instr, uint32_t reg); uint32_t refineFunctionEnd(const std::vector &words, uint32_t startAddr, uint32_t maxEndAddr); - +/// Could the slice starting at `words[0]` be a function body? +/// +/// The gate in front of the linear sweep, and the reason the sweep does not +/// turn data into functions. Two conditions, both required: +/// +/// - every word up to the terminator is a known instruction. Data that +/// happens to decode as something plausible almost always hits a reserved +/// encoding within a few words; +/// - a legitimate terminator appears *before* `maxEndAddr`. A slice that runs +/// into the next known entry point without ever returning is not a +/// function -- it is the middle of something, or it is not code. +/// +/// @param words Instruction words, starting at `startAddr`. +/// @param startAddr Virtual address of `words[0]`. +/// @param maxEndAddr The next known entry point, or the end of the section. +bool validatesAsFunction(const std::vector &words, uint32_t startAddr, + uint32_t maxEndAddr); // Function Detection Source enum class FunctionSource { - EntryPoint, // ELF entry point - Symbol, // From ELF symbol table (STT_FUNC) - JALTarget, // Target of a JAL instruction - Prologue, // Detected by ADDIU $sp, $sp, -N pattern - JumpArray, // Slot of a computed jump into an array of fixed-size bodies + EntryPoint, // ELF entry point + Symbol, // From ELF symbol table (STT_FUNC) + JALTarget, // Target of a JAL instruction + Prologue, // Detected by ADDIU $sp, $sp, -N pattern + JumpArray, // Slot of a computed jump into an array of fixed-size bodies + LinearSweep, // Validated slice of text no other pass claimed }; // FunctionInfo @@ -222,8 +251,17 @@ class FunctionFinder { void recomputeBoundaries(const ElfParser &elf); private: + /// A computed-jump array whose slots do not return: bodies of a switch that + /// only bounce back into the function they belong to. + struct JumpIsland { + uint32_t base; + uint32_t end; + uint32_t slot; + }; + std::vector m_functions; std::set m_jalTargets; + std::vector m_jumpIslands; // Detection passes void addEntryPoint(const ElfParser &elf); @@ -231,11 +269,16 @@ class FunctionFinder { void scanJALTargets(const Section &text); void scanPrologues(const Section &text); void scanJumpArrays(const Section &text); + void linearSweep(const Section &text); void computeBoundaries(const Section &text); // Helpers bool hasFunction(uint32_t addr) const; + /// Does `addr` (holding `word`) sit on a jump-island array the jump-array + /// pass traced and refused? Those slots are not function entry points. + bool isJumpIslandSlot(uint32_t addr, uint32_t word) const; + /// Read a 32-bit little-endian instruction from section data. static uint32_t readInstruction(const Section &sec, uint32_t offset); }; diff --git a/ps1Analyzer/src/config_generator.cpp b/ps1Analyzer/src/config_generator.cpp index f793e44..bd8f3cc 100644 --- a/ps1Analyzer/src/config_generator.cpp +++ b/ps1Analyzer/src/config_generator.cpp @@ -19,6 +19,22 @@ static std::string hexAddr(uint32_t addr) { return fmt::format("0x{:08X}", addr); } +/// Will the runtime do anything with this match, or is it only a name? +/// +/// Recognising a PsyQ routine buys nothing on its own. A match is only worth +/// taking the function away from the recompiler when something downstream +/// answers for it: the hash-based pass hands the name to the PsyQ registry, so +/// it has to be registered there; the older name/prefix passes carry no +/// library and are served by the `[[stubs]]` path instead. +/// +/// An unhandled match stays an ordinary function -- listed in `[[functions]]` +/// and translated from the game's own MIPS. Dropping it from both places is +/// how it used to vanish from the build entirely. +static bool matchIsHandled(const PsyQMatch& m) { + if (m.library.empty()) return true; // legacy [[stubs]] path + return psyqHleIsImplemented(fmt::format("{}_{}", m.library, m.name)); +} + // Build TOML Tree static toml::value buildConfig(const ElfParser& elf, @@ -65,10 +81,11 @@ static toml::value buildConfig(const ElfParser& elf, { toml::array funcs; for (const auto& fi : finder.getFunctions()) { - // Skip PsyQ functions (they go into stubs/skips/passthroughs) + // Skip PsyQ functions the runtime takes over (they go into + // stubs/skips/passthroughs/hle_functions instead) bool isPsyQ = false; for (const auto& m : matcher.getMatches()) { - if (m.address == fi.address) { + if (m.address == fi.address && matchIsHandled(m)) { isPsyQ = true; break; } @@ -104,6 +121,7 @@ static toml::value buildConfig(const ElfParser& elf, { toml::array stubs; for (const auto* m : matcher.getStubs()) { + if (!matchIsHandled(*m)) continue; toml::table s; s["name"] = m->name; s["address"] = hexAddr(m->address); @@ -118,6 +136,7 @@ static toml::value buildConfig(const ElfParser& elf, { toml::array skips; for (const auto* m : matcher.getSkips()) { + if (!matchIsHandled(*m)) continue; toml::table s; s["name"] = m->name; s["address"] = hexAddr(m->address); @@ -131,6 +150,7 @@ static toml::value buildConfig(const ElfParser& elf, { toml::array pass; for (const auto* m : matcher.getPassthroughs()) { + if (!matchIsHandled(*m)) continue; toml::table p; p["name"] = m->name; p["address"] = hexAddr(m->address); diff --git a/ps1Analyzer/src/function_finder.cpp b/ps1Analyzer/src/function_finder.cpp index e01833b..ee89028 100644 --- a/ps1Analyzer/src/function_finder.cpp +++ b/ps1Analyzer/src/function_finder.cpp @@ -32,6 +32,89 @@ bool writesRegister(uint32_t instr, uint32_t reg) { return false; } +bool isKnownInstruction(uint32_t instr) { + const uint32_t op = getOpcode(instr); + + if (op == OP_SPECIAL) { + switch (getFunction(instr)) { + case 0x00: // sll + case 0x02: // srl + case 0x03: // sra + case 0x04: // sllv + case 0x06: // srlv + case 0x07: // srav + case 0x08: // jr + case 0x09: // jalr + case 0x0C: // syscall + case 0x0D: // break + case 0x10: // mfhi + case 0x11: // mthi + case 0x12: // mflo + case 0x13: // mtlo + case 0x18: // mult + case 0x19: // multu + case 0x1A: // div + case 0x1B: // divu + case 0x20: // add + case 0x21: // addu + case 0x22: // sub + case 0x23: // subu + case 0x24: // and + case 0x25: // or + case 0x26: // xor + case 0x27: // nor + case 0x2A: // slt + case 0x2B: // sltu + return true; + default: + return false; + } + } + + if (op == OP_REGIMM) { + const uint32_t rt = getRt(instr); + // bltz, bgez, bltzal, bgezal -- every other rt is reserved + return rt == 0x00 || rt == 0x01 || rt == 0x10 || rt == 0x11; + } + + // j, jal, beq, bne, blez, bgtz, addi, addiu, slti, sltiu, andi, ori, + // xori, lui + if (op >= 0x02 && op <= 0x0F) + return true; + + if (op == 0x10) { // COP0 + const uint32_t rs = getRs(instr); + return rs == 0 || rs == 4 || rs == 16; // mfc0, mtc0, CO (rfe) + } + + if (op == 0x12) { // COP2 (GTE) + if (((instr >> 25) & 1) != 0) + return true; // GTE command + const uint32_t rs = getRs(instr); + return rs == 0 || rs == 2 || rs == 4 || rs == 6 || rs == 8; + } + + switch (op) { + case 0x20: // lb + case 0x21: // lh + case 0x22: // lwl + case 0x23: // lw + case 0x24: // lbu + case 0x25: // lhu + case 0x26: // lwr + case 0x28: // sb + case 0x29: // sh + case 0x2A: // swl + case 0x2B: // sw + case 0x2E: // swr + case 0x32: // lwc2 + case 0x3A: // swc2 + return true; + default: + return false; + } +} + } // namespace mips namespace { @@ -93,6 +176,23 @@ uint32_t refineFunctionEnd(const std::vector &words, return maxEndAddr; } +bool validatesAsFunction(const std::vector &words, uint32_t startAddr, + uint32_t maxEndAddr) { + if (words.empty() || maxEndAddr <= startAddr) + return false; + + for (size_t i = 0; i < words.size(); ++i) { + const uint32_t addr = startAddr + static_cast(i * 4); + if (addr >= maxEndAddr) + break; + if (!mips::isKnownInstruction(words[i])) + return false; + if (isFunctionEnd(words, i)) + return true; + } + return false; +} + void clampOverlappingSizes(std::vector &funcs) { if (funcs.size() < 2) return; @@ -113,6 +213,7 @@ void clampOverlappingSizes(std::vector &funcs) { void FunctionFinder::findFunctions(const ElfParser& elf) { m_functions.clear(); m_jalTargets.clear(); + m_jumpIslands.clear(); // Pass 1: Entry point addEntryPoint(elf); @@ -126,9 +227,10 @@ void FunctionFinder::findFunctions(const ElfParser& elf) { scanJALTargets(*text); scanPrologues(*text); scanJumpArrays(*text); + linearSweep(*text); } - // Pass 6: Compute sizes from sorted addresses + // Pass 7: Compute sizes from sorted addresses if (text != nullptr) { computeBoundaries(*text); } @@ -355,10 +457,9 @@ void FunctionFinder::scanJumpArrays(const Section& text) { const uint32_t slot = 1u << shamt; if (slot < kJumpArrayMinSlot || slot > kJumpArrayMaxSlot || - !text.containsAddress(base) || !returnsWithin(base, slot)) { + !text.containsAddress(base)) { continue; } - // Never run past an entry point another pass already found: that one // is better evidence than this arithmetic. uint32_t limit = textEnd; @@ -368,6 +469,23 @@ void FunctionFinder::scanJumpArrays(const Section& text) { } } + if (!returnsWithin(base, slot)) { + // The array is real -- the arithmetic traced -- but its slots do + // not return, so they are jump islands rather than functions. + // Remember that verdict with the extent it actually covers: the + // linear sweep would otherwise reach the same slots with far + // weaker evidence and claim them. + uint32_t end = base; + while (end < limit && text.containsAddress(end) && + mips::hasDelaySlot(wordAt(end)) && !returnsWithin(end, slot)) { + end += slot; + } + if (end > base) { + m_jumpIslands.push_back({base, end, slot}); + } + continue; + } + for (uint32_t n = 0;; ++n) { const uint32_t addr = base + n * slot; if (addr >= limit || !text.containsAddress(addr)) { @@ -387,7 +505,157 @@ void FunctionFinder::scanJumpArrays(const Section& text) { } } -// Pass 6: Compute Boundaries +// Pass 6: Linear Sweep + +/// Is `addr` a slot of a jump-island array `scanJumpArrays` traced and refused? +/// +/// Both conditions have to hold: the address sits exactly on the stride of a +/// traced dispatch base, and it opens with a control transfer, which is what an +/// island is made of. Real code that merely happens to land on the stride keeps +/// its chance at being a function. +bool FunctionFinder::isJumpIslandSlot(uint32_t addr, uint32_t word) const { + if (!mips::hasDelaySlot(word)) { + return false; + } + for (const auto& island : m_jumpIslands) { + if (addr >= island.base && addr < island.end && + ((addr - island.base) % island.slot) == 0) { + return true; + } + } + return false; +} + +/// Walk the bytes no other pass claimed and recover the functions hiding there. +/// +/// Every pass before this one needs a *reference* to the function: a symbol, a +/// `jal`, a stack prologue, an address in the config. A function that is only +/// ever reached by `jalr` through a pointer -- a table of handlers, a callback +/// installed at runtime, the tail of a dispatch family -- has none of those, so +/// nothing points at it and it disappears: the function before it simply +/// extends over it, and every branch that lands inside becomes a dispatch to an +/// address nobody emitted. +/// +/// The sweep finds them by asking a different question. Instead of "does +/// something point here?", it asks "does the code here stand on its own?". +/// Where the previous function has genuinely ended (`refineFunctionEnd`, not +/// the gap to the next entry point) and the bytes that follow validate as a +/// body, that is a function. +/// +/// This is the one pass that can invent functions, because it is the one pass +/// with no external evidence -- so `validatesAsFunction` does the whole job: +/// unknown encoding anywhere, or no terminator before the next known entry, and +/// the candidate is dropped and the sweep steps one word forward. Both guards +/// have to hold; either one alone lets a jump table through, since jump table +/// words decode as plausible loads. +void FunctionFinder::linearSweep(const Section& text) { + const uint32_t textStart = text.vaddr; + const uint32_t textEnd = text.vaddr + text.size; + if (text.size < 8) { + return; + } + + auto wordAt = [&](uint32_t addr) { + return readInstruction(text, addr - textStart); + }; + auto slice = [&](uint32_t from, uint32_t to) { + std::vector words; + words.reserve((to - from) / 4); + for (uint32_t a = from; a < to; a += 4) { + words.push_back(wordAt(a)); + } + return words; + }; + + std::set starts; + for (const auto& f : m_functions) { + if (f.address >= textStart && f.address < textEnd) { + starts.insert(f.address); + } + } + + // Where the already-detected functions really end. Their *sizes* are still + // the gap to the next entry point, which is exactly the over-extension the + // sweep is here to undo, so the extents have to be refined first. + std::vector> claimed; + claimed.reserve(starts.size()); + for (auto it = starts.begin(); it != starts.end(); ++it) { + auto next = std::next(it); + const uint32_t bound = (next == starts.end()) ? textEnd : *next; + claimed.emplace_back(*it, refineFunctionEnd(slice(*it, bound), *it, bound)); + } + + size_t ci = 0; + uint32_t addr = textStart; + while (addr + 8 <= textEnd) { + while (ci < claimed.size() && claimed[ci].second <= addr) { + ++ci; + } + if (ci < claimed.size() && claimed[ci].first <= addr) { + addr = std::max(addr + 4, claimed[ci].second); + continue; + } + + const uint32_t first = wordAt(addr); + + // Inter-function padding, and never a body's first instruction. + if (mips::isNOP(first)) { + addr += 4; + continue; + } + + // A slot of a computed-jump array that `scanJumpArrays` already looked + // at and refused, because it never returns: a jump island, which + // belongs to the function around it. `validatesAsFunction` cannot tell + // the two apart on its own -- it follows the island's branch straight + // into that function's `jr $ra` and accepts. The earlier pass traced + // the dispatch arithmetic and is the better evidence. + if (isJumpIslandSlot(addr, first)) { + addr += 4; + continue; + } + + uint32_t bound = textEnd; + const auto next = starts.upper_bound(addr); + if (next != starts.end()) { + bound = *next; + } + + const std::vector words = slice(addr, bound); + if (!validatesAsFunction(words, addr, bound)) { + addr += 4; + continue; + } + + const uint32_t end = refineFunctionEnd(words, addr, bound); + if (end <= addr) { + addr += 4; + continue; + } + // `refineFunctionEnd` may run past the terminator `validatesAsFunction` + // stopped at, when a branch reaches further; re-check what it kept. + const size_t count = (end - addr) / 4; + bool unknown = false; + for (size_t i = 0; i < count && i < words.size(); ++i) { + if (!mips::isKnownInstruction(words[i])) { + unknown = true; + break; + } + } + if (unknown) { + addr += 4; + continue; + } + + addFunction(addr, fmt::format("func_{:08X}", addr), + FunctionSource::LinearSweep); + starts.insert(addr); + claimed.insert(claimed.begin() + static_cast(ci), {addr, end}); + addr = end; + } +} + +// Pass 7: Compute Boundaries void FunctionFinder::computeBoundaries(const Section& text) { // Sort functions by address From 683d7acebb5678195734484b7adedb747d065621 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 16 Aug 2026 12:04:38 -0300 Subject: [PATCH 05/29] fix(psyq): byte-swap PadRead halves into the PsyQ button mask --- ps1Runtime/src/psyq/psyq_pad.cpp | 20 +++++++++---- ps1Test/runtime/test_psyq_pad.cpp | 49 +++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/ps1Runtime/src/psyq/psyq_pad.cpp b/ps1Runtime/src/psyq/psyq_pad.cpp index 575fce6..fe75e07 100644 --- a/ps1Runtime/src/psyq/psyq_pad.cpp +++ b/ps1Runtime/src/psyq/psyq_pad.cpp @@ -120,9 +120,15 @@ void hle_libetc_PadGetState(recomp_context *ctx) { } // PadRead(n) -- packed 32-bit pad word: port 1 in the low half, port 2 in -// the high half. Each half is the PsyQ active-low button mask (bit clear -// = pressed) which is exactly what `InputController::buttonState()` -// already returns; no extra inversion needed. +// the high half. Each half stays active-low (bit clear = pressed), but the +// two bytes within a half are swapped relative to `InputController`'s +// layout: the pad reports its halfword big-endian on the wire, and libetc +// hands that through untouched. Games therefore see SELECT..LEFT in the +// HIGH byte and L2..SQUARE in the LOW byte. +// +// Without the swap every button lands 8 bits away from where the game looks +// -- pressing START (bit 3) reads as R1 (bit 11) -- so input silently does +// the wrong thing instead of nothing. // // Whether the module is "active" doesn't gate reading on real libetc -- // PadRead just samples the most recent VBlank snapshot -- so we ignore @@ -130,8 +136,12 @@ void hle_libetc_PadGetState(recomp_context *ctx) { void hle_libetc_PadRead(recomp_context *ctx) { auto *input = inputOf(ctx); - uint16_t port1 = input ? input->buttonState(0) : 0xFFFF; - uint16_t port2 = input ? input->buttonState(1) : 0xFFFF; + auto byteSwap = [](uint16_t v) -> uint16_t { + return static_cast((v >> 8) | (v << 8)); + }; + + uint16_t port1 = byteSwap(input ? input->buttonState(0) : 0xFFFF); + uint16_t port2 = byteSwap(input ? input->buttonState(1) : 0xFFFF); // Refresh direct-mode buffers as a side-effect -- see PadInitDirect. writePadBuffer(ctx, input, g_state.buf1Addr, 0); diff --git a/ps1Test/runtime/test_psyq_pad.cpp b/ps1Test/runtime/test_psyq_pad.cpp index 3a1cc21..a3f6e8d 100644 --- a/ps1Test/runtime/test_psyq_pad.cpp +++ b/ps1Test/runtime/test_psyq_pad.cpp @@ -82,21 +82,47 @@ TEST_F(PsyqPadTest, PadReadIdleReturnsAllOnes) { EXPECT_EQ(ctx.r[V0], 0xFFFFFFFFu); } +// PadRead's halves are active-low but byte-swapped relative to +// `InputController`'s bit layout, so a pressed button clears exactly the bit +// named by the PsyQ `PADxxx` constant. These are the SDK's own values -- a +// game doing `if (!(pad & PADstart))` only works if we honour them. +constexpr uint16_t PAD_START = 0x0800; // BTN_START (controller bit 3) +constexpr uint16_t PAD_RDOWN = 0x0040; // BTN_CROSS (controller bit 14) +constexpr uint16_t PAD_RLEFT = 0x0080; // BTN_SQUARE (controller bit 15) +constexpr uint16_t PAD_RRIGHT = 0x0020; // BTN_CIRCLE (controller bit 13) + TEST_F(PsyqPadTest, PadReadPressedBitsAreCleared) { - // Press CROSS on port 0 -> bit 14 cleared in low half. + // Press CROSS on port 0 -> PADRdown cleared in the low half. input.press(input::BTN_CROSS, 0); hle_libetc_PadRead(&ctx); - uint32_t expected = 0xFFFFFFFFu & ~static_cast(input::BTN_CROSS); - EXPECT_EQ(ctx.r[V0], expected); + EXPECT_EQ(ctx.r[V0], 0xFFFFFFFFu & ~static_cast(PAD_RDOWN)); - // Add START on port 1 -> bit 3 of high half cleared. + // Add START on port 1 -> PADstart cleared in the high half. input.press(input::BTN_START, 1); hle_libetc_PadRead(&ctx); - expected = (~static_cast(input::BTN_CROSS) & 0xFFFFu) | - ((~static_cast(input::BTN_START) & 0xFFFFu) << 16); + uint32_t expected = static_cast(0xFFFFu & ~PAD_RDOWN) | + (static_cast(0xFFFFu & ~PAD_START) << 16); EXPECT_EQ(ctx.r[V0], expected); } +// Ground truth from the working reference (CrashBandicoot-Launcher, +// `RecompOne.Runtime/Bios/BiosB.cs::PadRead`): it byte-swaps each half +// (`(s >> 8) | (s << 8)`) before handing the word to the game, keeping the +// active-low sense. Measured against our own build: without the swap, +// pressing START moved controller bit 3, the game read it as R1, and +// `title_state` never left the title screen; with it, the guest's decoded pad +// global reads 0x0800 -- PADstart -- and the title advances. +TEST_F(PsyqPadTest, PadReadUsesPsyqButtonMaskNotControllerBitLayout) { + input.press(input::BTN_START, 0); + hle_libetc_PadRead(&ctx); + + const uint16_t low = static_cast(ctx.r[V0] & 0xFFFFu); + EXPECT_EQ(static_cast(~low & 0xFFFFu), PAD_START); + // The raw controller bit must NOT be what the game sees. + EXPECT_NE(static_cast(~low & 0xFFFFu), + static_cast(input::BTN_START)); +} + // Ground truth verified directly against the Crash Bandicoot (SCUS-94900) // retail binary: `PadUpdate` at VA 0x800167A4 loops the port index in $s1 and // picks the half with `bnez $s1, 0x80016818` before a delay-slot `srl @@ -115,19 +141,18 @@ TEST_F(PsyqPadTest, PadReadPort0IsLowHalfPerRetailPadUpdateDisassembly) { uint16_t highHalf = static_cast((ctx.r[V0] >> 16) & 0xFFFFu); // CROSS (port 0) must land in the low half, not the high half. - EXPECT_EQ(lowHalf, static_cast(0xFFFFu & ~input::BTN_CROSS)); - EXPECT_NE(highHalf, static_cast(0xFFFFu & ~input::BTN_CROSS)); + EXPECT_EQ(lowHalf, static_cast(0xFFFFu & ~PAD_RDOWN)); + EXPECT_NE(highHalf, static_cast(0xFFFFu & ~PAD_RDOWN)); // SQUARE (port 1) must land in the high half, not the low half. - EXPECT_EQ(highHalf, static_cast(0xFFFFu & ~input::BTN_SQUARE)); - EXPECT_NE(lowHalf, static_cast(0xFFFFu & ~input::BTN_SQUARE)); + EXPECT_EQ(highHalf, static_cast(0xFFFFu & ~PAD_RLEFT)); + EXPECT_NE(lowHalf, static_cast(0xFFFFu & ~PAD_RLEFT)); } TEST_F(PsyqPadTest, PadReadReflectsRelease) { input.press(input::BTN_CIRCLE, 0); hle_libetc_PadRead(&ctx); - EXPECT_EQ(ctx.r[V0] & 0xFFFFu, - static_cast(0xFFFFu & ~input::BTN_CIRCLE)); + EXPECT_EQ(ctx.r[V0] & 0xFFFFu, static_cast(0xFFFFu & ~PAD_RRIGHT)); input.release(input::BTN_CIRCLE, 0); hle_libetc_PadRead(&ctx); From dd11cc30128d73a5ab038cce43a1f185fe2f39bd Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 16 Aug 2026 12:35:50 -0300 Subject: [PATCH 06/29] feat(recomp): resolve computed in-function jumps to local labels --- ps1Recomp/CMakeLists.txt | 1 + ps1Recomp/include/ps1recomp/jump_analysis.h | 42 +++++++ ps1Recomp/src/jump_analysis.cpp | 119 ++++++++++++++++++++ ps1Recomp/src/main.cpp | 15 ++- ps1Test/CMakeLists.txt | 1 + ps1Test/recompiler/test_jump_analysis.cpp | 118 +++++++++++++++++++ 6 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 ps1Recomp/include/ps1recomp/jump_analysis.h create mode 100644 ps1Recomp/src/jump_analysis.cpp create mode 100644 ps1Test/recompiler/test_jump_analysis.cpp diff --git a/ps1Recomp/CMakeLists.txt b/ps1Recomp/CMakeLists.txt index da21612..f75414c 100644 --- a/ps1Recomp/CMakeLists.txt +++ b/ps1Recomp/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(ps1Recomp_lib src/overlay_handler.cpp src/hle_emitter.cpp src/dispatch_emitter.cpp + src/jump_analysis.cpp ) target_include_directories(ps1Recomp_lib diff --git a/ps1Recomp/include/ps1recomp/jump_analysis.h b/ps1Recomp/include/ps1recomp/jump_analysis.h new file mode 100644 index 0000000..f3f2212 --- /dev/null +++ b/ps1Recomp/include/ps1recomp/jump_analysis.h @@ -0,0 +1,42 @@ +#pragma once + +// ps1Recomp -- Indirect jump analysis +// +// Recovers the target set of a `jr $rx` so the emitter can turn it into local +// gotos instead of a runtime dispatch. + +#include +#include + +namespace ps1recomp { + +/// Entries to assume when no bounds check is found next to the index. +inline constexpr uint32_t kJumpTableFallbackEntries = 64; +/// Upper limit accepted from a bounds check, as a sanity clamp. +inline constexpr uint32_t kJumpTableMaxEntries = 4096; + +/// Resolve a `jr` whose target is *computed* from a code address inside the +/// same function, rather than loaded from a table (Duff's device). +/// +/// An unrolled copy loop is entered partway through: the caller scales the +/// remaining count and subtracts it from the address of the loop's tail, then +/// jumps there. Nothing is read from memory, so the table detector never +/// matches and the `jr` would fall through to a dispatch on a mid-function +/// address -- which is not a known function, and aborts. +/// +/// LUI $rb, hi ; $rb = address inside THIS function +/// ADDIU $rb, $rb, lo +/// SLL $rs, $ridx, 2 ; 4 bytes per unrolled instruction +/// SUBU $rt, $rb, $rs ; ADDU when the loop is entered ascending +/// JR $rt +/// +/// \param instrs the function's instruction words +/// \param jr_idx index of the `jr` in \p instrs +/// \param funcAddr address of the function's first instruction +/// \returns target addresses `base -/+ i*4`, clipped to the function; empty +/// when the pattern does not match. +std::vector +detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, + uint32_t funcAddr); + +} // namespace ps1recomp diff --git a/ps1Recomp/src/jump_analysis.cpp b/ps1Recomp/src/jump_analysis.cpp new file mode 100644 index 0000000..ec37e39 --- /dev/null +++ b/ps1Recomp/src/jump_analysis.cpp @@ -0,0 +1,119 @@ +#include + +#include + +namespace ps1recomp { + +// The pattern is matched backwards from the `jr`, in the order the compiler +// emits it. Steps 3 and 4 mirror the memory-table detector in main.cpp: trace +// the base to its LUI, and take the entry count from the SLTIU/SLTI that +// guards the index. +std::vector +detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, + uint32_t funcAddr) { + if (jr_idx == 0 || jr_idx >= instrs.size()) + return {}; + + const uint32_t funcEnd = funcAddr + static_cast(instrs.size() * 4); + + Instruction jr_inst = MipsDecoder::decode(instrs[jr_idx]); + uint8_t target_reg = jr_inst.rs; + + // Step 1: the target register is produced by ADDU/SUBU, not loaded. + int op_idx = -1; + bool descending = false; + uint8_t op_rs = 0, op_rt = 0; + for (int j = (int)jr_idx - 1; j >= 0 && j >= (int)jr_idx - 8; j--) { + Instruction inst = MipsDecoder::decode(instrs[j]); + if ((inst.id == InstrId::ADDU || inst.id == InstrId::SUBU) && + inst.rd == target_reg) { + op_idx = j; + descending = (inst.id == InstrId::SUBU); + op_rs = inst.rs; + op_rt = inst.rt; + break; + } + } + if (op_idx < 0) + return {}; + + // Step 2: trace a candidate register back to its LUI (+ optional ADDIU). + uint32_t lui_val = 0; + int16_t addiu_imm = 0; + auto traceToLui = [&](uint8_t reg) { + lui_val = 0; + addiu_imm = 0; + for (int j = op_idx - 1; j >= 0 && j >= (int)jr_idx - 20; j--) { + Instruction inst = MipsDecoder::decode(instrs[j]); + if (inst.id == InstrId::LUI && inst.rt == reg) { + lui_val = static_cast(static_cast(inst.imm16)) << 16; + return true; + } + if ((inst.id == InstrId::ADDIU || inst.id == InstrId::ADDI) && + inst.rt == reg && inst.rs == reg) { + addiu_imm = inst.imm16; + } + } + return false; + }; + + // SUBU only makes sense as base - scaled, so the base must be the minuend. + uint8_t scaled_reg = op_rt; + if (!traceToLui(op_rs)) { + if (descending) + return {}; + scaled_reg = op_rs; + if (!traceToLui(op_rt)) + return {}; + } + + uint32_t base = lui_val + static_cast(addiu_imm); + + // Step 3: the base has to land inside this function -- that is what makes + // the targets local labels rather than calls into another function. + if (base < funcAddr || base >= funcEnd || (base & 3u) != 0) + return {}; + + // Step 4: the scaled index comes from SLL $rs, $ridx, 2; the SLTIU/SLTI + // guarding that index carries the entry count. + int sll_idx = -1; + uint8_t index_reg = 0; + for (int j = op_idx - 1; j >= 0 && j >= (int)jr_idx - 24; j--) { + Instruction inst = MipsDecoder::decode(instrs[j]); + if (inst.id == InstrId::SLL && inst.rd == scaled_reg && inst.shamt == 2) { + sll_idx = j; + index_reg = inst.rt; + break; + } + } + if (sll_idx < 0) + return {}; + + uint32_t max_entries = kJumpTableFallbackEntries; + for (int j = sll_idx - 1; j >= 0 && j >= (int)jr_idx - 28; j--) { + Instruction inst = MipsDecoder::decode(instrs[j]); + if ((inst.id == InstrId::SLTIU || inst.id == InstrId::SLTI) && + inst.rs == index_reg) { + uint32_t bound = static_cast(static_cast(inst.imm16)); + if (bound > 0 && bound <= kJumpTableMaxEntries) + max_entries = bound; + break; + } + } + + // Step 5: enumerate base -/+ i*4, clipped to the function. Extra entries + // are inert -- they only become reachable if the computed value matches. + std::vector targets; + for (uint32_t i = 0; i < max_entries; i++) { + uint32_t off = i * 4; + if (descending && off > base - funcAddr) + break; + uint32_t target = descending ? base - off : base + off; + if (target < funcAddr || target >= funcEnd) + break; + targets.push_back(target); + } + return targets; +} + +} // namespace ps1recomp diff --git a/ps1Recomp/src/main.cpp b/ps1Recomp/src/main.cpp index 3e49994..2f96efe 100644 --- a/ps1Recomp/src/main.cpp +++ b/ps1Recomp/src/main.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -41,8 +42,6 @@ using namespace ps1recomp; // distinguishable from the data that follows it by that bound. // // Returns a vector of target addresses (empty if pattern not found). -static constexpr uint32_t kJumpTableFallbackEntries = 64; -static constexpr uint32_t kJumpTableMaxEntries = 4096; static std::vector detectJumpTable(const std::vector &instrs, size_t jr_idx, @@ -418,13 +417,19 @@ int main(int argc, char *argv[]) { // Try to detect the LUI+ADDIU+ADDU+LW+JR pattern and resolve // the table entries from the ELF binary. auto targets = detectJumpTable(rfunc.instructions, i, parser); + const char *how = "table entries detected"; + if (targets.empty()) { + // No table in memory: the target may be computed from a code + // address inside this function (Duff's device). + targets = ps1recomp::detectComputedCodeJump(rfunc.instructions, i, addr); + how = "computed in-function targets"; + } if (!targets.empty()) { JumpTableEntry jt; jt.jrInstrIdx = i; jt.targets = std::move(targets); - fmt::print(" [jump table] func 0x{:08X}+{}: {} targets (table " - "entries detected)\n", - addr, i * 4, jt.targets.size()); + fmt::print(" [jump table] func 0x{:08X}+{}: {} targets ({})\n", + addr, i * 4, jt.targets.size(), how); rfunc.jumpTables.push_back(std::move(jt)); } } diff --git a/ps1Test/CMakeLists.txt b/ps1Test/CMakeLists.txt index 5eefef4..e4a5638 100644 --- a/ps1Test/CMakeLists.txt +++ b/ps1Test/CMakeLists.txt @@ -39,6 +39,7 @@ add_executable(ps1Recomp_tests recompiler/test_hle_emission.cpp recompiler/test_dispatch_emitter.cpp recompiler/test_forward_branch.cpp + recompiler/test_jump_analysis.cpp ) target_link_libraries(ps1Recomp_tests diff --git a/ps1Test/recompiler/test_jump_analysis.cpp b/ps1Test/recompiler/test_jump_analysis.cpp new file mode 100644 index 0000000..295ad83 --- /dev/null +++ b/ps1Test/recompiler/test_jump_analysis.cpp @@ -0,0 +1,118 @@ +// Computed in-function jump (Duff's device) detection. +// +// Ground truth is func_80033FBC in Crash Bandicoot (SCUS-94900), the PsyQ +// memcpy: an unrolled copy loop entered partway through. Before this +// detector the `jr` emitted a runtime dispatch on a mid-function address and +// the game aborted with `unmapped call to 0x80034048` the moment the title +// screen accepted START. + +#include +#include + +#include + +using namespace ps1recomp; + +namespace { + +// Hand-assembled MIPS words, so the test pins the encoding the detector must +// match rather than trusting a second copy of the pattern matcher. +constexpr uint32_t lui(uint8_t rt, uint16_t imm) { + return (0x0Fu << 26) | (static_cast(rt) << 16) | imm; +} +constexpr uint32_t addiu(uint8_t rt, uint8_t rs, uint16_t imm) { + return (0x09u << 26) | (static_cast(rs) << 21) | + (static_cast(rt) << 16) | imm; +} +constexpr uint32_t sltiu(uint8_t rt, uint8_t rs, uint16_t imm) { + return (0x0Bu << 26) | (static_cast(rs) << 21) | + (static_cast(rt) << 16) | imm; +} +constexpr uint32_t sll(uint8_t rd, uint8_t rt, uint8_t sa) { + return (static_cast(rt) << 16) | (static_cast(rd) << 11) | + (static_cast(sa) << 6); +} +constexpr uint32_t subu(uint8_t rd, uint8_t rs, uint8_t rt) { + return (static_cast(rs) << 21) | (static_cast(rt) << 16) | + (static_cast(rd) << 11) | 0x23u; +} +constexpr uint32_t addu(uint8_t rd, uint8_t rs, uint8_t rt) { + return (static_cast(rs) << 21) | (static_cast(rt) << 16) | + (static_cast(rd) << 11) | 0x21u; +} +constexpr uint32_t jr(uint8_t rs) { + return (static_cast(rs) << 21) | 0x08u; +} +constexpr uint32_t kNop = 0u; + +constexpr uint32_t kFuncAddr = 0x80033FBC; + +// The shape at func_80033FBC+108: base 0x80034058 is the loop tail, $a2 the +// remaining count, guarded by `sltiu $at, $a2, 9`. +std::vector duffFunction(uint32_t base, bool descending) { + std::vector f((0x80034210u - kFuncAddr) / 4, kNop); + const size_t jrIdx = (0x80034028u - kFuncAddr) / 4; + const uint16_t lo = static_cast(base & 0xFFFFu); + f[jrIdx - 5] = sltiu(1, 6, 9); // sltiu $at, $a2, 9 + f[jrIdx - 4] = lui(8, base >> 16); // lui $t0, 0x8003 + f[jrIdx - 3] = addiu(8, 8, lo); // addiu $t0, $t0, lo + f[jrIdx - 2] = sll(1, 6, 2); // sll $at, $a2, 2 + f[jrIdx - 1] = descending ? subu(8, 8, 1) // subu $t0, $t0, $at + : addu(8, 8, 1); + f[jrIdx] = jr(8); + return f; +} + +} // namespace + +TEST(ComputedCodeJump, DescendingDuffDeviceResolvesToInFunctionTargets) { + auto f = duffFunction(0x80034058u, /*descending=*/true); + auto targets = + detectComputedCodeJump(f, (0x80034028u - kFuncAddr) / 4, kFuncAddr); + + // sltiu bound 9 -> base - 0..8 words. + ASSERT_EQ(targets.size(), 9u); + EXPECT_EQ(targets.front(), 0x80034058u); + EXPECT_EQ(targets.back(), 0x80034038u); + + // The address the game actually reached with count == 4, and the one that + // used to abort the run. + EXPECT_NE(std::find(targets.begin(), targets.end(), 0x80034048u), + targets.end()); +} + +TEST(ComputedCodeJump, AscendingFormWalksForwardFromTheBase) { + auto f = duffFunction(0x80034058u, /*descending=*/false); + auto targets = + detectComputedCodeJump(f, (0x80034028u - kFuncAddr) / 4, kFuncAddr); + + ASSERT_EQ(targets.size(), 9u); + EXPECT_EQ(targets.front(), 0x80034058u); + EXPECT_EQ(targets.back(), 0x80034078u); +} + +TEST(ComputedCodeJump, BaseOutsideTheFunctionIsRejected) { + // A computed address pointing at another function is a call, not a local + // branch -- resolving it to a goto would jump across function bodies. + auto f = duffFunction(0x80034058u, /*descending=*/true); + auto targets = detectComputedCodeJump(f, (0x80034028u - kFuncAddr) / 4, + /*funcAddr=*/0x80040000u); + EXPECT_TRUE(targets.empty()); +} + +TEST(ComputedCodeJump, MissingScaledIndexIsRejected) { + // Without `sll $rs, $ridx, 2` the value is not an unrolled-loop entry and + // the target set cannot be enumerated. + auto f = duffFunction(0x80034058u, /*descending=*/true); + f[(0x80034028u - kFuncAddr) / 4 - 2] = kNop; // drop the SLL + auto targets = + detectComputedCodeJump(f, (0x80034028u - kFuncAddr) / 4, kFuncAddr); + EXPECT_TRUE(targets.empty()); +} + +TEST(ComputedCodeJump, TableShapedJumpIsLeftToTheTableDetector) { + // `jr` whose target came out of memory has no ADDU/SUBU producing it. + std::vector f(64, kNop); + f[32] = jr(8); + EXPECT_TRUE(detectComputedCodeJump(f, 32, kFuncAddr).empty()); +} From 9c172f7131aea89f2d264cdd077c3a5f953b48bf Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 16 Aug 2026 13:04:35 -0300 Subject: [PATCH 07/29] feat(recomp): accept 8-byte stride in computed in-function jumps --- ps1Recomp/include/ps1recomp/jump_analysis.h | 9 +++++--- ps1Recomp/src/jump_analysis.cpp | 18 ++++++++++------ ps1Test/recompiler/test_jump_analysis.cpp | 23 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/ps1Recomp/include/ps1recomp/jump_analysis.h b/ps1Recomp/include/ps1recomp/jump_analysis.h index f3f2212..bf46a4c 100644 --- a/ps1Recomp/include/ps1recomp/jump_analysis.h +++ b/ps1Recomp/include/ps1recomp/jump_analysis.h @@ -26,15 +26,18 @@ inline constexpr uint32_t kJumpTableMaxEntries = 4096; /// /// LUI $rb, hi ; $rb = address inside THIS function /// ADDIU $rb, $rb, lo -/// SLL $rs, $ridx, 2 ; 4 bytes per unrolled instruction +/// SLL $rs, $ridx, N ; N=2 single instructions, N=3 8-byte slots /// SUBU $rt, $rb, $rs ; ADDU when the loop is entered ascending /// JR $rt /// +/// Shift 3 covers the other in-function form: a table of `bgez $zero, ...` +/// branches, each with its delay slot, jumped into by index. +/// /// \param instrs the function's instruction words /// \param jr_idx index of the `jr` in \p instrs /// \param funcAddr address of the function's first instruction -/// \returns target addresses `base -/+ i*4`, clipped to the function; empty -/// when the pattern does not match. +/// \returns target addresses `base -/+ i*stride`, clipped to the function; +/// empty when the pattern does not match. std::vector detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, uint32_t funcAddr); diff --git a/ps1Recomp/src/jump_analysis.cpp b/ps1Recomp/src/jump_analysis.cpp index ec37e39..627e0ec 100644 --- a/ps1Recomp/src/jump_analysis.cpp +++ b/ps1Recomp/src/jump_analysis.cpp @@ -74,15 +74,20 @@ detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, if (base < funcAddr || base >= funcEnd || (base & 3u) != 0) return {}; - // Step 4: the scaled index comes from SLL $rs, $ridx, 2; the SLTIU/SLTI - // guarding that index carries the entry count. + // Step 4: the scaled index comes from SLL $rs, $ridx, N. Shift 2 lands on + // single instructions (an unrolled loop); shift 3 lands on 8-byte slots, + // used when the table holds a branch plus its delay slot rather than the + // work itself. The SLTIU/SLTI guarding that index carries the entry count. int sll_idx = -1; uint8_t index_reg = 0; + uint32_t stride = 0; for (int j = op_idx - 1; j >= 0 && j >= (int)jr_idx - 24; j--) { Instruction inst = MipsDecoder::decode(instrs[j]); - if (inst.id == InstrId::SLL && inst.rd == scaled_reg && inst.shamt == 2) { + if (inst.id == InstrId::SLL && inst.rd == scaled_reg && + (inst.shamt == 2 || inst.shamt == 3)) { sll_idx = j; index_reg = inst.rt; + stride = 1u << inst.shamt; break; } } @@ -101,11 +106,12 @@ detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, } } - // Step 5: enumerate base -/+ i*4, clipped to the function. Extra entries - // are inert -- they only become reachable if the computed value matches. + // Step 5: enumerate base -/+ i*stride, clipped to the function. Extra + // entries are inert -- they only become reachable if the computed value + // matches. std::vector targets; for (uint32_t i = 0; i < max_entries; i++) { - uint32_t off = i * 4; + uint32_t off = i * stride; if (descending && off > base - funcAddr) break; uint32_t target = descending ? base - off : base + off; diff --git a/ps1Test/recompiler/test_jump_analysis.cpp b/ps1Test/recompiler/test_jump_analysis.cpp index 295ad83..bbf02a6 100644 --- a/ps1Test/recompiler/test_jump_analysis.cpp +++ b/ps1Test/recompiler/test_jump_analysis.cpp @@ -91,6 +91,29 @@ TEST(ComputedCodeJump, AscendingFormWalksForwardFromTheBase) { EXPECT_EQ(targets.back(), 0x80034078u); } +// The other in-function form, at func_80037D50 in the same binary: the base +// addresses a table of `bgez $zero, ...` branches, each followed by its delay +// slot, so the index is scaled by 8 rather than 4. +TEST(ComputedCodeJump, EightByteSlotTableUsesTheShiftAsStride) { + constexpr uint32_t kFn = 0x80037D50; + constexpr uint32_t kBase = 0x80037ED4; + constexpr size_t kJrIdx = (0x80037E9Cu - kFn) / 4; + + std::vector f((0x8003864Cu - kFn) / 4, kNop); + f[kJrIdx - 4] = lui(1, kBase >> 16); + f[kJrIdx - 3] = addiu(1, 1, static_cast(kBase & 0xFFFFu)); + f[kJrIdx - 2] = sll(2, 2, 3); // sll $v0, $v0, 3 + f[kJrIdx - 1] = addu(1, 1, 2); + f[kJrIdx] = jr(1); + + auto targets = detectComputedCodeJump(f, kJrIdx, kFn); + + ASSERT_FALSE(targets.empty()); + EXPECT_EQ(targets[0], kBase); + EXPECT_EQ(targets[1], kBase + 8); // stride 8, not 4 + EXPECT_LT(targets.back(), 0x8003864Cu); +} + TEST(ComputedCodeJump, BaseOutsideTheFunctionIsRejected) { // A computed address pointing at another function is a call, not a local // branch -- resolving it to a goto would jump across function bodies. From 2caa709592d3d32dab6eeea6853ee432d6a6a6aa Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Mon, 17 Aug 2026 00:07:09 -0300 Subject: [PATCH 08/29] fix(recomp): resume in-function on hijacked $ra and scale strides by the whole chain --- ps1Recomp/include/ps1recomp/jump_analysis.h | 24 ++++ ps1Recomp/include/ps1recomp/mips_decoder.h | 4 + ps1Recomp/src/instruction_emitter.cpp | 69 +++++----- ps1Recomp/src/jump_analysis.cpp | 110 +++++++++++++--- ps1Recomp/src/mips_decoder.cpp | 36 +++++ .../include/runtime/ps1_runtime_macros.h | 8 ++ ps1Test/recompiler/test_jump_analysis.cpp | 123 ++++++++++++++++++ 7 files changed, 322 insertions(+), 52 deletions(-) diff --git a/ps1Recomp/include/ps1recomp/jump_analysis.h b/ps1Recomp/include/ps1recomp/jump_analysis.h index bf46a4c..ee2fd25 100644 --- a/ps1Recomp/include/ps1recomp/jump_analysis.h +++ b/ps1Recomp/include/ps1recomp/jump_analysis.h @@ -14,6 +14,9 @@ namespace ps1recomp { inline constexpr uint32_t kJumpTableFallbackEntries = 64; /// Upper limit accepted from a bounds check, as a sanity clamp. inline constexpr uint32_t kJumpTableMaxEntries = 4096; +/// Largest per-entry stride accepted from the scaling chain. Real ones are a +/// handful of instructions wide; anything past this is a misread pattern. +inline constexpr uint32_t kJumpTableMaxStride = 256; /// Resolve a `jr` whose target is *computed* from a code address inside the /// same function, rather than loaded from a table (Duff's device). @@ -42,4 +45,25 @@ std::vector detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, uint32_t funcAddr); +/// Find the addresses a function loads into `$ra` as a constant pointing back +/// into itself -- a *hijacked* return address. +/// +/// LUI $ra, hi ; $ra = address inside THIS function +/// ADDIU $ra, $ra, lo +/// ... +/// JR $rx ; enters an out-of-line block +/// +/// The block ends in `jr $ra`, so control resumes at that address rather than +/// returning to the caller. Emitting the `jr $rx` as a plain dispatch-then- +/// return unwinds past the function's epilogue, so the registers it stashed on +/// entry are never restored and the caller sees the block's working values. +/// +/// \param instrs the function's instruction words +/// \param funcAddr address of the function's first instruction +/// \returns the in-function `$ra` constants, in program order, deduplicated; +/// empty when the function never hijacks `$ra`. +std::vector +detectInternalReturnTargets(const std::vector &instrs, + uint32_t funcAddr); + } // namespace ps1recomp diff --git a/ps1Recomp/include/ps1recomp/mips_decoder.h b/ps1Recomp/include/ps1recomp/mips_decoder.h index 5c7b72d..ce3767e 100644 --- a/ps1Recomp/include/ps1recomp/mips_decoder.h +++ b/ps1Recomp/include/ps1recomp/mips_decoder.h @@ -153,6 +153,10 @@ class MipsDecoder { /// Get human-readable name for a category static std::string_view categoryName(InstrCategory cat); + + /// The GPR this instruction writes, or -1 when it writes none (stores, + /// branches, MULT/DIV, COP moves to the coprocessor). + static int destGPR(const Instruction &inst); }; // Register Names diff --git a/ps1Recomp/src/instruction_emitter.cpp b/ps1Recomp/src/instruction_emitter.cpp index 203d3cd..fddc99a 100644 --- a/ps1Recomp/src/instruction_emitter.cpp +++ b/ps1Recomp/src/instruction_emitter.cpp @@ -2,6 +2,7 @@ // Translates decoded MIPS I instructions to C++ code using runtime macros #include "ps1recomp/instruction_emitter.h" +#include "ps1recomp/jump_analysis.h" #include #include @@ -60,39 +61,7 @@ static std::string u32(const std::string &val) { // that the preceding branch/jump reads for its condition/target. static int getDestGPR(const Instruction &inst) { - if (inst.isNOP() || !inst.isValid()) - return -1; - - switch (inst.category) { - case InstrCategory::ALU: - // R-type (ADD..SLTU, SLL..SRAV) writes rd; I-type writes rt - if (inst.id <= InstrId::SLTU || - (inst.id >= InstrId::SLL && inst.id <= InstrId::SRAV)) - return inst.rd; - return inst.rt; - case InstrCategory::Memory: - return inst.isLoad() ? static_cast(inst.rt) : -1; - case InstrCategory::MulDiv: - if (inst.id == InstrId::MFHI || inst.id == InstrId::MFLO) - return inst.rd; - return -1; // MULT, DIV etc. write HI/LO, not GPR - case InstrCategory::COP0: - if (inst.id == InstrId::MFC0) - return inst.rt; - return -1; - case InstrCategory::GTE: - if (inst.id == InstrId::MFC2 || inst.id == InstrId::CFC2) - return inst.rt; - return -1; - case InstrCategory::Jump: - if (inst.id == InstrId::JAL) - return 31; - if (inst.id == InstrId::JALR) - return inst.rd; - return -1; - default: - return -1; - } + return MipsDecoder::destGPR(inst); } // Replace all occurrences of `from` with `to` in `str`, but only when the @@ -571,6 +540,32 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { } } + // A function that points $ra back into itself resumes there when the block + // it jumped into runs its `jr $ra`. Those addresses need labels too -- they + // are reached from the dispatch below, not from any branch. + const std::vector raResume = + detectInternalReturnTargets(func.instructions, func.address); + for (uint32_t target : raResume) { + classifyTarget(target); + } + + // `jr $rx` for such a function must not return with the dispatch: the block + // it entered ends in `jr $ra`, which is a resume, not a return. Returning + // anyway unwinds past this function's epilogue, so whatever it stashed on + // entry -- $sp included -- is never restored. + auto indirectJump = [&](uint8_t rs) { + if (raResume.empty()) + return fmt::format("JUMP_INDIRECT(ctx, {});", reg(rs)); + std::string code = + fmt::format("JUMP_INDIRECT_RESUME(ctx, {});\n", reg(rs)); + for (uint32_t target : raResume) { + code += fmt::format(" if (ctx->r31 == 0x{:08X}u) goto {};\n", target, + label(target)); + } + code += " return;"; + return code; + }; + // Pass 2: Emit code // Track reachability: after JR/JALR + delay slot, treat subsequent // words as data comments until the next branch target label. @@ -608,7 +603,8 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { // If this JR $rx has a detected jump table, replace JUMP_INDIRECT // with a static switch/goto over the known target addresses. // A JUMP_INDIRECT fallback is kept for safety. - if (inst.id == InstrId::JR && inst.rs != 31 && !func.jumpTables.empty()) { + if (inst.id == InstrId::JR && inst.rs != 31) { + bool tabled = false; for (const auto &jt : func.jumpTables) { if (jt.jrInstrIdx == i && !jt.targets.empty()) { std::string sw; @@ -619,12 +615,15 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { sw += fmt::format(" if (_sw_target == 0x{:08X}u) goto {};\n", target, label(target)); } - sw += fmt::format(" JUMP_INDIRECT(ctx, {}); // fallback\n", reg(inst.rs)); + sw += fmt::format(" // fallback\n {}\n", indirectJump(inst.rs)); sw += " }"; code = sw; + tabled = true; break; } } + if (!tabled) + code = indirectJump(inst.rs); } // Handle branch delay slots: if this instruction has a delay slot, diff --git a/ps1Recomp/src/jump_analysis.cpp b/ps1Recomp/src/jump_analysis.cpp index 627e0ec..7e214dc 100644 --- a/ps1Recomp/src/jump_analysis.cpp +++ b/ps1Recomp/src/jump_analysis.cpp @@ -2,6 +2,9 @@ #include +#include +#include + namespace ps1recomp { // The pattern is matched backwards from the `jr`, in the order the compiler @@ -74,28 +77,59 @@ detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, if (base < funcAddr || base >= funcEnd || (base & 3u) != 0) return {}; - // Step 4: the scaled index comes from SLL $rs, $ridx, N. Shift 2 lands on - // single instructions (an unrolled loop); shift 3 lands on 8-byte slots, - // used when the table holds a branch plus its delay slot rather than the - // work itself. The SLTIU/SLTI guarding that index carries the entry count. - int sll_idx = -1; - uint8_t index_reg = 0; - uint32_t stride = 0; - for (int j = op_idx - 1; j >= 0 && j >= (int)jr_idx - 24; j--) { + // Step 4: recover the stride. It is the index scaled by a constant the + // compiler built out of shifts and adds, and only the whole chain gives the + // real figure: `sll $r,$i,2; addu $r,$r,$i; sll $r,$r,1` is x10, while the + // first shift alone reads as x4 -- half the entries, and the half the game + // actually jumps to are the missing ones. + // + // Every register is tracked as `coef * `, + // seeded with the identity. Anything the evaluator does not model resets its + // destination to the identity, which costs nothing: the index arrives in a + // register that some earlier arithmetic wrote, and only the scaling applied + // after that matters. + struct Linear { + uint8_t base; + uint32_t coef; + }; + std::array lin{}; + for (uint8_t r = 0; r < 32; ++r) + lin[r] = {r, 1}; + + const int win_start = std::max(0, static_cast(jr_idx) - 28); + for (int j = win_start; j < op_idx; ++j) { Instruction inst = MipsDecoder::decode(instrs[j]); - if (inst.id == InstrId::SLL && inst.rd == scaled_reg && - (inst.shamt == 2 || inst.shamt == 3)) { - sll_idx = j; - index_reg = inst.rt; - stride = 1u << inst.shamt; - break; + int written = MipsDecoder::destGPR(inst); + if (written <= 0) + continue; // writes no GPR, or writes $zero + const auto dst = static_cast(written); + + Linear val{dst, 1}; // identity unless one of the scaling forms matches + if (inst.id == InstrId::SLL && inst.shamt < 32) { + val = {lin[inst.rt].base, lin[inst.rt].coef << inst.shamt}; + } else if (inst.id == InstrId::ADDU || inst.id == InstrId::ADD) { + const Linear a = lin[inst.rs]; + const Linear b = lin[inst.rt]; + if (inst.rt == 0) + val = a; + else if (inst.rs == 0) + val = b; + else if (a.base == b.base) + val = {a.base, a.coef + b.coef}; } + lin[dst] = val; } - if (sll_idx < 0) + + // Coefficient 1 is the identity: the register carries no scaling at all, so + // this is not an indexed jump. + const Linear scale = lin[scaled_reg]; + if (scale.coef < 2 || scale.coef > kJumpTableMaxStride) return {}; + const uint32_t stride = scale.coef; + const uint8_t index_reg = scale.base; uint32_t max_entries = kJumpTableFallbackEntries; - for (int j = sll_idx - 1; j >= 0 && j >= (int)jr_idx - 28; j--) { + for (int j = op_idx - 1; j >= win_start; j--) { Instruction inst = MipsDecoder::decode(instrs[j]); if ((inst.id == InstrId::SLTIU || inst.id == InstrId::SLTI) && inst.rs == index_reg) { @@ -108,7 +142,9 @@ detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, // Step 5: enumerate base -/+ i*stride, clipped to the function. Extra // entries are inert -- they only become reachable if the computed value - // matches. + // matches. A stride that is not a multiple of 4 puts some of them off an + // instruction boundary; those the hardware could never reach, so drop them + // rather than stopping -- the aligned ones after them are the real entries. std::vector targets; for (uint32_t i = 0; i < max_entries; i++) { uint32_t off = i * stride; @@ -117,9 +153,49 @@ detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, uint32_t target = descending ? base - off : base + off; if (target < funcAddr || target >= funcEnd) break; + if ((target & 3u) != 0) + continue; targets.push_back(target); } return targets; } +// Only the explicit `lui $ra` + `addiu $ra, $ra` constant counts. A return +// address that merely happens to land inside the function -- recursion, say -- +// is an ordinary call and must keep its ordinary return. +std::vector +detectInternalReturnTargets(const std::vector &instrs, + uint32_t funcAddr) { + constexpr uint8_t kRa = 31; + const uint32_t funcEnd = funcAddr + static_cast(instrs.size() * 4); + + std::vector targets; + uint32_t hi = 0; + bool haveHi = false; + + for (uint32_t word : instrs) { + Instruction inst = MipsDecoder::decode(word); + + if (inst.id == InstrId::LUI && inst.rt == kRa) { + hi = static_cast(static_cast(inst.imm16)) << 16; + haveHi = true; + continue; + } + + if ((inst.id == InstrId::ADDIU || inst.id == InstrId::ADDI) && + inst.rt == kRa && inst.rs == kRa) { + if (!haveHi) + continue; + haveHi = false; + uint32_t addr = hi + static_cast(inst.imm16); + if (addr < funcAddr || addr >= funcEnd || (addr & 3u) != 0) + continue; + if (std::find(targets.begin(), targets.end(), addr) == targets.end()) + targets.push_back(addr); + } + } + + return targets; +} + } // namespace ps1recomp diff --git a/ps1Recomp/src/mips_decoder.cpp b/ps1Recomp/src/mips_decoder.cpp index 215158b..b140906 100644 --- a/ps1Recomp/src/mips_decoder.cpp +++ b/ps1Recomp/src/mips_decoder.cpp @@ -572,4 +572,40 @@ std::string_view cop0Name(uint8_t reg) { return s_cop0Names[reg]; } +int MipsDecoder::destGPR(const Instruction &inst) { + if (inst.isNOP() || !inst.isValid()) + return -1; + + switch (inst.category) { + case InstrCategory::ALU: + // R-type (ADD..SLTU, SLL..SRAV) writes rd; I-type writes rt + if (inst.id <= InstrId::SLTU || + (inst.id >= InstrId::SLL && inst.id <= InstrId::SRAV)) + return inst.rd; + return inst.rt; + case InstrCategory::Memory: + return inst.isLoad() ? static_cast(inst.rt) : -1; + case InstrCategory::MulDiv: + if (inst.id == InstrId::MFHI || inst.id == InstrId::MFLO) + return inst.rd; + return -1; // MULT, DIV etc. write HI/LO, not GPR + case InstrCategory::COP0: + if (inst.id == InstrId::MFC0) + return inst.rt; + return -1; + case InstrCategory::GTE: + if (inst.id == InstrId::MFC2 || inst.id == InstrId::CFC2) + return inst.rt; + return -1; + case InstrCategory::Jump: + if (inst.id == InstrId::JAL) + return 31; + if (inst.id == InstrId::JALR) + return inst.rd; + return -1; + default: + return -1; + } +} + } // namespace ps1recomp diff --git a/ps1Runtime/include/runtime/ps1_runtime_macros.h b/ps1Runtime/include/runtime/ps1_runtime_macros.h index 638d333..208f329 100644 --- a/ps1Runtime/include/runtime/ps1_runtime_macros.h +++ b/ps1Runtime/include/runtime/ps1_runtime_macros.h @@ -189,4 +189,12 @@ inline uint32_t indirect_trace_ra_filter() { recomp_dispatch(rdram, ctx, static_cast(addr)); \ return; \ } while (0) +// Same dispatch, but the emitter tests $ra afterwards: the block being jumped +// into ends in `jr $ra`, and the calling function has pointed $ra back into +// itself, so control resumes there instead of unwinding to the caller. +#define JUMP_INDIRECT_RESUME(ctx, addr) \ + do { \ + PS1_INDIRECT_TRACE_HOOK(ctx, addr); \ + recomp_dispatch(rdram, ctx, static_cast(addr)); \ + } while (0) #define COP0_RFE(ctx) /* NOP for now */ diff --git a/ps1Test/recompiler/test_jump_analysis.cpp b/ps1Test/recompiler/test_jump_analysis.cpp index bbf02a6..d623341 100644 --- a/ps1Test/recompiler/test_jump_analysis.cpp +++ b/ps1Test/recompiler/test_jump_analysis.cpp @@ -7,6 +7,7 @@ // screen accepted START. #include +#include #include #include @@ -114,6 +115,37 @@ TEST(ComputedCodeJump, EightByteSlotTableUsesTheShiftAsStride) { EXPECT_LT(targets.back(), 0x8003864Cu); } +// func_80033878+0x54C in the same binary. The offset is built as +// `sll $s3,$at,2; addu $s3,$s3,$at; sll $s3,$s3,1` -- x10, not the x4 the +// first shift alone reads as. With x4 the table stopped at 0x80033DD8 and the +// game dispatched 0x80033DD0, an address in the middle of the function. +TEST(ComputedCodeJump, ShiftAndAddChainGivesTheWholeStride) { + constexpr uint32_t kFn = 0x80033878; + constexpr uint32_t kBase = 0x80033DF8; + constexpr size_t kJrIdx = (0x80033D8Cu - kFn) / 4; + + std::vector f((0x80033EF8u - kFn) / 4, kNop); + f[kJrIdx - 7] = sltiu(18, 1, 9); + f[kJrIdx - 6] = lui(20, kBase >> 16); + f[kJrIdx - 5] = addiu(20, 20, static_cast(kBase & 0xFFFFu)); + f[kJrIdx - 4] = sll(19, 1, 2); // sll $s3, $at, 2 -> x4 + f[kJrIdx - 3] = addu(19, 19, 1); // addu $s3, $s3, $at -> x5 + f[kJrIdx - 2] = sll(19, 19, 1); // sll $s3, $s3, 1 -> x10 + f[kJrIdx - 1] = subu(20, 20, 19); + f[kJrIdx] = jr(20); + + auto targets = detectComputedCodeJump(f, kJrIdx, kFn); + + // The index is masked to a multiple of 4 before scaling, so only every + // other step lands on an instruction boundary -- the odd ones are dropped. + EXPECT_NE(std::find(targets.begin(), targets.end(), 0x80033DD0u), + targets.end()); + for (uint32_t t : targets) + EXPECT_EQ(t & 3u, 0u) << std::hex << t; + EXPECT_EQ(targets.front(), kBase); + EXPECT_EQ(targets[1], kBase - 20); +} + TEST(ComputedCodeJump, BaseOutsideTheFunctionIsRejected) { // A computed address pointing at another function is a call, not a local // branch -- resolving it to a goto would jump across function bodies. @@ -139,3 +171,94 @@ TEST(ComputedCodeJump, TableShapedJumpIsLeftToTheTableDetector) { f[32] = jr(8); EXPECT_TRUE(detectComputedCodeJump(f, 32, kFuncAddr).empty()); } + +// Hijacked return address +// +// Ground truth is func_80033878 in the same binary, the PsyQ decompressor. +// Its prologue stashes $s0-$ra in the scratchpad and sets $ra = 0x80033C28, +// an address inside itself. The `jr $s4` at 0x80033EEC enters one of the +// out-of-line copy stubs at 0x80033EF8+n*32; each stub ends in `jr $ra` and +// so lands back at 0x80033C28, which is where the epilogue restores the +// scratchpad. Emitting the `jr $s4` as dispatch-then-return skips that +// epilogue: $sp keeps the copy loop's source pointer, and $s0/$s1 keep its +// working state. Measured: func_80029B0C entered with sp=0x801FFD98 and +// returned with sp=0x8018AF4C, after which func_80025A60 spun 91M times on +// a loop counter that came back as garbage. + +namespace { +constexpr uint32_t kDecompressor = 0x80033878; +constexpr uint32_t kHijackedRa = 0x80033C28; +constexpr size_t kDecompressorLen = (0x80033EF8u - kDecompressor) / 4; + +// lui $ra, hi / addiu $ra, $ra, lo, then a `jr $s4` into the copy stubs. +std::vector hijackedRaFunction(uint32_t raTarget) { + std::vector f(kDecompressorLen, kNop); + f[0] = lui(31, raTarget >> 16); + f[1] = addiu(31, 31, static_cast(raTarget & 0xFFFFu)); + f[(0x80033EECu - kDecompressor) / 4] = jr(20); // jr $s4 + return f; +} +} // namespace + +TEST(HijackedReturnAddress, ConstantRaInsideTheFunctionIsReported) { + auto targets = + detectInternalReturnTargets(hijackedRaFunction(kHijackedRa), kDecompressor); + + ASSERT_EQ(targets.size(), 1u); + EXPECT_EQ(targets.front(), kHijackedRa); +} + +TEST(HijackedReturnAddress, RaPointingOutsideTheFunctionIsARealCall) { + // `lui/addiu $ra` to another function is the ordinary "call this, come back + // there" idiom -- the plain return is correct and must stay. + auto targets = detectInternalReturnTargets( + hijackedRaFunction(0x80041000u), kDecompressor); + EXPECT_TRUE(targets.empty()); +} + +TEST(HijackedReturnAddress, FunctionWithoutARaConstantReportsNothing) { + std::vector f(kDecompressorLen, kNop); + f[0] = lui(8, 0x8003); // $t0, not $ra + f[1] = addiu(8, 8, 0x3C28); + EXPECT_TRUE(detectInternalReturnTargets(f, kDecompressor).empty()); +} + +TEST(HijackedReturnAddress, IndirectJumpResumesInsteadOfReturning) { + ps1recomp::InstructionEmitter em; + ps1recomp::RecompFunction f; + f.name = "func_80033878"; + f.address = kDecompressor; + f.instructions = hijackedRaFunction(kHijackedRa); + f.isLabelTarget.assign(f.instructions.size(), false); + f.size = static_cast(f.instructions.size() * 4); + + const std::string out = em.emitFunction(f); + + // The stub's `jr $ra` comes back as a C++ return, so the dispatch must not + // return with it -- it has to resume at the hijacked address. + EXPECT_NE(out.find("if (ctx->r31 == 0x80033C28u) goto L_80033C28;"), + std::string::npos) + << out; + + // And that label has to be the real one at 0x80033C28, not a dispatch stub + // synthesised by the undefined-label post-pass. + EXPECT_EQ(out.find("recomp_dispatch(rdram, ctx, 0x80033C28)"), + std::string::npos) + << out; +} + +TEST(HijackedReturnAddress, OrdinaryIndirectJumpStillReturns) { + ps1recomp::InstructionEmitter em; + ps1recomp::RecompFunction f; + f.name = "plain"; + f.address = kDecompressor; + f.instructions = hijackedRaFunction(0x80041000u); // $ra points elsewhere + f.isLabelTarget.assign(f.instructions.size(), false); + f.size = static_cast(f.instructions.size() * 4); + + const std::string out = em.emitFunction(f); + + EXPECT_NE(out.find("JUMP_INDIRECT(ctx, ctx->r20);"), std::string::npos) + << out; + EXPECT_EQ(out.find("ctx->r31 =="), std::string::npos) << out; +} From 5dfe1fc5632b8d4774771da7370ce0c5efe95f6a Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Mon, 17 Aug 2026 00:07:09 -0300 Subject: [PATCH 09/29] refactor(analyzer): recompile PsyQ primitive builders and libgte instead of HLE --- ps1Analyzer/src/psyq_hle_allowlist.cpp | 47 -------------------------- 1 file changed, 47 deletions(-) diff --git a/ps1Analyzer/src/psyq_hle_allowlist.cpp b/ps1Analyzer/src/psyq_hle_allowlist.cpp index b1a4588..0ac630b 100644 --- a/ps1Analyzer/src/psyq_hle_allowlist.cpp +++ b/ps1Analyzer/src/psyq_hle_allowlist.cpp @@ -154,8 +154,6 @@ const std::unordered_set& implementedNames() { "libetc_strncmp", "libetc_strncpy", "libgpu_ClearImage", - "libgpu_ClearOTag", - "libgpu_ClearOTagR", "libgpu_DrawOTag", "libgpu_DrawPrim", "libgpu_DrawSync", @@ -165,26 +163,14 @@ const std::unordered_set& implementedNames() { "libgpu_FntOpen", "libgpu_FntPrint", "libgpu_FntSystem", - "libgpu_GetClut", - "libgpu_GetTPage", "libgpu_LoadImage", "libgpu_MoveImage", "libgpu_PutDispEnv", "libgpu_PutDrawEnv", "libgpu_ResetGraph", - "libgpu_SetDefDispEnv", - "libgpu_SetDefDrawEnv", "libgpu_SetDispMask", - "libgpu_SetDrawMode", "libgpu_SetDumpFnt", - "libgpu_SetPolyF4", - "libgpu_SetPolyFT4", - "libgpu_SetShadeTex", - "libgpu_SetSprt", - "libgpu_SetSprt16", - "libgpu_SetSprt8", "libgpu_StoreImage", - "libgpu_TermPrim", "libgpu__addque", "libgpu__addque2", "libgpu__clr", @@ -213,39 +199,6 @@ const std::unordered_set& implementedNames() { "libgs_GsInitGraph", "libgs_GsSetWorkBase", "libgs_GsSortClear", - "libgte_InitGeom", - "libgte_MulMatrix", - "libgte_RotMatrix", - "libgte_RotTrans", - "libgte_RotTransPers", - "libgte_ScaleMatrix", - "libgte_SetBackColor", - "libgte_SetColorMatrix", - "libgte_SetDQA", - "libgte_SetFarColor", - "libgte_SetGeomOffset", - "libgte_SetGeomScreen", - "libgte_SetLightMatrix", - "libgte_SetRotMatrix", - "libgte_SetTransMatrix", - "libgte_TransMatrix", - "libgte_abs", - "libgte_atoi", - "libgte_labs", - "libgte_memcmp", - "libgte_memcpy", - "libgte_memmove", - "libgte_memset", - "libgte_printf", - "libgte_rand", - "libgte_sprintf", - "libgte_srand", - "libgte_strcat", - "libgte_strcmp", - "libgte_strcpy", - "libgte_strlen", - "libgte_strncmp", - "libgte_strncpy", "libsn_abs", "libsn_atoi", "libsn_labs", From 713821a7b11bfd2fd83e476f44fb79597b46fc03 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Mon, 17 Aug 2026 00:07:09 -0300 Subject: [PATCH 10/29] feat(runtime): add global watch, input injection and stall sampling probes --- ps1Runtime/src/main_host.cpp | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index e4abb05..306656e 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -26,6 +26,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -119,6 +122,20 @@ static constexpr uint32_t SCANLINES_PER_FRAME = 263; // NTSC // SIGTERM/SIGINT raises this so `tools/smoke_test.py` (and `timeout`) can // stop the runtime gracefully -- giving the main loop a chance to dump VRAM // before the OS reaps us. +// Stall sampler (PS1_STALL_SAMPLE) +// No gdb/perf here, but every recompiled MIPS function is a real C++ function +// with an exported symbol, so a backtrace taken on the game thread names the +// func_XXXXXXXX it is stuck in. A watchdog thread pokes SIGPROF at it and +// prints what the handler captured. +static void *g_stallFrames[32]; +static int g_stallDepth = 0; +static std::atomic g_stallCaptured{false}; + +static void stallSampler(int) { + g_stallDepth = backtrace(g_stallFrames, 32); + g_stallCaptured.store(true, std::memory_order_release); +} + static volatile std::sig_atomic_t g_shutdown_requested = 0; static void onTerminate(int) { g_shutdown_requested = 1; } @@ -566,6 +583,15 @@ int main(int argc, char *argv[]) { // BSS mirror (`vblankCounterMirror`): when set via `[bss_mirrors]`, also // write-through to a PS1 RAM address so recompiled MIPS code that polls // the legacy slot directly keeps working -- see comment block above. + const bool watchGlobals = std::getenv("PS1_WATCH_GLOBALS") != nullptr; + const uint32_t autoStartVsync = + std::getenv("PS1_AUTO_START") + ? std::strtoul(std::getenv("PS1_AUTO_START"), nullptr, 10) + : 0; + const uint32_t autoStartHold = + std::getenv("PS1_AUTO_START_HOLD") + ? std::strtoul(std::getenv("PS1_AUTO_START_HOLD"), nullptr, 10) + : 8; std::thread vblankThread([&]() { using namespace std::chrono; const auto period = microseconds(16667); // ~60 Hz @@ -580,6 +606,29 @@ int main(int argc, char *argv[]) { if (vblankCounterMirror != 0) { memory.write32(vblankCounterMirror, newCount); } + if (autoStartVsync != 0) { + if (newCount == autoStartVsync) { + input.press(ps1::input::BTN_START, 0); + fmt::print(stderr, "[auto] START press @vsync={}\n", newCount); + } else if (newCount == autoStartVsync + autoStartHold) { + input.release(ps1::input::BTN_START, 0); + fmt::print(stderr, "[auto] START release @vsync={}\n", newCount); + } + } + if (watchGlobals) { + fmt::print(stderr, + "[watch] vsync={} ticks={} frames_elapsed={} vblank={} " + "title_state={} pad0=0x{:08X} raw=0x{:04X} " + "exit=0x{:08X} p={:08X} p32={:04X}\n", + newCount, memory.read32(0x80034520u), + memory.read32(0x80060E04u), memory.read32(0x800549F0u), + memory.read32(0x800618D4u), memory.read32(0x8005E71Cu), + input.buttonState(0), memory.read32(0x80061994u), + memory.read32(0x8005791Cu), + memory.read32(0x8005791Cu) + ? memory.read16(memory.read32(0x8005791Cu) + 32) + : 0xFFFF); + } gpu.snapshotDisplayBuffer(); bios.updatePadBuffers(); } @@ -606,6 +655,33 @@ int main(int argc, char *argv[]) { gameThreadDone.store(true, std::memory_order_release); }); + if (const char *everyMs = std::getenv("PS1_STALL_SAMPLE")) { + struct sigaction sa {}; + sa.sa_handler = stallSampler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; + sigaction(SIGPROF, &sa, nullptr); + const long periodMs = std::max(200L, std::strtol(everyMs, nullptr, 10)); + pthread_t gt = gameThread.native_handle(); + std::thread([gt, periodMs, &gameFinished]() { + while (!gameFinished.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds(periodMs)); + g_stallCaptured.store(false, std::memory_order_release); + if (pthread_kill(gt, SIGPROF) != 0) + return; + for (int i = 0; i < 200 && !g_stallCaptured.load(std::memory_order_acquire); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (!g_stallCaptured.load(std::memory_order_acquire)) + continue; + char **syms = backtrace_symbols(g_stallFrames, g_stallDepth); + fmt::print(stderr, "[stall] depth={}\n", g_stallDepth); + for (int i = 0; i < g_stallDepth && i < 14; ++i) + fmt::print(stderr, "[stall] #{} {}\n", i, syms ? syms[i] : "?"); + free(syms); + } + }).detach(); + } + bool running = true; while (running) { // 1. Poll SDL2 Events From 4878c9c4060d3c59392cb0f802282a0704140c97 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Tue, 1 Sep 2026 16:00:13 -0300 Subject: [PATCH 11/29] recomp: resolve computed jumps against in-function labels when the stride is unrecoverable --- ps1Recomp/include/ps1recomp/jump_analysis.h | 6 +++ ps1Recomp/src/instruction_emitter.cpp | 46 ++++++++++++++++++--- ps1Recomp/src/jump_analysis.cpp | 8 ++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/ps1Recomp/include/ps1recomp/jump_analysis.h b/ps1Recomp/include/ps1recomp/jump_analysis.h index ee2fd25..6d8b5b6 100644 --- a/ps1Recomp/include/ps1recomp/jump_analysis.h +++ b/ps1Recomp/include/ps1recomp/jump_analysis.h @@ -11,6 +11,12 @@ namespace ps1recomp { /// Entries to assume when no bounds check is found next to the index. +/// +/// Measured 2026-08-30: raising this to 1024 does NOT help Crash's +/// 0x80037D50 (the missing target 0x80038030 sits *below* the detected base, +/// and enumeration is one-directional) and it regressed the boot in 1 of 3 +/// runs. The gap there is direction/base, not count -- do not raise this +/// without a measurement that shows the extra entries are the ones needed. inline constexpr uint32_t kJumpTableFallbackEntries = 64; /// Upper limit accepted from a bounds check, as a sanity clamp. inline constexpr uint32_t kJumpTableMaxEntries = 4096; diff --git a/ps1Recomp/src/instruction_emitter.cpp b/ps1Recomp/src/instruction_emitter.cpp index fddc99a..5dd58f4 100644 --- a/ps1Recomp/src/instruction_emitter.cpp +++ b/ps1Recomp/src/instruction_emitter.cpp @@ -228,15 +228,18 @@ std::string InstructionEmitter::emitLoad(const Instruction &inst) const { auto offset = inst.imm16; switch (inst.id) { + // `read8`/`read16` return unsigned, so the cast is what carries the sign: + // without it LB/LH zero-extend and every negative byte or halfword in the + // game comes back as a large positive number. case InstrId::LB: - return assignReg(inst.rt, - fmt::format("MEM_READ8(ctx, {} + {})", s32(base), offset)); + return assignReg(inst.rt, fmt::format("(int8_t)MEM_READ8(ctx, {} + {})", + s32(base), offset)); case InstrId::LBU: return assignReg(inst.rt, fmt::format("(uint8_t)MEM_READ8(ctx, {} + {})", s32(base), offset)); case InstrId::LH: - return assignReg( - inst.rt, fmt::format("MEM_READ16(ctx, {} + {})", s32(base), offset)); + return assignReg(inst.rt, fmt::format("(int16_t)MEM_READ16(ctx, {} + {})", + s32(base), offset)); case InstrId::LHU: return assignReg(inst.rt, fmt::format("(uint16_t)MEM_READ16(ctx, {} + {})", s32(base), offset)); @@ -622,8 +625,39 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { break; } } - if (!tabled) - code = indirectJump(inst.rs); + if (!tabled) { + // No table was recovered -- the index scaling is not visible near the + // jump (Crash's 0x80037D50 forms it with an SRL ~130 instructions + // back, so the linear pass reads a coefficient of 1 and bails). The + // jump still lands on in-function code, and every branch target in + // this function already has a label, so match against those. + // + // This can only change behaviour for a value that is exactly one of + // this function's label addresses. Such a value is in-function code, + // so jumping there is right -- and it is what the indirect dispatch + // cannot do, since no *function* starts at an interior address, which + // is why it aborts instead. Anything else (a real function pointer, + // a return thunk) matches nothing and still takes the fallback. + std::string sw; + std::size_t n = 0; + for (std::size_t k = 0; k < labelTargets.size(); ++k) { + if (!labelTargets[k]) + continue; + const uint32_t target = func.address + static_cast(k) * 4; + sw += fmt::format(" if (_sw_target == 0x{:08X}u) goto {};\n", + target, label(target)); + ++n; + } + if (n == 0) { + code = indirectJump(inst.rs); + } else { + code = fmt::format("{{ // in-function labels ({} entries)\n" + " uint32_t _sw_target = static_cast({});\n" + "{}" + " // fallback\n {}\n }}", + n, reg(inst.rs), sw, indirectJump(inst.rs)); + } + } } // Handle branch delay slots: if this instruction has a delay slot, diff --git a/ps1Recomp/src/jump_analysis.cpp b/ps1Recomp/src/jump_analysis.cpp index 7e214dc..3dc4970 100644 --- a/ps1Recomp/src/jump_analysis.cpp +++ b/ps1Recomp/src/jump_analysis.cpp @@ -145,6 +145,14 @@ detectComputedCodeJump(const std::vector &instrs, size_t jr_idx, // matches. A stride that is not a multiple of 4 puts some of them off an // instruction boundary; those the hardware could never reach, so drop them // rather than stopping -- the aligned ones after them are the real entries. + // Measured 2026-08-30: walking BOTH directions here (an ADDU is modulo 2^32, + // so a negative index reaches base - n*stride through it) does put + // 0x80038030 into the third table of Crash's 0x80037D50 -- and the abort + // there does not change, because the block that actually falls through is + // the one based at 0x80037D54, whose entries are 4 mod 8 and so can never + // hold it. The gap is a base misdetected by +4, not the walk direction. + // Bidirectional walking also breaks ComputedCodeJump.* (4 tests), which pin + // the one-directional contract. Fix the base before touching this. std::vector targets; for (uint32_t i = 0; i < max_entries; i++) { uint32_t off = i * stride; From c5db1ee6e968ea82d664773a9ae51ec5fc22f37b Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Tue, 1 Sep 2026 16:00:39 -0300 Subject: [PATCH 12/29] tools: reproducible Crash Bandicoot pipeline with function-size and decompressor corrections --- tools/fix_crash_func_sizes.py | 85 ++++++++++++++++++ tools/regen_crash.sh | 159 +++++++++++++++++++++++++++++++++ tools/suppress_decomp_drain.py | 75 ++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100755 tools/fix_crash_func_sizes.py create mode 100755 tools/regen_crash.sh create mode 100755 tools/suppress_decomp_drain.py diff --git a/tools/fix_crash_func_sizes.py b/tools/fix_crash_func_sizes.py new file mode 100755 index 0000000..c474487 --- /dev/null +++ b/tools/fix_crash_func_sizes.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Correct function sizes the analyzer gets wrong for Crash Bandicoot. + +`ps1Analyzer` discovers functions from JAL targets. When a routine has two +entry points that share one body -- a common PsyQ libgte shape -- the second +entry is recorded as a new function and the first is truncated to end where it +begins. Everything after that first instruction is silently dropped from the +recompiled output. + +This is not cosmetic. 0x80042FEC is the libgte vector transform that +func_800180CC calls to fill a stack MATRIX's translation (`a1 = SP+44`). +Truncated to one instruction, the body stopped after `lwc2 $0,0(a0)`, so the +translation was never written; SetTransMatrix then loaded stale stack contents +(RAM pointers) into the GTE's TRX/TRY/TRZ, every RTPT saturated with SZ3 = 0, +and the game's own `if (FLAG < 0) skip polygon` discarded 100% of every actor's +geometry -- no Crash, no Naughty Dog doghouse or banner, no level trail. + +Extents verified against the c1c reference decompilation (srczz/zz_42fec.h). + +The real fix belongs in ps1Analyzer: an entry point discovered inside an +existing function must not shorten it. Until then, `tools/regen_crash.sh` +runs this pass so a regeneration does not silently undo the correction. + +Usage: + tools/fix_crash_func_sizes.py configs/crash_recomp.toml +""" + +import re +import sys + +# address -> (wrong size the analyzer emits, real size in bytes) +CORRECTIONS = { + "0x80042FEC": (4, 40), + # 0x800334A0 is the NSF chunk decompressor. 0x80033878 is a second entry + # point into its body, so the analyzer ends the first function there -- + # but the body branches forward to 0x80033C94, past the cut. The emitter + # cannot place code at a label outside the function's extent, so it emits + # L_80033C94: recomp_dispatch(rdram, ctx, 0x80033C94); return; + # and the dispatch aborts at run time: no function *starts* at that + # address. Reaching the level-select map is enough to hit it. + # Real extent runs to 0x80033EF8, where func_80033EF8 begins: 2648 bytes. + "0x800334A0": (984, 2648), +} + + +def main() -> int: + path = sys.argv[1] + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + + failures = [] + for addr, (wrong, right) in CORRECTIONS.items(): + # Match the size line of the [[functions]] block for this address. + block = re.compile( + r"(\[\[functions\]\]\n(?:[^\[]*?\n)?size = )(\d+)((?:[^\[]*?\n)?address = \"%s\"\n)" + % re.escape(addr)) + m = block.search(text) + if m is None: + failures.append("%s: no [[functions]] block found" % addr) + continue + current = int(m.group(2)) + if current == right: + print(" %s already %d" % (addr, right)) + continue + if current != wrong: + failures.append( + "%s: size is %d, expected %d before correction -- the analyzer's " + "output changed shape, re-verify before overriding" + % (addr, current, wrong)) + continue + text = text[:m.start(2)] + str(right) + text[m.end(2):] + print(" %s size %d -> %d" % (addr, current, right)) + + if failures: + for f in failures: + print("FAILED: " + f, file=sys.stderr) + return 1 + + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/regen_crash.sh b/tools/regen_crash.sh new file mode 100755 index 0000000..766d953 --- /dev/null +++ b/tools/regen_crash.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# Reproduces configs/crash_recomp.toml from the Crash Bandicoot 1 binary. +# Persists the --add-func entries that ps1Analyzer's heuristics miss +# (jumptable targets and a VBlank-counter symbol the prologue scan over-merges). +# +# GOOL interpreter: as of commit d04dcd7 (jump-table sizing fix), the game's +# own interpreter (func_800201DC) recompiles and runs correctly, so it is the +# default -- there is no [hle_overrides] entry for it unless requested. The +# hand-ported VM in ps1Runtime/src/gool/ remains in the build for comparison +# and is reachable with --gool-hle. +# +# Usage: +# tools/regen_crash.sh # regen toml only (native GOOL) +# tools/regen_crash.sh --recomp # regen toml + run ps1Recomp +# tools/regen_crash.sh --extra 0x80013B94 0x80013B30 # add transient adds +# tools/regen_crash.sh --gool-hle --recomp # replace the native GOOL +# interpreter (0x800201DC) with the hand-ported VM via [hle_overrides] + +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$PROJECT_DIR/build" +ANALYZER="$BUILD_DIR/ps1Analyzer/ps1Analyzer" +RECOMP="$BUILD_DIR/ps1Recomp/ps1Recomp" + +DISC="$PROJECT_DIR/test_roms/Crash Bandicoot /Crash Bandicoot (USA).bin" +OUT_TOML="$PROJECT_DIR/configs/crash_recomp.toml" +OUT_CPP="$PROJECT_DIR/ps1Runtime/src/recompiled_out.cpp" + +# Persistent --add-func entries the analyzer's heuristics miss for Crash. +# 0x80034504 — VBlank counter increment helper (prologue scan over-merges it +# into the surrounding function) +# 0x80016C18 — jumptable target inside the GTE setup loop +# 0x8002D638 — jumptable target inside the DMA init path +# 0x8002E8A4 — jumptable target inside the BSS clear loop +# 0x8001AC60 — jumptable target inside the GTE pointer setup +# 0x80025628 — jumptable target inside the post-NSD init dispatch +# (revealed after SWL/SWR emitter fix populated chunk[25]) +# 0x800466A0 — sound engine Timer0 tick (SPU completion poller). Registered +# via InterruptCallback(4, fn); only reached through the IRQ +# vector so the prologue scan over-merges it into func_8004636C. +# Delivers Event(0xF0000009, 0x20) that NS_waitForAllLoads +# slots 10/11 poll via TestEvent(9). +# 0x800256DC — sibling of 0x80025628 in the post-NSD dispatch family +# (dropped dispatch observed once main loop runs, RA=0x80013068) +ADD_FUNCS=( + 0x80034504 + 0x80016C18 + 0x8002D638 + 0x8002E8A4 + 0x8001AC60 + 0x80025628 + 0x800256DC + 0x800466A0 +) + +EXTRA_FUNCS=() +RUN_RECOMP=0 +GOOL_HLE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --recomp) RUN_RECOMP=1; shift ;; + --gool-hle) GOOL_HLE=1; shift ;; + --extra) + shift + while [[ $# -gt 0 && "$1" != --* ]]; do + EXTRA_FUNCS+=("$1"); shift + done + ;; + -h|--help) + sed -n '2,12p' "$0"; exit 0 ;; + *) + echo "unknown flag: $1" >&2; exit 1 ;; + esac +done + +if [[ ! -x "$ANALYZER" ]]; then + echo "ps1Analyzer not built at $ANALYZER" >&2 + echo " cmake --build $BUILD_DIR --target ps1Analyzer -j$(nproc)" >&2 + exit 1 +fi +if [[ ! -f "$DISC" ]]; then + echo "Crash disc not found at $DISC" >&2 + exit 1 +fi + +ARGS=("$DISC" "$OUT_TOML") +for addr in "${ADD_FUNCS[@]}" "${EXTRA_FUNCS[@]:-}"; do + [[ -z "$addr" ]] && continue + ARGS+=(--add-func "$addr") +done + +echo "[regen_crash] running ps1Analyzer with ${#ADD_FUNCS[@]} persistent + ${#EXTRA_FUNCS[@]} extra --add-func entries" +"$ANALYZER" "${ARGS[@]}" + +# Function-size corrections (2026-08-26) -- this is what made actors render. +# The analyzer truncates a function when a second entry point into its body +# is discovered as a JAL target; see tools/fix_crash_func_sizes.py for the +# full story on 0x80042FEC and the GTE translation it silently dropped. +echo "[regen_crash] correcting analyzer function sizes" +"$PROJECT_DIR/tools/fix_crash_func_sizes.py" "$OUT_TOML" + +# Wrappers the hash matcher CANNOT detect (T2, 2026-07-07): +# 0x80043498 / 0x80043984 are 8-instr wrappers in the dropped collision +# group (Task #25); 0x8003E754 is a trampoline through the libetc vtable +# (statically 0 in the binary). Route them to the HLE bodies explicitly. +echo "[regen_crash] appending [[hle_functions]]: CdSync/CdReadSync/InterruptCallback" +cat >> "$OUT_TOML" <<'EOF' + +[[hle_functions]] +subsystem = "CD-ROM" +name = "libcd_CdSync" +address = "0x80043498" +hle = true +stub_type = "recompile" + +[[hle_functions]] +subsystem = "CD-ROM" +name = "libcd_CdReadSync" +address = "0x80043984" +hle = true +stub_type = "recompile" + +[[hle_functions]] +subsystem = "Other" +name = "libetc_InterruptCallback" +address = "0x8003E754" +hle = true +stub_type = "recompile" +EOF + +if [[ "$GOOL_HLE" -eq 1 ]]; then + echo "[regen_crash] appending [hle_overrides]: 0x800201DC -> hle_gool_InterpretObject" + cat >> "$OUT_TOML" <<'EOF' + +# GOOL bytecode VM replaced by the HLE interpreter (gool_interp.cpp). +# Body-level override so direct JAL callers are intercepted too. +[hle_overrides] +"0x800201DC" = "hle_gool_InterpretObject" +EOF +fi + +if [[ "$RUN_RECOMP" -eq 1 ]]; then + if [[ ! -x "$RECOMP" ]]; then + echo "ps1Recomp not built at $RECOMP" >&2 + echo " cmake --build $BUILD_DIR --target ps1Recomp -j$(nproc)" >&2 + exit 1 + fi + echo "[regen_crash] running ps1Recomp -> $OUT_CPP" + "$RECOMP" "$OUT_TOML" "$OUT_CPP" + + # Suppression (2026-09-01) -- keeps the level playable. See the script's + # docstring: this is a workaround for callback damage during decompression, + # not a fix, and it must be reapplied after every regen. + echo "[regen_crash] suppressing decompressor drain" + "$PROJECT_DIR/tools/suppress_decomp_drain.py" "$OUT_CPP" +fi + +echo "[regen_crash] done." diff --git a/tools/suppress_decomp_drain.py b/tools/suppress_decomp_drain.py new file mode 100755 index 0000000..afe5f54 --- /dev/null +++ b/tools/suppress_decomp_drain.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Disable the injected callback yield point inside Crash's NSF decompressor. + +The recompiler injects `drainPendingCallbacks()` at every backward branch. In +`func_800334A0` (the NSF chunk decompressor) dispatching a callback there +damages the decompression in progress: the routine finishes with its output +count complete (r3 == r28) but a non-zero pending-run count (r11), and its exit +condition can then never be satisfied -- the only path that decrements r11 +requires r3 < r28. The game thread spins forever. + +Measured 2026-09-01, same binary, 5 runs each: + drain left in place -> hangs in most runs + drain suppressed -> 4/5 runs clean, level playable + +THIS IS SUPPRESSION, NOT A FIX. The damage is in memory, not registers -- +saving and restoring the whole register file around the callback made things +worse (1/6), which rules out register clobbering. Something a callback writes +corrupts the decompressor's working set. The real fix is to identify that +writer (arm PS1_WRITE_GUARD over 0x8008EA70..0x8008FCCC during a decompression, +after making the guard N-shot) and correct the cause. + +Until then this keeps the level playable. It is game-specific and must not be +baked into the emitter. + +Usage: + tools/suppress_decomp_drain.py ps1Runtime/src/recompiled_out.cpp +""" + +import re +import sys + +FUNC = "void func_800334A0(uint8_t* rdram, recomp_context* ctx) {" +# The decompressor's body ends where the next emitted function begins. +END = "void func_80033EF8(uint8_t* rdram" +DRAIN = " if (ctx->bios) ctx->bios->drainPendingCallbacks();" +REPLACEMENT = (" /* see tools/suppress_decomp_drain.py */ " + "if (ctx->bios) ctx->bios->pumpOnly();") + + +def main() -> int: + path = sys.argv[1] + with open(path, "r", encoding="utf-8", errors="surrogateescape") as fh: + lines = fh.read().split("\n") + + try: + start = next(i for i, l in enumerate(lines) if l.startswith(FUNC)) + end = next(i for i, l in enumerate(lines) if i > start and l.startswith(END)) + except StopIteration: + print("FAILED: func_800334A0 / func_80033EF8 not found -- the emitter's " + "output changed shape, re-verify before suppressing", file=sys.stderr) + return 1 + + n = 0 + for i in range(start, end): + if lines[i] == DRAIN: + lines[i] = REPLACEMENT + n += 1 + + if n == 0: + if any("suppressed: see tools/suppress_decomp_drain" in l + for l in lines[start:end]): + print(" already suppressed") + return 0 + print("FAILED: no drain call sites found inside func_800334A0", + file=sys.stderr) + return 1 + + with open(path, "w", encoding="utf-8", errors="surrogateescape") as fh: + fh.write("\n".join(lines)) + print(" suppressed %d drain site(s) in func_800334A0" % n) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 6069d6375be9acf5e8a8137986fa8db4b1ed8d2b Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Tue, 1 Sep 2026 16:01:01 -0300 Subject: [PATCH 13/29] runtime: add write guard and drain pump-only yield point --- ps1Runtime/include/runtime/bios/bios.h | 177 +++++++++- ps1Runtime/src/main_host.cpp | 434 ++++++++++++++++++++++++- 2 files changed, 601 insertions(+), 10 deletions(-) diff --git a/ps1Runtime/include/runtime/bios/bios.h b/ps1Runtime/include/runtime/bios/bios.h index be68bcd..628a02a 100644 --- a/ps1Runtime/include/runtime/bios/bios.h +++ b/ps1Runtime/include/runtime/bios/bios.h @@ -121,7 +121,148 @@ class Bios { // Drain pending event callbacks -- called from game thread at yield points // (testEvent, waitEvent, VSync, etc.) to safely dispatch mode-0x1000 // handlers. - void drainPendingCallbacks(); + // + // This is also the yield point the recompiler injects at *every backward + // branch*, so it runs orders of magnitude more often than it has work to + // do. The slow path below takes three mutexes and half a dozen atomic + // read-modify-writes; paying that per loop iteration costs more than the + // emulated loop body. So the entry point is a lock-free gate over the + // same set of pending flags, and the real work lives in + // `drainPendingCallbacksSlow`. Set `PS1_DRAIN_GATE=0` to bypass the gate + // and always take the slow path (A/B measurement). + void drainPendingCallbacks() { + ++drainCalls_; + if ((++pumpCounter_ & 0x3FFu) == 0) + pumpVBlank(); + if (spGuard_) + checkStackPointer(); + // A yield point is only safe where the guest has a usable stack. + // + // Hand-written PS1 assembly is free to save the whole register file to a + // context block and then use $sp as a general-purpose register -- Crash's + // model transform `func_80035E10` parks $sp in the scratchpad and packs + // GTE operands through it for the length of the routine. On hardware an + // interrupt there is harmless: the exception handler runs on its own + // stack. Here, dispatching a callback would run recompiled code that + // does `addiu $sp,-32; sw $ra,24($sp)` against a data value, so its + // locals land in nowhere -- measured: the sound tick's loop counter never + // read back what it wrote and span 1.4 billion times, wedging the game + // thread behind the re-entrancy guard. + // + // Deferring costs nothing: the callback stays queued and is dispatched at + // the next yield point that does have a stack, a few microseconds later. + if (!guestStackUsable()) { + ++drainDeferredNoStack_; + return; + } + if (drainGate_ && vsyncPtr_ != nullptr && + cdEventQueueDepth_.load(std::memory_order_relaxed) == 0 && + cdIntPending_.load(std::memory_order_relaxed) == 0 && + cdExceptionPending_.load(std::memory_order_relaxed) == 0 && + vblankExceptionPending_.load(std::memory_order_relaxed) == 0 && + !eventSystem_.hasPendingCallbacks() && + vsyncPtr_->load(std::memory_order_relaxed) == lastIntrTickFrame_) { + return; + } + drainPendingCallbacksSlow(); + } + + // Yield point that keeps the host clock running but delivers nothing. + // + // Inside a routine that must not be interrupted, the guest still needs the + // VBlank pump to advance -- the decompressor runs for millions of iterations + // and stopping the clock there wedges every VSync wait behind it. What it + // cannot tolerate is a dispatched callback: it finishes with the output + // count complete but a pending run count, and its exit condition can then + // never be met. So pump, check the stack, dispatch nothing. + void pumpOnly() { + ++drainCalls_; + if ((++pumpCounter_ & 0x3FFu) == 0) + pumpVBlank(); + if (spGuard_) + checkStackPointer(); + } + + void drainPendingCallbacksSlow(); + + // Host VBlank pump, run on the GAME thread. + // + // The 60 Hz tick used to live on its own thread, which meant the host + // advanced the guest clock, refreshed the pad buffer and snapshotted VRAM + // while recompiled code was running -- concurrent access to guest state that + // showed up as ~180 KB of globals turning to garbage in a single frame, + // roughly one run in four. Proven by A/B: adding any work to that thread + // made the corruption disappear. + // + // Driving the same tick from the game thread's own yield points removes the + // concurrency without losing the clock during spin-waits: loops that never + // call VSync (the NSF loader is one) still reach a backward branch, and the + // counter below keeps the pump cheap enough for that path. + void setVBlankPump(std::function pump) { + vblankPump_ = std::move(pump); + } + void pumpVBlank() { + if (vblankPump_) + vblankPump_(); + } + + // Global $sp watchpoint (`PS1_SP_GUARD=1`). + // + // The recompiler injects a drain at every backward branch, which makes this + // the one place every loop in the program passes through -- so validating + // the guest stack pointer here catches corruption at the first loop after + // it happens, anywhere, without knowing in advance which function to watch. + // A guest $sp must land in RAM or the scratchpad; anything else means a + // recompilation bug (a clobbered $sp save/restore, a bad computed jump) + // rather than game behaviour. + void checkStackPointer() { + const uint32_t sp = ctx_.r29; + const uint32_t masked = sp & 0x1FFFFFFFu; + if (masked < 0x200000u || + (masked >= 0x1F800000u && masked < 0x1F800400u)) + return; + reportBadStackPointer(sp); + } + + // Can a dispatched callback push a frame at the current guest $sp? + // + // Main RAM only: the 1 KiB scratchpad is small enough that routines which + // park $sp there are using it as working storage, so a callback frame would + // overwrite live data rather than fail loudly. A frame needs headroom + // below $sp, hence the floor. + bool guestStackUsable() const { + const uint32_t masked = ctx_.r29 & 0x1FFFFFFFu; + return masked >= 0x400u && masked < 0x200000u; + } + + // Diagnostic snapshot (PS1_METRICS). Written on the game thread, read + // from the reporting thread. + struct DrainMetrics { + uint64_t calls; // every injected yield point + uint64_t slow; // reached the slow path + uint64_t nested; // refused by the re-entrancy guard + uint64_t dispatched; + uint64_t noStack; // deferred: guest $sp was not a usable stack + }; + DrainMetrics drainMetrics() const { + return {drainCalls_, drainSlow_, drainNested_, drainDispatched_, + drainDeferredNoStack_}; + } + const EventSystem &eventSystem() const { return eventSystem_; } + + /// Re-entrancy guard for `drainPendingCallbacks`. + /// + /// A drained callback is recompiled game code, and the recompiler injects a + /// drain at every backward branch -- so a callback with a loop in it calls + /// back into the drain, which dispatches the same callback again. Measured in + /// Crash Bandicoot: the sound-engine tick `func_800466A0` re-entered without + /// bound, 85 million drain calls against 186 thousand real yield points, and + /// the game thread never returned to its main loop. + /// + /// Hardware has the same rule -- an interrupt handler does not re-enter the + /// dispatcher -- so refusing the nested call is the faithful behaviour, not a + /// workaround. + bool draining_ = false; // BSS mirrors for legacy MIPS polling. Phase 2.3/2.4 retired the BSS // writes of cd_sync_byte / cd_ready_byte in favor of `psyq_state()` @@ -187,6 +328,40 @@ class Bios { std::mutex cdEventQueueMtx_; std::queue cdEventQueue_; + // Lock-free mirror of `cdEventQueue_.size()`, written only under + // `cdEventQueueMtx_`. Lets the drain gate skip the lock when idle. + std::atomic cdEventQueueDepth_{0}; + + // Drain gate + accounting. `vsyncPtr_` caches + // `&psyq_state().vsyncCounter` so the gate does not pay for the + // singleton's initialisation guard; it is null until the first slow-path + // call, which forces the first drain through the slow path. + const bool drainGate_ = drainGateEnabled(); + std::atomic *vsyncPtr_ = nullptr; + static bool drainGateEnabled(); + + const bool spGuard_ = spGuardEnabled(); + static bool spGuardEnabled(); + // Out-of-line so the host backtrace has a real frame to walk from; the + // first few hits print where the recompiled code was when $sp went bad. + [[gnu::noinline]] void reportBadStackPointer(uint32_t sp); + unsigned badSpReports_ = 0; + + // Counters are game-thread-only writes; the reporting thread reads them + // without synchronisation, which is why they are plain integers read + // through a value-copy snapshot rather than atomics (a torn read costs a + // wrong diagnostic line, never behaviour). + uint64_t drainCalls_ = 0; + uint64_t drainSlow_ = 0; + uint64_t drainNested_ = 0; + uint64_t drainDispatched_ = 0; + uint64_t drainDeferredNoStack_ = 0; + + std::function vblankPump_; + // Yield points are hit tens of millions of times a second, so the pump is + // rate-limited by a plain counter rather than a clock read. + uint32_t pumpCounter_ = 0; + // Deferred CD exception -- set by triggerCdromEvent when a B0:0x19 handler // is registered. Consumed by drainPendingCallbacks, which calls // triggerCustomException() at a safe point (after the game enters its diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index 306656e..c8dd776 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -43,6 +46,104 @@ // SDL2 Audio Callback static ps1::spu::SPU *g_spu = nullptr; +// --------------------------------------------------------------------------- +// Write guard (`PS1_WRITE_GUARD=[,]`) +// +// Makes a span of guest RAM read-only and reports the *host* stack of whoever +// writes it first. Unlike a polled canary or a printf probe, this costs +// nothing until the illegal write happens, so it does not perturb the timing +// of the bug it is hunting -- which matters here, because every active probe +// tried so far suppressed the corruption instead of catching it. +// +// One-shot: the page is unprotected after the first report so the run +// continues and the same write is not re-reported for every store. +// --------------------------------------------------------------------------- +namespace { +uint8_t *g_guardBase = nullptr; +std::size_t g_guardLen = 0; +uint8_t *g_guardRamBase = nullptr; +volatile sig_atomic_t g_guardFired = 0; + +void guardSigsegv(int sig, siginfo_t *info, void *) { + uint8_t *fault = static_cast(info->si_addr); + if (fault < g_guardBase || fault >= g_guardBase + g_guardLen) { + // Not ours -- restore default so the real fault is not masked. + signal(sig, SIG_DFL); + return; + } + // Let the faulting store retry, then report. write(2) and backtrace() are + // the async-signal-safe-ish pair; fmt/printf are not used here on purpose. + mprotect(g_guardBase, g_guardLen, PROT_READ | PROT_WRITE); + if (!g_guardFired) { + g_guardFired = 1; + char buf[128]; + const uint32_t off = static_cast(fault - g_guardRamBase); + int n = snprintf(buf, sizeof buf, + "\n[WGUARD] escrita em 0x8%07X (phys 0x%X) -- pilha do host:\n", + off, off); + ssize_t ignored = write(2, buf, n); + (void)ignored; + void *bt[32]; + int frames = backtrace(bt, 32); + backtrace_symbols_fd(bt, frames, 2); + } +} + +// Arming is deferred to `PS1_WRITE_GUARD_AT=` so that boot-time writes +// by legitimate owners do not consume the one-shot before the game reaches the +// state under investigation. +uint32_t g_guardArmAt = 0; +bool g_guardArmed = false; + +void armWriteGuard() { + if (g_guardArmed || g_guardBase == nullptr) return; + g_guardArmed = true; + if (mprotect(g_guardBase, g_guardLen, PROT_READ) != 0) { + fmt::print(stderr, "[WGUARD] mprotect falhou ao armar\n"); + return; + } + fmt::print(stderr, "[WGUARD] armada em phys 0x{:X}..0x{:X}\n", + static_cast(g_guardBase - g_guardRamBase), + static_cast(g_guardBase - g_guardRamBase + g_guardLen)); +} + +void installWriteGuard(uint8_t *ramPtr) { + const char *spec = std::getenv("PS1_WRITE_GUARD"); + if (!spec || !*spec) return; + if (const char *at = std::getenv("PS1_WRITE_GUARD_AT")) + g_guardArmAt = std::strtoul(at, nullptr, 10); + char *end = nullptr; + const uint32_t guest = std::strtoul(spec, &end, 0); + unsigned pages = 1; + if (end && *end == ',') pages = std::strtoul(end + 1, nullptr, 0); + if (pages == 0) pages = 1; + + const long ps = sysconf(_SC_PAGESIZE); + const uint32_t phys = guest & 0x1FFFFFFFu; + uint8_t *want = ramPtr + phys; + uint8_t *aligned = reinterpret_cast( + reinterpret_cast(want) & ~static_cast(ps - 1)); + + g_guardRamBase = ramPtr; + g_guardBase = aligned; + g_guardLen = static_cast(ps) * pages; + + struct sigaction sa {}; + sa.sa_sigaction = guardSigsegv; + sa.sa_flags = SA_SIGINFO; + sigemptyset(&sa.sa_mask); + sigaction(SIGSEGV, &sa, nullptr); + + fmt::print(stderr, "[WGUARD] alvo phys 0x{:X}..0x{:X} ({} pagina(s)), " + "armar em vsync {}\n", + static_cast(g_guardBase - ramPtr), + static_cast(g_guardBase - ramPtr + g_guardLen), pages, + g_guardArmAt); + if (g_guardArmAt == 0) + armWriteGuard(); +} +} // namespace + // Forward declaration -- generated by ps1Recomp in recompiled_out.cpp extern void recomp_init_dispatch_table(); @@ -173,6 +274,23 @@ static bool dumpVramPpm(const ps1::gpu::GPU &gpu, const char *path) { return true; } + +// Follow goolobj->local (an nsentry) for the fields the GOOL render gate +// reads. Returns 0 when the pointer is not plausible RAM, so a bad entry +// shows up as zeros rather than as a crash. +static uint32_t nsw(ps1::Memory &mem, uint32_t obj, uint32_t off) { + const uint32_t local = mem.read32(obj + 32); + if ((local & 0x1FFFFFFFu) >= 0x200000u) + return 0; + return mem.read32(local + off); +} +static uint32_t nsCategory(ps1::Memory &mem, uint32_t obj) { + const uint32_t item0 = nsw(mem, obj, 16); + if ((item0 & 0x1FFFFFFFu) >= 0x200000u) + return 0xFFFFFFFFu; + return mem.read32(item0 + 4); +} + int main(int argc, char *argv[]) { if (argc < 2) { std::cerr << "Usage: ps1Runtime [disc_image] [--config " @@ -480,6 +598,26 @@ int main(int argc, char *argv[]) { // the shutdown VRAM is exactly the freshly-cleared state we are avoiding. bool frameDumpWritten = false; + const uint32_t shotEvery = + std::getenv("PS1_SHOT_EVERY") + ? std::strtoul(std::getenv("PS1_SHOT_EVERY"), nullptr, 10) + : 0; + const uint32_t shotFrom = + std::getenv("PS1_SHOT_FROM") + ? std::strtoul(std::getenv("PS1_SHOT_FROM"), nullptr, 10) + : 0; + const std::string shotDir = + std::getenv("PS1_SHOT_DIR") ? std::getenv("PS1_SHOT_DIR") : "/tmp/shots"; + uint32_t lastShotBucket = 0xFFFFFFFFu; + + uint32_t censusFrom = 0, censusTo = 0; + bool censusStarted = false, censusWritten = false; + if (const char *c = std::getenv("PS1_CENSUS")) { + censusFrom = std::strtoul(c, nullptr, 10); + if (const char *colon = std::strchr(c, ':')) + censusTo = std::strtoul(colon + 1, nullptr, 10); + } + // Initialize dispatch table before starting the game recomp_init_dispatch_table(); fmt::print("[Dispatch] Table initialized.\n"); @@ -554,6 +692,9 @@ int main(int argc, char *argv[]) { auto &st = ps1::psyq::psyq_state(); st.rcntTickAddr = readU32("rcnt_tick_addr"); st.rcntTicksPerVBlank = readU32("rcnt_ticks_per_vblank"); + st.gpuEnvAddr = readU32("gpu_env_addr"); + if (st.gpuEnvAddr != 0) + fmt::print("[timing] libgpu env block at 0x{:08X}\n", st.gpuEnvAddr); if (st.rcntTickAddr != 0 && st.rcntTicksPerVBlank != 0) fmt::print("[timing] rcnt tick 0x{:08X} += {} per VBlank\n", st.rcntTickAddr, st.rcntTicksPerVBlank); @@ -592,13 +733,92 @@ int main(int argc, char *argv[]) { std::getenv("PS1_AUTO_START_HOLD") ? std::strtoul(std::getenv("PS1_AUTO_START_HOLD"), nullptr, 10) : 8; - std::thread vblankThread([&]() { + // `PS1_AUTO_CROSS=` taps X every VBlanks. Menus advance on + // X, so an unattended run otherwise sits on the first screen that waits for + // it -- which reads as a hang but is the game doing exactly what it should. + const uint32_t autoCrossEvery = + std::getenv("PS1_AUTO_CROSS") + ? std::strtoul(std::getenv("PS1_AUTO_CROSS"), nullptr, 10) + : 0; + // `PS1_METRICS=` prints a per--VBlank delta panel: how many + // yield points the recompiled code hit, how many reached the drain slow + // path, how many were refused as re-entrant, and where dispatched callback + // time actually went. One run answers "is the drain the bottleneck", "is a + // callback being dispatched in a burst" and "which callback is slow" at + // once, instead of one hypothesis per rebuild. + // Named apart from PS1_METRICS, which metrics.cpp already reads as the + // output path for the shutdown JSON. + const bool inputTrace = std::getenv("PS1_INPUT_TRACE") != nullptr; + const bool canary = std::getenv("PS1_CANARY") != nullptr; + // The globals the corruption hits live around 0x80060000; grab 16 KB there. + constexpr uint32_t kCanaryBase = 0x80054000u; + constexpr uint32_t kCanarySize = 0x10000u; + const uint32_t metricsEvery = + std::getenv("PS1_DRAIN_METRICS") + ? std::strtoul(std::getenv("PS1_DRAIN_METRICS"), nullptr, 10) + : 0; + // `PS1_INPUT_SCRIPT="600:down,660:down,720:cross"` -- press a button at a + // given VBlank and release it 8 VBlanks later. Replaces hand-timed manual + // testing: a scripted run is repeatable, so a screenshot taken after a press + // is evidence about that press rather than about when a human hit the key. + struct ScriptedPress { + uint32_t atVsync; + uint16_t button; + }; + std::vector inputScript; + if (const char *scriptEnv = std::getenv("PS1_INPUT_SCRIPT")) { + using namespace ps1::input; + const std::pair names[] = { + {"up", BTN_UP}, {"down", BTN_DOWN}, + {"left", BTN_LEFT}, {"right", BTN_RIGHT}, + {"cross", BTN_CROSS}, {"circle", BTN_CIRCLE}, + {"square", BTN_SQUARE}, {"triangle", BTN_TRIANGLE}, + {"start", BTN_START}, {"select", BTN_SELECT}, + {"l1", BTN_L1}, {"r1", BTN_R1}, + }; + std::string spec(scriptEnv); + std::size_t pos = 0; + while (pos < spec.size()) { + std::size_t comma = spec.find(',', pos); + if (comma == std::string::npos) + comma = spec.size(); + const std::string item = spec.substr(pos, comma - pos); + pos = comma + 1; + const std::size_t colon = item.find(':'); + if (colon == std::string::npos) + continue; + const uint32_t at = std::strtoul(item.c_str(), nullptr, 10); + const std::string name = item.substr(colon + 1); + for (const auto &n : names) { + if (name == n.first) { + inputScript.push_back({at, n.second}); + fmt::print(stderr, "[script] vsync={} press {}\n", at, name); + break; + } + } + } + } + // VBlank tick -- runs on the GAME thread, driven from its yield points. + // + // This used to be a 60 Hz thread of its own. That put the host clock, the + // pad-buffer refresh and the VRAM snapshot on a different thread from the + // recompiled code, and the resulting race corrupted ~180 KB of guest globals + // in a single frame in roughly one run out of four. It was a Heisenbug: + // adding any work at all to that thread made it vanish, which is why every + // probe aimed at it came back clean. RecompOne -- the reference runtime that + // reaches gameplay -- is single-threaded for the same reason. + // + // Called from `Bios::drainPendingCallbacks` (every ~1024 yield points) and + // from the VSync wait, so the clock still advances inside spin-waits that + // never call VSync, such as the NSF loader. + auto vblankTick = [&]() { using namespace std::chrono; - const auto period = microseconds(16667); // ~60 Hz - auto next = steady_clock::now() + period; - while (!gameFinished.load(std::memory_order_acquire)) { - std::this_thread::sleep_until(next); - next += period; + static auto next = steady_clock::now(); + const auto now = steady_clock::now(); + if (now < next) + return; + next = now + microseconds(16667); // ~60 Hz + { uint32_t newCount = ps1::psyq::psyq_state().vsyncCounter.fetch_add( 1, std::memory_order_release) + 1; ps1::psyq::psyq_state().vblankPending.store( @@ -615,7 +835,64 @@ int main(int argc, char *argv[]) { fmt::print(stderr, "[auto] START release @vsync={}\n", newCount); } } + for (const auto &p : inputScript) { + if (newCount == p.atVsync) { + input.press(p.button, 0); + fmt::print(stderr, "[script] vsync={} down 0x{:04X}\n", newCount, + p.button); + } else if (newCount == p.atVsync + 8) { + input.release(p.button, 0); + } + } + if (autoCrossEvery != 0) { + const uint32_t phase = newCount % autoCrossEvery; + if (phase == 0) + input.press(ps1::input::BTN_CROSS, 0); + else if (phase == 8) + input.release(ps1::input::BTN_CROSS, 0); + } + if (g_guardArmAt != 0 && !g_guardArmed && newCount >= g_guardArmAt) + armWriteGuard(); if (watchGlobals) { + // GOOL actor state, named from the c1c decompilation's absolute + // addresses: whether the Crash object exists at all, how many objects + // are live, and how many the renderer accepted this frame. + // goolobj field offsets from the c1c decompilation's struct. + const uint32_t co = memory.read32(0x800566B4u); + if (co) { + fmt::print(stderr, + "[crash] state={} statusa=0x{:08X} statusb=0x{:08X} " + "statusc=0x{:08X} trans=({},{},{}) scale=({},{},{}) " + "animseq=0x{:08X} animframe={} displaymode=0x{:X} " + "entity=0x{:08X} zindex={} globanimflags=0x{:08X} src189C=0x{:08X} stateflags=0x{:08X} local=0x{:08X} " + "nsmagic=0x{:08X} nstype={} items={} item0=0x{:08X} category=0x{:X} " + "execanims=0x{:08X} animidx={} pc=0x{:08X} nsid=0x{:08X}\n", + memory.read32(co + 44), memory.read32(co + 200), + memory.read32(co + 204), memory.read32(co + 208), + (int32_t)memory.read32(co + 128), + (int32_t)memory.read32(co + 132), + (int32_t)memory.read32(co + 136), + (int32_t)memory.read32(co + 152), + (int32_t)memory.read32(co + 156), + (int32_t)memory.read32(co + 160), + memory.read32(co + 264), memory.read32(co + 268), + memory.read32(co + 296), memory.read32(co + 272), + memory.read32(co + 312), memory.read32(0x800618B0u), + memory.read32(0x8006189Cu), memory.read32(co + 288), memory.read32(co + 32), + // nsentry: magic, id, type, itemcount, items[]; the + // render gate reads category from items[0]+4. + nsw(memory, co, 0), nsw(memory, co, 8), nsw(memory, co, 12), + nsw(memory, co, 16), nsCategory(memory, co), + // items[5] is the exec's animation table; the render gate + // indexes it with the ChangeAnim instruction's anim field. + nsw(memory, co, 16 + 5 * 4), + (memory.read32(co + 264) - nsw(memory, co, 16 + 5 * 4)) / 4, + memory.read32(co + 224), nsw(memory, co, 4)); + } + fmt::print(stderr, + "[lvl] nextlevelid={} zone=0x{:08X} pad=0x{:08X}\n", + (int32_t)memory.read32(0x80056714u), + memory.read32(0x80057914u), memory.read32(0x8005E71Cu)); fmt::print(stderr, "[watch] vsync={} ticks={} frames_elapsed={} vblank={} " "title_state={} pad0=0x{:08X} raw=0x{:04X} " @@ -629,10 +906,121 @@ int main(int argc, char *argv[]) { ? memory.read16(memory.read32(0x8005791Cu) + 32) : 0xFFFF); } + // `PS1_INPUT_TRACE=1`: log only on change, so a human play session + // produces a short readable record of what the game actually received + // for each key press instead of thousands of identical lines. + if (inputTrace) { + const uint16_t raw = input.buttonState(0); + const uint32_t gamePad = memory.read32(0x8005E71Cu); + const uint32_t st = memory.read32(0x800618D4u); + const int32_t nextLevel = (int32_t)memory.read32(0x80056714u); + static uint16_t prevRaw = 0xFFFF; + static uint32_t prevGamePad = 0, prevSt = 0xFFFFFFFFu; + static int32_t prevNext = 0x7FFFFFFF; + if (raw != prevRaw || gamePad != prevGamePad || st != prevSt || + nextLevel != prevNext) { + fmt::print(stderr, + "[input] vsync={} tecla_raw=0x{:04X} pad[0]=0x{:08X} " + "pad[4]=0x{:08X} pad[8]=0x{:08X} pad[12]=0x{:08X} " + "title_state={} nextlevelid={} dono_carga=0x{:08X} " + "fila=0x{:08X}/0x{:08X} audio=[{},{},0x{:08X}] slot_som=0x{:08X}\n", + newCount, raw, gamePad, memory.read32(0x8005E720u), + memory.read32(0x8005E724u), memory.read32(0x8005E728u), + st, nextLevel, + // 0x8005CFB4 is the load pipeline's owner slot: state 10 + // waits for it to read 0, state 11 waits for it to name + // its own request. A stale owner deadlocks every load. + memory.read32(0x8005CFB4u), memory.read32(0x8005CFA8u), + memory.read32(0x8005CFACu), memory.read32(0x8005594Cu), + memory.read32(0x80055914u), memory.read32(0x800559A0u), + // 0x80055918 is the sound-tick's callback slot. While it + // is zero the tick delivers Event(0xF0000009,0x20), which + // is what the level loader waits on; once the game + // registers a handler there the tick calls that instead + // and the event stops arriving. + memory.read32(0x80055918u)); + prevRaw = raw; + prevGamePad = gamePad; + prevSt = st; + prevNext = nextLevel; + } + } + // `PS1_CANARY=1`: the boot instability corrupts ~180 KB of globals + // between two consecutive VBlanks with no bulk transfer to blame, so + // catching the value is not enough -- we need the *shape*. Keep a + // rolling copy of the globals region and, the first time the game state + // goes obviously wrong, write the previous and current copies out. + // Recognisable data in the "after" copy means something was written to + // the wrong place; noise means code ran wild. + if (canary) { + static std::vector prev(kCanarySize), cur(kCanarySize); + static bool primed = false, fired = false; + for (uint32_t i = 0; i < kCanarySize; ++i) + cur[i] = memory.read8(kCanaryBase + i); + // Trigger on the fill pattern itself: the level load overwrites the + // globals with a repeating word, so a known-small global holding it is + // an unambiguous "corruption happened here" signal. + const uint32_t ts = memory.read32(0x800618D4u); + const uint32_t gaf = memory.read32(0x800618B0u); + const bool wrecked = ts > 1000 || (gaf & 0xFF000000u) != 0; + if (!fired && primed && wrecked) { + fired = true; + FILE *fa = std::fopen("/tmp/canary_antes.bin", "wb"); + FILE *fb = std::fopen("/tmp/canary_depois.bin", "wb"); + if (fa) { std::fwrite(prev.data(), 1, prev.size(), fa); std::fclose(fa); } + if (fb) { std::fwrite(cur.data(), 1, cur.size(), fb); std::fclose(fb); } + uint32_t diff = 0; + for (uint32_t i = 0; i < kCanarySize; ++i) + if (prev[i] != cur[i]) ++diff; + fmt::print(stderr, + "[CANARIO] vsync={} title_state={} globanimflags=0x{:08X} bytes_alterados={}/{} " + "-- despejado em /tmp/canary_{{antes,depois}}.bin\n", + newCount, ts, gaf, diff, kCanarySize); + } + prev.swap(cur); + primed = true; + } + if (metricsEvery != 0 && newCount % metricsEvery == 0) { + static ps1::bios::Bios::DrainMetrics prev{}; + static uint32_t prevTicks = 0; + static uint64_t prevCalls[ps1::bios::EventSystem::kMaxCallbackStats]{}; + static uint64_t prevNanos[ps1::bios::EventSystem::kMaxCallbackStats]{}; + const auto m = bios.drainMetrics(); + const uint32_t ticks = memory.read32(0x80034520u); + fmt::print(stderr, + "[metrics] vsync={} ticks=+{} yields=+{} slow=+{} " + "nested=+{} queued=+{} nostack=+{}\n", + newCount, ticks - prevTicks, m.calls - prev.calls, + m.slow - prev.slow, m.nested - prev.nested, + m.dispatched - prev.dispatched, m.noStack - prev.noStack); + const auto *st = bios.eventSystem().callbackStats(); + for (std::size_t i = 0; i < ps1::bios::EventSystem::kMaxCallbackStats; + ++i) { + const uint32_t pc = st[i].pc.load(std::memory_order_relaxed); + if (pc == 0) + break; + const uint64_t c = st[i].calls.load(std::memory_order_relaxed); + const uint64_t n = st[i].nanos.load(std::memory_order_relaxed); + const uint64_t dc = c - prevCalls[i]; + const uint64_t dn = n - prevNanos[i]; + prevCalls[i] = c; + prevNanos[i] = n; + if (dc == 0) + continue; + fmt::print(stderr, "[metrics] cb 0x{:08X} calls=+{} ms=+{:.2f}\n", + pc, dc, dn / 1e6); + } + prev = m; + prevTicks = ticks; + } gpu.snapshotDisplayBuffer(); bios.updatePadBuffers(); } - }); + }; + bios.setVBlankPump(vblankTick); + + // Armed after the ELF loader's bulk copies, so only the running game trips it. + installWriteGuard(memory.ramPtr()); // Launch game thread std::thread gameThread([&]() { @@ -776,6 +1164,35 @@ int main(int argc, char *argv[]) { } } + // Primitive census over a VBlank window: `PS1_CENSUS=:`. + if (censusTo != 0) { + const uint32_t vblanks = + ps1::psyq::psyq_state().vsyncCounter.load(std::memory_order_acquire); + if (!censusStarted && vblanks >= censusFrom) { + censusStarted = true; + gpu.censusReset(); + } else if (censusStarted && !censusWritten && vblanks >= censusTo) { + censusWritten = true; + gpu.censusDump("/tmp/census.txt"); + fmt::print(stderr, "[census] written at vsync {}\n", vblanks); + } + } + + // Filmstrip: `PS1_SHOT_EVERY=` writes a VRAM dump every n VBlanks into + // `PS1_SHOT_DIR`, named by VBlank. One run then answers "did the screen + // change, and when" without re-running per question -- which is the only + // way to see whether a scripted button press had any effect. + if (shotEvery != 0) { + const uint32_t vblanks = + ps1::psyq::psyq_state().vsyncCounter.load(std::memory_order_acquire); + const uint32_t bucket = vblanks / shotEvery; + if (bucket != lastShotBucket && vblanks >= shotFrom) { + lastShotBucket = bucket; + dumpVramPpm(gpu, + fmt::format("{}/shot_{:05}.ppm", shotDir, vblanks).c_str()); + } + } + // Status every 5 seconds if (frameCount % 300 == 0) { fmt::print("[Frame {}] I_STAT=0x{:04X} I_MASK=0x{:04X}\n", frameCount, @@ -838,8 +1255,7 @@ int main(int argc, char *argv[]) { } // Join vblank ticker (gameFinished is already set above, so it will exit) - if (vblankThread.joinable()) - vblankThread.join(); + if (audioDevice > 0) { SDL_CloseAudioDevice(audioDevice); From 08a9ef148ad2d36eda4a9c91be4a4cbe878231c9 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 12 Sep 2026 13:57:02 -0300 Subject: [PATCH 14/29] runtime: fix SPU transfer address readback and DMA sound RAM destination --- ps1Runtime/include/runtime/spu/spu.h | 5 ++ ps1Runtime/src/dma/dma.cpp | 10 +++- ps1Runtime/src/spu/spu.cpp | 31 ++++++++++--- ps1Test/runtime/test_dma.cpp | 30 ++++++++++++ ps1Test/runtime/test_spu.cpp | 69 ++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/ps1Runtime/include/runtime/spu/spu.h b/ps1Runtime/include/runtime/spu/spu.h index 0e7b029..9ab6ec7 100644 --- a/ps1Runtime/include/runtime/spu/spu.h +++ b/ps1Runtime/include/runtime/spu/spu.h @@ -132,6 +132,11 @@ class SPU { uint16_t repeatAddr; // loop target, in 8-byte units bool loopFlag; bool endFlag; + // Envelope phase. Exposed because key-on and key-off precedence when both + // latches are set for the same voice is not observable any other way: no + // register reports the phase, and both outcomes can produce near-silent + // output for the first few samples. + AdsrPhase adsrPhase; }; VoiceDebugState debugVoiceState(uint32_t voiceIdx) const; diff --git a/ps1Runtime/src/dma/dma.cpp b/ps1Runtime/src/dma/dma.cpp index 2d2003e..22e76f5 100644 --- a/ps1Runtime/src/dma/dma.cpp +++ b/ps1Runtime/src/dma/dma.cpp @@ -230,8 +230,14 @@ void DMA::executeBlockTransfer(uint32_t ch) { break; case SPU_CH: if (spu_) { - spu_->writeSoundRam(i * 2, word & 0xFFFF); - spu_->writeSoundRam(i * 2 + 2, (word >> 16) & 0xFFFF); + // Write through the SPU's transfer address register, which the game + // programs before starting the DMA and which auto-increments. + // Addressing by the loop index instead restarted every upload at + // sound RAM 0, so each one overwrote the last and the voices -- which + // read from their own startAddr, far from zero -- found silence. + // Measured: 16 non-zero bytes in the whole 512 KB. + spu_->writeTransferData(word & 0xFFFF); + spu_->writeTransferData((word >> 16) & 0xFFFF); } break; case MDEC_IN: diff --git a/ps1Runtime/src/spu/spu.cpp b/ps1Runtime/src/spu/spu.cpp index e1c210b..1f279a6 100644 --- a/ps1Runtime/src/spu/spu.cpp +++ b/ps1Runtime/src/spu/spu.cpp @@ -263,6 +263,15 @@ uint16_t SPU::readRegister(uint32_t addr) const { return endxFlags_ & 0xFFFF; case 0x19E: return (endxFlags_ >> 16) & 0xFFFF; + // Transfer address, in 8-byte units -- the same encoding the write takes. + // + // Crash's SPU driver writes this register and then polls it back until the + // readback matches before it asks for DMA transfer mode. Returning 0 here + // made that wait time out (3841 spins), so the driver never set SPUCNT's + // transfer-mode bits, the DMA channel was never programmed, and sound RAM + // stayed empty -- 16 non-zero bytes out of 512 KB, and total silence. + case 0x1A6: + return static_cast(transferAddr_ / 8); case 0x1AA: return spuCtrl_; case 0x1AE: @@ -482,7 +491,8 @@ SPU::decodeAdpcmBlockForTest(const uint8_t block[ADPCM_BLOCK_SIZE], SPU::VoiceDebugState SPU::debugVoiceState(uint32_t voiceIdx) const { std::lock_guard lock(mutex_); const Voice &v = voices_[voiceIdx]; - return VoiceDebugState{v.currentAddr, v.repeatAddr, v.loopFlag, v.endFlag}; + return VoiceDebugState{v.currentAddr, v.repeatAddr, v.loopFlag, v.endFlag, + v.adsrPhase}; } // ADSR @@ -693,14 +703,23 @@ void SPU::processVoice(uint32_t voiceIdx, int32_t &outL, int32_t &outR, void SPU::generateSamples(int16_t *outputBuffer, uint32_t numSamples) { std::lock_guard lock(mutex_); - // Process key on/off latches + // Process key on/off latches. + // + // Hardware retires KON/KOFF at the 44.1 kHz sample clock; we only drain them + // once per audio callback (~23 ms of guest writes), so both bits are often + // set for the same voice. Applying both killed the voice at birth -- total + // silence, measured. Key-on wins, and the release is dropped. + // + // This is not faithful: notes sustain longer than they should, which is + // audible as overlap. Deferring the release by one callback instead was + // worse (the voice lived 23 ms), and applying both at write time also came + // out silent. The real fix is per-sample retirement inside the mixer; until + // then this is the variant that actually produces sound. for (uint32_t i = 0; i < NUM_VOICES; i++) { - if (keyOnLatch_ & (1 << i)) { + if (keyOnLatch_ & (1u << i)) keyOnVoice(i); - } - if (keyOffLatch_ & (1 << i)) { + else if (keyOffLatch_ & (1u << i)) keyOffVoice(i); - } } keyOnLatch_ = 0; keyOffLatch_ = 0; diff --git a/ps1Test/runtime/test_dma.cpp b/ps1Test/runtime/test_dma.cpp index 021e63d..a50cfea 100644 --- a/ps1Test/runtime/test_dma.cpp +++ b/ps1Test/runtime/test_dma.cpp @@ -1,5 +1,6 @@ #include "runtime/dma/dma.h" #include "runtime/memory.h" +#include "runtime/spu/spu.h" #include using namespace ps1; @@ -105,3 +106,32 @@ TEST_F(DmaTest, BlockTransferDoesntCrash) { EXPECT_EQ(dma.readRegister(0x1F8010C0), 0x00001000u); EXPECT_EQ(dma.readRegister(0x1F8010C4), 0x00010010u); } + +// SPU channel (Ch4) RAM -> sound RAM. +// +// Regression: the transfer used to address sound RAM by the loop index, so +// every upload restarted at sound RAM 0 and overwrote the previous one. The +// voices read from their own start addresses, far from zero, and found +// silence. The destination is the SPU's transfer address register, which the +// game programs before starting the DMA and which auto-increments. +TEST_F(DmaTest, SpuBlockTransferHonoursTheProgrammedTransferAddress) { + spu::SPU spu; + spu.reset(); + dma.setSPU(&spu); + + spu.writeRegister(0x1F801DA6, 0x80); // sound RAM byte address 0x400 + + mem.write32(0x1000, 0x22221111); + mem.write32(0x1004, 0x44443333); + + dma.writeRegister(0x1F8010F0, 0x08888888); // enable Ch4 + dma.writeRegister(0x1F8010C0, 0x00001000); // MADR + dma.writeRegister(0x1F8010C4, 0x00010002); // 1 block of 2 words + dma.writeRegister(0x1F8010C8, 0x11000001); // from RAM, burst, trigger+start + + EXPECT_EQ(spu.readSoundRam(0x400), 0x1111); + EXPECT_EQ(spu.readSoundRam(0x402), 0x2222); + EXPECT_EQ(spu.readSoundRam(0x404), 0x3333); + EXPECT_EQ(spu.readSoundRam(0x406), 0x4444); + EXPECT_EQ(spu.readSoundRam(0x000), 0x0000) << "must not restart at zero"; +} diff --git a/ps1Test/runtime/test_spu.cpp b/ps1Test/runtime/test_spu.cpp index 6ecda54..516e032 100644 --- a/ps1Test/runtime/test_spu.cpp +++ b/ps1Test/runtime/test_spu.cpp @@ -450,3 +450,72 @@ TEST(SpuControl, EndxFlagsClearedOnWrite) { // Initially 0 EXPECT_EQ(spu.readRegister(0x1F801D9C), 0); } + +// Sound RAM Transfer Address Tests +// +// Crash's SPU driver programs the transfer address and then polls the +// register back until the readback matches before switching SPUCNT to DMA +// transfer mode. A stubbed read made that wait spin forever, so sound RAM +// was never filled and the game was silent. + +TEST(SpuTransferAddress, ReadsBackTheValueWritten) { + SPU spu; + spu.reset(); + + spu.writeRegister(0x1F801DA6, 0x1234); + EXPECT_EQ(spu.readRegister(0x1F801DA6), 0x1234); +} + +TEST(SpuTransferAddress, DataWritesAdvanceTheAddress) { + SPU spu; + spu.reset(); + + spu.writeRegister(0x1F801DA6, 0x40); // byte address 0x200 + spu.writeTransferData(0xAAAA); + spu.writeTransferData(0xBBBB); + spu.writeTransferData(0xCCCC); + spu.writeTransferData(0xDDDD); + + EXPECT_EQ(spu.readSoundRam(0x200), 0xAAAA); + EXPECT_EQ(spu.readSoundRam(0x202), 0xBBBB); + EXPECT_EQ(spu.readSoundRam(0x204), 0xCCCC); + EXPECT_EQ(spu.readSoundRam(0x206), 0xDDDD); + // Eight bytes written, and the register counts in 8-byte units. + EXPECT_EQ(spu.readRegister(0x1F801DA6), 0x41); +} + +// Key On / Key Off Latch Tests + +TEST(SpuKeyLatch, KeyOnWinsWhenBothLatchesAreSetForTheSameVoice) { + // The latches are drained once per audio callback, which covers ~23 ms of + // guest register writes, so a voice keyed on and off inside that window + // arrives with both bits set. Applying both retires the voice before it + // ever plays, which measured as total silence in Crash. Key-on wins. + SPU spu; + spu.reset(); + + spu.writeRegister(0x1F801D88, 0x0001); // key on voice 0 + spu.writeRegister(0x1F801D8C, 0x0001); // key off voice 0 + + // Zero samples: drain the latches without letting the envelope advance, + // so the phase read back is the one the latches set. + int16_t buffer[2] = {}; + spu.generateSamples(buffer, 0); + + EXPECT_EQ(spu.debugVoiceState(0).adsrPhase, AdsrPhase::Attack); +} + +TEST(SpuKeyLatch, KeyOffAloneStillReleasesTheVoice) { + SPU spu; + spu.reset(); + + int16_t buffer[2] = {}; + spu.writeRegister(0x1F801D88, 0x0001); // key on voice 0 + spu.generateSamples(buffer, 0); + ASSERT_EQ(spu.debugVoiceState(0).adsrPhase, AdsrPhase::Attack); + + spu.writeRegister(0x1F801D8C, 0x0001); // key off voice 0 + spu.generateSamples(buffer, 0); + + EXPECT_EQ(spu.debugVoiceState(0).adsrPhase, AdsrPhase::Release); +} From 995c37e3f9488da39dc6b95f87cfbee4ac0d8533 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 12 Sep 2026 16:19:20 -0300 Subject: [PATCH 15/29] runtime: serialise CDROM sector hand-off between render and game threads --- .../include/runtime/cdrom/cdrom_controller.h | 78 ++++++++++++++++--- ps1Runtime/src/bios/bios.cpp | 46 +++++------ ps1Runtime/src/cdrom/cdrom_controller.cpp | 31 +++++++- ps1Runtime/src/dma/dma.cpp | 33 ++++---- ps1Test/runtime/test_cdrom_controller.cpp | 75 ++++++++++++++++++ 5 files changed, 212 insertions(+), 51 deletions(-) diff --git a/ps1Runtime/include/runtime/cdrom/cdrom_controller.h b/ps1Runtime/include/runtime/cdrom/cdrom_controller.h index fbfba68..c77b82d 100644 --- a/ps1Runtime/include/runtime/cdrom/cdrom_controller.h +++ b/ps1Runtime/include/runtime/cdrom/cdrom_controller.h @@ -11,6 +11,17 @@ * runs on the SDL render thread. IRQs raised from either thread are * funnelled through `interruptCallback` and ultimately queued for delivery * on the game thread by `Bios::queueCdromEvent` (Phase 3.3). + * + * Every public entry point takes `mtx_`, so the two threads cannot interleave + * inside the state machine. The lock is recursive because the interrupt + * callback re-enters the controller when it fires on the game thread + * (`Bios::queueCdromEvent` drains inline there, and the drain reads the + * sector). Consume a sector with `takeSectorPayload`, never by holding the + * pointer `getSectorBuffer` returns: `tick` memcpy's the next sector over + * that buffer, and a word-at-a-time reader used to splice two sectors + * together. Measured in Crash: one NSF page in ~25 runs came out corrupt, + * the LZ decompressor then never reached its terminating count, and it wrote + * bytes until it had wrapped 2 MB of guest RAM. */ #include @@ -18,6 +29,7 @@ #include #include #include +#include #include #include "runtime/cdrom/virtual_fs.h" @@ -69,20 +81,49 @@ class CdromController { void tick(uint32_t cycles); // Interrupt check - bool hasInterrupt() const { return interruptFlag_ != 0; } - uint8_t interruptFlag() const { return interruptFlag_; } + bool hasInterrupt() const { + std::lock_guard lk(mtx_); + return interruptFlag_ != 0; + } + uint8_t interruptFlag() const { + std::lock_guard lk(mtx_); + return interruptFlag_; + } void ackInterrupt(uint8_t val); // Clear the INT1 "waiting for ack" gate so tick() can deliver the next sector. // Call this AFTER the game's data callback has DMA-copied the current sector. void clearWaitingForAck(); - // Get sector data for DMA transfer + // Copy the ready sector's user-data payload out and clear the ready flag, + // both under the lock, so the render thread cannot drop the next sector on + // top of a half-read one. This is the only safe way to consume a sector. + // Returns the number of bytes written to `dst`, or 0 if none was ready. + uint32_t takeSectorPayload(uint8_t *dst, uint32_t maxBytes); + + // Raw access, kept for tests and diagnostics. Not safe to read across a + // `tick` from another thread -- use `takeSectorPayload` in the DMA and HLE + // paths instead. const uint8_t *getSectorBuffer() const { return sectorBuffer_.data(); } - uint32_t getSectorSize() const { return sectorSize_; } - uint8_t getMode() const { return mode_; } - bool hasSectorReady() const { return sectorReady_; } - void clearSectorReady() { sectorReady_ = false; } - uint32_t getCyclesPerSector() const { return cyclesPerSector_; } + uint32_t getSectorSize() const { + std::lock_guard lk(mtx_); + return sectorSize_; + } + uint8_t getMode() const { + std::lock_guard lk(mtx_); + return mode_; + } + bool hasSectorReady() const { + std::lock_guard lk(mtx_); + return sectorReady_; + } + void clearSectorReady() { + std::lock_guard lk(mtx_); + sectorReady_ = false; + } + uint32_t getCyclesPerSector() const { + std::lock_guard lk(mtx_); + return cyclesPerSector_; + } // XA-ADPCM callback for SPU using XaCallback = std::function; @@ -94,13 +135,22 @@ class CdromController { void setInterruptCallback(InterruptCallback cb) { interruptCallback_ = std::move(cb); } // State - CdromState getState() const { return state_; } + CdromState getState() const { + std::lock_guard lk(mtx_); + return state_; + } // HLE helper: stop an active read (equivalent to CdlPause from the game's perspective) - void stopReading() { state_ = CdromState::Idle; } + void stopReading() { + std::lock_guard lk(mtx_); + state_ = CdromState::Idle; + } // Returns true if a secondary response (e.g. INT2 after CdlInit INT3) is queued. - bool hasSecondaryResponse() const { return hasSecondaryResponse_; } + bool hasSecondaryResponse() const { + std::lock_guard lk(mtx_); + return hasSecondaryResponse_; + } // Deliver any queued secondary response immediately (e.g. INT2 after CdlInit). // Exposed publicly so the BIOS watchpoint can fire it from the game thread. @@ -110,6 +160,7 @@ class CdromController { // Used when the BIOS HLE has already delivered the interrupt synchronously // so the controller's own async response doesn't cause a duplicate event. void cancelPendingInterrupt() { + std::lock_guard lk(mtx_); interruptFlag_ = 0; hasSecondaryResponse_ = false; secondaryResponseDelay_ = 0; @@ -119,6 +170,11 @@ class CdromController { } private: + // Serialises the render thread's `tick` against the game thread's register + // and sector access. Recursive: the interrupt callback re-enters through + // `Bios::queueCdromEvent` when it fires on the game thread. + mutable std::recursive_mutex mtx_; + // State CdromState state_ = CdromState::Idle; uint8_t indexReg_ = 0; // Current index (0x1F801800 bits 0-1) diff --git a/ps1Runtime/src/bios/bios.cpp b/ps1Runtime/src/bios/bios.cpp index 4c39f02..384cc9d 100644 --- a/ps1Runtime/src/bios/bios.cpp +++ b/ps1Runtime/src/bios/bios.cpp @@ -1,5 +1,6 @@ #include "bios_internal.h" #include "runtime/bios/bios.h" +#include #include "runtime/cdrom/cdrom_controller.h" #include "runtime/dma/dma.h" #include "runtime/gpu/gpu.h" @@ -238,29 +239,30 @@ void Bios::triggerCdromEvent(uint8_t cdIntType) { // `CdReadCallback` (cdDataCb == 0), the drainPendingCallbacks pump // would dispatch nothing and ACK -- discarding the sector. Copy it // ourselves to the destination CdRead stashed in psyq_state. - if (state.cdRemaining > 0 && state.cdDataCb == 0 && - cdrom_->hasSectorReady() && state.cdDestPtr != 0) { - const uint8_t *sector = cdrom_->getSectorBuffer(); - uint32_t sectorSz = cdrom_->getSectorSize(); - // Raw sector (2352 bytes): 12-sync + 4-header + 8-subheader + data. - // sectorSize=2048 -> user data at +24; sectorSize=2340 -> +12. - uint32_t dataOff = (sectorSz == 2048) ? 24u : 12u; - uint32_t words = state.cdWordCount; - for (uint32_t i = 0; i < words; ++i) { - uint32_t off = dataOff + i * 4; - if (off + 3 >= 2352) - break; - uint32_t word = static_cast(sector[off]) | - (static_cast(sector[off + 1]) << 8) | - (static_cast(sector[off + 2]) << 16) | - (static_cast(sector[off + 3]) << 24); - ctx_.mem->write32(state.cdDestPtr + i * 4, word); + if (state.cdRemaining > 0 && state.cdDataCb == 0 && state.cdDestPtr != 0) { + // Take the whole payload in one step. The CDROM state machine ticks on + // the render thread and overwrites its sector buffer, so copying word by + // word out of `getSectorBuffer` used to splice two sectors together. + std::array payload{}; + const uint32_t got = + cdrom_->takeSectorPayload(payload.data(), payload.size()); + if (got > 0) { + uint32_t words = state.cdWordCount; + for (uint32_t i = 0; i < words; ++i) { + uint32_t off = i * 4; + if (off + 3 >= got) + break; + uint32_t word = static_cast(payload[off]) | + (static_cast(payload[off + 1]) << 8) | + (static_cast(payload[off + 2]) << 16) | + (static_cast(payload[off + 3]) << 24); + ctx_.mem->write32(state.cdDestPtr + i * 4, word); + } + state.cdDestPtr += words * 4; + state.cdRemaining -= 1; + if (state.cdRemaining == 0) + cdrom_->stopReading(); } - state.cdDestPtr += words * 4; - state.cdRemaining -= 1; - cdrom_->clearSectorReady(); - if (state.cdRemaining == 0) - cdrom_->stopReading(); } } diff --git a/ps1Runtime/src/cdrom/cdrom_controller.cpp b/ps1Runtime/src/cdrom/cdrom_controller.cpp index 9a46a19..fd827cb 100644 --- a/ps1Runtime/src/cdrom/cdrom_controller.cpp +++ b/ps1Runtime/src/cdrom/cdrom_controller.cpp @@ -35,6 +35,7 @@ uint8_t CdromController::fromBcd(uint8_t bcd) { CdromController::CdromController() { reset(); } void CdromController::reset() { + std::lock_guard lk(mtx_); state_ = CdromState::Idle; indexReg_ = 0; paramFifo_.clear(); @@ -62,11 +63,15 @@ void CdromController::reset() { commandPending_ = false; } -void CdromController::attachVirtualFs(VirtualFs *vfs) { vfs_ = vfs; } +void CdromController::attachVirtualFs(VirtualFs *vfs) { + std::lock_guard lk(mtx_); + vfs_ = vfs; +} // Status Byte void CdromController::fireSecondaryNow() { + std::lock_guard lk(mtx_); if (!hasSecondaryResponse_) return; hasSecondaryResponse_ = false; @@ -97,6 +102,7 @@ uint8_t CdromController::buildStatusByte() const { // Register I/O void CdromController::writeRegister(uint32_t addr, uint8_t val) { + std::lock_guard lk(mtx_); uint32_t port = addr & 3; CDROM_LOG("[CDROM-IO] Write port{}.idx{} = 0x{:02X}\n", port, indexReg_, val); @@ -184,6 +190,7 @@ void CdromController::writeRegister(uint32_t addr, uint8_t val) { } uint8_t CdromController::readRegister(uint32_t addr) { + std::lock_guard lk(mtx_); uint32_t port = addr & 3; if (port == 0) { @@ -229,16 +236,36 @@ uint8_t CdromController::readRegister(uint32_t addr) { // Interrupt void CdromController::ackInterrupt(uint8_t val) { + std::lock_guard lk(mtx_); interruptFlag_ &= ~(val & 0x1F); // NOTE: no longer clears waitingForAck_ here. // Use clearWaitingForAck() explicitly after the data callback has run. } -void CdromController::clearWaitingForAck() { waitingForAck_ = false; } +void CdromController::clearWaitingForAck() { + std::lock_guard lk(mtx_); + waitingForAck_ = false; +} // Tick +// Raw sector layout (2352 bytes): 12 sync + 4 header + 8 subheader + data. +// A 2048-byte read wants the user data at +24; a 2340-byte read keeps the +// subheader and starts at +12. +uint32_t CdromController::takeSectorPayload(uint8_t *dst, uint32_t maxBytes) { + std::lock_guard lk(mtx_); + if (!sectorReady_ || dst == nullptr || maxBytes == 0) + return 0; + const uint32_t dataOff = (sectorSize_ == 2048) ? 24u : 12u; + const uint32_t avail = SECTOR_SIZE_RAW - dataOff; + const uint32_t n = std::min(maxBytes, avail); + std::memcpy(dst, sectorBuffer_.data() + dataOff, n); + sectorReady_ = false; + return n; +} + void CdromController::tick(uint32_t cycles) { + std::lock_guard lk(mtx_); // Process pending command after delay if (commandPending_ && cyclesUntilResponse_ > 0) { if (cycles >= cyclesUntilResponse_) { diff --git a/ps1Runtime/src/dma/dma.cpp b/ps1Runtime/src/dma/dma.cpp index 22e76f5..c68fb0b 100644 --- a/ps1Runtime/src/dma/dma.cpp +++ b/ps1Runtime/src/dma/dma.cpp @@ -1,4 +1,5 @@ #include "runtime/dma/dma.h" +#include #include "runtime/cdrom/cdrom_controller.h" #include "runtime/gpu/gpu.h" #include "runtime/mdec/mdec.h" @@ -215,6 +216,13 @@ void DMA::executeBlockTransfer(uint32_t ch) { uint8_t *ram = mem_->ramPtr(); + // Snapshot the CDROM sector before touching guest RAM, so the whole + // transfer sees one sector and the ready flag is consumed atomically. + std::array payload{}; + uint32_t payloadBytes = 0; + if (ch == CDROM_CH && !fromRam && cdrom_) + payloadBytes = cdrom_->takeSectorPayload(payload.data(), payload.size()); + for (uint32_t i = 0; i < totalWords; i++) { uint32_t physAddr = addr & 0x1FFFFC; @@ -253,18 +261,15 @@ void DMA::executeBlockTransfer(uint32_t ch) { switch (ch) { case CDROM_CH: - if (cdrom_ && cdrom_->hasSectorReady()) { - const uint8_t *sector = cdrom_->getSectorBuffer(); - uint32_t sectorSz = cdrom_->getSectorSize(); - // Skip raw sector header to reach user data payload. - // Raw sector (2352 bytes): 12-sync + 4-header + 8-subheader + 2048-data - // sectorSize=2048 -> user data at offset 24 - // sectorSize=2340 -> sub-header+data at offset 12 - uint32_t dataOff = (sectorSz == 2048) ? 24 : 12; - uint32_t offset = dataOff + i * 4; - if (offset + 3 < 2352) { - word = sector[offset] | (sector[offset + 1] << 8) | - (sector[offset + 2] << 16) | (sector[offset + 3] << 24); + // `payload` was taken once, before the loop: the CDROM state machine + // ticks on the render thread and drops the next sector on top of the + // controller's buffer, so reading it word by word here spliced two + // sectors together. + if (payloadBytes > 0) { + const uint32_t offset = i * 4; + if (offset + 3 < payloadBytes) { + word = payload[offset] | (payload[offset + 1] << 8) | + (payload[offset + 2] << 16) | (payload[offset + 3] << 24); } } break; @@ -284,10 +289,6 @@ void DMA::executeBlockTransfer(uint32_t ch) { addr += step; } - - if (ch == CDROM_CH && cdrom_) { - cdrom_->clearSectorReady(); - } } void DMA::executeLinkedListTransfer(uint32_t ch) { diff --git a/ps1Test/runtime/test_cdrom_controller.cpp b/ps1Test/runtime/test_cdrom_controller.cpp index cad382e..603ea43 100644 --- a/ps1Test/runtime/test_cdrom_controller.cpp +++ b/ps1Test/runtime/test_cdrom_controller.cpp @@ -1,5 +1,7 @@ #include "runtime/cdrom/cdrom_controller.h" #include +#include +#include using namespace ps1::cdrom; @@ -85,3 +87,76 @@ TEST_F(CdromControllerTest, BcdConversion) { EXPECT_EQ(CdromController::fromBcd(0x59), 59); EXPECT_EQ(CdromController::fromBcd(0x10), 10); } + +// Sector hand-off +// +// `tick` runs on the render thread and memcpy's the next sector over the +// controller's buffer. A consumer that held the `getSectorBuffer` pointer and +// read it word by word could therefore splice two sectors together, which in +// Crash produced a corrupt NSF page and sent the LZ decompressor past the end +// of its output buffer. `takeSectorPayload` copies and clears in one locked +// step so a consumer always sees exactly one sector. + +namespace { +// Serves sector N filled with the byte N, so a spliced read is visible as a +// buffer that is not uniform. +class CountingFs : public ps1::cdrom::VirtualFs { +public: + std::optional readSector(uint32_t lba) override { + ps1::cdrom::Sector s{}; + std::memset(s.raw, static_cast(lba & 0xFF), ps1::cdrom::SECTOR_SIZE_RAW); + ++served; + return s; + } + int served = 0; +}; + +// Drive the controller until one sector is buffered. +void readOneSector(ps1::cdrom::CdromController &cdrom) { + cdrom.writeRegister(0x1F801800, 0x00); + cdrom.writeRegister(0x1F801802, 0x00); // minute + cdrom.writeRegister(0x1F801802, 0x02); // second + cdrom.writeRegister(0x1F801802, 0x00); // sector + cdrom.writeRegister(0x1F801801, 0x02); // SetLoc + cdrom.tick(100000); + cdrom.writeRegister(0x1F801801, 0x06); // ReadN + for (int i = 0; i < 40 && !cdrom.hasSectorReady(); ++i) + cdrom.tick(100000); +} +} // namespace + +TEST_F(CdromControllerTest, TakeSectorPayloadReturnsZeroWhenNoneReady) { + uint8_t buf[16] = {0xAA}; + EXPECT_EQ(cdrom.takeSectorPayload(buf, sizeof buf), 0u); + EXPECT_EQ(buf[0], 0xAA) << "must not touch the destination"; +} + +TEST_F(CdromControllerTest, TakeSectorPayloadCopiesUserDataAndConsumesTheSector) { + CountingFs fs; + cdrom.attachVirtualFs(&fs); + readOneSector(cdrom); + ASSERT_TRUE(cdrom.hasSectorReady()); + + std::array payload{}; + const uint32_t got = cdrom.takeSectorPayload(payload.data(), payload.size()); + + // Default mode is 2048-byte sectors: user data starts 24 bytes in. + EXPECT_EQ(got, ps1::cdrom::SECTOR_SIZE_RAW - 24u); + for (uint32_t i = 0; i < got; ++i) + ASSERT_EQ(payload[i], payload[0]) << "byte " << i << " came from elsewhere"; + + EXPECT_FALSE(cdrom.hasSectorReady()) << "the take must consume the sector"; + EXPECT_EQ(cdrom.takeSectorPayload(payload.data(), payload.size()), 0u); +} + +TEST_F(CdromControllerTest, TakeSectorPayloadHonoursTheDestinationSize) { + CountingFs fs; + cdrom.attachVirtualFs(&fs); + readOneSector(cdrom); + ASSERT_TRUE(cdrom.hasSectorReady()); + + uint8_t small[64]; + std::memset(small, 0, sizeof small); + EXPECT_EQ(cdrom.takeSectorPayload(small, sizeof small), sizeof small); + EXPECT_FALSE(cdrom.hasSectorReady()); +} From 343592fc02db2fb3ea49d621ab2b9ee4e18c2e7d Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 12 Sep 2026 16:19:34 -0300 Subject: [PATCH 16/29] runtime: make the write guard n-shot with per-writer dedup and value capture --- ps1Runtime/src/main_host.cpp | 239 +++++++++++++++++++++++++++++++---- 1 file changed, 215 insertions(+), 24 deletions(-) diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index c8dd776..b3b72a5 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -50,43 +51,196 @@ static ps1::spu::SPU *g_spu = nullptr; // Write guard (`PS1_WRITE_GUARD=[,]`) // // Makes a span of guest RAM read-only and reports the *host* stack of whoever -// writes it first. Unlike a polled canary or a printf probe, this costs -// nothing until the illegal write happens, so it does not perturb the timing -// of the bug it is hunting -- which matters here, because every active probe -// tried so far suppressed the corruption instead of catching it. +// writes it. Unlike a polled canary or a printf probe, this costs nothing +// until a write to the span happens, so it does not perturb the timing of the +// bug it is hunting -- which matters here, because every active probe tried so +// far suppressed the corruption instead of catching it. // -// One-shot: the page is unprotected after the first report so the run -// continues and the same write is not re-reported for every store. +// N-shot. The span is re-protected after each offending store, so legitimate +// writers no longer consume the whole instrument before the interesting one +// runs. Re-arming works by letting the store retry with the x86 trap flag +// set: the store completes, the resulting SIGTRAP re-protects the span. +// +// Reports are deduplicated by faulting host PC, so a writer in a loop is +// reported once and counted thereafter. Two budgets bound the cost: +// PS1_WRITE_GUARD_SHOTS= distinct PCs to report with a stack (def. 8) +// PS1_WRITE_GUARD_FAULTS= total faults before the guard gives up and +// unprotects for good (def. 20000) +// PS1_WRITE_GUARD_BYTES= report only writes landing in the first +// bytes from the requested address. mprotect +// works a page at a time, so watching one word +// inside a busy page otherwise drowns in its +// neighbours' traffic (def. the whole span). // --------------------------------------------------------------------------- namespace { uint8_t *g_guardBase = nullptr; std::size_t g_guardLen = 0; uint8_t *g_guardRamBase = nullptr; -volatile sig_atomic_t g_guardFired = 0; -void guardSigsegv(int sig, siginfo_t *info, void *) { +constexpr int kGuardMaxPcs = 64; +struct GuardWriter { + uintptr_t pc; + uint32_t firstOffset; + unsigned long count; + uint32_t lastValue; // word left behind by this writer's most recent store +}; +GuardWriter g_guardWriters[kGuardMaxPcs]; +volatile sig_atomic_t g_guardWriterCount = 0; +volatile sig_atomic_t g_guardStepping = 0; +// Set by the SIGSEGV handler so the SIGTRAP handler, which runs once the +// store has actually completed, can read back what was written. +volatile sig_atomic_t g_guardPendingSlot = -1; +uint8_t *g_guardPendingAddr = nullptr; +unsigned long g_guardFaults = 0; +unsigned g_guardShotLimit = 8; +unsigned long g_guardFaultBudget = 20000; +unsigned g_guardShots = 0; +uint8_t *g_guardWatchLo = nullptr; // reported window inside the guarded span +uint8_t *g_guardWatchHi = nullptr; +unsigned long g_guardIgnored = 0; + +// Re-protect the span once the offending store has completed. The SIGSEGV +// handler sets the trap flag before returning, so exactly one guest +// instruction runs before this fires. +void guardSigtrap(int sig, siginfo_t * /*info*/, void *ctx) { + if (!g_guardStepping) { + signal(sig, SIG_DFL); + return; + } + g_guardStepping = 0; + auto *uc = static_cast(ctx); + uc->uc_mcontext.gregs[REG_EFL] &= ~static_cast(0x100); + + const int slot = g_guardPendingSlot; + g_guardPendingSlot = -1; + if (slot >= 0 && g_guardPendingAddr != nullptr) { + uint32_t v; + memcpy(&v, g_guardPendingAddr, sizeof v); + g_guardWriters[slot].lastValue = v; + // A guest pointer outside the 2 MB of main RAM cannot be a valid target, + // so shout about it even when this writer's stack was already reported. + const uint32_t phys = v & 0x1FFFFFFFu; + if (v >= 0x80000000u && phys >= 0x200000u) { + char buf[128]; + int n = snprintf(buf, sizeof buf, + "[WGUARD] !! valor fora da RAM: 0x%08X escrito em " + "0x8%07X por pc=%p\n", + v, static_cast(g_guardPendingAddr - g_guardRamBase), + reinterpret_cast(g_guardWriters[slot].pc)); + ssize_t ignored = write(2, buf, n); + (void)ignored; + } + } + mprotect(g_guardBase, g_guardLen, PROT_READ); +} + +// Census of everyone who wrote the span, in a form usable from a signal +// handler. The run that matters is the one that crashes, and a crash never +// reaches the orderly shutdown path. +void guardCensusToStderr() { + char buf[192]; + int n = snprintf(buf, sizeof buf, + "[WGUARD] censo: %d escritor(es), %lu falta(s), " + "%lu fora da janela\n", + static_cast(g_guardWriterCount), g_guardFaults, + g_guardIgnored); + ssize_t ignored = write(2, buf, n); + for (int i = 0; i < g_guardWriterCount; i++) { + n = snprintf(buf, sizeof buf, + "[WGUARD] pc=%p 1a em 0x8%07X x%lu ultimo=0x%08X\n", + reinterpret_cast(g_guardWriters[i].pc), + g_guardWriters[i].firstOffset, g_guardWriters[i].count, + g_guardWriters[i].lastValue); + ignored = write(2, buf, n); + } + (void)ignored; +} + +void guardSigsegv(int sig, siginfo_t *info, void *ctx) { uint8_t *fault = static_cast(info->si_addr); if (fault < g_guardBase || fault >= g_guardBase + g_guardLen) { - // Not ours -- restore default so the real fault is not masked. + // Not ours -- this is the crash we are hunting, or an unrelated one. + // Report what the guard saw before the default action kills us. + guardCensusToStderr(); signal(sig, SIG_DFL); return; } - // Let the faulting store retry, then report. write(2) and backtrace() are - // the async-signal-safe-ish pair; fmt/printf are not used here on purpose. mprotect(g_guardBase, g_guardLen, PROT_READ | PROT_WRITE); - if (!g_guardFired) { - g_guardFired = 1; - char buf[128]; - const uint32_t off = static_cast(fault - g_guardRamBase); - int n = snprintf(buf, sizeof buf, - "\n[WGUARD] escrita em 0x8%07X (phys 0x%X) -- pilha do host:\n", - off, off); - ssize_t ignored = write(2, buf, n); + + auto *uc = static_cast(ctx); + const uintptr_t pc = static_cast(uc->uc_mcontext.gregs[REG_RIP]); + const uint32_t off = static_cast(fault - g_guardRamBase); + g_guardFaults++; + + if (fault < g_guardWatchLo || fault >= g_guardWatchHi) { + // Same page, different word. Count it and re-arm without reporting. + g_guardIgnored++; + if (g_guardFaults < g_guardFaultBudget) { + uc->uc_mcontext.gregs[REG_EFL] |= static_cast(0x100); + g_guardStepping = 1; + } + return; + } + + int slot = -1; + for (int i = 0; i < g_guardWriterCount; i++) { + if (g_guardWriters[i].pc == pc) { + slot = i; + break; + } + } + if (slot < 0 && g_guardWriterCount < kGuardMaxPcs) { + slot = g_guardWriterCount; + g_guardWriters[slot] = GuardWriter{pc, off, 0, 0}; + g_guardWriterCount = slot + 1; + + if (g_guardShots < g_guardShotLimit) { + g_guardShots++; + // write(2) and backtrace() are the async-signal-safe-ish pair; fmt and + // printf are deliberately not used here. + char buf[160]; + int n = snprintf(buf, sizeof buf, + "\n[WGUARD] #%u escrita em 0x8%07X (phys 0x%X) " + "pc=%p -- pilha do host:\n", + g_guardShots, off, off, reinterpret_cast(pc)); + ssize_t ignored = write(2, buf, n); + (void)ignored; + void *bt[32]; + int frames = backtrace(bt, 32); + backtrace_symbols_fd(bt, frames, 2); + } + } + if (slot >= 0) { + g_guardWriters[slot].count++; + g_guardPendingSlot = slot; + g_guardPendingAddr = fault; + } + + if (g_guardFaults >= g_guardFaultBudget) { + const char msg[] = "[WGUARD] orcamento de faltas esgotado -- guarda desligada\n"; + ssize_t ignored = write(2, msg, sizeof msg - 1); (void)ignored; - void *bt[32]; - int frames = backtrace(bt, 32); - backtrace_symbols_fd(bt, frames, 2); + return; // leave the span writable + } + + // Re-arm: let the store retry, then trap on the next instruction. + uc->uc_mcontext.gregs[REG_EFL] |= static_cast(0x100); + g_guardStepping = 1; +} + +// Called at shutdown so a run that caught several writers still reports the +// full census, not only the ones whose stack fit in the shot budget. +void dumpWriteGuard() { + if (g_guardBase == nullptr) + return; + mprotect(g_guardBase, g_guardLen, PROT_READ | PROT_WRITE); + if (g_guardWriterCount == 0) { + fmt::print(stderr, + "[WGUARD] nenhuma escrita na janela ({} falta(s) na pagina)\n", + g_guardFaults); + return; } + guardCensusToStderr(); } // Arming is deferred to `PS1_WRITE_GUARD_AT=` so that boot-time writes @@ -112,6 +266,13 @@ void installWriteGuard(uint8_t *ramPtr) { if (!spec || !*spec) return; if (const char *at = std::getenv("PS1_WRITE_GUARD_AT")) g_guardArmAt = std::strtoul(at, nullptr, 10); + if (const char *n = std::getenv("PS1_WRITE_GUARD_SHOTS")) + g_guardShotLimit = std::strtoul(n, nullptr, 10); + if (const char *n = std::getenv("PS1_WRITE_GUARD_FAULTS")) + g_guardFaultBudget = std::strtoul(n, nullptr, 10); + unsigned long watchBytes = 0; + if (const char *n = std::getenv("PS1_WRITE_GUARD_BYTES")) + watchBytes = std::strtoul(n, nullptr, 0); char *end = nullptr; const uint32_t guest = std::strtoul(spec, &end, 0); unsigned pages = 1; @@ -127,6 +288,15 @@ void installWriteGuard(uint8_t *ramPtr) { g_guardRamBase = ramPtr; g_guardBase = aligned; g_guardLen = static_cast(ps) * pages; + if (watchBytes > 0) { + g_guardWatchLo = want; + g_guardWatchHi = want + watchBytes; + if (g_guardWatchHi > g_guardBase + g_guardLen) + g_guardWatchHi = g_guardBase + g_guardLen; + } else { + g_guardWatchLo = g_guardBase; + g_guardWatchHi = g_guardBase + g_guardLen; + } struct sigaction sa {}; sa.sa_sigaction = guardSigsegv; @@ -134,11 +304,31 @@ void installWriteGuard(uint8_t *ramPtr) { sigemptyset(&sa.sa_mask); sigaction(SIGSEGV, &sa, nullptr); + struct sigaction st {}; + st.sa_sigaction = guardSigtrap; + st.sa_flags = SA_SIGINFO; + sigemptyset(&st.sa_mask); + sigaction(SIGTRAP, &st, nullptr); + + // The dispatcher aborts on an unmapped target, which is exactly the failure + // this guard is usually chasing. Print the census on the way out. + struct sigaction sab {}; + sab.sa_handler = [](int) { + guardCensusToStderr(); + signal(SIGABRT, SIG_DFL); + raise(SIGABRT); + }; + sigemptyset(&sab.sa_mask); + sigaction(SIGABRT, &sab, nullptr); + fmt::print(stderr, "[WGUARD] alvo phys 0x{:X}..0x{:X} ({} pagina(s)), " - "armar em vsync {}\n", + "armar em vsync {}, {} tiro(s), {} falta(s), " + "janela 0x{:X}..0x{:X}\n", static_cast(g_guardBase - ramPtr), static_cast(g_guardBase - ramPtr + g_guardLen), pages, - g_guardArmAt); + g_guardArmAt, g_guardShotLimit, g_guardFaultBudget, + static_cast(g_guardWatchLo - ramPtr), + static_cast(g_guardWatchHi - ramPtr)); if (g_guardArmAt == 0) armWriteGuard(); } @@ -1221,6 +1411,7 @@ int main(int argc, char *argv[]) { } gpu.publishMetrics(); ps1::metrics::dumpJson(); + dumpWriteGuard(); // Cleanup From 07fdb7eb66450fa3a5849c1334937c461af22f30 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 12 Sep 2026 21:37:38 -0300 Subject: [PATCH 17/29] recomp: report the originating guest site and host stack on an unresolved dispatch --- ps1Recomp/src/dispatch_emitter.cpp | 15 ++++++++-- ps1Recomp/src/instruction_emitter.cpp | 18 +++++------ ps1Recomp/src/main.cpp | 3 ++ .../include/runtime/ps1_runtime_macros.h | 30 +++++++++++++++++++ ps1Test/recompiler/test_dispatch_emitter.cpp | 20 +++++++++++++ ps1Test/recompiler/test_jump_analysis.cpp | 25 +++++++++++++++- 6 files changed, 99 insertions(+), 12 deletions(-) diff --git a/ps1Recomp/src/dispatch_emitter.cpp b/ps1Recomp/src/dispatch_emitter.cpp index 72cff6f..9e53c47 100644 --- a/ps1Recomp/src/dispatch_emitter.cpp +++ b/ps1Recomp/src/dispatch_emitter.cpp @@ -76,12 +76,23 @@ std::string emitDispatchBody() { return e && e[0] != '\0' && std::strcmp(e, "0") != 0; }(); if (!s_permissive) { + // `RA` names the caller only when the guest reached here through JALR. + // A direct JAL leaves the previous value in r31, so on those paths it + // points somewhere unrelated and has sent more than one investigation + // to the wrong function. The host stack always names the emitted + // function that issued the dispatch; resolve it with + // addr2line -f -C -e build/ps1Runtime/ps1Runtime fmt::print(stderr, - "[DISPATCH] FATAL: unmapped call to 0x{:08X} (RA=0x{:08X}, phys=0x{:08X})\n" + "[DISPATCH] FATAL: unmapped call to 0x{:08X} (phys=0x{:08X})\n" + " issued from guest site 0x{:08X}; RA=0x{:08X}\n" " This address was never emitted by the recompiler.\n" + " RA is stale on direct-JAL paths -- trust the site and the stack.\n" " Set PS1_DISPATCH_PERMISSIVE=1 to log and continue instead.\n", - addr, ctx->r[31], phys); + addr, phys, ps1LastIndirectSite(), ctx->r[31]); std::fflush(stderr); + void* bt[24]; + int frames = backtrace(bt, 24); + backtrace_symbols_fd(bt, frames, 2); std::abort(); } static std::unordered_map s_unknownHits; diff --git a/ps1Recomp/src/instruction_emitter.cpp b/ps1Recomp/src/instruction_emitter.cpp index 5dd58f4..2d8bec7 100644 --- a/ps1Recomp/src/instruction_emitter.cpp +++ b/ps1Recomp/src/instruction_emitter.cpp @@ -335,10 +335,10 @@ std::string InstructionEmitter::emitJump(const Instruction &inst, if (inst.rs == 31) { return "return;"; } - return fmt::format("JUMP_INDIRECT(ctx, {});", reg(inst.rs)); + return fmt::format("JUMP_INDIRECT_AT(ctx, {}, 0x{:08X}u);", reg(inst.rs), pc); case InstrId::JALR: - return fmt::format("ctx->r{} = 0x{:08X}; CALL_INDIRECT(ctx, {});", inst.rd, - pc + 8, reg(inst.rs)); + return fmt::format("ctx->r{} = 0x{:08X}; CALL_INDIRECT_AT(ctx, {}, 0x{:08X}u);", + inst.rd, pc + 8, reg(inst.rs), pc); default: return fmt::format("// UNKNOWN JUMP: {}", MipsDecoder::instrName(inst.id)); } @@ -556,11 +556,11 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { // it entered ends in `jr $ra`, which is a resume, not a return. Returning // anyway unwinds past this function's epilogue, so whatever it stashed on // entry -- $sp included -- is never restored. - auto indirectJump = [&](uint8_t rs) { + auto indirectJump = [&](uint8_t rs, uint32_t site) { if (raResume.empty()) - return fmt::format("JUMP_INDIRECT(ctx, {});", reg(rs)); + return fmt::format("JUMP_INDIRECT_AT(ctx, {}, 0x{:08X}u);", reg(rs), site); std::string code = - fmt::format("JUMP_INDIRECT_RESUME(ctx, {});\n", reg(rs)); + fmt::format("JUMP_INDIRECT_RESUME_AT(ctx, {}, 0x{:08X}u);\n", reg(rs), site); for (uint32_t target : raResume) { code += fmt::format(" if (ctx->r31 == 0x{:08X}u) goto {};\n", target, label(target)); @@ -618,7 +618,7 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { sw += fmt::format(" if (_sw_target == 0x{:08X}u) goto {};\n", target, label(target)); } - sw += fmt::format(" // fallback\n {}\n", indirectJump(inst.rs)); + sw += fmt::format(" // fallback\n {}\n", indirectJump(inst.rs, addr)); sw += " }"; code = sw; tabled = true; @@ -649,13 +649,13 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { ++n; } if (n == 0) { - code = indirectJump(inst.rs); + code = indirectJump(inst.rs, addr); } else { code = fmt::format("{{ // in-function labels ({} entries)\n" " uint32_t _sw_target = static_cast({});\n" "{}" " // fallback\n {}\n }}", - n, reg(inst.rs), sw, indirectJump(inst.rs)); + n, reg(inst.rs), sw, indirectJump(inst.rs, addr)); } } } diff --git a/ps1Recomp/src/main.cpp b/ps1Recomp/src/main.cpp index 2f96efe..98caac2 100644 --- a/ps1Recomp/src/main.cpp +++ b/ps1Recomp/src/main.cpp @@ -243,6 +243,9 @@ int main(int argc, char *argv[]) { std::string result_cpp = "// Generated by ps1Recomp\n"; result_cpp += "#include \n"; + // The fatal path in recomp_dispatch prints a host stack, because the + // guest RA is stale whenever the call came through a direct JAL. + result_cpp += "#include \n"; result_cpp += "#include \n"; result_cpp += "#include \n"; result_cpp += "#include \n"; diff --git a/ps1Runtime/include/runtime/ps1_runtime_macros.h b/ps1Runtime/include/runtime/ps1_runtime_macros.h index 208f329..392c388 100644 --- a/ps1Runtime/include/runtime/ps1_runtime_macros.h +++ b/ps1Runtime/include/runtime/ps1_runtime_macros.h @@ -178,6 +178,31 @@ inline uint32_t indirect_trace_ra_filter() { } \ } while (0) +// Guest address of the most recent indirect jump or call site. +// +// The dispatcher prints this when a target cannot be resolved. Neither of the +// other two clues is enough on its own: the guest `$ra` holds the return +// address of the last *direct* call, so it points somewhere unrelated on those +// paths, and the host stack names the emitted function but not which of its +// several indirect sites fired -- the GOOL interpreter alone has seven. +inline uint32_t &ps1LastIndirectSite() { + static uint32_t site = 0; + return site; +} + +#define PS1_SET_INDIRECT_SITE(pc) (ps1LastIndirectSite() = (pc)) + +#define CALL_INDIRECT_AT(ctx, addr, pc) \ + do { \ + PS1_SET_INDIRECT_SITE(pc); \ + CALL_INDIRECT(ctx, addr); \ + } while (0) +#define JUMP_INDIRECT_AT(ctx, addr, pc) \ + do { \ + PS1_SET_INDIRECT_SITE(pc); \ + JUMP_INDIRECT(ctx, addr); \ + } while (0) + #define CALL_INDIRECT(ctx, addr) \ do { \ PS1_INDIRECT_TRACE_HOOK(ctx, addr); \ @@ -197,4 +222,9 @@ inline uint32_t indirect_trace_ra_filter() { PS1_INDIRECT_TRACE_HOOK(ctx, addr); \ recomp_dispatch(rdram, ctx, static_cast(addr)); \ } while (0) +#define JUMP_INDIRECT_RESUME_AT(ctx, addr, pc) \ + do { \ + PS1_SET_INDIRECT_SITE(pc); \ + JUMP_INDIRECT_RESUME(ctx, addr); \ + } while (0) #define COP0_RFE(ctx) /* NOP for now */ diff --git a/ps1Test/recompiler/test_dispatch_emitter.cpp b/ps1Test/recompiler/test_dispatch_emitter.cpp index c5689c9..f2745d2 100644 --- a/ps1Test/recompiler/test_dispatch_emitter.cpp +++ b/ps1Test/recompiler/test_dispatch_emitter.cpp @@ -96,3 +96,23 @@ TEST(DispatchEmitter, NullDispatchStillReturnsQuietly) { } } // namespace + +// The site address is the only clue that survives every path. `RA` holds the +// return address of the last direct call, so it names an unrelated function +// whenever the bad jump came through one, and the host stack names the +// emitted function but not which of its indirect sites fired -- Crash's GOOL +// interpreter alone has seven. +TEST(DispatchEmitter, FatalMessageNamesTheIndirectSite) { + const std::string body = emitDispatchBody(); + EXPECT_NE(at(body, "ps1LastIndirectSite()"), std::string::npos); + EXPECT_NE(at(body, "issued from guest site 0x{:08X}"), std::string::npos); +} + +TEST(DispatchEmitter, FatalPathPrintsAHostStack) { + const std::string body = emitDispatchBody(); + EXPECT_NE(at(body, "backtrace_symbols_fd"), std::string::npos); + const size_t stackAt = at(body, "backtrace_symbols_fd"); + const size_t abortAt = at(body, "std::abort();"); + ASSERT_NE(abortAt, std::string::npos); + EXPECT_LT(stackAt, abortAt) << "the stack has to be printed before aborting"; +} diff --git a/ps1Test/recompiler/test_jump_analysis.cpp b/ps1Test/recompiler/test_jump_analysis.cpp index d623341..1a36a3e 100644 --- a/ps1Test/recompiler/test_jump_analysis.cpp +++ b/ps1Test/recompiler/test_jump_analysis.cpp @@ -258,7 +258,30 @@ TEST(HijackedReturnAddress, OrdinaryIndirectJumpStillReturns) { const std::string out = em.emitFunction(f); - EXPECT_NE(out.find("JUMP_INDIRECT(ctx, ctx->r20);"), std::string::npos) + // The plain form, which returns. Every indirect site also carries its own + // guest address so the dispatcher can name it when a target does not + // resolve, hence the _AT suffix and the trailing argument. + EXPECT_NE(out.find("JUMP_INDIRECT_AT(ctx, ctx->r20, 0x"), std::string::npos) << out; + EXPECT_EQ(out.find("JUMP_INDIRECT_RESUME"), std::string::npos) << out; EXPECT_EQ(out.find("ctx->r31 =="), std::string::npos) << out; } + +// An indirect call carries its own guest address too, not just the return +// address it writes to $ra. +TEST(IndirectSite, JalrRecordsTheCallSiteAddress) { + ps1recomp::InstructionEmitter em; + ps1recomp::RecompFunction f; + f.name = "callsite"; + f.address = 0x80010000u; + // jalr $ra, $t9 ; nop ; jr $ra ; nop + f.instructions = {0x0320F809u, 0x00000000u, 0x03E00008u, 0x00000000u}; + f.isLabelTarget.assign(f.instructions.size(), false); + f.size = static_cast(f.instructions.size() * 4); + + const std::string out = em.emitFunction(f); + + EXPECT_NE(out.find("CALL_INDIRECT_AT(ctx, ctx->r25, 0x80010000u);"), + std::string::npos) + << out; +} From 9ce2d4d3eb575679325d7498afdf2d9acc6df775 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sat, 12 Sep 2026 21:38:02 -0300 Subject: [PATCH 18/29] tools: drop the decompressor drain suppression, obsolete since the CDROM hand-off was serialised --- tools/regen_crash.sh | 10 ++--- tools/suppress_decomp_drain.py | 75 ---------------------------------- 2 files changed, 5 insertions(+), 80 deletions(-) delete mode 100755 tools/suppress_decomp_drain.py diff --git a/tools/regen_crash.sh b/tools/regen_crash.sh index 766d953..a6c7bbf 100755 --- a/tools/regen_crash.sh +++ b/tools/regen_crash.sh @@ -149,11 +149,11 @@ if [[ "$RUN_RECOMP" -eq 1 ]]; then echo "[regen_crash] running ps1Recomp -> $OUT_CPP" "$RECOMP" "$OUT_TOML" "$OUT_CPP" - # Suppression (2026-09-01) -- keeps the level playable. See the script's - # docstring: this is a workaround for callback damage during decompression, - # not a fix, and it must be reapplied after every regen. - echo "[regen_crash] suppressing decompressor drain" - "$PROJECT_DIR/tools/suppress_decomp_drain.py" "$OUT_CPP" + # The decompressor's callback yield points used to have to be suppressed + # here (tools/suppress_decomp_drain.py, since deleted). What actually + # damaged the decompression was the CDROM sector hand-off racing the render + # thread, not the callback: with that serialised, 18 consecutive runs load + # the level with the drains left in place. fi echo "[regen_crash] done." diff --git a/tools/suppress_decomp_drain.py b/tools/suppress_decomp_drain.py deleted file mode 100755 index afe5f54..0000000 --- a/tools/suppress_decomp_drain.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -"""Disable the injected callback yield point inside Crash's NSF decompressor. - -The recompiler injects `drainPendingCallbacks()` at every backward branch. In -`func_800334A0` (the NSF chunk decompressor) dispatching a callback there -damages the decompression in progress: the routine finishes with its output -count complete (r3 == r28) but a non-zero pending-run count (r11), and its exit -condition can then never be satisfied -- the only path that decrements r11 -requires r3 < r28. The game thread spins forever. - -Measured 2026-09-01, same binary, 5 runs each: - drain left in place -> hangs in most runs - drain suppressed -> 4/5 runs clean, level playable - -THIS IS SUPPRESSION, NOT A FIX. The damage is in memory, not registers -- -saving and restoring the whole register file around the callback made things -worse (1/6), which rules out register clobbering. Something a callback writes -corrupts the decompressor's working set. The real fix is to identify that -writer (arm PS1_WRITE_GUARD over 0x8008EA70..0x8008FCCC during a decompression, -after making the guard N-shot) and correct the cause. - -Until then this keeps the level playable. It is game-specific and must not be -baked into the emitter. - -Usage: - tools/suppress_decomp_drain.py ps1Runtime/src/recompiled_out.cpp -""" - -import re -import sys - -FUNC = "void func_800334A0(uint8_t* rdram, recomp_context* ctx) {" -# The decompressor's body ends where the next emitted function begins. -END = "void func_80033EF8(uint8_t* rdram" -DRAIN = " if (ctx->bios) ctx->bios->drainPendingCallbacks();" -REPLACEMENT = (" /* see tools/suppress_decomp_drain.py */ " - "if (ctx->bios) ctx->bios->pumpOnly();") - - -def main() -> int: - path = sys.argv[1] - with open(path, "r", encoding="utf-8", errors="surrogateescape") as fh: - lines = fh.read().split("\n") - - try: - start = next(i for i, l in enumerate(lines) if l.startswith(FUNC)) - end = next(i for i, l in enumerate(lines) if i > start and l.startswith(END)) - except StopIteration: - print("FAILED: func_800334A0 / func_80033EF8 not found -- the emitter's " - "output changed shape, re-verify before suppressing", file=sys.stderr) - return 1 - - n = 0 - for i in range(start, end): - if lines[i] == DRAIN: - lines[i] = REPLACEMENT - n += 1 - - if n == 0: - if any("suppressed: see tools/suppress_decomp_drain" in l - for l in lines[start:end]): - print(" already suppressed") - return 0 - print("FAILED: no drain call sites found inside func_800334A0", - file=sys.stderr) - return 1 - - with open(path, "w", encoding="utf-8", errors="surrogateescape") as fh: - fh.write("\n".join(lines)) - print(" suppressed %d drain site(s) in func_800334A0" % n) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 99816fd2e64b92262ab208f3112fedd264f6603f Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 19/29] build: track the analyzer's PsyQ HLE allow-list header --- .../include/ps1recomp/psyq_hle_allowlist.h | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 ps1Analyzer/include/ps1recomp/psyq_hle_allowlist.h diff --git a/ps1Analyzer/include/ps1recomp/psyq_hle_allowlist.h b/ps1Analyzer/include/ps1recomp/psyq_hle_allowlist.h new file mode 100644 index 0000000..96b401f --- /dev/null +++ b/ps1Analyzer/include/ps1recomp/psyq_hle_allowlist.h @@ -0,0 +1,21 @@ +#pragma once +// ps1Analyzer -- names the runtime's PsyQ HLE registry actually implements. +// +// Hash detection tells us a function *is* a known PsyQ routine. That is not +// the same as deciding to replace it: replacing means our hand-written C++ +// stands in for the real semantics, and every such substitution is a place the +// two can silently diverge. So detection is broad and replacement is narrow -- +// a function is only marked `hle = true` in the generated config when the name +// below exists, and everything else is recompiled from its own MIPS. +// +// Kept in sync with `psyq_register()` in ps1Runtime/src/psyq/. The e2e test +// `PsyqHleAllowList.MatchesRuntimeRegistry` fails if the two drift. + +#include + +namespace ps1recomp { + +/// True when the PsyQ HLE registry implements `name` (`_`). +bool psyqHleIsImplemented(const std::string& name); + +} // namespace ps1recomp From d7c0c3526f5b6cd9be3436d820e794554769817e Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 20/29] chore: ignore session notes, memory cards and local debugging scripts --- .gitignore | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index d27a6b3..ac25661 100644 --- a/.gitignore +++ b/.gitignore @@ -32,22 +32,28 @@ test_roms/ *.cue *.ecm -# Documentation -docs/ - # cache __pycache__/ *.pyc -# PsyQ SDK files (proprietary, never committed — kept outside repo) +# PsyQ SDK files *.LIB *.OBJ # Local artifacts .venv/ *.AppImage -.claude/ -.mcp.json -# psyq-obj-parser binary — rebuild from pcsx-redux clone, do not version +# psyq-obj-parser binary tools/psyq-obj-parser-bin/psyq-obj-parser + +# Emulated memory cards (per-machine save data, not source) +memcards/ + +# Local debugging scripts. +tools/coverage_frontier.py +tools/coverage_inject.py +tools/diff_oracle.py +tools/find_truncated_funcs.py +tools/jpsxdec.sh +tools/sp_sentry_inject.py From e5d9a2064a2e4a4f4ef962898cd829e15fb3d8ca Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 21/29] runtime: refine the GTE perspective divide the way the hardware does --- ps1Runtime/src/gte.cpp | 46 ++++++++++++------- .../recompiler/test_instruction_emitter.cpp | 30 ++++++++++++ ps1Test/runtime/test_gte.cpp | 43 +++++++++++++++++ 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/ps1Runtime/src/gte.cpp b/ps1Runtime/src/gte.cpp index 5c4ee72..d015e59 100644 --- a/ps1Runtime/src/gte.cpp +++ b/ps1Runtime/src/gte.cpp @@ -325,26 +325,40 @@ void GTE::pushRGB(CPUContext *ctx, uint8_t r, uint8_t g, uint8_t b, (static_cast(cd) << 24); } +// Perspective division, `(H * 0x20000) / SZ3` as the hardware computes it: +// normalise the divisor, look up a seed reciprocal, then refine it with two +// Newton-Raphson steps. psx-spx, "GTE Division Inaccuracy": +// +// z = count_leading_zeroes(SZ3) +// n = H << z ; d = SZ3 << z +// u = unr_table[(d - 0x7FC0) >> 7] + 0x101 +// d = (0x2000080 - d * u) >> 8 +// d = (0x0000080 + d * u) >> 8 +// n = min(0x1FFFF, (n * d + 0x8000) >> 16) +// +// Both refinement steps are what turn the ~0x101-scale seed into a 0x10000 +// scale reciprocal. Without them the quotient comes out ~100x too small and +// every projected vertex collapses onto the screen origin, which makes each +// triangle degenerate -- measured in Crash Bandicoot as 99.6% of ~800k +// triangles with zero area, SX2 = -1 where the hardware gives -123. uint32_t GTE::divide(CPUContext *ctx, uint16_t h, uint16_t sz3) { - if (sz3 == 0) { + // Overflow when the result would not fit 1.17: H >= SZ3*2. + if (sz3 == 0 || static_cast(h) >= static_cast(sz3) * 2) { ctx->cop2c[GTE_FLAG] |= gte_flag::DIV_OVERFLOW; return 0x1FFFF; } - if (static_cast(h) * 2 > static_cast(sz3)) { - ctx->cop2c[GTE_FLAG] |= gte_flag::DIV_OVERFLOW; - return 0x1FFFF; - } - int shift = countLeadingZeros(sz3) - 16; - uint64_t n = static_cast(h) << shift; - uint32_t d = static_cast(sz3) << shift; - uint32_t idx = (d >> 7) & 0xFF; - if (idx > 256) - idx = 256; - int32_t factor = s_unrTable[idx] + 0x101; - d = (d | 0x8000); - uint32_t res = static_cast( - std::min((n * factor + 0x8000) >> 16, 0x1FFFF)); - return res; + const int shift = countLeadingZeros(sz3) - 16; + const uint64_t n = static_cast(h) << shift; + uint64_t d = static_cast(sz3) << shift; + + // d is normalised to [0x8000, 0xFFFF], so the index lands in [0, 0x100]. + const uint32_t idx = static_cast((d - 0x7FC0) >> 7); + const uint64_t u = static_cast(s_unrTable[idx]) + 0x101; + + d = (0x2000080 - d * u) >> 8; + d = (0x0000080 + d * u) >> 8; + + return static_cast(std::min((n * d + 0x8000) >> 16, 0x1FFFF)); } // MVMVA core diff --git a/ps1Test/recompiler/test_instruction_emitter.cpp b/ps1Test/recompiler/test_instruction_emitter.cpp index a160b26..c4e03e3 100644 --- a/ps1Test/recompiler/test_instruction_emitter.cpp +++ b/ps1Test/recompiler/test_instruction_emitter.cpp @@ -197,6 +197,36 @@ TEST(InstructionEmitter, UnalignedStoreArgOrder) { << "DO_SWR must receive rt before addr; got: " << swr; } +// `Memory::read8`/`read16` return unsigned, so an emitted load carries no sign +// on its own -- the cast in the emitted expression is what distinguishes LB/LH +// from LBU/LHU. Without it every signed byte/halfword load zero-extends. +// +// Measured in Crash Bandicoot: the camera matrix built by func_80017A14 is +// `m = -(5*lh) >> 3` per element. With the halfword zero-extended, `lh` = -903 +// read as 64633 turned m[1][0] into 25140 instead of 564 -- six times over the +// 4096 = 1.0 scale. The GTE then projected every vertex behind the camera, SZ +// clamped to 0, the perspective divide saturated, and 99.96% of the game's +// triangles came out degenerate. +TEST(InstructionEmitter, SignedByteAndHalfwordLoadsSignExtend) { + auto emitter = makeEmitter(); + + // LB $v0, 4($a0) -- opcode 0x20 + auto lb = emitter.emitInstruction(MipsDecoder::decode(encI(0x20, 4, 2, 4)), 0); + EXPECT_NE(lb.find("(int8_t)MEM_READ8"), std::string::npos) + << "LB must sign-extend; got: " << lb; + + // LH $v0, 4($a0) -- opcode 0x21 + auto lh = emitter.emitInstruction(MipsDecoder::decode(encI(0x21, 4, 2, 4)), 0); + EXPECT_NE(lh.find("(int16_t)MEM_READ16"), std::string::npos) + << "LH must sign-extend; got: " << lh; + + // LBU/LHU stay unsigned. + auto lbu = emitter.emitInstruction(MipsDecoder::decode(encI(0x24, 4, 2, 4)), 0); + EXPECT_NE(lbu.find("(uint8_t)MEM_READ8"), std::string::npos) << lbu; + auto lhu = emitter.emitInstruction(MipsDecoder::decode(encI(0x25, 4, 2, 4)), 0); + EXPECT_NE(lhu.find("(uint16_t)MEM_READ16"), std::string::npos) << lhu; +} + // Branch Tests TEST(InstructionEmitter, BranchBEQ) { diff --git a/ps1Test/runtime/test_gte.cpp b/ps1Test/runtime/test_gte.cpp index 4eac594..37908e6 100644 --- a/ps1Test/runtime/test_gte.cpp +++ b/ps1Test/runtime/test_gte.cpp @@ -51,6 +51,49 @@ class GTETest : public ::testing::Test { } }; +// RTPS -- perspective division +// +// The divide seeds a reciprocal from the UNR table and then refines it with +// two Newton-Raphson steps. Skipping the refinement leaves a ~0x101-scale +// factor where a 0x10000-scale reciprocal belongs, so the quotient lands +// ~100x low and every vertex projects onto the screen origin. Measured in +// Crash Bandicoot as 99.6% of ~800k triangles collapsing to zero area. +// +// Numbers here are a real frame: MAC1 = -439 after the rotation, SZ3 = 5720, +// H = 800. The projection is IR1 * H / SZ3 = -439 * 800 / 5720 = -61.4, so +// SX2 must land there -- the unrefined divide gave -1. +TEST_F(GTETest, RTPS_PerspectiveDivideRefinesTheReciprocal) { + setIdentityRotation(); + setTranslation(0, 0, 5720); // vertex at the origin -> SZ3 = TRZ + setProjection(800, 0, 0, 0, 0); + setV0(-439, 0, 0); // identity rotation -> MAC1 = -439 + + GTE::RTPS(&ctx, true, false); + + EXPECT_EQ(ctx.cop2d[GTE_SZ3], 5720u); + const auto sx = static_cast(ctx.cop2d[GTE_SXY2] & 0xFFFF); + const auto sy = static_cast(ctx.cop2d[GTE_SXY2] >> 16); + EXPECT_NEAR(sx, -61, 2) << "quotient collapsed; got SX2 = " << sx; + EXPECT_EQ(sy, 0); +} + +// H >= SZ3*2 is the hardware's overflow condition. The inverted form +// (h*2 > sz3) fired on every near vertex and returned the 0x1FFFF clamp. +TEST_F(GTETest, RTPS_DivideOverflowOnlyWhenHIsAtLeastTwiceSZ3) { + setIdentityRotation(); + setProjection(800, 0, 0, 0, 0); + setV0(0, 0, 0); + + setTranslation(0, 0, 500); // H=800 >= 1000? no -> no overflow + GTE::RTPS(&ctx, true, false); + EXPECT_EQ(ctx.cop2c[GTE_FLAG] & gte_flag::DIV_OVERFLOW, 0u) + << "SZ3=500 with H=800 must not overflow"; + + setTranslation(0, 0, 300); // H=800 >= 600 -> overflow + GTE::RTPS(&ctx, true, false); + EXPECT_NE(ctx.cop2c[GTE_FLAG] & gte_flag::DIV_OVERFLOW, 0u); +} + // NCLIP TEST_F(GTETest, NCLIP_CounterClockwise) { From 8d502d22a8e0a09668b4a4dca63a64e6a03f870d Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 22/29] runtime: return the complement of the pad buffer from PadRead --- ps1Runtime/src/psyq/psyq_pad.cpp | 19 +++++++++-- ps1Test/runtime/test_psyq_pad.cpp | 52 ++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/ps1Runtime/src/psyq/psyq_pad.cpp b/ps1Runtime/src/psyq/psyq_pad.cpp index fe75e07..1bc520b 100644 --- a/ps1Runtime/src/psyq/psyq_pad.cpp +++ b/ps1Runtime/src/psyq/psyq_pad.cpp @@ -1,3 +1,4 @@ +#include #include "runtime/psyq/psyq_pad.h" #include "runtime/bios/bios.h" #include "runtime/input/input.h" @@ -147,8 +148,22 @@ void hle_libetc_PadRead(recomp_context *ctx) { writePadBuffer(ctx, input, g_state.buf1Addr, 0); writePadBuffer(ctx, input, g_state.buf2Addr, 1); - ctx->r[V0] = (static_cast(port2) << 16) | - static_cast(port1); + // PadRead returns the COMPLEMENT of the buffer, so a set bit means pressed: + // + // u_long PadRead(int id) { PAD_dr(id); return ~pad_buf; } + // + // (PsyQ libetc, per the psyz decompilation's src/libetc/pad.c.) The + // buffer itself stays active-low; only this entry point flips it, which is + // why PADLup/PADstart and friends are written as `if (pad & PADLup)`. + // + // Handing back the active-low word instead made every direction read as + // held: Crash's PAD_Update filters opposite directions with + // `if (pad & UP) pad &= ~DOWN`, which on an un-inverted word collapses to + // the same value whether or not anything is pressed -- so no press edge + // ever appeared, the menu cursor never moved, and the map camera spun as + // though a direction were stuck down. + ctx->r[V0] = ~((static_cast(port2) << 16) | + static_cast(port1)); } // Test-only state hooks diff --git a/ps1Test/runtime/test_psyq_pad.cpp b/ps1Test/runtime/test_psyq_pad.cpp index a3f6e8d..e156c35 100644 --- a/ps1Test/runtime/test_psyq_pad.cpp +++ b/ps1Test/runtime/test_psyq_pad.cpp @@ -74,12 +74,26 @@ TEST_F(PsyqPadTest, PadStartComStopComReturnZero) { EXPECT_EQ(ctx.r[V0], 0u); } -// PadRead -- packed (port2 << 16) | port1, active-low - -TEST_F(PsyqPadTest, PadReadIdleReturnsAllOnes) { - // No buttons pressed on either port -> 0xFFFFFFFF. +// PadRead -- packed (port2 << 16) | port1, ACTIVE-HIGH (set bit = pressed) +// +// Two different SDK entry points are called "pad read" and they disagree on +// polarity. BIOS B(0x16) fills the pad buffer and leaves it active-low; the +// libetc function this HLE stands in for calls that and returns its +// complement: +// +// u_long PadRead(int id) { PAD_dr(id); return ~pad_buf; } +// +// (psyz decompilation, src/libetc/pad.c.) Crash relies on the complement: +// its PAD_Update filters opposite directions with `if (pad & UP) pad &= ~DOWN` +// on the RETURN value, which is only meaningful when a set bit means pressed. +// Returning the active-low word instead made idle and Down-held produce the +// same 0x9FFF, so no press edge ever existed -- the menu cursor would not +// move and the map camera spun as if a direction were stuck. + +TEST_F(PsyqPadTest, PadReadIdleReturnsZero) { + // No buttons pressed on either port -> no bits set. hle_libetc_PadRead(&ctx); - EXPECT_EQ(ctx.r[V0], 0xFFFFFFFFu); + EXPECT_EQ(ctx.r[V0], 0u); } // PadRead's halves are active-low but byte-swapped relative to @@ -91,17 +105,17 @@ constexpr uint16_t PAD_RDOWN = 0x0040; // BTN_CROSS (controller bit 14) constexpr uint16_t PAD_RLEFT = 0x0080; // BTN_SQUARE (controller bit 15) constexpr uint16_t PAD_RRIGHT = 0x0020; // BTN_CIRCLE (controller bit 13) -TEST_F(PsyqPadTest, PadReadPressedBitsAreCleared) { - // Press CROSS on port 0 -> PADRdown cleared in the low half. +TEST_F(PsyqPadTest, PadReadPressedBitsAreSet) { + // Press CROSS on port 0 -> PADRdown set in the low half, nothing else. input.press(input::BTN_CROSS, 0); hle_libetc_PadRead(&ctx); - EXPECT_EQ(ctx.r[V0], 0xFFFFFFFFu & ~static_cast(PAD_RDOWN)); + EXPECT_EQ(ctx.r[V0], static_cast(PAD_RDOWN)); - // Add START on port 1 -> PADstart cleared in the high half. + // Add START on port 1 -> PADstart set in the high half. input.press(input::BTN_START, 1); hle_libetc_PadRead(&ctx); - uint32_t expected = static_cast(0xFFFFu & ~PAD_RDOWN) | - (static_cast(0xFFFFu & ~PAD_START) << 16); + uint32_t expected = static_cast(PAD_RDOWN) | + (static_cast(PAD_START) << 16); EXPECT_EQ(ctx.r[V0], expected); } @@ -117,7 +131,7 @@ TEST_F(PsyqPadTest, PadReadUsesPsyqButtonMaskNotControllerBitLayout) { hle_libetc_PadRead(&ctx); const uint16_t low = static_cast(ctx.r[V0] & 0xFFFFu); - EXPECT_EQ(static_cast(~low & 0xFFFFu), PAD_START); + EXPECT_EQ(low, PAD_START); // The raw controller bit must NOT be what the game sees. EXPECT_NE(static_cast(~low & 0xFFFFu), static_cast(input::BTN_START)); @@ -141,29 +155,29 @@ TEST_F(PsyqPadTest, PadReadPort0IsLowHalfPerRetailPadUpdateDisassembly) { uint16_t highHalf = static_cast((ctx.r[V0] >> 16) & 0xFFFFu); // CROSS (port 0) must land in the low half, not the high half. - EXPECT_EQ(lowHalf, static_cast(0xFFFFu & ~PAD_RDOWN)); - EXPECT_NE(highHalf, static_cast(0xFFFFu & ~PAD_RDOWN)); + EXPECT_EQ(lowHalf, PAD_RDOWN); + EXPECT_NE(highHalf, PAD_RDOWN); // SQUARE (port 1) must land in the high half, not the low half. - EXPECT_EQ(highHalf, static_cast(0xFFFFu & ~PAD_RLEFT)); - EXPECT_NE(lowHalf, static_cast(0xFFFFu & ~PAD_RLEFT)); + EXPECT_EQ(highHalf, PAD_RLEFT); + EXPECT_NE(lowHalf, PAD_RLEFT); } TEST_F(PsyqPadTest, PadReadReflectsRelease) { input.press(input::BTN_CIRCLE, 0); hle_libetc_PadRead(&ctx); - EXPECT_EQ(ctx.r[V0] & 0xFFFFu, static_cast(0xFFFFu & ~PAD_RRIGHT)); + EXPECT_EQ(ctx.r[V0] & 0xFFFFu, static_cast(PAD_RRIGHT)); input.release(input::BTN_CIRCLE, 0); hle_libetc_PadRead(&ctx); - EXPECT_EQ(ctx.r[V0], 0xFFFFFFFFu); + EXPECT_EQ(ctx.r[V0], 0u); } TEST_F(PsyqPadTest, PadReadFallsBackWhenNoBackend) { // Detach the input controller -- bios accessor returns nullptr. bios->setInputController(nullptr); hle_libetc_PadRead(&ctx); - EXPECT_EQ(ctx.r[V0], 0xFFFFFFFFu); + EXPECT_EQ(ctx.r[V0], 0u); // no backend reads as "nothing pressed" } // PadInitDirect -- 34-byte status buffer refresh From 837f587be1bdfd21486bc0e2f33d9e5584af3be1 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 23/29] runtime: emulate the PS1 memory card --- ps1Runtime/include/runtime/input/input.h | 55 +++-- ps1Runtime/src/input/input.cpp | 281 ++++++++++++++++++----- ps1Runtime/src/main_host.cpp | 30 +++ ps1Test/runtime/test_input.cpp | 228 ++++++++++++++++-- 4 files changed, 501 insertions(+), 93 deletions(-) diff --git a/ps1Runtime/include/runtime/input/input.h b/ps1Runtime/include/runtime/input/input.h index 0157d25..8da82b3 100644 --- a/ps1Runtime/include/runtime/input/input.h +++ b/ps1Runtime/include/runtime/input/input.h @@ -48,35 +48,60 @@ class MemoryCard { static constexpr uint32_t NUM_BLOCKS = 15; // 15 usable blocks static constexpr uint32_t SECTOR_SIZE = 128; // 128 bytes per sector + static constexpr uint32_t NUM_SECTORS = CARD_SIZE / SECTOR_SIZE; // 1024 + MemoryCard(); + /// Wipe and lay down a valid directory, as a freshly formatted card. void reset(); + + /// Back the card with `path`. An existing file is loaded; a missing one is + /// created from the formatted card. Later writes are flushed there. + bool attachFile(const std::string &path); + bool loadFromFile(const std::string &path); bool saveToFile(const std::string &path) const; - // SIO communication + /// One byte of an SIO exchange: returns what the card drives back. uint8_t transfer(uint8_t dataIn); + + /// True while the card still owes the host more bytes of a command. + bool transferActive() const { return cmd_ != Cmd::None; } + bool isPresent() const { return present_; } void setPresent(bool p) { present_ = p; } + /// Test seam: the raw 128 KB image. + const std::array &image() const { return data_; } + std::array &image() { return data_; } + + uint8_t flagByte() const { return flag_; } + private: - std::array data_; + // The card answers a fixed script per command, so the state is just "which + // command" plus "how many bytes into it". See psx-spx, + // controllersandmemorycards.md "Memory Card Read/Write Commands". + enum class Cmd : uint8_t { None, Await, Read, Write, GetId }; + + void formatImage(); + static uint8_t frameChecksum(const uint8_t *frame, std::size_t n); + void flush(); + + std::array data_{}; bool present_ = true; - // SIO state machine - enum class McState : uint8_t { - Idle, - AwaitCommand, - AwaitAddrHi, - AwaitAddrLo, - Reading, - Writing, - WriteAck - }; - McState state_ = McState::Idle; + Cmd cmd_ = Cmd::None; + uint32_t step_ = 0; // bytes exchanged since the command byte + uint8_t lastIn_ = 0; // the "(pre)" reply: echo of the previous byte uint16_t sectorAddr_ = 0; - uint32_t byteCounter_ = 0; - uint8_t checksum_ = 0; + uint8_t checksum_ = 0; // running MSB^LSB^data + uint8_t writeBuf_[SECTOR_SIZE]{}; + // Bit3 means "directory not read since insertion". Games clear it with a + // dummy write, and use it to sense a swapped card. + uint8_t flag_ = 0x08; + + std::string backingPath_; + bool dirty_ = false; }; // Input Controller diff --git a/ps1Runtime/src/input/input.cpp b/ps1Runtime/src/input/input.cpp index 25ffcbf..1eacb6e 100644 --- a/ps1Runtime/src/input/input.cpp +++ b/ps1Runtime/src/input/input.cpp @@ -1,4 +1,5 @@ #include "runtime/input/input.h" +#include "runtime/metrics.h" #include namespace ps1::input { @@ -7,18 +8,67 @@ namespace ps1::input { MemoryCard::MemoryCard() { reset(); } -void MemoryCard::reset() { +uint8_t MemoryCard::frameChecksum(const uint8_t *frame, std::size_t n) { + uint8_t x = 0; + for (std::size_t i = 0; i < n; i++) + x ^= frame[i]; + return x; +} + +// Lay down block 0 exactly as a Sony card leaves it after formatting. +// Without this the card reads back as blank and every game reports it +// unformatted or absent. Layout from psx-spx, +// controllersandmemorycards.md "Memory Card Data Format". +void MemoryCard::formatImage() { data_.fill(0); - state_ = McState::Idle; + + auto frame = [&](uint32_t index) { return data_.data() + index * SECTOR_SIZE; }; + + // Frame 0: header. "MC", zeroes, checksum of everything above it. + uint8_t *hdr = frame(0); + hdr[0] = 'M'; + hdr[1] = 'C'; + hdr[SECTOR_SIZE - 1] = frameChecksum(hdr, SECTOR_SIZE - 1); + + // Frames 1..15: directory, every block free and never used. + for (uint32_t i = 1; i <= 15; i++) { + uint8_t *d = frame(i); + std::memset(d, 0, SECTOR_SIZE); + d[0] = 0xA0; // free, freshly formatted + d[8] = 0xFF; // next block: none + d[9] = 0xFF; + d[SECTOR_SIZE - 1] = frameChecksum(d, SECTOR_SIZE - 1); + } + + // Frames 16..35: broken sector list, all "none". + for (uint32_t i = 16; i <= 35; i++) { + uint8_t *d = frame(i); + std::memset(d, 0, SECTOR_SIZE); + std::memset(d, 0xFF, 4); + d[SECTOR_SIZE - 1] = frameChecksum(d, SECTOR_SIZE - 1); + } + + // Frames 36..62: replacement data and unused, FFh-filled. + for (uint32_t i = 36; i <= 62; i++) + std::memset(frame(i), 0xFF, SECTOR_SIZE); + + // Frame 63: write-test frame, same shape as the header. + uint8_t *wt = frame(63); + std::memset(wt, 0, SECTOR_SIZE); + wt[0] = 'M'; + wt[1] = 'C'; + wt[SECTOR_SIZE - 1] = frameChecksum(wt, SECTOR_SIZE - 1); +} + +void MemoryCard::reset() { + formatImage(); + cmd_ = Cmd::None; + step_ = 0; + lastIn_ = 0; sectorAddr_ = 0; - byteCounter_ = 0; checksum_ = 0; - - // Initialize memory card header (block 0) - // Magic: "MC" at offset 0 - data_[0] = 'M'; - data_[1] = 'C'; - data_[0x7F] = 0x0E; // Checksum placeholder + flag_ = 0x08; + dirty_ = false; } bool MemoryCard::loadFromFile(const std::string &path) { @@ -37,73 +87,177 @@ bool MemoryCard::saveToFile(const std::string &path) const { return file.good(); } +bool MemoryCard::attachFile(const std::string &path) { + backingPath_ = path; + if (loadFromFile(path)) + return true; + // No file yet: keep the formatted image and write it out, so the card the + // game sees on this run is the one it will see on the next. + return saveToFile(path); +} + +// Written through after every accepted sector so a crash, or the fast exit +// that skips destructors, cannot lose a save. A sector write is 128 bytes of +// game data against a 128 KB file; a whole save file is 64 of them, which is +// well inside what this costs. +void MemoryCard::flush() { + if (!dirty_ || backingPath_.empty()) + return; + if (saveToFile(backingPath_)) + dirty_ = false; +} + +// One byte of an SIO exchange. +// +// Each command is a fixed script of byte pairs; `step_` counts how far in we +// are. "(pre)" in the spec means the card echoes the byte it received last, +// which is what `lastIn_` carries. Sequences from psx-spx, +// controllersandmemorycards.md "Memory Card Read/Write Commands". uint8_t MemoryCard::transfer(uint8_t dataIn) { - switch (state_) { - case McState::Idle: - if (dataIn == 0x81) { // Memory card access byte - state_ = McState::AwaitCommand; - return 0xFF; // Acknowledge flag + const uint8_t pre = lastIn_; + lastIn_ = dataIn; + + if (!present_) { + cmd_ = Cmd::None; + return 0xFF; + } + + if (cmd_ == Cmd::None) { + if (dataIn == 0x81) { // memory card address + cmd_ = Cmd::Await; + step_ = 0; } return 0xFF; + } - case McState::AwaitCommand: + if (cmd_ == Cmd::Await) { + step_ = 0; switch (dataIn) { - case 0x52: // Read - state_ = McState::AwaitAddrHi; - return 0x5A; // ID1 - case 0x57: // Write - state_ = McState::AwaitAddrHi; - return 0x5A; + case 0x52: + cmd_ = Cmd::Read; + break; + case 0x57: + cmd_ = Cmd::Write; + break; + case 0x53: + cmd_ = Cmd::GetId; + break; default: - state_ = McState::Idle; + cmd_ = Cmd::None; + if (ps1::metrics::enabled()) + ps1::metrics::count("memcard.cmd.unknown"); return 0xFF; } + // A save screen that shows nothing has two very different causes: the game + // never addressed the card, or it did and disliked the answer. Counting + // the commands separates them without a guess. + if (ps1::metrics::enabled()) + ps1::metrics::count(dataIn == 0x52 ? "memcard.cmd.read" + : dataIn == 0x57 ? "memcard.cmd.write" + : "memcard.cmd.getid"); + return flag_; // FLAG byte comes back with the command + } - case McState::AwaitAddrHi: - sectorAddr_ = static_cast(dataIn) << 8; - state_ = McState::AwaitAddrLo; - return 0x5D; // ID2 + const uint32_t step = step_++; + const bool badSector = sectorAddr_ >= NUM_SECTORS; - case McState::AwaitAddrLo: - sectorAddr_ |= dataIn; - byteCounter_ = 0; - checksum_ = static_cast(sectorAddr_ >> 8) ^ - static_cast(sectorAddr_ & 0xFF); - state_ = McState::Reading; - return 0x00; // ACK MSB - - case McState::Reading: { - uint32_t addr = - static_cast(sectorAddr_) * SECTOR_SIZE + byteCounter_; - uint8_t val = (addr < CARD_SIZE) ? data_[addr] : 0xFF; - checksum_ ^= val; - byteCounter_++; - if (byteCounter_ >= SECTOR_SIZE) { - state_ = McState::Idle; + if (cmd_ == Cmd::GetId) { + static const uint8_t reply[8] = {0x5A, 0x5D, 0x5C, 0x5D, + 0x04, 0x00, 0x00, 0x80}; + if (step >= 8) { + cmd_ = Cmd::None; + return 0xFF; } - return val; + if (step == 7) + cmd_ = Cmd::None; + return reply[step]; } - case McState::Writing: { - uint32_t addr = - static_cast(sectorAddr_) * SECTOR_SIZE + byteCounter_; - if (addr < CARD_SIZE) { - data_[addr] = dataIn; + if (cmd_ == Cmd::Read) { + switch (step) { + case 0: + return 0x5A; // ID1 + case 1: + return 0x5D; // ID2 + case 2: + sectorAddr_ = static_cast(dataIn) << 8; + return 0x00; + case 3: + sectorAddr_ = static_cast(sectorAddr_ | dataIn); + checksum_ = static_cast(sectorAddr_ >> 8) ^ + static_cast(sectorAddr_ & 0xFF); + return pre; + case 4: + return 0x5C; // command acknowledge 1 + case 5: + return 0x5D; // command acknowledge 2 + case 6: + // An out-of-range sector answers FFFFh and sends nothing further. + return badSector ? 0xFF : static_cast(sectorAddr_ >> 8); + case 7: + if (badSector) { + cmd_ = Cmd::None; + return 0xFF; + } + return static_cast(sectorAddr_ & 0xFF); + default: + break; } - checksum_ ^= dataIn; - byteCounter_++; - if (byteCounter_ >= SECTOR_SIZE) { - state_ = McState::WriteAck; + const uint32_t i = step - 8; + if (i < SECTOR_SIZE) { + const uint8_t v = data_[sectorAddr_ * SECTOR_SIZE + i]; + checksum_ ^= v; + return v; } - return 0x00; + if (i == SECTOR_SIZE) + return checksum_; + cmd_ = Cmd::None; + return 0x47; // "G": read good } - case McState::WriteAck: - state_ = McState::Idle; - return 0x47; // Good ("G") + // Cmd::Write + switch (step) { + case 0: + return 0x5A; + case 1: + return 0x5D; + case 2: + sectorAddr_ = static_cast(dataIn) << 8; + return 0x00; + case 3: + sectorAddr_ = static_cast(sectorAddr_ | dataIn); + checksum_ = static_cast(sectorAddr_ >> 8) ^ + static_cast(sectorAddr_ & 0xFF); + return pre; + default: + break; } - - return 0xFF; + const uint32_t i = step - 4; + if (i < SECTOR_SIZE) { + writeBuf_[i] = dataIn; + checksum_ ^= dataIn; + return pre; + } + if (i == SECTOR_SIZE) { + // The host's checksum byte. Keep it; the verdict goes out at the end. + checksum_ ^= dataIn; // zero iff the host agrees with us + return pre; + } + if (i == SECTOR_SIZE + 1) + return 0x5C; + if (i == SECTOR_SIZE + 2) + return 0x5D; + + cmd_ = Cmd::None; + if (badSector) + return 0xFF; // bad sector + if (checksum_ != 0) + return 0x4E; // "N": bad checksum, nothing written + std::memcpy(data_.data() + sectorAddr_ * SECTOR_SIZE, writeBuf_, SECTOR_SIZE); + dirty_ = true; + flag_ = static_cast(flag_ & ~0x08); // directory has now been touched + flush(); + return 0x47; // "G": write good } // Input Controller @@ -284,8 +438,15 @@ uint8_t InputController::processSioTransfer(uint8_t dataIn) { sioState_ = SioState::Idle; return port.analogLY; - case SioState::MemCardTransfer: - return memCards_[selectedPort_].transfer(dataIn); + case SioState::MemCardTransfer: { + const uint8_t out = memCards_[selectedPort_].transfer(dataIn); + // The card drops back to idle when a command's script runs out, so the + // port has to follow it -- otherwise the next 0x01 from the pad poll is + // fed to the card instead of starting a controller exchange. + if (!memCards_[selectedPort_].transferActive()) + sioState_ = SioState::Idle; + return out; + } } return 0xFF; diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index b3b72a5..9f20fad 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -552,6 +552,36 @@ int main(int argc, char *argv[]) { memory.setDMA(&dma); memory.setCDROM(&cdromCtrl); memory.setInput(&input); + + // Back both memory card slots with files, so a save survives the run. + // Default location is `memcards/` beside the config; `[paths] memcard_dir` + // overrides it. A missing file is created from a freshly formatted card. + { + std::filesystem::path cardDir = "memcards"; + if (!config_path.empty()) { + try { + auto cfg = toml::parse(config_path); + if (cfg.contains("paths")) { + auto &paths = toml::find(cfg, "paths"); + if (paths.contains("memcard_dir")) + cardDir = toml::find(paths, "memcard_dir"); + } + } catch (const std::exception &) { + // Paths are optional; the default stands. + } + } + std::error_code ec; + std::filesystem::create_directories(cardDir, ec); + for (int slot = 0; slot < 2; ++slot) { + const auto file = cardDir / fmt::format("card{}.mcd", slot + 1); + auto &card = input.getMemoryCard(slot); + if (card.attachFile(file.string())) + fmt::print("[MemCard] slot {} -> {}\n", slot + 1, file.string()); + else + fmt::print(stderr, "[MemCard] slot {}: could not use {}\n", slot + 1, + file.string()); + } + } memory.setMDEC(&mdec); memory.setTimers(&timers); memory.setInterruptController(&irqCtrl); diff --git a/ps1Test/runtime/test_input.cpp b/ps1Test/runtime/test_input.cpp index a1b7bce..c0f1bfa 100644 --- a/ps1Test/runtime/test_input.cpp +++ b/ps1Test/runtime/test_input.cpp @@ -1,4 +1,5 @@ #include "runtime/input/input.h" +#include #include using namespace ps1::input; @@ -62,32 +63,31 @@ TEST(MemCard, InitialState) { TEST(MemCard, SioAccessProtocol) { MemoryCard mc; - // Start communication - uint8_t resp = mc.transfer(0x81); - EXPECT_EQ(resp, 0xFF); // Flag byte + mc.reset(); - // Send read command - resp = mc.transfer(0x52); - EXPECT_EQ(resp, 0x5A); // ID1 + // The reply to the address byte is not defined by the card. + EXPECT_EQ(mc.transfer(0x81), 0xFF); - // Address high byte - resp = mc.transfer(0x00); - EXPECT_EQ(resp, 0x5D); // ID2 - - // Address low byte - resp = mc.transfer(0x00); - // Should return ACK + // The command byte is answered with the FLAG byte, not with ID1. ID1 and + // ID2 come on the two exchanges after it. This ordering is from psx-spx, + // controllersandmemorycards.md "Reading Data from Memory Card"; the test + // used to expect ID1 here, one byte too early. + EXPECT_EQ(mc.transfer(0x52), 0x08) << "FLAG, 08h before any write"; + EXPECT_EQ(mc.transfer(0x00), 0x5A) << "ID1"; + EXPECT_EQ(mc.transfer(0x00), 0x5D) << "ID2"; } -TEST(MemCard, WriteAndReadBack) { +TEST(MemCard, AbsentCardAnswersNothing) { MemoryCard mc; + mc.reset(); + mc.setPresent(false); + + EXPECT_EQ(mc.transfer(0x81), 0xFF); + EXPECT_EQ(mc.transfer(0x52), 0xFF); + EXPECT_FALSE(mc.transferActive()); - // Directly test file I/O persistence mc.setPresent(true); EXPECT_TRUE(mc.isPresent()); - - mc.setPresent(false); - EXPECT_FALSE(mc.isPresent()); } // SIO Register Tests @@ -104,3 +104,195 @@ TEST(InputSIO, JoyStatReadback) { uint32_t stat = input.readRegister(0x1F801044); EXPECT_NE(stat, 0); // TX ready flag should be set } + +// Memory Card +// +// Byte sequences are from psx-spx, controllersandmemorycards.md "Memory Card +// Read/Write Commands". The card answers a fixed script per command, so the +// tests drive whole exchanges and check every reply position that the spec +// pins down. + +namespace { +// Drives one exchange and collects the card's replies. +std::vector runExchange(ps1::input::MemoryCard &card, + const std::vector &send) { + std::vector got; + got.reserve(send.size()); + for (uint8_t b : send) + got.push_back(card.transfer(b)); + return got; +} + +std::vector readSector(ps1::input::MemoryCard &card, uint16_t sector) { + std::vector send{0x81, 0x52, 0x00, 0x00, + static_cast(sector >> 8), + static_cast(sector & 0xFF)}; + send.insert(send.end(), 4 + 128 + 2, 0x00); // acks, address echo, data, chk, end + return runExchange(card, send); +} +} // namespace + +TEST(MemoryCardProtocol, ReadReturnsTheSpecifiedHandshakeAndEndByte) { + ps1::input::MemoryCard card; + card.reset(); + + const auto got = readSector(card, 0); + + EXPECT_EQ(got[1], 0x08) << "FLAG byte, 08h before any write"; + EXPECT_EQ(got[2], 0x5A) << "ID1"; + EXPECT_EQ(got[3], 0x5D) << "ID2"; + EXPECT_EQ(got[6], 0x5C) << "command acknowledge 1"; + EXPECT_EQ(got[7], 0x5D) << "command acknowledge 2"; + EXPECT_EQ(got[8], 0x00) << "confirmed address MSB"; + EXPECT_EQ(got[9], 0x00) << "confirmed address LSB"; + EXPECT_EQ(got.back(), 0x47) << "end byte, 'G' for a good read"; +} + +TEST(MemoryCardProtocol, ReadChecksumIsAddressXorData) { + ps1::input::MemoryCard card; + card.reset(); + + const auto got = readSector(card, 0); + const std::size_t dataAt = 10; + + uint8_t expected = 0x00 ^ 0x00; // address MSB ^ LSB + for (std::size_t i = 0; i < 128; i++) + expected ^= got[dataAt + i]; + EXPECT_EQ(got[dataAt + 128], expected); +} + +TEST(MemoryCardProtocol, AFreshCardIsFormattedWithAValidHeader) { + ps1::input::MemoryCard card; + card.reset(); + + const auto got = readSector(card, 0); + const std::size_t dataAt = 10; + + EXPECT_EQ(got[dataAt + 0], 'M'); + EXPECT_EQ(got[dataAt + 1], 'C'); + uint8_t chk = 0; + for (std::size_t i = 0; i < 127; i++) + chk ^= got[dataAt + i]; + EXPECT_EQ(got[dataAt + 127], chk) << "header frame checksum"; +} + +TEST(MemoryCardProtocol, DirectoryFramesReportEveryBlockFree) { + ps1::input::MemoryCard card; + card.reset(); + + for (uint16_t frame = 1; frame <= 15; frame++) { + const auto got = readSector(card, frame); + EXPECT_EQ(got[10], 0xA0) << "frame " << frame << " allocation state"; + EXPECT_EQ(got[10 + 8], 0xFF) << "frame " << frame << " next-block pointer"; + EXPECT_EQ(got[10 + 9], 0xFF) << "frame " << frame; + } +} + +TEST(MemoryCardProtocol, WriteStoresTheSectorAndReadsBack) { + ps1::input::MemoryCard card; + card.reset(); + + std::vector payload(128); + for (std::size_t i = 0; i < payload.size(); i++) + payload[i] = static_cast(i * 3 + 1); + + const uint16_t sector = 0x40; // block 1, frame 0 + uint8_t chk = static_cast(sector >> 8) ^ + static_cast(sector & 0xFF); + for (uint8_t b : payload) + chk ^= b; + + std::vector send{0x81, 0x57, 0x00, 0x00, + static_cast(sector >> 8), + static_cast(sector & 0xFF)}; + send.insert(send.end(), payload.begin(), payload.end()); + send.push_back(chk); + send.insert(send.end(), 3, 0x00); // two acks plus the end byte + + const auto got = runExchange(card, send); + EXPECT_EQ(got.back(), 0x47) << "end byte, 'G' for a good write"; + + const auto back = readSector(card, sector); + for (std::size_t i = 0; i < payload.size(); i++) + ASSERT_EQ(back[10 + i], payload[i]) << "byte " << i; +} + +TEST(MemoryCardProtocol, WriteWithABadChecksumIsRejectedAndChangesNothing) { + ps1::input::MemoryCard card; + card.reset(); + + const uint16_t sector = 0x40; + std::vector send{0x81, 0x57, 0x00, 0x00, + static_cast(sector >> 8), + static_cast(sector & 0xFF)}; + send.insert(send.end(), 128, 0xAB); + send.push_back(0x00); // deliberately wrong + send.insert(send.end(), 3, 0x00); + + const auto got = runExchange(card, send); + EXPECT_EQ(got.back(), 0x4E) << "end byte, 'N' for a bad checksum"; + + const auto back = readSector(card, sector); + EXPECT_EQ(back[10], 0x00) << "a rejected write must not reach the card"; +} + +TEST(MemoryCardProtocol, WriteClearsTheDirectoryUnreadFlag) { + ps1::input::MemoryCard card; + card.reset(); + ASSERT_EQ(card.flagByte() & 0x08, 0x08); + + const uint16_t sector = 0x3F; + uint8_t chk = static_cast(sector >> 8) ^ + static_cast(sector & 0xFF); + std::vector send{0x81, 0x57, 0x00, 0x00, + static_cast(sector >> 8), + static_cast(sector & 0xFF)}; + send.insert(send.end(), 128, 0x00); + send.push_back(chk); + send.insert(send.end(), 3, 0x00); + runExchange(card, send); + + EXPECT_EQ(card.flagByte() & 0x08, 0x00) + << "bit3 is cleared by a write, which is how games sense a card swap"; +} + +TEST(MemoryCardProtocol, OutOfRangeSectorAnswersFfffAndSendsNoData) { + ps1::input::MemoryCard card; + card.reset(); + + const uint16_t sector = 0x400; // one past the last + std::vector send{0x81, 0x52, 0x00, 0x00, + static_cast(sector >> 8), + static_cast(sector & 0xFF), + 0x00, 0x00, 0x00, 0x00, 0x00}; + const auto got = runExchange(card, send); + + EXPECT_EQ(got[8], 0xFF) << "confirmed address MSB"; + EXPECT_EQ(got[9], 0xFF) << "confirmed address LSB"; + EXPECT_FALSE(card.transferActive()) << "the transfer aborts there"; +} + +TEST(MemoryCardProtocol, GetIdReportsSectorCountAndSectorSize) { + ps1::input::MemoryCard card; + card.reset(); + + const auto got = runExchange(card, {0x81, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00}); + EXPECT_EQ(got[2], 0x5A); + EXPECT_EQ(got[3], 0x5D); + EXPECT_EQ(got[4], 0x5C); + EXPECT_EQ(got[5], 0x5D); + EXPECT_EQ(got[6], 0x04); + EXPECT_EQ(got[7], 0x00); + EXPECT_EQ(got[8], 0x00); + EXPECT_EQ(got[9], 0x80); +} + +TEST(MemoryCardProtocol, AnUnknownCommandAbortsImmediately) { + ps1::input::MemoryCard card; + card.reset(); + + const auto got = runExchange(card, {0x81, 0x99, 0x00}); + EXPECT_EQ(got[1], 0xFF); + EXPECT_FALSE(card.transferActive()); +} From 66c42fad0d90d917e0c30c948d17e6362094af56 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 24/29] runtime: complete the keyboard pad mapping and print it at startup --- ps1Runtime/src/main_host.cpp | 100 +++++++++++++++++------------------ 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index 9f20fad..8e91c67 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -347,61 +347,58 @@ static void audioCallback(void * /*userdata*/, uint8_t *stream, int len) { } // SDL2 Key Mapping +// Keyboard to PS1 pad. One table, so the startup banner and the event handler +// cannot drift apart -- the pad layout is the first thing anyone needs to know +// to play, and "which key is Start" has already cost a play session. +struct KeyBinding { + SDL_Keycode key; + uint16_t button; + const char *keyName; + const char *padName; +}; + +static const KeyBinding kPadBindings[] = { + {SDLK_UP, ps1::input::BTN_UP, "Up", "D-Pad Up"}, + {SDLK_DOWN, ps1::input::BTN_DOWN, "Down", "D-Pad Down"}, + {SDLK_LEFT, ps1::input::BTN_LEFT, "Left", "D-Pad Left"}, + {SDLK_RIGHT, ps1::input::BTN_RIGHT, "Right", "D-Pad Right"}, + {SDLK_z, ps1::input::BTN_CROSS, "Z", "Cross"}, + {SDLK_x, ps1::input::BTN_CIRCLE, "X", "Circle"}, + {SDLK_a, ps1::input::BTN_SQUARE, "A", "Square"}, + {SDLK_s, ps1::input::BTN_TRIANGLE, "S", "Triangle"}, + {SDLK_q, ps1::input::BTN_L1, "Q", "L1"}, + {SDLK_w, ps1::input::BTN_R1, "W", "R1"}, + {SDLK_e, ps1::input::BTN_L2, "E", "L2"}, + {SDLK_r, ps1::input::BTN_R2, "R", "R2"}, + {SDLK_c, ps1::input::BTN_L3, "C", "L3"}, + {SDLK_v, ps1::input::BTN_R3, "V", "R3"}, + {SDLK_RETURN, ps1::input::BTN_START, "Enter", "Start"}, + {SDLK_RSHIFT, ps1::input::BTN_SELECT, "Right Shift", "Select"}, + {SDLK_BACKSPACE, ps1::input::BTN_SELECT, "Backspace", "Select"}, +}; + +static void printPadBindings() { + fmt::print("[pad] keyboard layout:\n"); + for (const auto &b : kPadBindings) + fmt::print("[pad] {:<12} -> {}\n", b.keyName, b.padName); + fmt::print("[pad] {:<12} -> {}\n", "Esc", "quit"); +} + static void mapKeyToButton(SDL_Keycode key, ps1::input::InputController &input, bool pressed) { - using namespace ps1::input; - uint16_t btn = 0; - switch (key) { - case SDLK_UP: - btn = BTN_UP; - break; - case SDLK_DOWN: - btn = BTN_DOWN; - break; - case SDLK_LEFT: - btn = BTN_LEFT; - break; - case SDLK_RIGHT: - btn = BTN_RIGHT; - break; - case SDLK_z: - btn = BTN_CROSS; - break; - case SDLK_x: - btn = BTN_CIRCLE; - break; - case SDLK_a: - btn = BTN_SQUARE; - break; - case SDLK_s: - btn = BTN_TRIANGLE; - break; - case SDLK_q: - btn = BTN_L1; - break; - case SDLK_w: - btn = BTN_R1; - break; - case SDLK_e: - btn = BTN_L2; - break; - case SDLK_r: - btn = BTN_R2; - break; - case SDLK_RETURN: - btn = BTN_START; - break; - case SDLK_RSHIFT: - case SDLK_BACKSPACE: - btn = BTN_SELECT; - break; - default: + for (const auto &b : kPadBindings) { + if (b.key != key) + continue; + // Printed on every press: a button that does nothing in the game is a + // different problem from a button that never reached the pad at all. + if (pressed) + fmt::print(stderr, "[pad] {} -> {}\n", b.keyName, b.padName); + if (pressed) + input.press(b.button, 0); + else + input.release(b.button, 0); return; } - if (pressed) - input.press(btn, 0); - else - input.release(btn, 0); } // Constants @@ -527,6 +524,7 @@ int main(int argc, char *argv[]) { return 1; } fmt::print("ps1Runtime -- PS1 Hardware Simulation (Full Integration)\n"); + printPadBindings(); // Initialize all hardware subsystems From 0dd14189bebd74339fca84f49060b63af45662d2 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 25/29] runtime: drive the hardware tick from the wall clock instead of the presented frame --- ps1Runtime/src/main_host.cpp | 110 ++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index 8e91c67..ea693dc 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -228,6 +228,32 @@ void guardSigsegv(int sig, siginfo_t *info, void *ctx) { g_guardStepping = 1; } +// Presented frames, guest VBlanks and wall time, side by side. +// +// The two clocks are separate: the render loop is paced by the compositor and +// has been measured between 32 and 54 frames per second during play, while the +// guest advances on its own 60 Hz pump. Printing both is what turns "the game +// feels slow" into a number, and tells which of the two is behind. +std::chrono::steady_clock::time_point g_runStart; +// Hardware ticks actually issued: CDROM state machine plus the root counters. +std::atomic g_hwTicks{0}; + +void reportClocks(uint64_t renderFrames) { + using namespace std::chrono; + const double secs = + duration_cast(steady_clock::now() - g_runStart).count() / + 1000.0; + if (secs <= 0.0) + return; + const uint32_t vsyncs = + ps1::psyq::psyq_state().vsyncCounter.load(std::memory_order_acquire); + const uint64_t hw = g_hwTicks.load(std::memory_order_relaxed); + fmt::print("[clocks] {:.1f}s render {} ({:.1f}/s) guest vblank {} ({:.1f}/s)" + " hw tick {} ({:.1f}/s)\n", + secs, renderFrames, renderFrames / secs, vsyncs, vsyncs / secs, hw, + hw / secs); +} + // Called at shutdown so a run that caught several writers still reports the // full census, not only the ones whose stack fit in the shot budget. void dumpWriteGuard() { @@ -1036,6 +1062,36 @@ int main(int argc, char *argv[]) { if (now < next) return; next = now + microseconds(16667); // ~60 Hz + + // Hardware tick, paced by the wall clock rather than by presentation. + g_hwTicks.fetch_add(1, std::memory_order_relaxed); + cdromCtrl.tick(CYCLES_PER_FRAME); + + uint32_t timerIrqs = 0; + for (uint32_t scanline = 0; scanline < SCANLINES_PER_FRAME; scanline++) + timerIrqs |= timers.tick(CYCLES_PER_SCANLINE, true, false); + if (timerIrqs & ps1::IRQ_TMR0) + irqCtrl.raiseInterrupt(ps1::IRQ_TMR0); + if (timerIrqs & ps1::IRQ_TMR1) + irqCtrl.raiseInterrupt(ps1::IRQ_TMR1); + if (timerIrqs & ps1::IRQ_TMR2) + irqCtrl.raiseInterrupt(ps1::IRQ_TMR2); + irqCtrl.raiseInterrupt(ps1::IRQ_VBLANK); + // The CDROM event is raised by the interrupt callback from pushResponse; + // only the hardware line is asserted here. + if (cdromCtrl.hasInterrupt()) + irqCtrl.raiseInterrupt(ps1::IRQ_CDROM); + if (dma.hasInterrupt()) + irqCtrl.raiseInterrupt(ps1::IRQ_DMA); + if (input.hasInterrupt()) { + irqCtrl.raiseInterrupt(ps1::IRQ_PAD_MC); + input.clearInterrupt(); + } + if (spu.hasIrq()) { + irqCtrl.raiseInterrupt(ps1::IRQ_SPU); + spu.clearIrq(); + } + { uint32_t newCount = ps1::psyq::psyq_state().vsyncCounter.fetch_add( 1, std::memory_order_release) + 1; @@ -1240,6 +1296,8 @@ int main(int argc, char *argv[]) { // Armed after the ELF loader's bulk copies, so only the running game trips it. installWriteGuard(memory.ramPtr()); + g_runStart = std::chrono::steady_clock::now(); + // Launch game thread std::thread gameThread([&]() { // Identify ourselves so Bios::queueCdromEvent can inline-drain when @@ -1310,49 +1368,15 @@ int main(int argc, char *argv[]) { } } - // 2. Tick Hardware (per frame) - // CDROM state machine - cdromCtrl.tick(CYCLES_PER_FRAME); - - // Timers (263 scanlines per NTSC frame) - uint32_t timerIrqs = 0; - for (uint32_t scanline = 0; scanline < SCANLINES_PER_FRAME; scanline++) { - timerIrqs |= timers.tick(CYCLES_PER_SCANLINE, true, false); - } - - // Timer IRQs - if (timerIrqs & ps1::IRQ_TMR0) - irqCtrl.raiseInterrupt(ps1::IRQ_TMR0); - if (timerIrqs & ps1::IRQ_TMR1) - irqCtrl.raiseInterrupt(ps1::IRQ_TMR1); - if (timerIrqs & ps1::IRQ_TMR2) - irqCtrl.raiseInterrupt(ps1::IRQ_TMR2); - - // VBlank IRQ (once per frame) - irqCtrl.raiseInterrupt(ps1::IRQ_VBLANK); - - // VBlank events (triggerVBlankEvent, snapshotDisplayBuffer, updatePadBuffers) - // are now fired by the dedicated vblankThread above at a steady 60Hz, - // so they run independently of the SDL render loop speed. - - // Other IRQs - if (cdromCtrl.hasInterrupt()) { - irqCtrl.raiseInterrupt(ps1::IRQ_CDROM); - // Event triggering is now handled by the interrupt callback - // (fired inline from pushResponse), so we only raise the HW IRQ here. - } - if (dma.hasInterrupt()) - irqCtrl.raiseInterrupt(ps1::IRQ_DMA); - if (input.hasInterrupt()) { - irqCtrl.raiseInterrupt(ps1::IRQ_PAD_MC); - input.clearInterrupt(); - } - if (spu.hasIrq()) { - irqCtrl.raiseInterrupt(ps1::IRQ_SPU); - spu.clearIrq(); - } + // The hardware tick used to run here, once per presented frame, which tied + // the CDROM and the root counters to the compositor. Measured during + // play: this loop ran between 32 and 54 frames per second while the guest + // pump held 59, so the root counters -- the clock Crash reads for + // velocity -- lost up to a third of their rate and the game moved in slow + // motion, worse the more there was to draw. It now rides the game + // thread's own 60 Hz pump; see vblankTick. - // 3. Render + // 2. Render if (!renderer.processEvents()) { running = false; continue; @@ -1439,6 +1463,7 @@ int main(int argc, char *argv[]) { } gpu.publishMetrics(); ps1::metrics::dumpJson(); + reportClocks(frameCount); dumpWriteGuard(); // Cleanup @@ -1462,6 +1487,7 @@ int main(int argc, char *argv[]) { // memory and SDL/audio handles. fmt::print("[Main] Game thread did not finish in 2s -- forcing exit " "(skipping destructors to avoid UAF race)\n"); + reportClocks(frameCount); fmt::print("Simulation ended after {} frames.\n", frameCount); // Re-publish: the game thread kept issuing GP0 words during the 2s // deadline above. publishMetrics() only hands over what has accrued From 4d1ecf877d6dd726d889f62f98698b779e994e9a Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 26/29] runtime: keep interrupt callbacks registered across ResetCallback --- ps1Runtime/src/psyq/psyq_libetc.cpp | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/ps1Runtime/src/psyq/psyq_libetc.cpp b/ps1Runtime/src/psyq/psyq_libetc.cpp index c3bd65d..e78508b 100644 --- a/ps1Runtime/src/psyq/psyq_libetc.cpp +++ b/ps1Runtime/src/psyq/psyq_libetc.cpp @@ -1,6 +1,8 @@ #include "runtime/psyq/psyq_libetc.h" #include "runtime/memory.h" #include "runtime/psyq/psyq_registry.h" +#include "runtime/metrics.h" +#include #include "runtime/psyq/psyq_state.h" #include @@ -29,9 +31,18 @@ inline std::size_t clampSlot(uint32_t n) { // is load-bearing: PSY-Q boilerplate checks it to detect init failure and // aborts the main loop when it sees zero. void hle_libetc_ResetCallback(recomp_context *ctx) { + // ResetCallback *starts* the callback module; it does not forget what the + // game registered: + // + // int ResetCallback(void) { return D_800B7080->start(); } + // + // (PsyQ libetc, psyz decompilation src/libetc/intr.c.) Clearing the slots + // here was wrong and load-bearing: `PadInit` calls ResetCallback, so every + // pad re-init silently unregistered Crash's Timer0 sound tick. With the + // tick gone the drain had nothing to dispatch, `DeliverEvent(0xF0000009, + // 0x20)` stopped firing, and the level loader's `TestEvent(9)` spun forever + // -- measured at 15 million polls against 9 hits. auto &s = psyq_state(); - for (auto &cb : s.intrCallback) cb = 0; - for (auto &cb : s.dmaCallback) cb = 0; s.callbacksEnabled = true; ctx->r[V0] = 1; } @@ -72,6 +83,15 @@ void hle_libetc_InterruptCallback(recomp_context *ctx) { uint32_t prev = s.intrCallback[n]; s.intrCallback[n] = ctx->r[A1]; ctx->r[V0] = prev; + // Which IRQ lines a game actually hooks, and with what. There is one slot + // per line, so a second registration on the same line silently displaces + // the first -- and the drain only ticks lines 4..6, so a handler parked + // anywhere else never runs at all. + if (ps1::metrics::enabled()) { + ps1::metrics::count(fmt::format("psyq.intr_cb.{}.{:08X}", n, ctx->r[A1])); + if (prev != 0 && prev != ctx->r[A1]) + ps1::metrics::count(fmt::format("psyq.intr_cb_displaced.{}.{:08X}", n, prev)); + } } void hle_libetc_DMACallback(recomp_context *ctx) { From be4ba17f66fdd90ed49b778ebada0a889ee4ad40 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 27/29] runtime: count SPU voices and VSync callback registrations under PS1_METRICS --- ps1Runtime/src/psyq/psyq_libgpu.cpp | 9 +++++++ ps1Runtime/src/spu/spu.cpp | 40 ++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ps1Runtime/src/psyq/psyq_libgpu.cpp b/ps1Runtime/src/psyq/psyq_libgpu.cpp index 9f68c3e..cb4d63e 100644 --- a/ps1Runtime/src/psyq/psyq_libgpu.cpp +++ b/ps1Runtime/src/psyq/psyq_libgpu.cpp @@ -2,6 +2,7 @@ #include "runtime/memory.h" #include "runtime/psyq/psyq_hle.h" #include "runtime/psyq/psyq_registry.h" +#include "runtime/metrics.h" #include "runtime/psyq/psyq_state.h" #include @@ -266,6 +267,14 @@ void hle_libgpu_VSyncCallback(recomp_context *ctx) { uint32_t prev = slot; slot = ctx->r[A0]; ctx->r[V0] = prev; + // One slot, and PsyQ games register more than one per-frame hook through + // it. Counting the distinct handlers registered says whether anything is + // being displaced -- the sound driver's tick is installed this way. + if (ps1::metrics::enabled()) { + ps1::metrics::count(fmt::format("psyq.vsync_cb.{:08X}", ctx->r[A0])); + if (prev != 0 && prev != ctx->r[A0]) + ps1::metrics::count(fmt::format("psyq.vsync_cb_displaced.{:08X}", prev)); + } } // SetVideoMode(mode): 0=NTSC, 1=PAL. Stores in module state, returns prev. diff --git a/ps1Runtime/src/spu/spu.cpp b/ps1Runtime/src/spu/spu.cpp index 1f279a6..26ab47e 100644 --- a/ps1Runtime/src/spu/spu.cpp +++ b/ps1Runtime/src/spu/spu.cpp @@ -2,7 +2,10 @@ #include #include #include +#include "runtime/metrics.h" #include +#include +#include namespace ps1::spu { @@ -47,6 +50,32 @@ void SPU::reset() { // Register I/O +// Per-voice census. Sound effects and music use disjoint voice ranges in +// Crash, so "which voices ever get programmed, and which ever get keyed on" is +// the one measurement that separates a music driver that plays nothing from a +// music driver that never runs. Names are built once, off the hot path. +namespace { +const std::string &voiceStartMetric(uint32_t voice) { + static const std::vector names = [] { + std::vector v; + for (uint32_t i = 0; i < NUM_VOICES; i++) + v.push_back(fmt::format("spu.voice_start.{:02}", i)); + return v; + }(); + return names[voice]; +} + +const std::string &voiceKeyOnMetric(uint32_t voice) { + static const std::vector names = [] { + std::vector v; + for (uint32_t i = 0; i < NUM_VOICES; i++) + v.push_back(fmt::format("spu.key_on.{:02}", i)); + return v; + }(); + return names[voice]; +} +} // namespace + void SPU::writeRegister(uint32_t addr, uint16_t val) { std::lock_guard lock(mutex_); uint32_t offset = addr - 0x1F801C00; @@ -69,6 +98,12 @@ void SPU::writeRegister(uint32_t addr, uint16_t val) { break; case 0x06: v.startAddr = val; + // Per-voice census. Sound effects and music use disjoint voice ranges + // in Crash, so "which voices ever get programmed, and which ever get + // keyed on" is the one measurement that separates a silent music driver + // from a music driver that never runs at all. + if (ps1::metrics::enabled()) + ps1::metrics::count(voiceStartMetric(voiceIdx)); break; case 0x08: v.adsrLo = val; @@ -716,8 +751,11 @@ void SPU::generateSamples(int16_t *outputBuffer, uint32_t numSamples) { // out silent. The real fix is per-sample retirement inside the mixer; until // then this is the variant that actually produces sound. for (uint32_t i = 0; i < NUM_VOICES; i++) { - if (keyOnLatch_ & (1u << i)) + if (keyOnLatch_ & (1u << i)) { keyOnVoice(i); + if (ps1::metrics::enabled()) + ps1::metrics::count(voiceKeyOnMetric(i)); + } else if (keyOffLatch_ & (1u << i)) keyOffVoice(i); } From 1a09d8d596ea0068d80c5a8c1d4bb753bbe40396 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 28/29] runtime: guard the callback drain, pump VSync from the game thread, add env-gated tracing --- .../include/runtime/bios/event_system.h | 32 +++++ ps1Runtime/include/runtime/gpu/gpu.h | 28 ++++ ps1Runtime/include/runtime/psyq/psyq_hle.h | 3 + ps1Runtime/include/runtime/psyq/psyq_state.h | 5 + ps1Runtime/src/bios/bios.cpp | 56 +++++++- ps1Runtime/src/bios/event_system.cpp | 89 ++++++++++++- ps1Runtime/src/gpu/gpu.cpp | 124 ++++++++++++++++++ ps1Runtime/src/psyq/psyq_hle.cpp | 59 ++++++++- ps1Runtime/src/psyq/psyq_registry.cpp | 12 +- ps1Runtime/src/psyq/psyq_state.cpp | 1 + ps1Test/runtime/test_bios.cpp | 85 ++++++++++++ ps1Test/runtime/test_psyq_cd.cpp | 3 + ps1Test/runtime/test_psyq_hle.cpp | 33 ++++- 13 files changed, 520 insertions(+), 10 deletions(-) diff --git a/ps1Runtime/include/runtime/bios/event_system.h b/ps1Runtime/include/runtime/bios/event_system.h index 2587cd4..2c60295 100644 --- a/ps1Runtime/include/runtime/bios/event_system.h +++ b/ps1Runtime/include/runtime/bios/event_system.h @@ -49,11 +49,22 @@ class EventSystem { // MUST be called from the game thread (uses ctx_ safely). void drainPendingCallbacks(); + // Lock-free "is there anything to dispatch?" probe. + // + // `drainPendingCallbacks` is called from every backward branch of every + // recompiled function, and taking `cbMtx_` there costs more than the loop + // body it guards. Callers use this to skip the lock when the queue is + // empty, which is the overwhelmingly common case. + bool hasPendingCallbacks() const { + return pendingCount_.load(std::memory_order_relaxed) != 0; + } + // Queue a callback with no specific register setup. // Thread-safe: may be called from the main loop thread. void queueCallback(uint32_t handlerPc) { std::lock_guard lk(cbMtx_); pendingCallbacks_.push_back({handlerPc, 0, 0, false, false}); + pendingCount_.store(pendingCallbacks_.size(), std::memory_order_release); } // Queue a callback with a0 ($r4) set to a specific value. @@ -61,6 +72,7 @@ class EventSystem { void queueCallbackWithArg(uint32_t handlerPc, uint32_t a0Val) { std::lock_guard lk(cbMtx_); pendingCallbacks_.push_back({handlerPc, a0Val, 0, true, false}); + pendingCount_.store(pendingCallbacks_.size(), std::memory_order_release); } // Queue a callback with both a0 ($r4) and a1 ($r5) set. @@ -68,8 +80,20 @@ class EventSystem { void queueCallbackWithArgs(uint32_t handlerPc, uint32_t a0Val, uint32_t a1Val) { std::lock_guard lk(cbMtx_); pendingCallbacks_.push_back({handlerPc, a0Val, a1Val, true, true}); + pendingCount_.store(pendingCallbacks_.size(), std::memory_order_release); } + // Per-handler dispatch accounting (diagnostic). Written on the game + // thread, read from the reporting thread; relaxed atomics keep the read + // race-free without costing the writer a lock. + struct CallbackStat { + std::atomic pc{0}; + std::atomic calls{0}; + std::atomic nanos{0}; + }; + static constexpr std::size_t kMaxCallbackStats = 12; + const CallbackStat *callbackStats() const { return cbStats_; } + private: recomp_context &ctx_; std::vector events_; @@ -87,6 +111,14 @@ class EventSystem { std::mutex cbMtx_; // protects pendingCallbacks_ std::vector pendingCallbacks_; + // Mirror of `pendingCallbacks_.size()`, readable without the mutex. + // Only ever written while `cbMtx_` is held, so it cannot disagree with + // the vector; a stale read costs at most one extra drain. + std::atomic pendingCount_{0}; + + CallbackStat cbStats_[kMaxCallbackStats]; + void recordDispatch(uint32_t pc, uint64_t nanos); + static constexpr uint32_t MAX_EVENTS = 32; static constexpr uint32_t INVALID_EVENT_ID = 0xFFFFFFFF; }; diff --git a/ps1Runtime/include/runtime/gpu/gpu.h b/ps1Runtime/include/runtime/gpu/gpu.h index 22b30f4..2a77ecf 100644 --- a/ps1Runtime/include/runtime/gpu/gpu.h +++ b/ps1Runtime/include/runtime/gpu/gpu.h @@ -228,6 +228,34 @@ class GPU { void executeLine(); void executeRect(); + // Primitive census (`PS1_CENSUS=:`, VBlank window). + // + // Answers "did this geometry reach the GPU at all" without knowing which + // game function submitted it: every triangle is tallied as drawn, degenerate + // or clipped-away, and its centroid is dropped into a coarse grid over the + // display area. Rendering that grid next to the frame shows whether missing + // content was never submitted or was submitted and thrown away. +public: + void censusDump(const char *path) const; + void censusReset(); + +private: + void censusRect(int x, int y, int w, int h); + void censusTriangle(const Vertex &v0, const Vertex &v1, const Vertex &v2, + bool degenerate); + static constexpr int kCensusW = 64; + static constexpr int kCensusH = 30; + mutable uint32_t censusGrid_[kCensusH][kCensusW]{}; + mutable uint32_t censusGridTri_[kCensusH][kCensusW]{}; + mutable uint64_t censusTri_ = 0; + mutable uint64_t censusGt4Start_ = 0; + mutable uint64_t censusGt4Exec_ = 0; + mutable uint64_t censusDrawn_ = 0; + mutable uint64_t censusDegenerate_ = 0; + mutable uint64_t censusClipped_ = 0; + mutable uint64_t censusClippedTri_ = 0; + mutable uint64_t censusClipPrints_ = 0; + // Helper methods for rasterization Color16 applyDither(Color16 baseColor, int x, int y); diff --git a/ps1Runtime/include/runtime/psyq/psyq_hle.h b/ps1Runtime/include/runtime/psyq/psyq_hle.h index eeae717..0ebd9d7 100644 --- a/ps1Runtime/include/runtime/psyq/psyq_hle.h +++ b/ps1Runtime/include/runtime/psyq/psyq_hle.h @@ -86,6 +86,9 @@ void hle_DrawSync(recomp_context *ctx); /// ResetGraph(mode) -- reset GPU to a known state. /// mode 0 -> flush + clear; mode 3 -> flush only. /// Implemented as a NOP here because GPU reset is handled by the runtime. +/// Slots libgpu's environment block keeps marked empty (0xFFFFFFFF). +inline constexpr uint32_t kGpuEnvFreeSlots = 28; + void hle_ResetGraph(recomp_context *ctx); /// ClearOTag(ot, n) -- fill an ordering-table with end-of-list terminators. diff --git a/ps1Runtime/include/runtime/psyq/psyq_state.h b/ps1Runtime/include/runtime/psyq/psyq_state.h index fe899c6..6789548 100644 --- a/ps1Runtime/include/runtime/psyq/psyq_state.h +++ b/ps1Runtime/include/runtime/psyq/psyq_state.h @@ -161,6 +161,11 @@ class PsyqState { /// Zero disables. uint32_t rcntTicksPerVBlank = 0; + /// Base of libgpu's own environment block, the one `ResetGraph` builds. + /// It lives in the game's BSS, so only the game TOML knows where -- the HLE + /// cannot derive it. Zero disables the initialisation. + uint32_t gpuEnvAddr = 0; + // Test helpers /// Reset every field to its default-constructed value. Tests call /// this in `SetUp()` to isolate cases sharing the singleton. diff --git a/ps1Runtime/src/bios/bios.cpp b/ps1Runtime/src/bios/bios.cpp index 384cc9d..1e8b768 100644 --- a/ps1Runtime/src/bios/bios.cpp +++ b/ps1Runtime/src/bios/bios.cpp @@ -8,7 +8,9 @@ #include "runtime/psyq/psyq_state.h" #include #include +#include #include +#include #include // Forward declare recomp_dispatch (defined in recompiled_out.cpp, global @@ -129,6 +131,7 @@ void Bios::queueCdromEvent(uint8_t cdIntType) { { std::lock_guard lk(cdEventQueueMtx_); cdEventQueue_.push(cdIntType); + cdEventQueueDepth_.store(cdEventQueue_.size(), std::memory_order_release); } // Inline-drain when the push originates from the game thread itself @@ -152,6 +155,7 @@ std::size_t Bios::drainCdromEventQueue() { { std::lock_guard lk(cdEventQueueMtx_); std::swap(local, cdEventQueue_); + cdEventQueueDepth_.store(cdEventQueue_.size(), std::memory_order_release); } std::size_t count = local.size(); while (!local.empty()) { @@ -453,7 +457,53 @@ void Bios::triggerVBlankEvent() { } } -void Bios::drainPendingCallbacks() { +bool Bios::drainGateEnabled() { + const char *e = std::getenv("PS1_DRAIN_GATE"); + return !(e && e[0] == '0'); +} + +bool Bios::spGuardEnabled() { return std::getenv("PS1_SP_GUARD") != nullptr; } + +void Bios::reportBadStackPointer(uint32_t sp) { + if (badSpReports_ >= 8) + return; + ++badSpReports_; + fmt::print(stderr, "[SP-GUARD] #{} guest $sp=0x{:08X} $ra=0x{:08X}\n", + badSpReports_, sp, ctx_.r31); + void *frames[24]; + int depth = backtrace(frames, 24); + char **syms = backtrace_symbols(frames, depth); + for (int i = 1; i < depth && i < 10; ++i) + fmt::print(stderr, "[SP-GUARD] #{} {}\n", i, syms ? syms[i] : "?"); + free(syms); +} + +void Bios::drainPendingCallbacksSlow() { + ++drainSlow_; + if (vsyncPtr_ == nullptr) + vsyncPtr_ = &ps1::psyq::psyq_state().vsyncCounter; + + // A dispatched callback runs recompiled game code, and the recompiler injects + // a drain at every backward branch -- so a callback with a loop in it calls + // back into here and gets dispatched again, without bound. Measured in Crash + // Bandicoot: the sound-engine tick `func_800466A0` re-entered ~165k times per + // dispatch, 561 million nested calls against 153 thousand real yield points, + // and the game thread never returned to its main loop. + // + // Hardware has the same rule -- an interrupt handler does not re-enter the + // dispatcher -- so refusing the nested call is the faithful behaviour. The + // check comes before anything else, counters included: at these volumes even + // counting the nested calls costs real time. + if (draining_) { + ++drainNested_; + return; + } + draining_ = true; + struct Clear { + bool &flag; + ~Clear() { flag = false; } + } clear{draining_}; + // Diagnostic — env-gated via `PS1_DRAIN_TRACE=N` (period). Prints // every Nth entry + every Nth exit so we can tell whether the // function is being re-entered, returns cleanly, or hangs inside. @@ -495,8 +545,10 @@ void Bios::drainPendingCallbacks() { lastIntrTickFrame_ = frame; for (std::size_t irq = 4; irq <= 6; ++irq) { uint32_t cb = st.intrCallback[irq]; - if (cb != 0) + if (cb != 0) { eventSystem_.queueCallback(cb); + ++drainDispatched_; + } } } } diff --git a/ps1Runtime/src/bios/event_system.cpp b/ps1Runtime/src/bios/event_system.cpp index 3c1ea79..585e95a 100644 --- a/ps1Runtime/src/bios/event_system.cpp +++ b/ps1Runtime/src/bios/event_system.cpp @@ -1,6 +1,7 @@ #include "runtime/bios/event_system.h" #include "runtime/cpu_context.h" #include "runtime/memory.h" +#include #include #include @@ -15,6 +16,7 @@ extern void recomp_dispatch(uint8_t *rdram, recomp_context *ctx, namespace ps1::bios { + EventSystem::EventSystem(recomp_context &ctx) : ctx_(ctx) { // Usually games assume event IDs start from a specific range or we can just // use vector index @@ -59,6 +61,8 @@ uint32_t EventSystem::closeEvent(uint32_t eventId) { events_[eventId].enabled = false; events_[eventId].pendingTrigger = false; + fmt::print(stderr, "[EVT-CLOSE] id={} classe=0x{:08X}\n", eventId, + events_[eventId].classId); events_[eventId].classId = 0xFFFFFFFF; // Mark inactive triggeredBits_.fetch_and(~(1u << eventId), std::memory_order_release); @@ -93,7 +97,29 @@ uint32_t EventSystem::testEvent(uint32_t eventId) { // reads) uint32_t bit = 1u << eventId; uint32_t bits = triggeredBits_.load(std::memory_order_acquire); - if (bits & bit) { + const bool hit = (bits & bit) != 0; + + // `PS1_EVENT_TRACE=`: count how often this descriptor is polled versus + // how often the poll actually consumes a trigger. testEvent clears the bit + // it reports, so a descriptor polled far more often than it is delivered can + // have its single trigger consumed by a caller that does not act on it -- + // the signal disappears without either side looking wrong in isolation. + static const long traceId = []() { + const char *e = std::getenv("PS1_EVENT_TRACE"); + return (e && *e) ? std::strtol(e, nullptr, 0) : -1; + }(); + if (traceId >= 0 && eventId == static_cast(traceId)) { + static unsigned long polls = 0, hits = 0; + ++polls; + if (hit) + ++hits; + if (hit || (polls % 500000) == 0) + fmt::print(stderr, + "[EVT{}] consultas={} acertos={} bits=0x{:08X}\n", + eventId, polls, hits, bits); + } + + if (hit) { // Clear this bit atomically (acknowledge) triggeredBits_.fetch_and(~bit, std::memory_order_release); return 1; @@ -123,6 +149,8 @@ uint32_t EventSystem::disableEvent(uint32_t eventId) { if (eventId >= events_.size()) return 0; events_[eventId].enabled = false; + fmt::print(stderr, "[EVT-DISABLE] id={} classe=0x{:08X}\n", eventId, + events_[eventId].classId); EVT_LOG("[BIOS] DisableEvent({})\n", eventId); return 1; } @@ -130,6 +158,26 @@ uint32_t EventSystem::disableEvent(uint32_t eventId) { void EventSystem::triggerEvent(uint32_t classId, uint32_t specId) { std::lock_guard lk(eventsMtx_); bool found = false; + // `PS1_EVENT_TRACE=` also reports every delivery aimed at that + // descriptor's class/spec: whether the table matched, and with what. A + // delivery that matches nothing looks identical from the caller's side to + // one that works, which is how a live event can go silent unnoticed. + static const long traceId = []() { + const char *e = std::getenv("PS1_EVENT_TRACE"); + return (e && *e) ? std::strtol(e, nullptr, 0) : -1; + }(); + const bool trace = + traceId >= 0 && static_cast(traceId) < events_.size() && + events_[traceId].classId == classId && events_[traceId].specId == specId; + if (trace) { + static unsigned long n = 0; + if ((++n % 200) == 1) + fmt::print(stderr, + "[EVT-ENTREGA] #{} classe=0x{:08X} spec=0x{:X} alvo_id={} " + "habilitado={} tabela={} bits=0x{:08X}\n", + n, classId, specId, traceId, events_[traceId].enabled, + events_.size(), triggeredBits_.load()); + } for (size_t i = 0; i < events_.size(); i++) { auto &ev = events_[i]; if (ev.classId == classId && ev.specId == specId) { @@ -137,6 +185,14 @@ void EventSystem::triggerEvent(uint32_t classId, uint32_t specId) { if (!ev.enabled) { // Buffer the trigger; will be applied when EnableEvent is called. ev.pendingTrigger = true; + if (s_evtVerbose || std::getenv("PS1_EVENT_TRACE")) { + static unsigned long swallowed = 0; + if ((++swallowed % 200) == 1) + fmt::print(stderr, + "[EVT-ENGOLIDO] #{} id={} classe=0x{:08X} spec=0x{:X} " + "(desabilitado)\n", + swallowed, i, classId, specId); + } continue; } // Set bit in atomic bitmask (thread-safe for main->game thread visibility) @@ -147,6 +203,11 @@ void EventSystem::triggerEvent(uint32_t classId, uint32_t specId) { // directly (which would be thread-unsafe when called from main thread). std::lock_guard lk2(cbMtx_); pendingCallbacks_.push_back({ev.handler, 0, 0, false, false}); + // Keep the lock-free mirror in step: `Bios::drainPendingCallbacks` + // skips the whole slow path when `hasPendingCallbacks()` reads false, + // so a queue push that forgets this counter is a callback that never + // runs. + pendingCount_.store(pendingCallbacks_.size(), std::memory_order_release); } } } @@ -180,6 +241,25 @@ void EventSystem::tick() { // Evaluate if any triggered events need handling } +void EventSystem::recordDispatch(uint32_t pc, uint64_t nanos) { + for (std::size_t i = 0; i < kMaxCallbackStats; ++i) { + uint32_t slot = cbStats_[i].pc.load(std::memory_order_relaxed); + if (slot == 0) { + cbStats_[i].pc.store(pc, std::memory_order_relaxed); + slot = pc; + } + if (slot != pc) + continue; + cbStats_[i].calls.store( + cbStats_[i].calls.load(std::memory_order_relaxed) + 1, + std::memory_order_relaxed); + cbStats_[i].nanos.store( + cbStats_[i].nanos.load(std::memory_order_relaxed) + nanos, + std::memory_order_relaxed); + return; + } +} + void EventSystem::drainPendingCallbacks() { // Move callbacks to a local copy under the lock, then release the lock // before dispatching (dispatched callbacks may re-enter triggerEvent). @@ -190,6 +270,7 @@ void EventSystem::drainPendingCallbacks() { return; local = std::move(pendingCallbacks_); pendingCallbacks_.clear(); + pendingCount_.store(0, std::memory_order_release); } for (const auto &cb : local) { @@ -209,7 +290,13 @@ void EventSystem::drainPendingCallbacks() { if (dispCbCount++ < 5) fmt::print(stderr, "[EVT] drainPendingCallbacks: dispatching 0x{:08X} a0=0x{:X}\n", cb.handlerPc, cb.hasArg ? cb.a0 : 0); + const auto t0 = std::chrono::steady_clock::now(); recomp_dispatch(ctx_.mem->ramPtr(), &ctx_, cb.handlerPc); + recordDispatch(cb.handlerPc, + static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count())); // Restore all registers that the game was using static_cast(ctx_) = saved; diff --git a/ps1Runtime/src/gpu/gpu.cpp b/ps1Runtime/src/gpu/gpu.cpp index f21e51f..649d6fd 100644 --- a/ps1Runtime/src/gpu/gpu.cpp +++ b/ps1Runtime/src/gpu/gpu.cpp @@ -1,4 +1,6 @@ #include "runtime/gpu/gpu.h" +#include +#include #include "runtime/metrics.h" #include #include @@ -99,6 +101,32 @@ uint32_t GPU::readGPUREAD() { } void GPU::writeGP0(uint32_t val) { + // `PS1_GP0_TRACE=` prints a host backtrace on the first few + // GP0 commands with that opcode. Crash writes GP0 directly rather than + // through DMA, so the backtrace names the recompiled guest function that + // emitted the primitive -- which is how you find the renderer for content + // that is missing from the screen without guessing at function roles. + { + static const int traceOp = []() { + const char *e = std::getenv("PS1_GP0_TRACE"); + return (e && *e) ? (int)std::strtol(e, nullptr, 16) : -1; + }(); + if (traceOp >= 0 && !vramTransfer_.isWritingToVRAM && + !isCommandExecuting_ && (int)(val >> 24) == traceOp) { + static int traced = 0; + if (traced < 4) { + ++traced; + fmt::print(stderr, "[GP0-TRACE] #{} op=0x{:02X} val=0x{:08X}\n", traced, + val >> 24, val); + void *fr[24]; + int d = backtrace(fr, 24); + char **sy = backtrace_symbols(fr, d); + for (int i = 1; i < d && i < 12; ++i) + fmt::print(stderr, "[GP0-TRACE] #{} {}\n", i, sy ? sy[i] : "?"); + free(sy); + } + } + } { static int gp0Count = 0; static std::unordered_map opcodeHist; @@ -204,6 +232,7 @@ void GPU::writeGP0(uint32_t val) { case 0x3E: case 0x3F: expectedCommandWords_ = 12; + ++censusGt4Start_; break; // Gouraud Textured 4-point polygon case 0x40: case 0x41: @@ -1015,10 +1044,99 @@ Color16 GPU::applyBlend(Color16 fg, Color16 bg) { return c; } +void GPU::censusTriangle(const Vertex &v0, const Vertex &v1, const Vertex &v2, + bool degenerate) { + if (degenerate) { + ++censusDegenerate_; + return; + } + const int minX = std::min({v0.x, v1.x, v2.x}); + const int minY = std::min({v0.y, v1.y, v2.y}); + const int maxX = std::max({v0.x, v1.x, v2.x}); + const int maxY = std::max({v0.y, v1.y, v2.y}); + if (maxX < drawAreaX1_ || minX > drawAreaX2_ || maxY < drawAreaY1_ || + minY > drawAreaY2_) { + ++censusClipped_; + // Triangles get their own budget: sprites are far more numerous and would + // otherwise exhaust a shared one before a single triangle is logged -- + // which is exactly how an earlier pass wrongly concluded that no 3D + // geometry was being clipped. + ++censusClippedTri_; + if (++censusClipPrints_ <= 12) + fmt::print(stderr, + "[clip-tri] #{} v0=({},{}) v1=({},{}) v2=({},{}) area=({},{})-({},{})\n", + censusClippedTri_, v0.x, v0.y, v1.x, v1.y, v2.x, v2.y, + drawAreaX1_, drawAreaY1_, drawAreaX2_, drawAreaY2_); + return; + } + ++censusDrawn_; + ++censusTri_; + // Centroid into the grid, in display-relative coordinates. + const int cx = ((v0.x + v1.x + v2.x) / 3) - drawAreaX1_; + const int cy = ((v0.y + v1.y + v2.y) / 3) - drawAreaY1_; + const int gx = cx * kCensusW / 512; + const int gy = cy * kCensusH / 240; + if (gx >= 0 && gx < kCensusW && gy >= 0 && gy < kCensusH) { + ++censusGrid_[gy][gx]; + ++censusGridTri_[gy][gx]; + } +} + +void GPU::censusRect(int x, int y, int w, int h) { + if (w <= 0 || h <= 0) { + ++censusDegenerate_; + return; + } + if (x + w < drawAreaX1_ || x > drawAreaX2_ || y + h < drawAreaY1_ || + y > drawAreaY2_) { + ++censusClipped_; + return; + } + ++censusDrawn_; + const int gx = (x + w / 2 - drawAreaX1_) * kCensusW / 512; + const int gy = (y + h / 2 - drawAreaY1_) * kCensusH / 240; + if (gx >= 0 && gx < kCensusW && gy >= 0 && gy < kCensusH) + ++censusGrid_[gy][gx]; +} + +void GPU::censusReset() { + std::memset(censusGrid_, 0, sizeof(censusGrid_)); + std::memset(censusGridTri_, 0, sizeof(censusGridTri_)); + censusTri_ = 0; + censusGt4Start_ = censusGt4Exec_ = 0; + censusClippedTri_ = 0; + censusClipPrints_ = 0; + censusDrawn_ = censusDegenerate_ = censusClipped_ = 0; +} + +void GPU::censusDump(const char *path) const { + FILE *f = std::fopen(path, "w"); + if (!f) + return; + std::fprintf(f, "drawn %lu degenerate %lu clipped %lu tri %lu\n", + (unsigned long)censusDrawn_, (unsigned long)censusDegenerate_, + (unsigned long)censusClipped_, (unsigned long)censusTri_); + std::fprintf(f, "gt4_start %lu gt4_exec %lu clipped_tri %lu\n", + (unsigned long)censusGt4Start_, (unsigned long)censusGt4Exec_, + (unsigned long)censusClippedTri_); + for (int y = 0; y < kCensusH; ++y) { + for (int x = 0; x < kCensusW; ++x) + std::fprintf(f, "%u ", censusGrid_[y][x]); + std::fprintf(f, "\n"); + } + for (int y = 0; y < kCensusH; ++y) { + for (int x = 0; x < kCensusW; ++x) + std::fprintf(f, "%u ", censusGridTri_[y][x]); + std::fprintf(f, "\n"); + } + std::fclose(f); +} + void GPU::rasterizeTriangle(Vertex v0, Vertex v1, Vertex v2, Color16 color, bool blend) { // Check winding and swap if necessary so we have CCW int area = edgeFunction(v0, v1, v2); + censusTriangle(v0, v1, v2, area == 0); if (area == 0) return; // Degenerate if (area < 0) { @@ -1066,6 +1184,7 @@ void GPU::rasterizeTexturedTriangle(Vertex v0, Vertex v1, Vertex v2, Color16 color, uint16_t clut, uint16_t tpage, bool isRaw, bool blend) { int area = edgeFunction(v0, v1, v2); + censusTriangle(v0, v1, v2, area == 0); if (area == 0) return; if (area < 0) { @@ -1234,6 +1353,8 @@ void GPU::executeRect() { break; } + censusRect(v.x, v.y, w, h); + // Textured sprites carry a UV base + CLUT in word[2] and sample from the // current texture page (GP0 0xE1). Untextured rects use the flat command // color. Prior to this, textured rects were filled with the command color @@ -1538,6 +1659,7 @@ void GPU::executeGouraudTexturedPoly3() { } void GPU::executeGouraudTexturedPoly4() { + ++censusGt4Exec_; // Word layout: c0+cmd, v0, uv0+clut, c1, v1, uv1+tpage, c2, v2, uv2, c3, // v3, uv3 Color24 c[4]; @@ -1584,6 +1706,7 @@ void GPU::executeGouraudTexturedPoly4() { void GPU::rasterizeGouraudTriangle(Vertex v0, Vertex v1, Vertex v2, Color24 c0, Color24 c1, Color24 c2, bool blend) { int area = edgeFunction(v0, v1, v2); + censusTriangle(v0, v1, v2, area == 0); if (area == 0) return; if (area < 0) { @@ -1638,6 +1761,7 @@ void GPU::rasterizeGouraudTexturedTriangle(Vertex v0, Vertex v1, Vertex v2, uint16_t tpage, bool isRaw, bool blend) { int area = edgeFunction(v0, v1, v2); + censusTriangle(v0, v1, v2, area == 0); if (area == 0) return; if (area < 0) { diff --git a/ps1Runtime/src/psyq/psyq_hle.cpp b/ps1Runtime/src/psyq/psyq_hle.cpp index 423b049..33b1cfa 100644 --- a/ps1Runtime/src/psyq/psyq_hle.cpp +++ b/ps1Runtime/src/psyq/psyq_hle.cpp @@ -1,4 +1,5 @@ #include "runtime/psyq/psyq_hle.h" +#include "runtime/bios/bios.h" #include "runtime/emuptr.h" #include "runtime/memory.h" #include "runtime/metrics.h" @@ -68,6 +69,10 @@ void hle_VSync(recomp_context *ctx) { auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); while (counter.load(std::memory_order_acquire) < target && std::chrono::steady_clock::now() < deadline) { + // The VBlank tick runs on this thread now (see Bios::setVBlankPump), so + // the wait has to drive it -- nothing else advances the counter. + if (ctx->bios) + ctx->bios->pumpVBlank(); drainOnce(); std::this_thread::sleep_for(std::chrono::microseconds(100)); } @@ -106,9 +111,33 @@ void hle_DrawSync(recomp_context *ctx) { // // The runtime GPU has no queued command list to flush, so this is a NOP. // +// ResetGraph +// +// PsyQ `ResetGraph(mode)` resets the GPU *and* builds libgpu's own environment +// block in the game's BSS. This runtime's GPU is synchronous, so there is no +// command list to flush -- but the block still has to exist, because the rest +// of libgpu reads it. Leaving it zeroed is not neutral: the 28 slots below are +// "empty" markers, and zero means "slot 0" everywhere instead. +// +// Reference is the game's own `ResetGraph`, run as recompiled MIPS and dumped: +// the block is 32 words -- an info word, a resolution word (0x02000400), an +// enable flag, a pad, then 28 words of 0xFFFFFFFF. Confirmed identical in the +// reference recompilation of the same binary at the same point in the run. +// +// The block's address is game BSS, so it comes from the TOML (`[timing] +// gpu_env_addr`); zero leaves this a no-op, which is the pre-existing +// behaviour for games that have not been mapped. void hle_ResetGraph(recomp_context *ctx) { - (void)ctx; - // NOP: runtime GPU is synchronous, no list to flush + const uint32_t env = ps1::psyq::psyq_state().gpuEnvAddr; + if (env == 0 || ctx->mem == nullptr) + return; + + ctx->mem->write32(env + 0, 0x00000100u); // GPU info / type + ctx->mem->write32(env + 4, 0x02000400u); // default resolution + ctx->mem->write32(env + 8, 0x00000001u); // enabled + ctx->mem->write32(env + 12, 0x00000000u); // pad + for (uint32_t i = 0; i < kGpuEnvFreeSlots; ++i) + ctx->mem->write32(env + 16 + i * 4, 0xFFFFFFFFu); } // ClearOTag @@ -196,6 +225,19 @@ void hle_DrawOTag(recomp_context *ctx) { return; } ps1::metrics::count("draw_otag.calls"); + // `PS1_OT_DUMP=`: on the nth call, histogram what the ordering table + // actually contains. Missing on-screen content is either absent from the + // table (the builder never emitted it) or present but dropped by the walk, + // and only the table's own contents tell the two apart. + static const long otDumpAt = []() { + const char *e = std::getenv("PS1_OT_DUMP"); + return (e && *e) ? std::strtol(e, nullptr, 10) : 0; + }(); + static long otCall = 0; + const bool otDump = (otDumpAt > 0 && ++otCall == otDumpAt); + uint32_t otOps[256] = {}; + uint32_t otEmpty = 0; + uint32_t ptr = ctx->r[A0]; int safety = 0; while ((ptr & 0xFFFFFFu) != 0xFFFFFFu && safety++ < 100000) { @@ -203,6 +245,12 @@ void hle_DrawOTag(recomp_context *ctx) { uint32_t wordCount = (hdr >> 24) & 0xFF; ps1::metrics::count("draw_otag.nodes"); ps1::metrics::count("draw_otag.words", wordCount); + if (otDump) { + if (wordCount == 0) + ++otEmpty; + else + ++otOps[ctx->mem->read32(ptr + 4) >> 24]; + } for (uint32_t i = 0; i < wordCount; i++) { g_cfg.writeGP0(ctx->mem->read32(ptr + 4 + i * 4)); } @@ -211,6 +259,13 @@ void hle_DrawOTag(recomp_context *ctx) { break; ptr = next | 0x80000000u; // restore KSEG0 bit } + if (otDump) { + fmt::print(stderr, "[OT] call#{} nodes={} empty={} safety_hit={}\n", otCall, + safety, otEmpty, safety >= 100000); + for (int i = 0; i < 256; ++i) + if (otOps[i]) + fmt::print(stderr, "[OT] op 0x{:02X} x{}\n", i, otOps[i]); + } ctx->r[V0] = 0; } diff --git a/ps1Runtime/src/psyq/psyq_registry.cpp b/ps1Runtime/src/psyq/psyq_registry.cpp index 7976927..87e5831 100644 --- a/ps1Runtime/src/psyq/psyq_registry.cpp +++ b/ps1Runtime/src/psyq/psyq_registry.cpp @@ -49,11 +49,17 @@ bool isPermissive() { void psyq_dispatch(const char *name, recomp_context *ctx) { // Optional per-call trace gated by `PS1_HLE_TRACE=1`. Single getenv // (cached) so the fast path is just a load+branch. - static const bool s_trace = []() { + // `PS1_HLE_TRACE=1` traces every call; `PS1_HLE_TRACE=` traces + // only the names containing it (e.g. `libcd`). Tracing everything emits + // thousands of lines a second, which is enough to change the timing of the + // run being diagnosed -- the filter keeps a targeted trace cheap. + static const char *s_filter = []() -> const char * { const char *e = std::getenv("PS1_HLE_TRACE"); - return e && std::strcmp(e, "0") != 0 && e[0] != '\0'; + if (!e || e[0] == '\0' || std::strcmp(e, "0") == 0) + return nullptr; + return (std::strcmp(e, "1") == 0) ? "" : e; }(); - if (s_trace) { + if (s_filter && (s_filter[0] == '\0' || std::strstr(name, s_filter))) { fmt::print(stderr, "[PSYQ] {} (a0={:08X} a1={:08X} a2={:08X} RA={:08X})\n", name, ctx->r[4], ctx->r[5], ctx->r[6], ctx->r[31]); } diff --git a/ps1Runtime/src/psyq/psyq_state.cpp b/ps1Runtime/src/psyq/psyq_state.cpp index 906f3de..36e22cb 100644 --- a/ps1Runtime/src/psyq/psyq_state.cpp +++ b/ps1Runtime/src/psyq/psyq_state.cpp @@ -19,6 +19,7 @@ void PsyqState::reset() { callbacksEnabled = true; drawSync = GpuDrawSync{}; rcntTickAddr = 0; + gpuEnvAddr = 0; rcntTicksPerVBlank = 0; } diff --git a/ps1Test/runtime/test_bios.cpp b/ps1Test/runtime/test_bios.cpp index 9b7d96a..7b12cb8 100644 --- a/ps1Test/runtime/test_bios.cpp +++ b/ps1Test/runtime/test_bios.cpp @@ -1,4 +1,5 @@ #include "runtime/bios/bios.h" +#include "runtime/psyq/psyq_state.h" #include "runtime/cdrom/virtual_fs.h" #include "runtime/cpu_context.h" #include "runtime/memory.h" @@ -14,6 +15,9 @@ class BiosTest : public ::testing::Test { void SetUp() override { // Set up execution context ctx.mem = &mem; + // A yield point only dispatches when the guest has a usable stack, so + // give the fixture one -- the top of main RAM, where PS1 stacks live. + ctx.r29 = 0x801FFFF0; fs = std::make_unique(); bios = std::make_unique(ctx, *fs, mem); @@ -115,3 +119,84 @@ TEST_F(BiosTest, MemsetFillsBytes) { EXPECT_EQ(mem.read8(0x80000002), 0x55); EXPECT_EQ(mem.read8(0x80000003), 0x55); } + +// Re-entrant drain +// +// A dispatched callback is recompiled game code, and the recompiler injects a +// drain at every backward branch -- so a callback with a loop in it calls back +// into drainPendingCallbacks and gets dispatched again. Measured in Crash +// Bandicoot as 561 million nested calls against 153 thousand real yield points, +// with the game thread never returning to its main loop. +// +// Here the dispatch hook stands in for that callback: it bumps the VSync +// counter (so the interrupt tick would re-queue) and re-enters the drain. +// Without the guard this recurses until the stack gives out. +extern void (*g_testRecompDispatchHook)(recomp_context *ctx, uint32_t addr); + +namespace { +Bios *g_reentryBios = nullptr; +int g_reentryDispatches = 0; + +void reentrantHook(recomp_context * /*ctx*/, uint32_t /*addr*/) { + if (++g_reentryDispatches > 50) + return; // runaway guard for the failing case + ps1::psyq::psyq_state().vsyncCounter.fetch_add(1, std::memory_order_release); + g_reentryBios->drainPendingCallbacks(); +} +} // namespace + +TEST_F(BiosTest, DrainPendingCallbacksRefusesToReenter) { + auto &st = ps1::psyq::psyq_state(); + st.reset(); + st.intrCallback[4] = 0x80046000; + st.vsyncCounter.store(1, std::memory_order_release); + + g_reentryBios = bios.get(); + g_reentryDispatches = 0; + g_testRecompDispatchHook = &reentrantHook; + + bios->drainPendingCallbacks(); + + g_testRecompDispatchHook = nullptr; + g_reentryBios = nullptr; + + EXPECT_EQ(g_reentryDispatches, 1) + << "nested drain dispatched the callback again"; + st.reset(); +} + +// Hand-written PS1 assembly may save the register file to a context block and +// then use $sp as a general-purpose register -- Crash's model transform parks +// $sp in the scratchpad and packs GTE operands through it. A yield point +// reached in that window must not dispatch, or the callback's prologue writes +// its frame over a data value. The work stays queued for the next yield +// point that does have a stack. +TEST_F(BiosTest, DrainDefersWhileGuestHasNoStack) { + auto &st = ps1::psyq::psyq_state(); + st.reset(); + st.intrCallback[4] = 0x80046000; + st.vsyncCounter.store(1, std::memory_order_release); + + g_reentryBios = bios.get(); + g_reentryDispatches = 0; + g_testRecompDispatchHook = &reentrantHook; + + const uint32_t savedSp = ctx.r29; + ctx.r29 = 0x0905A8E4; // a GTE operand, not an address + bios->drainPendingCallbacks(); + EXPECT_EQ(g_reentryDispatches, 0) << "dispatched without a guest stack"; + + ctx.r29 = 0x1F8000E0; // scratchpad: live working storage, not a stack + bios->drainPendingCallbacks(); + EXPECT_EQ(g_reentryDispatches, 0) << "dispatched onto the scratchpad"; + + // Restoring the stack releases the deferred callback. + ctx.r29 = savedSp; + bios->drainPendingCallbacks(); + EXPECT_EQ(g_reentryDispatches, 1) << "deferred callback was never delivered"; + + g_testRecompDispatchHook = nullptr; + g_reentryBios = nullptr; + ctx.r29 = savedSp; + st.reset(); +} diff --git a/ps1Test/runtime/test_psyq_cd.cpp b/ps1Test/runtime/test_psyq_cd.cpp index 036e227..ee3e785 100644 --- a/ps1Test/runtime/test_psyq_cd.cpp +++ b/ps1Test/runtime/test_psyq_cd.cpp @@ -40,6 +40,9 @@ class PsyqCdTest : public ::testing::Test { void SetUp() override { ctx.reset(); ctx.mem = &mem; + // A yield point only dispatches when the guest has a usable stack, so + // give the fixture one -- the top of main RAM, where PS1 stacks live. + ctx.r29 = 0x801FFFF0; bios = std::make_unique(ctx, fs, mem); bios->setCdromController(&cdrom); ctx.bios = bios.get(); diff --git a/ps1Test/runtime/test_psyq_hle.cpp b/ps1Test/runtime/test_psyq_hle.cpp index 369f4b1..642d999 100644 --- a/ps1Test/runtime/test_psyq_hle.cpp +++ b/ps1Test/runtime/test_psyq_hle.cpp @@ -71,14 +71,43 @@ TEST_F(PsyqHleTest, DrawSyncMode1ReturnsZero) { // ResetGraph -TEST_F(PsyqHleTest, ResetGraphIsNop) { +TEST_F(PsyqHleTest, ResetGraphEmitsNoGpuCommands) { ctx.r[A0] = 0; hle_ResetGraph(&ctx); - // No crash, no GP0/GP1 commands emitted + // The runtime GPU is synchronous -- there is no list to flush. EXPECT_TRUE(gp0Words.empty()); EXPECT_TRUE(gp1Words.empty()); } +// ResetGraph also builds libgpu's environment block in the game's BSS. Leaving +// it zeroed is not neutral: the 28 trailing slots are "empty" markers, so zero +// reads as "slot 0" everywhere. Measured against the same binary's own +// ResetGraph and against the reference recompilation -- 32 words, identical. +TEST_F(PsyqHleTest, ResetGraphBuildsTheLibgpuEnvBlock) { + constexpr uint32_t kEnv = 0x80054A6C; + psyq_state().gpuEnvAddr = kEnv; + + hle_ResetGraph(&ctx); + + EXPECT_EQ(mem.read32(kEnv + 0), 0x00000100u); + EXPECT_EQ(mem.read32(kEnv + 4), 0x02000400u); + EXPECT_EQ(mem.read32(kEnv + 8), 0x00000001u); + EXPECT_EQ(mem.read32(kEnv + 12), 0x00000000u); + for (uint32_t i = 0; i < kGpuEnvFreeSlots; ++i) + EXPECT_EQ(mem.read32(kEnv + 16 + i * 4), 0xFFFFFFFFu) << "slot " << i; +} + +// Games that have not been mapped leave the address unset; the HLE must stay +// the no-op it was rather than writing over address zero. +TEST_F(PsyqHleTest, ResetGraphWithoutAnEnvAddressWritesNothing) { + psyq_state().gpuEnvAddr = 0; + mem.write32(0x80054A6C, 0xDEADBEEFu); + + hle_ResetGraph(&ctx); + + EXPECT_EQ(mem.read32(0x80054A6C), 0xDEADBEEFu); +} + // VSync TEST_F(PsyqHleTest, VSyncWaitsOneFrame) { From 6f1b6a458c8fec825f78dd63220312508ce79c91 Mon Sep 17 00:00:00 2001 From: Italo Dell Areti Date: Sun, 20 Sep 2026 22:49:52 -0300 Subject: [PATCH 29/29] docs: document the pad layout and the memory cards --- ISSUES.md | 96 +++++++++++++++++++++++++++++-------------------------- README.md | 25 +++++++++++++++ 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 2567160..d005b30 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -34,36 +34,45 @@ test that catches regressions. --- -## Issue #1 — Crash Bandicoot: silent hang before first GPU command +## Issue #1 — Crash Bandicoot: hash table walk hangs without GOOL interpreter -**Status:** bypassed (workaround in place; permanent fix in roadmap PLANNING.md Fase 2) +**Status:** bypassed (workaround `PS1_SKIP_31BF8=1`; permanent fix requires GOOL bytecode interpreter — PLANNING.md Fase 5) **Game:** Crash Bandicoot (USA, SLUS-00005) **Branch:** main -### Current state (2026-05-17) - -Bypass shipped: env var `PS1_SKIP_31BF8=1` registers a runtime override that -NOPs `func_80031BF8` (the display-mode state machine call that triggers the -unpopulated hash table walk at `0x8005C530`). With the bypass plus the 4 new -function entries added to `crash_recomp.toml` (jumptable targets that -`ps1Analyzer` over-merged into surrounding functions), Crash now boots to the -main loop and runs the full PsyQ render pipeline: +### Current state (2026-05-26) + +Two architectural emitter/BIOS bugs that masqueraded as a Crash-specific hang +were fixed on 2026-05-25 (commits `f5b038b` BIOS A-table indices and +`5300604` SWL/SWR emitter argument order). NS init now succeeds (the runtime +prints `"reading file system"` + `"Inited and Allocated 20 pages"` for the +first time, and `chunk[25].size = 0x5ABF` is populated correctly). With the +six `--add-func` entries persisted in `tools/regen_crash.sh` (jumptable +targets `ps1Analyzer` over-merged into surrounding functions), zero +`[DISPATCH] Unknown target` warnings appear during boot. + +The residual hang is now isolated to the `func_80015B58` 21-module loop +walking the hash table at `0x8005C530`. The hash table base is not populated +by the NS chunk subsystem (which is what the prior diagnostic history below +hypothesised) — it is populated by the **GOOL bytecode interpreter** parsing +per-module data. GOOL is a Naughty Dog DSL whose interpreter lives in the +game binary; we do not implement it. This is scoped as Fase 5 of +`PLANNING.md` (~15 h work). + +Until GOOL lands, `PS1_SKIP_31BF8=1` remains a permanent gap, not a +temporary workaround. With the bypass plus the SWL/SWR fix, the render path +runs: | Metric | Value | |---|---| -| `libetc_VSync` calls/run | 881 (~58 fps over ~15s) | -| `libgpu_DrawOTag` calls/run | 435 | -| `libgte_MulMatrix` calls/run | 872 | -| `libgpu_PutDispEnv` calls/run | 436 | -| GP1 display swaps | 20 (double-buffer working) | -| Suite | 557/557 green | -| Unknown dispatch targets | 0 | - -The root cause (unpopulated hash table — see "Diagnostic history" below) is -unchanged; the bypass is a workaround. The permanent fix is to port the NS -(Naughty Sequence) chunk subsystem from c1c reference as `recomp_register_override` -implementations for `func_80013B94` (`NS_FixupPage`) and `func_80013B30` -(`NS_PageTransition`). See PLANNING.md Fase 2 for the roadmap. +| Suite | 569/569 green | +| `[DISPATCH] Unknown target` | 0 | +| GPU activity with `PS1_SKIP_31BF8=1` | 10 FillRect + DrawOTag + GTE 3D ops (race-prone) | +| NS_init success | yes (post 2026-05-25) | + +The race-prone qualifier reflects that the bypass NOPs a state-machine call +the engine relies on for double-buffer cadence; some runs progress further +than others. Reliable rendering still depends on GOOL. ### Symptom @@ -279,36 +288,33 @@ Conclusion: patching the hash lookup is insufficient. Downstream code treats the returned pointer as a valid struct and dereferences several fields; substituting a sentinel propagates the failure deeper. -### Real path forward (multi-session) +### Real path forward — superseded 2026-05-25 -To get Crash past iter 17, one of the following is required: +Options 1 and 2 are refuted. The 2026-05-25 SWL/SWR emitter fix +(commit `5300604`) revealed that the missing "init function" was the +existing NS chunk loader running on corrupted LBA arithmetic. Once the +emitter emits stores correctly, NS_init populates `chunk[25]` and the +NS-side data path is complete. The hash table at `0x8005C530` is +**separate** — it is filled by the GOOL bytecode interpreter walking +per-module records once the engine is running, which is what +`PS1_SKIP_31BF8=1` short-circuits. -1. **Ghidra-assisted analysis** of the Crash boot path (PC = - `0x8003E018` through to `func_80015B58`) to find the missing - initialization function that writes `0x8005C530`, then add it - via `ps1Analyzer --add-func `. -2. **Implement Crash's data-file parser as an HLE override** that - reads the CD-loaded data at `0x80061A80` and pre-populates BSS - addresses including `0x8005C530`. Requires understanding the - data file format (one of the early sectors of Crash's binary). -3. **Override `func_80031BF8` (display-mode setup) as a no-op**: this - is the iter-17 target. If we skip it entirely, the GTE loop - continues to iter 18..20 (mostly null pointers, skip) and exits. - The game then proceeds past this initialization phase -- though - subsequent code likely hangs on similar missing inits. +The only path that removes the bypass is: -Option 1 is the only one that produces a long-term-correct recompiler. -Options 2 and 3 are TCC-specific workarounds. +1. **Implement the GOOL bytecode interpreter** as a `recomp_register_override` + on the GOOL VM entry point. The opcode table and semantics are + documented in `../PS1Recomp-workspace/gooc/include/gool_ins.c`. The + `CrashEdit` and `gooc` repositories ship reference implementations. + Scoped as Fase 5 of `PLANNING.md` (~15 h). -### Independent open item +Option 3 (NOPing `func_80031BF8`) remains shipped as `PS1_SKIP_31BF8=1` +but is now classified as a *gap* (depends on Fase 5), not a workaround. -**UBSan: misaligned 4-byte load** at `iso9660.cpp:29` and `:31`. - -#### Independent open item +### Independent open item **UBSan: misaligned 4-byte load** at `iso9660.cpp:29` and `:31`. Reading `uint32_t` directly from a byte buffer that is not 4-aligned. Replace -with `memcpy` into a stack `uint32_t`. Not related to the hang; surface +with `memcpy` into a stack `uint32_t`. Not related to the hang; surfaces in any disc-mount path. ### Verification diff --git a/README.md b/README.md index 8f25bef..3992b70 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,31 @@ ctest --test-dir build --output-on-failure -j$(nproc) ./build/ps1Runtime/ps1Runtime --config rayman_config.toml ``` +## Controls + +The keyboard stands in for a PS1 digital pad. The runtime prints this table on +startup, so it is always visible in the log of a run. + +| Keyboard | PS1 pad | Keyboard | PS1 pad | +|---|---|---|---| +| Arrow keys | D-Pad | Q | L1 | +| Z | Cross | W | R1 | +| X | Circle | E | L2 | +| A | Square | R | R2 | +| S | Triangle | C | L3 | +| Enter | Start | V | R3 | +| Right Shift or Backspace | Select | Esc | quit | + +Each press is echoed to stderr as `[pad] ->