Files
psxrecomp/recompiler/src/function_analysis.cpp
T
Matthew Stanley cf05a0ab1f runtime + recompiler: Tomba boot through BIOS into Whoopee Camp FMV
Recompiler — `recompiler/src/function_analysis.cpp` now stops a
function's end_addr at its mapped `jr $ra` + delay slot instead of
stretching through trailing data. Fixes Tomba's 0x80076DB0, a real
136-byte function that was being emitted as a psx_unknown_dispatch
stub because data after it poisoned the boundary heuristic.
`code_generator.cpp` emits `func_<next>(cpu); return;` as a tail
call when a mid-function seed split leaves the physical fall-through
in a different C function, plus a `debug_server_log_call_entry` at
every function entry. `full_function_emitter.cpp` splits dispatch
into `psx_dispatch_impl(cpu, addr, stop_addr)` with public
`psx_dispatch` and new `psx_dispatch_call(cpu, addr, return_addr)`
so JAL/JALR into RAM stubs can return cleanly without growing the
native stack.

Runtime — `runtime/src/dma.c`/`include/dma.h`/`src/memory.c` now
expose `dma_write_masked()`; the 16-bit and 8-bit mmio paths route
DICR/DPCR/CHCR partial writes through it so a partial DICR write no
longer accidentally clears unrelated channels' pending IRQ flags,
and CHCR only triggers DMA when the written mask actually includes
the start bit. Unified `complete_transfer(ch)` across all channels,
removed `prev_master_flag` edge detection.

MDEC (`runtime/src/mdec.c`, `include/mdec.h`) gains a full debug
surface: `MDECDebugState` snapshot, an always-on 4096-entry
`MDECDebugEvent` ring with 11 event kinds (reset, ctrl_write,
cmd_begin/done, decode_done, dma_in/out start/end, output_drained,
read_underflow), and `mdec_state` / `mdec_trace` / `mdec_trace_clear`
debug-server commands. DMA ch0/1 emit start/end events around MDEC
transfers.

CDROM (`runtime/src/cdrom.c`, `include/cdrom.h`) — read stream
separated from one-shot pending state (`read_cmd`, `read_delay`,
`start_read_stream`, `stop_read_stream`, `deliver_read_sector`);
`cdrom_state`/`cdrom_trace` report the new fields.

Thread/fiber tracing — `traps.c`'s Win32-fiber TCB save/restore now
emits 12 thread-event kinds; new `ThreadTraceEntry` ring (64 K) plus
`thread_trace` / `thread_trace_clear` debug-server commands report
both live CPU regs and the target TCB's full saved register block.

wtrace / mmio rings — `WriteTraceEntry` grew (cpu_pc, sp, v0/v1/a0-a3/
t0/t1); capacity 1 M -> 4 M. `MmioTraceEntry` grew (pc, cpu_pc, sp,
a0-a3, sr, epc, i_stat, i_mask). Both dump commands accept
`count`/`newest=1` for reverse iteration. `restore_trace` and
`dirty_block_log` emit extended register fields (a2/a3/sp).

Misc — `interrupts.c` polls debug-server every 16K checks so it
stays responsive without a separate thread. `psx_runtime.h`'s
`call_by_address` routes through `psx_dispatch_call`. Dirty-RAM
interp JAL/JALR consult `psx_dispatch_game_compiled` when the game
is wired in. iso_reader gains the entry points needed by the game
disc path. New `tools/_tomba_probe.py` for port-4470 health checks.

Generated — regenerated `SCPH1001_dispatch.c` against the new
`psx_dispatch_impl` shape.

ISSUES.md — opened Issue #3 (BIOS PS-logo glyph missing on the
disc-detected screen, cosmetic).

.gitignore — added `recompiler/build-asan*/`, `build-checked/`,
`__pycache__/`.

Validated end-to-end: BIOS boots, disc detected, Whoopee Camp FMV
plays with audio. Frame count advances through ~40K frames without
freezes. `unknown_dispatch_log.total = 0`. Remaining FMV cluster
issues (1/4-size render, FMV-2 audio missing, no skip) tracked
under TombaRecomp/ISSUES.md.

Co-Authored-By: Codex (OpenAI) <noreply@openai.com>
2026-05-11 12:34:49 -07:00

415 lines
16 KiB
C++

#include "function_analysis.h"
#include <algorithm>
#include <map>
#include <set>
#include <fmt/format.h>
namespace PSXRecomp {
FunctionAnalyzer::FunctionAnalyzer(const PS1Executable& exe) : exe_(exe) {}
void FunctionAnalyzer::add_forced_entry(uint32_t addr) {
// Validate address is within the EXE range
if (addr >= exe_.header.load_address && addr < exe_.end_address()) {
forced_entry_points_.push_back(addr);
}
}
bool FunctionAnalyzer::is_jr_ra(uint32_t instr) {
// jr $ra: opcode=0, rs=31 ($ra), rt=0, rd=0, shamt=0, funct=8 (jr)
// Format: 000000 11111 00000 00000 00000 001000
// Hex: 0x03E00008
return instr == 0x03E00008;
}
bool FunctionAnalyzer::is_prologue(uint32_t instr, int32_t& stack_size) {
// addiu $sp, $sp, -N
// Format: 001001 11101 11101 <16-bit signed immediate>
// Opcode: 0x27 (addiu), rs=$sp (29), rt=$sp (29)
uint32_t opcode = (instr >> 26) & 0x3F;
uint32_t rs = (instr >> 21) & 0x1F;
uint32_t rt = (instr >> 16) & 0x1F;
int16_t imm = (int16_t)(instr & 0xFFFF);
if (opcode == 0x09 && rs == 29 && rt == 29 && imm < 0) {
stack_size = -imm; // Store positive stack frame size
return true;
}
return false;
}
bool FunctionAnalyzer::is_epilogue(uint32_t instr, int32_t& stack_size) {
// addiu $sp, $sp, +N
uint32_t opcode = (instr >> 26) & 0x3F;
uint32_t rs = (instr >> 21) & 0x1F;
uint32_t rt = (instr >> 16) & 0x1F;
int16_t imm = (int16_t)(instr & 0xFFFF);
if (opcode == 0x09 && rs == 29 && rt == 29 && imm > 0) {
stack_size = imm;
return true;
}
return false;
}
bool FunctionAnalyzer::is_branch_or_jump(uint32_t instr) {
uint32_t opcode = (instr >> 26) & 0x3F;
// J, JAL
if (opcode == 0x02 || opcode == 0x03) return true;
// BEQ, BNE, BLEZ, BGTZ
if (opcode >= 0x04 && opcode <= 0x07) return true;
// REGIMM: BLTZ, BGEZ, BLTZAL, BGEZAL
if (opcode == 0x01) return true;
// SPECIAL: JR, JALR
if (opcode == 0x00) {
uint32_t funct = instr & 0x3F;
if (funct == 0x08 || funct == 0x09) return true;
}
// COP1/COP2 branches (BC1F, BC1T, BC2F, BC2T) — opcode 0x11 or 0x12, rs=0x08
if ((opcode == 0x11 || opcode == 0x12) && ((instr >> 21) & 0x1F) == 0x08) return true;
return false;
}
static bool is_sw_reg_base(uint32_t instr, uint32_t base_reg, uint32_t value_reg) {
uint32_t opcode = (instr >> 26) & 0x3F;
uint32_t rs = (instr >> 21) & 0x1F;
uint32_t rt = (instr >> 16) & 0x1F;
return opcode == 0x2B && rs == base_reg && rt == value_reg;
}
static bool is_lui(uint32_t instr, uint32_t& rt_out) {
uint32_t opcode = (instr >> 26) & 0x3F;
if (opcode != 0x0F) return false;
rt_out = (instr >> 16) & 0x1F;
return true;
}
static bool is_lw_same_reg_base(uint32_t instr, uint32_t reg) {
uint32_t opcode = (instr >> 26) & 0x3F;
uint32_t rs = (instr >> 21) & 0x1F;
uint32_t rt = (instr >> 16) & 0x1F;
return opcode == 0x23 && rs == reg && rt == reg;
}
uint32_t FunctionAnalyzer::find_function_start(uint32_t return_addr) {
// Scan backward from jr $ra to find function start
// Heuristic: Look for prologue or function alignment (16-byte boundary after prev function)
uint32_t search_addr = return_addr;
const uint32_t max_search = 4096; // Search up to 4096 instructions backward (16 KB)
for (uint32_t i = 0; i < max_search; i++) {
search_addr -= 4;
if (search_addr < exe_.header.load_address) {
// Reached beginning of code
return exe_.header.load_address;
}
auto word_opt = exe_.read_word(search_addr);
if (!word_opt.has_value()) {
return return_addr; // Can't read, assume current position
}
uint32_t instr = *word_opt;
int32_t stack_size;
// Check if this is a prologue
if (is_prologue(instr, stack_size)) {
// Verify this isn't a delay slot of a branch/jump instruction.
// MIPS compilers often place stack allocation in the delay slot of
// the function's first conditional branch, e.g.:
// beq v0, zero, skip
// addiu sp, sp, -N <- delay slot, looks like prologue but isn't a function start
// If the preceding instruction is a branch/jump, skip this candidate.
if (search_addr >= exe_.header.load_address + 4) {
auto prev_opt = exe_.read_word(search_addr - 4);
if (prev_opt.has_value() && is_branch_or_jump(*prev_opt)) {
continue; // delay slot, not a real prologue — keep scanning
}
}
if (search_addr >= exe_.header.load_address + 8) {
auto prev2_opt = exe_.read_word(search_addr - 8);
auto prev1_opt = exe_.read_word(search_addr - 4);
if (prev2_opt.has_value() && prev1_opt.has_value()) {
uint32_t lui_rt = 0;
if (is_lui(*prev2_opt, lui_rt) &&
is_lw_same_reg_base(*prev1_opt, lui_rt)) {
return search_addr - 8;
}
}
}
return search_addr;
}
// Check if we hit another function's return
if (is_jr_ra(instr)) {
// We've gone too far backward and hit another function
// Return the address after this jr $ra (+ 8 for delay slot and alignment)
return search_addr + 8;
}
}
// Couldn't find clear start, assume max search distance
return return_addr - (max_search * 4);
}
bool FunctionAnalyzer::is_likely_data_section(uint32_t start_addr, uint32_t end_addr) const {
uint32_t size = end_addr - start_addr;
if (size < 100) return false; // Minimum check size
uint32_t total_words = size / 4;
uint32_t invalid_jal_count = 0;
uint32_t undefined_opcode_count = 0;
// Valid PS1 (MIPS R3000) primary opcodes
static const bool valid_opcode[64] = {
true, true, true, true, true, true, true, true, // 0x00-0x07
true, true, true, true, true, true, true, true, // 0x08-0x0F
true, false, true, false, false, false, false, false, // 0x10-0x17 (COP0=0x10, COP2=0x12)
false, false, false, false, false, false, false, false, // 0x18-0x1F
true, true, true, true, true, true, true, false, // 0x20-0x27
true, true, true, true, false, false, true, false, // 0x28-0x2F
true, false, true, false, false, false, false, false, // 0x30-0x37 (LWC0=0x30, LWC2=0x32)
true, false, true, false, false, false, false, false, // 0x38-0x3F (SWC0=0x38, SWC2=0x3A)
};
for (uint32_t addr = start_addr; addr < end_addr; addr += 4) {
auto word_opt = exe_.read_word(addr);
if (!word_opt.has_value()) break;
uint32_t instr = *word_opt;
uint32_t opcode = (instr >> 26) & 0x3F;
// Check for JAL with invalid target
if (opcode == 3) { // JAL opcode
// PS1 JAL target: upper 4 bits from PC region (0x80000000), low 28 bits from instr
uint32_t target = ((instr & 0x03FFFFFFu) << 2) | 0x80000000u;
if (target > 0x801FFFFFu) {
invalid_jal_count++;
}
}
// Check for undefined opcode
if (!valid_opcode[opcode]) {
undefined_opcode_count++;
}
}
// Size-graduated JAL threshold: higher ratio needed for smaller functions
uint32_t jal_ratio_x100 = (total_words > 0) ? (invalid_jal_count * 100u / total_words) : 0u;
if (size >= 10000 && jal_ratio_x100 > 5u) return true; // Large: >5% invalid JALs
if (size >= 1000 && jal_ratio_x100 > 30u) return true; // Medium: >30% invalid JALs
if (size >= 100 && jal_ratio_x100 > 60u) return true; // Small: >60% invalid JALs
// Undefined opcode check:
// Real PS1 code consistently uses 0% undefined opcodes (calibrated on Tomba!).
// Data sections masquerading as functions have ~9-27% undefined opcodes.
// A safe threshold of 7% catches all observed data sections with no false positives.
uint32_t undef_ratio_x100 = (total_words > 0) ? (undefined_opcode_count * 100u / total_words) : 0u;
if (size >= 1000 && undef_ratio_x100 > 7u) return true; // Large: >7% undefined opcodes
if (size >= 400 && undef_ratio_x100 > 50u) return true; // Small: >50% undefined opcodes (conservative)
return false;
}
FunctionAnalysisResult FunctionAnalyzer::analyze() {
FunctionAnalysisResult result;
result.total_instructions = 0;
result.jr_ra_count = 0;
result.prologue_count = 0;
result.call_discovered_count = 0;
result.state_continuation_count = 0;
fmt::print("\n=== Function Boundary Detection ===\n\n");
// Pass 1: Find all jr $ra instructions
std::vector<uint32_t> return_addresses;
uint32_t current_addr = exe_.header.load_address;
uint32_t end_addr = exe_.end_address();
fmt::print("Scanning {} KB of code for function returns...\n",
(end_addr - current_addr) / 1024);
while (current_addr < end_addr) {
auto word_opt = exe_.read_word(current_addr);
if (!word_opt.has_value()) {
break;
}
uint32_t instr = *word_opt;
result.total_instructions++;
if (is_jr_ra(instr)) {
return_addresses.push_back(current_addr);
result.jr_ra_count++;
}
int32_t stack_size;
if (is_prologue(instr, stack_size)) {
result.prologue_count++;
}
current_addr += 4;
}
fmt::print("✓ Found {} jr $ra instructions\n", result.jr_ra_count);
fmt::print("✓ Found {} function prologues\n", result.prologue_count);
fmt::print("✓ Scanned {} total instructions\n\n", result.total_instructions);
// Pass 2: For each jr $ra, find function boundaries
fmt::print("Analyzing function boundaries...\n");
std::set<uint32_t> function_starts; // Use set to avoid duplicates
std::map<uint32_t, uint32_t> function_last_return;
for (uint32_t return_addr : return_addresses) {
uint32_t func_start = find_function_start(return_addr);
function_starts.insert(func_start);
auto it = function_last_return.find(func_start);
if (it == function_last_return.end() || return_addr > it->second) {
function_last_return[func_start] = return_addr;
}
}
size_t jr_ra_discovered = function_starts.size();
fmt::print("✓ Identified {} unique functions from jr $ra scan\n", jr_ra_discovered);
// Pass 2.5: Follow JAL call targets to discover additional functions
// This finds functions that don't have standard jr $ra prologues
fmt::print("Following JAL call targets to discover additional functions...\n");
uint32_t exe_start = exe_.header.load_address;
uint32_t exe_end = exe_.end_address();
for (uint32_t addr = exe_start; addr < exe_end; addr += 4) {
auto word_opt = exe_.read_word(addr);
if (!word_opt.has_value()) break;
uint32_t instr = *word_opt;
uint32_t opcode = (instr >> 26) & 0x3F;
if (opcode == 3) { // JAL
// PS1 JAL target: upper 4 bits from KSEG0 region (0x80000000)
uint32_t target = ((instr & 0x03FFFFFFu) << 2) | 0x80000000u;
// Only add if target is within EXE range and word-aligned
if (target >= exe_start && target < exe_end && (target & 3) == 0) {
function_starts.insert(target);
}
}
}
result.call_discovered_count = static_cast<int>(function_starts.size() - jr_ra_discovered);
fmt::print("✓ Identified {} unique functions ({} call-discovered)\n\n",
function_starts.size(), result.call_discovered_count);
// Pass 2.6: discover continuations saved by setjmp/SaveState-style helpers.
//
// Some games save the caller's $ra into an application context object and
// later restore it from an exception/VSync callback. The restored PC is the
// instruction after the original JAL delay slot, not a normal prologue, so
// it must be available as a split dispatch entry.
fmt::print("Finding SaveState-style continuation entries...\n");
int state_continuations = 0;
for (uint32_t addr = exe_start; addr < exe_end; addr += 4) {
auto word_opt = exe_.read_word(addr);
if (!word_opt.has_value()) break;
uint32_t instr = *word_opt;
uint32_t opcode = (instr >> 26) & 0x3F;
if (opcode != 3) continue; // JAL
uint32_t target = ((instr & 0x03FFFFFFu) << 2) | 0x80000000u;
uint32_t cont = addr + 8;
if (target < exe_start || target >= exe_end || (target & 3) != 0) continue;
if (cont < exe_start || cont >= exe_end || (cont & 3) != 0) continue;
auto target_first = exe_.read_word(target);
if (!target_first.has_value()) continue;
// sw $ra, imm($a0) at callee entry is the narrow signature for these
// context-save helpers. It avoids splitting ordinary calls.
if (is_sw_reg_base(*target_first, 4, 31)) {
bool inserted = function_starts.insert(cont).second;
if (inserted) state_continuations++;
}
}
result.state_continuation_count = state_continuations;
fmt::print("Identified {} SaveState continuation entries\n\n",
result.state_continuation_count);
// Pass 2.7: Add forced entry points
// These are function starts that do not have a standard prologue (e.g. the
// PS1 EXE entry point, which is launched directly by the BIOS without a
// JAL and starts with a BSS-zeroing loop rather than ADDIU $sp, $sp, -N).
if (!forced_entry_points_.empty()) {
fmt::print("Adding {} forced entry point(s)...\n", forced_entry_points_.size());
for (uint32_t addr : forced_entry_points_) {
bool inserted = function_starts.insert(addr).second;
if (inserted) {
fmt::print(" Forced entry 0x{:08X} added\n", addr);
} else {
fmt::print(" Forced entry 0x{:08X} already present (skipped)\n", addr);
}
}
fmt::print("\n");
}
// Pass 3: Build function table with details
std::vector<uint32_t> starts_vec(function_starts.begin(), function_starts.end());
std::sort(starts_vec.begin(), starts_vec.end());
for (size_t i = 0; i < starts_vec.size(); i++) {
Function func;
func.start_addr = starts_vec[i];
// Estimate end address (next function start or end of executable)
if (i + 1 < starts_vec.size()) {
func.end_addr = starts_vec[i + 1];
} else {
func.end_addr = end_addr;
}
auto ret_it = function_last_return.find(func.start_addr);
if (ret_it != function_last_return.end()) {
uint32_t return_end = ret_it->second + 8u; /* include jr delay slot */
if (return_end > func.start_addr && return_end < func.end_addr) {
func.end_addr = return_end;
}
}
func.size = func.end_addr - func.start_addr;
func.name = fmt::format("func_{:08X}", func.start_addr);
// Check for prologue at start
auto first_instr = exe_.read_word(func.start_addr);
if (first_instr.has_value()) {
int32_t stack_size;
func.has_prologue = is_prologue(*first_instr, stack_size);
func.stack_frame_size = func.has_prologue ? stack_size : 0;
} else {
func.has_prologue = false;
func.stack_frame_size = 0;
}
// Check for jr $ra before end
func.has_epilogue = false;
uint32_t search_end = func.end_addr - 4;
for (uint32_t addr = func.start_addr; addr <= search_end && addr < func.end_addr; addr += 4) {
auto instr_opt = exe_.read_word(addr);
if (instr_opt.has_value() && is_jr_ra(*instr_opt)) {
func.has_epilogue = true;
break;
}
}
// Check if this "function" is actually a data section
func.is_data_section = is_likely_data_section(func.start_addr, func.end_addr);
result.functions.push_back(func);
}
return result;
}
} // namespace PSXRecomp