Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
73e7c49
feat(gool): GOOL bytecode interpreter and runtime wiring
Dellareti Jul 26, 2026
70268c5
fix(tools): use Linux paths in PCSX-Redux MCP bridge
Dellareti Jul 26, 2026
eb2e7e4
feat(runtime): uncapped metric collector with JSON report
Dellareti Jul 27, 2026
7b101d5
feat(runtime): write metric report on both shutdown paths
Dellareti Jul 27, 2026
af3571a
feat(gpu): publish uncapped GP0 and ordering table metrics
Dellareti Jul 27, 2026
551c708
feat(runtime): count GOOL interpretations and HLE dispatches
Dellareti Jul 27, 2026
9e17808
feat(runtime): frame-selectable VRAM capture with pixel census
Dellareti Jul 27, 2026
0200aaa
fix(runtime): stop shutdown dump overwriting frame VRAM capture
Dellareti Jul 27, 2026
272fea2
test(runtime): golden frame contract for Crash bring-up
Dellareti Jul 27, 2026
8b4bdaa
feat(tools): structured run report replacing log parsing
Dellareti Jul 27, 2026
ca2c735
chore(runtime): drop obsolete PS1_SKIP_31BF8 override
Dellareti Jul 28, 2026
19d1a17
docs(recomp): correct stale override comment after PS1_SKIP_31BF8 rem…
Dellareti Jul 28, 2026
bc17d6a
chore(recomp): remove unused recomp_register_override API
Dellareti Jul 28, 2026
2a9e55c
fix(psyq): encode SetDrawMode arguments into DR_MODE GP0 words
Dellareti Jul 29, 2026
b941587
fix(gpu): stop zero-size FillRect from wiping VRAM
Dellareti Jul 29, 2026
64a7add
test(runtime): anchor golden frame on display-band colour diversity
Dellareti Jul 29, 2026
6605659
test(psyq): cover GetTPage bit packing against reference
Dellareti Jul 29, 2026
8a2edea
fix(runtime): anchor VRAM frame capture on VBlank count
Dellareti Jul 29, 2026
d45cadc
fix(runtime): emit Clear Cache word before LoadImage/_dws CPU->VRAM h…
Dellareti Jul 29, 2026
7b165c3
docs(runtime): cite source and add coverage for _addque2 semantics
Dellareti Jul 29, 2026
82852e9
fix(runtime): PutDrawEnv masks tpage with 0x9FF and encodes dfe bit
Dellareti Jul 29, 2026
750b401
docs(psyq): downgrade _addque2 audit claim and record _dws gaps
Dellareti Jul 30, 2026
fc58fee
fix(psyq): rebuild PutDispEnv GP1 encoding from DISPENV.screen per sys.c
Dellareti Aug 6, 2026
13c06cb
docs(psyq): cite DMA2 linked-list source for DrawOTag semantics
Dellareti Aug 6, 2026
7ba8be3
docs(psyq): cite psx-spx CLUT layout source for already-correct GetClut
Dellareti Aug 6, 2026
aeae507
fix(psyq): derive SetDefDrawEnv tpage and dfe from source formula
Dellareti Aug 6, 2026
9710a9b
fix(psyq): honor PAL branch and remove UB clamp in PutDispEnv/SetDefD…
Dellareti Aug 7, 2026
1737aa2
fix(psyq): clamp and correctly mask PutDrawEnv's E3/E4 clip words
Dellareti Aug 7, 2026
b48de62
fix(psyq): reorder PutDrawEnv's GP0 words to match SetDrawEnv2
Dellareti Aug 7, 2026
efc3a10
fix(psyq): emit PutDrawEnv's missing GP0(E2)/GP0(E6) words
Dellareti Aug 7, 2026
b0af3ca
docs(psyq): fix PutDrawEnv's summary comment to list all six GP0 words
Dellareti Aug 7, 2026
7d9562a
fix(psyq): _addque2 returns 0 per Crash disassembly audit
Dellareti Aug 8, 2026
eb15d52
fix(runtime): stop GP0 metrics doubling on the forced-exit shutdown path
Dellareti Aug 9, 2026
1625095
fix(psyq): zero SetDefDispEnv screen fields per ext.c
Dellareti Aug 9, 2026
23159f0
fix(psyq): zero SetDefDrawEnv isbg and background colour per ext.c
Dellareti Aug 9, 2026
b013d6a
fix(psyq): return env pointer from PutDispEnv/PutDrawEnv and zero fro…
Dellareti Aug 9, 2026
d4c9dca
fix(tools): derive run_report video driver from the session instead o…
Dellareti Aug 9, 2026
ab0cda9
test(gool): cover interpreter entry, native callout and divide edge c…
Dellareti Aug 9, 2026
6ad6010
fix(gool): match R3000A div edge cases instead of trapping on INT32_M…
Dellareti Aug 9, 2026
f757e55
chore(gool): gate the interpreter entry sampler behind PS1_GOOL_TRACE
Dellareti Aug 9, 2026
c62f45f
docs(psyq): correct the doubled GP0(7C) figure cited in the SetDrawMo…
Dellareti Aug 9, 2026
9ff54fd
docs(psyq): drop the stale 'screen unused' claim and clarify _dws ret…
Dellareti Aug 9, 2026
0728eb2
docs(psyq): cite DRAWENV and getTPage from an admissible decomp tree
Dellareti Aug 10, 2026
8141eaa
feat(runtime): advance game root-counter ticks from VSync
Dellareti Aug 11, 2026
4ee2216
fix(recomp): abort on unmapped dispatch instead of logging and contin…
Dellareti Aug 11, 2026
103cd59
feat(analyzer): add function-end refinement with forward-reach tracking
Dellareti Aug 11, 2026
dedc3f4
fix(recomp): keep the label when a delay slot is also a branch target
Dellareti Aug 11, 2026
be3f823
fix(recomp): write $ra before direct calls like the hardware jal does
Dellareti Aug 11, 2026
77a2ee8
fix(analyzer): stop functions from overlapping the next entry point
Dellareti Aug 11, 2026
4069c85
fix(spu): sign-extend low ADPCM nibble correctly
Dellareti Aug 11, 2026
cee2f53
fix(spu): ADPCM End flag always jumps to repeat address
Dellareti Aug 11, 2026
d04dcd7
fix(recomp): derive jump table size from the sltiu bounds check
Dellareti Aug 12, 2026
baef8df
test(runtime): anchor the golden frame on the native GOOL timeline
Dellareti Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions ps1Analyzer/include/ps1recomp/function_finder.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t>(static_cast<int32_t>(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<uint32_t> &words,
uint32_t startAddr, uint32_t maxEndAddr);



// Function Detection Source

enum class FunctionSource {
Expand All @@ -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<FunctionInfo> &funcs);

// FunctionFinder

class FunctionFinder {
Expand Down
106 changes: 106 additions & 0 deletions ps1Analyzer/src/function_finder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t> &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<uint32_t> &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<uint32_t>(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<FunctionInfo> &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) {
Expand Down Expand Up @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions ps1Recomp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions ps1Recomp/include/ps1recomp/dispatch_emitter.h
Original file line number Diff line number Diff line change
@@ -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 <string>

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
100 changes: 100 additions & 0 deletions ps1Recomp/src/dispatch_emitter.cpp
Original file line number Diff line number Diff line change
@@ -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<uint32_t, uint32_t> 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
Loading