Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e05101e
test(input): pin PadRead port0-low-half layout against retail PadUpda…
Dellareti Aug 13, 2026
6d32c9c
feat(analyzer): detect computed jump-array slots as entry points
Dellareti Aug 13, 2026
c8df47b
fix(analyzer): only mark a PsyQ match HLE when the runtime registers it
Dellareti Aug 15, 2026
2c1c25d
feat(analyzer): linear sweep with validation for undetected functions
Dellareti Aug 16, 2026
683d7ac
fix(psyq): byte-swap PadRead halves into the PsyQ button mask
Dellareti Aug 16, 2026
dd11cc3
feat(recomp): resolve computed in-function jumps to local labels
Dellareti Aug 16, 2026
9c172f7
feat(recomp): accept 8-byte stride in computed in-function jumps
Dellareti Aug 16, 2026
2caa709
fix(recomp): resume in-function on hijacked $ra and scale strides by …
Dellareti Aug 17, 2026
5dfe1fc
refactor(analyzer): recompile PsyQ primitive builders and libgte inst…
Dellareti Aug 17, 2026
713821a
feat(runtime): add global watch, input injection and stall sampling p…
Dellareti Aug 17, 2026
4878c9c
recomp: resolve computed jumps against in-function labels when the st…
Dellareti Sep 1, 2026
c5db1ee
tools: reproducible Crash Bandicoot pipeline with function-size and d…
Dellareti Sep 1, 2026
6069d63
runtime: add write guard and drain pump-only yield point
Dellareti Sep 1, 2026
08a9ef1
runtime: fix SPU transfer address readback and DMA sound RAM destination
Dellareti Sep 12, 2026
995c37e
runtime: serialise CDROM sector hand-off between render and game threads
Dellareti Sep 12, 2026
343592f
runtime: make the write guard n-shot with per-writer dedup and value …
Dellareti Sep 12, 2026
07fdb7e
recomp: report the originating guest site and host stack on an unreso…
Dellareti Sep 13, 2026
9ce2d4d
tools: drop the decompressor drain suppression, obsolete since the CD…
Dellareti Sep 13, 2026
99816fd
build: track the analyzer's PsyQ HLE allow-list header
Dellareti Sep 21, 2026
d7c0c35
chore: ignore session notes, memory cards and local debugging scripts
Dellareti Sep 21, 2026
e5d9a20
runtime: refine the GTE perspective divide the way the hardware does
Dellareti Sep 21, 2026
8d502d2
runtime: return the complement of the pad buffer from PadRead
Dellareti Sep 21, 2026
837f587
runtime: emulate the PS1 memory card
Dellareti Sep 21, 2026
66c42fa
runtime: complete the keyboard pad mapping and print it at startup
Dellareti Sep 21, 2026
0dd1418
runtime: drive the hardware tick from the wall clock instead of the p…
Dellareti Sep 21, 2026
4d1ecf8
runtime: keep interrupt callbacks registered across ResetCallback
Dellareti Sep 21, 2026
be4ba17
runtime: count SPU voices and VSync callback registrations under PS1_…
Dellareti Sep 21, 2026
1a09d8d
runtime: guard the callback drain, pump VSync from the game thread, a…
Dellareti Sep 21, 2026
6f1b6a4
docs: document the pad layout and the memory cards
Dellareti Sep 21, 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
20 changes: 13 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,28 @@ test_roms/
*.cue
*.ecm

# Documentation
docs/

# cache
__pycache__/
*.pyc

# PsyQ SDK files (proprietary, never committed — kept outside repo)
# PsyQ SDK files
*.LIB
*.OBJ

# Local artifacts
.venv/
*.AppImage
.claude/
.mcp.json

# psyq-obj-parser binary — rebuild from pcsx-redux clone, do not version
# psyq-obj-parser binary
tools/psyq-obj-parser-bin/psyq-obj-parser

# Emulated memory cards (per-machine save data, not source)
memcards/

# Local debugging scripts.
tools/coverage_frontier.py
tools/coverage_inject.py
tools/diff_oracle.py
tools/find_truncated_funcs.py
tools/jpsxdec.sh
tools/sp_sentry_inject.py
96 changes: 51 additions & 45 deletions ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,45 @@ test that catches regressions.

---

## Issue #1 — Crash Bandicoot: silent hang before first GPU command
## Issue #1 — Crash Bandicoot: hash table walk hangs without GOOL interpreter

**Status:** bypassed (workaround in place; permanent fix in roadmap PLANNING.md Fase 2)
**Status:** bypassed (workaround `PS1_SKIP_31BF8=1`; permanent fix requires GOOL bytecode interpreter — PLANNING.md Fase 5)
**Game:** Crash Bandicoot (USA, SLUS-00005)
**Branch:** main

### Current state (2026-05-17)

Bypass shipped: env var `PS1_SKIP_31BF8=1` registers a runtime override that
NOPs `func_80031BF8` (the display-mode state machine call that triggers the
unpopulated hash table walk at `0x8005C530`). With the bypass plus the 4 new
function entries added to `crash_recomp.toml` (jumptable targets that
`ps1Analyzer` over-merged into surrounding functions), Crash now boots to the
main loop and runs the full PsyQ render pipeline:
### Current state (2026-05-26)

Two architectural emitter/BIOS bugs that masqueraded as a Crash-specific hang
were fixed on 2026-05-25 (commits `f5b038b` BIOS A-table indices and
`5300604` SWL/SWR emitter argument order). NS init now succeeds (the runtime
prints `"reading file system"` + `"Inited and Allocated 20 pages"` for the
first time, and `chunk[25].size = 0x5ABF` is populated correctly). With the
six `--add-func` entries persisted in `tools/regen_crash.sh` (jumptable
targets `ps1Analyzer` over-merged into surrounding functions), zero
`[DISPATCH] Unknown target` warnings appear during boot.

The residual hang is now isolated to the `func_80015B58` 21-module loop
walking the hash table at `0x8005C530`. The hash table base is not populated
by the NS chunk subsystem (which is what the prior diagnostic history below
hypothesised) — it is populated by the **GOOL bytecode interpreter** parsing
per-module data. GOOL is a Naughty Dog DSL whose interpreter lives in the
game binary; we do not implement it. This is scoped as Fase 5 of
`PLANNING.md` (~15 h work).

Until GOOL lands, `PS1_SKIP_31BF8=1` remains a permanent gap, not a
temporary workaround. With the bypass plus the SWL/SWR fix, the render path
runs:

| Metric | Value |
|---|---|
| `libetc_VSync` calls/run | 881 (~58 fps over ~15s) |
| `libgpu_DrawOTag` calls/run | 435 |
| `libgte_MulMatrix` calls/run | 872 |
| `libgpu_PutDispEnv` calls/run | 436 |
| GP1 display swaps | 20 (double-buffer working) |
| Suite | 557/557 green |
| Unknown dispatch targets | 0 |

The root cause (unpopulated hash table — see "Diagnostic history" below) is
unchanged; the bypass is a workaround. The permanent fix is to port the NS
(Naughty Sequence) chunk subsystem from c1c reference as `recomp_register_override`
implementations for `func_80013B94` (`NS_FixupPage`) and `func_80013B30`
(`NS_PageTransition`). See PLANNING.md Fase 2 for the roadmap.
| Suite | 569/569 green |
| `[DISPATCH] Unknown target` | 0 |
| GPU activity with `PS1_SKIP_31BF8=1` | 10 FillRect + DrawOTag + GTE 3D ops (race-prone) |
| NS_init success | yes (post 2026-05-25) |

The race-prone qualifier reflects that the bypass NOPs a state-machine call
the engine relies on for double-buffer cadence; some runs progress further
than others. Reliable rendering still depends on GOOL.

### Symptom

Expand Down Expand Up @@ -279,36 +288,33 @@ Conclusion: patching the hash lookup is insufficient. Downstream code
treats the returned pointer as a valid struct and dereferences several
fields; substituting a sentinel propagates the failure deeper.

### Real path forward (multi-session)
### Real path forward — superseded 2026-05-25

To get Crash past iter 17, one of the following is required:
Options 1 and 2 are refuted. The 2026-05-25 SWL/SWR emitter fix
(commit `5300604`) revealed that the missing "init function" was the
existing NS chunk loader running on corrupted LBA arithmetic. Once the
emitter emits stores correctly, NS_init populates `chunk[25]` and the
NS-side data path is complete. The hash table at `0x8005C530` is
**separate** — it is filled by the GOOL bytecode interpreter walking
per-module records once the engine is running, which is what
`PS1_SKIP_31BF8=1` short-circuits.

1. **Ghidra-assisted analysis** of the Crash boot path (PC =
`0x8003E018` through to `func_80015B58`) to find the missing
initialization function that writes `0x8005C530`, then add it
via `ps1Analyzer --add-func <addr>`.
2. **Implement Crash's data-file parser as an HLE override** that
reads the CD-loaded data at `0x80061A80` and pre-populates BSS
addresses including `0x8005C530`. Requires understanding the
data file format (one of the early sectors of Crash's binary).
3. **Override `func_80031BF8` (display-mode setup) as a no-op**: this
is the iter-17 target. If we skip it entirely, the GTE loop
continues to iter 18..20 (mostly null pointers, skip) and exits.
The game then proceeds past this initialization phase -- though
subsequent code likely hangs on similar missing inits.
The only path that removes the bypass is:

Option 1 is the only one that produces a long-term-correct recompiler.
Options 2 and 3 are TCC-specific workarounds.
1. **Implement the GOOL bytecode interpreter** as a `recomp_register_override`
on the GOOL VM entry point. The opcode table and semantics are
documented in `../PS1Recomp-workspace/gooc/include/gool_ins.c`. The
`CrashEdit` and `gooc` repositories ship reference implementations.
Scoped as Fase 5 of `PLANNING.md` (~15 h).

### Independent open item
Option 3 (NOPing `func_80031BF8`) remains shipped as `PS1_SKIP_31BF8=1`
but is now classified as a *gap* (depends on Fase 5), not a workaround.

**UBSan: misaligned 4-byte load** at `iso9660.cpp:29` and `:31`.

#### Independent open item
### Independent open item

**UBSan: misaligned 4-byte load** at `iso9660.cpp:29` and `:31`. Reading
`uint32_t` directly from a byte buffer that is not 4-aligned. Replace
with `memcpy` into a stack `uint32_t`. Not related to the hang; surface
with `memcpy` into a stack `uint32_t`. Not related to the hang; surfaces
in any disc-mount path.

### Verification
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@ ctest --test-dir build --output-on-failure -j$(nproc)
./build/ps1Runtime/ps1Runtime --config rayman_config.toml
```

## Controls

The keyboard stands in for a PS1 digital pad. The runtime prints this table on
startup, so it is always visible in the log of a run.

| Keyboard | PS1 pad | Keyboard | PS1 pad |
|---|---|---|---|
| Arrow keys | D-Pad | <kbd>Q</kbd> | L1 |
| <kbd>Z</kbd> | Cross | <kbd>W</kbd> | R1 |
| <kbd>X</kbd> | Circle | <kbd>E</kbd> | L2 |
| <kbd>A</kbd> | Square | <kbd>R</kbd> | R2 |
| <kbd>S</kbd> | Triangle | <kbd>C</kbd> | L3 |
| <kbd>Enter</kbd> | Start | <kbd>V</kbd> | R3 |
| <kbd>Right Shift</kbd> or <kbd>Backspace</kbd> | Select | <kbd>Esc</kbd> | quit |

Each press is echoed to stderr as `[pad] <key> -> <button>`, which separates a
button that reached the pad from one that never got there.

### Memory cards

Two cards are created under `memcards/` on first run, 128 KB each and formatted
as a real card is. Set `[paths] memcard_dir` in the game config to put them
somewhere else. A written sector is flushed to the file immediately, because
shutdown skips destructors and a deferred write would be lost.

## How It Works

### Phase 1 -- Analysis (`ps1Analyzer`)
Expand Down
1 change: 1 addition & 0 deletions ps1Analyzer/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ add_library(ps1Analyzer_lib
src/elf_parser.cpp
src/function_finder.cpp
src/psyq_signatures.cpp
src/psyq_hle_allowlist.cpp
src/config_generator.cpp
src/disc_reader.cpp
src/overlay_scanner.cpp
Expand Down
69 changes: 64 additions & 5 deletions ps1Analyzer/include/ps1recomp/function_finder.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ constexpr uint32_t OP_BEQ = 0x04;
constexpr uint32_t OP_BNE = 0x05;
constexpr uint32_t OP_BLEZ = 0x06;
constexpr uint32_t OP_BGTZ = 0x07;
constexpr uint32_t OP_ADDI = 0x08;
constexpr uint32_t OP_ADDIU = 0x09;
constexpr uint32_t OP_LUI = 0x0F;

// SPECIAL function codes (bits 5-0)
constexpr uint32_t FUNC_SLL = 0x00;
constexpr uint32_t FUNC_JR = 0x08;
constexpr uint32_t FUNC_JALR = 0x09;
constexpr uint32_t FUNC_ADDU = 0x21;
constexpr uint32_t FUNC_SUBU = 0x23;

// Register numbers
constexpr uint32_t REG_SP = 29;
Expand All @@ -39,6 +44,7 @@ inline uint32_t getOpcode(uint32_t instr) { return (instr >> 26) & 0x3F; }
inline uint32_t getRs(uint32_t instr) { return (instr >> 21) & 0x1F; }
inline uint32_t getRt(uint32_t instr) { return (instr >> 16) & 0x1F; }
inline uint32_t getRd(uint32_t instr) { return (instr >> 11) & 0x1F; }
inline uint32_t getShamt(uint32_t instr) { return (instr >> 6) & 0x1F; }
inline uint32_t getFunction(uint32_t instr) { return instr & 0x3F; }
inline int16_t getImm16(uint32_t instr) {
return static_cast<int16_t>(instr & 0xFFFF);
Expand Down Expand Up @@ -92,6 +98,14 @@ inline bool isBranch(uint32_t instr) {
op == OP_REGIMM;
}

/// Does `instr` carry a branch delay slot? True for every control transfer:
/// the word that follows one is never a function entry point, it is that
/// instruction's delay slot.
inline bool hasDelaySlot(uint32_t instr) {
return isBranch(instr) || isJ(instr) || isJAL(instr) || isJR(instr) ||
(getOpcode(instr) == OP_SPECIAL && getFunction(instr) == FUNC_JALR);
}

/// Compute a PC-relative branch target: PC + 4 + (signed imm16 << 2)
inline uint32_t branchTarget(uint32_t pc, uint32_t instr) {
return pc + 4 + (static_cast<uint32_t>(static_cast<int32_t>(getImm16(instr)))
Expand All @@ -108,6 +122,18 @@ inline bool isLoad(uint32_t instr) {
/// Conservative: covers R-type rd writes, loads, and the immediate ALU forms.
bool writesRegister(uint32_t instr, uint32_t reg);

/// Is `instr` an encoding the R3000A actually implements?
///
/// A whitelist, not a decoder. Every heuristic that walks over unclaimed bytes
/// has to answer "is this code at all?", and the only cheap answer that does
/// not fabricate functions out of data is: every word in the candidate has to
/// be a real instruction. One reserved encoding invalidates the whole slice.
///
/// Deliberately excludes COP1 (the PS1 has no FPU), COP3, the MIPS-II/III
/// opcodes the R3000A never had, and the reserved SPECIAL function codes --
/// those are the encodings data most often lands on.
bool isKnownInstruction(uint32_t instr);

} // namespace mips

/// Find where a function actually ends.
Expand All @@ -134,15 +160,33 @@ bool writesRegister(uint32_t instr, uint32_t reg);
uint32_t refineFunctionEnd(const std::vector<uint32_t> &words,
uint32_t startAddr, uint32_t maxEndAddr);


/// Could the slice starting at `words[0]` be a function body?
///
/// The gate in front of the linear sweep, and the reason the sweep does not
/// turn data into functions. Two conditions, both required:
///
/// - every word up to the terminator is a known instruction. Data that
/// happens to decode as something plausible almost always hits a reserved
/// encoding within a few words;
/// - a legitimate terminator appears *before* `maxEndAddr`. A slice that runs
/// into the next known entry point without ever returning is not a
/// function -- it is the middle of something, or it is not code.
///
/// @param words Instruction words, starting at `startAddr`.
/// @param startAddr Virtual address of `words[0]`.
/// @param maxEndAddr The next known entry point, or the end of the section.
bool validatesAsFunction(const std::vector<uint32_t> &words, uint32_t startAddr,
uint32_t maxEndAddr);

// Function Detection Source

enum class FunctionSource {
EntryPoint, // ELF entry point
Symbol, // From ELF symbol table (STT_FUNC)
JALTarget, // Target of a JAL instruction
Prologue, // Detected by ADDIU $sp, $sp, -N pattern
EntryPoint, // ELF entry point
Symbol, // From ELF symbol table (STT_FUNC)
JALTarget, // Target of a JAL instruction
Prologue, // Detected by ADDIU $sp, $sp, -N pattern
JumpArray, // Slot of a computed jump into an array of fixed-size bodies
LinearSweep, // Validated slice of text no other pass claimed
};

// FunctionInfo
Expand Down Expand Up @@ -207,19 +251,34 @@ class FunctionFinder {
void recomputeBoundaries(const ElfParser &elf);

private:
/// A computed-jump array whose slots do not return: bodies of a switch that
/// only bounce back into the function they belong to.
struct JumpIsland {
uint32_t base;
uint32_t end;
uint32_t slot;
};

std::vector<FunctionInfo> m_functions;
std::set<uint32_t> m_jalTargets;
std::vector<JumpIsland> m_jumpIslands;

// Detection passes
void addEntryPoint(const ElfParser &elf);
void addSymbolFunctions(const ElfParser &elf);
void scanJALTargets(const Section &text);
void scanPrologues(const Section &text);
void scanJumpArrays(const Section &text);
void linearSweep(const Section &text);
void computeBoundaries(const Section &text);

// Helpers
bool hasFunction(uint32_t addr) const;

/// Does `addr` (holding `word`) sit on a jump-island array the jump-array
/// pass traced and refused? Those slots are not function entry points.
bool isJumpIslandSlot(uint32_t addr, uint32_t word) const;

/// Read a 32-bit little-endian instruction from section data.
static uint32_t readInstruction(const Section &sec, uint32_t offset);
};
Expand Down
21 changes: 21 additions & 0 deletions ps1Analyzer/include/ps1recomp/psyq_hle_allowlist.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#pragma once
// ps1Analyzer -- names the runtime's PsyQ HLE registry actually implements.
//
// Hash detection tells us a function *is* a known PsyQ routine. That is not
// the same as deciding to replace it: replacing means our hand-written C++
// stands in for the real semantics, and every such substitution is a place the
// two can silently diverge. So detection is broad and replacement is narrow --
// a function is only marked `hle = true` in the generated config when the name
// below exists, and everything else is recompiled from its own MIPS.
//
// Kept in sync with `psyq_register()` in ps1Runtime/src/psyq/. The e2e test
// `PsyqHleAllowList.MatchesRuntimeRegistry` fails if the two drift.

#include <string>

namespace ps1recomp {

/// True when the PsyQ HLE registry implements `name` (`<library>_<basename>`).
bool psyqHleIsImplemented(const std::string& name);

} // namespace ps1recomp
Loading
Loading