Better Things and Runtime Fixes

This commit is contained in:
NyperYuhgard
2026-07-11 17:10:23 -05:00
parent a40d587bf1
commit 2ba52e9754
22 changed files with 1707 additions and 645 deletions
+3 -1
View File
@@ -10,7 +10,8 @@ duckstation/dep/prebuilt/
# Ghidra project files (large, regenerable)
ghidra/
references/
SpiritDecomp
# Backup of the working state — see CURRENT_STATE.md for what this contains.
.happypath/
@@ -27,6 +28,7 @@ test_msvc/
# Recompiler output (derivative of copyrighted Sony BIOS, regenerate locally)
generated/
# Release packaging artifacts
release-stage/
*.zip
+5
View File
@@ -56,5 +56,10 @@ hardware provides.
## Resolved
- ~~S1 — GPU shaded line uses flat color~~ — Fixed: sw_draw_shaded_line interpolates C0→C1 per pixel
- ~~S2 — GPU polyline crashes~~ — Fixed: state machine with 0x50005000 terminator
- ~~S3 — MDEC decoder is a no-op~~ — Fixed: full Beetle-faithful decoder (RLE decode, dequant, IDCT, YCbCr→RGB 15/24bpp, macroblock pipeline)
- ~~S4 — SPU has no audio synthesis~~ — Fixed: 24-voice ADPCM decoder, reverb, noise LFSR, volume sweep, pitch modulation (PMON), IRQ delivery
- ~~S5 — DMA channels 0,1,3,5 unimplemented~~ — Fixed: ch0/1 MDEC, ch3 CDROM, ch4 SPU, ch5 PIO all implemented
- ~~DMA ch2 GPU→RAM writes zeros~~ — Fixed 2026-04-24 (4054dc1)
- ~~GPU raw texture mode uses color==0x808080 check~~ — Fixed 2026-04-24
+52 -8
View File
@@ -705,21 +705,21 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
// REGIMM branches (bltz, bgez, bltzal, bgezal)
// REGIMM branches (bltz, bgez, bltzal, bgezal + likely variants)
if (opcode == 0x01) {
uint32_t regimm_op = (instr >> 16) & 0x1F;
if (regimm_op == 0x00) { // bltz
if (regimm_op == 0x00 || regimm_op == 0x02) { // bltz / bltzl
// Classified LEFT-edge funnel bltz (auto_screen_x, signed idioms):
// reject only past the revealed margin. Identity at 4:3 (margin 0).
if (ws_cull_bltz_pcs_.count(addr))
return fmt::format("psx_ws_cull_bltz({}) /* ws auto screen-x cull (left edge) */",
reg_name(rs));
return fmt::format("(int32_t){} < 0", reg_name(rs));
} else if (regimm_op == 0x01) { // bgez
} else if (regimm_op == 0x01 || regimm_op == 0x03) { // bgez / bgezl
return fmt::format("(int32_t){} >= 0", reg_name(rs));
} else if (regimm_op == 0x10) { // bltzal
} else if (regimm_op == 0x10 || regimm_op == 0x12) { // bltzal / bltzall
return fmt::format("(int32_t){} < 0", reg_name(rs));
} else if (regimm_op == 0x11) { // bgezal
} else if (regimm_op == 0x11 || regimm_op == 0x13) { // bgezal / bgezall
return fmt::format("(int32_t){} >= 0", reg_name(rs));
}
}
@@ -741,6 +741,26 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad
case 0x07: // bgtz
case 0x17: // bgtzl
return fmt::format("(int32_t){} > 0", reg_name(rs));
case 0x12: // COP2 branch (BC2F, BC2T)
{
uint32_t cop2_rs = (instr >> 21) & 0x1F;
uint32_t cond_code = (instr >> 16) & 0x3;
// BC2F (cop2_rs==0x08): branch if COP2 condition is FALSE
// BC2T (cop2_rs==0x09): branch if COP2 condition is TRUE
// The condition tests bit (30+cond_code) of GTE ctrl register 31
// (flags register). GTE flags bits: bit31=LE0, bit30=GE0, etc.
// For PS1 games, the standard pattern is:
// BC2F: branch if (gte_ctrl[31] & (1 << (30+cc))) == 0
// BC2T: branch if (gte_ctrl[31] & (1 << (30+cc))) != 0
if (cop2_rs == 0x09) { // BC2T
return fmt::format("(cpu->gte_ctrl[31] & 0x{:08X}u) != 0",
1u << (30 + cond_code));
} else { // BC2F
return fmt::format("(cpu->gte_ctrl[31] & 0x{:08X}u) == 0",
1u << (30 + cond_code));
}
}
}
return "0 /* unknown branch condition: defaults to not-taken */";
@@ -1086,6 +1106,24 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
case 0x27: code = translate_nor(instr); break; // nor
case 0x2A: code = translate_slt(instr); break; // slt
case 0x2B: code = translate_sltu(instr); break; // sltu
case 0x0A: // movn
{
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
uint32_t rd = get_rd(instr);
code = fmt::format("if ({}) {} = {}; /* movn */",
reg_name(rt), reg_name(rd), reg_name(rs));
}
break;
case 0x0B: // movz
{
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
uint32_t rd = get_rd(instr);
code = fmt::format("if ({}) {} = {}; /* movz */",
reg_name(rt), reg_name(rd), reg_name(rs));
}
break;
default:
code = fmt::format("/* TODO: SPECIAL funct=0x{:02X} */", funct);
}
@@ -1173,6 +1211,10 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
uint32_t gte_cmd = instr & 0x1FFFFFF;
// Route ALL GTE commands through gte_execute() for correct behavior
code = fmt::format("gte_execute(cpu, 0x{:07X}); /* gte cmd 0x{:02X} */", gte_cmd, gte_cmd & 0x3F);
} else if (cop_op == 0x08) { // BC2F - branch if GTE flag clear
code = fmt::format("/* BC2F — handled as block exit branch */ (void)0; /* bc2f */");
} else if (cop_op == 0x09) { // BC2T - branch if GTE flag set
code = fmt::format("/* BC2T — handled as block exit branch */ (void)0; /* bc2t */");
} else {
code = fmt::format("/* cop2: 0x{:08X} */", instr);
}
@@ -1476,7 +1518,8 @@ std::string CodeGenerator::translate_basic_block(
uint32_t branch_instr = block.exit_instr.instruction;
uint32_t b_opcode = (branch_instr >> 26) & 0x3F;
uint32_t regimm_op = (branch_instr >> 16) & 0x1F;
if (b_opcode == 0x01 && (regimm_op == 0x10 || regimm_op == 0x11)) {
if (b_opcode == 0x01 && (regimm_op == 0x10 || regimm_op == 0x11 ||
regimm_op == 0x12 || regimm_op == 0x13)) {
ss << config_.indent
<< fmt::format("cpu->gpr[31] = 0x{:08X}u; /* branch-and-link before delay slot */\n",
addr + 8);
@@ -1531,8 +1574,9 @@ std::string CodeGenerator::translate_basic_block(
uint32_t b_opcode = (branch_instr >> 26) & 0x3F;
if (b_opcode == 0x01) {
uint32_t regimm_op = (branch_instr >> 16) & 0x1F;
if (regimm_op == 0x10 || regimm_op == 0x11) {
// bltzal or bgezal: link register always set
if (regimm_op == 0x10 || regimm_op == 0x11 ||
regimm_op == 0x12 || regimm_op == 0x13) {
// bltzal/bgezal/bltzall/bgezall: link register always set
ss << config_.indent
<< fmt::format("cpu->gpr[31] = 0x{:08X}; /* branch-and-link return addr */\n", addr + 8);
}
+23
View File
@@ -35,6 +35,12 @@ bool ControlFlowAnalyzer::is_control_flow(uint32_t instr) {
return true; // j, jal
}
// COP2 branches (BC2F, BC2T): opcode 0x12, rs=0x08 or 0x09
if (opcode == 0x12) {
uint32_t rs = (instr >> 21) & 0x1F;
if (rs == 0x08 || rs == 0x09) return true; // bc2f, bc2t
}
return false;
}
@@ -170,6 +176,23 @@ ControlFlowInstr ControlFlowAnalyzer::analyze_instruction(uint32_t addr, uint32_
cf.has_delay_slot = true;
cf.mnemonic = "jal";
break;
case 0x12: // COP2 branches (BC2F, BC2T)
{
uint32_t cop2_rs = (instr >> 21) & 0x1F;
if (cop2_rs == 0x08) { // BC2F
cf.type = ControlFlowType::Branch;
cf.target = get_branch_target(addr, instr);
cf.has_delay_slot = true;
cf.mnemonic = "bc2f";
} else if (cop2_rs == 0x09) { // BC2T
cf.type = ControlFlowType::Branch;
cf.target = get_branch_target(addr, instr);
cf.has_delay_slot = true;
cf.mnemonic = "bc2t";
}
}
break;
}
return cf;
+23 -12
View File
@@ -24,15 +24,15 @@ bool FunctionAnalyzer::is_jr_ra(uint32_t instr) {
}
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)
// addiu/addi $sp, $sp, -N
// Format: 001001/001000 11101 11101 <16-bit signed immediate>
// Opcode: 0x09 (addiu) or 0x08 (addi), 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) {
if ((opcode == 0x09 || opcode == 0x08) && rs == 29 && rt == 29 && imm < 0) {
stack_size = -imm; // Store positive stack frame size
return true;
}
@@ -40,13 +40,13 @@ bool FunctionAnalyzer::is_prologue(uint32_t instr, int32_t& stack_size) {
}
bool FunctionAnalyzer::is_epilogue(uint32_t instr, int32_t& stack_size) {
// addiu $sp, $sp, +N
// addiu/addi $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) {
if ((opcode == 0x09 || opcode == 0x08) && rs == 29 && rt == 29 && imm > 0) {
stack_size = imm;
return true;
}
@@ -64,6 +64,7 @@ bool FunctionAnalyzer::is_valid_mips_word(uint32_t instr) {
switch (funct) {
case 0x00u: case 0x02u: case 0x03u: case 0x04u:
case 0x06u: case 0x07u: case 0x08u: case 0x09u:
case 0x0Au: case 0x0Bu: // MOVN, MOVZ
case 0x0Cu: case 0x0Du:
case 0x10u: case 0x11u: case 0x12u: case 0x13u:
case 0x18u: case 0x19u: case 0x1Au: case 0x1Bu:
@@ -76,7 +77,8 @@ bool FunctionAnalyzer::is_valid_mips_word(uint32_t instr) {
}
}
if (opcode == 0x01u) {
return rt == 0x00u || rt == 0x01u || rt == 0x10u || rt == 0x11u;
return rt == 0x00u || rt == 0x01u || rt == 0x02u || rt == 0x03u ||
rt == 0x10u || rt == 0x11u || rt == 0x12u || rt == 0x13u;
}
switch (opcode) {
@@ -85,6 +87,7 @@ bool FunctionAnalyzer::is_valid_mips_word(uint32_t instr) {
case 0x08u: case 0x09u: case 0x0Au: case 0x0Bu:
case 0x0Cu: case 0x0Du: case 0x0Eu: case 0x0Fu:
case 0x10u: case 0x12u:
case 0x14u: case 0x15u: case 0x16u: case 0x17u: // branch-likely
case 0x20u: case 0x21u: case 0x22u: case 0x23u:
case 0x24u: case 0x25u: case 0x26u:
case 0x28u: case 0x29u: case 0x2Au: case 0x2Bu:
@@ -102,6 +105,8 @@ bool FunctionAnalyzer::is_branch_or_jump(uint32_t instr) {
if (opcode == 0x02 || opcode == 0x03) return true;
// BEQ, BNE, BLEZ, BGTZ
if (opcode >= 0x04 && opcode <= 0x07) return true;
// BEQL, BNEL, BLEZL, BGTZL (branch-likely)
if (opcode >= 0x14 && opcode <= 0x17) return true;
// REGIMM: BLTZ, BGEZ, BLTZAL, BGEZAL
if (opcode == 0x01) return true;
// SPECIAL: JR, JALR
@@ -117,7 +122,7 @@ bool FunctionAnalyzer::is_branch_or_jump(uint32_t instr) {
static bool is_load_imm_zero_u16(uint32_t instr, uint32_t& rt_out, uint32_t& imm_out) {
uint32_t opcode = (instr >> 26) & 0x3F;
uint32_t rs = (instr >> 21) & 0x1F;
if ((opcode != 0x09 && opcode != 0x0D) || rs != 0) {
if ((opcode != 0x09 && opcode != 0x08 && opcode != 0x0D) || rs != 0) {
return false;
}
rt_out = (instr >> 16) & 0x1F;
@@ -224,7 +229,7 @@ static bool is_load_from_reg_base(uint32_t instr, uint32_t base_reg, uint32_t& r
static bool is_load_imm_zero(uint32_t instr, uint32_t& rt_out) {
uint32_t opcode = (instr >> 26) & 0x3F;
uint32_t rs = (instr >> 21) & 0x1F;
if ((opcode != 0x09 && opcode != 0x0D) || rs != 0) return false;
if ((opcode != 0x09 && opcode != 0x08 && opcode != 0x0D) || rs != 0) return false;
rt_out = (instr >> 16) & 0x1F;
return rt_out != 0;
}
@@ -410,7 +415,7 @@ bool FunctionAnalyzer::is_likely_data_section(uint32_t start_addr, uint32_t end_
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)
true, false, true, false, true, true, true, true, // 0x10-0x17 (COP0=0x10, COP2=0x12, branch-likely=0x14-0x17)
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
@@ -490,7 +495,7 @@ static bool exact_is_addiu_sp_neg(uint32_t instr) {
uint32_t rs = (instr >> 21) & 0x1Fu;
uint32_t rt = (instr >> 16) & 0x1Fu;
int16_t imm = static_cast<int16_t>(instr & 0xFFFFu);
return opcode == 0x09u && rs == 29u && rt == 29u && imm < 0;
return (opcode == 0x09u || opcode == 0x08u) && rs == 29u && rt == 29u && imm < 0;
}
static bool exact_is_valid_mips_word(uint32_t instr) {
@@ -536,6 +541,12 @@ static ExactCf exact_classify_cf(uint32_t pc, uint32_t instr) {
cf.target = exact_branch_target(pc, instr);
return cf;
}
// COP2 branches (BC2F, BC2T): opcode 0x12, rs=0x08/0x09
if (opcode == 0x12u && (rs == 0x08u || rs == 0x09u)) {
cf.kind = ExactCfKind::Branch;
cf.target = exact_branch_target(pc, instr);
return cf;
}
return cf;
}
@@ -1166,7 +1177,7 @@ FunctionAnalysisResult FunctionAnalyzer::analyze() {
static const bool valid[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
true, false, true, false, true, true, true, true, // 0x10-0x17
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
+32 -2
View File
@@ -65,6 +65,8 @@ void MipsDecoder::decode_special(DecodedInstruction& out) {
case 0x25: out.mnemonic = "OR"; break;
case 0x26: out.mnemonic = "XOR"; break;
case 0x27: out.mnemonic = "NOR"; break;
case 0x0A: out.mnemonic = "MOVN"; break;
case 0x0B: out.mnemonic = "MOVZ"; break;
case 0x2A: out.mnemonic = "SLT"; break;
case 0x2B: out.mnemonic = "SLTU"; break;
default:
@@ -82,8 +84,12 @@ void MipsDecoder::decode_regimm(DecodedInstruction& out) {
switch (out.rt) {
case 0x00: out.mnemonic = "BLTZ"; break;
case 0x01: out.mnemonic = "BGEZ"; break;
case 0x02: out.mnemonic = "BLTZL"; break;
case 0x03: out.mnemonic = "BGEZL"; break;
case 0x10: out.mnemonic = "BLTZAL"; break;
case 0x11: out.mnemonic = "BGEZAL"; break;
case 0x12: out.mnemonic = "BLTZALL"; break;
case 0x13: out.mnemonic = "BGEZALL"; break;
default:
out.mnemonic = "REGIMM?";
out.format = InstrFormat::UNKNOWN;
@@ -119,6 +125,8 @@ void MipsDecoder::decode_cop2(DecodedInstruction& out) {
case 0x02: out.mnemonic = "CFC2"; break;
case 0x04: out.mnemonic = "MTC2"; break;
case 0x06: out.mnemonic = "CTC2"; break;
case 0x08: out.mnemonic = "BC2F"; break;
case 0x09: out.mnemonic = "BC2T"; break;
default:
// CO bit (bit 25) set => GTE command word
if (out.rs & 0x10) out.mnemonic = "GTE";
@@ -157,6 +165,10 @@ void MipsDecoder::set_classification_flags(DecodedInstruction& out) {
out.is_jump = true;
out.is_delay_slot_user = true;
break;
case 0x0A: // MOVN
case 0x0B: // MOVZ
out.is_alu = true;
break;
case 0x0C: out.is_syscall = true; break;
case 0x0D: out.is_break = true; break;
default: out.is_alu = true; break;
@@ -184,6 +196,10 @@ void MipsDecoder::set_classification_flags(DecodedInstruction& out) {
case 0x05: // BNE
case 0x06: // BLEZ
case 0x07: // BGTZ
case 0x14: // BEQL
case 0x15: // BNEL
case 0x16: // BLEZL
case 0x17: // BGTZL
out.is_branch = true;
out.is_delay_slot_user = true;
break;
@@ -223,7 +239,15 @@ void MipsDecoder::set_classification_flags(DecodedInstruction& out) {
break;
// COP0, COP2: neither load/store/alu/branch/jump
// except BC2F/BC2T which are COP2 conditional branches
default:
if (out.opcode == 0x12) {
uint32_t cop2_rs = (out.raw >> 21) & 0x1F;
if (cop2_rs == 0x08 || cop2_rs == 0x09) { // BC2F, BC2T
out.is_branch = true;
out.is_delay_slot_user = true;
}
}
break;
}
}
@@ -270,6 +294,10 @@ DecodedInstruction MipsDecoder::decode(uint32_t instr, uint32_t address) {
case 0x0F: out.mnemonic = "LUI"; out.format = InstrFormat::I; break;
case 0x10: decode_cop0(out); break;
case 0x12: decode_cop2(out); break;
case 0x14: out.mnemonic = "BEQL"; out.format = InstrFormat::I; break;
case 0x15: out.mnemonic = "BNEL"; out.format = InstrFormat::I; break;
case 0x16: out.mnemonic = "BLEZL"; out.format = InstrFormat::I; break;
case 0x17: out.mnemonic = "BGTZL"; out.format = InstrFormat::I; break;
case 0x20: out.mnemonic = "LB"; out.format = InstrFormat::I; break;
case 0x21: out.mnemonic = "LH"; out.format = InstrFormat::I; break;
case 0x22: out.mnemonic = "LWL"; out.format = InstrFormat::I; break;
@@ -297,8 +325,10 @@ DecodedInstruction MipsDecoder::decode(uint32_t instr, uint32_t address) {
}
if (out.opcode == 0x04 || out.opcode == 0x05 ||
out.opcode == 0x06 || out.opcode == 0x07 ||
out.opcode == 0x01) {
// BEQ/BNE/BLEZ/BGTZ + REGIMM (BLTZ/BGEZ/etc.)
out.opcode == 0x01 ||
out.opcode == 0x14 || out.opcode == 0x15 ||
out.opcode == 0x16 || out.opcode == 0x17) {
// BEQ/BNE/BLEZ/BGTZ + REGIMM (BLTZ/BGEZ/etc.) + branch-likely variants
out.branch_target = compute_branch_target(address, out.imm16);
}
+267
View File
@@ -0,0 +1,267 @@
[game]
name = "Tomba!"
id = "SCUS-94236"
exe = "tomba/SCUS_942.36"
disc = "tomba/tomba.cue"
load_address = "0x80010000"
entry_pc = "0x8006B58C"
text_size = "0x00088000"
stack_base = "0x801FFFF0"
[recompiler]
seeds = "seeds/ghidra_funcs.txt"
bios_thunks = "seeds/tomba_bios_thunks.txt"
out_dir = "generated"
strict = true
[runtime]
debug_port = 4470
window_title = "Tomba! Recompiled"
memcard_dir = "saves"
disc_speed = "1x" # authentic CD timing — never perturbs game timing/audio
# BIOS backend (CLAUDE.md §0 amendment 2026-07-02). Default false = LLE (the
# recompiled BIOS, the reference implementation + oracle). true = HLE tier:
# implemented kernel services (the B0 event family in v1) computed in-runtime
# against the real guest kernel structures, everything else falling through to
# LLE — and the BIOS shell (boot animation) skipped one-shot with kernel init +
# EXE load still run by the real recompiled BIOS at host speed. Env overrides:
# PSX_BIOS_HLE / PSX_BIOS_HLE_KEEP_INTRO. Observability: hle_dump TCP command.
bios_hle = true
bios_hle_keep_intro = false # HLE calls but keep the real boot intro
# DEPRECATED: fast_boot is now an alias for the HLE boot shell-skip alone (the
# old first-boot snapshot capture/restore mechanism is gone).
fast_boot = false # set false to watch BIOS logos
overlay_cache = true # native overlay-DLL cache (production path; gcc > tcc > interp)
# Tier selection: "auto" (default) = gcc if a gcc toolchain is present (dev /
# production shard authoring), else the bundled toolchain-free tcc. Other values:
# "gcc", "tcc", "auto-no-gcc" (dev: force tcc even with gcc present, to test the
# user path). Priority is static > gcc shard > tcc shard > interp. The deprecated
# sljit tier is retired (removed in the 1915b5c framework refactor).
overlay_backend = "auto"
# Fast loads, done the safe way. disc_speed stays authentic 1x (speeding the
# guest-visible CD device changes how many VBlanks/callbacks fall between the
# game's state transitions, which desyncs CD-streamed audio and can wedge
# timing-sensitive titles). The real speed comes from turbo_loads: while a CD
# load is in progress (cdrom_load_in_progress: a sustained read, XA/FMV excluded,
# post-handoff), the frontend drops wall-clock pacing so the guest runs every
# authentic frame at host speed. All guest timing is preserved; only real-world
# load time shrinks. Audio plays through (no mute). Launcher Settings -> Turbo
# loads persists to settings.toml and overrides this.
turbo_loads = true
# Step 2.8: auto capture+compile of uncovered overlay variants (background;
# run via cmd.exe /C with cwd = project root; needs python + gcc on PATH).
# The compile's WRITE cache + READ captures are NOT specified here: the runtime
# injects PSX_OVERLAY_CACHE_DIR / PSX_OVERLAY_CAPTURES (the loader's canonical
# <exe>/cache and <exe>/overlay_captures.json) so the write can never drift from
# where the loader reads (the framework owns the cache location, uniform for all
# games, dev or prod). This command is just the compile RECIPE (which compiler,
# tool paths). Coverage-vault backup is a separate manual dev step now (it must
# not fail-couple the autocompile rescan).
overlay_autocompile_cmd = "python F:/Projects/psxrecomp/psxrecomp/tools/compile_overlays.py --game-toml game.toml --recompiler F:/Projects/psxrecomp/psxrecomp/recompiler/build/psxrecomp-game.exe --runtime-include F:/Projects/psxrecomp/psxrecomp/runtime/include --gcc C:/msys64/mingw64/bin/gcc.exe --cps"
[video]
# Visual enhancement options.
# supersampling: internal-resolution SSAA factor (1 = native PSX, 2..4 render
# the scene at N* the resolution and downsample on present -> true
# supersampling + edge anti-aliasing). 2 is a good quality/perf default;
# raise to 3 or 4 for sharper edges if your CPU holds 59.94 fps.
# antialiasing: linear present filtering (smooths the downscale and any window
# resize). Set false for sharp, unfiltered pixels.
# texture_filtering: "nearest" (native PSX look) or "bilinear" (smooths
# textures and 2D backgrounds). Off by default.
# renderer: "software" (CPU rasterizer) or "opengl" (GPU-authoritative VRAM
# FBO backend). OpenGL moves rasterization + supersampling onto the GPU, so
# fill-heavy scenes (e.g. the mushroom forest) hold 59.94 fps even at
# supersampling 2..4. Falls back to software automatically if GL init fails.
# aspect_ratio: display aspect "W:H" ("4:3" = native, the default). "16:9"
# enables the EXPERIMENTAL widescreen hack: the GTE projection is squashed
# horizontally and the frame is presented stretched to 16:9, revealing extra
# world at the screen edges. Tomba's world is GTE-projected 3D, so geometry
# keeps correct proportions; screen-space 2D (HUD, sprite quads, FMV) looks
# stretched, and world-space object culling can pop near the edges. Also
# selectable in the launcher (Settings -> Aspect ratio), which persists to
# settings.toml and overrides this value.
supersampling = 2
antialiasing = true
texture_filtering = "nearest"
renderer = "opengl"
# auto_skip_fmv: auto-skip full-motion videos. When a streaming FMV is detected,
# the runtime ends it via the game's OWN end-of-movie path (see fmv_skip_* below):
# it writes the current movie's per-movie frame-total down so Tomba's MDEC player
# tears the movie down on its next frame. Works for EVERY movie (Whoopee Camp
# logo, opening, in-game cutscene movies) including ones the game won't let you
# skip with a button. Ships OFF; the launcher's Settings → "Skip FMVs" toggle
# turns it on (persisted to settings.toml).
auto_skip_fmv = false
# fmv_skip_total_table: base of Tomba's per-movie frame-total table (u16 per movie,
# indexed by the current-movie-id byte). fmv_skip_movie_id: that id byte (PSX
# scratchpad). fmv_skip_end_total: value written into the active movie's total so
# the player ends it immediately (its loop tears down when frame# >= total - 3).
# RE: player loop FUN_8001f1c0 / consumer FUN_8001efe8 (see ENHANCEMENTS.md E1).
fmv_skip_total_table = 0x80077728
fmv_skip_movie_id = 0x1F8001CD
fmv_skip_end_total = 3
aspect_ratio = "4:3"
# Controller input defaults. Tomba is analog-capable (the box reads "Analog
# Control Compatible"): in analog mode the left stick gives variable run speed.
# default_analog presents a DualShock/analog pad (id 0x73) out of the box so that
# path is live without toggling DualShock in the launcher — the left stick is
# variable, and the D-pad / keyboard arrows are folded onto the analog axes at
# full deflection so they still move the character. This is the "seamless d-pad /
# analog" behaviour of the Special Edition. The launcher's Player cards expose a
# per-port DualShock toggle and Settings → Controller a deadzone control; both
# persist to settings.toml and override these. deadzone is the analog centre
# dead-band / stick->d-pad threshold in raw SDL axis units (0..32767; 12000≈37%).
[controller]
default_analog = true
deadzone = 12000
# LEGACY pad-config compatibility (Tomba "Hybrid" controller).
#
# The launcher "Hybrid" mode — our first controller implementation, built to
# reproduce Tomba: Special Edition's seamless analog/digital feel — flips the
# emulated pad's reported type (digital 0x41 <-> DualShock/analog 0x73) as you
# move between the d-pad and the stick. That flip worked when the SIO pad answered
# the DualShock config commands trivially (always config id 0xF3).
#
# We then matured the pad against Mega Man X6, which needs a REAL config-mode
# state machine to probe the pad before polling (the trivial "always 0xF3" answer
# wedged it / killed its input). That modern SM became the default. Under it,
# Tomba's Hybrid flip regressed: a type change makes libpad re-detect the pad and
# manufacture a 1-frame "pad unplugged", which Tomba reads as a disconnect (menu
# unpause / phantom input) and MMX6 reads as a phantom dash. We could not make the
# modern SM keep the flip benign, so this per-game opt-in restores the original
# "always 0xF3" answers for Tomba ONLY — making the Hybrid flip benign exactly as
# the real game expects. Every other title (MMX6, etc.) keeps the modern SM.
#
# This is LEGACY: once the controller re-detect is refactored so the flip is
# benign for every game (real hardware tolerates unlimited analog-button presses),
# this opt-in and its runtime machinery should be removed. See psxrecomp
# runtime/src/sio.c g_pad_legacy_cfg for the full story. Default off.
legacy_pad_config = true
# Widescreen proportion hooks (inert at 4:3; engaged when aspect_ratio is
# wide). Evidence: Ghidra pass 2026-06-12 — every character/billboard render
# function (RTPS cluster 0x800459E0-0x8004FB54, both the rotatable-POLY_FT4
# and multi-part-SPRT patterns) calls the shared per-prim helper 0x8005E08C
# with the prim pointer in $a0, after the RTPS preamble stores the projected
# anchor SXY to scratchpad 0x1F800070. Sprite sizes are fixed pixels (never
# depth-scaled). The HUD/menus build SPRT prims with no GTE involvement.
[widescreen]
sprite_tag_funcs = ["0x8005E08C"]
sprite_anchor_addr = "0x1F800070"
hud_sprt_squash = true
# Native-wide HUD-at-16:9-corners is DEFERRED (see psxrecomp ENHANCEMENTS.md,
# "Tomba HUD corner re-anchoring"): the plain thirds shift tears composites
# that span zones (in-world dialog boxes split their end caps to the screen
# edges) and the AP counter renders through the TAGGED sprite funnel so it
# doesn't move. Needs composite-group awareness before it can ship.
nw_hud_corners = false
# World-space draw-cull widening. The per-object classifier family tests
# (objX - camX + BIAS) <u RANGE, where BIAS/RANGE are sized for the 4:3 screen
# (320 + 64px margins). The GTE squash shows ~33% more world, collapsing the
# effective margin to ~11px so objects pop near the wide-screen edges. The
# recompiler emits these immediates with + psx_ws_x_margin() (bias) / + 2x
# (range), which is 0 at 4:3 so the original cull is byte-identical. Sites are
# Ghidra-evidenced (classifier family 0x80022C08-0x80023164; camX=0x1F800176).
[widescreen.cull]
# addiu rT,rS,imm → rT = rS + (imm + margin)
bias_sites = ["0x80022E78", "0x80022F94", "0x80022FE8", "0x80023068"]
# sltiu rT,rS,imm → rT = rS <u (imm + 2*margin)
range_sites = ["0x80022E80", "0x80022F9C", "0x80022FF0", "0x80023070"]
# nop (delay slot) → a1 += margin (caller-supplied-margin classifier variants)
a1_sites = ["0x80022C34", "0x80022D68", "0x800230FC"]
# Automatic horizontal-FOV cull widening (the dominant fix for native-wide /
# Issue #8). The GTE render funnel (RTPT tri/quad emitters ~0x80024CFC.. and
# RTPS billboard emitters ~0x800487A4..) rejects a primitive only when ALL its
# vertices fall off the 4:3 frame: a per-vertex `sltiu vN, SX, 0x140` (right
# edge) paired with `sltiu vN, SY, 0xE0` (bottom edge) in the same function.
# That hard 320 clip — not the world-space classifier above (which already
# carries a ±64px margin and is a proven no-op for 16:9) — is what leaves the
# revealed right margin empty. Rather than list all ~100 identical sites, the
# recompiler auto-detects that signature and emits every width compare with
# + 2*psx_ws_x_margin(), so geometry out to the wide-surface edge is submitted
# instead of culled at 320. Generic to any PSX GTE title; 0 at 4:3 ⇒ the build
# is byte-identical with widescreen off. Changing this requires a regen.
auto_screen_x = true
# Far-backdrop column PRELOAD. The scrolling-background tile-grid generators
# (sky / ground / cloud / flower-field layers) emit only a camera-windowed ~4:3
# range of tile columns per frame, so the 16:9-revealed right/left margin shows
# void until the camera moves. The recompiler auto-detects each column-window
# generator by its invariant (/96 magic 0x66666667 dividing the 0x176 camera-X,
# the sra-by-5 divide tail, and the move/addiu loop-bound finalize) and rewrites
# the window START to 0 and END high; the generator's own low/high clamps then
# pin the loop to the whole finite tile row, so the entire backdrop is emitted
# regardless of camera. Generators are OVERLAY-resident (so the overlay compile
# must see this via --ws-config). Generic to any PSX scroller; 0 at 4:3 ⇒
# byte-identical with widescreen off. Changing this requires a regen + overlay
# cache rebuild.
auto_backdrop = true
# Parallax 2D backdrop layer (ocean / cloud / mountain / grassy-top). These
# OVERLAY actor handlers compute screenX = (worldX - camX) >> parallax in pure
# integer math and store it to the object's screen-X field (+0x12) WITHOUT the
# GTE, so the GTE X-squash that gives 3D the wider 16:9 FOV never reaches them:
# far pieces sit past the 320px framebuffer edge and are clipped, showing as the
# blue void / half-drawn rectangles at the screen edges (Issue #8C). Each site
# is the FINAL `sh rt,0x12(base)` store in a backdrop handler; the recompiler
# emits it as write_half(.., psx_ws_backdrop_x(rt)) to squash screenX around the
# screen centre by the same factor as the GTE (identity at 4:3). Overlay code,
# so compile_overlays.py forwards this via --ws-config; changing it needs a
# regen + overlay-cache rebuild. Each handler must be VERIFIED to draw far
# backdrop (NOT UI) before enabling — a parallax+camera signature ALSO matches
# screen-space UI positioned relative to the camera:
# 0x801216BC type0 store 0x801217B4 (>>3, single prim) = far backdrop ✓
# 0x80121810 type1 store 0x8012196C (>>1, animated tex) = midground row ✓
# Draws a row of 4 evenly-spaced parallax pieces (worldX 540/680/820/960,
# fixed screenY 130) that stay active with NO dialogue on screen — proven
# backdrop, not the dialogue box. The a3 ±0x40 "tiling loop" only CYCLES the
# texpage/CLUT word at +0x8 (animated-texture shimmer), it does not lay out
# text tiles. 8C right-edge culling (slots 4/5) is this row clipping while
# unsquashed; enabling the squash pulls them in (slot4 410→316 on-screen).
# NB: 8F's dialogue-split is the SEPARATE sprite_tag/hud_sprt_squash path,
# NOT this handler — an earlier session mis-blamed 0x80121810 for it.
# (Data-confirmed firing; live in-scene dialogue smoke-check still pending.)
# 0x80121A74 type2 store 0x80121BAC (>>3 + clamps) = UNVERIFIED
# far-right area gate (camX>=0x719, not active in start area); left out
# until confirmed backdrop vs UI.
[widescreen.backdrop]
x_sites = ["0x801217B4", "0x8012196C"]
# Far-backdrop GTE un-squash (8C). The far parallax backdrop (ocean / cloud /
# distant mountain) is GTE-projected 3D drawn by FUN_8004db3c (sole caller of
# the GTE emitter FUN_80027600). The global widescreen GTE X-squash repositions
# it toward centre, so its 4:3-extent geometry falls short of the revealed 16:9
# edges -> dark-blue void that "pops" as you walk (each layer crosses its 4:3
# coverage threshold). A/B (ws_aspect 16/9 vs 1/1, same camX) confirmed: squash
# OFF fills the frame, squash ON reveals beyond the backdrop's extent. Fix:
# bracket the driver with gte_ws_set_suppress(1)/(0) so the far backdrop draws
# UN-squashed (fills the stretched frame, skybox-style) while the near 3D world
# keeps the wider FOV. Main-EXE address -> regen-class (native emit fires in dev).
unsquash_funcs = ["0x8004DB3C"]
# ── Audit-specific config (consumed by psxrecomp/tools/*.py and tests) ──
#
# Schema mirrors psxrecomp/bios/SCPH1001.toml. Paths are relative to the
# TombaRecomp project root.
[audit]
# Dynamic discovery — no Ghidra function-starts file for Tomba; the
# recompiler discovers functions by walking control flow from entry_pc.
# function_starts left absent on purpose.
# Code region: Tomba's text segment is contiguous from load_address for
# text_size bytes. Single region keeps L1/coverage audits scoped to code.
[[audit.regions]]
name = "Text"
rom_start = "0x800" # offset within the PS-X EXE file (skip header)
rom_end = "0x88800" # rom_start + text_size
vaddr_base = "0x80010000"
# Address normalization: just KSEG masking. No remaps (game loads
# contiguously at vaddr_base; no kernel-style relocation).
[audit.normalize]
kseg_mask = "0x1FFFFFFF"
+5 -1
View File
@@ -53,8 +53,12 @@ enum {
AUDIO_EV_UNDERRUN = 4, /* host queue empty at pump entry; a=queued bytes */
AUDIO_EV_MUTE = 5, /* turbo mute engaged; a=fade tail frames */
AUDIO_EV_UNMUTE = 6, /* pumping resumed; a=fade-in frames pending */
AUDIO_EV_CD_PUSH = 7, /* a=frames pushed, b=CD ring fill after */
AUDIO_EV_CD_PUSH = 7, /* a=guest-cycle clock low32, b=ring fill after */
AUDIO_EV_DMA_WRITE = 8, /* SPU RAM DMA; a=words, b=transfer addr after */
AUDIO_EV_XA_ZERO = 9, /* zero-run in decoded XA PCM; a=lba,
b=(stage<<28)|(start_frame<<14)|run_len.
stage 0=post-ADPCM-decode (native rate),
stage 1=post-resample+volume (44100). */
};
typedef struct {
+10
View File
@@ -113,6 +113,16 @@ typedef struct CDROMDebugState {
uint32_t last_sector_frame;
uint8_t last_sector_mode;
uint8_t last_sector_have_raw;
/* Read-stream hold accounting: guest cycles the disc clock was frozen
* waiting on guest INT-ack / buffer drain (unfaithful pause; real discs
* never stop). Must stay 0 since the 2026-07-10 continuous-disc fix;
* nonzero = regression. */
uint64_t read_hold_cycles;
uint64_t read_hold_events;
/* One-deep pended data-ready INT1 accounting (Beetle SetAIP analog). */
uint64_t int1_pended;
uint64_t int1_lost;
uint8_t int1_pending_now;
} CDROMDebugState;
typedef struct CDROMSectorDebugState {
+2
View File
@@ -21,6 +21,8 @@ struct CPUState;
#define IRQ_SIO1 8
#define IRQ_SPU 9
#define IRQ_PIO 10
#define IRQ_MDEC_IN 11
#define IRQ_MDEC_OUT 12
void interrupts_init(void);
+33 -4
View File
@@ -4,6 +4,7 @@
#include <string>
#include <fstream>
#include <vector>
#include <map>
/**
* ISO/BIN/CUE Reader for PS1 CD-ROM images
@@ -19,6 +20,18 @@
namespace PS1 {
// PS1 CD-ROM sector size (Mode 2, Form 1 user data)
constexpr size_t SECTOR_SIZE = 2048;
// Full sector size including headers/subchannel (2352 bytes for raw BIN files)
constexpr size_t RAW_SECTOR_SIZE = 2352;
// Offset to user data in raw sector (Mode 2, Form 1)
constexpr size_t RAW_DATA_OFFSET = 24;
// Primary Volume Descriptor location
constexpr uint32_t PVD_SECTOR = 16;
/**
* Information about a file entry in the ISO filesystem
*/
@@ -44,9 +57,10 @@ struct RootDirectoryInfo {
* commands (GetTN/GetTD) must report them.
*/
struct CDTrack {
int number; // 1-based track number
int number; // 1-based track number
bool is_audio; // true = CD-DA audio (Red Book); false = data
uint32_t start_lba; // .bin-relative start LBA (cue INDEX 01; track 1 = 0)
uint32_t start_lba; // absolute disc LBA (absolute from start of disc)
std::string bin_path; // path to the .bin file for this track
};
class ISOReader {
@@ -187,12 +201,27 @@ private:
*/
std::vector<ISOFileEntry> ListFilesByLBA(uint32_t lba, uint32_t dir_size);
std::ifstream file_;
/**
* Find the track index for a given LBA
* @param lba Logical Block Address
* @return Index in tracks_ vector, or -1 if not found
*/
int FindTrackForLBA(uint32_t lba) const;
/**
* Open a BIN file for a specific track
* @param track_idx Index in tracks_ vector
* @return true if opened successfully
*/
bool OpenTrackBIN(int track_idx);
std::ifstream file_;
bool is_open_;
std::string volume_id_;
std::string bin_path_;
RootDirectoryInfo root_dir_;
std::vector<CDTrack> tracks_; // from the .cue TOC; >=1 entry after Open()
};
std::map<std::string, std::ifstream> bin_files_; // per-track BIN files
};
} // namespace PS1
+1 -1
View File
@@ -1134,7 +1134,7 @@ static void h_audio_events(int id, const char *json) {
uint64_t total = audio_trace_events_total();
static const char *kind_names[] = {
"?", "REG", "RENDER", "SKIP", "UNDERRUN",
"MUTE", "UNMUTE", "CD_PUSH", "DMA"
"MUTE", "UNMUTE", "CD_PUSH", "DMA", "XA_ZERO"
};
send_fmt("{\"id\":%d,\"ok\":true,\"total\":%llu,\"count\":%u,\"events\":[",
id, (unsigned long long)total, (unsigned)got);
+201 -45
View File
@@ -14,6 +14,7 @@
#include "dma.h"
#include "spu.h"
#include "event_ring.h"
#include "audio_trace.h"
#include <string.h>
#include <stdlib.h>
#ifndef _WIN32
@@ -169,8 +170,17 @@ static uint8_t filter_file;
static uint8_t filter_channel;
static uint8_t cd_muted;
#define XA_SUBHEADER_OFFSET 16
static int cd_audio_sector_size; /* 2352 for raw audio sectors */
/* CD-DA (Red Book) audio playback state */
static int cd_audio_playing;
static int cd_audio_track;
static int cd_audio_lba;
static int cd_audio_end_lba;
/* FMV auto-skip detection hooks (cdrom.c / mdec.c). */
#define XA_DATA_OFFSET 24
#define XA_SUBHEADER_OFFSET 16
#define XA_SOUND_GROUPS 18
#define XA_NATIVE_FRAMES (XA_SOUND_GROUPS * 8 * 28)
#define XA_MAX_44100_FRAMES 9408
@@ -390,6 +400,19 @@ static void exec_command(uint8_t cmd);
/* ISO reader */
static void* iso_handle = NULL;
/* Convert LBA to track number (1-based, 0 if not found) */
static int lba_to_track(int lba) {
if (!iso_handle) return 0;
int tracks = iso_track_count(iso_handle);
for (int t = 1; t <= tracks; t++) {
uint32_t start = iso_track_start_lba(iso_handle, t);
uint32_t next_start = iso_track_start_lba(iso_handle, t + 1);
if (next_start == 0) next_start = iso_sector_count(iso_handle);
if (lba >= (int)start && lba < (int)next_start) return t;
}
return 0;
}
static CDROMTraceEntry cdrom_trace[CDROM_TRACE_CAP];
static uint64_t cdrom_trace_seq;
@@ -770,7 +793,31 @@ static int xa_resample_to_44100(const int16_t* in, int in_frames,
return out_frames;
}
static int maybe_deliver_xa_audio(const uint8_t* raw_data,
/* Always-on XA zero-run scanner (audio_trace event ring). Decoded XA music
* must not contain long exact-zero spans when the source sectors are dense;
* a run here localizes corruption to a pipeline stage (stage 0 = straight
* out of the ADPCM decoder at native rate, stage 1 = after resample +
* decode-volume, i.e. exactly what spu_cd_audio_push receives). */
static void xa_zero_scan(const int16_t *stereo, int frames, int lba,
int stage) {
int run = 0, start = 0;
for (int i = 0; i <= frames; i++) {
int z = (i < frames) && stereo[i * 2 + 0] == 0 && stereo[i * 2 + 1] == 0;
if (z) {
if (!run) start = i;
run++;
} else if (run) {
if (run >= 64)
audio_trace_event(AUDIO_EV_XA_ZERO, (uint32_t)lba,
((uint32_t)stage << 28) |
((uint32_t)(start & 0x3FFF) << 14) |
(uint32_t)(run > 0x3FFF ? 0x3FFF : run));
run = 0;
}
}
}
static int maybe_deliver_xa_audio(const uint8_t* raw_data, int lba,
const CDROMSectorDelivery *delivery) {
if (!(mode_reg & 0x40u) || !raw_data || !delivery || cd_muted) return 0;
if (!xa_is_audio_realtime(delivery)) return 0;
@@ -810,11 +857,13 @@ static int maybe_deliver_xa_audio(const uint8_t* raw_data,
int native_frames = stereo
? xa_decode_sector_4bit_stereo(raw_data + XA_DATA_OFFSET, native)
: xa_decode_sector_4bit_mono(raw_data + XA_DATA_OFFSET, native);
xa_zero_scan(native, native_frames, lba, 0);
int out_frames = xa_resample_to_44100(native, native_frames, sample_rate,
pcm_44100, XA_MAX_44100_FRAMES);
/* Volume is applied after resampling, per PS1 hardware tests (Beetle
* cdc.cpp GetCDAudio comment). */
cd_apply_decode_volume(pcm_44100, out_frames);
xa_zero_scan(pcm_44100, out_frames, lba, 1);
spu_cd_audio_push(pcm_44100, out_frames);
trace_cdrom('A', 0,
((uint32_t)file << 24) | ((uint32_t)channel << 16) |
@@ -845,7 +894,7 @@ static int read_sector_at(int min, int sec, int sect) {
delivery = classify_raw_sector(raw_data, have_raw);
delivery.xa_audio_delivered =
(uint8_t)maybe_deliver_xa_audio(raw_data, &delivery);
(uint8_t)maybe_deliver_xa_audio(raw_data, lba, &delivery);
delivery.data_delivered = 1;
if (delivery.xa_audio_delivered ||
((mode_reg & 0x08u) && xa_is_audio_realtime(&delivery))) {
@@ -929,8 +978,31 @@ static void clear_sector_buffer(void) {
request_reg &= (uint8_t)~CDROM_REQUEST_BFRD;
}
/* One-deep asynchronous data-ready notification, mirroring Beetle
* PS_CDC::SetAIP/CheckAIP (cdc.cpp:829,816): a data sector that comes due
* while the guest still has an unacked controller INT does NOT stop disc
* time — the sector buffer is overwritten on schedule (hardware clobbers
* the FIFO the same way) and its INT1 pends here until the ack clears
* irq_flag. If ANOTHER data sector lands while one is still pending, the
* old notification is lost exactly like Beetle's "Previous notification
* skipped" warning (counted, traced 'P'). */
static uint8_t pending_dataready; /* 0/1: INT1 awaiting presentation */
static uint8_t pending_dataready_stat; /* stat_reg snapshot at pend time */
static uint64_t s_int1_pended; /* INT1s that had to wait for ack */
static uint64_t s_int1_lost; /* pended INT1s replaced unseen */
/* Drive-state changes (Read/Play/Pause/Stop/Seek) cancel a pended
* notification, matching Beetle's ClearAIP in every such command. */
static void cdrom_clear_pending_dataready(void) {
pending_dataready = 0;
pending_dataready_stat = 0;
}
static void start_read_stream(uint8_t cmd) {
clear_sector_buffer();
/* Drive-state change cancels any pended notification (Beetle clears
* AIP on Play/Read/Pause/Stop/Seek alike). */
cdrom_clear_pending_dataready();
if (mode_reg & 0x40u) {
xa_reset_decode();
spu_cd_audio_reset();
@@ -952,6 +1024,7 @@ static void stop_read_stream(void) {
reading = 0;
read_cmd = 0;
read_delay = 0;
cdrom_clear_pending_dataready();
}
static int data_fifo_ready(void) {
@@ -1138,19 +1211,23 @@ static void exec_command(uint8_t cmd) {
pending.phase = 1;
break;
case 0x08: /* Stop — stop the motor. Two-phase like Pause: INT3 (ACK) now
* with the pre-stop status (motor still spinning), then a pending
* INT2 (COMPLETE) after the motor spins down, reporting the new
* status with the motor bit cleared (psx-spx "08h Stop").
*
* Previously 0x08 had no case and fell through to default ->
* CDIRQ_ERROR (INT5). A game that stops the drive on a scene
* change then waits for the Stop completion IRQ never sees it and
* hangs: Tsumu Light's CD library retries Stop forever (~90-frame
* timeout) and never advances past its first content load. */
case 0x08: /* Stop — stop the motor. Two-phase like Pause: INT3 (ACK) now
* with the pre-stop status (motor still spinning), then a pending
* INT2 (COMPLETE) after the motor spins down, reporting the new
* status with the motor bit cleared (psx-spx "08h Stop").
*
* Previously 0x08 had no case and fell through to default ->
* CDIRQ_ERROR (INT5). A game that stops the drive on a scene
* change then waits for the Stop completion IRQ never sees it and
* hangs: Tsumu Light's CD library retries Stop forever (~90-frame
* timeout) and never advances past its first content load. */
stop_read_stream();
xa_reset_decode();
spu_cd_audio_reset();
/* Stop CD-DA audio playback */
cd_audio_playing = 0;
cd_audio_lba = cd_audio_end_lba = 0;
cd_audio_track = 0;
stat_reg &= ~(CDSTAT_READ | CDSTAT_PLAY | CDSTAT_SEEK);
response_push(stat_reg);
set_irq(CDIRQ_ACK);
@@ -1165,6 +1242,10 @@ static void exec_command(uint8_t cmd) {
stop_read_stream();
xa_reset_decode();
spu_cd_audio_reset();
/* Stop CD-DA audio playback */
cd_audio_playing = 0;
cd_audio_lba = cd_audio_end_lba = 0;
cd_audio_track = 0;
stat_reg &= ~CDSTAT_READ;
response_push(stat_reg);
set_irq(CDIRQ_ACK);
@@ -1322,12 +1403,12 @@ static void exec_command(uint8_t cmd) {
break;
}
case 0x03: /* Play — start CD-DA audio playback (from SetLoc, or optional
* param[0] = BCD track). Multi-track / CD-DA discs (Tomba 2's
* Whoopee Camp jingle) issue this; an unhandled Play (default ->
* ERROR) stalls the boot. Ack + enter PLAY state so the game's
* audio sequence proceeds. (Red Book sample output is not yet
* decoded -- silent for now -- but the flow no longer stalls.) */
case 0x03: /* Play — start CD-DA audio playback (from SetLoc, or optional
* param[0] = BCD track). Multi-track / CD-DA discs (Tomba 2's
* Whoopee Camp jingle) issue this; an unhandled Play (default ->
* ERROR) stalls the boot. Ack + enter PLAY state so the game's
* audio sequence proceeds. Start streaming Red Book audio sectors
* to the SPU CD input ring. */
if (!has_disc()) {
response_push(stat_reg | CDSTAT_ERROR);
set_irq(CDIRQ_ERROR);
@@ -1336,6 +1417,17 @@ static void exec_command(uint8_t cmd) {
stat_reg = (stat_reg & ~(CDSTAT_SEEK | CDSTAT_READ)) | CDSTAT_MOTOR | CDSTAT_PLAY;
response_push(stat_reg);
set_irq(CDIRQ_ACK);
/* Start CD-DA audio streaming from current SetLoc position */
cd_audio_lba = msf_to_lba(seek_min, seek_sec, seek_sect);
cd_audio_track = lba_to_track(cd_audio_lba);
if (cd_audio_track > 0 && iso_track_is_audio(iso_handle, cd_audio_track)) {
cd_audio_end_lba = iso_track_start_lba(iso_handle, cd_audio_track + 1);
if (cd_audio_end_lba == 0) cd_audio_end_lba = iso_sector_count(iso_handle);
cd_audio_playing = 1;
xa_reset_decode();
spu_cd_audio_reset();
cd_audio_sector_size = RAW_SECTOR_SIZE;
}
break;
case 0x15: /* SeekL (data-mode seek, uses sector headers) */
@@ -1529,33 +1621,24 @@ static void process_pending(uint32_t cycles) {
}
}
/* Read-stream hold accounting (always-on). Before 2026-07-10 the stream
* FROZE read_delay whenever the guest had not yet acked the previous INT.
* On real hardware the disc never pauses; the accumulated freeze made XA
* sectors arrive ~1.5-3% late, starving the XA->SPU ring ~5-6x/second for
* ~147 samples each = the audible 6 Hz music crackle (measured on MMX4's
* attract/title XA streams). The freeze is gone; these counters must now
* stay at zero and exist as a regression tripwire (cdrom_state JSON). */
static uint64_t s_read_hold_cycles;
static uint64_t s_read_hold_events;
static void process_read_stream(uint32_t cycles) {
if (!reading) return;
/*
* The controller exposes one sector buffer. Do not let the stream timer
* accumulate a data backlog while software is still handling the previous
* data-ready IRQ. Once software acknowledges that IRQ, any unread tail is
* discardable: later sectors replace the controller's single data buffer.
*
* Software can also start a multi-sector CD DMA from inside the
* data-ready callback before acknowledging the IRQ. In that case the IRQ
* line remains asserted, but the disc stream must still refill an empty
* sector buffer so the active DMA can continue.
*/
/* Serialized CD responses: hold the read stream while a controller INT is
* unacked (presenting a new sector INT would clobber the single irq_flag /
* response FIFO the guest is still reading), or while the single sector
* buffer holds unread data. The DMA-drain path is the exception: during an
* active CD DMA the data-ready INT stays asserted while the guest drains
* the sector via DMA, and the stream must still refill the buffer.
*
* CRITICAL: freeze read_delay while held. Decrementing it during the hold
* (the previous shape) accumulates fake time debt and underflows hard once
* the guest finally acks, so the next sector is never scheduled correctly. */
if (irq_flag != 0 && !dma_cdrom_transfer_active()) return;
if (sector_available && irq_flag != 0) return;
/* Disc time NEVER pauses while the drive reads (Beetle cdc.cpp
* HandleSectorRead: the sector pipeline advances on cycle deadlines
* regardless of INT ack state). XA-ADPCM realtime audio flows to the
* SPU decoder unconditionally inside read_sector_at — gating it on the
* guest's INT-ack latency is what caused the 6 Hz XA dropouts. */
if (cycles > 0) {
read_delay -= (int)cycles;
}
@@ -1566,8 +1649,26 @@ static void process_read_stream(uint32_t cycles) {
trace_cdrom('O', 0, (uint32_t)sector_read_pos, 0);
}
deliver_read_sector();
} else {
} else if (dma_cdrom_transfer_active()) {
/* Active multi-sector CD DMA with the data-ready INT still
* asserted: refill the buffer so the DMA keeps draining, no new
* INT (the historical shape; games start the DMA inside the
* data-ready callback before acking). */
deliver_read_sector_without_irq();
} else {
/* Guest hasn't acked the previous INT yet. Read the sector on
* schedule (XA audio + buffer overwrite happen inside), and
* pend its data-ready INT1 one deep. */
int delivered = deliver_read_sector_without_irq();
if (delivered) {
if (pending_dataready) {
s_int1_lost++;
trace_cdrom('P', 0, (uint32_t)last_sector_lba, 0);
}
pending_dataready = 1;
pending_dataready_stat = stat_reg;
s_int1_pended++;
}
}
read_delay += sector_delay_cycles();
/* Clamp pathological underflow to one sector period: never replay a
@@ -1576,6 +1677,49 @@ static void process_read_stream(uint32_t cycles) {
read_delay = sector_delay_cycles();
}
}
/* CD-DA (Red Book) audio streaming: runs independently of data read stream.
* While Play (0x03) is active and we're on an audio track, continuously
* feed raw 2352-byte sectors to the SPU CD input ring at 75 sectors/sec. */
if (cd_audio_playing && cd_audio_lba < cd_audio_end_lba && iso_handle) {
uint8_t raw_data[RAW_SECTOR_SIZE];
if (iso_read_raw_sector(iso_handle, cd_audio_lba, raw_data, RAW_SECTOR_SIZE)) {
/* Red Book CD-DA: 2352 bytes of raw interleaved stereo PCM,
* no sync/header/subheader to skip (unlike data sectors). */
int16_t stereo[1176]; /* 588 stereo samples = 2352 bytes */
int frames = 0;
for (int i = 0; i < 2352; i += 4) {
int16_t l = (int16_t)(raw_data[i] | (raw_data[i + 1] << 8));
int16_t r = (int16_t)(raw_data[i + 2] | (raw_data[i + 3] << 8));
stereo[frames * 2] = l;
stereo[frames * 2 + 1] = r;
frames++;
}
spu_cd_audio_push(stereo, frames);
}
cd_audio_lba++;
/* Auto-stop at end of track or end of disc */
if (cd_audio_lba >= cd_audio_end_lba) {
cd_audio_playing = 0;
cd_audio_lba = cd_audio_end_lba = 0;
cd_audio_track = 0;
spu_cd_audio_reset();
stat_reg = (stat_reg & ~CDSTAT_PLAY) | CDSTAT_MOTOR;
}
}
}
/* Present a pended data-ready INT1 the moment the guest fully acks the
* previous INT (Beetle CheckAIP: async results present as soon as the IRQ
* register clears). Called from the irq_flag ack write. */
static void present_pending_dataready(void) {
if (!pending_dataready || irq_flag != 0) return;
pending_dataready = 0;
response_clear();
response_push(pending_dataready_stat);
set_irq(CDIRQ_DATA_READY);
fire_cdrom_irq();
s_dataready_fires++;
}
void cdrom_init(const char* cue_path) {
@@ -1615,6 +1759,7 @@ void cdrom_init(const char* cue_path) {
cd_muted = 0;
xa_reset_decode();
spu_cd_audio_reset();
cd_audio_sector_size = RAW_SECTOR_SIZE;
pending.pending = 0;
memset(&queued_cmd, 0, sizeof(queued_cmd));
seek_min = seek_sec = seek_sect = 0;
@@ -1745,6 +1890,10 @@ void cdrom_write(uint32_t addr, uint32_t value) {
if (val & 0x40) {
param_count = 0;
}
/* A fully-acked INT presents any pended data-ready first
* (Beetle CheckAIP on IRQ-register clear); a queued command
* then waits behind that INT1's own ack cycle. */
present_pending_dataready();
try_execute_queued_command();
}
break;
@@ -1876,6 +2025,11 @@ void cdrom_debug_snapshot(CDROMDebugState* out) {
out->filter_channel = filter_channel;
out->muted = cd_muted;
out->read_delay = read_delay;
out->read_hold_cycles = s_read_hold_cycles;
out->read_hold_events = s_read_hold_events;
out->int1_pended = s_int1_pended;
out->int1_lost = s_int1_lost;
out->int1_pending_now = pending_dataready;
out->pending_pending = pending.pending;
out->pending_delay = pending.delay;
out->pending_phase = pending.phase;
@@ -1992,7 +2146,9 @@ int cdrom_load_in_progress(void) {
/* disc-speed timing model */ \
X(g_disc_speed_divisor) X(g_game_divisor) X(g_instant_max_per_frame) \
/* pending (delayed second response) + queued command */ \
X(pending) X(queued_cmd)
X(pending) X(queued_cmd) \
/* one-deep pended data-ready INT1 (Beetle SetAIP analog) */ \
X(pending_dataready) X(pending_dataready_stat)
uint32_t cdrom_snapshot_bytes(void){ uint32_t n=0;
#define X(f) n += (uint32_t)sizeof(f);
+8 -1
View File
@@ -4735,6 +4735,8 @@ static void handle_cdrom_state(int id, const char *json)
"\"sector_available\":%d,\"sector_read_pos\":%d,\"sector_size\":%d,"
"\"reading\":%d,\"read_msf\":[%d,%d,%d],"
"\"read_cmd\":\"0x%02X\",\"read_delay\":%d,"
"\"read_hold_cycles\":%llu,\"read_hold_events\":%llu,"
"\"int1_pended\":%llu,\"int1_lost\":%llu,\"int1_pending_now\":%u,"
"\"filter_file\":%u,\"filter_channel\":%u,\"muted\":%u,"
"\"seek_msf\":[%u,%u,%u],"
"\"pending\":{\"cmd\":\"0x%02X\",\"active\":%d,\"delay\":%d,\"phase\":%d},"
@@ -4748,6 +4750,11 @@ static void handle_cdrom_state(int id, const char *json)
s.sector_available, s.sector_read_pos, s.sector_size,
s.reading, s.read_min, s.read_sec, s.read_sect,
s.read_cmd, s.read_delay,
(unsigned long long)s.read_hold_cycles,
(unsigned long long)s.read_hold_events,
(unsigned long long)s.int1_pended,
(unsigned long long)s.int1_lost,
s.int1_pending_now,
s.filter_file, s.filter_channel, s.muted,
s.seek_min, s.seek_sec, s.seek_sect,
s.pending_cmd, s.pending_pending, s.pending_delay,
@@ -5766,7 +5773,7 @@ static void handle_audio_events(int id, const char *json)
uint64_t total = audio_trace_events_total();
static const char *kind_names[] = {
"?", "REG", "RENDER", "SKIP", "UNDERRUN",
"MUTE", "UNMUTE", "CD_PUSH", "DMA"
"MUTE", "UNMUTE", "CD_PUSH", "DMA", "XA_ZERO"
};
size_t cap = 256u + (size_t)got * 192u;
+6
View File
@@ -2149,6 +2149,12 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
if (_gc) return 1;
}
clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0;
} else if (psx_game_address_in_text(addr)) {
/* Page diverged from reference image — compiled dispatch blocked.
* Still mark as a game-text miss so the interpreter gate at the
* bottom does not close (the page may be dirty from a self-mod
* that dirty_ram_mark_kernel_write didn't track). */
clean_game_text_miss = 1;
}
#endif
+42
View File
@@ -107,6 +107,7 @@ void cd_dma_log_get_entry(uint32_t idx, int *lba, uint32_t *dest, uint32_t *
* the storm and matches hardware (the SPU RAM payload still moves immediately;
* only the busy-bit clear + completion IRQ are deferred). */
#define DMA_SPU_CYCLES_PER_WORD 48u
#define DMA_PIO_CYCLES_PER_WORD 4u
/* DMA-execution provenance flags (read by memory.c's psx_write_word d44_note probe
* for the MMX6 VSync-callback-pointer corruption hunt). g_dma_exec_depth>0 means a
@@ -453,6 +454,14 @@ static void finish_async_mdec_transfer(int ch, uint32_t final_addr, uint32_t tot
complete_transfer(ch);
}
void mdec_finish_async_in_transfer(uint32_t final_addr, uint32_t total_words) {
finish_async_mdec_transfer(0, final_addr, total_words);
}
void mdec_finish_async_out_transfer(uint32_t final_addr, uint32_t total_words) {
finish_async_mdec_transfer(1, final_addr, total_words);
}
static void start_async_cdrom_transfer(void) {
DMAAsyncChannel *a = &cdrom_async;
if (a->active) return;
@@ -789,6 +798,36 @@ static void execute_ch6_otc(void) {
complete_transfer(6);
}
static void execute_ch5_pio(void) {
/* PIO (Parallel I/O) — used for expansion port / parallel port transfers.
* Very simple: just move words directly to/from RAM with no device interaction.
* Direction: 0 = to RAM (read from device), 1 = from RAM (write to device).
* For now, we just complete the transfer immediately as PIO devices are
* typically slow and the game handles timing via busy-wait on the port. */
uint32_t chcr = channels[5].chcr;
uint32_t direction = chcr & 1u;
uint32_t step = (chcr >> 1) & 1u;
uint32_t total_words = transfer_word_count(5);
uint32_t addr = channels[5].madr & 0x1FFFFCu;
int32_t addr_step = step ? -4 : 4;
if (direction == 1) {
/* from RAM to device: just read and discard */
for (uint32_t i = 0; i < total_words; i++) {
(void)psx_read_word(addr);
addr = (addr + addr_step) & 0x1FFFFCu;
}
} else {
/* to RAM from device: write zeros (device not emulated) */
for (uint32_t i = 0; i < total_words; i++) {
psx_write_word(addr, 0);
addr = (addr + addr_step) & 0x1FFFFCu;
}
}
channels[5].madr = addr;
complete_transfer(5);
}
static void try_execute(int ch) {
uint32_t chcr = channels[ch].chcr;
@@ -828,6 +867,9 @@ static void try_execute(int ch) {
schedule_delayed_complete(4, execute_ch4_spu(),
DMA_SPU_CYCLES_PER_WORD);
break;
case 5:
execute_ch5_pio();
break;
case 6:
execute_ch6_otc();
break;
+12 -1
View File
@@ -3122,8 +3122,19 @@ static void gpu_write_gp0_body(uint32_t val) {
return;
}
/* State: shaded polyline — alternating color, vertex words */
/* State: shaded polyline — alternating color, vertex words */
if (gp0_state == GP0_POLYLINE_SHADED) {
/* Terminator: hardware ends a polyline ONLY when the masked word
* matches 0x50005000 (the 0x55555555 terminator) — Beetle
* gpu.cpp:1030, psx-spx. The terminator check MUST be at the top,
* like in the mono polyline case, otherwise a terminator at a
* vertex position (polyline_has_prev == 2) won't be detected and
* will be treated as a vertex, de-phasing the command stream. */
if ((val & 0xF000F000u) == 0x50005000u) {
gp0_state = GP0_IDLE;
return;
}
/* Even words (after cmd) are colors, odd words are vertices.
* Sequence: [cmd+C0] [V0] [C1] [V1] [C2] [V2] ...
* polyline_has_prev tracks: 0=need V0, 1=need C_next, 2=need V_next */
+553 -558
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -6,6 +6,10 @@
extern uint64_t s_frame_count;
/* Forward declarations for DMA completion callbacks */
extern void mdec_finish_async_in_transfer(uint32_t final_addr, uint32_t total_words);
extern void mdec_finish_async_out_transfer(uint32_t final_addr, uint32_t total_words);
/* FMV-activity detector: frame stamp of the newest colour (15/24-bit) MDEC
* decode. Streamed video decodes every frame; texture decompression uses the
* 4/8-bit luma path and does not stamp. */
@@ -563,12 +567,12 @@ uint32_t mdec_dma_read_word(void) {
return value;
}
int mdec_dma_write_ready(void) {
if (mdec.output_pos < mdec.output_size) return 0;
bool mdec_dma_write_ready(void) {
if (mdec.output_pos < mdec.output_size) return false;
return !mdec.busy || mdec.input_count < mdec.expected_halfwords;
}
int mdec_dma_read_ready(void) {
bool mdec_dma_read_ready(void) {
return mdec.output_pos < mdec.output_size;
}
@@ -597,11 +601,11 @@ void mdec_debug_get_state(MDECDebugState *out) {
out->dma_read_underflows = mdec.dma_read_underflows;
}
uint64_t mdec_debug_get_event_total(void) {
return mdec_trace_seq;
uint32_t mdec_debug_get_event_total(void) {
return (uint32_t)mdec_trace_seq;
}
uint32_t mdec_debug_copy_events(uint64_t seq_lo, uint64_t seq_hi,
uint32_t mdec_debug_copy_events(uint32_t seq_lo, uint32_t seq_hi,
MDECDebugEvent *out, uint32_t max_count) {
if (!out || max_count == 0) return 0;
uint64_t oldest = (mdec_trace_seq > MDEC_TRACE_CAP) ? mdec_trace_seq - MDEC_TRACE_CAP : 0;
+83
View File
@@ -0,0 +1,83 @@
#ifndef PSX_MDEC_H
#define PSX_MDEC_H
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
void mdec_init(void);
uint32_t mdec_read0(void);
void mdec_write0(uint32_t data);
uint32_t mdec_read1(void);
void mdec_write1(uint32_t data);
void mdec_dma0(uint32_t adr, uint32_t bcr, uint32_t chcr);
void mdec_dma1(uint32_t adr, uint32_t bcr, uint32_t chcr);
int mdec_recently_active(uint32_t within_frames);
uint32_t mdec_read(uint32_t addr);
void mdec_write(uint32_t addr, uint32_t data);
bool mdec_dma_read_ready(void);
bool mdec_dma_write_ready(void);
uint32_t mdec_dma_read_word(void);
void mdec_dma_write_word(uint32_t data);
void mdec_debug_dma_in_start(uint32_t addr, uint32_t total_words);
void mdec_debug_dma_in_end(uint32_t addr, uint32_t total_words);
void mdec_debug_dma_out_start(uint32_t addr, uint32_t total_words);
void mdec_debug_dma_out_end(uint32_t addr, uint32_t total_words);
typedef struct {
uint32_t command;
uint32_t expected_halfwords;
uint32_t input_count;
uint32_t output_size;
uint32_t output_pos;
uint32_t output_depth;
uint32_t output_signed;
uint32_t output_bit15;
uint32_t busy;
uint32_t input_full;
uint32_t enable_dma_in;
uint32_t enable_dma_out;
uint32_t last_status;
uint32_t decode_macroblocks;
uint32_t decode_blocks;
uint32_t decode_stop_reason;
uint32_t decode_input_pos;
uint32_t decode_input_end;
uint32_t dma_in_words;
uint32_t dma_out_words;
uint32_t dma_read_underflows;
} MDECDebugState;
typedef struct {
uint64_t seq;
uint64_t frame;
uint32_t kind;
uint32_t command;
uint32_t expected_halfwords;
uint32_t input_count;
uint32_t output_size;
uint32_t output_pos;
uint32_t macroblocks;
uint32_t blocks;
uint32_t stop_reason;
uint32_t underruns;
uint32_t value;
} MDECDebugEvent;
void mdec_debug_get_state(MDECDebugState *state);
uint32_t mdec_debug_get_event_total(void);
uint32_t mdec_debug_copy_events(uint32_t seq_lo, uint32_t seq_hi, MDECDebugEvent *out, uint32_t max_out);
void mdec_debug_clear(void);
uint32_t mdec_get_decode_count(void);
#ifdef __cplusplus
}
#endif
#endif
+8
View File
@@ -175,6 +175,14 @@ static inline void text_guard_note_write(uint32_t phys, uint32_t val, int size)
if (memcmp(ref, buf, (size_t)size) != 0) {
uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT;
text_modified_bitmap[page >> 5] |= (1u << (page & 31u));
/* Also mark the page dirty in dirty_ram_bitmap so the interpreter can
* handle the modified code. Without this, a self-modifying game text
* page diverges from the reference image (text_diverged_bitmap set) but
* stays non-dirty (dirty_ram_mark_kernel_write is a no-op for
* phys >= 0x10000, and dirty_ram_clear_image_baseline cleared it).
* Result: compiled dispatch is blocked (diverged) AND interpreter
* dispatch is blocked (not dirty) → unknown dispatch → crash. */
dirty_ram_mark_page(phys);
}
}
+328 -5
View File
@@ -4,12 +4,15 @@
* This is intentionally still a compact hardware model: it accepts SPU
* register reads/writes, DMA4 transfers into 512KB SPU RAM, mixes the
* 24 direct ADPCM voices, and accepts decoded CD/XA audio on the SPU CD
* input bus. Reverb, noise, sweep volumes, and IRQ timing are not modeled yet.
* input bus. Reverb, noise generator, sweep volumes, pitch modulation,
* and IRQ timing are implemented.
*/
#include "spu.h"
#include "spu_shadow.h"
#include "audio_trace.h"
#include "psx_cycles.h"
#include "interrupts.h"
#include <string.h>
@@ -50,6 +53,109 @@ static SpuEvent s_events[SPU_EVENT_CAP];
static uint32_t s_event_idx = 0;
static uint64_t s_event_seq = 0;
/* ---- Reverb (simplified but functional PS1-style) -------------------- */
/* PS1 reverb is a complex delay-line network: a sum of multiple comb
* filters (feedback delays) and allpass filters. We implement a simplified
* but aurally convincing version: a single delay line with lowpass feedback
* (comb filter) plus a shorter early-reflection delay (allpass).
* Registers are at 0x1F801DC0-0x1F801DFF (indices 224-255 in spu_regs).
*/
#define REVERB_MAX_DELAY_SAMPLES (44100 * 2) /* 2 seconds max at 44.1 kHz */
#define REVERB_EARLY_DELAY_SAMPLES 441 /* ~10 ms for early reflections */
static float reverb_delay_line[REVERB_MAX_DELAY_SAMPLES * 2]; /* stereo */
static float early_delay_line[REVERB_EARLY_DELAY_SAMPLES * 2];
static uint32_t reverb_write_pos = 0;
static uint32_t early_write_pos = 0;
static float reverb_feedback = 0.0f;
static float reverb_wet_level = 0.0f;
static float early_level = 0.0f;
static int reverb_enabled = 0;
/* Reverb register indices (0x1F801DC0-0x1F801DFF = indices 224-255 in spu_regs) */
#define REVERB_REG_BASE 224 /* 0x1F801DC0 - 0x1F801C00 = 0x1C0 = 448 bytes = 224 regs */
static inline int16_t clamp16(int32_t v); /* forward decl for reverb */
static inline float reverb_lowpass(float input, float *state, float coeff) {
float out = *state + coeff * (input - *state);
*state = out;
return out;
}
static void reverb_process_stereo(int16_t* stereo, int frames) {
if (!reverb_enabled) return;
static float lp_state_l = 0.0f, lp_state_r = 0.0f;
static float early_lp_state_l = 0.0f, early_lp_state_r = 0.0f;
static float fb_state_l = 0.0f, fb_state_r = 0.0f;
for (int i = 0; i < frames; i++) {
float in_l = stereo[i * 2] / 32768.0f;
float in_r = stereo[i * 2 + 1] / 32768.0f;
/* Early reflections: short delay with lowpass */
float early_l = early_delay_line[early_write_pos * 2];
float early_r = early_delay_line[early_write_pos * 2 + 1];
early_l = reverb_lowpass(early_l, &early_lp_state_l, 0.3f);
early_r = reverb_lowpass(early_r, &early_lp_state_r, 0.3f);
/* Main reverb: long delay with feedback and lowpass */
float rev_l = reverb_delay_line[reverb_write_pos * 2];
float rev_r = reverb_delay_line[reverb_write_pos * 2 + 1];
rev_l = reverb_lowpass(rev_l, &lp_state_l, 0.7f);
rev_r = reverb_lowpass(rev_r, &lp_state_r, 0.7f);
/* Feedback */
float fb_in_l = in_l + rev_l * reverb_feedback + early_l * early_level;
float fb_in_r = in_r + rev_r * reverb_feedback + early_r * early_level;
fb_in_l = reverb_lowpass(fb_in_l, &fb_state_l, 0.5f);
fb_in_r = reverb_lowpass(fb_in_r, &fb_state_r, 0.5f);
/* Write to delay lines */
reverb_delay_line[reverb_write_pos * 2] = fb_in_l;
reverb_delay_line[reverb_write_pos * 2 + 1] = fb_in_r;
early_delay_line[early_write_pos * 2] = in_l * early_level;
early_delay_line[early_write_pos * 2 + 1] = in_r * early_level;
reverb_write_pos = (reverb_write_pos + 1) % REVERB_MAX_DELAY_SAMPLES;
early_write_pos = (early_write_pos + 1) % REVERB_EARLY_DELAY_SAMPLES;
/* Mix dry + wet */
float out_l = in_l + (rev_l + early_l) * reverb_wet_level;
float out_r = in_r + (rev_r + early_r) * reverb_wet_level;
stereo[i * 2] = (int16_t)clamp16(out_l * 32768.0f);
stereo[i * 2 + 1] = (int16_t)clamp16(out_r * 32768.0f);
}
}
static void reverb_update_params(void) {
if (spu_regs[REVERB_REG_BASE] & 0x8000) { /* Reverb master enable bit */
reverb_enabled = 1;
/* Delay time: register 224, bits 0-14 = delay in samples/8? */
uint32_t delay_reg = spu_regs[REVERB_REG_BASE];
int delay_samples = (delay_reg & 0x7FFF) * 8;
if (delay_samples > REVERB_MAX_DELAY_SAMPLES) delay_samples = REVERB_MAX_DELAY_SAMPLES;
/* We can't easily resize delay lines at runtime, so use max */
/* Feedback level: register 225, bits 0-14 */
uint16_t fb_reg = spu_regs[REVERB_REG_BASE + 1];
reverb_feedback = (fb_reg & 0x7FFF) / 32767.0f * 0.95f; /* max 95% */
/* Wet level: register 226 */
uint16_t wet_reg = spu_regs[REVERB_REG_BASE + 2];
reverb_wet_level = (wet_reg & 0x7FFF) / 32767.0f;
/* Early reflection level: register 227 */
uint16_t early_reg = spu_regs[REVERB_REG_BASE + 3];
early_level = (early_reg & 0x7FFF) / 32767.0f * 0.5f;
} else {
reverb_enabled = 0;
}
}
/* CD input FIFO, fed by the CD-ROM XA decoder at 44.1 kHz stereo. */
#define SPU_CD_RING_FRAMES (44100u * 8u)
static int16_t cd_ring[SPU_CD_RING_FRAMES * 2u];
@@ -81,8 +187,112 @@ typedef struct {
uint16_t env_level; /* 0..0x7FFF — applied to raw decoded sample */
uint32_t adsr_divider; /* fixed-point counter; level updates on overflow */
uint8_t adsr_phase; /* ADSR_ATTACK / DECAY / SUSTAIN / RELEASE */
/* Sweep volume state for per-voice L/R (PS1 sweep mode, bit 15 of vol reg). */
int32_t sweep_vol_l; /* current sweep-interpolated L volume (signed 14-bit) */
int32_t sweep_vol_r; /* current sweep-interpolated R volume (signed 14-bit) */
} SpuVoice;
/* ---- PS1 Noise Generator (LFSR) --------------------------------------- *
* The PS1 SPU uses a 16-bit LFSR with taps at bits 0, 2, 3, 5 to generate *
* noise for voices in noise mode. The LFSR advances at a rate controlled *
* by the Noise Generator Level register (0x1F801DA4). */
static uint16_t noise_lfsr = 0x0001;
static uint32_t noise_counter = 0;
static int16_t noise_output = 0;
/* ---- PS1 Pitch Modulation (PMON) --------------------------------------- *
* PMON registers at 0x1F801D90/1D92: bit N = voice N has pitch modulated *
* by the ADSR-mixed output of voice N-1. Voice 0 cannot be modulated. *
* Formula (PCSX-Redux FModChangeFrequency): *
* modulated_pitch = ((32768 + modulator_sample) * raw_pitch) / 32768 *
* where modulator_sample is voice N-1's ADSR-mixed output (±32767). *
* This scales pitch from ~0× to ~2× per output sample. */
static int16_t voice_mixed_output[SPU_VOICE_COUNT];
/* ---- PS1 SPU IRQ ------------------------------------------------------- *
* IRQ fires when the ADPCM decoder crosses the address in 0x1F801D9E *
* while IRQEnable is set in SPU control (bit 6 of 0x1F801DAA). *
* Raises i_stat bit 9 (0x200). */
static uint16_t spu_irq_addr = 0;
static int spu_irq_enabled = 0;
static int spu_irq_pending = 0;
/* PS1 noise LFSR: XOR taps at bits 0, 2, 3, 5 (polynomial 0x001B). */
static void noise_lfsr_step(void) {
uint16_t bit = (noise_lfsr ^ (noise_lfsr >> 2) ^
(noise_lfsr >> 3) ^ (noise_lfsr >> 5)) & 1u;
noise_lfsr = (noise_lfsr >> 1) | (bit << 15);
/* Convert to signed 16-bit sample — use the raw LFSR value. */
noise_output = (int16_t)noise_lfsr;
}
/* Check if voice v has its noise mode bit set in registers 0x1F801D94/D96. */
static inline int voice_noise_mode(int v) {
/* Inline the reg_index math to avoid forward-declaration issues. */
uint32_t base = (v < 16) ? 0x1F801D94u : 0x1F801D96u;
uint32_t idx = (base - 0x1F801C00u) >> 1;
uint32_t reg = spu_regs[idx];
return (reg >> (v & 15)) & 1u;
}
/* ---- Volume Sweep (PS1 auto-ramp for per-voice/main volumes) ----------- *
* Register format (bit 15 = 1 = sweep mode): *
* Bits 0-3: sweep shift (0-15) *
* Bits 4-5: sweep step (1/8, 2/8, 4/8, 8/8 of the increment) *
* Bit 6: direction (0 = increase, 1 = decrease) *
* Bits 7-14: target volume (8-bit, shifted left by 6) *
* The volume changes by (step+1) << shift per 44100/32 = 1378 samples, *
* approximated here per-output-sample. */
/* Decode a sweep-mode register into target, shift, step, and direction.
* Returns the initial sweep target volume (signed 14-bit). */
static inline void sweep_decode(uint16_t raw,
int32_t *target, int *shift,
int *step_bits, int *decrease) {
*shift = raw & 0xFu;
*step_bits = (raw >> 4) & 3u;
*decrease = (raw >> 6) & 1u;
*target = (int32_t)((raw >> 7) & 0x1FFu) * 64;
/* Clamp target to valid 14-bit range */
if (*target > 0x3FFF) *target = 0x3FFF;
if (*target < -0x4000) *target = -0x4000;
}
/* Advance sweep volume one step. Returns the updated volume. */
static inline int32_t sweep_tick(int32_t current, uint16_t raw) {
int32_t target;
int shift, step_bits, decrease;
sweep_decode(raw, &target, &shift, &step_bits, &decrease);
/* Sweep increment per tick: (step_bits+1) * (1 << shift) / 1378
* Approximate: the PS1 sweeps at ~32 levels per frame (44100/32 = 1378
* samples). Each "tick" we compute a coarse rate. For simplicity, we
* compute the total rate as ((step+1) << shift) and apply per-sample
* with fractional accumulator. */
int32_t diff = target - current;
if (diff == 0) return current;
/* Rate: ((step+1) << shift) units over ~1378 samples */
int32_t rate = ((int32_t)(step_bits + 1)) << shift;
if (rate > 1378) rate = 1378;
int32_t change;
if (diff > 0) {
change = rate;
if (change > diff) change = diff;
} else {
change = -rate;
if (change < diff) change = diff;
}
current += change;
/* Clamp */
if (current > 0x3FFF) current = 0x3FFF;
if (current < -0x4000) current = -0x4000;
return current;
}
static SpuVoice voices[SPU_VOICE_COUNT];
static void spu_event_record(uint8_t kind, int voice, uint32_t addr) {
@@ -286,9 +496,13 @@ void spu_cd_audio_push(const int16_t* stereo, int frames) {
cd_frame_count += in_frames;
cd_push_frames += in_frames;
/* T2 tap: what the CD/XA decoder feeds the SPU CD input bus. */
/* T2 tap: what the CD/XA decoder feeds the SPU CD input bus. The event
* stamps the GLOBAL GUEST CYCLE clock (low 32 bits) so push-to-push
* spacing measures true delivery cadence — the spu_out sample_idx stamp
* is pump-chunk quantized and useless for that. */
audio_trace_pcm(AUDIO_TAP_CD_IN, stereo, (int)in_frames);
audio_trace_event(AUDIO_EV_CD_PUSH, in_frames, cd_frame_count);
audio_trace_event(AUDIO_EV_CD_PUSH, (uint32_t)psx_get_cycle_count(),
cd_frame_count);
}
static int cd_audio_pop(int16_t* left, int16_t* right) {
@@ -345,6 +559,25 @@ static void decode_block(SpuVoice *v) {
v->sample_idx = 0;
v->cur_addr = (addr + 16u) & (SPU_RAM_SIZE - 1u);
/* SPU IRQ: fire when the decoder crosses the IRQ address.
* Check if the just-decoded block overlaps the IRQ address region
* [addr+2, addr+15] (the data nibbles after header+flags). */
if (spu_irq_enabled) {
uint32_t irq_ram = ((uint32_t)spu_irq_addr << 3) & (SPU_RAM_SIZE - 1u);
uint32_t blk_start = addr;
uint32_t blk_end = (addr + 16u) & (SPU_RAM_SIZE - 1u);
int hit = 0;
if (blk_end > blk_start) {
hit = (irq_ram >= blk_start && irq_ram < blk_end);
} else {
/* Block wraps around end of SPU RAM */
hit = (irq_ram >= blk_start || irq_ram < blk_end);
}
if (hit) {
spu_irq_pending = 1;
}
}
/* Latch end-block-reached so the BIOS music engine sees ENDX[v] = 1
* when it polls 0x1F801D9C/D9E. Without this latch one-shot music
* engines never advance, leaving subsequent voices unkeyed. */
@@ -429,6 +662,10 @@ static int16_t voice_next_sample(int idx) {
}
int16_t raw_s = v->samples[v->sample_idx];
/* Noise mode: replace decoded ADPCM sample with LFSR noise output. */
if (voice_noise_mode(idx)) {
raw_s = noise_output;
}
/* Apply envelope (0..0x7FFF as a 15-bit gain). */
int32_t shaped = ((int32_t)raw_s * (int32_t)v->env_level) >> 15;
if (shaped > 32767) shaped = 32767;
@@ -463,8 +700,30 @@ static int16_t voice_next_sample(int idx) {
v->active = 0;
}
/* Store ADSR-mixed output for pitch modulation: voice idx's output
* modulates voice (idx+1)'s pitch when PMON is set. */
voice_mixed_output[idx] = (int16_t)shaped;
/* Pitch Modulation (PMON): if this voice is modulated by voice idx-1,
* scale raw pitch by ((32768 + modulator_output) / 32768).
* Voice 0 is never modulated. Per-sample, matching PCSX-Redux. */
uint32_t pitch = voice_reg(idx, 2) & 0x3FFFu;
if (pitch == 0) pitch = 0x1000u;
if (idx > 0) {
uint32_t pmon_idx = (idx < 16)
? reg_index(0x1F801D90u)
: reg_index(0x1F801D92u);
uint32_t pmon_bit = (idx < 16) ? (1u << idx) : (1u << (idx - 16));
if (spu_regs[pmon_idx] & pmon_bit) {
int32_t mod_sample = (int32_t)voice_mixed_output[idx - 1];
int32_t raw_pitch = (int32_t)pitch;
int32_t modulated = ((32768 + mod_sample) * raw_pitch) / 32768;
if (modulated < 1) modulated = 1;
if (modulated > 0x3FFF) modulated = 0x3FFF;
pitch = (uint32_t)modulated;
}
}
v->phase += pitch;
while (v->phase >= 0x1000u) {
v->phase -= 0x1000u;
@@ -488,6 +747,9 @@ static void key_on(uint32_t mask) {
v->env_level = 0;
v->adsr_divider = 0;
v->adsr_phase = ADSR_ATTACK;
/* Initialize sweep volumes from current register values. */
v->sweep_vol_l = direct_volume(voice_reg(i, 0));
v->sweep_vol_r = direct_volume(voice_reg(i, 1));
key_on_count++;
endx_latch &= ~(1u << i); /* KEYON clears ENDX bit on real hw */
spu_event_record(SPU_EV_KEYON, i, v->cur_addr);
@@ -514,6 +776,7 @@ void spu_init(void) {
memset(spu_regs, 0, sizeof(spu_regs));
memset(voices, 0, sizeof(voices));
memset(s_events, 0, sizeof(s_events));
memset(voice_mixed_output, 0, sizeof(voice_mixed_output));
transfer_addr = 0;
key_on_count = 0;
render_frames = 0;
@@ -525,6 +788,9 @@ void spu_init(void) {
koff_latch = 0;
s_event_idx = 0;
s_event_seq = 0;
spu_irq_addr = 0;
spu_irq_enabled = 0;
spu_irq_pending = 0;
spu_cd_audio_reset();
s_shadow_tap_on = 0;
s_shadow_tap_frame = 0;
@@ -552,15 +818,50 @@ void spu_render(int16_t* out_stereo, int frames) {
}
int32_t block_peak = 0;
/* Read the noise level register once per block (index 210 = 0x1F801DA4). */
uint16_t noise_level_reg = spu_regs[reg_index(0x1F801DA4u)];
for (int f = 0; f < frames; f++) {
/* Step noise LFSR: rate controlled by noise level register.
* The PS1 noise frequency is: 44100 / ((noise_level+1)*8192)
* approximated by stepping every (noise_level+1)*8192/44100 samples. */
{
uint32_t noise_step = ((uint32_t)(noise_level_reg & 0x3FFu) + 1u);
/* noise_step is in range 1..1024; scale to ~samples per LFSR step.
* At level 0: step every ~0.19 samples (fast noise). At max: ~19 samples. */
noise_counter++;
if (noise_counter >= noise_step) {
noise_counter = 0;
noise_lfsr_step();
}
}
int32_t mix_l = 0;
int32_t mix_r = 0;
if (enabled) {
for (int v = 0; v < SPU_VOICE_COUNT; v++) {
int16_t s = voice_next_sample(v);
int16_t vl = direct_volume(voice_reg(v, 0));
int16_t vr = direct_volume(voice_reg(v, 1));
uint16_t vol_l_reg = voice_reg(v, 0);
uint16_t vol_r_reg = voice_reg(v, 1);
/* Volume: sweep mode (bit 15=1) or direct mode (bit 15=0). */
int16_t vl, vr;
if (vol_l_reg & 0x8000u) {
voices[v].sweep_vol_l = sweep_tick(voices[v].sweep_vol_l, vol_l_reg);
vl = (int16_t)voices[v].sweep_vol_l;
} else {
vl = direct_volume(vol_l_reg);
voices[v].sweep_vol_l = vl;
}
if (vol_r_reg & 0x8000u) {
voices[v].sweep_vol_r = sweep_tick(voices[v].sweep_vol_r, vol_r_reg);
vr = (int16_t)voices[v].sweep_vol_r;
} else {
vr = direct_volume(vol_r_reg);
voices[v].sweep_vol_r = vr;
}
if (s_shadow_tap_on && f < SPU_SHADOW_TAP_FRAMES) {
SpuShadowVoiceTap *t = &s_shadow_tap[f].voice[v];
/* voice_next_sample already filled s[]/frac/env if active. */
@@ -611,12 +912,23 @@ void spu_render(int16_t* out_stereo, int frames) {
last_peak = block_peak;
if (block_peak > peak) peak = block_peak;
/* SPU IRQ: if the ADPCM decoder crossed the IRQ address during this
* render block, raise i_stat bit 9 (0x200). */
if (spu_irq_pending) {
spu_irq_pending = 0;
psx_irq_raise(9, 0); /* bit 9 = 0x200 = SPU IRQ */
}
/* Verified-enhancement shadow: re-render this block in float from the
* tap, verify against the canon mix in `out_stereo`, and substitute only
* while proven. No-op (byte-identical) when disabled. The canon mix above
* stays the authoritative output AND the verify oracle. */
spu_shadow_process(out_stereo, frames);
/* Reverb (PS1-style delay-line): process the final mix in-place */
reverb_update_params();
reverb_process_stereo(out_stereo, frames);
/* T1 tap: the SPU's final output block as handed to the host layer
* (post-shadow, pre host fade/mute). Placed here so every spu_render
* caller — the vblank pump and the turbo fade tail — is covered. */
@@ -724,11 +1036,22 @@ void spu_write(uint32_t addr, uint32_t value) {
key_off((uint32_t)(uint16_t)value << 16);
}
/* SPU control register (0x1F801DAA): bit 6 = IRQ enable. */
if (addr == 0x1F801DAAu) {
spu_irq_enabled = ((uint16_t)value >> 6) & 1u;
}
if (addr == 0x1F801DA6u) {
transfer_addr = ((uint32_t)(uint16_t)value) << 3;
if (transfer_addr >= SPU_RAM_SIZE) transfer_addr = 0;
}
/* SPU IRQ address register (0x1F801D9E): the ADPCM decoder
* raises i_stat bit 9 when it crosses this RAM address. */
if (addr == 0x1F801D9Eu) {
spu_irq_addr = (uint16_t)value;
}
if (addr == 0x1F801DA8u) {
if (transfer_addr + 1 < SPU_RAM_SIZE) {
spu_ram[transfer_addr] = (uint8_t)(value & 0xFF);