diff --git a/ps1Analyzer/include/ps1recomp/function_finder.h b/ps1Analyzer/include/ps1recomp/function_finder.h index 451d474..72fcea0 100644 --- a/ps1Analyzer/include/ps1recomp/function_finder.h +++ b/ps1Analyzer/include/ps1recomp/function_finder.h @@ -76,8 +76,66 @@ inline bool isStackEpilogue(uint32_t instr) { /// Check if instruction is a NOP (0x00000000) inline bool isNOP(uint32_t instr) { return instr == 0; } +/// Check if instruction is JR (any register) +inline bool isJR(uint32_t instr) { + return getOpcode(instr) == OP_SPECIAL && getFunction(instr) == FUNC_JR; +} + +/// Check if instruction is an unconditional J (not JAL) +inline bool isJ(uint32_t instr) { return getOpcode(instr) == OP_J; } + +/// Check if instruction is a PC-relative conditional branch (BEQ/BNE/BLEZ/ +/// BGTZ or a REGIMM BLTZ/BGEZ family member). +inline bool isBranch(uint32_t instr) { + const uint32_t op = getOpcode(instr); + return op == OP_BEQ || op == OP_BNE || op == OP_BLEZ || op == OP_BGTZ || + op == OP_REGIMM; +} + +/// 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))) + << 2); +} + +/// Check if instruction loads from memory into a register (LB..LWR). +inline bool isLoad(uint32_t instr) { + const uint32_t op = getOpcode(instr); + return op >= 0x20 && op <= 0x26; // LB, LH, LWL, LW, LBU, LHU, LWR +} + +/// Does `instr` write general-purpose register `reg`? +/// Conservative: covers R-type rd writes, loads, and the immediate ALU forms. +bool writesRegister(uint32_t instr, uint32_t reg); + } // namespace mips +/// Find where a function actually ends. +/// +/// A function does **not** end at the first `jr` it contains: hand-written MIPS +/// routinely branches forward over its own epilogue, and jump tables reach +/// labels far past it. Ending there splits one function in two, and the branch +/// that crossed the cut then has to be emitted as a call into the middle of +/// another function -- an address the recompiler never emits, which the +/// dispatcher can only fail on. +/// +/// So the scan tracks `reach`, the furthest forward target seen so far, and +/// accepts a terminator only once it lies at or past `reach`. `jr $ra` always +/// terminates; `jr $reg` terminates only when `$reg` was not loaded from memory +/// (a loaded register means a jump table or computed jump, which stays inside +/// the function). +/// +/// @param words Instruction words, starting at `startAddr`. +/// @param startAddr Virtual address of `words[0]`. +/// @param maxEndAddr Hard upper bound (the next known entry point, or the end +/// of the section). Never returns past this. +/// @return Address one past the function's last instruction, delay slot +/// included. Falls back to `maxEndAddr` when no terminator qualifies. +uint32_t refineFunctionEnd(const std::vector &words, + uint32_t startAddr, uint32_t maxEndAddr); + + + // Function Detection Source enum class FunctionSource { @@ -101,6 +159,20 @@ struct FunctionInfo { } }; +/// Shrink any function that extends into the next one. +/// +/// Sizes arrive from several places -- the ELF symbol table, the gap to the +/// next detected entry, `--add-func` on the command line -- and the later +/// sources do not revisit the earlier ones. So adding an entry point inside an +/// already-sized function leaves that function's extent untouched, and the +/// overlapping bytes get emitted twice, under two different function names and +/// two sets of labels. Every `--add-func` used to work around weak detection +/// created one of these. +/// +/// Expects `funcs` sorted by address; sorts it if not. Leaves the last +/// function alone (nothing follows it to overlap). +void clampOverlappingSizes(std::vector &funcs); + // FunctionFinder class FunctionFinder { diff --git a/ps1Analyzer/src/function_finder.cpp b/ps1Analyzer/src/function_finder.cpp index 60f9986..f57102d 100644 --- a/ps1Analyzer/src/function_finder.cpp +++ b/ps1Analyzer/src/function_finder.cpp @@ -8,6 +8,106 @@ namespace ps1recomp { +namespace mips { + +bool writesRegister(uint32_t instr, uint32_t reg) { + if (reg == 0) + return false; // $zero is never really written + + const uint32_t op = getOpcode(instr); + + if (op == OP_SPECIAL) { + const uint32_t fn = getFunction(instr); + // jr, jalr, mthi, mtlo, mult, multu, div, divu write no GPR rd we track + const bool noRdWrite = (fn == 0x08 || fn == 0x09 || fn == 0x11 || + fn == 0x13 || fn == 0x18 || fn == 0x19 || + fn == 0x1A || fn == 0x1B); + return !noRdWrite && getRd(instr) == reg; + } + if (isLoad(instr)) + return getRt(instr) == reg; + // ADDI, ADDIU, SLTI, SLTIU, ANDI, ORI, XORI, LUI -- all write rt + if (op >= 0x08 && op <= 0x0F) + return getRt(instr) == reg; + return false; +} + +} // namespace mips + +namespace { + +/// Does the instruction at `idx` terminate the function? +/// +/// `jr $ra` always does. For `jr $reg` we walk back to whoever wrote `$reg`: +/// a load means the target came out of memory -- a jump table or dispatch +/// through a pointer, both of which stay inside the function -- while anything +/// else (notably `move $reg, $ra`) means the register carries a return address. +bool isFunctionEnd(const std::vector &words, size_t idx) { + const uint32_t instr = words[idx]; + if (!mips::isJR(instr)) + return false; + const uint32_t reg = mips::getRs(instr); + if (reg == mips::REG_RA) + return true; + + for (size_t k = idx; k-- > 0;) { + if (!mips::writesRegister(words[k], reg)) + continue; + return !mips::isLoad(words[k]); + } + // Nothing in this range wrote it, so it came in from the caller. + return true; +} + +} // namespace + +uint32_t refineFunctionEnd(const std::vector &words, + uint32_t startAddr, uint32_t maxEndAddr) { + if (words.empty() || maxEndAddr <= startAddr) + return maxEndAddr; + + uint32_t reach = startAddr; + + for (size_t i = 0; i < words.size(); ++i) { + const uint32_t addr = startAddr + static_cast(i * 4); + if (addr >= maxEndAddr) + break; + const uint32_t instr = words[i]; + + // Track the furthest forward target: while any branch still jumps past + // this point, the body continues regardless of terminators in between. + if (mips::isBranch(instr) || mips::isJ(instr)) { + const uint32_t target = mips::isJ(instr) + ? mips::jalTarget(addr, instr) + : mips::branchTarget(addr, instr); + if (target > reach && target > startAddr && target < maxEndAddr) + reach = target; + } + + if (isFunctionEnd(words, i) && addr >= reach) { + const uint32_t end = addr + 8; // terminator + its delay slot + return end > maxEndAddr ? maxEndAddr : end; + } + } + + return maxEndAddr; +} + +void clampOverlappingSizes(std::vector &funcs) { + if (funcs.size() < 2) + return; + if (!std::is_sorted(funcs.begin(), funcs.end())) + std::sort(funcs.begin(), funcs.end()); + + for (size_t i = 0; i + 1 < funcs.size(); ++i) { + if (funcs[i + 1].address <= funcs[i].address) + continue; // duplicate address -- not this function's problem + const uint32_t room = funcs[i + 1].address - funcs[i].address; + if (funcs[i].size > room) + funcs[i].size = room; + } +} + // Main Entry Point void FunctionFinder::findFunctions(const ElfParser& elf) { @@ -140,6 +240,12 @@ void FunctionFinder::computeBoundaries(const Section& text) { } } + // No function may reach into the next one. Sizes set by earlier passes + // (ELF symbols) and by `--add-func` after the fact never revisit each + // other, so a containing function keeps its old extent and the same + // bytes get emitted twice, under two names. + clampOverlappingSizes(m_functions); + // Determine leaf functions: scan each function for JAL/JALR instructions for (auto& func : m_functions) { if (func.size == 0 || !text.containsAddress(func.address)) { diff --git a/ps1Recomp/CMakeLists.txt b/ps1Recomp/CMakeLists.txt index ee50da3..da21612 100644 --- a/ps1Recomp/CMakeLists.txt +++ b/ps1Recomp/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(ps1Recomp_lib src/gte_emitter.cpp src/overlay_handler.cpp src/hle_emitter.cpp + src/dispatch_emitter.cpp ) target_include_directories(ps1Recomp_lib diff --git a/ps1Recomp/include/ps1recomp/dispatch_emitter.h b/ps1Recomp/include/ps1recomp/dispatch_emitter.h new file mode 100644 index 0000000..00a4961 --- /dev/null +++ b/ps1Recomp/include/ps1recomp/dispatch_emitter.h @@ -0,0 +1,33 @@ +#pragma once +/** + * @file dispatch_emitter.h + * @brief Emits the body of the generated `recomp_dispatch` function. + * + * `recomp_dispatch` is the runtime resolver for every indirect call and jump + * the recompiler could not bind statically. Its body is fixed text, so it + * lives here rather than inline in the CLI: the policy it encodes -- above all + * what happens when an address was never emitted -- is behaviour worth pinning + * with tests. + */ + +#include + +namespace ps1recomp { + +/** + * @brief Generate the C++ source of `recomp_dispatch`. + * + * Resolution order: NULL guard, direct lookup, KSEG-normalised retry, BIOS + * entry points, BIOS table sentinels, JR-RA trampolines in RAM, and finally + * the unmapped-target path. + * + * **Unmapped targets abort by default.** An address that reaches the end is + * code the recompiler never emitted; continuing past it turns the miss into a + * silent no-op, so the game runs on with corrupted state and the symptom + * appears far from the cause. Aborting makes the failure point the cause. + * `PS1_DISPATCH_PERMISSIVE=1` restores rate-limited logging for when you + * deliberately want to see how far a knowingly-incomplete build gets. + */ +std::string emitDispatchBody(); + +} // namespace ps1recomp diff --git a/ps1Recomp/src/dispatch_emitter.cpp b/ps1Recomp/src/dispatch_emitter.cpp new file mode 100644 index 0000000..72cff6f --- /dev/null +++ b/ps1Recomp/src/dispatch_emitter.cpp @@ -0,0 +1,100 @@ +#include "ps1recomp/dispatch_emitter.h" + +namespace ps1recomp { + +std::string emitDispatchBody() { + return R"CPP(void recomp_dispatch(uint8_t* rdram, recomp_context* ctx, uint32_t addr) { + // Lazy-init on first call + if (!recomp_table_ready) recomp_init_dispatch_table(); + + // 1. NULL pointer guard + if (addr == 0) [[unlikely]] { + static bool nullDispatchWarned = false; + if (!nullDispatchWarned) { + nullDispatchWarned = true; + fmt::print("[DISPATCH] null addr suppressed (startup transient, RA=0x{:08X})\n", ctx->r[31]); + } + return; + } + + // 2. Direct lookup in dispatch table + recomp_func_t fn = recomp_lookup(addr); + if (fn) { fn(rdram, ctx); return; } + + // 3. Address normalization (KSEG mirrors) and retry + uint32_t phys = addr & 0x1FFFFFFFu; + uint32_t normalized = phys | 0x80000000u; + if (normalized != addr) { + fn = recomp_lookup(normalized); + if (fn) { fn(rdram, ctx); return; } + } + + // 4. BIOS entry points (A0, B0, C0 -- any KSEG mirror) + if (ctx->bios) { + if (phys == 0xA0) { ctx->bios->executeA0(); return; } + if (phys == 0xB0) { ctx->bios->executeB0(); return; } + if (phys == 0xC0) { ctx->bios->executeC0(); return; } + } + + // 5. BIOS table sentinel dispatch (B0:xx / C0:xx) + if (ctx->bios) { + if (phys >= 0xB000 && phys < 0xB100) { + ctx->r[9] = phys & 0xFF; // set $t1 = function index + ctx->bios->executeB0(); + return; + } + if (phys >= 0xC000 && phys < 0xC100) { + ctx->r[9] = phys & 0xFF; + ctx->bios->executeC0(); + return; + } + if (phys >= 0xA000 && phys < 0xA100) { + ctx->r[9] = phys & 0xFF; + ctx->bios->executeA0(); + return; + } + } + + // 6. JR RA trampoline detection in RAM + if (phys < 0x200000u) { // Within 2MB main RAM + uint32_t instr = (uint32_t)rdram[phys] | ((uint32_t)rdram[phys+1] << 8) | + ((uint32_t)rdram[phys+2] << 16) | ((uint32_t)rdram[phys+3] << 24); + if (instr == 0x03E00008u) { + return; // JR RA trampoline -- no-op return + } + } + + // 7. Unmapped target -- fatal unless PS1_DISPATCH_PERMISSIVE + // + // Anything reaching here is code the recompiler never emitted. Logging and + // continuing turns the miss into a silent no-op: execution proceeds with + // corrupted state and the symptom surfaces far from its cause. Aborting + // makes the failure point at the cause, which is what makes the fix loop + // converge -- run, read the address and caller, emit it, repeat. + static const bool s_permissive = []() { + const char* e = std::getenv("PS1_DISPATCH_PERMISSIVE"); + return e && e[0] != '\0' && std::strcmp(e, "0") != 0; + }(); + if (!s_permissive) { + fmt::print(stderr, + "[DISPATCH] FATAL: unmapped call to 0x{:08X} (RA=0x{:08X}, phys=0x{:08X})\n" + " This address was never emitted by the recompiler.\n" + " Set PS1_DISPATCH_PERMISSIVE=1 to log and continue instead.\n", + addr, ctx->r[31], phys); + std::fflush(stderr); + std::abort(); + } + static std::unordered_map s_unknownHits; + auto& hitCount = s_unknownHits[addr]; + if (hitCount < 5) { + fmt::print(stderr, "[DISPATCH] Unknown target: 0x{:08X} (RA=0x{:08X}, phys=0x{:08X})\n", + addr, ctx->r[31], phys); + } else if (hitCount == 5) { + fmt::print(stderr, "[DISPATCH] Unknown target: 0x{:08X} -- suppressing further logs\n", addr); + } + hitCount++; +} +)CPP"; +} + +} // namespace ps1recomp diff --git a/ps1Recomp/src/instruction_emitter.cpp b/ps1Recomp/src/instruction_emitter.cpp index 8e29ddf..203d3cd 100644 --- a/ps1Recomp/src/instruction_emitter.cpp +++ b/ps1Recomp/src/instruction_emitter.cpp @@ -351,7 +351,13 @@ std::string InstructionEmitter::emitJump(const Instruction &inst, case InstrId::JAL: { auto target = inst.jumpTarget(pc); auto name = defaultFuncName(target); - return fmt::format("{}(rdram, ctx);", name); + // `jal` writes $ra = PC+8 on hardware. The C++ call returns on its own, so + // it is tempting to skip this -- but $ra is data as much as it is control + // flow: callees save it to the stack, pass it on, and jump through it. A + // stale $ra from some earlier indirect call then travels into memory and + // comes back as a garbage pointer. JALR below already does this; JAL did + // not, across every one of its call sites. + return fmt::format("ctx->r31 = 0x{:08X}; {}(rdram, ctx);", pc + 8, name); } case InstrId::JR: if (inst.rs == 31) { @@ -693,6 +699,35 @@ std::string InstructionEmitter::emitFunction(const RecompFunction &func) const { result += yieldCode; result += fmt::format(" {}\n", code); } + // A delay slot can also be somebody else's branch target. It is emitted + // inline above (before the transfer), and the main loop is about to skip + // its natural position -- which would take its label with it, leaving + // `goto L_` with no definition anywhere. The post-pass then turns + // that into a dispatch to an address inside this very function, and the + // runtime can only fail on it. + // + // Measured case: Crash SCUS-94900 func_8003A144, where 0x8003A264 is the + // delay slot of the branch at 0x8003A260 *and* the target of branches at + // 0x8003A1E0 and 0x8003A258. + // + // So re-emit it at its natural position, labelled, for control arriving + // from elsewhere. The fall-through path already ran it inline, so it + // jumps over the copy. + const bool dsIsBranchTarget = + (i + 1) < labelTargets.size() && labelTargets[i + 1]; + if (dsIsBranchTarget) { + const uint32_t dsAddr = addr + 4; + const bool guardFallThrough = inst.isBranch(); + const std::string done = fmt::format("L_dsdone_{:08X}", dsAddr); + if (guardFallThrough) + result += fmt::format(" goto {};\n", done); + result += fmt::format("{}:\n", label(dsAddr)); + result += fmt::format(" {} // delay slot, also a branch target\n", + delayCode); + if (guardFallThrough) + result += fmt::format("{}: ;\n", done); + } + ++i; // Skip delay slot instruction } else { result += fmt::format(" {}\n", code); diff --git a/ps1Recomp/src/main.cpp b/ps1Recomp/src/main.cpp index 54fcc55..3e49994 100644 --- a/ps1Recomp/src/main.cpp +++ b/ps1Recomp/src/main.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -22,16 +23,27 @@ using namespace ps1recomp; // Jump Table Auto-Detection // // Detects the canonical MIPS switch table pattern: -// LUI $rbase, hi ; rbase = upper address -// ADDIU $rbase, $rbase, lo ; rbase = table_base -// ADDU $rtbl, $rbase, $ridx ; rtbl = table_base + index*4 -// LW $rtgt, N($rtbl) ; rtgt = table[index] -// JR $rtgt ; indirect jump +// SLTIU $rchk, $ridx, N ; bounds check against the entry count +// BEQZ $rchk, default ; out-of-range -> default case +// SLL $rscale, $ridx, 2 ; scale index to a word offset (delay slot) +// LUI $rbase, hi ; rbase = upper address +// ADDIU $rbase, $rbase, lo ; rbase = table_base +// ADDU $rtbl, $rbase, $rscale ; rtbl = table_base + index*4 +// LW $rtgt, N($rtbl) ; rtgt = table[index] +// JR $rtgt ; indirect jump // // When found, reads the target addresses from the ELF data section // and stores them in a JumpTableEntry on the RecompFunction. // +// The entry count comes from the SLTIU/SLTI bounds check when one is present. +// Without it there is nothing in the code stream that marks where the table +// ends, so we fall back to a conservative fixed window: a table is only +// 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, const ElfParser &parser) { @@ -58,49 +70,88 @@ detectJumpTable(const std::vector &instrs, size_t jr_idx, return {}; // Step 2: Find ADDU $lw_base_reg, $rconst, $rscale within 8 instructions back. - // One of rs/rt should be a constant pointer, the other the scaled index. - uint8_t const_reg = 0xFF; + // One of rs/rt is the constant table pointer, the other the scaled index. + int addu_idx = -1; + uint8_t addu_rs = 0, addu_rt = 0; for (int j = lw_idx - 1; j >= 0 && j >= (int)jr_idx - 12; j--) { Instruction inst = MipsDecoder::decode(instrs[j]); if (inst.id == InstrId::ADDU && inst.rd == lw_base_reg) { - // Assume rs is the constant-address register (table_base) - const_reg = inst.rs; + addu_idx = j; + addu_rs = inst.rs; + addu_rt = inst.rt; break; } } - if (const_reg == 0xFF) + if (addu_idx < 0) return {}; - // Step 3: Track const_reg back to LUI (+ optional ADDIU) within 16 instrs + // Step 3: Track a candidate register back to LUI (+ optional ADDIU). + // Whichever ADDU operand resolves to a LUI is the table base; the other + // one is the scaled index. uint32_t lui_val = 0; int16_t addiu_imm = 0; - bool found_lui = false; - for (int j = lw_idx - 1; j >= 0 && j >= (int)jr_idx - 20; j--) { - Instruction inst = MipsDecoder::decode(instrs[j]); - if (inst.id == InstrId::LUI && inst.rt == const_reg) { - lui_val = static_cast(static_cast(inst.imm16)) << 16; - found_lui = true; - break; - } - if ((inst.id == InstrId::ADDIU || inst.id == InstrId::ADDI) && - inst.rt == const_reg && inst.rs == const_reg) { - addiu_imm = inst.imm16; + auto traceToLui = [&](uint8_t reg) { + lui_val = 0; + addiu_imm = 0; + for (int j = lw_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; + }; + + uint8_t scaled_reg = addu_rt; + if (!traceToLui(addu_rs)) { + scaled_reg = addu_rs; + if (!traceToLui(addu_rt)) + return {}; } - if (!found_lui) - return {}; uint32_t table_addr = lui_val + static_cast(addiu_imm) + static_cast(lw_imm); - // Step 4: Read table entries from ELF data section + // Step 4: Recover the entry count from the bounds check that guards the + // table. SLL $scaled_reg, $ridx, 2 gives the raw index register, and the + // SLTIU/SLTI against that register carries the number of entries. + uint32_t max_entries = kJumpTableFallbackEntries; + int sll_idx = -1; + uint8_t index_reg = 0; + for (int j = addu_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) { + 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: Read table entries from ELF data section const Section *section = parser.findSectionByAddress(table_addr); if (!section || section->data == nullptr) return {}; uint32_t offset = table_addr - section->vaddr; std::vector targets; - for (uint32_t i = 0; i < 64; i++) { + for (uint32_t i = 0; i < max_entries; i++) { uint32_t entry_offset = offset + i * 4; if (entry_offset + 4 > section->size) break; @@ -698,7 +749,10 @@ int main(int argc, char *argv[]) { result_cpp += "\n// Dispatch Table\n"; result_cpp += "// Maps PS1 addresses to recompiled function pointers\n"; result_cpp += "// Populated once at startup, queried on every indirect call/jump\n\n"; - result_cpp += "#include \n\n"; + result_cpp += "#include \n"; + result_cpp += "#include \n"; // getenv, abort + result_cpp += "#include \n"; // strcmp + result_cpp += "#include \n\n"; // fflush result_cpp += "typedef void (*recomp_func_t)(uint8_t*, recomp_context*);\n\n"; @@ -722,108 +776,9 @@ int main(int argc, char *argv[]) { result_cpp += " return (it != recomp_func_table.end()) ? it->second : nullptr;\n"; result_cpp += "}\n\n"; - // Override registration: lets main_host.cpp replace any function entry - // with a C++ stub. Used for env-gated per-game patches (e.g. Crash - // PS1_SKIP_31BF8) where a single MIPS function needs to be NOP'd - // without modifying the generated source. Forward-declared in - // `runtime/ps1_runtime_macros.h`-adjacent headers. - result_cpp += "void recomp_register_override(uint32_t addr, recomp_func_t fn) {\n"; - result_cpp += " if (!recomp_table_ready) recomp_init_dispatch_table();\n"; - result_cpp += " recomp_func_table[addr] = fn;\n"; - result_cpp += "}\n\n"; - - // Main dispatch function - result_cpp += "void recomp_dispatch(uint8_t* rdram, recomp_context* ctx, " - "uint32_t addr) {\n"; - result_cpp += " // Lazy-init on first call\n"; - result_cpp += " if (!recomp_table_ready) recomp_init_dispatch_table();\n\n"; - - // Step 1: NULL pointer guard - // Null dispatches are benign at startup (uninitialized callback pointers). - // Log only once so noise is minimal, then silently drop subsequent calls. - result_cpp += " // 1. NULL pointer guard\n"; - result_cpp += " if (addr == 0) [[unlikely]] {\n"; - result_cpp += " static bool nullDispatchWarned = false;\n"; - result_cpp += " if (!nullDispatchWarned) {\n"; - result_cpp += " nullDispatchWarned = true;\n"; - result_cpp += " fmt::print(\"[DISPATCH] null addr suppressed" - " (startup transient, RA=0x{:08X})\\n\", ctx->r[31]);\n"; - result_cpp += " }\n"; - result_cpp += " return;\n"; - result_cpp += " }\n\n"; - - // Step 2: Direct lookup - result_cpp += " // 2. Direct lookup in dispatch table\n"; - result_cpp += " recomp_func_t fn = recomp_lookup(addr);\n"; - result_cpp += " if (fn) { fn(rdram, ctx); return; }\n\n"; - - // Step 3: Normalize address (KSEG0/KSEG1 -> canonical KSEG0) and retry - result_cpp += " // 3. Address normalization (KSEG mirrors) and retry\n"; - result_cpp += " uint32_t phys = addr & 0x1FFFFFFFu;\n"; - result_cpp += " uint32_t normalized = phys | 0x80000000u;\n"; - result_cpp += " if (normalized != addr) {\n"; - result_cpp += " fn = recomp_lookup(normalized);\n"; - result_cpp += " if (fn) { fn(rdram, ctx); return; }\n"; - result_cpp += " }\n\n"; - - // Step 4: BIOS entry points A0/B0/C0 (any KSEG mirror) - result_cpp += " // 4. BIOS entry points (A0, B0, C0 -- any KSEG mirror)\n"; - result_cpp += " if (ctx->bios) {\n"; - result_cpp += " if (phys == 0xA0) { ctx->bios->executeA0(); return; }\n"; - result_cpp += " if (phys == 0xB0) { ctx->bios->executeB0(); return; }\n"; - result_cpp += " if (phys == 0xC0) { ctx->bios->executeC0(); return; }\n"; - result_cpp += " }\n\n"; - - // Step 5: BIOS table sentinel addresses - // The BIOS fills B0/C0 tables with sentinel addresses 0x0000B0xx/0x0000C0xx. - // When the game reads a table entry and jalr's to it, we intercept here. - result_cpp += " // 5. BIOS table sentinel dispatch (B0:xx / C0:xx)\n"; - result_cpp += " if (ctx->bios) {\n"; - result_cpp += " if (phys >= 0xB000 && phys < 0xB100) {\n"; - result_cpp += " ctx->r[9] = phys & 0xFF; // set $t1 = function index\n"; - result_cpp += " ctx->bios->executeB0();\n"; - result_cpp += " return;\n"; - result_cpp += " }\n"; - result_cpp += " if (phys >= 0xC000 && phys < 0xC100) {\n"; - result_cpp += " ctx->r[9] = phys & 0xFF;\n"; - result_cpp += " ctx->bios->executeC0();\n"; - result_cpp += " return;\n"; - result_cpp += " }\n"; - result_cpp += " if (phys >= 0xA000 && phys < 0xA100) {\n"; - result_cpp += " ctx->r[9] = phys & 0xFF;\n"; - result_cpp += " ctx->bios->executeA0();\n"; - result_cpp += " return;\n"; - result_cpp += " }\n"; - result_cpp += " }\n\n"; - - // Step 6: RAM JR-RA trampoline detection - // BIOS or game may write JR RA (0x03E00008) at hook addresses. - // If target RAM contains JR RA, the intended behavior is "just return". - result_cpp += " // 6. JR RA trampoline detection in RAM\n"; - result_cpp += " if (phys < 0x200000u) { // Within 2MB main RAM\n"; - result_cpp += " uint32_t instr = (uint32_t)rdram[phys] | " - "((uint32_t)rdram[phys+1] << 8) |\n"; - result_cpp += " ((uint32_t)rdram[phys+2] << 16) | " - "((uint32_t)rdram[phys+3] << 24);\n"; - result_cpp += " if (instr == 0x03E00008u) {\n"; - result_cpp += " return; // JR RA trampoline -- no-op return\n"; - result_cpp += " }\n"; - result_cpp += " }\n\n"; - - // Step 7: Rate-limited fallback logging - result_cpp += " // 7. Unknown target -- rate-limited log (don't crash)\n"; - result_cpp += " static std::unordered_map s_unknownHits;\n"; - result_cpp += " auto& hitCount = s_unknownHits[addr];\n"; - result_cpp += " if (hitCount < 5) {\n"; - result_cpp += " fmt::print(stderr, \"[DISPATCH] Unknown target: " - "0x{:08X} (RA=0x{:08X}, phys=0x{:08X})\\n\",\n"; - result_cpp += " addr, ctx->r[31], phys);\n"; - result_cpp += " } else if (hitCount == 5) {\n"; - result_cpp += " fmt::print(stderr, \"[DISPATCH] Unknown target: " - "0x{:08X} -- suppressing further logs\\n\", addr);\n"; - result_cpp += " }\n"; - result_cpp += " hitCount++;\n"; - result_cpp += "}\n"; + // Main dispatch function -- body lives in dispatch_emitter.cpp so the + // unmapped-target policy it encodes can be pinned by tests. + result_cpp += ps1recomp::emitDispatchBody(); std::ofstream out(output_path); if (!out) { diff --git a/ps1Runtime/CMakeLists.txt b/ps1Runtime/CMakeLists.txt index 22d3667..d439eee 100644 --- a/ps1Runtime/CMakeLists.txt +++ b/ps1Runtime/CMakeLists.txt @@ -4,6 +4,9 @@ find_package(OpenGL REQUIRED) # Runtime library (shared between exe and tests) add_library(ps1Runtime_lib STATIC src/gte.cpp + src/metrics.cpp + src/gool/emu_compat.cpp + src/gool/gool_interp.cpp src/cdrom/cue_parser.cpp src/cdrom/bin_reader.cpp src/cdrom/iso9660.cpp diff --git a/ps1Runtime/include/runtime/gool/emu_compat.h b/ps1Runtime/include/runtime/gool/emu_compat.h new file mode 100644 index 0000000..c3e55e6 --- /dev/null +++ b/ps1Runtime/include/runtime/gool/emu_compat.h @@ -0,0 +1,93 @@ +#pragma once +/** + * @file emu_compat.h + * @brief Compatibility shim that lets the hand-decompiled GOOL engine + * (ported from the `c1c` reference, which targets a threaded + * PS1 interpreter) run on top of this project's *static* + * recompilation runtime. + * + * The `c1c` GOOL sources express every PS1-memory access and every + * callout to a native PS1 routine through a small primitive vocabulary + * (`EMU_ReadU32`, `EMU_Write32`, `EMUADDR`, `EMU_Invoke`, ...). Those + * primitives are defined here against the facilities this runtime + * already has: + * + * - reads/writes -> `recomp_context::mem->read32 / write32` (full + * path: RAM, scratchpad, MMIO) + * - host<->PS1 -> `ps1::emuptr_translate` and its inverse + * - native call -> `recomp_dispatch` (the static-recomp dispatcher) + * + * **Key difference from the c1c reference.** `c1c::EMU_Invoke` drives a + * threaded program-counter loop with a `0xDEADBEEF` return sentinel + * because c1c interprets. Here every recompiled MIPS function is a real + * C++ function that returns at its `JR RA`, so `EMU_Invoke` simply sets + * up the argument registers, calls `recomp_dispatch`, and reads `V0`. + * + * **Binding.** The `EMU_*` functions are free functions but operate on + * the active `recomp_context`. The GOOL interpreter override binds it on + * entry via `emu_bind()` and clears it on return via `emu_unbind()`. + * Binding is thread-local: only the game thread runs GOOL. + */ + +#include +#include + +struct recomp_context; + +namespace ps1::gool { + +/// Bind the active CPU context + RAM base for the duration of one GOOL +/// interpretation. Call on override entry; pair with `emu_unbind()`. +void emu_bind(uint8_t *rdram, recomp_context *ctx) noexcept; +void emu_unbind() noexcept; + +/// Access to the bound context (asserts in debug if unbound). Exposed so +/// the ported GOOL translation unit can define the bare-name MIPS +/// register macros (A0, V0, SP, RA, LO, HI, ...) without pulling in the +/// `ps1::Register` enum, which would collide with those macro names. +recomp_context *emu_ctx() noexcept; +uint32_t &emu_reg(int index) noexcept; // index: 0..31 GPR, 32=LO, 33=HI + +// --- Memory primitives (route through Memory: RAM/scratchpad/MMIO) ---- + +uint8_t EMU_ReadU8(uint32_t address) noexcept; +uint16_t EMU_ReadU16(uint32_t address) noexcept; +uint32_t EMU_ReadU32(uint32_t address) noexcept; +int8_t EMU_ReadS8(uint32_t address) noexcept; +int16_t EMU_ReadS16(uint32_t address) noexcept; + +void EMU_Write8(uint32_t address, uint8_t value) noexcept; +void EMU_Write16(uint32_t address, uint16_t value) noexcept; +void EMU_Write32(uint32_t address, uint32_t value) noexcept; + +// --- Host pointer <-> PS1 address ------------------------------------- + +/// Inverse of `emuptr_translate`: maps a host pointer that lives inside +/// the bound RAM buffer back to its PS1 (KSEG0) virtual address. 64-bit +/// safe, unlike the c1c macro which truncates the pointer to uint32_t. +uint32_t EMU_Address(const void *host_ptr) noexcept; + +// --- Native callout --------------------------------------------------- + +/// Call the recompiled PS1 function at `address` with `argc` arguments. +/// Args 0..3 go in A0..A3; args 4+ are written to the reserved outgoing +/// stack slots (o32 ABI), matching the c1c reference. Returns V0. +uint32_t EMU_Invoke(uint32_t address, int argc, ...) noexcept; + +// --- HI/LO mul-div helpers (mirror c1c::EMU_*Multiply / *Divide) ------ + +void EMU_SMultiply(int32_t a, int32_t b) noexcept; +void EMU_UMultiply(uint32_t a, uint32_t b) noexcept; +void EMU_SDivide(int32_t a, int32_t b) noexcept; +void EMU_UDivide(uint32_t a, uint32_t b) noexcept; + +/// Debug breakpoint hook in the original engine. No-op here (logged once +/// under PS1_GOOL_TRACE). +void EMU_Break(uint32_t id) noexcept; + +} // namespace ps1::gool + +/// 64-bit-safe replacement for the c1c `EMUADDR(x)` macro. The original +/// is `EMU_Address((uint32_t)(x))`, which truncates a host pointer on a +/// 64-bit build; this passes the full pointer. +#define EMUADDR(x) ::ps1::gool::EMU_Address((const void *)(x)) diff --git a/ps1Runtime/include/runtime/gool/gool_types.h b/ps1Runtime/include/runtime/gool/gool_types.h new file mode 100644 index 0000000..d1791d6 --- /dev/null +++ b/ps1Runtime/include/runtime/gool/gool_types.h @@ -0,0 +1,363 @@ +#pragma once +/** + * @file gool_types.h + * @brief PS1-memory layout structs for the GOOL engine, ported verbatim + * from the `c1c` hand-decompilation (src/gfx.h, src/ns.h, + * src/gool.h). + * + * These mirror the byte layout of structures that live in the Crash + * Bandicoot game's RAM, so the ported GOOL interpreter can manipulate + * them through `ps1::emuptr` exactly as the original MIPS code did. + * + * **Byte-exactness is load-bearing.** Every field type and order matches + * the decomp. `EMUPTR(T)` resolves to `ps1::emuptr` (a 4-byte trivial + * wrapper, ABI-identical to the PS1's 32-bit pointer). The `static_assert` + * block at the end pins the field offsets the decomp annotated (0x104, + * 0x130, ...); if a future edit perturbs the layout the build breaks + * loudly instead of corrupting game state silently. + * + * Source of truth: ../PS1Recomp-workspace/c1c/src/{gfx,ns,gool}.h + */ + +#include "runtime/emuptr.h" + +#include +#include + +/// In C++ the c1c `EMUPTR(x)` macro expands to `emuptr`; here it maps +/// to this project's `ps1::emuptr`. Kept as a macro so the ported +/// struct bodies stay textually faithful to the decomp. +#ifndef EMUPTR +#define EMUPTR(T) ::ps1::emuptr +#endif + +namespace ps1::gool { + +// "unknown-purpose" scalar aliases from the decomp (c1c/src/emu.h). +using unk8_t = uint8_t; +using unk16_t = uint16_t; +using unk32_t = uint32_t; + +// --------------------------------------------------------------------- +// gfx.h — geometry / colour primitives +// --------------------------------------------------------------------- + +struct vector { + int32_t x; + int32_t y; + int32_t z; +}; +using point = vector; // c1c: #define point vector + +struct svector { + int16_t x; + int16_t y; + int16_t z; +}; +using spoint = svector; // c1c: #define spoint svector + +struct angle { + int32_t y; + int32_t x; + int32_t z; +}; + +struct sangle { + int16_t y; + int16_t x; + int16_t z; +}; + +struct bound { + point p1; + point p2; +}; +using space = bound; // c1c: #define space bound + +struct dimension { + int32_t w; + int32_t h; + int32_t d; +}; + +struct volume { + point pos; + dimension dim; +}; + +struct matrix { + vector v1; + vector v2; + vector v3; +}; + +struct smatrix { + svector v1; + svector v2; + svector v3; +}; +using slightmatrix = smatrix; // c1c: #define slightmatrix smatrix + +struct scolor { + uint16_t r; + uint16_t g; + uint16_t b; +}; + +struct scolormatrix { + scolor v1; + scolor v2; + scolor v3; +}; + +// --------------------------------------------------------------------- +// ns.h — NaughtyStorage (paging) entries +// --------------------------------------------------------------------- + +using nsitem = uint8_t; + +struct nsentry { + unk32_t magic; + unk32_t id; + unk32_t type; + int32_t itemcount; + EMUPTR(nsitem) items[]; // flexible array (GCC/Clang extension) +}; + +struct nspage { + uint16_t magic; + uint16_t type; + unk32_t pagenum; + int32_t entrycount; + unk32_t checksum; + EMUPTR(nsentry) entries[]; +}; + +struct nspageinfo { + EMUPTR(nspage) page; + int16_t status; + unk16_t off_6; + int16_t unk_8; + unk16_t off_10; + int16_t savings; + int16_t compressed; + EMUPTR(nspage) nextpage; + int32_t pagenum; + unk32_t off_24; + int32_t pagecount; + unk32_t off_32; + unk32_t off_36; + unk32_t off_40; +}; + +// --------------------------------------------------------------------- +// gool.h — object engine structures +// --------------------------------------------------------------------- + +struct goolobj; // self-referential + +struct gooldbllink { + EMUPTR(goolobj) prev; + EMUPTR(goolobj) next; + unk32_t unk_8; +}; + +struct goolobj { + uint32_t header; + union { + uint32_t proctype; + EMUPTR(goolobj) listchildren; + }; + struct bound bound; // elaborated: member shares the type's name (C++) + EMUPTR(nsentry) local; + EMUPTR(nsentry) external; + EMUPTR(nsentry) zone; + uint32_t state; + + union { + slightmatrix colors; + slightmatrix lightmatrix; + }; + scolor color; + scolormatrix colormatrix; + scolor intensity; + + EMUPTR(goolobj) self; + EMUPTR(goolobj) parent; + EMUPTR(goolobj) sibling; + EMUPTR(goolobj) children; + EMUPTR(goolobj) creator; + EMUPTR(goolobj) player; + EMUPTR(goolobj) collider; + EMUPTR(goolobj) invoker; + + union { + vector vectors; + vector trans; + }; + angle rot; + vector scale; + union { + vector velocity; + gooldbllink link; + }; + angle targetrot; + union { + angle angvelocity; + struct { + uint32_t modeflagsa; + uint32_t modeflagsb; + uint32_t modeflagsc; + }; + }; + + uint32_t statusa; + uint32_t statusb; + uint32_t statusc; + uint32_t subtype; + uint32_t id; + EMUPTR(uint32_t) sp; + EMUPTR(uint32_t) pc; + EMUPTR(uint32_t) fp; + EMUPTR(uint32_t) pctrans; + EMUPTR(uint32_t) pcevent; + EMUPTR(uint32_t) pchead; + union { + uint32_t misc_flag; + EMUPTR(goolobj) misc_child; + EMUPTR(nsentry) misc_entry; + uint32_t misc_memcard; + }; + uint32_t unk_F8; + uint32_t stampanim; + uint32_t stampstate; + uint32_t animcounter; // 0x104 + EMUPTR(uint32_t) animseq; // 0x108 + uint32_t animframe; // 0x10C + + EMUPTR(nsitem) entity; + int32_t pathprogress; + uint32_t pathcount; + uint32_t groundy; + uint32_t stateflags; + int32_t speed; + uint32_t displaymode; + uint32_t unk_12C; + uint32_t stampland; // 0x130 + int32_t landyvel; // 0x134 + uint32_t zindex; // 0x138 + uint32_t event; // 0x13C + int32_t camzoom; // 0x140 + uint32_t approachyz; // 0x144 + int32_t hotspotclip; // 0x148 + int32_t unk_14C; + unk32_t unk_150; + unk32_t unk_154; + int32_t node; + uint32_t memory[0x40]; +}; + +struct goolobjlist { + uint32_t header; + EMUPTR(goolobj) children; +}; + +struct goolentity { + EMUPTR(nsentry) parententry; + uint16_t spawnflags; + uint16_t proctype; + uint16_t id; + uint16_t pathlen; + union { + sangle rot; + struct { + uint16_t modeflagsa; + uint16_t modeflagsb; + uint16_t modeflagsc; + }; + }; + uint8_t type; + uint8_t subtype; + spoint path[]; +}; + +struct goolstateinfo { + uint32_t flags; + uint32_t statusc; + uint16_t externeidrel; + uint16_t event; + uint16_t trans; + uint16_t code; +}; + +struct goolstateref { + uint32_t state; + uint32_t guard; +}; + +struct gooleventpoll { + union { + uint32_t categories; + struct { + uint32_t unused_flags : 25; + uint32_t sendtoenemiesc : 1; + uint32_t sendtoenemiesb : 1; + uint32_t sendtosprites : 1; + uint32_t sendtoenemiesa : 1; + uint32_t sendtopausemenu : 1; + uint32_t sendtoplayer : 1; + uint32_t unk_flag : 1; + }; + }; + EMUPTR(goolobj) sender; + EMUPTR(goolobj) collider; + uint32_t distnearest; + uint32_t event; +}; + +#define GOOL_OBJECT_LEVELSPAWNLISTSIZE 3592 +#define GOOL_OBJECT_SPAWNLISTSIZE 304 +#define GOOL_OBJECT_POOLSIZE 96 + +struct levelstate { + vector playertrans; + angle playerrot; + vector playerscale; + uint32_t currentzone; + uint32_t currentsection; + uint32_t currentprogress; + uint32_t levelid; + uint32_t flag; + uint32_t spawnlist[GOOL_OBJECT_SPAWNLISTSIZE]; + uint32_t boxesbroken; +}; + +// --------------------------------------------------------------------- +// Layout guards — pin the offsets the decomp annotated. Both the PS1 +// (MIPS o32) and host (x86-64) are little-endian with identical scalar +// sizes and natural alignment, so a verbatim port reproduces the layout. +// If any of these fire, the struct diverged from game memory. +// --------------------------------------------------------------------- + +static_assert(sizeof(::ps1::emuptr) == 4, + "emuptr must be 4 bytes to match the PS1 pointer width"); +static_assert(sizeof(vector) == 12); +static_assert(sizeof(bound) == 24); +static_assert(sizeof(smatrix) == 18); +static_assert(sizeof(scolor) == 6); +static_assert(sizeof(scolormatrix) == 18); +static_assert(offsetof(goolobj, animcounter) == 0x104); +static_assert(offsetof(goolobj, animseq) == 0x108); +static_assert(offsetof(goolobj, animframe) == 0x10C); +static_assert(offsetof(goolobj, stampland) == 0x130); +static_assert(offsetof(goolobj, landyvel) == 0x134); +static_assert(offsetof(goolobj, zindex) == 0x138); +static_assert(offsetof(goolobj, event) == 0x13C); +static_assert(offsetof(goolobj, camzoom) == 0x140); +static_assert(offsetof(goolobj, approachyz) == 0x144); +static_assert(offsetof(goolobj, hotspotclip) == 0x148); +static_assert(offsetof(goolobj, node) == 0x158); +static_assert(offsetof(goolobj, memory) == 0x15C); +static_assert(sizeof(goolobj) == 0x25C, "goolobj must match PS1 layout (no trailing padding)"); + +} // namespace ps1::gool diff --git a/ps1Runtime/include/runtime/gpu/gpu.h b/ps1Runtime/include/runtime/gpu/gpu.h index e83cfc7..22b30f4 100644 --- a/ps1Runtime/include/runtime/gpu/gpu.h +++ b/ps1Runtime/include/runtime/gpu/gpu.h @@ -13,6 +13,7 @@ * the VBlank tick on the game thread. Internal state is single-writer. */ +#include #include #include #include @@ -77,6 +78,13 @@ class GPU { // Get a pointer to the 1024x512 VRAM framebuffer (live -- game thread writes here) const Color16 *getVRAM() const { return vram_.data(); } + /// Push accumulated GPU counters into ps1::metrics at shutdown. + /// `ps1::metrics::count()` accumulates, so this publishes only what has + /// accrued since the previous call: main_host reaches this from two + /// shutdown paths (normal join and the forced `_Exit`), and a flat + /// re-publish would double every `gp0.op.XX` and `gp0.words`. + void publishMetrics() const; + // Load 1024x512x2 bytes of VRAM from a save-state buffer. void loadVram(const uint8_t *data); @@ -133,6 +141,17 @@ class GPU { uint32_t gpuStat_; uint32_t gpuRead_; + // GP0 opcode histogram. Written on the game thread, read by the main + // thread at shutdown, hence atomic. Indexed by the command byte. + mutable std::atomic gp0Hist_[256]{}; + mutable std::atomic gp0Words_{0}; + + // High-water marks of what publishMetrics() has already handed to + // ps1::metrics. Only ever touched by publishMetrics(), which runs on the + // main thread at shutdown, so these need no synchronisation of their own. + mutable uint64_t gp0HistPublished_[256]{}; + mutable uint64_t gp0WordsPublished_{0}; + // Command buffering std::deque commandQueue_; uint32_t expectedCommandWords_; diff --git a/ps1Runtime/include/runtime/metrics.h b/ps1Runtime/include/runtime/metrics.h new file mode 100644 index 0000000..3beaccb --- /dev/null +++ b/ps1Runtime/include/runtime/metrics.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +// Runtime metric collector. +// +// Exists because every legacy GPU log in this project is capped (FillRect at +// 10, CPU->VRAM at 20) or one-shot (the GP0 histogram fires at fixed word +// counts). Two consecutive debugging sessions reached opposite conclusions +// from those logs. Counters here are uncapped and are the source of truth for +// any decision. +// +// Disabled unless PS1_METRICS names an output path, so the hot path pays +// nothing in a normal run. +namespace ps1::metrics { + +/// True when PS1_METRICS is set. Resolved once from the environment. +bool enabled(); + +/// Add `n` to the counter `name`. No-op while disabled. +void count(std::string_view name, uint64_t n = 1); + +/// Record the latest value of `name`. No-op while disabled. +void setState(std::string_view name, int64_t value); + +/// Read a counter back. Returns 0 when absent. +uint64_t get(std::string_view name); + +/// Read a state field back. Returns 0 when absent. +int64_t getState(std::string_view name); + +/// Write the JSON report to the path in PS1_METRICS. +bool dumpJson(); + +/// Write the JSON report to an explicit path, ignoring PS1_METRICS. +bool dumpJsonTo(const char *path); + +/// Drop everything recorded so far. +void reset(); + +/// Force the enabled flag, bypassing the environment. For tests. +void setEnabledForTesting(bool on); + +} // namespace ps1::metrics diff --git a/ps1Runtime/include/runtime/psyq/psyq_hle.h b/ps1Runtime/include/runtime/psyq/psyq_hle.h index c108492..eeae717 100644 --- a/ps1Runtime/include/runtime/psyq/psyq_hle.h +++ b/ps1Runtime/include/runtime/psyq/psyq_hle.h @@ -57,6 +57,21 @@ void configure(const HleConfig &cfg); /// stubs that need writeGP0/writeGP1 callbacks without re-binding them. const HleConfig &getConfig(); +/** + * @brief Advance the game's root-counter tick word by `vblanks` VBlanks' worth. + * + * Models the root-counter interrupt callback this runtime never fires: on + * hardware the game arms `SetRCnt` and registers a handler that increments a + * word in its own BSS, and that word is the frame delta its physics and waits + * are written against. Address and rate come from `psyq_state()` + * (`rcntTickAddr` / `rcntTicksPerVBlank`, populated from the game TOML's + * `[timing]` block); when either is zero this is a no-op. + * + * Runs on the game thread, from `hle_VSync`, so it serialises with the game's + * own writes to the same word. + */ +void applyRootCounterTicks(recomp_context *ctx, uint32_t vblanks); + // PsyQ SDK HLE implementations /// VSync(n) -- wait for n vertical blanks then return the total VBlank count. @@ -101,4 +116,9 @@ void hle_SetDefDrawEnv(recomp_context *ctx); /// a0=*DrawEnv void hle_PutDrawEnv(recomp_context *ctx); +/// GOOL bytecode VM entry (Crash Bandicoot, native 0x800201DC). +/// a0=*goolobj, a1=flags, a2=*goolstateref. Wired through [hle_overrides] +/// so direct JAL callers reach it. Implemented in src/gool/gool_interp.cpp. +void hle_gool_InterpretObject(recomp_context *ctx); + } // namespace ps1::psyq diff --git a/ps1Runtime/include/runtime/psyq/psyq_libgpu.h b/ps1Runtime/include/runtime/psyq/psyq_libgpu.h index 391c411..96c4e29 100644 --- a/ps1Runtime/include/runtime/psyq/psyq_libgpu.h +++ b/ps1Runtime/include/runtime/psyq/psyq_libgpu.h @@ -61,6 +61,11 @@ void hle_libgpu_SetVideoMode(recomp_context *ctx); /// GetVideoMode(): returns current video mode (0=NTSC, 1=PAL). void hle_libgpu_GetVideoMode(recomp_context *ctx); +/// C++-side accessor for the current video mode (0=NTSC, 1=PAL), for other +/// HLE modules (PutDispEnv, SetDefDrawEnv) that must follow the same +/// PAL/NTSC branch the PsyQ source takes internally via GetVideoMode(). +int getVideoMode(); + // Group 1.A -- libgs scene-graph wrappers (NOP stubs) // // Both Rayman and Crash bypass libgs and drive libgpu directly. These stubs diff --git a/ps1Runtime/include/runtime/psyq/psyq_state.h b/ps1Runtime/include/runtime/psyq/psyq_state.h index 596e267..fe899c6 100644 --- a/ps1Runtime/include/runtime/psyq/psyq_state.h +++ b/ps1Runtime/include/runtime/psyq/psyq_state.h @@ -140,6 +140,27 @@ class PsyqState { /// `gpu_drawsync_index_addr` / `gpu_drawsync_count`. GpuDrawSync drawSync; + // Root-counter tick accumulator + /// PS1 RAM address of a game-owned tick word that a root-counter IRQ + /// callback would increment on real hardware. Zero disables the whole + /// mechanism. Set from the game TOML's `[timing]` block. + /// + /// Games that drive their frame delta off a root counter register an + /// `OpenEvent(0xF200000N, EvSpINT, EvMdINTR, fn)` and arm `SetRCnt` with + /// a target; the BIOS then runs `fn` every time the counter reaches it. + /// This runtime models interrupts cooperatively and never fires that + /// callback, so the word stays frozen and every time-driven wait in the + /// game stalls or crawls. These two fields let the VSync HLE advance it + /// at the right rate instead. + uint32_t rcntTickAddr = 0; + + /// Units to add to `rcntTickAddr` per elapsed VBlank. Derived per game + /// from the counter's clock and target: for a counter running at + /// sysclk/8 (4.2336 MHz) with target T, the callback fires + /// `4233600 / T` times a second, i.e. `4233600 / (T * 60)` per VBlank. + /// Zero disables. + uint32_t rcntTicksPerVBlank = 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/include/runtime/spu/spu.h b/ps1Runtime/include/runtime/spu/spu.h index bb1bb23..0e7b029 100644 --- a/ps1Runtime/include/runtime/spu/spu.h +++ b/ps1Runtime/include/runtime/spu/spu.h @@ -112,6 +112,29 @@ class SPU { // Thread safety for audio callback std::mutex &getMutex() { return mutex_; } + // Test-only hook (ps1Test/runtime/test_spu.cpp): decode a single 16-byte + // SPU-ADPCM block in isolation, using the same algorithm as + // advanceAdpcmBlock() (see spu.cpp for citations), so decoded PCM output + // can be checked against hand-worked values without routing through + // pitch/ADSR/mixing. + std::array + decodeAdpcmBlockForTest(const uint8_t block[ADPCM_BLOCK_SIZE], + int16_t prevSample1, int16_t prevSample2) const; + + // Test-only accessor (ps1Test/runtime/test_spu.cpp): a voice's ADPCM + // playback/loop state. Needed because an End+Mute block can force + // AdsrPhase::Off within a sample or two of being read, which would + // otherwise make the repeat-address jump unobservable through audio + // output or through registers (there is no hardware-readable "current + // address" register). + struct VoiceDebugState { + uint32_t currentAddr; // byte address in SPU RAM of the next block to read + uint16_t repeatAddr; // loop target, in 8-byte units + bool loopFlag; + bool endFlag; + }; + VoiceDebugState debugVoiceState(uint32_t voiceIdx) const; + private: // Voices Voice voices_[NUM_VOICES]; diff --git a/ps1Runtime/src/gool/emu_compat.cpp b/ps1Runtime/src/gool/emu_compat.cpp new file mode 100644 index 0000000..91a46f9 --- /dev/null +++ b/ps1Runtime/src/gool/emu_compat.cpp @@ -0,0 +1,191 @@ +/** + * @file emu_compat.cpp + * @brief Implementation of the GOOL EMU_* compatibility shim. + * + * See `runtime/gool/emu_compat.h` for the rationale. Reads and writes go + * through `Memory` so scratchpad / MMIO behave; native callouts go + * through `recomp_dispatch`, which (unlike the c1c interpreter) returns + * normally because each recompiled function is a real C++ function. + */ + +#include "runtime/gool/emu_compat.h" + +#include "runtime/cpu_context.h" +#include "runtime/emuptr.h" +#include "runtime/memory.h" + +#include +#include +#include + +// Declared by the generated recompiled_out.cpp (and the stub build). +void recomp_dispatch(uint8_t *rdram, recomp_context *ctx, uint32_t addr); + +namespace ps1::gool { + +namespace { +thread_local recomp_context *t_ctx = nullptr; +thread_local uint8_t *t_rdram = nullptr; + +// Cached RAM base for host<->PS1 address conversion. Resolved lazily from +// emuptr_translate so it shares the exact mapping emuptr uses. +inline uint8_t *ram_base() noexcept { + return static_cast(::ps1::emuptr_translate(0x80000000u)); +} +} // namespace + +void emu_bind(uint8_t *rdram, recomp_context *ctx) noexcept { + t_rdram = rdram; + t_ctx = ctx; +} + +void emu_unbind() noexcept { + t_rdram = nullptr; + t_ctx = nullptr; +} + +recomp_context *emu_ctx() noexcept { + assert(t_ctx && "emu_ctx() used outside a GOOL emu_bind() scope"); + return t_ctx; +} + +uint32_t &emu_reg(int index) noexcept { + recomp_context *ctx = emu_ctx(); + if (index < 32) + return ctx->r[index]; + if (index == 32) + return ctx->lo; + return ctx->hi; // index == 33 +} + +// --- Memory primitives ------------------------------------------------- + +uint8_t EMU_ReadU8(uint32_t address) noexcept { + return emu_ctx()->mem->read8(address); +} +uint16_t EMU_ReadU16(uint32_t address) noexcept { + return emu_ctx()->mem->read16(address); +} +uint32_t EMU_ReadU32(uint32_t address) noexcept { + return emu_ctx()->mem->read32(address); +} +int8_t EMU_ReadS8(uint32_t address) noexcept { + return static_cast(emu_ctx()->mem->read8(address)); +} +int16_t EMU_ReadS16(uint32_t address) noexcept { + return static_cast(emu_ctx()->mem->read16(address)); +} + +void EMU_Write8(uint32_t address, uint8_t value) noexcept { + emu_ctx()->mem->write8(address, value); +} +void EMU_Write16(uint32_t address, uint16_t value) noexcept { + emu_ctx()->mem->write16(address, value); +} +void EMU_Write32(uint32_t address, uint32_t value) noexcept { + emu_ctx()->mem->write32(address, value); +} + +// --- Host pointer <-> PS1 address ------------------------------------- + +uint32_t EMU_Address(const void *host_ptr) noexcept { + const auto *p = static_cast(host_ptr); + uint8_t *base = ram_base(); + if (p >= base && p < base + Memory::RAM_SIZE) { + return (static_cast(p - base) & (Memory::RAM_SIZE - 1)) | + 0x80000000u; + } + // GOOL object data lives in RAM, so this should not happen. Mirror the + // c1c abort semantics but log instead of crashing the runtime. + std::fprintf(stderr, + "[GOOL] EMU_Address: host pointer %p outside bound RAM\n", + host_ptr); + return 0; +} + +// --- Native callout ---------------------------------------------------- + +uint32_t EMU_Invoke(uint32_t address, int argc, ...) noexcept { + recomp_context *ctx = emu_ctx(); + const uint32_t ra_saved = ctx->r[31]; // RA + const uint32_t bytes = static_cast(argc) * 4u; + + // Reserve the outgoing-argument area (o32: a0..a3 home + stack args). + ctx->r[29] -= bytes; // SP + + std::va_list ap; + va_start(ap, argc); + for (int i = 0; i < argc; ++i) { + const uint32_t v = va_arg(ap, uint32_t); + if (i < 4) + ctx->r[4 + i] = v; // A0..A3 + else + ctx->mem->write32(ctx->r[29] + static_cast(i) * 4u, v); + } + va_end(ap); + + // Static recomp: the target is a C++ function that returns at JR RA. + recomp_dispatch(t_rdram, ctx, address); + + ctx->r[29] += bytes; // restore SP + ctx->r[31] = ra_saved; // restore RA + return ctx->r[2]; // V0 +} + +// --- HI/LO mul-div helpers -------------------------------------------- + +void EMU_SMultiply(int32_t a, int32_t b) noexcept { + const int64_t result = static_cast(a) * static_cast(b); + recomp_context *ctx = emu_ctx(); + ctx->lo = static_cast(result); + ctx->hi = static_cast(result >> 32); +} +void EMU_UMultiply(uint32_t a, uint32_t b) noexcept { + const uint64_t result = static_cast(a) * static_cast(b); + recomp_context *ctx = emu_ctx(); + ctx->lo = static_cast(result); + ctx->hi = static_cast(result >> 32); +} +// R3000A `div` raises no exception on a division error; it writes fixed +// garbage instead. psx-spx tabulates every case (docs/cpuspecifications.md: +// 363-369, "The hardware DOES NOT generate exceptions on divide overflows"): +// div 0..+7FFFFFFFh 0 --> HI = Rs, LO = -1 +// div -80000000h..-1 0 --> HI = Rs, LO = +1 +// div -80000000h -1 --> HI = 0, LO = -80000000h +// Both special cases have to be branches rather than C++ division: INT32_MIN / +// -1 is signed-overflow UB and raises SIGFPE on x86, and the zero-divisor sign +// split cannot come out of a division that never runs. The previous code had +// only the zero branch and returned LO = -1 from it regardless of sign. +void EMU_SDivide(int32_t a, int32_t b) noexcept { + recomp_context *ctx = emu_ctx(); + if (b == 0) { + ctx->hi = static_cast(a); + ctx->lo = a < 0 ? 1u : 0xFFFFFFFFu; + } else if (a == INT32_MIN && b == -1) { + ctx->hi = 0; + ctx->lo = static_cast(INT32_MIN); + } else { + ctx->lo = static_cast(a / b); + ctx->hi = static_cast(a % b); + } +} +void EMU_UDivide(uint32_t a, uint32_t b) noexcept { + recomp_context *ctx = emu_ctx(); + if (b) { + ctx->lo = a / b; + ctx->hi = a % b; + } else { + ctx->lo = 0xFFFFFFFFu; + ctx->hi = a; + } +} + +void EMU_Break(uint32_t id) noexcept { + static bool warned = false; + if (!warned && std::getenv("PS1_GOOL_TRACE")) { + std::fprintf(stderr, "[GOOL] EMU_Break(0x%X) (no-op)\n", id); + warned = true; + } +} + +} // namespace ps1::gool diff --git a/ps1Runtime/src/gool/gool_interp.cpp b/ps1Runtime/src/gool/gool_interp.cpp new file mode 100644 index 0000000..a19db09 --- /dev/null +++ b/ps1Runtime/src/gool/gool_interp.cpp @@ -0,0 +1,994 @@ +/** + * @file gool_interp.cpp + * @brief HLE interpreter for the GOOL bytecode VM (Crash Bandicoot). + * + * Crash Bandicoot drives all game objects through GOOL, an in-game + * bytecode VM whose native interpreter lives at 0x800201DC in the + * SCUS-94900 binary. This file re-implements that VM in C++ on top of + * the EMU_* shim (`runtime/gool/emu_compat.h`), so the interpreter can + * be observed, traced, and extended while everything it calls out to + * (NS paging, object lifecycle, GTE math) stays native recompiled code + * reached through `EMU_Invoke`. + * + * Semantics were derived from the game binary's disassembly with the + * ManDude `c1c` decompilation as a cross-reference for opcode encoding + * and struct layout (see `gool_types.h`). Register-style sections (e.g. + * `opPaging`) intentionally mirror the original MIPS register flow. + * + * Interception: the recompiler's `[hle_overrides]` stanza replaces the + * body of `func_800201DC` with a call to `ps1::psyq::hle_gool_InterpretObject`, + * so both direct JAL callers and indirect dispatch reach this VM. + * + * Opcodes not yet ported log once and abort the current interpretation + * with the game's own INVALID error code; the log line names the opcode + * so coverage can be extended exactly where the boot path needs it. + */ + +#include "runtime/gool/emu_compat.h" +#include "runtime/gool/gool_types.h" + +#include "runtime/cpu_context.h" +#include "runtime/emu_globals.h" +#include "runtime/emuptr.h" +#include "runtime/metrics.h" +#include "runtime/psyq/psyq_hle.h" + +#include +#include +#include + +namespace ps1::gool { +namespace { + +// MIPS register aliases over the bound recomp_context (see emu_compat.h). +#define GREG(n) (::ps1::gool::emu_reg(n)) +#define AT GREG(1) +#define V0 GREG(2) +#define V1 GREG(3) +#define A0 GREG(4) +#define A1 GREG(5) +#define A2 GREG(6) +#define S0 GREG(16) +#define S1 GREG(17) +#define S2 GREG(18) +#define S3 GREG(19) +#define S4 GREG(20) +#define S5 GREG(21) +#define S6 GREG(22) +#define S7 GREG(23) +#define SP GREG(29) +#define FP GREG(30) +#define RA GREG(31) +constexpr uint32_t R0 = 0; + +// Game error-code convention (values >= ALLOCFAILED are errors). +constexpr uint32_t ERR_SUCCESS = 0xFFFFFF01; +constexpr uint32_t ERR_ALLOCFAILED = 0xFFFFFF02; +constexpr uint32_t ERR_INVALIDSTATERETURN = 0xFFFFFFE6; +constexpr uint32_t ERR_INVALIDRETURN = 0xFFFFFFE7; +constexpr uint32_t ERR_INVALID = 0xFFFFFFF2; +inline bool isError(uint32_t x) { return x >= ERR_ALLOCFAILED; } + +// Native callouts (Crash SCUS-94900 addresses); all are recompiled +// functions reached through recomp_dispatch via EMU_Invoke. +constexpr uint32_t FN_RAND = 0x8002F6B0; +constexpr uint32_t FN_TEST_CONTROLS = 0x8001FDC4; +constexpr uint32_t FN_SIN = 0x8003905C; +constexpr uint32_t FN_ANG_DIST = 0x800245F0; +constexpr uint32_t FN_CHANGE_OBJECT_STATE = 0x8001D698; +constexpr uint32_t FN_CREATE_OBJECT = 0x8001C6C8; +constexpr uint32_t FN_SEND_EVENT_BROADCAST = 0x80025134; +constexpr uint32_t FN_SEND_EVENT_DIRECT = 0x80024040; +constexpr uint32_t FN_SEND_EVENT_CASCADE = 0x800251B8; +constexpr uint32_t FN_NS_LOOKUP = 0x8001FC4C; +constexpr uint32_t FN_NS_PAGE_A = 0x80015118; +constexpr uint32_t FN_NS_PAGE_B = 0x80015458; +constexpr uint32_t FN_NS_PAGE_C = 0x800156D4; +constexpr uint32_t FN_NS_PAGE_D = 0x8001579C; +constexpr uint32_t FN_SOLID_RANGE_TEST = 0x80029C90; +constexpr uint32_t FN_OBJECT_FLIP = 0x8001D33C; + +// GOOL globals in game BSS. +EMUGLOBALVAR(::ps1::emuptr, constbuf, 0x80056480) +EMUGLOBALVAR(uint32_t, activebufindex, 0x80056484) +EMUGLOBALVAR(::ps1::emuptr, crash_obj, 0x800566B4) +EMUGLOBALVAR(uint32_t, rendercount, 0x80057960) +EMUGLOBALVAR(::ps1::emuptr, dispenv, 0x80058404) +EMUGLOBALVAR(uint32_t, tickselapsed, 0x80060E04) +EMUGLOBALARR(uint32_t, gool_globals, 0x8006188C) + +// Instruction field extraction. +inline uint32_t opcodeOf(uint32_t ins) { return ins >> 24; } +inline uint32_t gopA(uint32_t ins) { return (ins >> 12) & 0xFFF; } +inline uint32_t gopB(uint32_t ins) { return ins & 0xFFF; } + +constexpr uint32_t GOP_STACKTOP = 0xE1F; + +// Object stack / register-file helpers. The GOOL "register file" is the +// run of uint32 slots starting at the object's `self` field. +inline uint32_t selfBase(emuptr obj) { + return obj.addr() + offsetof(goolobj, self); +} +inline emuptr objReg(emuptr obj, uint32_t idx) { + return emuptr(selfBase(obj) + idx * 4); +} +inline void push(emuptr obj, uint32_t v) { + EMU_Write32((obj->sp++).addr(), v); +} +inline uint32_t pop(emuptr obj) { + return EMU_ReadU32((--obj->sp).addr()); +} +inline uint32_t peek(emuptr obj) { + return EMU_ReadU32(obj->sp.addr()); +} +// Conditional operand: register index, or stack pop when 0x1F. +inline uint32_t regRef(emuptr obj, uint32_t idx) { + return (idx == 0x1F) ? pop(obj) : EMU_ReadU32(objReg(obj, idx).addr()); +} +inline uint32_t fpRel(emuptr obj) { + return obj->fp.addr() ? obj->fp.addr() - selfBase(obj) : 0; +} +inline uint32_t spRel(emuptr obj) { + return obj->sp.addr() ? obj->sp.addr() - selfBase(obj) : 0; +} +inline uint32_t frameRange(uint32_t fprel, uint32_t sprel) { + return (fprel << 16) | (sprel & 0xFFFF); +} + +// Operand load/store go through Memory so a null/garbage translated +// pointer behaves like the console (a plain RAM access) instead of +// tripping host-side asserts. +inline uint32_t ld(emuptr p) { return EMU_ReadU32(p.addr()); } +inline void st(emuptr p, uint32_t v) { EMU_Write32(p.addr(), v); } + +// --- Operand translation (gop -> memory ref) --------------------------- + +emuptr translateGop(emuptr obj, uint32_t gop) { + if (!(gop & 0x800)) { // ireg (local) or pool (external) ref + uint32_t index = gop & 0x3FF; + uint32_t execdata = (!(gop & 0x400)) ? obj->local->items[2].addr() + : obj->external->items[2].addr(); + return emuptr(execdata + index * 4); + } + if (!(gop & 0x400)) { + // The const-buffer pointer (0x80056480) targets SCRATCHPAD (0x1F800040 + // in Crash). emuptr raw derefs only know main RAM, so the stores must + // go through Memory (EMU_Write32); the returned ref is later read via + // ld() which is Memory-routed too. + if (!(gop & 0x200)) { // sign-extended int constant + int32_t intval = static_cast((gop & 0x1FF) << 23) >> 15; + activebufindex() = !activebufindex(); + emuptr slot = constbuf() + activebufindex(); + EMU_Write32(slot.addr(), static_cast(intval)); + return slot; + } + if (!(gop & 0x100)) { // sign-extended fractional constant + int32_t fracval = static_cast((gop & 0xFF) << 24) >> 20; + activebufindex() = !activebufindex(); + emuptr slot = constbuf() + activebufindex(); + EMU_Write32(slot.addr(), static_cast(fracval)); + return slot; + } + if (!(gop & 0x80)) { // frame-pointer-relative var + int32_t index = static_cast((gop & 0x3F) << 26) >> 26; + return emuptr(obj->fp.addr() + + static_cast(index * 4)); + } + if (gop == 0xBE0) // null ref + return emuptr(0); + if (gop == 0xBF0) // sp-double sentinel + return emuptr(0xBF0); + return emuptr(1); // invalid ref + } + // link-object register ref + uint32_t linkindex = (gop >> 6) & 0x7; + uint32_t linkreg = gop & 0x3F; + emuptr link(EMU_ReadU32(objReg(obj, linkindex).addr())); + if (link) + return emuptr(selfBase(link) + linkreg * 4); + return emuptr(0); +} + +emuptr inGop(emuptr obj, uint32_t gop) { + if ((gop & 0xFFF) == GOP_STACKTOP) // stack pop + return --obj->sp; + if ((gop & 0xE00) == 0xE00) // stack/register-file ref + return objReg(obj, gop & 0x1FF); + return translateGop(obj, gop); +} + +emuptr outGop(emuptr obj, uint32_t gop) { + if ((gop & 0xFFF) == GOP_STACKTOP) // stack push + return obj->sp++; + if ((gop & 0xE00) == 0xE00) + return objReg(obj, gop & 0x1FF); + return translateGop(obj, gop); +} + +inline uint32_t ldA(emuptr obj, uint32_t ins) { + return ld(inGop(obj, gopA(ins))); +} +inline uint32_t ldB(emuptr obj, uint32_t ins) { + return ld(inGop(obj, gopB(ins))); +} + +// --- Interpreter frame (mirrors the native prologue/epilogue) ----------- + +thread_local uint32_t g_retflag = 0; +thread_local uint32_t g_retcode = 0; + +void nativeEnter() { + SP -= 320; + EMU_Write32(SP + 316, RA); + EMU_Write32(SP + 312, FP); + EMU_Write32(SP + 308, S7); + EMU_Write32(SP + 304, S6); + EMU_Write32(SP + 300, S5); + EMU_Write32(SP + 296, S4); + EMU_Write32(SP + 292, S3); + EMU_Write32(SP + 288, S2); + EMU_Write32(SP + 284, S1); + EMU_Write32(SP + 280, S0); +} + +void nativeReturn(uint32_t result) { + RA = EMU_ReadU32(SP + 316); + FP = EMU_ReadU32(SP + 312); + S7 = EMU_ReadU32(SP + 308); + S6 = EMU_ReadU32(SP + 304); + S5 = EMU_ReadU32(SP + 300); + S4 = EMU_ReadU32(SP + 296); + S3 = EMU_ReadU32(SP + 292); + S2 = EMU_ReadU32(SP + 288); + S1 = EMU_ReadU32(SP + 284); + S0 = EMU_ReadU32(SP + 280); + SP += 320; + g_retflag = 1; + g_retcode = result; +} + +#define greturn(x) \ + do { \ + nativeReturn(x); \ + return; \ + } while (0) + +int traceLevel() { + static int level = [] { + const char *e = std::getenv("PS1_GOOL_TRACE"); + return e ? std::atoi(e) : 0; + }(); + return level; +} + +// --- Named operations (boot-path set) ----------------------------------- + +void opControlFlow(emuptr obj, uint32_t ins, uint32_t &flags, + emuptr transition) { + // `cond` persists across invocations: condtype 3 reuses the last result. + thread_local uint32_t cond = 0; + thread_local uint32_t flagspair = 0; + + uint32_t condtype = (ins >> 20) & 3; + if (condtype == 0) + cond = 1; + else if (condtype == 1) + cond = regRef(obj, (ins >> 14) & 0x3F); + else if (condtype == 2) + cond = !regRef(obj, (ins >> 14) & 0x3F); + + uint32_t optype = (ins >> 22) & 3; + if (!cond || optype == 3) + return; + + if (optype == 0) { // branch + int32_t offset = static_cast((ins & 0x3FF) << 22) >> 22; + int32_t varskip = (ins >> 10) & 0xF; + obj->pc += offset; + obj->sp -= varskip; + } else if (optype == 1) { // state change + uint32_t state = ins & 0x3FFF; + emuptr execstates(obj->local->items[4].addr()); + emuptr stateinfo = execstates + state; + + uint32_t stateflagstest; + if (obj->displaymode >= 2 && obj->displaymode <= 4) + stateflagstest = (obj->statusc | 0x1002) & stateinfo->flags; + else + stateflagstest = obj->statusc & stateinfo->flags; + + if (!stateflagstest) { + uint32_t result = + EMU_Invoke(FN_CHANGE_OBJECT_STATE, 4, obj.addr(), state, 0, 0); + if (isError(result) || !(flags & 2)) + pop(obj); + else + greturn(result); + } + } else { // optype == 2: return from the current GOOL frame + flagspair = flags; + + obj->sp = obj->fp + 2; + uint32_t callerframerange = peek(obj); + uint32_t callerframefp = callerframerange >> 16; + uint32_t callerframesp = callerframerange & 0xFFFF; + + uint32_t result; + if (callerframefp) { // not the initial frame + result = ERR_SUCCESS; + + obj->sp = obj->fp + 1; + obj->pc = emuptr(peek(obj)); + + pop(obj); + uint32_t prevflags = peek(obj); + flagspair = (flags & 0xFFFF0000u) | (prevflags & 0xFFFF); + + obj->sp = emuptr(selfBase(obj) + callerframesp); + obj->fp = emuptr(selfBase(obj) + callerframefp); + } else { + result = ERR_INVALIDRETURN; + } + + if (flags & 8) { + if (flags & 0x20) { + transition->state = 0xFF; + greturn(ERR_SUCCESS); + } + greturn(ERR_INVALIDSTATERETURN); + } + + if ((flags & 1) || isError(result)) { + if ((flags & 0x10) && !isError(result)) + push(obj, 0); // initial value for wait + greturn(result); + } + if ((flagspair & 0xFFFF) != 0xFFFF) + flags = flagspair; + } +} + +void opJumpAndLink(emuptr obj, uint32_t ins, uint32_t &flags) { + uint32_t address = ins & 0x3FFF; + uint32_t argc = (ins >> 20) & 0xF; + + uint32_t fprel = fpRel(obj); + uint32_t sprel = spRel(obj) - argc * 4; + + obj->fp = obj->sp; + push(obj, flags); + push(obj, obj->pc.addr()); + push(obj, frameRange(fprel, sprel)); + + if (address != 0x3FFF) { + uint32_t execcode = obj->local->items[1].addr(); + obj->pc = emuptr(execcode + address * 4); + } else { + obj->pc = emuptr(0); + } + flags &= 0xFFFFFFF6u; +} + +void opReturnStateTransition(emuptr obj, uint32_t ins, + uint32_t &flags, + emuptr transition, + uint32_t opcode) { + if (!(flags & 8)) + greturn(ERR_INVALID); + + thread_local uint32_t cond = 0; + + uint32_t condtype = (ins >> 20) & 3; + if (condtype == 0) + cond = 1; + else if (condtype == 1) + cond = regRef(obj, (ins >> 14) & 0x3F); + else if (condtype == 2) + cond = !regRef(obj, (ins >> 14) & 0x3F); + + uint32_t rettype = (ins >> 22) & 3; + if (cond) { + flags |= 0x20; // RETURNEVENT + transition->guard = (opcode == 0x89); + + if (rettype == 1 || rettype == 2) { + obj->sp = obj->fp + 2; + uint32_t callerframerange = peek(obj); + uint32_t callerframefp = callerframerange >> 16; + uint32_t callerframesp = callerframerange & 0xFFFF; + + uint32_t result; + if (callerframefp) { + obj->sp = obj->fp + 1; + obj->pc = emuptr(peek(obj)); + pop(obj); + + obj->sp = emuptr(selfBase(obj) + callerframesp); + obj->fp = emuptr(selfBase(obj) + callerframefp); + result = ERR_SUCCESS; + } else { + result = ERR_INVALIDRETURN; + } + + if (!isError(result)) + transition->state = (rettype == 1) ? (ins & 0x3FFF) : 0xFF; + greturn(result); + } + } else if (rettype == 0) { + int32_t offset = static_cast((ins & 0x3FF) << 22) >> 22; + int32_t varskip = (ins >> 10) & 0xF; + obj->pc += offset; + obj->sp -= varskip; + } +} + +void opSendEvent(emuptr obj, uint32_t ins, uint32_t &flags, + emuptr recipient, uint32_t opcode) { + SP -= 64; + emuptr args(SP); // scratch buffer in the interpreter frame + + emuptr eventptr = inGop(obj, gopB(ins)); + + obj->statusa &= 0xFFFDFFFFu; // clear bit 17 (state-changed marker) + + uint32_t cond = regRef(obj, (ins >> 12) & 0x3F); + uint32_t argc = (ins >> 18) & 0x7; + uint32_t mode = (ins >> 21) & 0x7; + + if (eventptr && cond && (recipient || opcode == 0x8F)) { + for (uint32_t i = 0; i < argc; i++) + args[i] = ld(obj->sp + (static_cast(i) - + static_cast(argc))); + + uint32_t event = ld(eventptr); + if (opcode == 0x8F) + EMU_Invoke(FN_SEND_EVENT_BROADCAST, 5, obj.addr(), event, mode, argc, + args.addr()); + else if (opcode == 0x87) + EMU_Invoke(FN_SEND_EVENT_DIRECT, 5, obj.addr(), recipient.addr(), event, + argc, args.addr()); + else if (opcode == 0x90) + EMU_Invoke(FN_SEND_EVENT_CASCADE, 6, obj.addr(), recipient.addr(), event, + mode, argc, args.addr()); + } else { + obj->misc_flag = 0; + } + SP += 64; + + if (obj->statusa & 0x20000) { // state changed as a result of the event + if (flags & 2) + greturn(ERR_SUCCESS); + pop(obj); + } else { + obj->sp -= argc; + } +} + +void opSpawnChildren(emuptr obj, uint32_t ins, uint32_t opcode) { + uint32_t spawncount = ins & 0x3F; + uint32_t argc = (ins >> 20) & 0xF; + + if (!spawncount) { + argc -= 1; + spawncount = ld(obj->sp + static_cast(argc)); + } + + if (spawncount > 0) { + uint32_t flag = (opcode == 0x91); + for (uint32_t i = 0; i < spawncount; i++) { + uint32_t type = (ins >> 12) & 0xFF; + uint32_t subtype = (ins >> 6) & 0x3F; + + uint32_t argsAddr = obj->sp.addr() - argc * 4; + uint32_t child = EMU_Invoke(FN_CREATE_OBJECT, 6, obj.addr(), type, + subtype, argc, argsAddr, flag); + + obj->misc_child = + isError(child) ? emuptr(0) : emuptr(child); + // The game writes `creator` without checking the error path; done + // through Memory so an error-code "address" degrades like on console. + EMU_Write32(child + offsetof(goolobj, creator), obj.addr()); + } + } + obj->sp -= argc; +} + +// Register-style port: the NS paging op drives the native page loaders +// (0x80015118 / 0x80015458 / 0x800156D4 / 0x8001579C) exactly as the +// original code does, args pre-set in A0..A2. +void opPaging(emuptr obj, uint32_t ins) { + S1 = ins; + S2 = obj.addr(); + + A0 = S2; + A1 = (S1 >> 12) & 0xFFF; + A2 = R0; + V0 = EMU_Invoke(FN_NS_LOOKUP, 0); + A0 = S2; + A1 = S1 & 0xFFF; + S0 = EMU_ReadU32(V0); + A2 = R0; + V0 = EMU_Invoke(FN_NS_LOOKUP, 0); + A0 = V0; + S0 -= 1; + if (S0 >= 6) { + V0 = S0 << 2; + return; + } + V0 = S0 << 2; + AT = 0x80010000 + V0; + V0 = EMU_ReadU32(AT + 2040); // original jumptable fetch (kept for fidelity) + + switch (S0) { + case 0: + case 5: + A1 = (S0 == 5) ? 1 : R0; + A2 = 1; + V0 = EMU_Invoke(FN_NS_PAGE_A, 0); + EMU_Write32(S2 + 244, V0); // obj->misc (+0xF4) + return; + case 1: + A1 = 1; + V0 = EMU_Invoke(FN_NS_PAGE_B, 0); + EMU_Write32(S2 + 244, V0); + return; + case 2: + A1 = R0; + V0 = EMU_Invoke(FN_NS_PAGE_B, 0); + A0 = EMU_ReadU32(S2 + 220); // obj->sp (+0xDC) + V1 = A0 + 4; + break; + case 3: + V0 = EMU_Invoke(FN_NS_PAGE_C, 0); + A0 = EMU_ReadU32(S2 + 220); + V1 = A0 + 4; + break; + case 4: + S0 = EMU_ReadU32(A0); + V0 = EMU_ReadU32(S2 + 220); + A0 = S0 << 2; + A0 = V0 - A0; + A1 = S0; + V0 = EMU_Invoke(FN_NS_PAGE_D, 0); + A0 = EMU_ReadU32(S2 + 220); + S0 <<= 2; + A0 = A0 - S0; + V1 = A0 + 4; + break; + } + EMU_Write32(S2 + 220, V1); + EMU_Write32(A0, V0); +} + +void opChangeAnim(emuptr obj, uint32_t ins, uint32_t &flags) { + uint32_t animframe = ins & 0x7F; + uint32_t anim = (ins >> 7) & 0x1FF; + uint32_t wait = (ins >> 16) & 0x3F; + uint32_t flip = (ins >> 22) & 0x3; + + uint32_t execanims = obj->local->items[5].addr(); + obj->animframe = animframe << 8; + obj->animseq = emuptr(execanims + anim * 4); + + push(obj, (wait << 24) | tickselapsed()); + + int32_t sx = obj->scale.x; + if (flip == 0) + obj->scale.x = sx < 0 ? sx : -sx; + else if (flip == 1) + obj->scale.x = sx < 0 ? -sx : sx; + else if (flip == 2) + obj->scale.x = -sx; + + if (obj->statusb & 0x18) { // solid object stopped by solid surfaces + uint32_t outofrange = EMU_Invoke( + FN_SOLID_RANGE_TEST, 6, obj.addr(), + obj.addr() + offsetof(goolobj, trans), + crash_obj().addr() + offsetof(goolobj, trans), 0x7D000, 0xAF000, + 0x7D000); + if (!outofrange || (obj->statusb & 0x80000000u)) + EMU_Invoke(FN_OBJECT_FLIP, 2, obj.addr(), + obj.addr() + offsetof(goolobj, scale)); + } + + if (flags & 4) + greturn(ERR_SUCCESS); +} + +void opChangeAnimFrame(emuptr obj, uint32_t ins, uint32_t &flags) { + uint32_t animframe = ldB(obj, ins); + uint32_t wait = (ins >> 16) & 0x3F; + uint32_t flip = (ins >> 22) & 0x3; + + obj->animframe = animframe; + push(obj, (wait << 24) | tickselapsed()); + + int32_t sx = obj->scale.x; + if (flip == 0) + obj->scale.x = sx < 0 ? sx : -sx; + else if (flip == 1) + obj->scale.x = sx < 0 ? -sx : sx; + else if (flip == 2) + obj->scale.x = -sx; + + EMU_Invoke(FN_OBJECT_FLIP, 2, obj.addr(), + obj.addr() + offsetof(goolobj, scale)); + + if (flags & 4) + greturn(ERR_SUCCESS); +} + +void opNotPorted(emuptr obj, uint32_t ins) { + static bool warned[256] = {}; + uint32_t opcode = opcodeOf(ins); + char mname[32]; + std::snprintf(mname, sizeof(mname), "gool.opcode_missing.%02X", opcode); + ps1::metrics::count(mname); + if (!warned[opcode]) { + warned[opcode] = true; + std::fprintf(stderr, + "[GOOL] opcode 0x%02X not ported (ins=0x%08X pc=0x%08X " + "obj=0x%08X) -- aborting this interpretation\n", + opcode, ins, (obj->pc - 1).addr(), obj.addr()); + } + greturn(ERR_INVALID); +} + +// --- Core interpretation loop ------------------------------------------- + +uint32_t interpretObject(emuptr obj, uint32_t flags, + emuptr transition) { + nativeEnter(); + ps1::metrics::count("gool.interpret"); + + emuptr recipient{}; + uint32_t argbuf = 0; + const bool traceOps = traceLevel() >= 2; + + do { + FP = GOP_STACKTOP; + uint32_t ins = EMU_ReadU32(obj->pc.addr()); + obj->pc += 1; + uint32_t opcode = opcodeOf(ins); + + if (traceOps) + std::fprintf(stderr, "[GOOL] pc=0x%08X ins=0x%08X op=0x%02X\n", + (obj->pc - 1).addr(), ins, opcode); + + switch (opcode) { + case 0x00: { + uint32_t r = ldA(obj, ins), l = ldB(obj, ins); + push(obj, l + r); + break; + } + case 0x01: { + uint32_t r = ldA(obj, ins), l = ldB(obj, ins); + push(obj, l - r); + break; + } + case 0x02: { + uint32_t r = ldA(obj, ins), l = ldB(obj, ins); + push(obj, l * r); + break; + } + case 0x03: { + // Unsigned divide; MIPS DIVU by zero yields all-ones, no trap. + uint32_t r = ldA(obj, ins), l = ldB(obj, ins); + push(obj, r ? l / r : 0xFFFFFFFFu); + break; + } + case 0x04: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, !(a ^ b)); + break; + } + case 0x05: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, b ? (a != 0) : 0); + break; + } + case 0x06: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, a || b); + break; + } + case 0x07: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, a & b); + break; + } + case 0x08: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, a | b); + break; + } + case 0x09: { + int32_t r = static_cast(ldA(obj, ins)); + int32_t l = static_cast(ldB(obj, ins)); + push(obj, l > r); + break; + } + case 0x0A: { + int32_t r = static_cast(ldA(obj, ins)); + int32_t l = static_cast(ldB(obj, ins)); + push(obj, l >= r); + break; + } + case 0x0B: { + int32_t r = static_cast(ldA(obj, ins)); + int32_t l = static_cast(ldB(obj, ins)); + push(obj, l < r); + break; + } + case 0x0C: { + int32_t r = static_cast(ldA(obj, ins)); + int32_t l = static_cast(ldB(obj, ins)); + push(obj, l <= r); + break; + } + case 0x0D: { + // Unsigned modulo; DIVU-by-zero remainder is the numerator. + uint32_t r = ldA(obj, ins), l = ldB(obj, ins); + push(obj, r ? l % r : l); + break; + } + case 0x0E: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, a ^ b); + break; + } + case 0x0F: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + push(obj, !((a & b) ^ a)); + break; + } + case 0x10: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + if (a == b) { + push(obj, b); + } else { + uint32_t rnd = EMU_Invoke(FN_RAND, 1, a - b); + push(obj, b + rnd); + } + break; + } + case 0x11: { + uint32_t a = ldA(obj, ins); + emuptr dst = outGop(obj, gopB(ins)); + if (dst) + st(dst, a); + break; + } + case 0x12: { + uint32_t a = ldA(obj, ins); + emuptr dst = outGop(obj, gopB(ins)); + st(dst, !a); + break; + } + case 0x14: { + emuptr src = inGop(obj, gopA(ins)); + emuptr dst = outGop(obj, gopB(ins)); + st(dst, src.addr()); + break; + } + case 0x15: { + // Variable shift; count masks to 5 bits like MIPS SLLV/SRAV. + int32_t sr = static_cast(ldA(obj, ins)); + int32_t sl = static_cast(ldB(obj, ins)); + if (sr < 0) + push(obj, static_cast(sl >> ((-sr) & 31))); + else + push(obj, static_cast(sl) << (sr & 31)); + break; + } + case 0x16: + case 0x26: { + emuptr src = inGop(obj, gopA(ins)); + emuptr dst = inGop(obj, gopB(ins)); + if (opcode == 0x26) { + if (!dst) + break; + push(obj, dst.addr()); + if (src) + push(obj, src.addr()); + } else { + if (!dst) + break; + if (src) + argbuf = ld(src); + push(obj, ld(dst)); + if (src) + push(obj, argbuf); + } + break; + } + case 0x17: { + emuptr src = inGop(obj, gopA(ins)); + emuptr dst = outGop(obj, gopB(ins)); + if (dst) + st(dst, ~ld(src)); + break; + } + case 0x18: { + uint32_t execcode = obj->local->items[1].addr(); + uint32_t offset = ins & 0x3FFF; + uint32_t mem = (ins >> 14) & 0x3F; + uint32_t target = execcode + offset * 4; + if (mem == 0x1F) + push(obj, target); + else + EMU_Write32(objReg(obj, mem).addr(), target); + break; + } + case 0x19: { + int32_t sa = static_cast(ldA(obj, ins)); + emuptr dst = outGop(obj, gopB(ins)); + st(dst, static_cast(sa < 0 ? -sa : sa)); + break; + } + case 0x1A: { + uint32_t result = EMU_Invoke(FN_TEST_CONTROLS, 2, ins & 0x1FFFFF, 0); + push(obj, result); + break; + } + case 0x1B: { + int32_t sa = static_cast(ldA(obj, ins)); + int32_t sb = static_cast(ldB(obj, ins)); + uint32_t gamespeed = EMU_ReadU32(dispenv().addr() + 0x84); + int32_t speed = (gamespeed >= 0x67) ? sa * 0x66 + : sa * static_cast(gamespeed); + if (speed < 0) + speed += 0x3FF; + push(obj, static_cast(sb + (speed >> 10))); + break; + } + case 0x1D: { + int32_t sa = static_cast(ldA(obj, ins)); + int32_t sb = static_cast(ldB(obj, ins)); + uint32_t angle = + sa ? static_cast((sb << 11) / sa) - 0x400 : 0; + uint32_t sin = EMU_Invoke(FN_SIN, 1, angle); + uint32_t sinadjusted = sin + 0x1000; + uint32_t sinscaled = + (sinadjusted * static_cast(sa)) >> 13; + push(obj, sinscaled); + break; + } + case 0x1E: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + uint32_t n = a + rendercount(); + push(obj, b ? n % b : n); + break; + } + case 0x1F: { + uint32_t b = ldB(obj, ins); + push(obj, gool_globals()[b >> 8]); + break; + } + case 0x20: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + gool_globals()[b >> 8] = a; + break; + } + case 0x21: { + uint32_t a = ldA(obj, ins), b = ldB(obj, ins); + uint32_t angdist = EMU_Invoke(FN_ANG_DIST, 2, b, a); + push(obj, angdist); + break; + } + case 0x23: { + uint32_t linkindex = (ins >> 12) & 7; + uint32_t colorindex = (ins >> 15) & 0x3F; + uint32_t link = EMU_ReadU32(objReg(obj, linkindex).addr()); + uint32_t color = + EMU_ReadU16(link + offsetof(goolobj, colors) + colorindex * 2); + push(obj, color); + break; + } + case 0x24: { + uint32_t b = ldB(obj, ins); + uint32_t linkindex = (ins >> 12) & 7; + uint32_t colorindex = (ins >> 15) & 0x3F; + uint32_t link = EMU_ReadU32(objReg(obj, linkindex).addr()); + EMU_Write16(link + offsetof(goolobj, colors) + colorindex * 2, + static_cast(b)); + break; + } + case 0x27: { + uint32_t a = ldA(obj, ins); + emuptr dst = outGop(obj, gopB(ins)); + uint32_t execanims = obj->local->items[5].addr(); + st(dst, execanims + a / 64); + break; + } + case 0x82: + opControlFlow(obj, ins, flags, transition); + break; + case 0x83: + opChangeAnim(obj, ins, flags); + break; + case 0x84: + opChangeAnimFrame(obj, ins, flags); + break; + case 0x86: + opJumpAndLink(obj, ins, flags); + break; + case 0x87: + case 0x90: + recipient = + emuptr(EMU_ReadU32(objReg(obj, (ins >> 21) & 0x7).addr())); + [[fallthrough]]; + case 0x8F: + opSendEvent(obj, ins, flags, recipient, opcode); + break; + case 0x88: + case 0x89: + opReturnStateTransition(obj, ins, flags, transition, opcode); + break; + case 0x8A: + case 0x91: + opSpawnChildren(obj, ins, opcode); + break; + case 0x8B: + opPaging(obj, ins); + break; + default: + // 0x13, 0x1C (Misc), 0x22/0x25, 0x85, 0x8C/0x8D (audio), 0x8E and + // anything unknown: not in the boot-path slice yet. + opNotPorted(obj, ins); + break; + } + } while (!g_retflag); + + g_retflag = 0; + return g_retcode; +} + +#undef greturn + +} // namespace +} // namespace ps1::gool + +namespace ps1::psyq { + +void hle_gool_InterpretObject(recomp_context *ctx) { + using namespace ps1::gool; + using ps1::emuptr; + + // Re-entrant: native ChangeObjectState calls the interpreter recursively. + static thread_local int depth = 0; + static thread_local uint32_t calls = 0; + + auto *rdram = static_cast(ps1::emuptr_translate(0x80000000u)); + if (depth++ == 0) + emu_bind(rdram, ctx); + ++calls; + + // Entry sampler: object state at the first calls and every power of two. + // Gated behind PS1_GOOL_TRACE like every other diagnostic in this file -- + // it used to print unconditionally because it was the go/no-go instrument + // for the "does interpreting GOOL populate the NS hash table" question, + // which closed on 2026-07-09 (hypothesis A confirmed). The hash-table words + // it sampled for that question went with it; `gool.interpret` in the metric + // report is the uncapped call count now. + if (traceLevel() >= 1 && (calls <= 4 || (calls & (calls - 1)) == 0)) { + uint32_t objAddr = ctx->r[4]; + std::fprintf(stderr, + "[GOOL] interpret #%u obj=0x%08X flags=0x%X pc=0x%08X " + "sp=%08X fp=%08X local=%08X\n", + calls, objAddr, ctx->r[5], + objAddr ? EMU_ReadU32(objAddr + offsetof(goolobj, pc)) : 0, + objAddr ? EMU_ReadU32(objAddr + offsetof(goolobj, sp)) : 0, + objAddr ? EMU_ReadU32(objAddr + offsetof(goolobj, fp)) : 0, + objAddr ? EMU_ReadU32(objAddr + offsetof(goolobj, local)) : 0); + } + + uint32_t result = interpretObject(emuptr(ctx->r[4]), ctx->r[5], + emuptr(ctx->r[6])); + ctx->r[2] = result; // V0 + + if (--depth == 0) + emu_unbind(); +} + +} // namespace ps1::psyq diff --git a/ps1Runtime/src/gpu/gpu.cpp b/ps1Runtime/src/gpu/gpu.cpp index f8ef8a0..f21e51f 100644 --- a/ps1Runtime/src/gpu/gpu.cpp +++ b/ps1Runtime/src/gpu/gpu.cpp @@ -1,5 +1,7 @@ #include "runtime/gpu/gpu.h" +#include "runtime/metrics.h" #include +#include #include #include #include @@ -101,9 +103,11 @@ void GPU::writeGP0(uint32_t val) { static int gp0Count = 0; static std::unordered_map opcodeHist; gp0Count++; + gp0Words_.fetch_add(1, std::memory_order_relaxed); if (!vramTransfer_.isWritingToVRAM && !isCommandExecuting_) { uint8_t op = val >> 24; opcodeHist[op]++; + gp0Hist_[op].fetch_add(1, std::memory_order_relaxed); } if (gp0Count == 500 || gp0Count == 2000 || gp0Count == 5000) { fmt::print(stderr, "[GPU] GP0 command histogram after {} calls:\n", gp0Count); @@ -397,8 +401,11 @@ void GPU::writeGP1(uint32_t val) { displayVRAMXStart_ = val & 0x3FF; displayVRAMYStart_ = (val >> 10) & 0x1FF; displayAreaSet_ = true; - { static int cnt=0; if(cnt++<20) fmt::print(stderr,"[GPU] GP1(0x05): display area -> ({},{})\n", + { static int cnt=0; if(cnt++<20) fmt::print(stderr,"[GPU] GP1(0x05) (sample, first 20): display area -> ({},{})\n", displayVRAMXStart_, displayVRAMYStart_); } + ps1::metrics::count("gp1.display_area"); + ps1::metrics::setState("display.x", static_cast(displayVRAMXStart_)); + ps1::metrics::setState("display.y", static_cast(displayVRAMYStart_)); break; case 0x06: // Horizontal Display Range displayX1_ = val & 0xFFF; @@ -1306,16 +1313,31 @@ void GPU::executeFillRect() { uint32_t x = pos & 0x3FF; uint32_t y = (pos >> 16) & 0x1FF; + // GP0(02h) size semantics differ from the A0h/C0h transfer commands: there a + // zero dimension means "the full 1024/512", here it means "fill nothing". + // Width rounds up to a multiple of 16, height is masked to 9 bits (psx-spx, + // Fill Rectangle in VRAM). + // + // Treating a zero height as 512 is what erased the game's texture pages: the + // per-frame clear at (0,12)/(512,12) became 512 rows tall, wrapped past the + // bottom of VRAM through the `% VRAM_HEIGHT` below, and wiped y=384..511 -- + // exactly where the two 256x128 texture pages are uploaded. Sprites then + // sampled transparent texels and drew nothing. + // Deviation left in place deliberately: psx-spx also rounds the width up to a + // multiple of 16. That is not done here because no observed command needs it, + // and applying it would widen every small fill (a 5px request becomes 16px). uint32_t w = size & 0x3FF; - // width/height of 0 means 1024/512 - w = ((w - 1) & 0x3FF) + 1; uint32_t h = (size >> 16) & 0x1FF; - h = ((h - 1) & 0x1FF) + 1; + if (w == 0 || h == 0) { + ps1::metrics::count("fill_rect_zero"); + return; + } static int fillCount = 0; if (++fillCount <= 10) - fmt::print(stderr, "[GPU] FillRect #{}: color=0x{:06X} pos=({},{}) size={}x{}\n", + fmt::print(stderr, "[GPU] FillRect #{} (sample, first 10): color=0x{:06X} pos=({},{}) size={}x{}\n", fillCount, color, x, y, w, h); + ps1::metrics::count("fill_rect"); // Convert 24-bit RGB to 15-bit uint16_t r5 = (color & 0xFF) >> 3; @@ -1341,11 +1363,12 @@ void GPU::executeCPUToVRAM() { static int cpuVramCount = 0; if (++cpuVramCount <= 20) { - fmt::print(stderr, "[GPU] CPU->VRAM #{}: dest=({},{}) size={}x{} (opcode=0x{:02X})\n", + fmt::print(stderr, "[GPU] CPU->VRAM #{} (sample, first 20): dest=({},{}) size={}x{} (opcode=0x{:02X})\n", cpuVramCount, pos & 0x3FF, (pos >> 16) & 0x1FF, size & 0xFFFF, (size >> 16) & 0xFFFF, commandQueue_[0] >> 24); } + ps1::metrics::count("cpu_to_vram"); vramTransfer_.destX = pos & 0x3FF; vramTransfer_.destY = (pos >> 16) & 0x1FF; @@ -1717,4 +1740,23 @@ void GPU::rasterizeGouraudTexturedTriangle(Vertex v0, Vertex v1, Vertex v2, } } +void GPU::publishMetrics() const { + // ps1::metrics::count() accumulates, so publish the delta since the last + // call rather than the whole histogram: main_host calls this from both + // shutdown paths, and re-publishing the totals doubled every gp0.op.XX. + char name[16]; + for (int op = 0; op < 256; ++op) { + const uint64_t n = gp0Hist_[op].load(std::memory_order_relaxed); + const uint64_t delta = n - gp0HistPublished_[op]; + if (delta == 0) + continue; + gp0HistPublished_[op] = n; + std::snprintf(name, sizeof(name), "gp0.op.%02X", op); + ps1::metrics::count(name, delta); + } + const uint64_t words = gp0Words_.load(std::memory_order_relaxed); + ps1::metrics::count("gp0.words", words - gp0WordsPublished_); + gp0WordsPublished_ = words; +} + } // namespace ps1::gpu diff --git a/ps1Runtime/src/main_host.cpp b/ps1Runtime/src/main_host.cpp index 5e8f682..e4abb05 100644 --- a/ps1Runtime/src/main_host.cpp +++ b/ps1Runtime/src/main_host.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -41,8 +42,6 @@ static ps1::spu::SPU *g_spu = nullptr; // Forward declaration -- generated by ps1Recomp in recompiled_out.cpp extern void recomp_init_dispatch_table(); -using recomp_func_t = void (*)(uint8_t *, recomp_context *); -extern void recomp_register_override(uint32_t addr, recomp_func_t fn); static void audioCallback(void * /*userdata*/, uint8_t *stream, int len) { if (!g_spu) { @@ -136,17 +135,24 @@ static bool dumpVramPpm(const ps1::gpu::GPU &gpu, const char *path) { std::fprintf(f, "P6\n%u %u\n255\n", W, H); const ps1::gpu::Color16 *vram = gpu.getVRAM(); std::vector row(W * 3); + uint64_t nonZero = 0; for (uint32_t y = 0; y < H; ++y) { for (uint32_t x = 0; x < W; ++x) { uint16_t p = vram[y * W + x].raw; row[x * 3 + 0] = static_cast((p & 0x1F) << 3); row[x * 3 + 1] = static_cast(((p >> 5) & 0x1F) << 3); row[x * 3 + 2] = static_cast(((p >> 10) & 0x1F) << 3); + if (p != 0) + ++nonZero; } std::fwrite(row.data(), 1, row.size(), f); } std::fclose(f); - fmt::print("[VRAM-DUMP] wrote {} ({}x{})\n", path, W, H); + ps1::metrics::count("vram_dump.written"); + ps1::metrics::setState("vram_dump.nonzero_pixels", + static_cast(nonZero)); + fmt::print("[VRAM-DUMP] wrote {} ({}x{}), {} non-zero pixels\n", path, W, H, + nonZero); return true; } @@ -452,26 +458,15 @@ int main(int argc, char *argv[]) { std::atomic gameFinished{false}; std::atomic gameThreadDone{false}; uint64_t frameCount = 0; + // Set once PS1_VRAM_DUMP_FRAME has been honoured, so the shutdown dump does + // not overwrite the requested frame -- both write PS1_VRAM_DUMP_PATH, and + // the shutdown VRAM is exactly the freshly-cleared state we are avoiding. + bool frameDumpWritten = false; // Initialize dispatch table before starting the game recomp_init_dispatch_table(); fmt::print("[Dispatch] Table initialized.\n"); - // Per-game function overrides (env-gated) - // Crash Bandicoot SLUS-00005 hangs inside `func_80031BF8` -> `func_80031EB4` - // -> ... -> `func_80015978` (hash-bucket walk @ 0x8005C530 with no chain - // terminator -- the table is populated by GOOL bytecode our runtime does - // not yet interpret). Enabling `PS1_SKIP_31BF8=1` short-circuits that - // call so the GTE setup loop in `func_80015B58` can complete and the - // game can reach its main render loop. See ISSUES.md #1. - if (const char *e = std::getenv("PS1_SKIP_31BF8"); e && *e && *e != '0') { - recomp_register_override(0x80031BF8, [](uint8_t *, recomp_context *ctx) { - ctx->r[ps1::V0] = 0; - }); - fmt::print("[Override] PS1_SKIP_31BF8 active: func_80031BF8 -> NOP\n"); - } - - // BSS mirror addresses (per-game polling slots) // Phase 2.2 retired the per-game VBlank counter BSS slot in favor of // `psyq_state().vsyncCounter` (atomic). PsyQ HLE reads the singleton. @@ -519,6 +514,36 @@ int main(int argc, char *argv[]) { } catch (const std::exception &) { // Optional section -- silent fallback. } + + // `[timing]` (TOML, optional): games that drive their frame delta off a + // root counter register an IRQ callback that increments a word in their + // own BSS. This runtime models interrupts cooperatively and never fires + // it, so the word stays frozen and every time-driven wait crawls. These + // two keys let the VSync HLE advance it at the right rate instead. + try { + auto cfg = toml::parse(config_path); + if (cfg.contains("timing")) { + auto &t = toml::find(cfg, "timing"); + auto readU32 = [&](const char *key) -> uint32_t { + if (!t.contains(key)) + return 0; + auto v = toml::find(t, key); + if (v.is_string()) + return std::strtoul(v.as_string().c_str(), nullptr, 0); + if (v.is_integer()) + return static_cast(v.as_integer()); + return 0; + }; + auto &st = ps1::psyq::psyq_state(); + st.rcntTickAddr = readU32("rcnt_tick_addr"); + st.rcntTicksPerVBlank = readU32("rcnt_ticks_per_vblank"); + if (st.rcntTickAddr != 0 && st.rcntTicksPerVBlank != 0) + fmt::print("[timing] rcnt tick 0x{:08X} += {} per VBlank\n", + st.rcntTickAddr, st.rcntTicksPerVBlank); + } + } catch (const std::exception &) { + // Optional section -- silent fallback. + } } bios.setBssMirrors(cdSyncMirror, cdReadyMirror); @@ -653,6 +678,27 @@ int main(int argc, char *argv[]) { renderer.renderFrame(); frameCount++; + ps1::metrics::count("frames"); + + // Capture at a chosen frame, not only at shutdown: the game clears VRAM + // every frame, so a shutdown-only dump always catches it freshly wiped. + // + // The threshold counts VBlanks, not iterations of this loop. This loop is + // paced by the SDL renderer's vsync and has been measured anywhere from 39 + // to 59 iterations per second depending on compositor load, while the game + // thread advances on the steady 60 Hz vblankThread. Anchoring on + // frameCount therefore captured a different point of the intro on every + // run -- sometimes the logo screen, sometimes the black gap after it -- + // which is what made the golden frame look non-deterministic. + if (const char *atFrame = std::getenv("PS1_VRAM_DUMP_FRAME")) { + const uint32_t vblanks = + ps1::psyq::psyq_state().vsyncCounter.load(std::memory_order_acquire); + if (!frameDumpWritten && vblanks >= std::strtoul(atFrame, nullptr, 10)) { + const char *path = std::getenv("PS1_VRAM_DUMP_PATH"); + frameDumpWritten = + dumpVramPpm(gpu, path ? path : "/tmp/vram_frame.ppm"); + } + } // Status every 5 seconds if (frameCount % 300 == 0) { @@ -677,8 +723,11 @@ int main(int argc, char *argv[]) { } if (const char *vramDump = std::getenv("PS1_VRAM_DUMP_PATH")) { - dumpVramPpm(gpu, vramDump); + if (!frameDumpWritten) + dumpVramPpm(gpu, vramDump); } + gpu.publishMetrics(); + ps1::metrics::dumpJson(); // Cleanup @@ -702,6 +751,12 @@ int main(int argc, char *argv[]) { fmt::print("[Main] Game thread did not finish in 2s -- forcing exit " "(skipping destructors to avoid UAF race)\n"); 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 + // since the call before the join, so this adds those words without + // doubling the ones already reported. + gpu.publishMetrics(); + ps1::metrics::dumpJson(); std::_Exit(0); } } diff --git a/ps1Runtime/src/metrics.cpp b/ps1Runtime/src/metrics.cpp new file mode 100644 index 0000000..dc4f92f --- /dev/null +++ b/ps1Runtime/src/metrics.cpp @@ -0,0 +1,109 @@ +#include "runtime/metrics.h" + +#include +#include +#include +#include +#include +#include + +namespace ps1::metrics { +namespace { + +std::mutex g_mutex; +// `std::less<>` makes these heterogeneously comparable, so lookups take a +// string_view without allocating. +std::map> g_counters; +std::map> g_states; + +// -1 = unresolved, 0 = off, 1 = on. Racing threads all compute the same +// value from the same environment, so the race is benign. +std::atomic g_enabled{-1}; + +} // namespace + +bool enabled() { + int e = g_enabled.load(std::memory_order_relaxed); + if (e < 0) { + e = std::getenv("PS1_METRICS") != nullptr ? 1 : 0; + g_enabled.store(e, std::memory_order_relaxed); + } + return e == 1; +} + +void setEnabledForTesting(bool on) { + g_enabled.store(on ? 1 : 0, std::memory_order_relaxed); +} + +void count(std::string_view name, uint64_t n) { + if (!enabled()) + return; + std::lock_guard lock(g_mutex); + auto it = g_counters.find(name); + if (it == g_counters.end()) + g_counters.emplace(std::string(name), n); + else + it->second += n; +} + +void setState(std::string_view name, int64_t value) { + if (!enabled()) + return; + std::lock_guard lock(g_mutex); + auto it = g_states.find(name); + if (it == g_states.end()) + g_states.emplace(std::string(name), value); + else + it->second = value; +} + +uint64_t get(std::string_view name) { + std::lock_guard lock(g_mutex); + auto it = g_counters.find(name); + return it == g_counters.end() ? 0u : it->second; +} + +int64_t getState(std::string_view name) { + std::lock_guard lock(g_mutex); + auto it = g_states.find(name); + return it == g_states.end() ? 0 : it->second; +} + +bool dumpJsonTo(const char *path) { + if (!path) + return false; + std::lock_guard lock(g_mutex); + std::ofstream out(path); + if (!out) + return false; + // Metric names are internal literals, never user input, so they need no + // JSON escaping. Keep it that way. + out << "{\n \"counters\": {"; + bool first = true; + for (const auto &kv : g_counters) { + out << (first ? "\n" : ",\n") << " \"" << kv.first << "\": " << kv.second; + first = false; + } + out << (first ? "" : "\n") << " },\n \"state\": {"; + first = true; + for (const auto &kv : g_states) { + out << (first ? "\n" : ",\n") << " \"" << kv.first << "\": " << kv.second; + first = false; + } + out << (first ? "" : "\n") << " }\n}\n"; + return out.good(); +} + +bool dumpJson() { + if (!enabled()) + return false; + return dumpJsonTo(std::getenv("PS1_METRICS")); +} + +void reset() { + std::lock_guard lock(g_mutex); + g_counters.clear(); + g_states.clear(); +} + +} // namespace ps1::metrics diff --git a/ps1Runtime/src/psyq/psyq_hle.cpp b/ps1Runtime/src/psyq/psyq_hle.cpp index eb0bacc..423b049 100644 --- a/ps1Runtime/src/psyq/psyq_hle.cpp +++ b/ps1Runtime/src/psyq/psyq_hle.cpp @@ -1,6 +1,8 @@ #include "runtime/psyq/psyq_hle.h" #include "runtime/emuptr.h" #include "runtime/memory.h" +#include "runtime/metrics.h" +#include "runtime/psyq/psyq_libgpu.h" #include "runtime/psyq/psyq_state.h" #include #include @@ -22,6 +24,17 @@ static inline void drainOnce() { g_cfg.drainCallbacks(); } +void applyRootCounterTicks(recomp_context *ctx, uint32_t vblanks) { + auto &st = psyq_state(); + if (st.rcntTickAddr == 0 || st.rcntTicksPerVBlank == 0 || vblanks == 0 || + ctx == nullptr || ctx->mem == nullptr) + return; + const uint32_t current = ctx->mem->read32(st.rcntTickAddr); + ctx->mem->write32(st.rcntTickAddr, + current + st.rcntTicksPerVBlank * vblanks); + ps1::metrics::count("rcnt.ticks", st.rcntTicksPerVBlank * vblanks); +} + // VSync // // PsyQ VSync(n): @@ -64,7 +77,10 @@ void hle_VSync(recomp_context *ctx) { g_cfg.deliverVBlankEvent(); } - ctx->r[V0] = counter.load(std::memory_order_acquire); + const uint32_t end = counter.load(std::memory_order_acquire); + applyRootCounterTicks(ctx, end - start); + + ctx->r[V0] = end; } // DrawSync @@ -152,16 +168,41 @@ void hle_ClearOTagR(recomp_context *ctx) { // Followed by word_count GP0 data words. // Terminal node: next_ptr == 0x00FFFFFF. // +// Audited 2026-08-06: DrawOTag(p) itself (psyz decomp/src/libgpu/sys.c:354-360) +// just forwards to `addque2(cwc, p, 0, 0)`, and _cwc (sys.c:850-855) programs +// DMA2 in "linked-list" mode (CHCR=0x01000401) with MADR=p -- on real +// hardware the GPU's DMA controller itself walks the OT and streams the +// words to GP0, the CPU does not decode headers at all. This function is +// this project's software model of that DMA-controller traversal, so the +// node format it must match is the DMA2 linked-list format, not sys.c +// (which never decodes it in software). That format is documented directly +// in psx-spx (docs/dmachannels.md:173-190, "Linked List DMA"): +// bits 0-23 = address of next node (or all-1s end marker, 0xFFFFFF) +// bits 24-31 = number of extra (data) words in this node +// and in docs/graphicsprocessingunitgpu.md:1084-1136 ("Depth Ordering +// Table"), whose worked example confirms only the N data words after the +// header are sent to GP0 -- the header word itself is link-chain metadata, +// never pushed as a GP0 command. Below already matches this exactly: header +// decode (wordCount = bits 31:24, next = bits 23:0), per-node GP0 pushes +// limited to the N words following the header, KSEG0-bit restore on the +// 24-bit physical next-pointer, and termination on next == 0xFFFFFF. No +// change was needed; see DrawOTagEmptyListEmitsNothing / +// DrawOTagSubmitsSinglePrimitive / DrawOTagTraversesChain / +// DrawOTagHeaderWordItselfIsNeverPushedToGp0 in test_psyq_hle.cpp. +// void hle_DrawOTag(recomp_context *ctx) { if (!g_cfg.writeGP0) { ctx->r[V0] = 0; return; } + ps1::metrics::count("draw_otag.calls"); uint32_t ptr = ctx->r[A0]; int safety = 0; while ((ptr & 0xFFFFFFu) != 0xFFFFFFu && safety++ < 100000) { uint32_t hdr = ctx->mem->read32(ptr); uint32_t wordCount = (hdr >> 24) & 0xFF; + ps1::metrics::count("draw_otag.nodes"); + ps1::metrics::count("draw_otag.words", wordCount); for (uint32_t i = 0; i < wordCount; i++) { g_cfg.writeGP0(ctx->mem->read32(ptr + 4 + i * 4)); } @@ -183,14 +224,20 @@ void hle_DrawOTag(recomp_context *ctx) { // +2 disp.y (int16) display area Y in VRAM // +4 disp.w (int16) display width // +6 disp.h (int16) display height -// +8 screen.x (int16) (unused / screen clip) -// +10 screen.y (int16) -// +12 screen.w (int16) -// +14 screen.h (int16) +// +8 screen.x (int16) screen display range -- NOT unused: PutDispEnv +// +10 screen.y (int16) builds GP1(06)/(07) from these four fields, and +// +12 screen.w (int16) substitutes its own defaults (2560 / 240) when +// +14 screen.h (int16) w/h are zero (sys.c:415-418) // +16 isinter (uint8) interlace enable // +17 isrgb24 (uint8) 24bpp enable // +18 pad[2] // +// Audited 2026-08-09 against the psyz decomp reference (workspace clone, +// PS1Recomp-workspace/psyz/decomp/src/libgpu/ext.c:72-86): all four `screen` +// fields are set to 0, `isrgb24`/`isinter`/`pad1`/`pad0` to 0, and `env` is +// returned. See the note in the body on why seeding `screen` with w/h was +// wrong. +// namespace { struct DispEnv { int16_t disp_x, disp_y; // 0, 2 -- VRAM display area origin @@ -202,6 +249,34 @@ struct DispEnv { uint8_t pad[2]; // 18, 19 }; static_assert(sizeof(DispEnv) == 20, "PsyQ DispEnv is 20 bytes"); + +// Faithful port of the psyz decomp's CLAMP macro +// (decomp/include/common.h:25): `#define CLAMP(x, min, max) x < min ? min +// : (x > max ? max : x)`. Unlike std::clamp, this ternary has no precondition +// on min <= max -- sys.c's own `CLAMP(h_end, h_start + 0x50, 3290)` can be +// called with min > max whenever h_start is itself already near its ceiling +// (reachable from screen.x >= 269, an in-range int16 read straight out of +// game RAM), which is well-defined here (returns min) but is undefined +// behavior under std::clamp. Match the macro's exact branch order instead. +inline int32_t sourceClamp(int32_t x, int32_t lo, int32_t hi) { + return x < lo ? lo : (x > hi ? hi : x); +} + +// VRAM dimensions used by get_cs/get_ce's CLAMP (sys.c:634-651, "info.w"/ +// "info.h"). ResetGraph (sys.c:159-175) sets these from two lookup tables +// indexed by info.version (sys.c:107-108): +// D_800B89A8[] = {1024, 1024, 1024, 1024, 1024}; // info.w +// D_800B89BC[] = {512, 1024, 1024, 512, 1024}; // info.h +// For info.version 0 or 3 (the retail/1MB-VRAM branch this project's +// SetDrawMode/PutDrawEnv audits already established as the applicable one), +// both indices give {1024, 512} -- the same numbers this runtime already +// carries as ps1::gpu::GPU::VRAM_WIDTH/VRAM_HEIGHT (gpu/gpu.h:106-107). +// Restated here as local literals rather than #include-ing gpu.h: this +// module talks to the GPU exclusively through the writeGP0/writeGP1 +// callbacks in HleConfig, never through a direct type dependency, and two +// constants don't warrant breaking that. +constexpr int32_t kInfoW = 1024; +constexpr int32_t kInfoH = 512; } // namespace void hle_SetDefDispEnv(recomp_context *ctx) { @@ -216,10 +291,17 @@ void hle_SetDefDispEnv(recomp_context *ctx) { env->disp_y = y; env->disp_w = w; env->disp_h = h; + // All four screen fields are zero, not the w/h just written into disp + // (ext.c:72-86: `env->screen.x = 0; env->screen.y = 0; env->screen.w = 0; + // env->screen.h = 0;`). PutDispEnv builds GP1(06)/(07) *from* screen and + // substitutes its own defaults for a zero w/h (2560 tenths of a pixel / + // 240 lines, sys.c:416-417), so seeding screen with w/h skips those + // defaults: for a 320x240 setup the reference gets h_end = 608 + 2560 = + // 3168, while w=320 gave 608 + 3200 = 3808, clamped down to 3290. env->screen_x = 0; env->screen_y = 0; - env->screen_w = w; - env->screen_h = h; + env->screen_w = 0; + env->screen_h = 0; env->isinter = 0; // non-interlaced env->isrgb24 = 0; // RGB15 env->pad[0] = 0; @@ -237,42 +319,109 @@ void hle_SetDefDispEnv(recomp_context *ctx) { // GP1(0x07) -- set vertical display range // GP1(0x08) -- set display mode (width, height, interlace) // +// Audited 2026-08-06, amended 2026-08-06 after review, against the psyz +// decomp reference (workspace clone, +// PS1Recomp-workspace/psyz/decomp/src/libgpu/sys.c:399-461). PutDispEnv +// branches on info.version (GPU hardware type); the same retail/1MB-VRAM +// branch this project's PutDrawEnv audit established applies here +// (info.version 0/3) is used below. It also branches on GetVideoMode() +// (env->pad0, NTSC=0/PAL=1) -- honoured explicitly via getVideoMode() +// rather than assumed NTSC, since libetc_SetVideoMode is reachable from PS1 +// code (psyq_libgpu.cpp) and a PAL title would otherwise get silently wrong +// values from code whose whole purpose is source fidelity: +// +// GP1(0x05) -- sys.c:409-412: (disp.y & 0x3FF) << 10 | (disp.x & 0x3FF). +// The pre-audit code masked vx but not vy; an unmasked negative vy can +// shift into bits 24-26 and corrupt the 0x05 command nibble itself. +// GP1(0x06)/(0x07) -- sys.c:413-427: computed from DISPENV.screen (NOT +// .disp), with defaults when screen.w/h == 0 and hard clamps that +// depend on pad0 (v_start offset 0x10/0x13, v bounds 256,258/310,312). +// The pre-audit code used a fabricated disp.w/h-based formula that +// matched neither branch, and (pre-amendment) this project's own first +// audit pass hardcoded the NTSC constants instead of reading pad0. +// sys.c's own CLAMP(h_end, h_start + 0x50, 3290) call is reachable with +// its own min argument (h_start + 0x50) exceeding its max (3290) +// whenever screen.x >= 269 -- CLAMP's ternary (common.h:25) is +// well-defined there (returns min, "bug-compatible" with the source); +// see sourceClamp() above and the std::clamp-precondition finding this +// amendment fixes. +// GP1(0x08) -- sys.c:404, 428-457: bit layout matches psx-spx's documented +// GP1(08h) fields (docs/graphicsprocessingunitgpu.md, "GP1(08h) - +// Display mode" table at line 770-779, GPUSTAT mapping 897-905) -- +// NOTE: this is the correct citation; an earlier revision of this +// comment cited "line 377+", which is GP0(E1h) Draw Mode, a different +// register. The layout matches the same one gpu.cpp's case 0x08 +// consumer assumes for bits 0-5 (Hres1/Vres/isrgb24/isinter), but NOT +// for bit 6 (Hres2/368-mode): gpu.cpp:418-428 maps GP1(08h) bits 0-5 +// onto GPUSTAT bits 17-22 correctly, then lands bit 6 on GPUSTAT bit 23 +// (Display Enable) instead of bit 16 -- a real but separate defect in +// gpu.cpp, out of this function's scope, logged for follow-up rather +// than fixed here (neither Crash nor Rayman uses 368-wide mode). The +// pre-audit code put isrgb24 at bit5 and isinter at bit6 (both wrong) +// and never set the Hres2 or video-mode bits. +// info.reverse (SetGraphReverse) is not tracked by this runtime, so +// that bit (0x80) is never set -- no game exercises it here. +// void hle_PutDispEnv(recomp_context *ctx) { + uint32_t envPtr = ctx->r[A0]; + // `DISPENV* PutDispEnv(DISPENV* env)` returns env (sys.c:399, 460). Set + // before the writeGP1 guard below: the return value does not depend on + // whether this runtime has a GP1 sink wired up. + ctx->r[V0] = envPtr; if (!g_cfg.writeGP1) { return; } - uint32_t envPtr = ctx->r[A0]; int16_t vx = static_cast(ctx->mem->read16(envPtr + 0)); int16_t vy = static_cast(ctx->mem->read16(envPtr + 2)); int16_t vw = static_cast(ctx->mem->read16(envPtr + 4)); int16_t vh = static_cast(ctx->mem->read16(envPtr + 6)); + int16_t sx = static_cast(ctx->mem->read16(envPtr + 8)); + int16_t sy = static_cast(ctx->mem->read16(envPtr + 10)); + int16_t sw = static_cast(ctx->mem->read16(envPtr + 12)); + int16_t sh = static_cast(ctx->mem->read16(envPtr + 14)); uint8_t isinter = ctx->mem->read8(envPtr + 16); uint8_t isrgb24 = ctx->mem->read8(envPtr + 17); + bool pal = getVideoMode() != 0; // sys.c: env->pad0 = GetVideoMode() // GP1(0x05): display start address - g_cfg.writeGP1(0x05000000u | (static_cast(vy) << 10) | - static_cast(vx & 0x3FF)); - - // GP1(0x06): horizontal display range [X1, X2] - // NTSC: X1=0x260, X2=0x260+vw*8 (for 8-pix/line units) - uint32_t x1 = 0x260u; - uint32_t x2 = x1 + static_cast(vw) * 8u; - g_cfg.writeGP1(0x06000000u | (x2 << 12) | x1); - - // GP1(0x07): vertical display range [Y1, Y2] - // NTSC: Y1=0x88, Y2=0x88+vh - uint32_t y1 = 0x88u; - uint32_t y2 = y1 + static_cast(vh); - g_cfg.writeGP1(0x07000000u | (y2 << 10) | y1); + g_cfg.writeGP1(0x05000000u | ((static_cast(vy) & 0x3FFu) << 10) | + (static_cast(vx) & 0x3FFu)); + + // GP1(0x06)/(0x07): horizontal/vertical display range from DISPENV.screen. + // v_start offset and v clamp bounds depend on pad0 (sys.c:414-422). + int32_t hStart = static_cast(sx) * 10 + 0x260; + int32_t hEnd = hStart + (sw != 0 ? static_cast(sw) * 10 : 2560); + int32_t vStart = static_cast(sy) + (pal ? 0x13 : 0x10); + int32_t vEnd = vStart + (sh != 0 ? static_cast(sh) : 240); + hStart = sourceClamp(hStart, 500, 3290); + hEnd = sourceClamp(hEnd, hStart + 0x50, 3290); + vStart = sourceClamp(vStart, 0x10, pal ? 310 : 256); + vEnd = sourceClamp(vEnd, vStart + 2, pal ? 312 : 258); + g_cfg.writeGP1(0x06000000u | ((static_cast(hEnd) & 0xFFFu) << 12) | + (static_cast(hStart) & 0xFFFu)); + g_cfg.writeGP1(0x07000000u | ((static_cast(vEnd) & 0x3FFu) << 10) | + (static_cast(vStart) & 0x3FFu)); // GP1(0x08): display mode - // Bits: [2:0]=hres (0=256,1=320,2=512,3=640), [3]=vres (0=240,1=480) - // [4]=video (0=NTSC), [5]=isrgb24, [6]=isinter - uint32_t hmode = (vw >= 640) ? 3u : (vw >= 512) ? 2u : (vw >= 320) ? 1u : 0u; - uint32_t vmode = (vh >= 480) ? 1u : 0u; - uint32_t dispMode = hmode | (vmode << 3) | (static_cast(isrgb24) << 5) | - (static_cast(isinter) << 6); - g_cfg.writeGP1(0x08000000u | dispMode); + uint32_t mode = 0; + if (pal) mode |= 0x08u; // video mode (sys.c:434-436) + if (isrgb24) mode |= 0x10u; + if (isinter) mode |= 0x20u; + if (vw <= 280) { + // Hres1 = 0 (256) + } else if (vw <= 352) { + mode |= 0x01u; // Hres1 = 1 (320) + } else if (vw <= 400) { + mode |= 0x40u; // Hres2 = 1 (368) + } else if (vw <= 560) { + mode |= 0x02u; // Hres1 = 2 (512) + } else { + mode |= 0x03u; // Hres1 = 3 (640) + } + if (vh > (pal ? 288 : 256)) { + mode |= 0x24u; // Vres (bit2) + interlace (bit5) combo for >threshold-line modes + } + g_cfg.writeGP1(0x08000000u | mode); } // SetDefDrawEnv @@ -293,12 +442,29 @@ void hle_PutDispEnv(recomp_context *ctx) { // +23 dfe (uint8) draw-to-display enable // ... (DR_TPAGE follows) // +// Audited 2026-08-06, amended 2026-08-06 after review, against the psyz +// decomp reference (workspace clone, +// PS1Recomp-workspace/psyz/decomp/src/libgpu/ext.c:46-69): +// env->tpage = getTPage(0, 0, 640, 0) -- always this fixed value, +// independent of the x/y/w/h args, evaluating (via this project's own +// already-audited GetTPage formula, psyq_libgpu.cpp) to +// (640 & 0x3FF) >> 6 = 0x0A. +// env->dfe = video_mode ? h <= 288 : h <= 256 -- honoured explicitly via +// getVideoMode() rather than assumed NTSC (amended after review: an +// earlier revision of this comment asserted GetVideoMode() "defaults to +// and stays NTSC/0" as an invariant, but libetc_SetVideoMode is +// reachable from PS1 code -- see hle_libgpu_SetVideoMode, +// psyq_libgpu.cpp -- so a PAL title would silently get a wrong dfe from +// code whose whole purpose is source fidelity). +// The pre-audit implementation hardcoded tpage=0 and dfe=0, ignoring both +// GetTPage and the height threshold. void hle_SetDefDrawEnv(recomp_context *ctx) { uint32_t envPtr = ctx->r[A0]; int16_t x = static_cast(ctx->r[A1]); int16_t y = static_cast(ctx->r[A2]); int16_t w = static_cast(ctx->r[A3]); int16_t h = static_cast(ctx->mem->read32(ctx->r[SP] + 16)); + bool pal = getVideoMode() != 0; // ext.c: video_mode = GetVideoMode() // clip rect ctx->mem->write16(envPtr + 0, static_cast(x)); @@ -313,10 +479,25 @@ void hle_SetDefDrawEnv(recomp_context *ctx) { ctx->mem->write16(envPtr + 14, 0); ctx->mem->write16(envPtr + 16, 0); ctx->mem->write16(envPtr + 18, 0); - // tpage = 0 (default), dtd=1 (dithering), dfe=0 - ctx->mem->write16(envPtr + 20, 0); + // tpage = getTPage(0, 0, 640, 0) = 0x0A (ext.c:67), dtd=1 (dithering) + ctx->mem->write16(envPtr + 20, 0x000Au); ctx->mem->write8(envPtr + 22, 1); // dtd - ctx->mem->write8(envPtr + 23, 0); // dfe + // dfe = video_mode ? h <= 288 : h <= 256 (ext.c:60-64) + ctx->mem->write8(envPtr + 23, h <= (pal ? 288 : 256) ? 1 : 0); + // isbg (+24) and the background colour r0/g0/b0 (+25..+27) close out the + // struct: ext.c:56-58 sets `env->r0 = 0; env->g0 = 0; env->b0 = 0;` and + // ext.c:68 sets `env->isbg = 0`. Offsets from the DRAWENV layout + // (chrono-cross-decomp/include/psyq/libgpu.h:361-371: isbg at :368 (+24), + // r0/g0/b0 at :369 (+25..+27)), which is + // the same layout this function's tpage/dtd/dfe writes above already + // follow. Without these four bytes a stack-allocated DRAWENV keeps + // whatever garbage was on the stack, so PutDrawEnv's isbg auto-clear tail + // (sys.c:576-619) would fire on an uninitialised flag and paint an + // uninitialised colour. + ctx->mem->write8(envPtr + 24, 0); // isbg + ctx->mem->write8(envPtr + 25, 0); // r0 + ctx->mem->write8(envPtr + 26, 0); // g0 + ctx->mem->write8(envPtr + 27, 0); // b0 ctx->r[V0] = envPtr; } @@ -324,18 +505,70 @@ void hle_SetDefDrawEnv(recomp_context *ctx) { // PutDrawEnv // // PsyQ PutDrawEnv(env): -// Applies a DrawEnv to the GPU by sending GP0 commands: -// GP0(0xE1) -- texture page (tpage) -// GP0(0xE2) -- texture window +// Applies a DrawEnv to the GPU by sending GP0 commands, in this order +// (SetDrawEnv2, sys.c:561-575 -- see the gap 2/3 amendment below): // GP0(0xE3) -- drawing area top-left // GP0(0xE4) -- drawing area bottom-right // GP0(0xE5) -- drawing offset +// GP0(0xE1) -- texture page (tpage) +// GP0(0xE2) -- texture window +// GP0(0xE6) -- mask bit setting +// +// Audited 2026-07-28 against the psyz decomp reference (workspace clone, +// PS1Recomp-workspace/psyz/decomp/src/libgpu/sys.c): PutDrawEnv builds its +// GP0(0xE1) word via SetDrawEnv2 -> get_mode(env->dfe, env->dtd, env->tpage) +// (sys.c:362-373, 561-573), the same get_mode() this project's SetDrawMode +// audit already confirmed (sys.c:624-632, retail/1MB-VRAM branch -- +// info.version 0/3, applicable to this target per that audit): +// (dtd ? 0xE1000200 : 0xE1000000) | (dfe ? 0x400 : 0) | (tpage & 0x9FF) +// The pre-audit implementation read only dtd and masked tpage with 0x7FF +// (bits 0-10) instead of 0x9FF (bits 0-8 + bit 11). Per psx-spx (GP0(E1h)), +// bits 9-10 are exclusively Dither/Draw-to-display-area, supplied by +// dtd/dfe -- never by the raw tpage value -- so 0x7FF let stray high bits of +// env->tpage leak into those flags, and env->dfe (DRAWENV +0x17, offset 23; +// chrono-cross-decomp/include/psyq/libgpu.h:361-371) was never read at all. // +// Amended 2026-08-07 (Task 4b, gap 1/3) against the same reference: +// get_cs/get_ce (sys.c:634-651, retail branch) additionally CLAMP the +// coordinates to the VRAM extent before packing, and mask y with 0x3FF (10 +// bits), not 0x1FF (9 bits): +// x = CLAMP(x, 0, info.w - 1); y = CLAMP(y, 0, info.h - 1); +// return 0xE3000000 | ((y & 0x3FF) << 10) | (x & 0x3FF); // get_cs -> E3 +// return 0xE4000000 | ((y & 0x3FF) << 10) | (x & 0x3FF); // get_ce -> E4 +// info.w/info.h are 1024/512 on the retail/1MB-VRAM branch (sys.c:107-108, +// 170-171 -- see kInfoW/kInfoH above). The pre-amendment implementation did +// neither: an out-of-range clip rect leaked unclamped, wrongly-masked bits +// straight into the GP0 words. Note the mask half of this fix (0x3FF vs +// 0x1FF) has no separately observable effect once the clamp is also applied +// -- a clamped y can never exceed info.h-1 = 511 = 0x1FF, so both masks +// agree on every value the clamp can produce; it is fixed anyway because it +// is a real, source-confirmed divergence in its own right, and because an +// unclamped call path (there is none today, but nothing enforces that) +// would make the two masks disagree. +// +// Amended 2026-08-07 (Task 4b, gap 2/3): SetDrawEnv2 (sys.c:561-575) pushes +// its GP0 words in this order: get_cs -> E3, get_ce -> E4, get_ofs -> E5, +// get_mode -> E1, get_tw -> E2, literal 0xE6000000 -> E6. The pre-amendment +// implementation emitted E1 first (E1, E3, E4, E5); it now emits E3, E4, E5, +// E1, matching the source for the four words this function currently +// builds. On real hardware order matters here: each GP0 word takes effect +// as it arrives, so the draw mode (E1) is applied only once the clip area +// (E3/E4) is already set, not before. +// +// Amended 2026-08-07 (Task 4b, gap 3/3): SetDrawEnv2 always also emits +// GP0(0xE2) Texture Window (get_tw(&env->tw), sys.c:574, 662-673) and the +// literal GP0(0xE6) Mask Bit Setting (sys.c:575), which this implementation +// now does too, at the tail of the SetDrawEnv2 order established by gap 2. +// The pre-amendment implementation emitted neither. void hle_PutDrawEnv(recomp_context *ctx) { + uint32_t envPtr = ctx->r[A0]; + // `DRAWENV* PutDrawEnv(DRAWENV* env)` returns env (sys.c:362, 372). Set + // before the writeGP0 guard below: the return value does not depend on + // whether this runtime has a GP0 sink wired up. + ctx->r[V0] = envPtr; if (!g_cfg.writeGP0) { return; } - uint32_t envPtr = ctx->r[A0]; int16_t cx = static_cast(ctx->mem->read16(envPtr + 0)); int16_t cy = static_cast(ctx->mem->read16(envPtr + 2)); int16_t cw = static_cast(ctx->mem->read16(envPtr + 4)); @@ -344,25 +577,72 @@ void hle_PutDrawEnv(recomp_context *ctx) { int16_t oy = static_cast(ctx->mem->read16(envPtr + 10)); uint16_t tpage = ctx->mem->read16(envPtr + 20); uint8_t dtd = ctx->mem->read8(envPtr + 22); - - // GP0(0xE1): texture page + dithering - uint32_t e1 = 0xE1000000u | (tpage & 0x7FFu) | (static_cast(dtd) << 9); - g_cfg.writeGP0(e1); - - // GP0(0xE3): drawing area top-left - g_cfg.writeGP0(0xE3000000u | (static_cast(cy & 0x1FF) << 10) | - static_cast(cx & 0x3FF)); - - // GP0(0xE4): drawing area bottom-right (inclusive) + uint8_t dfe = ctx->mem->read8(envPtr + 23); + + // Emission order matches SetDrawEnv2 (sys.c:561-575): get_cs -> E3, + // get_ce -> E4, get_ofs -> E5, get_mode -> E1 (get_tw -> E2 and the + // literal E6 word are gap 3, tracked separately below). On real hardware + // each GP0 word takes effect as it arrives, so this order means the draw + // mode (E1) is applied only after the clip area (E3/E4) already is, not + // before -- the pre-fix implementation emitted E1 first. + + // GP0(0xE3): drawing area top-left. sys.c:634-641 (get_cs, retail branch): + // x = CLAMP(x, 0, info.w - 1); y = CLAMP(y, 0, info.h - 1); + // return 0xE3000000 | ((y & 0x3FF) << 10) | (x & 0x3FF); + int32_t csX = sourceClamp(cx, 0, kInfoW - 1); + int32_t csY = sourceClamp(cy, 0, kInfoH - 1); + g_cfg.writeGP0(0xE3000000u | ((static_cast(csY) & 0x3FFu) << 10) | + (static_cast(csX) & 0x3FFu)); + + // GP0(0xE4): drawing area bottom-right (inclusive). sys.c:643-651 + // (get_ce, retail branch) -- same CLAMP/mask shape as get_cs above. int16_t x2 = static_cast(cx + cw - 1); int16_t y2 = static_cast(cy + ch - 1); - g_cfg.writeGP0(0xE4000000u | (static_cast(y2 & 0x1FF) << 10) | - static_cast(x2 & 0x3FF)); + int32_t ceX = sourceClamp(x2, 0, kInfoW - 1); + int32_t ceY = sourceClamp(y2, 0, kInfoH - 1); + g_cfg.writeGP0(0xE4000000u | ((static_cast(ceY) & 0x3FFu) << 10) | + (static_cast(ceX) & 0x3FFu)); // GP0(0xE5): drawing offset g_cfg.writeGP0(0xE5000000u | (static_cast(static_cast(oy) & 0x7FF) << 11) | static_cast(static_cast(ox) & 0x7FF)); + + // GP0(0xE1): texture page + dithering + draw-to-display-area enable + uint32_t e1 = (dtd ? 0xE1000200u : 0xE1000000u) | + (dfe ? 0x400u : 0u) | (tpage & 0x9FFu); + g_cfg.writeGP0(e1); + + // GP0(0xE2): texture window. sys.c:662-673 (get_tw), called as + // get_tw(&env->tw) -- always the non-NULL branch here, since env->tw is a + // struct field, not a pointer: + // code[0] = (rect->x & 0xFF) >> 3; code[2] = (-rect->w & 0xFF) >> 3; + // code[1] = (rect->y & 0xFF) >> 3; code[3] = (-rect->h & 0xFF) >> 3; + // return 0xE2000000 | (code[1]<<15) | (code[0]<<10) | (code[3]<<5) | + // code[2]; + // env->tw is the RECT at DRAWENV offset +12 (x,y,w,h, each int16 -- the + // same offset SetDefDrawEnv above already writes as all-zero "no + // restriction"). Cross-checked against psx-spx's documented GP0(E2h) bit + // layout (workspace clone PS1Recomp-workspace/psx-spx.github.io/docs/ + // graphicsprocessingunitgpu.md:406-414): bits 0-4 Mask X, 5-9 Mask Y, + // 10-14 Offset X, 15-19 Offset Y -- matching code[2]@0-4, code[3]@5-9, + // code[0]@10-14, code[1]@15-19 above. + int32_t twX = static_cast(ctx->mem->read16(envPtr + 12)); + int32_t twY = static_cast(ctx->mem->read16(envPtr + 14)); + int32_t twW = static_cast(ctx->mem->read16(envPtr + 16)); + int32_t twH = static_cast(ctx->mem->read16(envPtr + 18)); + uint32_t twCode0 = (static_cast(twX) & 0xFFu) >> 3; + uint32_t twCode1 = (static_cast(twY) & 0xFFu) >> 3; + uint32_t twCode2 = (static_cast(-twW) & 0xFFu) >> 3; + uint32_t twCode3 = (static_cast(-twH) & 0xFFu) >> 3; + g_cfg.writeGP0(0xE2000000u | (twCode1 << 15) | (twCode0 << 10) | + (twCode3 << 5) | twCode2); + + // GP0(0xE6): mask bit setting. sys.c:575 -- SetDrawEnv2 always pushes this + // literal, with no dependency on any DRAWENV field (env->isbg's RECT push, + // sys.c:576-619, is a separate, conditional tail this function does not + // model -- out of scope for Task 4b's three gaps). + g_cfg.writeGP0(0xE6000000u); } } // namespace ps1::psyq diff --git a/ps1Runtime/src/psyq/psyq_libgpu.cpp b/ps1Runtime/src/psyq/psyq_libgpu.cpp index e7b4660..9f68c3e 100644 --- a/ps1Runtime/src/psyq/psyq_libgpu.cpp +++ b/ps1Runtime/src/psyq/psyq_libgpu.cpp @@ -69,6 +69,19 @@ void warnOnceFor(const char *name) { // GetClut(x, y) -> packed CLUT id. y occupies the high 9 bits, x the low 6 // (x is always a multiple of 16, hence the >>4). +// +// Audited 2026-08-06: the psyz decomp's GetClut (decomp/src/libgpu/prim.c:9, +// `u_short GetClut(int x, int y) { return getClut(x, y); }`) only forwards +// to an SDK-internal `getClut` macro whose body is not present in the +// decomp tree (proprietary SDK header, not admissible). The packed layout +// is independently documented as hardware fact -- not an SDK internal -- by +// psx-spx (docs/graphicsprocessingunitgpu.md:366-374, "Clut Attribute"), +// which for the v0 (1 MB VRAM) GPU this project targets gives: +// bits 0-5 X coordinate X/16 (16-halfword steps) +// bits 6-14 Y coordinate 0-511 (9 bits) +// bit 15 unused +// Verdict: already correct -- (y & 0x1FF) << 6 | (x >> 4) & 0x3F matches +// exactly. No change made. void hle_libgpu_GetClut(recomp_context *ctx) { uint32_t x = ctx->r[A0]; uint32_t y = ctx->r[A1]; @@ -136,12 +149,48 @@ void hle_libgpu_SetDispMask(recomp_context *ctx) { writeGP1(0x03000000u | (mask ? 0u : 1u)); } -// LoadImage(rect*, src*) -- GP0(0xA0) + (w*h+1)/2 data words. +// LoadImage(rect*, src*) -- GP0(0x01) Clear Cache + GP0(0xA0) + (w*h+1)/2 +// data words. +// +// LoadImage(rect,data) forwards to _addque2(dws, rect, sizeof(RECT), data) +// (psyz decomp/src/libgpu/sys.c:280-284), and _dws -- the queued executor +// this function's body doubles as (see hle_libgpu__dws below) -- always +// issues a GP0(0x01) "Clear Cache" word immediately before the CPU->VRAM +// transfer header (sys.c:767-769: `*GPU_STATUS = STATUS_READY_TO_RECEIVE_CMD; +// *GPU_DATA = CMD_CLEAR_CACHE; *GPU_DATA = ... CMD_COPY_CPU_TO_VRAM;`, with +// CMD_CLEAR_CACHE = 0x01000000 at sys.c:148). Audited 2026-07-28: the +// pre-audit implementation omitted this leading word. gpu.cpp's +// executeClearCache() is a documented NOP for this software rasterizer, so +// the omission had no visible symptom, but the GP0 stream did not match what +// real PsyQ emits on every one of the 1458 CPU->VRAM uploads measured per +// run (2026-07-27). +// Two deviations from _dws (sys.c:745-783) that this audit did not close, left +// as behaviour changes with game-visible risk rather than silent fixes: +// * sys.c:753-754 clamps rect.w/h against info.w/h and writes the clamped +// values back into the CALLER's RECT. We do not write back, so a game +// passing an oversized rect keeps its original values. +// * sys.c:756-758 returns -1 when there is nothing to write. We return early +// with V0=0, so an empty rect reports success. Note this deviation is +// about _dws's OWN return; LoadImage never propagates it either way, +// because it returns what _addque2 hands back (0) -- see below. +// Neither is exercised by Crash's 1458 uploads (all in-range, none empty), so +// changing them now would be an unmeasured behaviour change on the one path +// that currently renders. Revisit if a game depends on either. +// +// Return value: `int LoadImage(RECT*, u_long*)` tail-returns _addque2's value +// (sys.c:280-284 -- the `return` is on the addque2 call, with no early exit), +// and _addque2's immediate path returns 0, proven by disassembly of the Crash +// binary (`move v0,zero` at 0x80042148; LoadImage at 0x800404D0-D8 restores RA +// and returns straight after the jalr). See hle_libgpu__addque2 below for the +// full trace. So V0 = 0 on every path, including the empty-rect early return, +// which the source does not have at all. void hle_libgpu_LoadImage(recomp_context *ctx) { PsyqRect r = readRect(ctx, ctx->r[A0]); uint32_t src = ctx->r[A1]; + ctx->r[V0] = 0; if (r.w <= 0 || r.h <= 0) return; + writeGP0(0x01000000u); // GP0(0x01): Clear Cache writeGP0(0xA0000000u); writeGP0(static_cast(r.y & 0xFFFF) << 16 | static_cast(r.x & 0xFFFF)); @@ -232,6 +281,8 @@ void hle_libgpu_GetVideoMode(recomp_context *ctx) { ctx->r[V0] = static_cast(g_videoMode); } +int getVideoMode() { return g_videoMode; } + // Group 1.A -- libgs scene-graph stubs // // libgs is a higher-level wrapper around libgpu; neither Rayman nor Crash @@ -249,16 +300,96 @@ void hle_libgs_GsDefDispBuff(recomp_context *ctx) { // release-mode behaviour (psyz decomp libgpu/sys.c:249). void hle_libgpu_checkRECT(recomp_context *ctx) { (void)ctx; } -// _addque2(exec, p1, len, p2) -- PSY-Q internal GPU op queue (libgpu -// sys.c). LoadImage/StoreImage/ClearImage/MoveImage enqueue their device -// routine here; the real impl copies `len` bytes of p1 into a queue slot -// and runs `exec(p1, p2)` when the GPU is idle. The runtime GPU is fully -// synchronous, so the queue depth is effectively zero: execute the native -// routine immediately and return its result in V0. +// _addque2(exec, p1, len, p2) -- PSY-Q internal GPU op queue (libgpu sys.c). +// LoadImage/StoreImage/ClearImage/DrawOTag/PutDrawEnv all submit their device +// routine through here. +// +// Audited 2026-08-08 by disassembling the Crash binary, which the phase-1 plan +// admits as a source. The previous note here cited psyz's PC reimplementation +// (psyz/src/psyz/*.c) -- a port, not the decomp -- and was withdrawn in 750b401; +// the decomp body is `INCLUDE_ASM` only (psyz decomp/src/libgpu/sys.c:866, with +// no matching entry under asm/nonmatchings/libgpu/sys/). This comment replaces +// that citation entirely. +// +// Source: test_roms/Crash Bandicoot /Crash Bandicoot (USA).bin.boot.exe, +// PS-EXE t_addr=0x80010000, 0x800 header => file offset = va - 0x80010000 + +// 0x800. _addque2 spans 0x80042000..0x800422E0 (file 0x32800..0x32AE0). +// Full listing: .superpowers/sdd/phase-1-hle-audit/addque2.dis (gitignored). +// +// That 0x80042000 really is libgpu's _addque2 is established by the binary +// itself, not by the config's naming: +// * 0x80042000 and 0x80041FDC sit as adjacent words at 0x80054A2C/0x80054A28 +// in a function-pointer table whose slot 0 (0x80054A24) holds the string +// "$Id: sys.c,v 1.120 1996/05/01 12:09:07 noda Exp $" -- PSY-Q libgpu's +// own RCS tag. +// * 0x80041FDC's whole body is `a3 = a2; a2 = 0; tailcall 0x80042000` -- +// which is `_addque` as the decomp spells it out at +// psyz decomp/src/libgpu/sys.c:862-864, `int _addque(int (*exec)(u_long, +// u_long), u_long p1, u_long p2) { return _addque2(exec, p1, 0, p2); }`. +// So the routine 0x80041FDC tail-calls is _addque2 by construction. +// * Every caller loads that table and calls slot +0x08. LoadImage +// (0x80040484) passes a0 = slot +0x20 (= _dws, 0x800419E4, the address +// configs/crash_recomp.toml:528-529 also assigns to libgpu__dws) and +// a2 = 8 = sizeof(RECT) -- exactly sys.c:280-284's `return +// D_800B8920->addque2(D_800B8920->dws, rect, sizeof(RECT), p)`. +// +// Semantics derived instruction by instruction (register file: s3=exec, +// s0=p1, s1=len, s2=p2): +// +// 0x80042024 arm a VSync(-1)-based timeout (0x80042864) +// 0x8004204c if ((head+1) & 0x3F) == tail -> queue full: poll 0x80042898, +// return -1 if it reports timeout, else run _exeque and retry +// 0x8004206c saved_mask = SetIntrMask(0) (0x8003E870 swaps *0x1F801074) +// 0x80042094 if queue-enable byte [0x80054A6D] == 0 -> immediate path +// else immediate path only if ALL of: +// head == tail (queue empty) +// !(*D2_CHCR & 0x01000000) (GPU DMA not busy) +// [0x80054A78] == 0 (no DrawSync callback) +// immediate path (0x800420EC..0x80042148): +// spin until (*GPUSTAT & 0x04000000); exec(p1, p2) via `jalr s3`; +// record exec/p1/p2 into 0x80054B60/64/68; SetIntrMask(saved); +// *** return 0 *** (`move v0,zero` at 0x80042148) +// enqueue path (0x8004214C..0x800422C0): +// register _exeque as the DMA callback, copy len/4 words of p1 +// into a 96-byte ring slot, store exec/p2/(&slot.buf or p1), +// head = (head+1) & 0x3F, restore mask, run _exeque, and +// return (head - tail) & 0x3F -- the queue depth. +// +// The register identities above are read out of the binary, not assumed: the +// pointer table holds 0x1F801814 (GPUSTAT/GP1) at 0x80054B40, 0x1F8010A8 +// (D2_CHCR) at 0x80054B4C and 0x1F801074 (I_MASK) at 0x800549C4, and the +// timeout printf at 0x800428E8 formats exactly those, in that order, against +// "GPU timeout:que=%d,stat=%08x,chcr=%08x,madr=%08x," (0x80011350). +// +// What this HLE models is the immediate path, and that is the correct choice +// here: this runtime's GPU is synchronous, so the queue is always empty, DMA2 +// is never left busy, and Crash never installs a DrawSync callback -- the three +// conditions that select it. Consequences, all confirmed above: +// * `len` (A2) is genuinely unused: the immediate path hands the caller's own +// p1 to exec and never touches the ring's copy buffer. +// * p2 comes from A3, and exec is called as exec(p1, p2). +// * the return value is 0, not exec's. This was the one proven divergence -- +// we previously let exec's V0 leak through, which happened to be 0 for +// every executor we dispatch (_dws and _cwc both force V0=0) but was not +// guaranteed. Callers tail-return this value (LoadImage at 0x800404D0-D8 +// restores RA and returns straight after the jalr), so it is observable. +// +// Not modelled, deliberately: the interrupt-mask critical section, the GPUSTAT +// ready-to-receive spin, the 64-slot ring and its len/4 word copy, the queue +// -full retry with its VSync(-1) + 240 deadline, and the -1 timeout return. +// All of them exist only to serialise against an asynchronous GPU/DMA that this +// runtime does not have. Note the ring copy has no bound check against the +// 84-byte slot buffer, so a hypothetical len > 84 would corrupt the next slot; +// no caller in this binary passes anything but 0 or 8. +// +// The `exec == 0` guard is a defensive addition with no source counterpart -- +// 0x80042110 is an unconditional `jalr s3`. Every real caller (LoadImage/_dws, +// StoreImage/_drs, ClearImage/_clr, DrawOTag's and PutDrawEnv's _cwc) passes a +// valid routine. The test that pins it says so explicitly. void hle_libgpu__addque2(recomp_context *ctx) { uint32_t exec = ctx->r[A0]; uint32_t p1 = ctx->r[A1]; - uint32_t p2 = ctx->r[A3]; // (A2 = len, only needed for the deferred copy) + uint32_t p2 = ctx->r[A3]; // (A2 = len; dead on the immediate path) if (exec == 0) { ctx->r[V0] = 0; return; @@ -268,9 +399,12 @@ void hle_libgpu__addque2(recomp_context *ctx) { ctx->r[A1] = p2; recomp_dispatch(ctx->mem->ramPtr(), ctx, exec); ctx->r[RA] = ra; + ctx->r[V0] = 0; // immediate path returns 0, not exec's value (0x80042148) } -// _addque(exec, p1, p2) -- 3-arg sibling of _addque2 (no deferred copy). +// _addque(exec, p1, p2) -- 3-arg sibling of _addque2. Its entire body at +// 0x80041FDC is `a3 = a2; a2 = 0; jal _addque2`, so it is _addque2 with len=0, +// with the same return value. See the _addque2 note above for the audit. void hle_libgpu__addque(recomp_context *ctx) { uint32_t exec = ctx->r[A0]; uint32_t p1 = ctx->r[A1]; @@ -284,16 +418,28 @@ void hle_libgpu__addque(recomp_context *ctx) { ctx->r[A1] = p2; recomp_dispatch(ctx->mem->ramPtr(), ctx, exec); ctx->r[RA] = ra; + ctx->r[V0] = 0; } // _dws(rect*, data*) -- device write: the queued executor behind LoadImage -// (CPU RAM -> VRAM). Identical argument layout to LoadImage. +// (CPU RAM -> VRAM). Identical argument layout and GP0 sequence to LoadImage +// (psyz decomp/src/libgpu/sys.c:745-783, `int _dws(RECT*, u_long*)`) -- +// confirmed as the same routine LoadImage's addque2 call dispatches to +// (sys.c:280-284). This is the 1458-calls/run item from the phase-1 +// measurement: _dws is what actually runs each frame; LoadImage itself is +// never dispatched by name (it only runs here via this direct C++ call). void hle_libgpu__dws(recomp_context *ctx) { hle_libgpu_LoadImage(ctx); ctx->r[V0] = 0; } // _drs(rect*, data*) -- device read: the executor behind StoreImage. +// NOTE (2026-07-28 audit): sys.c's _drs (sys.c:787-831) issues the same +// leading GP0(0x01) Clear Cache word _dws does before its VRAM->CPU header. +// StoreImage/_drs is out of scope for this audit pass (not in the phase-1 +// mandatory list -- its VRAM->CPU drain is separately stubbed, see +// hle_libgpu_StoreImage's own comment); flagging here so a future +// StoreImage audit does not have to rediscover it. void hle_libgpu__drs(recomp_context *ctx) { hle_libgpu_StoreImage(ctx); ctx->r[V0] = 0; @@ -372,13 +518,62 @@ void psyq_register_libgpu_extras() { ((y & 0x100) >> 4) | ((x & 0x3FF) >> 6) | ((y & 0x200) << 2); ctx->r[V0] = r & 0xFFFFu; }); + // SetDrawMode(DR_MODE *p, int dfe, int dtd, int tpage, RECT *tw): + // DR_MODE { u_long tag; u_long code[2]; }; code[0] = GP0(E1) draw mode, + // code[1] = GP0(E2) texture window. 5th arg (tw) travels on the stack at + // sp+16 (o32 ABI) -- confirmed from recompiled_out.cpp's callers of + // func_80041054 (SetDrawMode's HLE'd address), which write the RECT* + // via MEM_WRITE32(ctx, ctx->r29 + 16, ...) right before the call. + // + // Encoding confirmed against the psyz decomp reference (workspace clone, + // PS1Recomp-workspace/psyz/decomp/src/libgpu/sys.c): + // SetDrawMode: sys.c:503-506 -- setlen(p,2); code[0]=get_mode(...); + // code[1]=get_tw(tw). + // get_mode: sys.c:624-630. Two branches key off info.version (GPU + // hardware type, not PsyQ SDK version): 1/2 select an old + // large-coordinate devkit GPU encoding; every other value selects the + // retail/production encoding used below. info.version is 0 or 3 for + // 1 MB VRAM (the D_800B89BC height table at sys.c:108), which is what + // Crash (and every retail PS1) targets, so the retail branch applies: + // (dtd ? 0xE1000200 : 0xE1000000) | (dfe ? 0x400 : 0) | + // (tpage & 0x9FF) + // i.e. bit9 = dither (dtd), bit10 = draw-in-display-area-allowed + // (dfe), and tpage supplies bits 0-8 plus bit 11 unmasked -- matching + // psx-spx's GP0(E1h) layout (bit11 = "texpage Y base 2, 2MB VRAM + // only"). PsyQ does not mask bit 11 out for retail builds; it is + // simply never set by GetTPage for in-range (<512) VRAM Y coordinates + // on 1 MB hardware, so it is always 0 here in practice (matches the + // 2026-07-22 project finding that bit 11 does not apply to this + // target). + // get_tw: sys.c:662-673 -- returns the literal value 0 when tw==NULL + // (i.e. GP0(0x00), a NOP -- not a GP0(0xE2) command). For a non-null + // RECT {x,y,w,h}, it packs offsetY<<15 | offsetX<<10 | maskY<<5 | + // maskX (all in 8px steps), matching psx-spx's GP0(E2h) layout. psyq_register("libgpu_SetDrawMode", [](recomp_context *ctx) { - // DR_MODE { u_long tag; u_long code[2]; } — set len=2, codes are GP0 cmds. uint32_t p = ctx->r[A0]; if (p == 0) return; - ctx->mem->write8(p + 3, 2); // length in tag's high byte - ctx->mem->write32(p + 4, 0xE1000000u); // GP0(0xE1) draw mode placeholder - ctx->mem->write32(p + 8, 0xE2000000u); // GP0(0xE2) tex window placeholder + bool dfe = ctx->r[A1] != 0; + bool dtd = ctx->r[A2] != 0; + uint32_t tpage = ctx->r[A3]; + uint32_t tw = ctx->mem->read32(ctx->r[SP] + 16); // RECT *tw, o32 stack arg + + writeLen(ctx, p, 2); + + uint32_t mode = (dtd ? 0xE1000200u : 0xE1000000u) | + (dfe ? 0x400u : 0u) | (tpage & 0x9FFu); + ctx->mem->write32(p + 4, mode); + + uint32_t twWord = 0; + if (tw != 0) { + PsyqRect r = readRect(ctx, tw); + auto lowByte = [](int32_t v) { return static_cast(v) & 0xFFu; }; + uint32_t offsX = lowByte(r.x) >> 3; + uint32_t offsY = lowByte(r.y) >> 3; + uint32_t maskX = lowByte(-static_cast(r.w)) >> 3; + uint32_t maskY = lowByte(-static_cast(r.h)) >> 3; + twWord = 0xE2000000u | (offsY << 15) | (offsX << 10) | (maskY << 5) | maskX; + } + ctx->mem->write32(p + 8, twWord); }); } diff --git a/ps1Runtime/src/psyq/psyq_registry.cpp b/ps1Runtime/src/psyq/psyq_registry.cpp index 10a1099..7976927 100644 --- a/ps1Runtime/src/psyq/psyq_registry.cpp +++ b/ps1Runtime/src/psyq/psyq_registry.cpp @@ -8,6 +8,7 @@ #include "runtime/psyq/psyq_libgpu.h" #include "runtime/psyq/psyq_libgte.h" #include "runtime/psyq/psyq_pad.h" +#include "runtime/metrics.h" #include #include @@ -75,6 +76,11 @@ void psyq_dispatch(const char *name, recomp_context *ctx) { name, ctx->r[31]); std::abort(); } + if (ps1::metrics::enabled()) { + std::string key = "hle."; + key += name; + ps1::metrics::count(key); + } it->second(ctx); } @@ -109,6 +115,15 @@ void psyq_registry_init_defaults() { // TermPrim, immediate-mode draws via DrawPrim). psyq_register("libapi_StopRCnt", &hle_noop_stub); psyq_register("libapi_StartRCnt", &hle_noop_stub); + // SetRCnt(spec, target, mode) programs a root counter's compare value. The + // runtime models interrupts cooperatively, so no counter ever reaches a + // target and no handler ever fires -- accepting and discarding the target is + // consistent with Start/StopRCnt above. What the target *implies* is not + // discarded, though: for a game whose frame delta is driven by a root-counter + // handler, `[timing] rcnt_ticks_per_vblank` in the game TOML carries the + // resulting rate, and `applyRootCounterTicks` advances the word from VSync. + // (Crash: target 0x1000 on counter 2 at sysclk/8 => ~17 ticks per VBlank.) + psyq_register("libapi_SetRCnt", &hle_noop_stub); psyq_register("libcd_StCdInterrupt2", &hle_noop_stub); psyq_register("libgpu__reset", &hle_noop_stub); psyq_register("libgpu_TermPrim", &hle_noop_stub); diff --git a/ps1Runtime/src/psyq/psyq_state.cpp b/ps1Runtime/src/psyq/psyq_state.cpp index 33c72a3..906f3de 100644 --- a/ps1Runtime/src/psyq/psyq_state.cpp +++ b/ps1Runtime/src/psyq/psyq_state.cpp @@ -18,6 +18,8 @@ void PsyqState::reset() { for (auto &cb : dmaCallback) cb = 0; callbacksEnabled = true; drawSync = GpuDrawSync{}; + rcntTickAddr = 0; + rcntTicksPerVBlank = 0; } PsyqState &psyq_state() { diff --git a/ps1Runtime/src/recompiled_out_stub.cpp b/ps1Runtime/src/recompiled_out_stub.cpp index 0477400..8b086c5 100644 --- a/ps1Runtime/src/recompiled_out_stub.cpp +++ b/ps1Runtime/src/recompiled_out_stub.cpp @@ -17,8 +17,3 @@ void recomp_init_dispatch_table() { void recomp_dispatch(uint8_t* /*rdram*/, recomp_context* /*ctx*/, uint32_t /*addr*/) { // no-op: no game loaded } - -using recomp_func_t = void (*)(uint8_t*, recomp_context*); -void recomp_register_override(uint32_t /*addr*/, recomp_func_t /*fn*/) { - // no-op: no game loaded, overrides have nothing to override -} diff --git a/ps1Runtime/src/spu/spu.cpp b/ps1Runtime/src/spu/spu.cpp index 2f38540..e1c210b 100644 --- a/ps1Runtime/src/spu/spu.cpp +++ b/ps1Runtime/src/spu/spu.cpp @@ -344,6 +344,61 @@ void SPU::keyOffVoice(uint32_t idx) { } // ADPCM Decoding +// +// Block layout and the decode formula are documented in psx-spx +// (PS1Recomp-workspace/psx-spx.github.io/docs/): +// soundprocessingunitspu.md:116-125 16-byte block: shift/filter, flags, +// 14 bytes of 2-nibbles-per-byte data +// soundprocessingunitspu.md:119 "Shift/Filter (reportedly same as for +// CD-XA)" +// cdromformat.md:827-838 decode_28_nibbles(): shift = 12 - +// header_shift, t = signed4bit(nibble), +// s = (t< 12) ? 9 : shiftRaw; + int32_t shift = 12 - clampedShiftRaw; + + for (int i = 0; i < 14; i++) { + uint8_t dataByte = dataBytes14[i]; + for (int nibble = 0; nibble < 2; nibble++) { + // Sign-extend the 4-bit nibble (cdromformat.md:834, signed4bit()): + // values 8..15 represent -8..-1. Casting a 0..15 value straight to + // int8_t and *then* shifting does not sign-extend, because the shift + // operand is promoted to `int` first -- the shift must place the + // nibble's top bit at bit 7 of an 8-bit value before the cast. + uint8_t raw = (dataByte >> (nibble * 4)) & 0x0F; + int32_t t = (raw < 8) ? static_cast(raw) + : static_cast(raw) - 16; + + int32_t sample = (t << shift); + sample += (prevSample1 * f0 + prevSample2 * f1 + 32) / 64; + sample = std::clamp(sample, -32768, 32767); + + prevSample2 = prevSample1; + prevSample1 = static_cast(sample); + out[i * 2 + nibble] = static_cast(sample); + } + } +} + +} // namespace void SPU::advanceAdpcmBlock(Voice &v) { // Read the header byte of the 16-byte ADPCM block @@ -355,33 +410,15 @@ void SPU::advanceAdpcmBlock(Voice &v) { if (filter > 4) filter = 4; - int32_t f0 = ADPCM_FILTER_POS[filter]; - int32_t f1 = ADPCM_FILTER_NEG[filter]; - - // Decode 28 samples from 14 data bytes (2 nibbles per byte) + uint8_t dataBytes[14]; for (int i = 0; i < 14; i++) { - uint8_t dataByte = soundRam_[(v.currentAddr + 2 + i) % SOUND_RAM_SIZE]; - for (int nibble = 0; nibble < 2; nibble++) { - int32_t sample; - if (nibble == 0) { - sample = - static_cast(static_cast(dataByte & 0x0F) << 4) >> - 4; - } else { - sample = - static_cast(static_cast(dataByte & 0xF0)) >> 4; - } - - sample <<= (12 - shift); - sample += (v.prevSample1 * f0 + v.prevSample2 * f1 + 32) / 64; - sample = std::clamp(sample, -32768, 32767); - - v.prevSample2 = v.prevSample1; - v.prevSample1 = static_cast(sample); - v.decodedSamples[i * 2 + nibble] = static_cast(sample); - } + dataBytes[i] = soundRam_[(v.currentAddr + 2 + i) % SOUND_RAM_SIZE]; } + decodeAdpcmBlockData(dataBytes, shift, ADPCM_FILTER_POS[filter], + ADPCM_FILTER_NEG[filter], v.prevSample1, + v.prevSample2, v.decodedSamples); + v.decodedIndex = 0; // Check IRQ @@ -401,18 +438,21 @@ void SPU::advanceAdpcmBlock(Voice &v) { v.currentAddr = 0; } - if (flags & 0x01) { // End flag + if (flags & 0x01) { // Loop End: set ENDX and jump, regardless of bit 1. + // soundprocessingunitspu.md:130,135-141: Code 1 (End+Mute, bit1=0) and + // Code 3 (End+Repeat, bit1=1) both "jump to Loop-address, set ENDX + // flag"; only Code 1 additionally forces Release with envelope zeroed. v.endFlag = true; - endxFlags_ |= (1 << (&v - voices_)); // set ENDX bit + endxFlags_ |= (1u << (&v - voices_)); // set ENDX bit + v.currentAddr = static_cast(v.repeatAddr) * 8; + v.loopFlag = true; - if (flags & 0x02) { // Loop flag -- jump to repeat address - v.currentAddr = static_cast(v.repeatAddr) * 8; - v.loopFlag = true; - } else { - // Voice stops - v.adsrPhase = AdsrPhase::Off; + if (!(flags & 0x02)) { + // Code 1 = End+Mute: force Release phase, envelope to zero. + v.adsrPhase = AdsrPhase::Release; v.adsrVolume = 0; } + // Code 3 (flags & 0x02) = End+Repeat: jump only, envelope untouched. } } @@ -423,6 +463,28 @@ int16_t SPU::decodeAdpcmSample(Voice &v) { return v.decodedSamples[v.decodedIndex]; } +std::array +SPU::decodeAdpcmBlockForTest(const uint8_t block[ADPCM_BLOCK_SIZE], + int16_t prevSample1, int16_t prevSample2) const { + uint8_t header = block[0]; + uint8_t shift = header & 0x0F; + uint8_t filter = (header >> 4) & 0x07; + if (filter > 4) + filter = 4; + + std::array out{}; + decodeAdpcmBlockData(block + 2, shift, ADPCM_FILTER_POS[filter], + ADPCM_FILTER_NEG[filter], prevSample1, prevSample2, + out.data()); + return out; +} + +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}; +} + // ADSR void SPU::tickAdsr(Voice &v) { diff --git a/ps1Test/CMakeLists.txt b/ps1Test/CMakeLists.txt index 3e5379f..5eefef4 100644 --- a/ps1Test/CMakeLists.txt +++ b/ps1Test/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable(ps1Analyzer_tests analyzer/test_psyq_hash_matching.cpp analyzer/test_config_generator.cpp analyzer/test_overlay_scanner.cpp + analyzer/test_function_boundaries.cpp ) target_link_libraries(ps1Analyzer_tests @@ -36,6 +37,8 @@ add_executable(ps1Recomp_tests recompiler/test_gte_emitter.cpp recompiler/test_overlay_handler.cpp recompiler/test_hle_emission.cpp + recompiler/test_dispatch_emitter.cpp + recompiler/test_forward_branch.cpp ) target_link_libraries(ps1Recomp_tests @@ -100,6 +103,9 @@ add_executable(ps1Runtime_tests runtime/test_psyq_pad.cpp runtime/test_psyq_libc.cpp runtime/test_psyq_state.cpp + runtime/test_metrics.cpp + runtime/test_gool.cpp + runtime/test_golden_frame.cpp ) target_include_directories(ps1Runtime_tests diff --git a/ps1Test/analyzer/test_function_boundaries.cpp b/ps1Test/analyzer/test_function_boundaries.cpp new file mode 100644 index 0000000..064ccab --- /dev/null +++ b/ps1Test/analyzer/test_function_boundaries.cpp @@ -0,0 +1,231 @@ +// Tests for function-end refinement. +// +// The rule this replaces was "a function ends where the next detected one +// begins". That is wrong whenever detection produces a false entry point +// inside a real function: the body gets cut, and the branch spanning the cut +// is emitted as a call to an address the recompiler never emits. Measured on +// Crash Bandicoot (SCUS-94900): 0x8003A264 is a label inside func_8003A144, +// reached by two forward branches; we cut there and dispatched to nothing. + +#include "ps1recomp/function_finder.h" +#include +#include + +using namespace ps1recomp; + +namespace { + +// Instruction builders -- just enough to express the shapes under test. +constexpr uint32_t kNop = 0x00000000u; +constexpr uint32_t kJrRa = 0x03E00008u; // jr $ra + +// jr $reg +uint32_t jr(uint32_t reg) { return (reg << 21) | 0x08u; } + +// lw $rt, imm($rs) -- opcode 0x23 +uint32_t lw(uint32_t rt, uint32_t rs, int16_t imm) { + return (0x23u << 26) | (rs << 21) | (rt << 16) | + static_cast(imm); +} + +// addu $rd, $rs, $rt -- SPECIAL fn 0x21 +uint32_t addu(uint32_t rd, uint32_t rs, uint32_t rt) { + return (rs << 21) | (rt << 16) | (rd << 11) | 0x21u; +} + +// move $rd, $ra == addu $rd, $ra, $zero +uint32_t moveFromRa(uint32_t rd) { return addu(rd, 31, 0); } + +// beq $rs, $rt, offset(instructions forward from the delay slot) +uint32_t beq(uint32_t rs, uint32_t rt, int16_t words) { + return (0x04u << 26) | (rs << 21) | (rt << 16) | + static_cast(words); +} + +constexpr uint32_t kBase = 0x80010000u; + +TEST(RefineFunctionEnd, EndsAtPlainReturn) { + // index 0: nop, 1: jr $ra, 2: nop(delay) -> body is [base, base+12) + const std::vector w{kNop, kJrRa, kNop, kNop, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 20), kBase + 12); +} + +TEST(RefineFunctionEnd, ForwardBranchOverTheReturnKeepsTheBodyTogether) { + // This is the shape that broke Crash. A branch jumps past an early `jr $ra`; + // the function continues to the *second* return, and everything in between + // belongs to one body. + // + // 0: beq $0,$0,+4 -> targets index 6 + // 1: nop (delay) + // 2: jr $ra <- must NOT end the function: index 6 is still ahead + // 3: nop + // 4: nop + // 5: nop + // 6: jr $ra <- the real end + // 7: nop (delay) + const std::vector w{beq(0, 0, 4), kNop, kJrRa, kNop, + kNop, kNop, kJrRa, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 32), kBase + 32) + << "an early return before a live forward branch must not cut the body"; +} + +TEST(RefineFunctionEnd, ReturnAtOrPastReachDoesEndTheFunction) { + // Same shape, but the branch target lands exactly on the terminator, so the + // terminator is legitimately the end. + // + // 0: beq $0,$0,+2 -> index 4 + // 1: nop (delay) + // 2: nop + // 3: nop + // 4: jr $ra + // 5: nop (delay) + // 6..7: another function's padding + const std::vector w{beq(0, 0, 2), kNop, kNop, kNop, + kJrRa, kNop, kNop, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 32), kBase + 24); +} + +TEST(RefineFunctionEnd, JumpTableJrDoesNotEndTheFunction) { + // `jr $t0` where $t0 came out of memory is a jump table or a call through a + // pointer -- the targets are inside this function, so it continues. + // + // 0: lw $t0, 0($t1) + // 1: jr $t0 <- not a return + // 2: nop (delay) + // 3: nop + // 4: jr $ra <- the real end + // 5: nop (delay) + const std::vector w{lw(8, 9, 0), jr(8), kNop, kNop, kJrRa, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 24), kBase + 24); +} + +TEST(RefineFunctionEnd, JrThroughAnRaAliasIsAReturn) { + // `move $t0, $ra; ...; jr $t0` is a return with the address parked in another + // register -- it must end the function, unlike the jump-table case above. + const std::vector w{moveFromRa(8), kNop, jr(8), kNop, kNop, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 24), kBase + 16); +} + +TEST(RefineFunctionEnd, NeverRunsPastTheHardBound) { + // No terminator at all: the caller's bound wins, and nothing beyond it is + // ever claimed. + const std::vector w{kNop, kNop, kNop, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 8), kBase + 8); +} + +TEST(RefineFunctionEnd, TerminatorNearTheBoundIsClampedNotOverrun) { + // `jr $ra` in the last slot would put end at +8 past it; the bound clamps it. + const std::vector w{kNop, kJrRa}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 8), kBase + 8); +} + +TEST(RefineFunctionEnd, BackwardBranchDoesNotExtendTheBody) { + // A loop branching backwards must not push `reach` forward and swallow the + // following function. + // + // 0: nop + // 1: beq $0,$0,-2 -> back to index 0 + // 2: nop (delay) + // 3: jr $ra + // 4: nop (delay) + const std::vector w{kNop, beq(0, 0, -2), kNop, kJrRa, kNop, kNop}; + EXPECT_EQ(refineFunctionEnd(w, kBase, kBase + 24), kBase + 20); +} + +TEST(WritesRegister, ClassifiesTheFormsBoundaryDetectionRelieson) { + EXPECT_TRUE(mips::writesRegister(lw(8, 9, 0), 8)); + EXPECT_FALSE(mips::writesRegister(lw(8, 9, 0), 9)) << "rs is the base, not a destination"; + EXPECT_TRUE(mips::writesRegister(addu(8, 9, 10), 8)); + EXPECT_FALSE(mips::writesRegister(jr(8), 8)) << "jr writes nothing"; + EXPECT_FALSE(mips::writesRegister(addu(0, 9, 10), 0)) << "$zero is never written"; +} + +} // namespace + +// Sobreposicao de funcoes. +// +// Tamanhos vem de fontes que nao se revisitam: simbolo do ELF, distancia ate a +// proxima entrada, e `--add-func` depois de tudo. Adicionar uma entrada dentro +// de uma funcao ja dimensionada deixava a que engloba com o tamanho antigo, e +// os mesmos bytes saiam duas vezes no arquivo gerado, sob dois nomes. +// +// Os sete casos abaixo sao os medidos em configs/crash_recomp.toml -- um para +// cada `--add-func` que tools/regen_crash.sh acumulou para contornar a +// deteccao fraca. Cada workaround criou uma sobreposicao. + +namespace { + +FunctionInfo fn(uint32_t addr, uint32_t size, const char *name) { + FunctionInfo f; + f.address = addr; + f.size = size; + f.name = name; + f.source = FunctionSource::JALTarget; + f.isLeaf = false; + return f; +} + +} // namespace + +TEST(ClampOverlappingSizes, ShrinksTheContainerToTheNextEntry) { + // O par medido: func_8004636C (1716) engloba func_added_800466A0 (896); + // as duas terminavam em 0x80046A20. + std::vector funcs{ + fn(0x8004636Cu, 1716, "func_8004636C"), + fn(0x800466A0u, 896, "func_added_800466A0"), + }; + clampOverlappingSizes(funcs); + EXPECT_EQ(funcs[0].size, 0x800466A0u - 0x8004636Cu) + << "a funcao que engloba tem de parar onde a proxima comeca"; + EXPECT_EQ(funcs[1].size, 896u) << "a ultima nao tem o que sobrepor"; +} + +TEST(ClampOverlappingSizes, FixesEverySevenMeasuredPairs) { + // Os sete pares reais de configs/crash_recomp.toml. + std::vector funcs{ + fn(0x80016A6Cu, 680, "func_80016A6C"), fn(0x80016C18u, 4, "add1"), + fn(0x8001AAD8u, 452, "func_8001AAD8"), fn(0x8001AC60u, 4, "add2"), + fn(0x800253A0u, 912, "func_800253A0"), fn(0x80025628u, 4, "add3"), + fn(0x8002D384u, 792, "func_8002D384"), fn(0x8002D638u, 4, "add4"), + fn(0x8002E3F8u, 1236, "func_8002E3F8"), fn(0x8002E8A4u, 4, "add5"), + fn(0x800342D8u, 588, "func_800342D8"), fn(0x80034504u, 4, "add6"), + fn(0x8004636Cu, 1716, "func_8004636C"), fn(0x800466A0u, 4, "add7"), + }; + clampOverlappingSizes(funcs); + for (size_t i = 0; i + 1 < funcs.size(); ++i) { + EXPECT_LE(funcs[i].address + funcs[i].size, funcs[i + 1].address) + << funcs[i].name << " ainda alcanca " << funcs[i + 1].name; + } +} + +TEST(ClampOverlappingSizes, LeavesNonOverlappingSizesAlone) { + std::vector funcs{ + fn(0x80010000u, 16, "a"), // termina exatamente onde b comeca + fn(0x80010010u, 8, "b"), // deixa uma folga antes de c + fn(0x80010100u, 32, "c"), + }; + const auto before = funcs; + clampOverlappingSizes(funcs); + for (size_t i = 0; i < funcs.size(); ++i) + EXPECT_EQ(funcs[i].size, before[i].size) << funcs[i].name; +} + +TEST(ClampOverlappingSizes, SortsWhenTheInputIsOutOfOrder) { + std::vector funcs{ + fn(0x800466A0u, 896, "depois"), + fn(0x8004636Cu, 1716, "antes"), + }; + clampOverlappingSizes(funcs); + ASSERT_EQ(funcs[0].name, "antes"); + EXPECT_EQ(funcs[0].size, 0x800466A0u - 0x8004636Cu); +} + +TEST(ClampOverlappingSizes, ToleratesDuplicateAddresses) { + std::vector funcs{ + fn(0x80010000u, 64, "a"), + fn(0x80010000u, 64, "duplicata"), + fn(0x80010080u, 16, "b"), + }; + clampOverlappingSizes(funcs); // nao pode dividir por zero nem zerar tudo + EXPECT_EQ(funcs[2].size, 16u); +} diff --git a/ps1Test/recompiler/test_dispatch_emitter.cpp b/ps1Test/recompiler/test_dispatch_emitter.cpp new file mode 100644 index 0000000..c5689c9 --- /dev/null +++ b/ps1Test/recompiler/test_dispatch_emitter.cpp @@ -0,0 +1,98 @@ +// Tests for the generated `recomp_dispatch` body. +// +// The behaviour under test is a policy decision, not a formatting detail: an +// address that reaches the end of the resolver is code the recompiler never +// emitted. The original generator logged it five times and kept running, which +// made every miss a silent no-op -- execution continued with corrupted state +// and the symptom surfaced far from its cause. Measured consequence on Crash +// Bandicoot: months spent chasing render symptoms whose real cause was a +// function split in two by the analyzer, whose intra-function branch became a +// dispatch to an address that was never emitted, and was then swallowed. + +#include "ps1recomp/dispatch_emitter.h" +#include +#include + +using ps1recomp::emitDispatchBody; + +namespace { + +// Position of `needle` in the emitted body, or std::string::npos. +size_t at(const std::string &body, const char *needle) { + return body.find(needle); +} + +TEST(DispatchEmitter, AbortsOnUnmappedTarget) { + const std::string body = emitDispatchBody(); + EXPECT_NE(at(body, "std::abort();"), std::string::npos) + << "an unmapped target must stop the run, not be logged and skipped"; +} + +TEST(DispatchEmitter, FatalMessageNamesAddressCallerAndCause) { + const std::string body = emitDispatchBody(); + // The whole point of failing here is that the message is actionable: the + // address to emit, and the caller that reached it. + EXPECT_NE(at(body, "unmapped call to 0x{:08X}"), std::string::npos); + EXPECT_NE(at(body, "RA=0x{:08X}"), std::string::npos); + EXPECT_NE(at(body, "never emitted by the recompiler"), std::string::npos); +} + +TEST(DispatchEmitter, AbortIsReachedBeforeTheRateLimitedLogging) { + const std::string body = emitDispatchBody(); + const size_t abortAt = at(body, "std::abort();"); + const size_t logAt = at(body, "s_unknownHits"); + ASSERT_NE(abortAt, std::string::npos); + ASSERT_NE(logAt, std::string::npos); + EXPECT_LT(abortAt, logAt) + << "the permissive log must sit after the abort, so it only runs when " + "the escape hatch was taken"; +} + +TEST(DispatchEmitter, PermissiveEscapeHatchIsEnvGated) { + const std::string body = emitDispatchBody(); + EXPECT_NE(at(body, "PS1_DISPATCH_PERMISSIVE"), std::string::npos) + << "there must be a documented way to run a knowingly-incomplete build"; + // Guard against the hatch being open by default: the abort is skipped only + // when the variable is set to something other than empty or "0". + EXPECT_NE(at(body, "std::getenv(\"PS1_DISPATCH_PERMISSIVE\")"), + std::string::npos); + EXPECT_NE(at(body, "if (!s_permissive)"), std::string::npos); +} + +// The resolver has six legitimate paths before the failure. Losing any of them +// would turn a resolvable call into a spurious abort, which is worse than the +// silent no-op it replaced -- so pin the order. +TEST(DispatchEmitter, ResolutionPathsPrecedeTheFailure) { + const std::string body = emitDispatchBody(); + const size_t nullGuard = at(body, "1. NULL pointer guard"); + const size_t direct = at(body, "2. Direct lookup"); + const size_t normalize = at(body, "3. Address normalization"); + const size_t biosEntry = at(body, "4. BIOS entry points"); + const size_t sentinel = at(body, "5. BIOS table sentinel"); + const size_t trampoline = at(body, "6. JR RA trampoline"); + const size_t fatal = at(body, "7. Unmapped target"); + + for (size_t pos : {nullGuard, direct, normalize, biosEntry, sentinel, + trampoline, fatal}) + ASSERT_NE(pos, std::string::npos) << "a resolution path went missing"; + + EXPECT_LT(nullGuard, direct); + EXPECT_LT(direct, normalize); + EXPECT_LT(normalize, biosEntry); + EXPECT_LT(biosEntry, sentinel); + EXPECT_LT(sentinel, trampoline); + EXPECT_LT(trampoline, fatal); +} + +TEST(DispatchEmitter, NullDispatchStillReturnsQuietly) { + // Null targets are a known benign startup transient (uninitialised callback + // pointers), so they must keep returning rather than abort. + const std::string body = emitDispatchBody(); + const size_t guard = at(body, "if (addr == 0)"); + const size_t fatal = at(body, "std::abort();"); + ASSERT_NE(guard, std::string::npos); + ASSERT_NE(fatal, std::string::npos); + EXPECT_LT(guard, fatal); +} + +} // namespace diff --git a/ps1Test/recompiler/test_forward_branch.cpp b/ps1Test/recompiler/test_forward_branch.cpp new file mode 100644 index 0000000..23d545b --- /dev/null +++ b/ps1Test/recompiler/test_forward_branch.cpp @@ -0,0 +1,90 @@ +// Regressao: alvo de branch DENTRO da funcao virando despacho. +// +// Medido em Crash Bandicoot (SCUS-94900), func_8003A144 (size 1576): +// 0x8003A1E0: b 0x8003A264 -- alvo no indice 72 de 394, bem dentro do corpo +// O emissor produzia dois `goto L_8003A264` e nenhuma definicao local, e o +// pos-passo entao sintetizava `L_8003A264: recomp_dispatch(...)`. Com o +// dispatch fatal isso aborta o jogo; antes dele, era um no-op silencioso. + +#include "ps1recomp/instruction_emitter.h" +#include +#include +#include +#include + +using namespace ps1recomp; + +namespace { + +constexpr const char *kExe = + "../../test_roms/Crash Bandicoot /Crash Bandicoot (USA).bin.boot.exe"; +constexpr uint32_t kLoadAddr = 0x80010000u; +constexpr uint32_t kHeader = 0x800u; + +// Le `count` palavras a partir do endereco virtual `va`. +bool readWords(uint32_t va, size_t count, std::vector &out) { + std::FILE *f = std::fopen(kExe, "rb"); + if (!f) + return false; + const long off = static_cast(va - kLoadAddr + kHeader); + if (std::fseek(f, off, SEEK_SET) != 0) { + std::fclose(f); + return false; + } + out.resize(count); + const size_t got = std::fread(out.data(), 4, count, f); + std::fclose(f); + return got == count; +} + +TEST(EmitterForwardBranch, CrashFunc8003A144KeepsItsForwardTargetLocal) { + std::vector words; + if (!readWords(0x8003A144u, 1576 / 4, words)) + GTEST_SKIP() << "Crash boot exe not available; skipping."; + + RecompFunction f; + f.name = "func_8003A144"; + f.address = 0x8003A144u; + f.size = 1576; + f.instructions = words; + f.isLabelTarget.assign(words.size(), false); + + InstructionEmitter em; + const std::string out = em.emitFunction(f); + + EXPECT_NE(out.find("goto L_8003A264"), std::string::npos) + << "o branch para frente deveria virar goto"; + EXPECT_EQ(out.find("recomp_dispatch(rdram, ctx, 0x8003A264)"), + std::string::npos) + << "0x8003A264 esta a 288 bytes do inicio de uma funcao de 1576 -- " + "e alvo interno, nao pode virar despacho"; +} + +} // namespace + +// `jal` grava $ra = PC+8 no hardware. O emissor confiava no retorno do C++ e +// nao gravava nada, entao qualquer funcao que LEIA $ra como dado -- salvar na +// pilha, repassar, saltar por ele -- via lixo da ultima chamada indireta. +// +// Medido no gerado do Crash antes da correcao: 1523 chamadas diretas, ZERO +// gravando r31. No build de referencia do mesmo binario: 1591 de 1597. +// `func_8001B648` e um caso concreto -- faz `MEM_WRITE32(sp+32, ctx->r31)`. +TEST(EmitterDirectCall, JalWritesReturnAddressBeforeCalling) { + RecompFunction f; + f.name = "func_test"; + f.address = 0x80010000u; + // jal 0x80020000 ; nop(delay) ; jr $ra ; nop + f.instructions = {0x0C008000u, 0x00000000u, 0x03E00008u, 0x00000000u}; + f.size = f.instructions.size() * 4; + f.isLabelTarget.assign(f.instructions.size(), false); + + InstructionEmitter em; + const std::string out = em.emitFunction(f); + + const size_t ra = out.find("ctx->r31 = 0x80010008"); + const size_t call = out.find("func_80020000(rdram, ctx);"); + ASSERT_NE(call, std::string::npos) << out; + ASSERT_NE(ra, std::string::npos) + << "jal deve gravar $ra = PC+8 antes de chamar\n" << out; + EXPECT_LT(ra, call) << "$ra tem de estar valido quando a callee comeca"; +} diff --git a/ps1Test/runtime/test_golden_frame.cpp b/ps1Test/runtime/test_golden_frame.cpp new file mode 100644 index 0000000..6ac9e94 --- /dev/null +++ b/ps1Test/runtime/test_golden_frame.cpp @@ -0,0 +1,160 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Tests run from build/ps1Test/, so the repository root is two levels up. +// The pre-existing pipeline tests use "../" and consequently skip forever -- +// do not copy that. +constexpr const char *kRepoRoot = "../../"; + +std::string atRoot(const char *relative) { + return std::string(kRepoRoot) + relative; +} + +// Counts distinct non-black colours inside the display band (y=12..227), which +// is where the game's visible framebuffer lives. Pixel count alone cannot tell +// content from an empty screen: a plain clear already covers the whole band, so +// it scores ~221k non-zero pixels while carrying only 4 distinct colours. +// Colour diversity is what separates "something was drawn" from "the screen was +// cleared". Returns -1 when the file is unreadable or malformed. +long countBandColours(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in.good()) + return -1; + std::string magic; + int w = 0, h = 0, maxval = 0; + in >> magic >> w >> h >> maxval; + if (magic != "P6" || w <= 0 || h <= 0) + return -1; + in.get(); + std::vector px(static_cast(w) * h * 3); + in.read(reinterpret_cast(px.data()), + static_cast(px.size())); + if (in.gcount() != static_cast(px.size())) + return -1; + std::set> colours; + for (int y = 12; y < 228 && y < h; ++y) { + const size_t base = static_cast(y) * w * 3; + for (int x = 0; x < w; ++x) { + const size_t i = base + static_cast(x) * 3; + if (px[i] || px[i + 1] || px[i + 2]) + colours.insert({px[i], px[i + 1], px[i + 2]}); + } + } + return static_cast(colours.size()); +} + +// Counts non-zero 16-bit pixels in a P6 PPM produced by dumpVramPpm. +// Returns -1 when the file is unreadable or malformed. +long countNonZeroPixels(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in.good()) + return -1; + std::string magic; + int w = 0, h = 0, maxval = 0; + in >> magic >> w >> h >> maxval; + if (magic != "P6" || w <= 0 || h <= 0) + return -1; + in.get(); // single whitespace byte before the pixel payload + std::vector px(static_cast(w) * h * 3); + in.read(reinterpret_cast(px.data()), + static_cast(px.size())); + if (in.gcount() != static_cast(px.size())) + return -1; + long n = 0; + for (size_t i = 0; i + 2 < px.size(); i += 3) + if (px[i] || px[i + 1] || px[i + 2]) + ++n; + return n; +} + +bool exists(const std::string &path) { + if (FILE *f = std::fopen(path.c_str(), "r")) { + std::fclose(f); + return true; + } + return false; +} + +// The golden frame contract for Crash bring-up. Skipped when the game config +// or the runtime binary is absent, matching the skip-if-no-rom pattern in +// ps1Test/pipeline/. +TEST(GoldenFrame, CrashRendersContentAtFrame400) { + const std::string config = atRoot("configs/crash.toml"); + const std::string runtime = atRoot("build/ps1Runtime/ps1Runtime"); + if (!exists(config)) + GTEST_SKIP() << "configs/crash.toml not found. Skipping."; + if (!exists(runtime)) + GTEST_SKIP() << "ps1Runtime binary not found. Skipping."; + + const std::string ppm = "/tmp/ps1_golden_frame_test.ppm"; + std::remove(ppm.c_str()); + + // Needs a real display. `dummy` has no OpenGL at all -- the runtime exits + // before the first frame. `offscreen` has OpenGL but no vsync, so the main + // loop free-runs at ~880fps and frame 200 lands 0.2s into the boot, before + // anything is drawn; pushing the capture later there is unstable because + // content presence then races the per-frame clear. Under a vsynced display + // frame 200 is 3.3s in and the capture is repeatable. + if (!std::getenv("WAYLAND_DISPLAY") && !std::getenv("DISPLAY")) + GTEST_SKIP() << "No display available; golden frame needs vsynced " + "rendering. Skipping."; + + // Run from the repository root: the disc paths inside crash.toml are + // relative to it. + // PS1_DISPATCH_PERMISSIVE: this test's contract is the render pipeline, not + // the completeness of the dispatch table. Unmapped calls abort by default so + // that a missing function is found at its cause rather than swallowed -- but + // Crash still reaches several of them before frame 200 (the analyzer splits + // functions and their intra-function branches become dispatches to addresses + // that were never emitted). Until the boundary analysis is fixed, this test + // deliberately runs the knowingly-incomplete build, which is exactly what the + // escape hatch is for. Drop this variable once the game boots clean; if it + // then still passes, the render contract never depended on the hatch. + const std::string cmd = + "cd " + std::string(kRepoRoot) + + " && SDL_AUDIODRIVER=dummy PS1_DISPATCH_PERMISSIVE=1 " + // Frame 400, not 200. The old anchor was tuned to the hand-ported GOOL + // VM's boot timeline; since d04dcd7 the game runs its own interpreter and + // content arrives later. Measured on the native build: 16 distinct band + // colours at frame 200, ~988 by frame 400. The threshold below is + // unchanged -- only the anchor moved, so it still separates a real screen + // from a cleared one (which scores 4). + "PS1_VRAM_DUMP_FRAME=400 PS1_VRAM_DUMP_PATH=" + + ppm + + " timeout --kill-after=5 25 ./build/ps1Runtime/ps1Runtime " + "--config configs/crash.toml > /dev/null 2>&1"; + std::system(cmd.c_str()); + + const long n = countNonZeroPixels(ppm); + ASSERT_GE(n, 0) << "VRAM capture missing or malformed at " << ppm; + EXPECT_GT(n, 0) << "VRAM is entirely black at frame 200"; + + // Content check. A cleared screen scores exactly 4 colours in this band; + // the Universal Interactive Studios boot screen scored 300..477 across five + // runs on 2026-07-28, four of them byte-identical at 300. The bound sits far + // below that and far above a clear, so it fails on a blank screen without + // being flaky. + // + // That reproducibility depends on PS1_VRAM_DUMP_FRAME counting VBlanks rather + // than host render-loop iterations. Anchored on the render loop the same + // five runs scored 16..119, because the loop's rate follows compositor load + // while the game advances on a steady 60 Hz -- so "frame 200" landed on a + // different point of the intro each time. + const long colours = countBandColours(ppm); + ASSERT_GE(colours, 0) << "VRAM capture missing or malformed at " << ppm; + EXPECT_GT(colours, 50) + << "display band has " << colours + << " distinct colours -- a cleared screen scores 4, so nothing was drawn"; + + std::remove(ppm.c_str()); +} + +} // namespace diff --git a/ps1Test/runtime/test_gool.cpp b/ps1Test/runtime/test_gool.cpp new file mode 100644 index 0000000..67fda83 --- /dev/null +++ b/ps1Test/runtime/test_gool.cpp @@ -0,0 +1,257 @@ +// Tests for the GOOL bytecode VM shim (ps1Runtime/src/gool/). +// +// That module compiles into every build but was reachable only through a game +// run: `hle_gool_InterpretObject` is dispatched via an `[hle_overrides]` entry +// in an untracked config, so nothing in the repository exercised it. These +// tests drive the public entry point over a hand-built `goolobj` so the shim's +// memory access, operand decode, native-callout dispatch and return path are +// pinned without a disc. +// +// Deliberately narrow: a couple of opcodes and a terminator. Broad opcode +// coverage is Phase 4 work, not this file's job. + +#include "runtime/cpu_context.h" +#include "runtime/emuptr.h" +#include "runtime/gool/emu_compat.h" +#include "runtime/gool/gool_types.h" +#include "runtime/memory.h" +#include "runtime/metrics.h" +#include "runtime/psyq/psyq_hle.h" + +#include +#include +#include +#include + +// Stand-in for the recompiled function a native callout would reach +// (ps1Test/runtime/test_stubs.cpp). +extern void (*g_testRecompDispatchHook)(recomp_context *, uint32_t); + +namespace { + +using ps1::gool::goolobj; + +// All inside the 2 MB main RAM window, far enough apart not to overlap. +constexpr uint32_t kObj = 0x80110000; +constexpr uint32_t kCode = 0x80120000; // bytecode +constexpr uint32_t kStack = 0x80130000; // the object's GOOL stack +constexpr uint32_t kSp = 0x801F0000; // MIPS stack for the native frame +constexpr uint32_t kRaSentinel = 0x80001234; + +// Error codes from gool_interp.cpp's game convention. +constexpr uint32_t kErrInvalid = 0xFFFFFFF2; + +// Crash SCUS-94900 address of angle_distance, the callout opcode 0x21 makes. +constexpr uint32_t kFnAngDist = 0x800245F0; + +// What the stand-in callout saw. +struct Callout { + int calls = 0; + uint32_t addr = 0, a0 = 0, a1 = 0; +}; +Callout g_callout; + +class GoolTest : public ::testing::Test { +protected: + ps1::Memory mem; + recomp_context ctx; + + void SetUp() override { + mem.reset(); + ctx.reset(); + ctx.mem = &mem; + ps1::emuptr_set_ram(mem.ramPtr()); + // interpretObject's prologue spills ten callee-saved registers at + // SP-320..SP-4, so SP has to be real RAM with room below it. + ctx.r[29] = kSp; + ctx.r[31] = kRaSentinel; + g_callout = Callout{}; + g_testRecompDispatchHook = nullptr; + ps1::metrics::reset(); + ps1::metrics::setEnabledForTesting(true); + } + + void TearDown() override { + g_testRecompDispatchHook = nullptr; + ps1::metrics::reset(); + ps1::metrics::setEnabledForTesting(false); + ps1::emuptr_set_ram(nullptr); + } + + // The GOOL register file is the run of uint32 slots starting at `self`. + static uint32_t objReg(uint32_t index) { + return kObj + offsetof(goolobj, self) + index * 4; + } + + // A GOOL instruction: opcode in bits 31-24, operand A in 23-12, B in 11-0. + static uint32_t ins(uint32_t opcode, uint32_t gopA = 0, uint32_t gopB = 0) { + return (opcode << 24) | ((gopA & 0xFFF) << 12) | (gopB & 0xFFF); + } + + // Operand referring to register-file slot `index` (the 0xE00 form). 0xE1F is + // reserved: it is GOP_STACKTOP, the pop/push operand. + static uint32_t regOperand(uint32_t index) { return 0xE00u | index; } + static constexpr uint32_t kStackTop = 0xE1Fu; + + void bootObject() { + mem.write32(kObj + offsetof(goolobj, pc), kCode); + mem.write32(kObj + offsetof(goolobj, sp), kStack); + mem.write32(kObj + offsetof(goolobj, fp), 0); + } + + // Every test here terminates on an unported opcode, which warns on stderr. + // Swallow it so the suite output stays clean. Not asserted on: the warning + // is warn-once per opcode per *process*, so any expectation about its text + // would pass on the first run and fail under --gtest_repeat. The + // `gool.opcode_missing.XX` counter is the durable contract for that path, + // and it is asserted below. Each test still uses a distinct opcode so the + // counter names never collide. + void interpret() { + ctx.r[4] = kObj; // obj + ctx.r[5] = 0; // flags + ctx.r[6] = 0; // transition + testing::internal::CaptureStderr(); + ps1::psyq::hle_gool_InterpretObject(&ctx); + (void)testing::internal::GetCapturedStderr(); + } +}; + +// Arithmetic over the register file and the object stack, terminated by an +// opcode the port does not implement. Pins operand decode (register-file refs +// and the GOP_STACKTOP pop/push form), the push/pop cursor in `goolobj::sp`, +// the `pc` advance, the abort return code, and the metric wiring -- all +// through ps1::Memory, which is what the shim routes every access through. +TEST_F(GoolTest, InterpretsArithmeticThenAbortsOnUnportedOpcode) { + bootObject(); + mem.write32(objReg(2), 7); + mem.write32(objReg(3), 5); + mem.write32(objReg(4), 20); + + // 0x00 add: push(regB + regA) = 5 + 7 = 12 + mem.write32(kCode + 0, ins(0x00, regOperand(2), regOperand(3))); + // 0x01 sub: A pops the 12 just pushed, B is reg4 -> push(20 - 12) = 8 + mem.write32(kCode + 4, ins(0x01, kStackTop, regOperand(4))); + // 0x13 is not in the ported slice; it aborts this interpretation. + mem.write32(kCode + 8, ins(0x13)); + + interpret(); + + EXPECT_EQ(ctx.r[2], kErrInvalid); + EXPECT_EQ(mem.read32(kStack), 8u) << "stack top should hold 20 - (5 + 7)"; + EXPECT_EQ(mem.read32(kObj + offsetof(goolobj, sp)), kStack + 4) + << "one net push"; + EXPECT_EQ(mem.read32(kObj + offsetof(goolobj, pc)), kCode + 12) + << "pc must advance past all three instructions"; + + EXPECT_EQ(ps1::metrics::get("gool.interpret"), 1u); + EXPECT_EQ(ps1::metrics::get("gool.opcode_missing.13"), 1u); +} + +// The whole point of the shim is that everything the VM does not implement +// stays native code reached through EMU_Invoke -> recomp_dispatch. Opcode 0x21 +// (angular distance) is the cheapest opcode that makes such a callout: it must +// arrive at the right address with the operands in A0/A1, and its V0 must come +// back as the pushed value. +TEST_F(GoolTest, NativeCalloutReachesRecompDispatchAndReturnsThroughV0) { + bootObject(); + mem.write32(objReg(2), 0x11110000); + mem.write32(objReg(3), 0x22220000); + + mem.write32(kCode + 0, ins(0x21, regOperand(2), regOperand(3))); + mem.write32(kCode + 4, ins(0x22)); // terminate (unported, own warning) + + g_testRecompDispatchHook = [](recomp_context *c, uint32_t addr) { + ++g_callout.calls; + g_callout.addr = addr; + g_callout.a0 = c->r[4]; + g_callout.a1 = c->r[5]; + c->r[2] = 0xABCD1234u; // V0 + }; + + interpret(); + + EXPECT_EQ(g_callout.calls, 1); + EXPECT_EQ(g_callout.addr, kFnAngDist); + // case 0x21 calls EMU_Invoke(FN_ANG_DIST, 2, b, a) -- B first. + EXPECT_EQ(g_callout.a0, 0x22220000u); + EXPECT_EQ(g_callout.a1, 0x11110000u); + EXPECT_EQ(mem.read32(kStack), 0xABCD1234u) + << "the callout's V0 is what gets pushed"; +} + +// EMU_Invoke reserves outgoing-argument stack space and clobbers RA to make +// the call; the interpreter's own prologue spills 320 bytes. Both have to +// unwind, or the recompiled caller returns into the wrong frame. +TEST_F(GoolTest, InterpretationRestoresStackPointerAndReturnAddress) { + bootObject(); + mem.write32(kCode + 0, ins(0x21, regOperand(2), regOperand(3))); + mem.write32(kCode + 4, ins(0x25)); // terminate (unported, own warning) + g_testRecompDispatchHook = [](recomp_context *c, uint32_t) { c->r[2] = 0; }; + + interpret(); + + EXPECT_EQ(ctx.r[29], kSp); + EXPECT_EQ(ctx.r[31], kRaSentinel); +} + +// --- EMU_SDivide -------------------------------------------------------- +// +// R3000A `div` raises no exception on a divide error, it writes fixed garbage. +// psx-spx tabulates all three cases (docs/cpuspecifications.md:363-369): +// div 0..+7FFFFFFFh 0 --> HI = Rs, LO = -1 +// div -80000000h..-1 0 --> HI = Rs, LO = +1 +// div -80000000h -1 --> HI = 0, LO = -80000000h +// The last one cannot be delegated to C++ `/`: it is signed-overflow UB and +// raises SIGFPE on x86, killing the process. + +class EmuDivideTest : public ::testing::Test { +protected: + ps1::Memory mem; + recomp_context ctx; + + void SetUp() override { + mem.reset(); + ctx.reset(); + ctx.mem = &mem; + ps1::emuptr_set_ram(mem.ramPtr()); + ps1::gool::emu_bind(static_cast( + ps1::emuptr_translate(0x80000000u)), + &ctx); + } + void TearDown() override { + ps1::gool::emu_unbind(); + ps1::emuptr_set_ram(nullptr); + } +}; + +TEST_F(EmuDivideTest, SignedMinDividedByMinusOneDoesNotTrap) { + ps1::gool::EMU_SDivide(INT32_MIN, -1); + EXPECT_EQ(ctx.lo, 0x80000000u); + EXPECT_EQ(ctx.hi, 0u); +} + +TEST_F(EmuDivideTest, SignedDivideByZeroSplitsOnDividendSign) { + ps1::gool::EMU_SDivide(1234, 0); + EXPECT_EQ(ctx.hi, 1234u); + EXPECT_EQ(ctx.lo, 0xFFFFFFFFu) << "non-negative dividend -> LO = -1"; + + ps1::gool::EMU_SDivide(-1234, 0); + EXPECT_EQ(ctx.hi, static_cast(-1234)); + EXPECT_EQ(ctx.lo, 1u) << "negative dividend -> LO = +1"; +} + +TEST_F(EmuDivideTest, OrdinarySignedDivideIsUnchanged) { + ps1::gool::EMU_SDivide(-7, 2); + EXPECT_EQ(static_cast(ctx.lo), -3); + EXPECT_EQ(static_cast(ctx.hi), -1); +} + +// divu has no overflow case, only the zero divisor (psx-spx, same table: +// HI = Rs, LO = FFFFFFFFh). Pinned so the sign split above is not copied here. +TEST_F(EmuDivideTest, UnsignedDivideByZeroReturnsAllOnes) { + ps1::gool::EMU_UDivide(1234u, 0u); + EXPECT_EQ(ctx.hi, 1234u); + EXPECT_EQ(ctx.lo, 0xFFFFFFFFu); +} + +} // namespace diff --git a/ps1Test/runtime/test_gpu_commands.cpp b/ps1Test/runtime/test_gpu_commands.cpp index bb06307..9b24fe6 100644 --- a/ps1Test/runtime/test_gpu_commands.cpp +++ b/ps1Test/runtime/test_gpu_commands.cpp @@ -70,6 +70,45 @@ TEST_F(GpuCommandTest, GP0FillRectFillsArea) { EXPECT_EQ(vram[20 * GPU::VRAM_WIDTH + 9].raw, 0); // Outside left } +// GP0(02h) does not share the A0h/C0h "zero means the full dimension" rule: a +// zero width or height fills nothing. Reading a zero height as 512 made the +// game's per-frame clear at y=12 wrap past the bottom of VRAM and erase the +// texture pages at y=384..511, so every textured sprite sampled transparent +// texels and the screen stayed black. +TEST_F(GpuCommandTest, GP0FillRectWithZeroHeightFillsNothing) { + // Seed a pixel inside the region a wrapping 512-row fill would have reached. + gpu.writeGP0(0x020000FF); + gpu.writeGP0((400 << 16) | 600); // (600,400) -- texture-page territory + gpu.writeGP0((1 << 16) | 1); // 1x1 + const Color16 *vram = gpu.getVRAM(); + const uint16_t seeded = vram[400 * GPU::VRAM_WIDTH + 600].raw; + ASSERT_NE(seeded, 0) << "seed fill did not land; test cannot detect the wipe"; + + // Height field of 0: the clear the game actually issues. + gpu.writeGP0(0x02000000); + gpu.writeGP0((12 << 16) | 512); // (512,12), as observed in Crash + gpu.writeGP0((0 << 16) | 512); // 512 wide, height field 0 + + EXPECT_EQ(vram[400 * GPU::VRAM_WIDTH + 600].raw, seeded) + << "zero-height fill wrapped and wiped the texture page region"; +} + +TEST_F(GpuCommandTest, GP0FillRectWithZeroWidthFillsNothing) { + gpu.writeGP0(0x020000FF); + gpu.writeGP0((100 << 16) | 100); + gpu.writeGP0((1 << 16) | 1); + const Color16 *vram = gpu.getVRAM(); + const uint16_t seeded = vram[100 * GPU::VRAM_WIDTH + 100].raw; + ASSERT_NE(seeded, 0); + + gpu.writeGP0(0x02000000); + gpu.writeGP0((100 << 16) | 100); + gpu.writeGP0((8 << 16) | 0); // width field 0, height 8 + + EXPECT_EQ(vram[100 * GPU::VRAM_WIDTH + 100].raw, seeded) + << "zero-width fill was treated as 1024 wide"; +} + TEST_F(GpuCommandTest, GP1DMADirectionUpdatesGPUSTAT) { uint32_t initialStat = gpu.readGPUSTAT(); diff --git a/ps1Test/runtime/test_metrics.cpp b/ps1Test/runtime/test_metrics.cpp new file mode 100644 index 0000000..080ab6e --- /dev/null +++ b/ps1Test/runtime/test_metrics.cpp @@ -0,0 +1,109 @@ +#include "runtime/metrics.h" + +#include "runtime/gpu/gpu.h" + +#include +#include +#include +#include + +namespace { + +class MetricsTest : public ::testing::Test { +protected: + void SetUp() override { + ps1::metrics::reset(); + ps1::metrics::setEnabledForTesting(true); + } + void TearDown() override { + ps1::metrics::reset(); + ps1::metrics::setEnabledForTesting(false); + } +}; + +TEST_F(MetricsTest, CountAccumulatesAcrossCalls) { + ps1::metrics::count("gp0.op.7C"); + ps1::metrics::count("gp0.op.7C", 5); + EXPECT_EQ(ps1::metrics::get("gp0.op.7C"), 6u); +} + +TEST_F(MetricsTest, CountIsUncappedPastLegacyLogLimits) { + // The bug this whole effort exists for: the old logs stopped at 10 or 20. + for (int i = 0; i < 5000; ++i) + ps1::metrics::count("fill_rect"); + EXPECT_EQ(ps1::metrics::get("fill_rect"), 5000u); +} + +TEST_F(MetricsTest, UnknownCounterReadsZero) { + EXPECT_EQ(ps1::metrics::get("never.touched"), 0u); +} + +TEST_F(MetricsTest, SetStateKeepsMostRecentValue) { + ps1::metrics::setState("display.x", 0); + ps1::metrics::setState("display.x", 512); + EXPECT_EQ(ps1::metrics::getState("display.x"), 512); +} + +TEST_F(MetricsTest, DisabledCollectorRecordsNothing) { + ps1::metrics::setEnabledForTesting(false); + ps1::metrics::count("ignored"); + EXPECT_EQ(ps1::metrics::get("ignored"), 0u); +} + +TEST_F(MetricsTest, DumpJsonWritesCountersAndState) { + ps1::metrics::count("frames", 42); + ps1::metrics::setState("display.y", -7); + + const char *path = "/tmp/ps1_metrics_dump_test.json"; + std::remove(path); + ASSERT_TRUE(ps1::metrics::dumpJsonTo(path)); + + std::ifstream in(path); + ASSERT_TRUE(in.good()); + std::stringstream buf; + buf << in.rdbuf(); + const std::string body = buf.str(); + + EXPECT_NE(body.find("\"frames\": 42"), std::string::npos) << body; + EXPECT_NE(body.find("\"display.y\": -7"), std::string::npos) << body; + std::remove(path); +} + +// main_host.cpp reaches GPU::publishMetrics() from two shutdown paths -- the +// normal join and the forced `_Exit` taken whenever the recompiled game thread +// is still spinning -- and count() accumulates. Publishing the whole +// histogram on both paths doubled every reported gp0.op.XX and gp0.words. +TEST_F(MetricsTest, GpuPublishMetricsDoesNotDoubleOnSecondShutdownPath) { + ps1::gpu::GPU gpu; + gpu.writeGP0(0xE1000000u); // draw mode + gpu.writeGP0(0xE1000000u); + gpu.writeGP0(0xE6000000u); // mask bit setting + + gpu.publishMetrics(); + ASSERT_EQ(ps1::metrics::get("gp0.op.E1"), 2u); + ASSERT_EQ(ps1::metrics::get("gp0.op.E6"), 1u); + ASSERT_EQ(ps1::metrics::get("gp0.words"), 3u); + + gpu.publishMetrics(); + EXPECT_EQ(ps1::metrics::get("gp0.op.E1"), 2u); + EXPECT_EQ(ps1::metrics::get("gp0.op.E6"), 1u); + EXPECT_EQ(ps1::metrics::get("gp0.words"), 3u); +} + +// ...but the second call is not a plain no-op: the game thread keeps issuing +// GP0 words during main_host's 2s join deadline, and the forced-exit report +// has to include them so gp0.* stays consistent with the live counters +// (cpu_to_vram etc.) written in that same window. +TEST_F(MetricsTest, GpuPublishMetricsReportsTrafficBetweenCalls) { + ps1::gpu::GPU gpu; + gpu.writeGP0(0xE1000000u); + gpu.publishMetrics(); + ASSERT_EQ(ps1::metrics::get("gp0.op.E1"), 1u); + + gpu.writeGP0(0xE1000000u); + gpu.publishMetrics(); + EXPECT_EQ(ps1::metrics::get("gp0.op.E1"), 2u); + EXPECT_EQ(ps1::metrics::get("gp0.words"), 2u); +} + +} // namespace diff --git a/ps1Test/runtime/test_psyq_gpu.cpp b/ps1Test/runtime/test_psyq_gpu.cpp index 91720bd..41bd6f1 100644 --- a/ps1Test/runtime/test_psyq_gpu.cpp +++ b/ps1Test/runtime/test_psyq_gpu.cpp @@ -14,6 +14,7 @@ #include "runtime/psyq/psyq_registry.h" #include "runtime/psyq/psyq_state.h" +#include #include #include #include @@ -71,9 +72,42 @@ TEST_F(PsyqGpuTest, SetDispMaskDisableSendsGP1_03_With1) { EXPECT_EQ(gp1[0], 0x03000001u); } -// LoadImage - -TEST_F(PsyqGpuTest, LoadImageEmitsCpuToVramHeaderAndData) { +// LoadImage / _dws +// +// LoadImage(rect,data) forwards to _addque2(dws, rect, sizeof(RECT), data) +// (psyz decomp/src/libgpu/sys.c:280-284: `return D_800B8920->addque2( +// (int(*)(u_long,u_long))D_800B8920->dws, (u_long)rect, sizeof(RECT), +// (u_long)p);`). _dws is the queued executor that actually runs the +// transfer -- and it is what accounts for the 1458 calls/run measured +// 2026-07-27 (LoadImage itself is registered but dispatched 0x/run; its +// body only runs because hle_libgpu__dws calls hle_libgpu_LoadImage +// directly in C++, psyq_libgpu.cpp). +// +// _dws's real GP0 sequence (sys.c:745-783, `int _dws(RECT* rect, u_long* +// data)`) is: +// *GPU_STATUS = STATUS_READY_TO_RECEIVE_CMD; // sys.c:767 -- GP1, see below +// *GPU_DATA = CMD_CLEAR_CACHE; // sys.c:768 (0x01000000, sys.c:148) +// *GPU_DATA = CMD_COPY_CPU_TO_VRAM; // sys.c:769 (0xA0000000) +// *GPU_DATA = rect.x | rect.y << 16; // sys.c:770 +// *GPU_DATA = rect.w | rect.h << 16; // sys.c:771 +// ... (w*h+1)/2 data words ... // sys.c:773-781 +// The pre-audit implementation omitted the leading GP0(0x01) Clear Cache +// word entirely -- gpu.cpp's executeClearCache() is a documented NOP for +// this software rasterizer, so the omission produced no visible symptom, +// but the GP0 stream did not match what every real CPU->VRAM upload emits. +// +// Two gaps this audit did NOT close, recorded so they are not rediscovered: +// * `*GPU_STATUS` is the GP1 port, not GP0. sys.c:767 is GP1(0x04) with +// DMA direction 0, and sys.c:778 sets 0x04000002. Neither GP1 write is +// emitted here; the runtime GPU does not model DMA direction for this +// path, so nothing observable depends on them yet. +// * sys.c:753-758 clamps rect.w/h against info.w/h and writes the clamped +// values back into the caller's RECT, and returns -1 when there is +// nothing to write. Our LoadImage returns early and hle_libgpu__dws +// forces V0=0, so an empty rect reports success and no clamp is written +// back. See the note on hle_libgpu_LoadImage in psyq_libgpu.cpp. + +TEST_F(PsyqGpuTest, LoadImageEmitsClearCacheThenCpuToVramHeaderAndData) { // 4x2 pixels = 8 px = 4 words of pixel data. uint32_t rectP = 0x80100000u; uint32_t srcP = 0x80100100u; @@ -85,12 +119,13 @@ TEST_F(PsyqGpuTest, LoadImageEmitsCpuToVramHeaderAndData) { ctx.r[A1] = srcP; hle_libgpu_LoadImage(&ctx); - ASSERT_EQ(gp0.size(), 3u + 4u); - EXPECT_EQ(gp0[0], 0xA0000000u); // CPU->VRAM cmd - EXPECT_EQ(gp0[1], (0u << 16) | 320u); // Y|X - EXPECT_EQ(gp0[2], (2u << 16) | 4u); // H|W + ASSERT_EQ(gp0.size(), 4u + 4u); + EXPECT_EQ(gp0[0], 0x01000000u); // GP0(0x01): Clear Cache + EXPECT_EQ(gp0[1], 0xA0000000u); // CPU->VRAM cmd + EXPECT_EQ(gp0[2], (0u << 16) | 320u); // Y|X + EXPECT_EQ(gp0[3], (2u << 16) | 4u); // H|W for (int i = 0; i < 4; ++i) - EXPECT_EQ(gp0[3 + i], pixels[i]); + EXPECT_EQ(gp0[4 + i], pixels[i]); } TEST_F(PsyqGpuTest, LoadImageOddPixelCountRoundsUpDataWords) { @@ -104,9 +139,10 @@ TEST_F(PsyqGpuTest, LoadImageOddPixelCountRoundsUpDataWords) { ctx.r[A0] = rectP; ctx.r[A1] = srcP; hle_libgpu_LoadImage(&ctx); - ASSERT_EQ(gp0.size(), 5u); // 3 hdr + 2 data - EXPECT_EQ(gp0[3], 0xCAFEBABEu); - EXPECT_EQ(gp0[4], 0xDEADBEEFu); + ASSERT_EQ(gp0.size(), 6u); // clear-cache + 3 hdr + 2 data + EXPECT_EQ(gp0[0], 0x01000000u); + EXPECT_EQ(gp0[4], 0xCAFEBABEu); + EXPECT_EQ(gp0[5], 0xDEADBEEFu); } TEST_F(PsyqGpuTest, LoadImageZeroSizeIsNoop) { @@ -118,6 +154,174 @@ TEST_F(PsyqGpuTest, LoadImageZeroSizeIsNoop) { EXPECT_TRUE(gp0.empty()); } +// `int LoadImage(RECT*, u_long*)` tail-returns _addque2's value (sys.c:280-284 +// -- the `return` is on the addque2 call, and there is no early exit), and +// _addque2's immediate path returns 0 (`move v0,zero` at 0x80042148 in the +// Crash binary; LoadImage at 0x800404D0-D8 restores RA and returns straight +// after the jalr). The HLE left V0 untouched, so callers saw whatever the +// previous HLE happened to leave in it. +TEST_F(PsyqGpuTest, LoadImageReturnsZero) { + uint32_t rectP = 0x80100000u; + uint32_t srcP = 0x80100100u; + writeRect(rectP, 0, 0, 2, 1); + mem.write32(srcP, 0xABCD1234u); + + ctx.r[A0] = rectP; + ctx.r[A1] = srcP; + ctx.r[V0] = 0xDEADBEEFu; // stale value from a previous call + hle_libgpu_LoadImage(&ctx); + EXPECT_EQ(ctx.r[V0], 0u); +} + +// The source has no empty-rect early exit at all, so the zero-size shortcut +// this HLE takes must still leave the same return value behind. +TEST_F(PsyqGpuTest, LoadImageReturnsZeroOnZeroSizeRect) { + uint32_t rectP = 0x80100000u; + writeRect(rectP, 0, 0, 0, 0); + ctx.r[A0] = rectP; + ctx.r[A1] = 0x80100100u; + ctx.r[V0] = 0xDEADBEEFu; + hle_libgpu_LoadImage(&ctx); + EXPECT_EQ(ctx.r[V0], 0u); +} + +// _dws is the symbol actually dispatched 1458x/run (LoadImage itself is +// never dispatched by name); exercise it through the registry rather than +// trusting the delegation in psyq_libgpu.cpp is wired correctly. +TEST_F(PsyqGpuTest, DwsDispatchesLikeLoadImageAndReturnsZero) { + psyq_register_libgpu_extras(); + uint32_t rectP = 0x80100000u; + uint32_t srcP = 0x80100100u; + writeRect(rectP, 64, 8, 2, 1); // 2 px -> 1 data word + mem.write32(srcP, 0xABCD1234u); + + ctx.r[A0] = rectP; + ctx.r[A1] = srcP; + ctx.r[V0] = 0xFFFFFFFFu; + psyq_dispatch("libgpu__dws", &ctx); + + ASSERT_EQ(gp0.size(), 4u + 1u); + EXPECT_EQ(gp0[0], 0x01000000u); + EXPECT_EQ(gp0[1], 0xA0000000u); + EXPECT_EQ(gp0[2], (8u << 16) | 64u); + EXPECT_EQ(gp0[3], (1u << 16) | 2u); + EXPECT_EQ(gp0[4], 0xABCD1234u); + EXPECT_EQ(ctx.r[V0], 0u); +} + +// _addque2 +// +// Audited 2026-08-08 against the disassembly of _addque2 at 0x80042000 in +// test_roms/Crash Bandicoot /Crash Bandicoot (USA).bin.boot.exe -- the source +// the phase-1 plan admits alongside the decomp, used because the decomp body +// is `INCLUDE_ASM`-only (decomp/src/libgpu/sys.c:866). The earlier note here +// cited psyz's PC reimplementation, which is a port and not admissible; it was +// withdrawn in 750b401. Listing and derivation: +// .superpowers/sdd/phase-1-hle-audit/addque2.dis and task-4c-report.md, and +// the instruction-level summary above hle_libgpu__addque2 in psyq_libgpu.cpp. +// +// The three facts the tests below pin, and where each comes from: +// * exec is called as exec(p1, p2), p2 taken from a3 -- `move a0,s0; +// move a1,s2; jalr s3` at 0x8004210C-0x80042114, with s0=a1(p1) and +// s2=a3(p2) from the prologue at 0x80042010/0x80042028. +// * `len` (a2) is dead on this path -- the immediate path passes the +// caller's own p1 through and never reaches the ring-copy loop at +// 0x80042164, which is the only reader of s1=len. +// * the return value is 0, NOT exec's -- `move v0,zero` at 0x80042148, in +// the delay slot of the jump to the epilogue. Callers tail-return it +// (LoadImage at 0x800404D0 restores RA immediately after the jalr), so +// this is observable to the game. + +// Installs a stand-in for the dispatched device routine so these tests can see +// what exec receives and prove _addque2 discards what exec returns. +extern void (*g_testRecompDispatchHook)(recomp_context *, uint32_t); + +namespace { +struct ScopedDispatchHook { + explicit ScopedDispatchHook(void (*fn)(recomp_context *, uint32_t)) { + g_testRecompDispatchHook = fn; + } + ~ScopedDispatchHook() { g_testRecompDispatchHook = nullptr; } +}; + +// Observations recorded by the stand-in exec. +struct ExecObservation { + int calls = 0; + uint32_t addr = 0, a0 = 0, a1 = 0; +}; +ExecObservation g_exec; +} // namespace + +TEST_F(PsyqGpuTest, AddQue2CallsExecWithP1AndP2AndReturnsZeroNotExecsValue) { + psyq_register_libgpu_extras(); + g_exec = ExecObservation{}; + ScopedDispatchHook hook([](recomp_context *c, uint32_t addr) { + g_exec.calls++; + g_exec.addr = addr; + g_exec.a0 = c->r[A0]; + g_exec.a1 = c->r[A1]; + c->r[V0] = 0x5A5A5A5Au; // exec returns nonzero; _addque2 must discard it + }); + + ctx.r[A0] = 0x80012340u; // exec + ctx.r[A1] = 0x80100000u; // p1 + ctx.r[A2] = 0x99u; // len -- dead on the immediate path + ctx.r[A3] = 0x80200000u; // p2 + ctx.r[RA] = 0x80099999u; + + psyq_dispatch("libgpu__addque2", &ctx); + + EXPECT_EQ(g_exec.calls, 1) << "exec must be invoked exactly once"; + EXPECT_EQ(g_exec.addr, 0x80012340u) << "a0 selects the routine to dispatch"; + EXPECT_EQ(g_exec.a0, 0x80100000u) << "exec's a0 is p1 (0x8004210C)"; + EXPECT_EQ(g_exec.a1, 0x80200000u) + << "exec's a1 is p2 from a3, not a2=len (0x80042114)"; + EXPECT_EQ(ctx.r[V0], 0u) + << "immediate path returns 0, not exec's value (0x80042148)"; + EXPECT_EQ(ctx.r[RA], 0x80099999u) << "RA must be restored after the call"; +} + +// _addque(exec, p1, p2) is _addque2 with len=0: its whole body at 0x80041FDC is +// `move a3,a2; move a2,zero; jal 0x80042000`. So p2 comes from a2 here, and the +// return value is the same 0. +TEST_F(PsyqGpuTest, AddQueTakesP2FromA2AndAlsoReturnsZero) { + psyq_register_libgpu_extras(); + g_exec = ExecObservation{}; + ScopedDispatchHook hook([](recomp_context *c, uint32_t addr) { + g_exec.calls++; + g_exec.addr = addr; + g_exec.a0 = c->r[A0]; + g_exec.a1 = c->r[A1]; + c->r[V0] = 0x5A5A5A5Au; + }); + + ctx.r[A0] = 0x80012340u; // exec + ctx.r[A1] = 0x80100000u; // p1 + ctx.r[A2] = 0x80200000u; // p2 (3-arg form) + ctx.r[A3] = 0xBADBAD00u; // must be ignored + + psyq_dispatch("libgpu__addque", &ctx); + + EXPECT_EQ(g_exec.calls, 1); + EXPECT_EQ(g_exec.a0, 0x80100000u); + EXPECT_EQ(g_exec.a1, 0x80200000u) << "_addque takes p2 from a2"; + EXPECT_EQ(ctx.r[V0], 0u); +} + +// Pins OUR defensive null guard, not PsyQ semantics: the real _addque2 has no +// null check (its callers always pass a valid device routine). This test exists +// so the guard is not removed by accident, and it must not be read as evidence +// that the hardware behaves this way. +TEST_F(PsyqGpuTest, AddQue2NullExecTakesOurDefensiveGuardNotPsyqSemantics) { + psyq_register_libgpu_extras(); + ctx.r[A0] = 0; // exec = NULL + ctx.r[A1] = 0x80100000u; + ctx.r[A3] = 0x80200000u; + ctx.r[V0] = 0xDEADBEEFu; + psyq_dispatch("libgpu__addque2", &ctx); + EXPECT_EQ(ctx.r[V0], 0u); +} + // StoreImage TEST_F(PsyqGpuTest, StoreImageEmitsVramToCpuHeader) { @@ -226,6 +430,314 @@ TEST_F(PsyqGpuTest, LibgsStubsAreNoopAndDoNotCrash) { EXPECT_TRUE(gp1.empty()); } +// GetClut +// +// Audited 2026-08-06 against psx-spx (docs/graphicsprocessingunitgpu.md: +// 366-374, "Clut Attribute"), which documents the packed layout as hardware +// fact for the v0 (1 MB VRAM) GPU this project targets: +// bits 0-5 X coordinate X/16 (16-halfword steps) +// bits 6-14 Y coordinate 0-511 (9 bits) +// bit 15 unused +// The psyz decomp's GetClut (decomp/src/libgpu/prim.c:9) only forwards to an +// SDK-internal `getClut` macro not present in the decomp tree, so psx-spx is +// the citable source here, not sys.c/prim.c directly. Verdict: already +// correct on first read -- no implementation change made, this test exists +// purely to pin the encoding. + +TEST_F(PsyqGpuTest, GetClutPacksXShr4AndYIntoPsxSpxLayout) { + // x=512 (multiple of 16, x>>4=32), y=300 -> (300&0x1FF)<<6 | 32 + ctx.r[A0] = 512; + ctx.r[A1] = 300; + hle_libgpu_GetClut(&ctx); + EXPECT_EQ(ctx.r[V0], (300u << 6) | 32u); +} + +TEST_F(PsyqGpuTest, GetClutXIsDividedBy16NotPassedThrough) { + // x must be a CLUT-cell index (x/16), never the raw VRAM X coordinate. + ctx.r[A0] = 128; // 128/16 = 8 + ctx.r[A1] = 0; + hle_libgpu_GetClut(&ctx); + EXPECT_EQ(ctx.r[V0], 8u); +} + +TEST_F(PsyqGpuTest, GetClutYOccupiesBits6Through14) { + ctx.r[A0] = 0; + ctx.r[A1] = 511; // max in-range Y for the v0 (1 MB VRAM) GPU + hle_libgpu_GetClut(&ctx); + EXPECT_EQ(ctx.r[V0], 511u << 6); + EXPECT_EQ(ctx.r[V0] & 0x3Fu, 0u) << "low 6 bits must stay reserved for X"; +} + +// SetDrawMode +// +// SetDrawMode(DR_MODE *p, int dfe, int dtd, int tpage, RECT *tw) must encode +// its arguments into the two GP0 words of the DR_MODE primitive. The +// pre-2026-07 implementation wrote fixed 0xE1000000 / 0xE2000000 +// placeholders, which forced texpage 0 on every textured primitive the game +// queued -- ~229000 GP0(0x7C) sprites per 15s run (remeasured 2026-08-09; +// the 456720 first recorded here was the same figure doubled by the +// publish-twice bug in GPU::publishMetrics(), fixed in eb15d52). +// Texpage 0 points at VRAM (0,0), where the framebuffer band lives, instead +// of the loaded texture pages at (512..1023, 384). Those pages do hold real +// data (7944 distinct colours), so this is a wrong-address bug, not missing +// data. Encoding confirmed against the psyz decomp reference (workspace +// clone at PS1Recomp-workspace/psyz/decomp/src/libgpu/sys.c): +// SetDrawMode: sys.c:503-506. +// get_mode (retail/production-GPU branch -- info.version 0/3, per the 1MB +// VRAM height table at sys.c:108; Crash targets retail hardware, not the +// 2MB-VRAM devkit branch taken when info.version is 1 or 2): sys.c:624-630. +// get_tw: sys.c:662-673. +// 5th arg (RECT *tw) travels on the stack at sp+16 (o32 ABI), confirmed via +// the recompiled_out.cpp callers of func_80041054 (SetDrawMode's HLE'd +// address): MEM_WRITE32(ctx, ctx->r29 + 16, ...). + +TEST_F(PsyqGpuTest, SetDrawModeEncodesTexpageAndFlagsIntoGp0Word) { + psyq_register_libgpu_extras(); + const uint32_t p = 0x80100000u; + const int dfe = 1, dtd = 0; + const int tpage = 0x1A; // texture page base the game would pass + + ctx.r[SP] = 0x801FFF00u; + ctx.r[A0] = p; + ctx.r[A1] = static_cast(dfe); + ctx.r[A2] = static_cast(dtd); + ctx.r[A3] = static_cast(tpage); + mem.write32(ctx.r[SP] + 16u, 0); // RECT *tw = NULL + + psyq_dispatch("libgpu_SetDrawMode", &ctx); + + const uint32_t mode = mem.read32(p + 4); + EXPECT_EQ(mode >> 24, 0xE1u) << "must remain a GP0(E1) command"; + EXPECT_EQ(mode & 0x1FFu, static_cast(tpage) & 0x1FFu) + << "texpage bits were dropped -- this is the 2026-07 regression"; + EXPECT_EQ((mode >> 10) & 1u, static_cast(dfe)); + EXPECT_EQ((mode >> 9) & 1u, static_cast(dtd)); + EXPECT_EQ(mem.read8(p + 3), 2u) << "DR_MODE len must stay 2"; +} + +// get_tw(NULL) returns the literal value 0 (sys.c:672 `return 0;`), not a +// bare GP0(0xE2) command -- a null texture window becomes a GP0(0x00) NOP, +// which psx-spx notes is often inserted between Texpage and Rectangle +// commands anyway. +TEST_F(PsyqGpuTest, SetDrawModeWithNullTextureWindowEmitsZeroWord) { + psyq_register_libgpu_extras(); + const uint32_t p = 0x80100000u; + ctx.r[SP] = 0x801FFF00u; + ctx.r[A0] = p; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 0; + mem.write32(ctx.r[SP] + 16u, 0); // RECT *tw = NULL + + psyq_dispatch("libgpu_SetDrawMode", &ctx); + + EXPECT_EQ(mem.read32(p + 8), 0x00000000u); +} + +TEST_F(PsyqGpuTest, SetDrawModeEncodesTextureWindowRectIntoGp0Word) { + psyq_register_libgpu_extras(); + const uint32_t p = 0x80100000u; + const uint32_t tw = 0x80100100u; + ctx.r[SP] = 0x801FFF00u; + ctx.r[A0] = p; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 0; + writeRect(tw, /*x=*/8, /*y=*/16, /*w=*/64, /*h=*/32); + mem.write32(ctx.r[SP] + 16u, tw); + + psyq_dispatch("libgpu_SetDrawMode", &ctx); + + // maskX = (-w & 0xFF) >> 3 = (-64 & 0xFF) >> 3 = 0x18 + // maskY = (-h & 0xFF) >> 3 = (-32 & 0xFF) >> 3 = 0x1C + // offsX = (x & 0xFF) >> 3 = (8 & 0xFF) >> 3 = 0x01 + // offsY = (y & 0xFF) >> 3 = (16 & 0xFF) >> 3 = 0x02 + // word = E2000000 | offsY<<15 | offsX<<10 | maskY<<5 | maskX + const uint32_t expected = 0xE2000000u | (0x02u << 15) | (0x01u << 10) | + (0x1Cu << 5) | 0x18u; + EXPECT_EQ(mem.read32(p + 8), expected); +} + +// GetTPage +// +// GetTPage(tp, abr, x, y) packs a texpage description into the u_short that +// feeds SetDrawMode's `tpage` argument (see the SetDrawMode tests above) -- +// an error here reintroduces the same wrong-texpage symptom through a +// different HLE. Encoding confirmed against the psyz decomp reference +// (workspace clone, PS1Recomp-workspace/psyz/): GetTPage forwards directly +// to the getTPage() macro (decomp/src/libgpu/prim.c:5-6 -- `return +// getTPage(tp, abr, x, y);`), defined at +// chrono-cross-decomp/include/psyq/libgpu.h:262-264: +// #define getTPage(tp, abr, x, y) \ +// ((((tp) & 0x3) << 7) | (((abr) & 0x3) << 5) | (((y) & 0x100) >> 4) | \ +// (((x) & 0x3ff) >> 6) | (((y) & 0x200) << 2)) +// This is byte-for-byte what ps1Runtime's HLE already computes. +// +// Bit 11 (the `(y & 0x200) << 2` term) is psx-spx's GP0(E1h) "Texture page Y +// Base 2 (N*512), only for 2 MB VRAM" (workspace clone, +// PS1Recomp-workspace/psx-spx.github.io/docs/graphicsprocessingunitgpu.md:385, +// 899). It is part of the confirmed PsyQ macro -- the source does not mask +// it out -- so it stays. It is inert on this project's target: SetDrawMode +// already forwards it unmasked (`tpage & 0x9FF`), but gpu.cpp separately +// discards bit 11 when latching the mode word (`currentTexpage_ = cmd & +// 0x1FF`, gpu.cpp:766), and this target's y coordinates never reach 512, so +// no rendering path currently observes it (2026-07-22 project finding). +// Dropping the term here would be an undocumented deviation from the +// confirmed source, not a fix -- so the audit keeps it and documents why. + +namespace { +uint32_t referenceGetTPage(int tp, int abr, int x, int y) { + return static_cast(((tp & 0x3) << 7) | ((abr & 0x3) << 5) | + ((y & 0x100) >> 4) | ((x & 0x3FF) >> 6) | + ((y & 0x200) << 2)) & + 0xFFFFu; +} +} // namespace + +// x=0,y=0 / x=512,y=0 / x=768,y=384 are the real Crash Bandicoot texture +// page origins (see the SetDrawMode regression comment above). Sweep every +// tp (0-3) and abr (0-3) at each origin against the source-derived formula, +// not the implementation under test. +TEST_F(PsyqGpuTest, GetTPageEncodesRealCrashTexturePages) { + psyq_register_libgpu_extras(); + struct Origin { + int x, y; + }; + const Origin origins[] = {{0, 0}, {512, 0}, {768, 384}}; + for (const Origin &o : origins) { + for (int tp = 0; tp < 4; ++tp) { + for (int abr = 0; abr < 4; ++abr) { + ctx.reset(); + ctx.mem = &mem; + ctx.r[A0] = static_cast(tp); + ctx.r[A1] = static_cast(abr); + ctx.r[A2] = static_cast(o.x); + ctx.r[A3] = static_cast(o.y); + + psyq_dispatch("libgpu_GetTPage", &ctx); + + EXPECT_EQ(ctx.r[V0], referenceGetTPage(tp, abr, o.x, o.y)) + << "tp=" << tp << " abr=" << abr << " x=" << o.x + << " y=" << o.y; + } + } + } +} + +// Isolates the two y-derived bits the table sweep above exercises only in +// combination: bit 4 of the result (y's bit 8, in-range VRAM Y) and bit 11 +// (y's bit 9, the 2 MB-VRAM term kept per the source, see block comment +// above). +TEST_F(PsyqGpuTest, GetTPageEncodesYBit8AndBit11FromSource) { + psyq_register_libgpu_extras(); + struct Case { + int y; + uint32_t expected; + }; + const Case cases[] = { + {0, 0x0000u}, + {256, 0x0010u}, // y bit 8 -> result bit 4 + {512, 0x0800u}, // y bit 9 -> result bit 11 (2 MB VRAM term) + {768, 0x0810u}, // both bits set together + }; + for (const Case &c : cases) { + ctx.reset(); + ctx.mem = &mem; + ctx.r[A0] = 0; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = static_cast(c.y); + + psyq_dispatch("libgpu_GetTPage", &ctx); + + EXPECT_EQ(ctx.r[V0], c.expected) << "y=" << c.y; + } +} + +// PutDrawEnv +// +// PutDrawEnv(env) builds a DR_ENV command block via SetDrawEnv2 (psyz decomp +// src/libgpu/sys.c:362-373, 561-620) and queues it through addque2/cwc; the +// GP0(0xE1) word it produces comes from the same get_mode() helper +// SetDrawMode uses (sys.c:503-507 for SetDrawMode itself, get_mode at +// sys.c:628-631, confirmed applicable to this target in the SetDrawMode +// audit above): +// (dtd ? 0xE1000200 : 0xE1000000) | (dfe ? 0x400 : 0) | (tpage & 0x9FF) +// DRAWENV's field layout (chrono-cross-decomp/include/psyq/libgpu.h:361-371) +// confirms dfe lives +// at +0x17 (offset 23, right after dtd at +0x16/offset 22) -- both already +// documented in this file's SetDefDrawEnv comment. +// +// Audited 2026-07-28: the pre-audit hle_PutDrawEnv (psyq_hle.cpp) read only +// dtd and masked tpage with 0x7FF (bits 0-10) instead of 0x9FF (bits 0-8 + +// bit 11). Per psx-spx (GP0(E1h), workspace clone +// PS1Recomp-workspace/psx-spx.github.io/docs/graphicsprocessingunitgpu.md: +// 376-388), bits 9-10 of GP0(E1h) are exclusively "Dither" and "Drawing to +// display area allowed" -- supplied by dtd/dfe, never by the raw tpage +// value -- so 0x7FF let stray high bits of env->tpage leak into those two +// flags, and dfe (env->dfe, offset 23) was never read or encoded at all. +// This is the same mask this audit's Task 2 pass already applied to +// SetDrawMode (test_psyq_gpu.cpp above); PutDrawEnv had drifted from it. +// +// GP0(0xE2) Texture Window and GP0(0xE6) Mask Bit Setting: SetDrawEnv2 always +// emits both (sys.c:574, 575/601), and the header comment above already +// claimed GP0(0xE2) -- but the implementation never emitted either. Fixing +// that here would add GP0 words to hle_PutDrawEnv's output, which would +// break test_psyq_hle.cpp's PutDrawEnvEmitsGP0Commands / +// PutDrawEnvBottomRightEncoding (exact `gp0Words.size()==4` assertions) -- +// a file outside this task's edit scope. Left as a documented, confirmed gap +// for a follow-up that can touch that file; not fixed in this pass. + +TEST_F(PsyqGpuTest, PutDrawEnvMasksTpageAndDropsStrayDtdDfeBitsFromRawValue) { + const uint32_t env = 0x80100000u; + // clip/offset arbitrary but valid; the assertion is on the E1 word only. + writeRect(env, 0, 0, 320, 240); // clip.x/y/w/h at +0 + mem.write16(env + 8, 0); // ofs.x + mem.write16(env + 10, 0); // ofs.y + // tpage has every bit 0-10 set, including the two (9,10) that PsyQ + // reserves exclusively for dtd/dfe -- a real DRAWENV should never carry + // them, but nothing stops stray bits from surviving if the mask is wrong. + mem.write16(env + 20, 0x7FFu); // tpage + mem.write8(env + 22, 0); // dtd = 0 + mem.write8(env + 23, 0); // dfe = 0 + + ctx.r[A0] = env; + hle_PutDrawEnv(&ctx); + + // Task 4b (gap 2) reordered PutDrawEnv's GP0 words to match SetDrawEnv2 + // (sys.c:561-575: E3, E4, E5, E1, ...), so E1 is no longer gp0[0] -- find + // it by opcode instead of hardcoding a position that gap 2/3 now move. + auto e1 = std::find_if(gp0.begin(), gp0.end(), + [](uint32_t w) { return (w >> 24) == 0xE1u; }); + ASSERT_NE(e1, gp0.end()); + // get_mode(dfe=0, dtd=0, tpage=0x7FF) = 0xE1000000 | (0x7FF & 0x9FF) + // = 0xE10001FF + EXPECT_EQ(*e1, 0xE10001FFu) + << "tpage bits 9-10 (dtd/dfe-reserved) must not leak through unmasked"; +} + +TEST_F(PsyqGpuTest, PutDrawEnvEncodesDfeBitFromEnv) { + const uint32_t env = 0x80100000u; + writeRect(env, 0, 0, 320, 240); + mem.write16(env + 8, 0); + mem.write16(env + 10, 0); + mem.write16(env + 20, 0); // tpage = 0 + mem.write8(env + 22, 0); // dtd = 0 + mem.write8(env + 23, 1); // dfe = 1 (draw-to-display-area enable) + + ctx.r[A0] = env; + hle_PutDrawEnv(&ctx); + + // See PutDrawEnvMasksTpageAndDropsStrayDtdDfeBitsFromRawValue above: find + // E1 by opcode, its position moved with the Task 4b gap 2 reorder. + auto e1 = std::find_if(gp0.begin(), gp0.end(), + [](uint32_t w) { return (w >> 24) == 0xE1u; }); + ASSERT_NE(e1, gp0.end()); + // get_mode(dfe=1, dtd=0, tpage=0) = 0xE1000000 | 0x400 = 0xE1000400 + EXPECT_EQ(*e1, 0xE1000400u) + << "env->dfe (offset 23) must reach GP0(E1) bit 10, per get_mode"; +} + // Registry wiring TEST_F(PsyqGpuTest, RegistryDispatchesAllNewNames) { diff --git a/ps1Test/runtime/test_psyq_hle.cpp b/ps1Test/runtime/test_psyq_hle.cpp index 5570090..369f4b1 100644 --- a/ps1Test/runtime/test_psyq_hle.cpp +++ b/ps1Test/runtime/test_psyq_hle.cpp @@ -6,6 +6,7 @@ #include "runtime/cpu_context.h" #include "runtime/emuptr.h" #include "runtime/memory.h" +#include "runtime/psyq/psyq_libgpu.h" #include "runtime/psyq/psyq_state.h" #include #include @@ -227,6 +228,17 @@ TEST_F(PsyqHleTest, ClearOTagRNZeroIsNop) { } // DrawOTag +// +// Audited 2026-08-06 against psx-spx's DMA2 linked-list documentation +// (docs/dmachannels.md:173-190) and OT worked example +// (docs/graphicsprocessingunitgpu.md:1084-1136), cross-referenced with the +// psyz decomp (sys.c:354-360, 850-855) confirming DrawOTag(p) is +// _cwc(p) -- a DMA2 linked-list kick, so the CPU-side traversal below is +// this project's software model of what the GPU's own DMA controller does +// on real hardware. Verdict: already correct, no source-shape divergence +// found. The four tests below pin: empty-list no-op, single-node word push, +// multi-node traversal order, and (new) that the header word itself is +// link-chain metadata and must never be pushed to GP0. TEST_F(PsyqHleTest, DrawOTagEmptyListEmitsNothing) { // Write a single terminal node at 0x1000 @@ -275,6 +287,28 @@ TEST_F(PsyqHleTest, DrawOTagTraversesChain) { EXPECT_EQ(gp0Words[1], 0xAAAAAAAAu); // from B } +// dmachannels.md:173-190 and the OT worked example in +// graphicsprocessingunitgpu.md:1084-1136 agree: only the N words AFTER the +// header are DMA'd to GP0. The header word (word_count | next_ptr) is +// link-chain metadata consumed by the DMA controller, never a GP0 command. +TEST_F(PsyqHleTest, DrawOTagHeaderWordItselfIsNeverPushedToGp0) { + const uint32_t base = 0x1000; + // Header word deliberately holds a recognisable "poison" pattern so the + // test fails loudly if it ever leaks into gp0Words. + mem.write32(base + 0, (1u << 24) | 0x00FFFFFFu); + mem.write32(base + 4, 0xC0FFEEEEu); // the single real data word + + ctx.r[A0] = base; + hle_DrawOTag(&ctx); + + ASSERT_EQ(gp0Words.size(), 1u); + EXPECT_EQ(gp0Words[0], 0xC0FFEEEEu); + for (uint32_t w : gp0Words) { + EXPECT_NE(w, (1u << 24) | 0x00FFFFFFu) + << "header word must never be pushed to GP0"; + } +} + // SetDefDispEnv TEST_F(PsyqHleTest, SetDefDispEnvWritesFields) { @@ -297,7 +331,73 @@ TEST_F(PsyqHleTest, SetDefDispEnvWritesFields) { EXPECT_EQ(ctx.r[V0], env); // returns env ptr } +// ext.c:72-86 zeroes all four DISPENV.screen fields; it does NOT seed them +// from w/h. This was inert until PutDispEnv started building GP1(06)/(07) +// from .screen, at which point `screen.w = w` suppressed the source's own +// screen.w == 0 default. Poison the struct first: the fields are asserted to +// be zero, and a zeroed Memory would let a no-write pass by accident. +TEST_F(PsyqHleTest, SetDefDispEnvZeroesAllScreenFields) { + const uint32_t env = 0x3100; + for (uint32_t i = 0; i < 20; i += 2) + mem.write16(env + i, 0xBEEFu); + + ctx.r[A0] = env; + ctx.r[A1] = 0; // x + ctx.r[A2] = 0; // y + ctx.r[A3] = 320; // w + mem.write32(ctx.r[SP] + 16, 240); // h + + hle_SetDefDispEnv(&ctx); + + EXPECT_EQ(mem.read16(env + 8), 0u); // screen.x + EXPECT_EQ(mem.read16(env + 10), 0u); // screen.y + EXPECT_EQ(mem.read16(env + 12), 0u); // screen.w -- NOT w + EXPECT_EQ(mem.read16(env + 14), 0u); // screen.h -- NOT h +} + +// Nothing in the suite composed the two functions: every PutDispEnv test +// hand-writes .screen to the reference's zeros, so SetDefDispEnv seeding it +// with w/h went unnoticed. Run them back to back on one struct, the way a +// game does, and pin the horizontal range that comes out. +// reference: h_start = 0*10 + 0x260 = 608, screen.w == 0 -> h_end = +// 608 + 2560 = 3168 (sys.c:415-417) +// pre-fix: screen.w = 320 -> h_end = 608 + 3200 = 3808, clamped to 3290 +// The vertical axis agrees either way (16 + 240 = 256), which hid half of it. +TEST_F(PsyqHleTest, SetDefDispEnvThenPutDispEnvUsesReferenceScreenDefaults) { + const uint32_t env = 0x3200; + ctx.r[A0] = env; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 320; + mem.write32(ctx.r[SP] + 16, 240); + hle_SetDefDispEnv(&ctx); + + ctx.r[A0] = env; + hle_PutDispEnv(&ctx); + + ASSERT_EQ(gp1Words.size(), 4u); + const uint32_t gp1_06 = gp1Words[1]; + EXPECT_EQ(gp1_06 & 0xFFFu, 608u); // h_start + EXPECT_EQ((gp1_06 >> 12) & 0xFFFu, 3168u); // h_end + + const uint32_t gp1_07 = gp1Words[2]; + EXPECT_EQ(gp1_07 & 0x3FFu, 16u); + EXPECT_EQ((gp1_07 >> 10) & 0x3FFu, 256u); +} + // PutDispEnv +// +// Audited 2026-08-06, amended 2026-08-06 after review, against the psyz +// decomp reference (workspace clone, +// PS1Recomp-workspace/psyz/decomp/src/libgpu/sys.c:399-461, retail/1MB-VRAM +// branch -- info.version 0/3, the same branch this project's PutDrawEnv audit +// established as applicable to this target). Cross-checked against psx-spx +// (docs/graphicsprocessingunitgpu.md: GP1(05h) at line 694-701, GP1(08h) at +// line 770-779, GPUSTAT mapping at 897-905 -- corrected after review; an +// earlier revision of this comment cited "line 377+", which is +// GP0(E1h) Draw Mode, a different register) and against gpu.cpp's own GP1 +// consumer (case 0x05/0x08 in gpu.cpp, whose comment already states the +// correct GP1(08h) bit layout). TEST_F(PsyqHleTest, PutDispEnvEmitsFourGP1Commands) { const uint32_t env = 0x4000; @@ -320,7 +420,285 @@ TEST_F(PsyqHleTest, PutDispEnvEmitsFourGP1Commands) { EXPECT_EQ(gp1Words[3] >> 24, 0x08u); } +// GP1(0x05): sys.c:409-412 (retail branch) masks BOTH disp.x and disp.y with +// 0x3FF before packing -- (disp.y & 0x3FF) << 10 | (disp.x & 0x3FF). The +// pre-audit implementation masked vx but not vy. That is latent for the +// small in-range values real callers pass, but for a large/negative disp.y +// the unmasked shift can bleed into the 0x05 command nibble itself (bits +// 24-26), corrupting the command. Pin the masking directly rather than +// relying on gpu.cpp's consumer-side re-mask to hide the producer bug. +TEST_F(PsyqHleTest, PutDispEnvMasksBothXAndYIntoDisplayStart) { + const uint32_t env = 0x4100; + mem.write16(env + 0, static_cast(300)); // vx + mem.write16(env + 2, static_cast(-500)); // vy (out of 0-511 range) + mem.write16(env + 4, 320); + mem.write16(env + 6, 240); + mem.write8(env + 16, 0); + mem.write8(env + 17, 0); + + ctx.r[A0] = env; + hle_PutDispEnv(&ctx); + + ASSERT_GE(gp1Words.size(), 1u); + uint32_t gp1_05 = gp1Words[0]; + // Command nibble must stay 0x05 -- an unmasked negative vy shifted into + // bits 10+ can otherwise corrupt bits 24-26 and change the command. + EXPECT_EQ(gp1_05 >> 24, 0x05u); + uint32_t x = gp1_05 & 0x3FFu; + uint32_t y = (gp1_05 >> 10) & 0x3FFu; + EXPECT_EQ(x, 300u); + EXPECT_EQ(y, static_cast(-500) & 0x3FFu); +} + +// GP1(0x06)/(0x07): sys.c:413-427 (retail/NTSC branch, pad0=GetVideoMode()==0) +// compute the horizontal/vertical display range from DISPENV.screen (NOT +// .disp), with defaults (screen.w==0 -> 2560, screen.h==0 -> 240) and hard +// clamps. The pre-audit implementation fabricated a formula from disp.w/h +// instead (x1=0x260, x2=x1+disp.w*8; y1=0x88, y2=y1+disp.h) that does not +// match sys.c at all. +TEST_F(PsyqHleTest, PutDispEnvHorizontalVerticalRangeFromScreenDefaults) { + const uint32_t env = 0x4200; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, 320); + mem.write16(env + 6, 240); + mem.write16(env + 8, 0); // screen.x + mem.write16(env + 10, 0); // screen.y + mem.write16(env + 12, 0); // screen.w = 0 -> default 2560 (sys.c:417) + mem.write16(env + 14, 0); // screen.h = 0 -> default 240 (sys.c:418) + mem.write8(env + 16, 0); + mem.write8(env + 17, 0); + + ctx.r[A0] = env; + hle_PutDispEnv(&ctx); + + ASSERT_EQ(gp1Words.size(), 4u); + uint32_t gp1_06 = gp1Words[1]; + uint32_t hStart = gp1_06 & 0xFFFu; + uint32_t hEnd = (gp1_06 >> 12) & 0xFFFu; + // h_start = 0*10 + 0x260 = 608; h_end = 608 + 2560 = 3168 (both within clamp) + EXPECT_EQ(hStart, 608u); + EXPECT_EQ(hEnd, 3168u); + + uint32_t gp1_07 = gp1Words[2]; + uint32_t vStart = gp1_07 & 0x3FFu; + uint32_t vEnd = (gp1_07 >> 10) & 0x3FFu; + // v_start = 0 + 0x10 = 16; v_end = 16 + 240 = 256 (both within clamp) + EXPECT_EQ(vStart, 16u); + EXPECT_EQ(vEnd, 256u); +} + +// GP1(0x08): sys.c:404, 428-457 build the mode word from isrgb24 (bit4), +// isinter (bit5), disp.w thresholds (bits 0-1 + bit6 "Hres2"), and disp.h +// (bit2+bit5 combo for >256-line modes). Matches psx-spx's documented +// GP1(08h) bit layout, i.e. the SAME layout gpu.cpp's case 0x08 comment +// already assumes. The pre-audit implementation put isrgb24 at bit5 and +// isinter at bit6 (both wrong slots) and never set the video-mode/Hres2 +// bits, so the two implementations actively disagreed with each other. +TEST_F(PsyqHleTest, PutDispEnvDisplayModeBitsMatchGp1_08Layout) { + auto modeFor = [&](int16_t w, int16_t h, uint8_t isrgb24, uint8_t isinter) { + const uint32_t env = 0x4300; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, static_cast(w)); + mem.write16(env + 6, static_cast(h)); + mem.write16(env + 8, 0); + mem.write16(env + 10, 0); + mem.write16(env + 12, 0); + mem.write16(env + 14, 0); + mem.write8(env + 16, isinter); + mem.write8(env + 17, isrgb24); + gp1Words.clear(); + ctx.r[A0] = env; + hle_PutDispEnv(&ctx); + return gp1Words.back(); + }; + + // 256-wide, 240-tall, 15bpp, non-interlaced -> mode == 0 (no bits set) + EXPECT_EQ(modeFor(256, 240, 0, 0) & 0xFFu, 0x00u); + // 320-wide -> Hres1 bit0 + EXPECT_EQ(modeFor(320, 240, 0, 0) & 0xFFu, 0x01u); + // 368-wide -> Hres2 bit6, Hres1 stays 0 + EXPECT_EQ(modeFor(368, 240, 0, 0) & 0xFFu, 0x40u); + // 512-wide -> Hres1 bit1 + EXPECT_EQ(modeFor(512, 240, 0, 0) & 0xFFu, 0x02u); + // 640-wide -> Hres1 bits0+1 + EXPECT_EQ(modeFor(640, 240, 0, 0) & 0xFFu, 0x03u); + // isrgb24 -> bit4 + EXPECT_EQ(modeFor(256, 240, 1, 0) & 0xFFu, 0x10u); + // isinter -> bit5 + EXPECT_EQ(modeFor(256, 240, 0, 1) & 0xFFu, 0x20u); + // 480-tall -> bit2 + bit5 combo (0x24), needs interlace hardware-wise + EXPECT_EQ(modeFor(256, 480, 0, 0) & 0xFFu, 0x24u); + // Command nibble is always 0x08 + EXPECT_EQ(modeFor(320, 240, 0, 0) >> 24, 0x08u); +} + +// Review finding (Important 1): sys.c's own `CLAMP(h_end, h_start + 0x50, +// 3290)` call (sys.c:417) can be reached with its min argument exceeding its +// max whenever the unclamped h_start is already close to the 3290 ceiling -- +// reachable from screen.x >= 269, an in-range int16 value read straight out +// of game RAM (envPtr+8). CLAMP's ternary (common.h:25) is well-defined +// there (the `x < min` branch wins regardless of min-vs-max), but the +// pre-fix std::clamp() had a `!(hi < lo)` precondition and was UB on this +// input. This test exercises exactly that input and pins the well-defined, +// source-faithful (sourceClamp) result rather than crashing. +TEST_F(PsyqHleTest, PutDispEnvHandlesScreenXNearCeilingWithoutUndefinedBehavior) { + const uint32_t env = 0x4400; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, 320); + mem.write16(env + 6, 240); + mem.write16(env + 8, 269); // screen.x -- unclamped h_start = 269*10+0x260 = 3298 > 3290 + mem.write16(env + 10, 0); // screen.y + mem.write16(env + 12, 1); // screen.w = 1 -> unclamped h_end = 3298+10 = 3308 + mem.write16(env + 14, 0); // screen.h = 0 -> default + mem.write8(env + 16, 0); + mem.write8(env + 17, 0); + + ctx.r[A0] = env; + hle_PutDispEnv(&ctx); // must not abort/UB + + ASSERT_EQ(gp1Words.size(), 4u); + uint32_t gp1_06 = gp1Words[1]; + uint32_t hStart = gp1_06 & 0xFFFu; + uint32_t hEnd = (gp1_06 >> 12) & 0xFFFu; + // h_start: 3298 > 3290 -> clamped to 3290. + EXPECT_EQ(hStart, 3290u); + // h_end: unclamped 3308 < (clamped h_start + 0x50) = 3370, so CLAMP's + // `x < min` branch returns min = 3370 -- which is itself > the 3290 max, + // matching sys.c's own CLAMP macro exactly (bug-compatible by design). + EXPECT_EQ(hEnd, 3370u); +} + +// Review finding (Important 3): sys.c branches on GetVideoMode() (env->pad0) +// for the v_start offset (0x10/0x13), the v-range clamp ceiling (256,258 / +// 310,312), the video-mode bit (GP1(08h) bit3), and the Vres/interlace +// height threshold (256/288). A pre-amendment revision of this HLE assumed +// NTSC unconditionally; PAL is reachable via libetc_SetVideoMode +// (hle_libgpu_SetVideoMode, psyq_libgpu.cpp), already round-tripped by +// PsyqGpuTest.SetGetVideoModeRoundTrip (test_psyq_gpu.cpp). +TEST_F(PsyqHleTest, PutDispEnvHonoursPalVideoModeForRangeAndModeBit) { + ctx.r[A0] = 1; // PAL + hle_libgpu_SetVideoMode(&ctx); + + const uint32_t env = 0x4500; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, 320); + mem.write16(env + 6, 280); // disp.h: > 256 (NTSC threshold) but <= 288 (PAL threshold) + mem.write16(env + 8, 0); // screen.x + mem.write16(env + 10, 0); // screen.y + mem.write16(env + 12, 0); // screen.w = 0 -> default + mem.write16(env + 14, 0); // screen.h = 0 -> default + mem.write8(env + 16, 0); + mem.write8(env + 17, 0); + + ctx.r[A0] = env; + hle_PutDispEnv(&ctx); + + ASSERT_EQ(gp1Words.size(), 4u); + uint32_t gp1_07 = gp1Words[2]; + uint32_t vStart = gp1_07 & 0x3FFu; + // v_start = screen.y + 0x13 (PAL offset, not the NTSC 0x10) = 19. + EXPECT_EQ(vStart, 19u); + + uint32_t mode = gp1Words[3] & 0xFFu; + // Video mode bit (bit3) must be set for PAL. + EXPECT_EQ(mode & 0x08u, 0x08u); + // disp.h=280 is <= the PAL threshold (288), so the Vres/interlace combo + // (bit2+bit5) must NOT be set here, unlike the NTSC threshold (256) + // which PutDispEnvDisplayModeBitsMatchGp1_08Layout already pins at h=480. + EXPECT_EQ(mode & 0x24u, 0u); + + // Restore to NTSC so other tests start clean (same pattern as + // PsyqGpuTest.SetGetVideoModeRoundTrip). + ctx.r[A0] = 0; + hle_libgpu_SetVideoMode(&ctx); +} + +// `DISPENV* PutDispEnv(DISPENV* env)` returns env (sys.c:399, 460). The HLE +// left V0 untouched, so a caller doing `disp = PutDispEnv(disp)` picked up +// whatever the previous call left in the register. +TEST_F(PsyqHleTest, PutDispEnvReturnsEnvPointer) { + const uint32_t env = 0x4600; + ctx.r[A0] = env; + ctx.r[V0] = 0xDEADBEEFu; + hle_PutDispEnv(&ctx); + EXPECT_EQ(ctx.r[V0], env); +} + // SetDefDrawEnv +// +// Audited 2026-08-06 against the psyz decomp reference (workspace clone, +// PS1Recomp-workspace/psyz/decomp/src/libgpu/ext.c:46-69, +// `DRAWENV* SetDefDrawEnv(DRAWENV* env, int x, int y, int w, int h)`): +// env->tpage = getTPage(0, 0, 640, 0) -- NOT 0 +// env->dfe = video_mode ? h<=288 : h<=256 -- NOT always 0 +// This project has no PAL path (GetVideoMode() defaults to, and stays, 0 / +// NTSC -- see hle_libgpu_GetVideoMode), so the NTSC branch (h<=256) applies. +// getTPage(0,0,640,0), using this project's own already-audited GetTPage +// formula (psyq_libgpu.cpp, cited in the SetDrawMode audit), evaluates to +// (640 & 0x3FF) >> 6 = 0x0A -- the pre-audit implementation hardcoded 0. + +TEST_F(PsyqHleTest, SetDefDrawEnvWritesTexturePageFromGetTPage640Origin) { + const uint32_t env = 0x5100; + ctx.r[A0] = env; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 320; + mem.write32(ctx.r[SP] + 16, 240); + + hle_SetDefDrawEnv(&ctx); + + // ext.c:67 -- env->tpage = getTPage(0, 0, 640, 0) = 0x0A, independent of + // the x/y/w/h arguments passed to SetDefDrawEnv itself. + EXPECT_EQ(mem.read16(env + 20), 0x0Au); +} + +TEST_F(PsyqHleTest, SetDefDrawEnvSetsDfeFromHeightNtscThreshold) { + // ext.c:60-64 -- NTSC branch: dfe = (h <= 256). + const uint32_t envShort = 0x5200; + ctx.r[A0] = envShort; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 320; + mem.write32(ctx.r[SP] + 16, 240); // h = 240 <= 256 -> dfe = 1 + hle_SetDefDrawEnv(&ctx); + EXPECT_EQ(mem.read8(envShort + 23), 1u); + + const uint32_t envTall = 0x5300; + ctx.r[A0] = envTall; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 320; + mem.write32(ctx.r[SP] + 16, 480); // h = 480 > 256 -> dfe = 0 + hle_SetDefDrawEnv(&ctx); + EXPECT_EQ(mem.read8(envTall + 23), 0u); +} + +// Review finding (Important 3): ext.c:60-64 branches on GetVideoMode() for +// the dfe threshold (video_mode ? h<=288 : h<=256). A pre-amendment revision +// assumed NTSC unconditionally; PAL is reachable via libetc_SetVideoMode. +TEST_F(PsyqHleTest, SetDefDrawEnvSetsDfeFromHeightPalThreshold) { + ctx.r[A0] = 1; // PAL + hle_libgpu_SetVideoMode(&ctx); + + const uint32_t env = 0x5400; + ctx.r[A0] = env; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 320; + // h = 280: > NTSC threshold (256) but <= PAL threshold (288) -- if the + // video-mode branch were still dropped this would wrongly read dfe = 0. + mem.write32(ctx.r[SP] + 16, 280); + hle_SetDefDrawEnv(&ctx); + EXPECT_EQ(mem.read8(env + 23), 1u); + + // Restore to NTSC so other tests start clean. + ctx.r[A0] = 0; + hle_libgpu_SetVideoMode(&ctx); +} TEST_F(PsyqHleTest, SetDefDrawEnvWritesClipAndOffset) { const uint32_t env = 0x5000; @@ -339,10 +717,43 @@ TEST_F(PsyqHleTest, SetDefDrawEnvWritesClipAndOffset) { EXPECT_EQ(mem.read16(env + 8), 0u); // ofs.x = clip.x EXPECT_EQ(mem.read16(env + 10), 0u); // ofs.y = clip.y EXPECT_EQ(mem.read8(env + 22), 1u); // dtd = 1 (dithering on by default) - EXPECT_EQ(mem.read8(env + 23), 0u); // dfe = 0 + // dfe = h<=256 on the NTSC branch (ext.c:60-64); h=240 here -> dfe=1. + // (Corrected 2026-08-06: this assertion previously pinned the pre-audit + // implementation's hardcoded dfe=0, which did not match the source.) + EXPECT_EQ(mem.read8(env + 23), 1u); EXPECT_EQ(ctx.r[V0], env); } +// ext.c:56-58 and ext.c:68 also zero the last four bytes of the struct: +// `env->r0 = 0; env->g0 = 0; env->b0 = 0;` and `env->isbg = 0`. Offsets from +// the DRAWENV layout (chrono-cross-decomp/include/psyq/libgpu.h:361-371 -- +// isbg at :368 (+24), r0/g0/b0 at :369 (+25..+27)), the same layout the +// tpage/dtd/dfe assertions above follow. The +// implementation wrote +0..+23 and stopped, so a stack-allocated DRAWENV kept +// stack garbage in the auto-clear flag and its background colour. Poison +// first -- Memory starts zeroed, so an unpoisoned struct would pass either +// way. +TEST_F(PsyqHleTest, SetDefDrawEnvZeroesIsbgAndBackgroundColour) { + const uint32_t env = 0x5100; + mem.write8(env + 24, 0xAAu); // isbg + mem.write8(env + 25, 0xBBu); // r0 + mem.write8(env + 26, 0xCCu); // g0 + mem.write8(env + 27, 0xDDu); // b0 + + ctx.r[A0] = env; + ctx.r[A1] = 0; + ctx.r[A2] = 0; + ctx.r[A3] = 320; + mem.write32(ctx.r[SP] + 16, 240); + + hle_SetDefDrawEnv(&ctx); + + EXPECT_EQ(mem.read8(env + 24), 0u); // isbg + EXPECT_EQ(mem.read8(env + 25), 0u); // r0 + EXPECT_EQ(mem.read8(env + 26), 0u); // g0 + EXPECT_EQ(mem.read8(env + 27), 0u); // b0 +} + // PutDrawEnv TEST_F(PsyqHleTest, PutDrawEnvEmitsGP0Commands) { @@ -360,12 +771,107 @@ TEST_F(PsyqHleTest, PutDrawEnvEmitsGP0Commands) { ctx.r[A0] = env; hle_PutDrawEnv(&ctx); - // Expect GP0(0xE1), GP0(0xE3), GP0(0xE4), GP0(0xE5) - ASSERT_EQ(gp0Words.size(), 4u); - EXPECT_EQ(gp0Words[0] >> 24, 0xE1u); // tpage - EXPECT_EQ(gp0Words[1] >> 24, 0xE3u); // draw area top-left - EXPECT_EQ(gp0Words[2] >> 24, 0xE4u); // draw area bottom-right - EXPECT_EQ(gp0Words[3] >> 24, 0xE5u); // draw offset + // Expect GP0(0xE3), GP0(0xE4), GP0(0xE5), GP0(0xE1), GP0(0xE2), GP0(0xE6), + // in SetDrawEnv2's order (sys.c:561-575); see + // PutDrawEnvEmitsWordsInSetDrawEnv2Order below for the order rationale, + // and PutDrawEnvTextureWindowEncoding for the GP0(0xE2) bit layout. + ASSERT_EQ(gp0Words.size(), 6u); + EXPECT_EQ(gp0Words[0] >> 24, 0xE3u); // draw area top-left + EXPECT_EQ(gp0Words[1] >> 24, 0xE4u); // draw area bottom-right + EXPECT_EQ(gp0Words[2] >> 24, 0xE5u); // draw offset + EXPECT_EQ(gp0Words[3] >> 24, 0xE1u); // tpage + EXPECT_EQ(gp0Words[4] >> 24, 0xE2u); // texture window + EXPECT_EQ(gp0Words[5] >> 24, 0xE6u); // mask bit setting +} + +// SetDrawEnv2 (sys.c:561-575) pushes GP0 words in this exact order: +// get_cs -> E3, get_ce -> E4, get_ofs -> E5, get_mode -> E1, get_tw -> E2, +// literal 0xE6000000 -> E6. +// On real hardware this order matters: the GPU applies each attribute as its +// word arrives, so E1 (draw mode) landing after E3/E4 (clip area) means the +// draw mode takes effect only once the clip area is already set, not before. +// The pre-fix implementation emitted E1 first (E1, E3, E4, E5) and never +// emitted E2/E6 at all. +TEST_F(PsyqHleTest, PutDrawEnvEmitsWordsInSetDrawEnv2Order) { + const uint32_t env = 0x7300; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, 320); + mem.write16(env + 6, 240); + mem.write16(env + 8, 0); + mem.write16(env + 10, 0); + mem.write16(env + 20, 0); + mem.write8(env + 22, 0); + + ctx.r[A0] = env; + hle_PutDrawEnv(&ctx); + + ASSERT_EQ(gp0Words.size(), 6u); + EXPECT_EQ(gp0Words[0] >> 24, 0xE3u) << "get_cs first"; + EXPECT_EQ(gp0Words[1] >> 24, 0xE4u) << "get_ce second"; + EXPECT_EQ(gp0Words[2] >> 24, 0xE5u) << "get_ofs third"; + EXPECT_EQ(gp0Words[3] >> 24, 0xE1u) << "get_mode fourth"; + EXPECT_EQ(gp0Words[4] >> 24, 0xE2u) << "get_tw fifth"; + EXPECT_EQ(gp0Words[5] >> 24, 0xE6u) << "literal 0xE6000000 last, per sys.c:561-575"; +} + +// GP0(0xE2): sys.c's get_tw (662-673), called as get_tw(&env->tw) -- always +// the non-NULL branch here, since env->tw is a struct field, not a pointer: +// code[0] = (rect->x & 0xFF) >> 3; code[2] = (-rect->w & 0xFF) >> 3; +// code[1] = (rect->y & 0xFF) >> 3; code[3] = (-rect->h & 0xFF) >> 3; +// return 0xE2000000 | (code[1]<<15) | (code[0]<<10) | (code[3]<<5) | code[2]; +// env->tw is the RECT at DRAWENV offset +12 (x,y,w,h, each int16 -- the same +// offset this file's SetDefDrawEnv tests already exercise). Cross-checked +// against psx-spx's documented GP0(E2h) bit layout (workspace clone +// PS1Recomp-workspace/psx-spx.github.io/docs/graphicsprocessingunitgpu.md: +// 406-414): bits 0-4 Mask X, 5-9 Mask Y, 10-14 Offset X, 15-19 Offset Y -- +// matching code[2]@0-4, code[3]@5-9, code[0]@10-14, code[1]@15-19 above. The +// pre-fix implementation never emitted this word at all. +TEST_F(PsyqHleTest, PutDrawEnvTextureWindowEncoding) { + const uint32_t env = 0x7400; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, 320); + mem.write16(env + 6, 240); + mem.write16(env + 8, 0); + mem.write16(env + 10, 0); + mem.write16(env + 12, 8); // tw.x + mem.write16(env + 14, 16); // tw.y + mem.write16(env + 16, 32); // tw.w + mem.write16(env + 18, 64); // tw.h + mem.write16(env + 20, 0); + mem.write8(env + 22, 0); + + ctx.r[A0] = env; + hle_PutDrawEnv(&ctx); + + ASSERT_EQ(gp0Words.size(), 6u); + // code[0]=(8&0xFF)>>3=1, code[1]=(16&0xFF)>>3=2, + // code[2]=(-32&0xFF)>>3=28, code[3]=(-64&0xFF)>>3=24 + // -> 0xE2000000 | (2<<15) | (1<<10) | (24<<5) | 28 = 0xE201071C + EXPECT_EQ(gp0Words[4], 0xE201071Cu); +} + +// GP0(0xE6): SetDrawEnv2 (sys.c:575) always pushes the literal 0xE6000000 -- +// no computation, no dependency on any DRAWENV field. Confirmed against +// psx-spx's documented GP0(E6h) layout (graphicsprocessingunitgpu.md:463-468): +// bits 0-1 are Set-mask-while-drawing / Check-mask-before-draw, both 0 here. +TEST_F(PsyqHleTest, PutDrawEnvMaskBitSettingIsAlwaysLiteralE6) { + const uint32_t env = 0x7500; + mem.write16(env + 0, 0); + mem.write16(env + 2, 0); + mem.write16(env + 4, 320); + mem.write16(env + 6, 240); + mem.write16(env + 8, 0); + mem.write16(env + 10, 0); + mem.write16(env + 20, 0); + mem.write8(env + 22, 0); + + ctx.r[A0] = env; + hle_PutDrawEnv(&ctx); + + ASSERT_EQ(gp0Words.size(), 6u); + EXPECT_EQ(gp0Words[5], 0xE6000000u); } TEST_F(PsyqHleTest, PutDrawEnvBottomRightEncoding) { @@ -383,11 +889,143 @@ TEST_F(PsyqHleTest, PutDrawEnvBottomRightEncoding) { ctx.r[A0] = env; hle_PutDrawEnv(&ctx); - // GP0(0xE4): bottom-right = (w-1, h-1) = (255, 239) + // GP0(0xE4): bottom-right = (w-1, h-1) = (255, 239). Index 1: emission + // order is E3, E4, E5, E1 (sys.c:561-575). // bits: [19:10]=y, [9:0]=x - uint32_t e4 = gp0Words[2]; + uint32_t e4 = gp0Words[1]; uint32_t bx = e4 & 0x3FF; uint32_t by = (e4 >> 10) & 0x1FF; EXPECT_EQ(bx, 255u); EXPECT_EQ(by, 239u); } + +// GP0(0xE3)/(0xE4): sys.c's get_cs (634-641) and get_ce (643-651), retail +// branch, both do `x = CLAMP(x, 0, info.w - 1); y = CLAMP(y, 0, info.h - 1);` +// before packing `(y & 0x3FF) << 10 | (x & 0x3FF)`, where info.w/info.h are +// 1024/512 for info.version 0/3 (sys.c:107-108, 170-171 -- the retail/ +// 1MB-VRAM branch this project's SetDrawMode/PutDrawEnv audits already use). +// The pre-fix implementation neither clamped nor masked y correctly (0x1FF +// instead of 0x3FF), so an out-of-range clip rect leaked raw, unclamped bits +// into the GP0 words instead of being pinned to the VRAM edge like real +// hardware. clip.x/y here are negative and clip.w/h push the bottom-right +// corner past both VRAM edges, so every one of those bugs is reachable in a +// single call. +TEST_F(PsyqHleTest, PutDrawEnvClampsClipAreaToVramBounds) { + const uint32_t env = 0x7100; + mem.write16(env + 0, static_cast(-10)); // clip.x (< 0) + mem.write16(env + 2, static_cast(-5)); // clip.y (< 0) + mem.write16(env + 4, 2000); // clip.w -- pushes x2 = -10+2000-1 = 1989 > 1023 + mem.write16(env + 6, 2000); // clip.h -- pushes y2 = -5+2000-1 = 1994 > 511 + mem.write16(env + 8, 0); + mem.write16(env + 10, 0); + mem.write16(env + 20, 0); + mem.write8(env + 22, 0); + + ctx.r[A0] = env; + hle_PutDrawEnv(&ctx); + + ASSERT_GE(gp0Words.size(), 2u); + // Emission order is E3, E4, E5, E1 (sys.c:561-575). + uint32_t e3 = gp0Words[0]; + uint32_t e4 = gp0Words[1]; + // get_cs(-10, -5) clamps both to 0 -> top-left pinned to VRAM origin. + EXPECT_EQ(e3 & 0x3FFu, 0u); + EXPECT_EQ((e3 >> 10) & 0x3FFu, 0u); + // get_ce(1989, 1994) clamps to (info.w-1, info.h-1) = (1023, 511). + EXPECT_EQ(e4 & 0x3FFu, 1023u); + EXPECT_EQ((e4 >> 10) & 0x3FFu, 511u); +} + +// `DRAWENV* PutDrawEnv(DRAWENV* env)` returns env (sys.c:362, 372). The HLE +// left V0 untouched, so a caller doing `draw = PutDrawEnv(draw)` picked up +// whatever the previous call left in the register. +TEST_F(PsyqHleTest, PutDrawEnvReturnsEnvPointer) { + const uint32_t env = 0x6600; + ctx.r[A0] = env; + ctx.r[V0] = 0xDEADBEEFu; + hle_PutDrawEnv(&ctx); + EXPECT_EQ(ctx.r[V0], env); +} + +// Root-counter tick accumulator +// +// Games that drive their frame delta off a root counter arm `SetRCnt` and +// register an interrupt handler that increments a word in their own BSS. +// This runtime models interrupts cooperatively and never fires that handler, +// so without `applyRootCounterTicks` the word stays frozen and every +// time-driven wait in the game crawls. +// +// Crash Bandicoot (SCUS-94900) is the measured case: `0x800165A8` opens event +// class 0xF2000002 (Root Counter 2) with handler `0x80034504` -- a leaf that +// does `*(0x80034520) += 1` -- and arms the counter with target 0x1000. At +// sysclk/8 (4233600 Hz) that fires 4233600/4096 = ~1033 times a second, i.e. +// ~17 per VBlank. Advancing 1 per VBlank instead ran the game ~18x too slow: +// `frames_elapsed` measured 69 in 40 s versus 1261 with the correct rate. + +TEST_F(PsyqHleTest, RootCounterTicksDisabledByDefault) { + const uint32_t addr = 0x80034520u; + mem.write32(addr, 0); + applyRootCounterTicks(&ctx, 10); + EXPECT_EQ(mem.read32(addr), 0u) + << "no [timing] config means the word must not be touched"; +} + +TEST_F(PsyqHleTest, RootCounterTicksAddRatePerVBlank) { + const uint32_t addr = 0x80034520u; + psyq_state().rcntTickAddr = addr; + psyq_state().rcntTicksPerVBlank = 17; + mem.write32(addr, 0); + + applyRootCounterTicks(&ctx, 1); + EXPECT_EQ(mem.read32(addr), 17u); + + applyRootCounterTicks(&ctx, 3); + EXPECT_EQ(mem.read32(addr), 17u + 51u) << "must accumulate, not overwrite"; +} + +TEST_F(PsyqHleTest, RootCounterTicksPreserveExistingValue) { + // The game writes this word itself on some paths (Crash: `+= 34` at + // 0x80017180, and a timer read at 0x800171FC), so the accumulator must + // build on whatever is already there rather than assume it owns the slot. + const uint32_t addr = 0x80034520u; + psyq_state().rcntTickAddr = addr; + psyq_state().rcntTicksPerVBlank = 17; + mem.write32(addr, 1000); + applyRootCounterTicks(&ctx, 2); + EXPECT_EQ(mem.read32(addr), 1034u); +} + +TEST_F(PsyqHleTest, RootCounterTicksZeroVBlanksIsNoop) { + const uint32_t addr = 0x80034520u; + psyq_state().rcntTickAddr = addr; + psyq_state().rcntTicksPerVBlank = 17; + mem.write32(addr, 500); + applyRootCounterTicks(&ctx, 0); + EXPECT_EQ(mem.read32(addr), 500u); +} + +TEST_F(PsyqHleTest, RootCounterTicksRateZeroIsNoop) { + const uint32_t addr = 0x80034520u; + psyq_state().rcntTickAddr = addr; + psyq_state().rcntTicksPerVBlank = 0; + mem.write32(addr, 500); + applyRootCounterTicks(&ctx, 5); + EXPECT_EQ(mem.read32(addr), 500u); +} + +TEST_F(PsyqHleTest, VSyncAdvancesRootCounterTicksByVBlanksWaited) { + // The real path: hle_VSync waits for N VBlanks and must advance the tick + // word by exactly the number it actually observed, not by a fixed amount. + const uint32_t addr = 0x80034520u; + psyq_state().rcntTickAddr = addr; + psyq_state().rcntTicksPerVBlank = 17; + mem.write32(addr, 0); + + const uint32_t start = psyq_state().vsyncCounter.load(); + ctx.r[A0] = 3; + hle_VSync(&ctx); + const uint32_t waited = psyq_state().vsyncCounter.load() - start; + + ASSERT_GE(waited, 3u); + EXPECT_EQ(mem.read32(addr), 17u * waited); +} diff --git a/ps1Test/runtime/test_spu.cpp b/ps1Test/runtime/test_spu.cpp index b790804..6ecda54 100644 --- a/ps1Test/runtime/test_spu.cpp +++ b/ps1Test/runtime/test_spu.cpp @@ -1,9 +1,16 @@ #include "runtime/spu/spu.h" +#include #include using namespace ps1::spu; // ADPCM Decoding Tests +// +// Vectors are hand-worked from the psx-spx SPU-ADPCM decode formula +// (PS1Recomp-workspace/psx-spx.github.io/docs/cdromformat.md:827-838, +// coefficient tables at :843-844) applied to the SPU block layout +// (soundprocessingunitspu.md:116-125). See the derivation notes in +// .superpowers/sdd/phase-3-crash-jogavel/task-S1-report.md. class SpuAdpcmTest : public ::testing::Test { protected: @@ -11,6 +18,66 @@ class SpuAdpcmTest : public ::testing::Test { void SetUp() override { spu.reset(); } }; +TEST_F(SpuAdpcmTest, DecodesFullNibbleRangeWithFilterZero) { + // header = filter 0, raw shift 12 (=> effective shift 12-12=0). With + // filter 0, f0=f1=0 (cdromformat.md:843-844), so per the formula + // s = (t<<0) + (old*0+older*0+32)/64 = t + 0 (integer division truncates + // 32/64 toward zero) -- i.e. the decoded output is exactly the + // sign-extended nibble sequence. This isolates nibble sign-extension from + // the prediction filter. + uint8_t block[16] = {}; + block[0] = 0x0C; // filter=0, shift=12 + block[1] = 0x00; // flags: normal + + // Nibble sequence -8..7 repeated (28 values), covering every 4-bit + // pattern including the ones that must sign-extend to negative (8..15). + // Packed 2 nibbles/byte, low nibble first (soundprocessingunitspu.md:121: + // "LSBs=1st Sample, MSBs=2nd Sample"). + const uint8_t dataBytes[14] = {0x98, 0xBA, 0xDC, 0xFE, 0x10, 0x32, 0x54, + 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x10, 0x32}; + std::memcpy(block + 2, dataBytes, 14); + + auto samples = spu.decodeAdpcmBlockForTest(block, /*prevSample1=*/0, + /*prevSample2=*/0); + + const int16_t expected[28] = {-8, -7, -6, -5, -4, -3, -2, -1, 0, 1, + 2, 3, 4, 5, 6, 7, -8, -7, -6, -5, + -4, -3, -2, -1, 0, 1, 2, 3}; + for (int i = 0; i < 28; i++) { + EXPECT_EQ(samples[i], expected[i]) << "sample index " << i; + } +} + +TEST_F(SpuAdpcmTest, AppliesFilterOneCoefficientsToPrediction) { + // Same nibble data as above, but filter=1 (f0=+60, f1=0 per + // cdromformat.md:843-844) with raw shift 12 (effective shift 0), so the + // IIR prediction term (old*f0 + older*f1 + 32) / 64 is exercised. + // Expected values derived by applying cdromformat.md:827-838 verbatim + // (integer division truncating toward zero, matching C++ int division): + // s0 = -8 + (0*60+0*0+32)/64 = -8 + 0 = -8 + // s1 = -7 + (-8*60+0*0+32)/64 = -7 + (-448/64=-7) = -14 + // s2 = -6 + (-14*60+(-8)*0+32)/64 = -6 + (-808/64=-12) = -18 + // ... (full 28-sample sequence computed with the same recurrence) + uint8_t block[16] = {}; + block[0] = 0x1C; // filter=1, shift=12 (effective shift 0) + block[1] = 0x00; + + const uint8_t dataBytes[14] = {0x98, 0xBA, 0xDC, 0xFE, 0x10, 0x32, 0x54, + 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x10, 0x32}; + std::memcpy(block + 2, dataBytes, 14); + + auto samples = spu.decodeAdpcmBlockForTest(block, /*prevSample1=*/0, + /*prevSample2=*/0); + + const int16_t expected[28] = {-8, -14, -18, -21, -23, -24, -24, -23, + -21, -18, -14, -9, -3, 3, 9, 15, + 6, -1, -6, -10, -12, -13, -13, -12, + -10, -7, -4, 0}; + for (int i = 0; i < 28; i++) { + EXPECT_EQ(samples[i], expected[i]) << "sample index " << i; + } +} + TEST_F(SpuAdpcmTest, SilentBlockDecodesZeros) { // Set up a silent ADPCM block (all zeros) at address 0 uint8_t *ram = spu.soundRamPtr(); @@ -80,6 +147,104 @@ TEST_F(SpuAdpcmTest, MultipleBlocksDontCrash) { EXPECT_TRUE(true); } +// ADPCM Loop/End Flag Tests +// +// Flag bits and the Code0-3 combinations are documented at +// soundprocessingunitspu.md:128-141. Cross-checked against +// CrashBandicoot-Launcher/RecompOne.Runtime/Hardware/Spu.cs:509-525 +// (DecodeBlock), which unconditionally jumps CurAddr to RepeatAddr on the +// End flag (bit 0), regardless of the Repeat bit (bit 1); bit 1 only +// decides whether the envelope is also forced to Release/zero. +// +// A single generateSamples(buf, 1) call is enough to trigger exactly one +// advanceAdpcmBlock(): Key On forces decodedIndex to ADPCM_SAMPLES_PER_BLOCK, +// and pitch=0x1000 (1.0) advances decodedIndex by exactly one per output +// sample. + +class SpuAdpcmLoopFlagTest : public ::testing::Test { +protected: + SPU spu; + void SetUp() override { spu.reset(); } + + void writeBlockHeader(uint32_t byteAddr, uint8_t shiftFilter, + uint8_t flags) { + uint8_t *ram = spu.soundRamPtr(); + ram[byteAddr] = shiftFilter; + ram[byteAddr + 1] = flags; + std::memset(ram + byteAddr + 2, 0, 14); + } +}; + +TEST_F(SpuAdpcmLoopFlagTest, Code0NormalAdvancesToNextBlockOnly) { + writeBlockHeader(0, 0x00, 0x00); // filter=0,shift=0, flags=Normal + + spu.writeRegister(0x1F801C06, 0); // start addr = 0 + spu.writeRegister(0x1F801C04, 0x1000); // pitch = 1.0 + spu.writeRegister(0x1F801D88, 0x0001); // key on voice 0 + + int16_t buffer[2] = {}; + spu.generateSamples(buffer, 1); + + auto state = spu.debugVoiceState(0); + EXPECT_EQ(state.currentAddr, 16u); // advanced by one 16-byte block only + EXPECT_FALSE(state.endFlag); + EXPECT_FALSE(state.loopFlag); +} + +TEST_F(SpuAdpcmLoopFlagTest, LoopStartBitLatchesCurrentBlockAddress) { + // Voice starts at block address 16 (startAddr=2, in 8-byte units). + writeBlockHeader(16, 0x00, 0x04); // flags: Loop Start + + spu.writeRegister(0x1F801C06, 2); // start addr = 2 (=> byte addr 16) + spu.writeRegister(0x1F801C04, 0x1000); + spu.writeRegister(0x1F801D88, 0x0001); + + int16_t buffer[2] = {}; + spu.generateSamples(buffer, 1); + + // soundprocessingunitspu.md:100-102: "If the hardware finds an ADPCM + // header with Loop-Start-Bit, then it copies the current address to the + // repeat address register" -- current block's own address (16 bytes = + // 2 in 8-byte units), not the next block. + EXPECT_EQ(spu.readRegister(0x1F801C0E), 2); +} + +TEST_F(SpuAdpcmLoopFlagTest, Code1EndMuteJumpsToRepeatAddressAndReleases) { + writeBlockHeader(0, 0x00, 0x01); // flags: Loop End, bit1=0 => Code 1 + + spu.writeRegister(0x1F801C06, 0); // start addr = 0 + spu.writeRegister(0x1F801C0E, 5); // repeat addr = 5 (=> byte addr 40) + spu.writeRegister(0x1F801C04, 0x1000); // pitch = 1.0 + spu.writeRegister(0x1F801D88, 0x0001); // key on voice 0 + + int16_t buffer[2] = {}; + spu.generateSamples(buffer, 1); + + auto state = spu.debugVoiceState(0); + EXPECT_EQ(state.currentAddr, 40u); // jumped to repeatAddr*8, not addr+16 + EXPECT_TRUE(state.endFlag); + EXPECT_TRUE(state.loopFlag); + EXPECT_EQ(spu.readRegister(0x1F801D9C) & 0x1, 1); // ENDX bit 0 set +} + +TEST_F(SpuAdpcmLoopFlagTest, Code3EndRepeatJumpsToRepeatAddress) { + writeBlockHeader(0, 0x00, 0x03); // flags: Loop End + Loop Repeat => Code 3 + + spu.writeRegister(0x1F801C06, 0); + spu.writeRegister(0x1F801C0E, 5); // repeat addr = 5 (=> byte addr 40) + spu.writeRegister(0x1F801C04, 0x1000); + spu.writeRegister(0x1F801D88, 0x0001); + + int16_t buffer[2] = {}; + spu.generateSamples(buffer, 1); + + auto state = spu.debugVoiceState(0); + EXPECT_EQ(state.currentAddr, 40u); + EXPECT_TRUE(state.endFlag); + EXPECT_TRUE(state.loopFlag); + EXPECT_EQ(spu.readRegister(0x1F801D9C) & 0x1, 1); +} + // ADSR Envelope Tests class SpuAdsrTest : public ::testing::Test { diff --git a/ps1Test/runtime/test_stubs.cpp b/ps1Test/runtime/test_stubs.cpp index a0dec45..69a6dc1 100644 --- a/ps1Test/runtime/test_stubs.cpp +++ b/ps1Test/runtime/test_stubs.cpp @@ -4,9 +4,14 @@ #include #include +// Opt-in hook letting a test stand in for the dispatched target, so HLEs that +// call through recomp_dispatch (e.g. libgpu__addque2 invoking its device +// routine) can be asserted on what the callee actually observes and returns. +// Null by default: recomp_dispatch stays a no-op for every other test. +void (*g_testRecompDispatchHook)(recomp_context *ctx, uint32_t addr) = nullptr; + // Stub recomp_dispatch -- called by EventSystem and Bios drainPendingCallbacks. -// In tests we simply do nothing. -void recomp_dispatch(uint8_t * /*rdram*/, recomp_context * /*ctx*/, - uint32_t /*addr*/) { - // no-op in test builds +void recomp_dispatch(uint8_t * /*rdram*/, recomp_context *ctx, uint32_t addr) { + if (g_testRecompDispatchHook) + g_testRecompDispatchHook(ctx, addr); } diff --git a/tools/pcsx_redux_mcp/server.py b/tools/pcsx_redux_mcp/server.py index bfc134f..a59b4bd 100644 --- a/tools/pcsx_redux_mcp/server.py +++ b/tools/pcsx_redux_mcp/server.py @@ -30,10 +30,10 @@ PCSX_BASE = "http://localhost:8079" MCP_PORT = 8078 -VRAM_OUT = "C:/temp/pcsx_vram.png" -SHOT_OUT = "C:/temp/pcsx_screenshot.png" -CMD_FILE = "C:/temp/pcsx_cmd.lua" -RESULT_FILE = "C:/temp/pcsx_result.txt" +VRAM_OUT = "/tmp/pcsx_vram.png" +SHOT_OUT = "/tmp/pcsx_screenshot.png" +CMD_FILE = "/tmp/pcsx_cmd.lua" +RESULT_FILE = "/tmp/pcsx_result.txt" RAM_SIZE = 0x200000 SCRATCH_SIZE = 0x400 LUA_TIMEOUT = 5.0 diff --git a/tools/pcsx_redux_mcp/startup.lua b/tools/pcsx_redux_mcp/startup.lua index d177062..5cc2ea7 100644 --- a/tools/pcsx_redux_mcp/startup.lua +++ b/tools/pcsx_redux_mcp/startup.lua @@ -1,9 +1,10 @@ -- PCSX-Redux startup script for PSXRecomp debugging --- Load via Lua Console: loadfile("F:/Projects/psxrecomp-v2/tools/pcsx_redux_mcp/startup.lua")() +-- Load via Lua Console: +-- loadfile("/home/dellareti/Documents/github/Italo Dell Areti/ps1-recomp/tools/pcsx_redux_mcp/startup.lua")() -- Requires emulation running (GPU::Vsync fires once per frame). -local CMD_FILE = "C:/temp/pcsx_cmd.lua" -local RESULT_FILE = "C:/temp/pcsx_result.txt" +local CMD_FILE = "/tmp/pcsx_cmd.lua" +local RESULT_FILE = "/tmp/pcsx_result.txt" -- Must be stored in a global to prevent garbage collection _psxrecomp_listener = PCSX.Events.createEventListener("GPU::Vsync", function() diff --git a/tools/run_report.py b/tools/run_report.py new file mode 100755 index 0000000..a4e66f5 --- /dev/null +++ b/tools/run_report.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Run a game and print the structured metric report. + +Replaces log parsing as the source of truth. Every capped log in this project +under-reports; the JSON written by ps1::metrics does not. + +Needs a real display: see pick_video_driver below. + +Usage: + tools/run_report.py configs/crash.toml [seconds] +""" +import json +import os +import subprocess +import sys +import tempfile + +INTERESTING_PREFIXES = ("gp0.op.", "draw_otag.", "gool.", "hle.libgpu", + "vram_dump.", "fill_rect", "cpu_to_vram") + +# Video drivers under which a run's numbers cannot be trusted. `dummy` has no +# OpenGL at all, so the runtime exits before the first frame. `offscreen` has +# OpenGL but no vsync, so the main loop free-runs and the boot degrades: six +# measured runs gave cpu_to_vram of ?, ?, 2, 2, 1, 973 and gool.interpret 0..26, +# against 1458 exactly and 298..300 on a vsynced display. This tool exists +# because every capped log in this project under-reports -- defaulting it to a +# driver that silently reports a broken run would reintroduce that same failure. +DEGRADED_DRIVERS = ("offscreen", "dummy") + + +def pick_video_driver(): + """Return the SDL_VIDEODRIVER to run under, or False when there is no + display. + + Derived from the session rather than hardcoded, so an X11-only box gets + x11 and a Wayland one gets wayland; an explicit SDL_VIDEODRIVER always + wins, with a warning when it names a driver known to degrade the run. + + Deriving beats leaving the variable unset: with no override SDL picks x11 + even in a Wayland session whenever DISPLAY points at XWayland (verified + here -- the runtime connects to @/tmp/.X11-unix/X1), so "let SDL choose" + silently measures a different backend than the one the audit numbers were + taken on. Refuse outright when neither variable is set instead of + printing numbers from a boot that never rendered; same signal as + ps1Test/runtime/test_golden_frame.cpp, which skips on it. + """ + explicit = os.environ.get("SDL_VIDEODRIVER") + if explicit: + if explicit in DEGRADED_DRIVERS: + print(f"warning: SDL_VIDEODRIVER={explicit} degrades the boot; " + "counters below are not comparable to a real run.", + file=sys.stderr) + return explicit + if os.environ.get("WAYLAND_DISPLAY"): + return "wayland" + if os.environ.get("DISPLAY"): + return "x11" + print("no display: set WAYLAND_DISPLAY or DISPLAY and run again. A " + "headless run boots degraded and reports numbers that look real " + "but are not (see DEGRADED_DRIVERS in this file). To measure one " + "anyway, set SDL_VIDEODRIVER explicitly.", file=sys.stderr) + return False + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__, file=sys.stderr) + return 2 + config = sys.argv[1] + seconds = sys.argv[2] if len(sys.argv) > 2 else "15" + + metrics_path = os.path.join(tempfile.gettempdir(), "ps1_run_report.json") + if os.path.exists(metrics_path): + os.remove(metrics_path) + + driver = pick_video_driver() + if driver is False: + return 1 + + env = dict(os.environ) + env.update(PS1_METRICS=metrics_path, SDL_AUDIODRIVER="dummy", + SDL_VIDEODRIVER=driver) + cmd = [ + "timeout", "--kill-after=5", seconds, + "./build/ps1Runtime/ps1Runtime", "--config", config, + ] + subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=False) + + if not os.path.exists(metrics_path): + print(f"no metric report written under SDL_VIDEODRIVER={driver} -- " + "did the runtime reach a shutdown path? If this SDL build has " + "no such driver, set SDL_VIDEODRIVER to one it does have.", + file=sys.stderr) + return 1 + + with open(metrics_path) as fh: + report = json.load(fh) + + counters = report.get("counters", {}) + state = report.get("state", {}) + + print(f"frames: {counters.get('frames', 0)}") + print("\ncounters:") + for key in sorted(counters): + if key == "frames": + continue + if key.startswith(INTERESTING_PREFIXES): + print(f" {key:<32} {counters[key]}") + print("\nstate:") + for key in sorted(state): + print(f" {key:<32} {state[key]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())