diff --git a/.gitignore b/.gitignore index a1ea0a71..a034f93f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/internal/STUBS_TO_FIX.md b/docs/internal/STUBS_TO_FIX.md index 11045ebc..7e2be1ab 100644 --- a/docs/internal/STUBS_TO_FIX.md +++ b/docs/internal/STUBS_TO_FIX.md @@ -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 diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 9b84e7e1..5c6761c1 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -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); } diff --git a/recompiler/src/control_flow.cpp b/recompiler/src/control_flow.cpp index 3a08662d..ecf4c3cf 100644 --- a/recompiler/src/control_flow.cpp +++ b/recompiler/src/control_flow.cpp @@ -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; diff --git a/recompiler/src/function_analysis.cpp b/recompiler/src/function_analysis.cpp index 6efa827c..3e7c7254 100644 --- a/recompiler/src/function_analysis.cpp +++ b/recompiler/src/function_analysis.cpp @@ -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(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 diff --git a/recompiler/src/mips_decoder.cpp b/recompiler/src/mips_decoder.cpp index fa51f2ca..6aee19d1 100644 --- a/recompiler/src/mips_decoder.cpp +++ b/recompiler/src/mips_decoder.cpp @@ -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); } diff --git a/recompiler/tomba_example.toml b/recompiler/tomba_example.toml new file mode 100644 index 00000000..b0f76f16 --- /dev/null +++ b/recompiler/tomba_example.toml @@ -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 +# /cache and /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) > 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" diff --git a/runtime/include/audio_trace.h b/runtime/include/audio_trace.h index 61540c7a..60b5c8b2 100644 --- a/runtime/include/audio_trace.h +++ b/runtime/include/audio_trace.h @@ -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 { diff --git a/runtime/include/cdrom.h b/runtime/include/cdrom.h index 7740bba7..d63a922c 100644 --- a/runtime/include/cdrom.h +++ b/runtime/include/cdrom.h @@ -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 { diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index 462bb1fd..84a864c5 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -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); diff --git a/runtime/include/iso_reader.h b/runtime/include/iso_reader.h index da0fc21b..2026a9d6 100644 --- a/runtime/include/iso_reader.h +++ b/runtime/include/iso_reader.h @@ -4,6 +4,7 @@ #include #include #include +#include /** * 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 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 tracks_; // from the .cue TOC; >=1 entry after Open() -}; + std::map bin_files_; // per-track BIN files +}; } // namespace PS1 diff --git a/runtime/src/beetle_debug_server.c b/runtime/src/beetle_debug_server.c index b5d98fca..9cd7045b 100644 --- a/runtime/src/beetle_debug_server.c +++ b/runtime/src/beetle_debug_server.c @@ -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); diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index ee062a96..eb162470 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -14,6 +14,7 @@ #include "dma.h" #include "spu.h" #include "event_ring.h" +#include "audio_trace.h" #include #include #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); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index bfc3e095..134f8f84 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -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; diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 31224f0c..13ddddd8 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -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 diff --git a/runtime/src/dma.c b/runtime/src/dma.c index a923c3ce..bf98b9eb 100644 --- a/runtime/src/dma.c +++ b/runtime/src/dma.c @@ -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; diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index d5d96f71..d8c1e5e1 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -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 */ diff --git a/runtime/src/iso_reader.cpp b/runtime/src/iso_reader.cpp index 3f6baabc..a4af6c3c 100644 --- a/runtime/src/iso_reader.cpp +++ b/runtime/src/iso_reader.cpp @@ -1,558 +1,553 @@ -#include "iso_reader.h" -#include -#include -#include -#include - -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; - -ISOReader::ISOReader() - : is_open_(false) { - root_dir_.lba = 0; - root_dir_.size = 0; -} - -ISOReader::~ISOReader() { - Close(); -} - -bool ISOReader::Open(const std::string& filename) { - // Close any previously opened file - Close(); - - // Check if file exists - if (!std::filesystem::exists(filename)) { - return false; - } - - // Handle .cue files - parse to find .bin file - std::string bin_filename = filename; - auto ends_with = [](const std::string& s, const std::string& suffix) { - return s.size() >= suffix.size() && - s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; - }; - if (ends_with(filename, ".cue") || ends_with(filename, ".CUE")) { - // Parse the .cue: resolve the FILE, AND build the track TOC from the - // TRACK/INDEX lines. A bare .bin/.iso (no .cue) falls through to the - // single-track synthesis below; a multi-track disc (data + CD-DA audio) - // gets each track so the CD model's GetTN/GetTD report them correctly. - std::ifstream cue_file(filename); - if (!cue_file.is_open()) { - return false; - } - - std::string line; - int cur_track_num = -1; - bool cur_track_audio = false; - while (std::getline(cue_file, line)) { - // FILE "filename.bin" BINARY - size_t file_pos = line.find("FILE"); - size_t binary_pos = line.find("BINARY"); - if (file_pos != std::string::npos && binary_pos != std::string::npos) { - size_t quote1 = line.find('"', file_pos); - size_t quote2 = line.find('"', quote1 + 1); - if (quote1 != std::string::npos && quote2 != std::string::npos) { - std::string bin_name = line.substr(quote1 + 1, quote2 - quote1 - 1); - std::filesystem::path cue_path(filename); - std::filesystem::path bin_path(bin_name); - if (bin_path.is_relative()) { - bin_filename = (cue_path.parent_path() / bin_name).string(); - } else { - bin_filename = bin_name; - } - } - continue; - } - - // TRACK NN MODE2/2352 | TRACK NN AUDIO - int tn = 0; - char type[32] = {0}; - if (std::sscanf(line.c_str(), " TRACK %d %31s", &tn, type) == 2) { - cur_track_num = tn; - cur_track_audio = (std::strstr(type, "AUDIO") != nullptr); - continue; - } - - // INDEX 01 MM:SS:FF — the track's start (INDEX 00 is its pregap). - int idx = 0, mm = 0, ss = 0, ff = 0; - if (std::sscanf(line.c_str(), " INDEX %d %d:%d:%d", &idx, &mm, &ss, &ff) == 4 - && idx == 1 && cur_track_num >= 1) { - CDTrack t; - t.number = cur_track_num; - t.is_audio = cur_track_audio; - t.start_lba = (uint32_t)(((mm * 60 + ss) * 75) + ff); // .bin-relative LBA - tracks_.push_back(t); - cur_track_num = -1; - } - } - cue_file.close(); - } - - // Synthesize a single data track for a bare .bin/.iso or a .cue with no - // parseable TRACK entries, so TrackCount() is always >= 1. - if (tracks_.empty()) { - CDTrack t; - t.number = 1; t.is_audio = false; t.start_lba = 0; - tracks_.push_back(t); - } - - // Store the resolved bin path for callers (e.g. xa_audio) - bin_path_ = bin_filename; - - // Open the ISO/BIN file in binary mode - file_.open(bin_filename, std::ios::binary); - if (!file_.is_open()) { - return false; - } - - is_open_ = true; - - // Parse the volume descriptor to extract filesystem metadata when - // present. Runtime CD-ROM access only needs sector reads, so keep the - // image mounted even if an ISO9660 header is missing or nonstandard. - if (!ParseVolumeDescriptor()) { - file_.clear(); - volume_id_.clear(); - root_dir_.lba = 0; - root_dir_.size = 0; - } - - return true; -} - -void ISOReader::Close() { - if (file_.is_open()) { - file_.close(); - } - is_open_ = false; -} - -bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { - if (!is_open_ || !buffer) { - return false; - } - - // Clear any error flags - file_.clear(); - - // Determine sector format by checking file size - file_.seekg(0, std::ios::end); - std::streampos file_size = file_.tellg(); - - // Clear error flags after seeking - file_.clear(); - - // Check if file uses raw sectors (2352 bytes) or cooked sectors (2048 bytes) - bool is_raw_format = (file_size % RAW_SECTOR_SIZE == 0); - - if (is_raw_format) { - // Raw BIN format - read full sector, extract user data - std::streampos offset = static_cast(lba) * RAW_SECTOR_SIZE + RAW_DATA_OFFSET; - file_.seekg(offset, std::ios::beg); - - if (!file_.good()) { - file_.clear(); - return false; - } - - file_.read(reinterpret_cast(buffer), SECTOR_SIZE); - std::streamsize bytes_read = file_.gcount(); - bool success = (bytes_read == SECTOR_SIZE); - file_.clear(); - return success; - } else { - // ISO format - sectors are already 2048 bytes - std::streampos offset = static_cast(lba) * SECTOR_SIZE; - file_.seekg(offset, std::ios::beg); - - if (!file_.good()) { - file_.clear(); - return false; - } - - file_.read(reinterpret_cast(buffer), SECTOR_SIZE); - std::streamsize bytes_read = file_.gcount(); - bool success = (bytes_read == SECTOR_SIZE); - file_.clear(); - return success; - } -} - -bool ISOReader::ReadRawSector(uint32_t lba, uint8_t* buffer) { - if (!is_open_ || !buffer) { - return false; - } - - file_.clear(); - file_.seekg(0, std::ios::end); - std::streampos file_size = file_.tellg(); - file_.clear(); - - if (file_size <= 0 || (file_size % RAW_SECTOR_SIZE) != 0) { - return false; - } - - std::streampos offset = static_cast(lba) * RAW_SECTOR_SIZE; - file_.seekg(offset, std::ios::beg); - if (!file_.good()) { - file_.clear(); - return false; - } - - file_.read(reinterpret_cast(buffer), RAW_SECTOR_SIZE); - std::streamsize bytes_read = file_.gcount(); - bool success = (bytes_read == RAW_SECTOR_SIZE); - file_.clear(); - return success; -} - -bool ISOReader::IsOpen() const { - return is_open_; -} - -std::string ISOReader::GetVolumeID() const { - return volume_id_; -} - -std::string ISOReader::GetBinPath() const { - return bin_path_; -} - -uint32_t ISOReader::GetSectorCount() { - if (!is_open_) { - return 0; - } - - file_.clear(); - file_.seekg(0, std::ios::end); - std::streampos file_size = file_.tellg(); - file_.clear(); - if (file_size <= 0) { - return 0; - } - - const uint64_t size = static_cast(file_size); - if ((size % RAW_SECTOR_SIZE) == 0) { - return static_cast(size / RAW_SECTOR_SIZE); - } - return static_cast(size / SECTOR_SIZE); -} - -int ISOReader::TrackCount() const { - return static_cast(tracks_.size()); -} - -uint32_t ISOReader::TrackStartLBA(int track) const { - for (const auto& t : tracks_) { - if (t.number == track) return t.start_lba; - } - return 0; -} - -bool ISOReader::TrackIsAudio(int track) const { - for (const auto& t : tracks_) { - if (t.number == track) return t.is_audio; - } - return false; -} - -RootDirectoryInfo ISOReader::GetRootDirectory() const { - return root_dir_; -} - -uint32_t ISOReader::Read733(const uint8_t* data) const { - // Read little-endian half of both-endian 32-bit value - return data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); -} - -bool ISOReader::ParseVolumeDescriptor() { - // Read Primary Volume Descriptor from sector 16 - uint8_t pvd[SECTOR_SIZE]; - if (!ReadSector(PVD_SECTOR, pvd)) { - return false; - } - - // Verify ISO9660 signature: offset 1 should contain "CD001" - if (pvd[0] != 0x01 || std::memcmp(&pvd[1], "CD001", 5) != 0) { - return false; // Not a valid ISO9660 disc - } - - // Extract volume ID (offset 40, 32 bytes, space-padded ASCII) - volume_id_.clear(); - for (int i = 0; i < 32; i++) { - char c = pvd[40 + i]; - if (c != ' ' && c != '\0') { - volume_id_ += c; - } - } - - // Extract root directory record (offset 156, 34 bytes) - const uint8_t* root_record = &pvd[156]; - - // Root directory LBA is at offset 2 within the directory record (both-endian 32-bit) - root_dir_.lba = Read733(&root_record[2]); - - // Root directory size is at offset 10 within the directory record (both-endian 32-bit) - root_dir_.size = Read733(&root_record[10]); - - return true; -} - -bool ISOReader::ParseDirectoryRecord(const uint8_t* data, ISOFileEntry& entry) const { - // Check record length (offset 0) - uint8_t record_len = data[0]; - if (record_len == 0 || record_len < 33) { - return false; // Invalid or padding record - } - - // Extract LBA (offset 2, both-endian 32-bit) - entry.lba = Read733(&data[2]); - - // Extract file size (offset 10, both-endian 32-bit) - entry.size = Read733(&data[10]); - - // Extract file flags (offset 25) - uint8_t flags = data[25]; - entry.is_directory = (flags & 0x02) != 0; - - // Extract filename length (offset 32) - uint8_t name_len = data[32]; - if (name_len == 0) { - return false; // Invalid record - } - - // Extract filename (offset 33) - const char* name_ptr = reinterpret_cast(&data[33]); - - // Handle special directory entries - if (name_len == 1 && name_ptr[0] == '\x00') { - entry.name = "."; // Current directory - return true; - } - if (name_len == 1 && name_ptr[0] == '\x01') { - entry.name = ".."; // Parent directory - return true; - } - - // Parse regular filename, strip version suffix (";1") - entry.name.clear(); - for (uint8_t i = 0; i < name_len; i++) { - char c = name_ptr[i]; - if (c == ';') { - break; // Stop at version separator - } - entry.name += c; - } - - return true; -} - -std::vector ISOReader::ListFilesByLBA(uint32_t lba, uint32_t dir_size) { - std::vector results; - - if (!is_open_ || lba == 0 || dir_size == 0) { - return results; - } - - // Calculate number of sectors needed for directory data - uint32_t num_sectors = (dir_size + 2047) / 2048; - - // Allocate buffer for directory data - std::vector dir_data(num_sectors * 2048, 0); - - // Read all directory sectors - for (uint32_t i = 0; i < num_sectors; i++) { - if (!ReadSector(lba + i, &dir_data[i * 2048])) { - return results; // Error reading sector - } - } - - // Parse directory records - uint32_t offset = 0; - while (offset < dir_size) { - uint8_t record_len = dir_data[offset]; - - if (record_len == 0) { - // Skip to next sector boundary - uint32_t sector_offset = offset % 2048; - if (sector_offset != 0) { - offset += (2048 - sector_offset); - continue; - } - break; - } - - if (offset + record_len > dir_data.size()) break; - - ISOFileEntry entry; - if (ParseDirectoryRecord(&dir_data[offset], entry)) { - if (entry.name != "." && entry.name != "..") { - results.push_back(entry); - } - } - - offset += record_len; - } - - return results; -} - -std::vector ISOReader::ListFiles(const std::string& path) { - std::vector results; - - // Check if file is open - if (!is_open_) { - return results; - } - - if (path.empty()) { - // List root directory - RootDirectoryInfo root = GetRootDirectory(); - return ListFilesByLBA(root.lba, root.size); - } - - // Non-empty path: navigate to that subdirectory within the root - // Find the matching directory entry in root - RootDirectoryInfo root = GetRootDirectory(); - std::vector root_entries = ListFilesByLBA(root.lba, root.size); - - std::string path_upper = path; - std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - - for (const auto& e : root_entries) { - if (!e.is_directory) continue; - std::string name_upper = e.name; - std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - if (name_upper == path_upper) { - // Found the subdirectory — list its contents - return ListFilesByLBA(e.lba, e.size > 0 ? e.size : 2048); - } - } - - return results; // Directory not found -} - -bool ISOReader::FindFile(const std::string& path, ISOFileEntry& entry) { - // Check if file is open - if (!is_open_) { - return false; - } - - // Check if path contains a directory separator - size_t sep = path.find('/'); - if (sep == std::string::npos) { - sep = path.find('\\'); - } - - if (sep != std::string::npos) { - // Subdirectory path: "DIR/FILE" or "DIR\FILE" - std::string dir_name = path.substr(0, sep); - std::string file_name = path.substr(sep + 1); - - // List the subdirectory - std::vector sub_files = ListFiles(dir_name); - - std::string file_upper = file_name; - std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - - for (const auto& f : sub_files) { - std::string name_upper = f.name; - std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - if (name_upper == file_upper) { - entry = f; - return true; - } - } - return false; - } - - // Root-level file: search root directory - std::vector files = ListFiles(""); - - // Search for matching filename (case-insensitive comparison) - for (const auto& file : files) { - std::string file_upper = file.name; - std::string path_upper = path; - - std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - - if (file_upper == path_upper) { - entry = file; - return true; - } - } - - // File not found - return false; -} - -size_t ISOReader::ReadFile(const std::string& path, uint8_t* buffer, size_t max_size) { - // Validate buffer pointer - if (!buffer) { - return 0; - } - - // Find the file - ISOFileEntry entry; - if (!FindFile(path, entry)) { - return 0; // File not found - } - - // Calculate how many bytes to read (min of file size and max_size) - size_t bytes_to_read = std::min(static_cast(entry.size), max_size); - - // Calculate number of sectors to read - uint32_t sectors_to_read = (bytes_to_read + SECTOR_SIZE - 1) / SECTOR_SIZE; - - // Read sectors sequentially - size_t bytes_read = 0; - for (uint32_t i = 0; i < sectors_to_read; i++) { - // Calculate how many bytes to read from this sector - size_t bytes_remaining = bytes_to_read - bytes_read; - size_t sector_bytes = std::min(bytes_remaining, SECTOR_SIZE); - - // Read sector into temporary buffer - uint8_t sector_buffer[SECTOR_SIZE]; - if (!ReadSector(entry.lba + i, sector_buffer)) { - return bytes_read; // Error - return what we've read so far - } - - // Copy data to output buffer - std::memcpy(buffer + bytes_read, sector_buffer, sector_bytes); - bytes_read += sector_bytes; - } - - return bytes_read; -} - -size_t ISOReader::GetFileSize(const std::string& path) { - // Find the file - ISOFileEntry entry; - if (!FindFile(path, entry)) { - return 0; // File not found - } - - // Return file size - return entry.size; -} - -} // namespace PS1 +#include "iso_reader.h" +#include +#include +#include +#include + +namespace PS1 { + +// PS1 CD-ROM sector size (Mode 2, Form 1 user data) +// constexpr size_t PS1::SECTOR_SIZE = 2048; +// constexpr size_t PS1::RAW_SECTOR_SIZE = 2352; +// constexpr size_t PS1::RAW_DATA_OFFSET = 24; +// constexpr uint32_t PS1::PVD_SECTOR = 16; + +ISOReader::ISOReader() + : is_open_(false) { + root_dir_.lba = 0; + root_dir_.size = 0; +} + +ISOReader::~ISOReader() { + Close(); +} + +bool ISOReader::Open(const std::string& filename) { + // Close any previously opened file + Close(); + + // Check if file exists + if (!std::filesystem::exists(filename)) { + return false; + } + + // Handle .cue files - parse to find .bin file + std::string bin_filename = filename; + auto ends_with = [](const std::string& s, const std::string& suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + if (ends_with(filename, ".cue") || ends_with(filename, ".CUE")) { + // Parse the .cue: resolve the FILE, AND build the track TOC from the + // TRACK/INDEX lines. A bare .bin/.iso (no .cue) falls through to the + // single-track synthesis below; a multi-track disc (data + CD-DA audio) + // gets each track so the CD model's GetTN/GetTD report them correctly. + std::ifstream cue_file(filename); + if (!cue_file.is_open()) { + return false; + } + + std::string line; + int cur_track_num = -1; + bool cur_track_audio = false; + std::string current_bin_path; + while (std::getline(cue_file, line)) { + // FILE "filename.bin" BINARY + size_t file_pos = line.find("FILE"); + size_t binary_pos = line.find("BINARY"); + if (file_pos != std::string::npos && binary_pos != std::string::npos) { + size_t quote1 = line.find('"', file_pos); + size_t quote2 = line.find('"', quote1 + 1); + if (quote1 != std::string::npos && quote2 != std::string::npos) { + std::string bin_name = line.substr(quote1 + 1, quote2 - quote1 - 1); + std::filesystem::path cue_path(filename); + std::filesystem::path bin_path(bin_name); + if (bin_path.is_relative()) { + bin_filename = (cue_path.parent_path() / bin_name).string(); + } else { + bin_filename = bin_name; + } + } + continue; + } + + // TRACK NN MODE2/2352 | TRACK NN AUDIO + int tn = 0; + char type[32] = {0}; + if (std::sscanf(line.c_str(), " TRACK %d %31s", &tn, type) == 2) { + cur_track_num = tn; + cur_track_audio = (std::strstr(type, "AUDIO") != nullptr); + continue; + } + + // INDEX 01 MM:SS:FF — the track's start (INDEX 00 is its pregap). + int idx = 0, mm = 0, ss = 0, ff = 0; + if (std::sscanf(line.c_str(), " INDEX %d %d:%d:%d", &idx, &mm, &ss, &ff) == 4 + && idx == 1 && cur_track_num >= 1) { + CDTrack t; + t.number = cur_track_num; + t.is_audio = cur_track_audio; + t.start_lba = (uint32_t)(((mm * 60 + ss) * 75) + ff); // .bin-relative LBA + tracks_.push_back(t); + cur_track_num = -1; + } + } + cue_file.close(); + } + + // Synthesize a single data track for a bare .bin/.iso or a .cue with no + // parseable TRACK entries, so TrackCount() is always >= 1. + if (tracks_.empty()) { + CDTrack t; + t.number = 1; t.is_audio = false; t.start_lba = 0; + tracks_.push_back(t); + } + + // Store the resolved bin path for callers (e.g. xa_audio) + bin_path_ = bin_filename; + + // Open the ISO/BIN file in binary mode + file_.open(bin_filename, std::ios::binary); + if (!file_.is_open()) { + return false; + } + + is_open_ = true; + + // Parse the volume descriptor to extract filesystem metadata when + // present. Runtime CD-ROM access only needs sector reads, so keep the + // image mounted even if an ISO9660 header is missing or nonstandard. + if (!ParseVolumeDescriptor()) { + file_.clear(); + volume_id_.clear(); + root_dir_.lba = 0; + root_dir_.size = 0; + } + + return true; +} + +void ISOReader::Close() { + if (file_.is_open()) { + file_.close(); + } + is_open_ = false; +} + +bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { + if (!is_open_ || !buffer) { + return false; + } + + // Clear any error flags + file_.clear(); + + // Determine sector format by checking file size + file_.seekg(0, std::ios::end); + std::streampos file_size = file_.tellg(); + + // Clear error flags after seeking + file_.clear(); + + // Check if file uses raw sectors (2352 bytes) or cooked sectors (2048 bytes) + bool is_raw_format = (file_size % PS1::RAW_SECTOR_SIZE == 0); + + if (is_raw_format) { + // Raw BIN format - read full sector, extract user data + std::streampos offset = static_cast(lba) * PS1::RAW_SECTOR_SIZE + PS1::RAW_DATA_OFFSET; + file_.seekg(offset, std::ios::beg); + + if (!file_.good()) { + file_.clear(); + return false; + } + + file_.read(reinterpret_cast(buffer), PS1::SECTOR_SIZE); + std::streamsize bytes_read = file_.gcount(); + bool success = (bytes_read == PS1::SECTOR_SIZE); + file_.clear(); + return success; + } else { + // ISO format - sectors are already 2048 bytes + std::streampos offset = static_cast(lba) * PS1::SECTOR_SIZE; + file_.seekg(offset, std::ios::beg); + + if (!file_.good()) { + file_.clear(); + return false; + } + + file_.read(reinterpret_cast(buffer), PS1::SECTOR_SIZE); + std::streamsize bytes_read = file_.gcount(); + bool success = (bytes_read == PS1::SECTOR_SIZE); + file_.clear(); + return success; + } +} + +bool ISOReader::ReadRawSector(uint32_t lba, uint8_t* buffer) { + if (!is_open_ || !buffer) { + return false; + } + + file_.clear(); + file_.seekg(0, std::ios::end); + std::streampos file_size = file_.tellg(); + file_.clear(); + + if (file_size <= 0 || (file_size % PS1::RAW_SECTOR_SIZE) != 0) { + return false; + } + + std::streampos offset = static_cast(lba) * PS1::RAW_SECTOR_SIZE; + file_.seekg(offset, std::ios::beg); + if (!file_.good()) { + file_.clear(); + return false; + } + + file_.read(reinterpret_cast(buffer), PS1::RAW_SECTOR_SIZE); + std::streamsize bytes_read = file_.gcount(); + bool success = (bytes_read == PS1::RAW_SECTOR_SIZE); + file_.clear(); + return success; +} + +bool ISOReader::IsOpen() const { + return is_open_; +} + +std::string ISOReader::GetVolumeID() const { + return volume_id_; +} + +std::string ISOReader::GetBinPath() const { + return bin_path_; +} + +uint32_t ISOReader::GetSectorCount() { + if (!is_open_) { + return 0; + } + + file_.clear(); + file_.seekg(0, std::ios::end); + std::streampos file_size = file_.tellg(); + file_.clear(); + if (file_size <= 0) { + return 0; + } + + const uint64_t size = static_cast(file_size); + if ((size % PS1::RAW_SECTOR_SIZE) == 0) { + return static_cast(size / PS1::RAW_SECTOR_SIZE); + } + return static_cast(size / PS1::SECTOR_SIZE); +} + +int ISOReader::TrackCount() const { + return static_cast(tracks_.size()); +} + +uint32_t ISOReader::TrackStartLBA(int track) const { + for (const auto& t : tracks_) { + if (t.number == track) return t.start_lba; + } + return 0; +} + +bool ISOReader::TrackIsAudio(int track) const { + for (const auto& t : tracks_) { + if (t.number == track) return t.is_audio; + } + return false; +} + +RootDirectoryInfo ISOReader::GetRootDirectory() const { + return root_dir_; +} + +uint32_t ISOReader::Read733(const uint8_t* data) const { + // Read little-endian half of both-endian 32-bit value + return data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); +} + +bool ISOReader::ParseVolumeDescriptor() { + // Read Primary Volume Descriptor from sector 16 + uint8_t pvd[PS1::SECTOR_SIZE]; + if (!ReadSector(PS1::PVD_SECTOR, pvd)) { + return false; + } + + // Verify ISO9660 signature: offset 1 should contain "CD001" + if (pvd[0] != 0x01 || std::memcmp(&pvd[1], "CD001", 5) != 0) { + return false; // Not a valid ISO9660 disc + } + + // Extract volume ID (offset 40, 32 bytes, space-padded ASCII) + volume_id_.clear(); + for (int i = 0; i < 32; i++) { + char c = pvd[40 + i]; + if (c != ' ' && c != '\0') { + volume_id_ += c; + } + } + + // Extract root directory record (offset 156, 34 bytes) + const uint8_t* root_record = &pvd[156]; + + // Root directory LBA is at offset 2 within the directory record (both-endian 32-bit) + root_dir_.lba = Read733(&root_record[2]); + + // Root directory size is at offset 10 within the directory record (both-endian 32-bit) + root_dir_.size = Read733(&root_record[10]); + + return true; +} + +bool ISOReader::ParseDirectoryRecord(const uint8_t* data, ISOFileEntry& entry) const { + // Check record length (offset 0) + uint8_t record_len = data[0]; + if (record_len == 0 || record_len < 33) { + return false; // Invalid or padding record + } + + // Extract LBA (offset 2, both-endian 32-bit) + entry.lba = Read733(&data[2]); + + // Extract file size (offset 10, both-endian 32-bit) + entry.size = Read733(&data[10]); + + // Extract file flags (offset 25) + uint8_t flags = data[25]; + entry.is_directory = (flags & 0x02) != 0; + + // Extract filename length (offset 32) + uint8_t name_len = data[32]; + if (name_len == 0) { + return false; // Invalid record + } + + // Extract filename (offset 33) + const char* name_ptr = reinterpret_cast(&data[33]); + + // Handle special directory entries + if (name_len == 1 && name_ptr[0] == '\x00') { + entry.name = "."; // Current directory + return true; + } + if (name_len == 1 && name_ptr[0] == '\x01') { + entry.name = ".."; // Parent directory + return true; + } + + // Parse regular filename, strip version suffix (";1") + entry.name.clear(); + for (uint8_t i = 0; i < name_len; i++) { + char c = name_ptr[i]; + if (c == ';') { + break; // Stop at version separator + } + entry.name += c; + } + + return true; +} + +std::vector ISOReader::ListFilesByLBA(uint32_t lba, uint32_t dir_size) { + std::vector results; + + if (!is_open_ || lba == 0 || dir_size == 0) { + return results; + } + + // Calculate number of sectors needed for directory data + uint32_t num_sectors = (dir_size + 2047) / 2048; + + // Allocate buffer for directory data + std::vector dir_data(num_sectors * 2048, 0); + + // Read all directory sectors + for (uint32_t i = 0; i < num_sectors; i++) { + if (!ReadSector(lba + i, &dir_data[i * 2048])) { + return results; // Error reading sector + } + } + + // Parse directory records + uint32_t offset = 0; + while (offset < dir_size) { + uint8_t record_len = dir_data[offset]; + + if (record_len == 0) { + // Skip to next sector boundary + uint32_t sector_offset = offset % 2048; + if (sector_offset != 0) { + offset += (2048 - sector_offset); + continue; + } + break; + } + + if (offset + record_len > dir_data.size()) break; + + ISOFileEntry entry; + if (ParseDirectoryRecord(&dir_data[offset], entry)) { + if (entry.name != "." && entry.name != "..") { + results.push_back(entry); + } + } + + offset += record_len; + } + + return results; +} + +std::vector ISOReader::ListFiles(const std::string& path) { + std::vector results; + + // Check if file is open + if (!is_open_) { + return results; + } + + if (path.empty()) { + // List root directory + RootDirectoryInfo root = GetRootDirectory(); + return ListFilesByLBA(root.lba, root.size); + } + + // Non-empty path: navigate to that subdirectory within the root + // Find the matching directory entry in root + RootDirectoryInfo root = GetRootDirectory(); + std::vector root_entries = ListFilesByLBA(root.lba, root.size); + + std::string path_upper = path; + std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + + for (const auto& e : root_entries) { + if (!e.is_directory) continue; + std::string name_upper = e.name; + std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + if (name_upper == path_upper) { + // Found the subdirectory — list its contents + return ListFilesByLBA(e.lba, e.size > 0 ? e.size : 2048); + } + } + + return results; // Directory not found +} + +bool ISOReader::FindFile(const std::string& path, ISOFileEntry& entry) { + // Check if file is open + if (!is_open_) { + return false; + } + + // Check if path contains a directory separator + size_t sep = path.find('/'); + if (sep == std::string::npos) { + sep = path.find('\\'); + } + + if (sep != std::string::npos) { + // Subdirectory path: "DIR/FILE" or "DIR\FILE" + std::string dir_name = path.substr(0, sep); + std::string file_name = path.substr(sep + 1); + + // List the subdirectory + std::vector sub_files = ListFiles(dir_name); + + std::string file_upper = file_name; + std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + + for (const auto& f : sub_files) { + std::string name_upper = f.name; + std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + if (name_upper == file_upper) { + entry = f; + return true; + } + } + return false; + } + + // Root-level file: search root directory + std::vector files = ListFiles(""); + + // Search for matching filename (case-insensitive comparison) + for (const auto& file : files) { + std::string file_upper = file.name; + std::string path_upper = path; + + std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + + if (file_upper == path_upper) { + entry = file; + return true; + } + } + + // File not found + return false; +} + +size_t ISOReader::ReadFile(const std::string& path, uint8_t* buffer, size_t max_size) { + // Validate buffer pointer + if (!buffer) { + return 0; + } + + // Find the file + ISOFileEntry entry; + if (!FindFile(path, entry)) { + return 0; // File not found + } + + // Calculate how many bytes to read (min of file size and max_size) + size_t bytes_to_read = std::min(static_cast(entry.size), max_size); + + // Calculate number of sectors to read + uint32_t sectors_to_read = (bytes_to_read + PS1::SECTOR_SIZE - 1) / PS1::SECTOR_SIZE; + + // Read sectors sequentially + size_t bytes_read = 0; + for (uint32_t i = 0; i < sectors_to_read; i++) { + // Calculate how many bytes to read from this sector + size_t bytes_remaining = bytes_to_read - bytes_read; + size_t sector_bytes = std::min(bytes_remaining, PS1::SECTOR_SIZE); + + // Read sector into temporary buffer + uint8_t sector_buffer[PS1::SECTOR_SIZE]; + if (!ReadSector(entry.lba + i, sector_buffer)) { + return bytes_read; // Error - return what we've read so far + } + + // Copy data to output buffer + std::memcpy(buffer + bytes_read, sector_buffer, sector_bytes); + bytes_read += sector_bytes; + } + + return bytes_read; +} + +size_t ISOReader::GetFileSize(const std::string& path) { + // Find the file + ISOFileEntry entry; + if (!FindFile(path, entry)) { + return 0; // File not found + } + + // Return file size + return entry.size; +} + +} // namespace PS1 diff --git a/runtime/src/mdec.c b/runtime/src/mdec.c index b9446483..9fee2f15 100644 --- a/runtime/src/mdec.c +++ b/runtime/src/mdec.c @@ -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; diff --git a/runtime/src/mdec.h b/runtime/src/mdec.h new file mode 100644 index 00000000..c2b2708c --- /dev/null +++ b/runtime/src/mdec.h @@ -0,0 +1,83 @@ +#ifndef PSX_MDEC_H +#define PSX_MDEC_H + +#include +#include + +#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 \ No newline at end of file diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 3a2391b2..8adc08e2 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -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); } } diff --git a/runtime/src/spu.c b/runtime/src/spu.c index 711048b3..46ee2057 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -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 @@ -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);