diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 1d5ee098..b0b70408 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -140,18 +140,34 @@ std::string CodeGenerator::emit_mid_block_cycle_charge(uint32_t addr, return ss.str(); } +/* Both check emitters honor an exception REDIRECT: when the delivery's + * epilogue publishes a resume PC different from this site's static + * continuation (guest RFE to a non-EPC target — longjmp-style handlers, + * SetCustomExitFromException, thread switch), the compiled code must + * surface it to the trampoline instead of overwriting it with the static + * transfer. This is the compiled analogue of the dirty interpreter's + * "Handler resumed elsewhere — surface to dispatch" contract; without it a + * baked/compiled leader silently drops the guest's continuation (WipEout 3 + * static bake: deterministic top-level "execution completed, PC=0" ~10 s + * into boot, root-caused to redirects dropped at compiled leaders). A + * published pc equal to the static continuation falls through (same-thread + * restore publishes 0; the depth-guard rescue publishes the site PC). */ std::string CodeGenerator::emit_interrupt_check(uint32_t resume_pc, const std::string& indent) const { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc); + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc) + indent + + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", + resume_pc); } std::string CodeGenerator::emit_interrupt_check_expr(const std::string& resume_pc_expr, const std::string& indent) const { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent + + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ ({})) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", + resume_pc_expr); } std::string CodeGenerator::reg_name(int reg_num) { @@ -1812,7 +1828,7 @@ std::string CodeGenerator::translate_basic_block( if (!(insn_addr == block.start_addr || (insn_addr & 0xCu) == 0 || extra_labels_.count(insn_addr))) return; ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_icache_fetch(cpu, 0x{:08X}u);\n", insn_addr); + ss << indent << fmt::format("PSX_ICACHE_FETCH(cpu, 0x{:08X}u);\n", insn_addr); ss << "#endif\n"; }; auto emit_cosim_instr = [&](uint32_t insn_addr, const std::string& indent) { @@ -2458,7 +2474,27 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", next_addr); + } else if (cps_enabled_) { + // No successor block and no known function owns the next PC — + // the block runs off the region/image edge. Emitting only the + // interrupt check here let execution FALL OFF the C function with + // the entry-switch's consumed pc==0: the dispatcher returned + // "handled" with a null PC and the top-level trampoline read it + // as "program ended" (WipEout 3 static bake: the 0xD5000 region + // variant's truncated block_800D7FC0, continuation 0x800D8004, + // died the moment CD streaming loaded content matching that + // variant — the deterministic boot exit at ~frame 1221). Publish + // the continuation instead: the dispatcher routes it to whoever + // owns it (a neighboring region variant, AOT text, or the + // interpreter). + ss << emit_interrupt_check(next_addr, config_.indent); + ss << config_.indent + << fmt::format("cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + next_addr); } else { + // Non-CPS legacy: the check-only fall-through remains valid (the + // function nests; a C return continues the caller). Same split as + // upstream PR #161. ss << emit_interrupt_check(next_addr, config_.indent); } } @@ -2792,6 +2828,30 @@ GeneratedFunction CodeGenerator::generate_function( fallthrough_name); } } + } else if (!cfg.block_order.empty()) { + // No in-image fallthrough function exists (region/image boundary): + // a reachable last block that simply runs off the end must TAIL- + // TRANSFER to its continuation PC, never fall off the C function — + // the entry-switch prologue consumed cpu->pc to 0, so falling off + // returns pc==0 to the dispatcher and the top-level trampoline reads + // it as "program ended" (WipEout 3 static bake: region-truncated + // block_800D7FC0 in the 0xD5000 variant fell off at 0x800D8004 → + // deterministic "execution completed, PC=0" ~10 s into boot). The + // published continuation is routed by the dispatcher to whoever owns + // it (a neighboring region variant, AOT text, or the interpreter). + const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); + bool runs_off_end = + last_block.exit_instr.type == ControlFlowType::None || + ((last_block.exit_instr.type == ControlFlowType::Branch || + last_block.exit_instr.type == ControlFlowType::Jump) && + last_block.successors.empty()); + bool is_reachable = last_block.is_entry || !last_block.predecessors.empty(); + if (runs_off_end && is_reachable) { + uint32_t cont = last_block.end_addr + 4u; + body_ss << fmt::format( + " cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + cont); + } } body_ss << " ; /* label compatibility: C requires a statement after the last label */\n"; body_ss << "}\n"; @@ -3021,6 +3081,25 @@ std::vector CodeGenerator::generate_alias_group( body << fmt::format(" {}(cpu); /* fallthrough to next function */\n", fallthrough_name); } + } else if (!cfg.block_order.empty() && + live_blocks.count(cfg.block_order.back())) { + // Image-edge fallthrough (mirrors generate_function): no in-image next + // function exists, so a live final block that runs off the end must + // tail-transfer to its continuation PC — falling off the C function + // returns the entry-switch's consumed pc==0 and the top-level + // trampoline reads "program ended" (the WipEout 3 static-bake boot + // exit: truncated block_800D7FC0 in the 0xD5000 region variant). + const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); + bool runs_off_end = + (last_block.exit_instr.type == ControlFlowType::None) || + ((last_block.exit_instr.type == ControlFlowType::Branch || + last_block.exit_instr.type == ControlFlowType::Jump) && + last_block.successors.empty()); + if (runs_off_end) { + body << fmt::format( + " cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + last_block.end_addr + 4u); + } } body << " ; /* label compatibility */\n"; body << "}\n"; @@ -3308,6 +3387,14 @@ std::string CodeGenerator::build_shared_decls_header(const std::vector(runtime, "fast_boot"); } + if (runtime.contains("ape_card_unstick")) { + rt.ape_card_unstick = toml::find(runtime, "ape_card_unstick"); + } if (runtime.contains("openbios")) { rt.openbios = toml::find(runtime, "openbios"); } @@ -638,6 +641,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("hd_texture_dir")) { rt.video_hd_texture_dir = toml::find(video, "hd_texture_dir"); } + if (video.contains("hd_texture_exclude")) { + rt.video_hd_texture_exclude = + toml::find>(video, "hd_texture_exclude"); + } if (video.contains("geometry_correction")) { rt.video_geometry_correction = toml::find(video, "geometry_correction"); diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 0363f8c8..262264fe 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -254,6 +254,14 @@ struct RuntimeConfig { // Kept so existing game.toml/settings.toml keep working. bool fast_boot = false; + // Ape Escape LOAD card-IRQ unstick pump ([runtime] ape_card_unstick). + // A per-game kernel-nest repair that force-re-edges I_MASK.7/IRQ7 after a + // LOAD-style probe abort. OPT-IN: on WipEout 3 under the x2 CRTC the + // arming detector misfires and the forced IRQ storm wedges the kernel + // card driver (completion events never deliver -> memcard screen hangs). + // Env PSX_APE_CARD_UNSTICK=0/1 still overrides either way. + bool ape_card_unstick = false; + // bios_hle: High-Level Emulation tier for BIOS kernel services // (CLAUDE.md §0 amendment 2026-07-02, the gbarecomp model). DEFAULT ON as of // 2026-07-06 (user-directed player default: instant boot-skip for every @@ -460,6 +468,13 @@ struct RuntimeConfig { // authored for RetroArch already expects to sit. std::string video_hd_texture_dir; + // hd_texture_exclude: texture hashes (hex) never substituted from the HD + // pack, merged with the pack dir's optional exclude.txt. Config-owned so + // the exclusions survive pack-directory regeneration - the pack dir is + // game content (gitignored), and losing its exclude.txt silently + // re-enabled font-atlas substitution (the doubled-glyph defect). + std::vector video_hd_texture_exclude; + // geometry_correction: sub-pixel vertex precision (the PGXP-style fix for // PS1 polygon jitter/wobble). The GTE projects in 16.16 and then throws the // fraction away when it saturates SXY to integer screen pixels; vertices of diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index dc9bb0ce..61c8e19b 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -171,17 +171,25 @@ bool FullFunctionEmitter::emit_function( const char* e = std::getenv("PSX_CPS"); return e == nullptr || e[0] != '0'; }(); + /* Redirect-honoring checks — see CodeGenerator::emit_interrupt_check: + * an exception epilogue that publishes a resume PC different from this + * site's static continuation must surface to the trampoline, never be + * overwritten by the static transfer (the dropped-continuation class + * behind the WipEout 3 static-bake top-level PC=0 exit). */ auto emit_irq_check = [](uint32_t resume_pc, const std::string& indent = " ") { + uint32_t rt = bios_runtime_pc(resume_pc); return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", - bios_runtime_pc(resume_pc)); + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", rt) + indent + + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", rt); }; auto emit_irq_check_expr = [](const std::string& resume_pc_expr, const std::string& indent = " ") { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent + + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ ({})) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", + resume_pc_expr); }; auto emit_cosim_instr = [](uint32_t pc, const std::string& indent = " ") { return "#ifdef PSX_COSIM\n" + indent + @@ -713,7 +721,7 @@ bool FullFunctionEmitter::emit_function( auto emit_icache_fetch = [&](uint32_t rom_addr) { if (!per_insn_cycles) return; if (!(block_leaders.count(rom_addr) || (rom_addr & 0xCu) == 0)) return; - out += fmt::format("#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_icache_fetch(cpu, 0x{:08X}u);\n#endif\n", + out += fmt::format("#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_icache_fetch_interp(cpu, 0x{:08X}u);\n#endif\n", relocate_ra(rom_addr)); }; @@ -1202,9 +1210,10 @@ bool FullFunctionEmitter::emit_function( " * back here as a registered continuation target. */\n" " if (cpu->read_word(0x{:08X}u) != 0u) {{\n" " psx_check_interrupts_at(cpu, 0x{:08X}u);\n" + " if (cpu->pc != 0u) {{ if ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume */\n" " cpu->pc = 0x{:08X}u; return;\n" " }}\n", - addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc); + addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc, ram_pc); } // Non-terminator: emit normally — unless this load's successor reads @@ -1638,6 +1647,10 @@ void FullFunctionEmitter::emit_dispatch( out += "extern void psx_check_interrupts(CPUState* cpu);\n"; out += "extern void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc);\n"; out += "extern void psx_restore_state_escape(void);\n"; + out += "#include \"psx_icache.h\" /* inline per-insn i-cache tag hit */\n"; + out += "#ifndef PSX_ICACHE_FETCH\n" + "#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n" + "#endif\n"; out += "extern void gte_execute(CPUState* cpu, uint32_t cmd);\n"; out += "extern void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val);\n"; out += "extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg);\n"; @@ -1914,7 +1927,7 @@ void FullFunctionEmitter::emit_dispatch( out += " (w1 & 0xFFFF0000u) != 0x25080000u ||\n"; out += " w2 != 0x01000008u || w3 != 0u) return 0;\n"; out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - out += " psx_icache_fetch(cpu, addr);\n"; + out += " psx_icache_fetch_interp(cpu, addr);\n"; out += fmt::format(" psx_cyc_step(cpu, 0x{:X}u);\n", psx_cyc_dep_res_mask(0x3C080000u)); out += fmt::format(" psx_cyc_step(cpu, 0x{:X}u);\n", @@ -2153,6 +2166,12 @@ void FullFunctionEmitter::emit_dispatch( out += " if (outermost) {\n"; out += " psx_dispatch_check_return_boundary(cpu, stop_addr);\n"; out += " }\n"; + out += " /* Top-level pc==0 exit is the abnormal-run terminator: name the\n"; + out += " * last dispatched target in the pc0 journal before surfacing. */\n"; + out += " if (stop_addr == 0u) {\n"; + out += " extern void psx_pc0_trampoline_exit(CPUState* cpu, uint32_t last_target);\n"; + out += " psx_pc0_trampoline_exit(cpu, addr);\n"; + out += " }\n"; out += " return;\n"; out += " }\n"; out += " if (stop_addr != 0 && cpu->pc == stop_addr) {\n"; @@ -2300,6 +2319,10 @@ EmitStats FullFunctionEmitter::emit( full_c += "extern void psx_check_interrupts(CPUState* cpu);\n"; full_c += "extern void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc);\n"; full_c += "extern void psx_restore_state_escape(void);\n"; + full_c += "#include \"psx_icache.h\" /* inline per-insn i-cache tag hit */\n"; + full_c += "#ifndef PSX_ICACHE_FETCH\n" + "#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n" + "#endif\n"; full_c += "extern void gte_execute(CPUState* cpu, uint32_t cmd);\n"; full_c += "extern void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val);\n"; full_c += "extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg);\n"; diff --git a/recompiler/src/pgxp_hook_emitter.cpp b/recompiler/src/pgxp_hook_emitter.cpp index 7f4c8921..247837a5 100644 --- a/recompiler/src/pgxp_hook_emitter.cpp +++ b/recompiler/src/pgxp_hook_emitter.cpp @@ -38,6 +38,13 @@ inline int16_t get_imm16(uint32_t i) { return (int16_t)(i & 0xFFFF); } * after the directive — it only warns. That bug dropped 3268 hooks (1641 * MULDIV + 1627 ALU) across 54 shards of one game's AOT output. */ void append_pgxp_hooks(uint32_t instr, std::string& code) { + /* A translation ending in a preprocessor directive (the block-cycles + * muldiv wrappers end in `#endif`) would swallow an appended hook: the + * preprocessor discards tokens after #endif (the "extra tokens at end of + * #endif" warning class), so the MULT/DIV/MFLO/MFHI hooks silently never + * ran. Give the hook its own line. */ + if (code.size() >= 6 && code.compare(code.size() - 6, 6, "#endif") == 0) + code += "\n "; const uint32_t opcode = (instr >> 26) & 0x3F; const uint32_t rs = get_rs(instr); const uint32_t rt = get_rt(instr); diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 5d1713bc..beb55c57 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -220,6 +220,51 @@ extern uint32_t gte_geometry_correction_hits(void); extern void gte_geometry_correction_stats(uint32_t *lookups, uint32_t *hits, uint32_t *miss_unrecorded, uint32_t *miss_ambiguous); +/* Exact-sign NCLIP coverage: precise = all three SXY shadows live and + * word-validated; fallback = integer sign decided (the widescreen thin-face + * flicker regime); corrected = sign disagreements actually fixed. */ +extern void gte_nclip_precise_stats(uint64_t *hits, uint64_t *fallbacks, + uint64_t *corrected); + +/* pc=0 escape journal (traps.c). Every runtime site that publishes a null PC + * (the "continue the native chain below" convention) or rescues one records + * an entry; when the convention breaks — a published 0 reaches the top-level + * trampoline and the run dies with "execution completed, PC=0" — the ring + * tail names the exact publisher and its context. Publishes are rare, so the + * always-on cost is a few stores; this works in play builds where the + * per-dispatch fntrace ring is compiled out (the exit trace was blind). */ +enum { + PSX_PC0J_TRAP_CHANGE_SELF = 1, /* a: target_tcb */ + PSX_PC0J_TRAP_FIBER_SWITCH = 2, + PSX_PC0J_TRAP_CRIT_ENTER = 3, /* a: sr */ + PSX_PC0J_TRAP_CRIT_EXIT = 4, /* a: sr */ + PSX_PC0J_TRAP_NULL_TARGET = 5, /* a: addr (0) */ + PSX_PC0J_TRAP_SENTINEL = 6, /* a: addr; in-exception longjmp path */ + PSX_PC0J_TRAP_ASYNC_RFE = 7, /* a: resume pc (rescue, not a 0) */ + PSX_PC0J_TRAP_E10_NOOP = 8, /* a: addr */ + PSX_PC0J_TRAP_STALE_MISS = 9, /* a: addr */ + PSX_PC0J_IRQ_SAME_THREAD = 10, /* a: would-be resume; d: escape reason */ + PSX_PC0J_IRQ_RESCUE = 11, /* a: published resume; d: reason */ + PSX_PC0J_DIRTY_SENTINEL = 12, /* d: bit0 in_exception, bit1 async-armed */ + PSX_PC0J_DIRTY_UNSUPPORTED = 13, /* a: unsupported pc; d: reason */ + PSX_PC0J_TOP_EXIT = 14, /* trampoline top-level pc==0 exit; + * a: last dispatched target; d: $sp */ +}; +typedef struct Pc0JournalEntry { + uint64_t seq; + uint32_t site; /* PSX_PC0J_* */ + uint32_t frame; /* present frame count at publish */ + int32_t depth; /* g_psx_dispatch_depth at publish */ + uint32_t a; /* site-specific (see enum) */ + uint32_t b; /* $ra at publish */ + uint32_t c; /* COP0 EPC at publish */ + uint32_t d; /* site-specific extra */ +} Pc0JournalEntry; +#define PSX_PC0J_CAP 64 +extern Pc0JournalEntry g_pc0j_ring[PSX_PC0J_CAP]; +extern uint64_t g_pc0j_seq; +extern void psx_pc0_journal_note(uint32_t site, CPUState *cpu, + uint32_t a, uint32_t d); /* PGXP dataflow-shadowing hook macros (PGXP_LOAD/STORE/ALU/MULDIV/COP2). * The emitter writes them unconditionally; they expand to real calls only diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 6f5601dc..a792cff9 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -22,7 +22,6 @@ int gl_renderer_init_context(struct SDL_Window *win); /* Set the GL swap interval / vsync mode (1=vsync, 0=immediate, -1=adaptive). * Safe before or after context creation; applies live when a context exists. */ void gl_renderer_set_swap_interval(int interval); - /* Presentation-only frame interpolation. High-refresh sub-presents blend the * two most recent stable display images; guest simulation timing is unchanged. */ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index c2acb8e7..eee589b7 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -211,6 +211,23 @@ int psx_ws_cull_vxrange(uint32_t x, uint32_t imm) { return (((x + (uint32_t)margin) & 0xFFFFu) < (bound + 2u * (uint32_t)margin)) ? 1 : 0; } +/* [[widescreen.cull.widen]] SLT-family helpers. The emitter writes these at + * explicitly configured cull sites, INCLUDING in overlay-resident code, so + * the DLL must define them or the shard fails to link (undefined reference -> + * no native shard -> that mod-patched page runs interpreted forever). Same + * contract as the funnel helpers above: self-contained on psx_ws_x_margin(), + * identity at margin 0 (4:3), MUST stay byte-for-byte identical to the gpu.c + * implementations. */ +int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm) { + int32_t bound = (int32_t)(int16_t)(uint16_t)imm; + return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; +} +int psx_ws_cull_slt_widen(uint32_t rs, uint32_t rt, int bound_is_rt) { + const int32_t m = psx_ws_x_margin(); + const int32_t a = (int32_t)rs, b = (int32_t)rt; + return bound_is_rt ? ((a < b + m) ? 1 : 0) + : ((a + m < b) ? 1 : 0); +} void psx_ws_sprite_tag(CPUState *cpu) { if (g_cbs.ws_sprite_tag) g_cbs.ws_sprite_tag(cpu); } diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index db419e91..b33b2e54 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -34,6 +34,12 @@ int pgxp_enabled(void); void pgxp_set_cpu_mode(int enabled); int pgxp_cpu_mode(void); +/* ALU/MULDIV hook bodies run only when tier-2 cpu_mode OR this flag is set + * (texture/geometry correction dataflow chains). NCLIP-only arms leave it 0: + * the SXY chain is LOAD/STORE/COP2. Stale shadows from the gated period are + * dropped by pv_validate at every consumer (the single safety invariant). */ +void pgxp_set_full_hooks(int on); + /* Reject a precise position whose sub-pixel offset from the native integer * position exceeds this many pixels. < 0 disables the clamp (default). */ void pgxp_set_tolerance(float pixels); @@ -58,6 +64,11 @@ void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, uint32_t packed); /* Current SXY FIFO shadow (index 0..3 selects GTE data regs 12..15). * Returns nonzero when the shadow is live and carries X/Y precision. */ int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16); +/* Validate-on-read variant: check!=0 additionally requires the shadow's packed + * word to equal expect (the live guest SXY word) - the engine's single safety + * invariant, which sign-consumers must not skip. */ +int pgxp_get_gte_sxy_checked(uint32_t index, uint32_t expect, int check, + int32_t *x16, int32_t *y16); /* Guest write to a GTE data register outside gte_execute (MTC2/CTC2 handled * by psx_pgxp_cop2; this is for direct gte_write_data paths): reg 15 performs @@ -98,6 +109,7 @@ typedef struct PGXPStats { uint64_t w_valid; /* lookups that also carried a usable depth */ uint64_t produced; /* RTPS/RTPT projections pushed into shadows */ uint64_t swc2_stores; /* GTE reg shadows copied to RAM shadows */ + uint64_t invalidations; /* generation bumps (each kills all shadows) */ } PGXPStats; void pgxp_get_stats(PGXPStats *out); diff --git a/runtime/include/pgxp_hooks.h b/runtime/include/pgxp_hooks.h index 257525f1..a4d91a41 100644 --- a/runtime/include/pgxp_hooks.h +++ b/runtime/include/pgxp_hooks.h @@ -73,10 +73,17 @@ typedef struct PGXPHooks { } PGXPHooks; #if defined(PSX_PGXP) && PSX_PGXP +/* ALU/MULDIV hooks matter only under tier-2 cpu_mode or the correction + * consumers' dataflow chains; an NCLIP-only arm leaves them idle. Their hook + * bodies already early-out, but at ~100M ALU/s the CALL ITSELF was 3.4% of + * the emu thread (WipEout 3 120 Hz profile) — gate inline on a flag pgxp.cpp + * maintains (g_pgxp_alu_armed = active && (cpu_mode || full_hooks)). LOAD/ + * STORE/COP2 stay direct: they carry the SXY provenance chain. */ +extern int g_pgxp_alu_armed; #define PGXP_LOAD(instr, addr, val) psx_pgxp_load(cpu, (instr), (addr), (val)) #define PGXP_STORE(instr, addr, val) psx_pgxp_store(cpu, (instr), (addr), (val)) -#define PGXP_ALU(instr, res, s1, s2) psx_pgxp_alu(cpu, (instr), (res), (s1), (s2)) -#define PGXP_MULDIV(instr, hi, lo, s1, s2) psx_pgxp_muldiv(cpu, (instr), (hi), (lo), (s1), (s2)) +#define PGXP_ALU(instr, res, s1, s2) do { if (g_pgxp_alu_armed) psx_pgxp_alu(cpu, (instr), (res), (s1), (s2)); } while (0) +#define PGXP_MULDIV(instr, hi, lo, s1, s2) do { if (g_pgxp_alu_armed) psx_pgxp_muldiv(cpu, (instr), (hi), (lo), (s1), (s2)); } while (0) #define PGXP_COP2(instr, val, addr) psx_pgxp_cop2(cpu, (instr), (val), (addr)) #else #define PGXP_LOAD(instr, addr, val) ((void)0) diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 6ca5b2a6..78b8af14 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -75,6 +75,8 @@ uint64_t sio_get_advance_with_work(void); * Bulk 0x80 responses follow the real TAP/REQ latch (psx-spx): REQ=1 in the * third command byte arms the *next* transfer; empty tap slots are fine. */ void sio_set_multitap(int enabled); +/* [runtime] ape_card_unstick config default (env PSX_APE_CARD_UNSTICK overrides). */ +void sio_set_ape_card_unstick(int on); int sio_get_multitap(void); /* phys_port: 0 = console Port 1, 1 = console Port 2. Default 0. */ void sio_set_multitap_port(int phys_port); diff --git a/runtime/include/tex_pack.h b/runtime/include/tex_pack.h index 9ef71cab..e421b831 100644 --- a/runtime/include/tex_pack.h +++ b/runtime/include/tex_pack.h @@ -51,6 +51,8 @@ extern "C" { * still just works. The dump folder always follows the disc-stem convention. * * Cheap no-op (and tex_pack_active() stays 0) when both flags are 0. */ +/* Add a config-owned exclusion (call after tex_pack_init). */ +void tex_pack_add_excluded(uint32_t hash); void tex_pack_init(const char *disc_path, int enable_replace, int enable_dump, const char *dir_override, const char *pack_dir); void tex_pack_shutdown(void); diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index 2018428e..33641732 100644 --- a/runtime/src/autocompile.c +++ b/runtime/src/autocompile.c @@ -855,6 +855,16 @@ int autocompile_request(void) { if (s_cache_dir[0]) SetEnvironmentVariableA("PSX_OVERLAY_CACHE_DIR", s_cache_dir); if (s_captures[0]) SetEnvironmentVariableA("PSX_OVERLAY_CAPTURES", s_captures); SetEnvironmentVariableA("PSX_OVERLAY_LIVE_AUTOCOMPILE", "1"); + /* Pin the FLAVOR the same way: the tool's --flavor defaults to 0 (play), + * so an instrumented (flavor 2) runtime spawned a compile whose shards it + * could never load — the loader's flavor guard rejected them and EVERY + * overlay ran interpreted, forever, on debug builds. The child must + * always write the flavor this runtime reads. */ + { + char flavor_buf[16]; + snprintf(flavor_buf, sizeof flavor_buf, "%d", (int)PSX_OVERLAY_FLAVOR); + SetEnvironmentVariableA("PSX_OVERLAY_FLAVOR", flavor_buf); + } /* cmd.exe /C resolves the command via PATH and supports the relative * paths in the configured line (cwd = project root). The WHOLE command is diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index fd9cdd42..380626b6 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -2900,7 +2900,13 @@ static void cdrom_apply_savestate_delay_caps(int log_clamps) { void cdrom_accelerate_after_savestate(void) { const char *probe = getenv("PSX_POST_LOAD_PROBE"); const int log_probe = (probe && probe[0] == '1'); - s_savestate_cd_boost_vblanks = CDROM_SAVESTATE_BOOST_VBLANKS; + /* Counted per SIM vblank: scale by the CRTC multiplier so the boost + * window keeps its intended ~3s of wall time at NTSC x2. */ + { + extern uint32_t gpu_get_crtc_refresh_multiplier(void); + s_savestate_cd_boost_vblanks = CDROM_SAVESTATE_BOOST_VBLANKS * + (int)gpu_get_crtc_refresh_multiplier(); + } cdrom_apply_savestate_delay_caps(/*log_clamps*/log_probe); if (log_probe) fprintf(stderr, diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 655b39f9..4af4a7e2 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5215,6 +5215,11 @@ static void handle_geom_correction(int id, const char *json) * triangles that reach the predicate. */ uint64_t tc_att = 0, tc_arm = 0, tc_off = 0, tc_nosrc = 0, tc_noz = 0; gpu_texture_correction_stats(&tc_att, &tc_arm, &tc_off, &tc_nosrc, &tc_noz); + /* Exact-sign NCLIP coverage: fallbacks are frames' worth of faces decided + * by the integer sign (the flicker regime); invalidations counts shadow + * generation bumps (each one blinks the whole arena's provenance). */ + uint64_t nc_hit = 0, nc_fb = 0, nc_corr = 0; + gte_nclip_precise_stats(&nc_hit, &nc_fb, &nc_corr); send_fmt("{\"id\":%d,\"ok\":true," "\"geometry_correction\":%d," "\"geometry_vertex_hits\":%u," @@ -5226,7 +5231,10 @@ static void handle_geom_correction(int id, const char *json) "\"lookups\":%llu,\"dataflow_hit\":%llu,\"fallback_hit\":%llu," "\"native\":%llu,\"value_mismatch\":%llu,\"trunc_reject\":%llu," "\"tolerance_reject\":%llu,\"w_valid\":%llu," - "\"produced\":%llu,\"swc2_stores\":%llu}}", + "\"produced\":%llu,\"swc2_stores\":%llu," + "\"invalidations\":%llu}," + "\"nclip\":{\"precise\":%llu,\"fallback\":%llu," + "\"corrected\":%llu}}", id, gte_geometry_correction_enabled(), (unsigned)hits, @@ -5245,7 +5253,10 @@ static void handle_geom_correction(int id, const char *json) (unsigned long long)ps.tolerance_reject, (unsigned long long)ps.w_valid, (unsigned long long)ps.produced, - (unsigned long long)ps.swc2_stores); + (unsigned long long)ps.swc2_stores, + (unsigned long long)ps.invalidations, + (unsigned long long)nc_hit, (unsigned long long)nc_fb, + (unsigned long long)nc_corr); } /* pgxp — live-tune the value-propagation engine for one-toggle isolation runs diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index c4fe4584..110e2cf3 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -200,6 +200,10 @@ uint32_t g_pczero_in_exc = 0; uint32_t g_pczero_async_rfe = 0; uint32_t g_pczero_dirty_safe = 0; uint64_t g_pczero_count = 0; +/* Which dirty_ram_dispatch_inner sub-path produced the last handled dispatch: + * 1=sentinel 2=AOT-compiled 3=static-overlay 4=DLL-shard 5=interp. Read by the + * pc0 tripwire so a null-PC return names its producer. */ +uint32_t g_dd_last_route = 0; /* Mid-block unsupported-opcode counters. Bumped instead of fprintf-spamming * stderr (CLAUDE.md §3). Read via dirty_ram_get_unsupported(). The "last_*" @@ -2359,6 +2363,11 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { if (r == 1 && cpu->pc == 0u) { extern uint32_t g_async_rfe_resume_pc, g_dirty_safe_resume_pc; g_pczero_count++; + /* Journal every occurrence (site 15): the dirty/overlay dispatch chain + * reported handled yet published a null PC — at top level this IS the + * abnormal exit. a = dispatched addr; d = occurrence count. */ + psx_pc0_journal_note(15u, cpu, addr, + (uint32_t)g_pczero_count | (g_dd_last_route << 28)); if (!g_pczero_latched) { g_pczero_latched = 1; g_pczero_addr = addr; @@ -2683,9 +2692,13 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ int clean_game_text_miss = 0; if (addr == 0x80000048u) { + g_dd_last_route = 1; g_sentinel_reach_dirty++; if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */ g_sentinel_reach_async = g_async_rfe_resume_pc; + psx_pc0_journal_note(PSX_PC0J_DIRTY_SENTINEL, cpu, addr, + (psx_get_in_exception() ? 1u : 0u) | + (g_async_rfe_resume_pc ? 2u : 0u)); cpu->pc = 0; if (psx_get_in_exception()) { psx_exception_longjmp(); /* does not return */ @@ -2714,6 +2727,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ ls_func_enter(addr, cpu); int prev_phase = g_exec_phase; g_exec_phase = 3; + g_dd_last_route = 2; int _gc = psx_dispatch_game_compiled(cpu, addr); g_exec_phase = prev_phase; ls_func_exit(addr, cpu, _gc); @@ -2741,6 +2755,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ #ifdef PSX_HAS_OVERLAY_DISPATCH { extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); + g_dd_last_route = 3; if (psx_overlay_dispatch(cpu, addr)) return 1; } #endif @@ -2771,6 +2786,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); + g_dd_last_route = 4; if (overlay_loader_dispatch(cpu, addr)) { if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); return 1; @@ -2779,7 +2795,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ /* Every exit retires any deferred load writeback: once we hand control back to * compiled code (or the dispatch loop) nothing downstream knows a register * write is still owed, and the pipeline would have drained by then anyway. */ -#define OV_FPLOG_RET1() do { dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) +#define OV_FPLOG_RET1() do { g_dd_last_route = 5; dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) if (!dirty_ram_is_dirty(phys) && !clean_game_text_miss) { /* Bulk host transfers can populate post-EXE executable RAM without @@ -2815,6 +2831,17 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * pages overwritten by a runtime overlay (Tomba 2), not just [FLOOR, RAM). */ int allow_local_dirty_flow = phys_is_overlay_flow_region(phys); + /* Backend-invariant mod_function_entry hooks: generated code fires + * psx_mod_function_entry at listed function entries, but a mod-patched + * (dirty) page runs HERE instead and would silently skip them. Fire the + * same hook on interp dispatch so the contract does not depend on which + * backend executes the page. The runtime filters by exact address, so + * this is a short scan over the (tiny) registered-plugin list. */ + { + extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); + psx_mod_function_entry(cpu, addr); + } + /* Per-PC entry counter (visible via dirty_ram_stats). */ DirtyRamPcEntry *pc_entry = pc_table_get_or_insert(phys); if (pc_entry) pc_entry->hits++; @@ -3025,6 +3052,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ g_dirty_ram_last_unsupported_pc = g_unsupported_pc; g_dirty_ram_last_unsupported_insn = g_unsupported_insn; g_dirty_ram_last_unsupported_reason = g_unsupported_reason; + psx_pc0_journal_note(PSX_PC0J_DIRTY_UNSUPPORTED, cpu, + g_unsupported_pc, g_unsupported_insn); cpu->pc = 0; OV_FPLOG_RET1(); } diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c index 1b19c019..fb45e2c2 100644 --- a/runtime/src/fntrace.c +++ b/runtime/src/fntrace.c @@ -131,6 +131,13 @@ void fntrace_record(CPUState* cpu, uint32_t target) { /* The BIOS has completed the PS-X EXE load by the time it dispatches the * entry point. Apply the validated plan before the first guest instruction. */ mod_runtime_on_dispatch(target); + /* Play builds (PSX_NO_DEBUG_TOOLS): the tail/SP-domain rings have no + * reader — the TCP server that dumps them is compiled out — so their + * per-dispatch writes (incl. a psx_get_cycle_count() call each) are pure + * tax at ~1M dispatches/s. Post-mortem forensics in play builds is + * carried by the pc0 escape journal (traps.c), which records only the + * rare events. Debug builds keep both rings always-on, as designed. */ +#ifndef PSX_NO_DEBUG_TOOLS { extern uint64_t psx_get_cycle_count(void); DispTailEntry *t = &g_disp_tail[g_disp_tail_seq % DISP_TAIL_CAP]; @@ -161,6 +168,7 @@ void fntrace_record(CPUState* cpu, uint32_t target) { s_spdom_prev_sp = sp_now; } } +#endif /* !PSX_NO_DEBUG_TOOLS */ /* On-the-fly string translation (framework feature — text_xlate.cpp). The * generated psx_dispatch_impl calls us at the top of each dispatch iteration diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 72068b21..559538d1 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -221,7 +221,13 @@ static int ws_gameplay_state_value_count = 0; * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ #define WS_GTE_GAME_MODE_HYSTERESIS 45u void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } -void gpu_ws_set_precise_nclip(int on) { ws_precise_nclip_cfg = on ? 1 : 0; } +void gpu_pgxp_rederive_enable(void); +void gpu_ws_set_precise_nclip(int on) { + ws_precise_nclip_cfg = on ? 1 : 0; + /* Precise NCLIP and clamp rescue consume PGXP dataflow shadows even when + * both correction features are off — re-derive the engine arm. */ + gpu_pgxp_rederive_enable(); +} int gpu_ws_precise_nclip_enabled(void) { return ws_precise_nclip_cfg && ws_active(); } void gpu_ws_set_gameplay_state_gate(uint32_t addr, const uint32_t *values, int nvalues) { @@ -364,6 +370,10 @@ int gpu_ws_present_native_43(void) { /* Squash applies only when configured AND the frame is being stretched. */ static int ws_active(void) { return ws_configured() && !gpu_ws_present_native_43(); } +/* Exported scene gate for the present-aspect switch (main.cpp): menus/2D and + * FMV present at the game's own 4:3 (its natural bars intact, bezels around); + * gameplay presents at the mod aspect. */ +int psx_ws_scene_wide_active(void) { return ws_active() && ws_game_mode(); } /* ----- Native-wide (mode 2) ------------------------------------------------ * Render the wider FOV into an actually wider frame instead of squashing it. @@ -3448,12 +3458,24 @@ static int s_texture_correction_enabled = 0; extern int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z); +/* The PGXP dataflow engine feeds the two correction features AND the + * widescreen precision consumers (precise NCLIP, clamp rescue). Arm it while + * any of them is on. Every setter re-derives through here so init order and + * runtime toggles (debug server) can never leave the engine stale. */ +void gpu_pgxp_rederive_enable(void) { + pgxp_set_enabled(s_texture_correction_enabled || + gte_geometry_correction_enabled() || + ws_precise_nclip_cfg); + /* ALU/MULDIV hooks feed only the correction consumers' dataflow chains + * (and tier-2 cpu_mode, gated inside pgxp.cpp); an NCLIP-only arm skips + * their per-instruction bodies (LOAD/STORE/COP2 carry the SXY chain). */ + pgxp_set_full_hooks(s_texture_correction_enabled || + gte_geometry_correction_enabled()); +} + void gpu_texture_correction_set(int enabled) { s_texture_correction_enabled = enabled ? 1 : 0; - /* The PGXP dataflow engine feeds BOTH corrections; arm it while either - * is on (geometry correction is toggled in gte.cpp, so re-derive here). */ - pgxp_set_enabled(s_texture_correction_enabled || - gte_geometry_correction_enabled()); + gpu_pgxp_rederive_enable(); } int gpu_texture_correction_enabled(void) { @@ -3498,10 +3520,62 @@ int gpu_pgxp_census_dump(uint32_t *frame_out, uint32_t *tris, uint32_t *clean, return n; } +/* Clamp rescue (widescreen precision, rides the precise-NCLIP opt-in): a + * vertex parked exactly on a GTE saturation rail (+/-1024 screen units) is + * hardware-mangled — its true projection is somewhere far off-frame, and + * rasterizing the clamped position folds that error INTO the frame as the + * classic PS1 "polygon spike" (a sliver streaking across the screen). 4:3 + * framing plus CRT overscan hid most of these; a wide aspect stares right at + * them. When the address-keyed dataflow shadow knows the true sub-pixel + * position (clamped to +/-4096 px at the producer), substitute it for railed + * vertices only — every in-range vertex stays native, so normal geometry is + * bit-identical and shared edges cannot crack. */ +static int ws_clamp_rescue_vertex(uint32_t addr, uint32_t word, + int32_t raw_x, int32_t raw_y, + int32_t *px, int32_t *py) { + if (raw_x != -1024 && raw_x != 1023 && raw_y != -1024 && raw_y != 1023) + return 0; + uint16_t sz; + if (pgxp_get_precise_vertex(addr, word, raw_x, raw_y, px, py, &sz) + != PGXP_SRC_DATAFLOW) + return 0; + /* Only substitute when the shadow actually differs — a legit vertex that + * genuinely sits on the rail keeps its native position. */ + return ((*px >> 16) != raw_x) || ((*py >> 16) != raw_y); +} + static void prepare_precise_triangle(int i0, int i1, int i2, const int32_t vx[3], const int32_t vy[3]) { gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); if (!gte_geometry_correction_enabled()) { + if (gpu_ws_precise_nclip_enabled() && + gp0_cmd_source_addr != 0xFFFFFFFFu) { + const int idx[3] = { i0, i1, i2 }; + int32_t fx[3], fy[3]; + int any_rescued = 0; + for (int i = 0; i < 3; i++) { + uint32_t word = gp0_cmd_buf[idx[i]]; + int32_t raw_x, raw_y; + parse_vertex(word, &raw_x, &raw_y); + uint32_t addr = gp0_cmd_source_addr + (uint32_t)idx[i] * 4u; + int32_t px, py; + if (ws_clamp_rescue_vertex(addr, word, raw_x, raw_y, &px, &py)) { + any_rescued = 1; + fx[i] = (int32_t)((int64_t)px + + (int64_t)(vx[i] - raw_x) * 65536); + fy[i] = (int32_t)((int64_t)py + + (int64_t)(vy[i] - raw_y) * 65536); + } else { + fx[i] = vx[i] << 16; + fy[i] = vy[i] << 16; + } + } + if (any_rescued) { + gr_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], + fx[2],fy[2]); + return; + } + } gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } @@ -5829,12 +5903,21 @@ static void gp1_dma_direction(uint32_t val) { dma_direction = val & 3; } +/* Internal rendered-frame counter: the game finishing a frame = it flips the + * display origin to the other buffer (GP1(05h) with a CHANGED address). This + * is the "internal FPS" every mainstream emulator reports, distinct from the + * vblank rate — under load the two diverge and both matter. */ +static uint64_t s_display_flips = 0; +uint64_t psx_gpu_display_flip_count(void) { return s_display_flips; } static void gp1_display_area_start(uint32_t val) { /* GP1(05h): Start of display area in VRAM * bits 0-9: X (in halfwords, 0-1023) * bits 10-18: Y (0-511) */ - display_area_x = val & 0x3FF; - display_area_y = (val >> 10) & 0x1FF; + uint32_t nx = val & 0x3FF; + uint32_t ny = (val >> 10) & 0x1FF; + if (nx != display_area_x || ny != display_area_y) s_display_flips++; + display_area_x = nx; + display_area_y = ny; ws_note_display_base(display_area_x); /* learn the display buffer set (native-wide) */ } diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d4fd8478..e0f3c4eb 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4123,6 +4123,16 @@ int gl_renderer_cpu_auth_dual(void) { return s_cpu_auth_dual; } +/* When driver vsync owns the frame cadence, the blocking Swap IS the game's + * clock: skipping the swap on an unchanged frame silently unpaces that frame + * and the sim sprints (WipEout 3 at 30 Hz sim: every other vblank renders + * nothing new, so "fps readout off" ran the game at a fluctuating ~2x — the + * readout's persistent OSD line forced a swap per frame and masked it). + * Real hardware scans out every vblank regardless of change; when the swap + * paces, so do we. Queried LIVE at every skip decision (frontend + * psx_present_swap_paces) so no cached copy can go stale. */ +extern int psx_present_swap_paces(void); + void gl_renderer_present_probe_reset(void) { s_probe_skip = 0; s_probe_swap = 0; @@ -5412,48 +5422,133 @@ int gl_renderer_set_bezel(const void *rgba, int w, int h) { int gl_renderer_has_bezel(void) { return s_bezel_tex != 0; } -/* Draw the bezel into ONE viewport, cover-cropped to that viewport's aspect. +/* Padding shade: a procedural darkening gradient drawn OVER the bezel layer, + * derived from the live viewport rectangle. The area adjacent to the game is + * near-black and low-contrast; the pattern re-emerges gradually toward the + * outer screen edges, so the bezel reads as environmental texture instead of + * a second UI layer competing with gameplay. The game framebuffer itself is + * never touched — the shade exists only in the padding rects, which also + * makes it aspect-agnostic (columns shade along X, bands along Y). * - * Cover, not stretch: scale to fill and crop the overflow, centred. Stretching - * distorts any artwork whose aspect does not match, and a margin rarely does -- - * a portrait poster squeezed across a 7680-wide window is unrecognisable, while - * the same poster cover-cropped into a tall narrow margin fits it naturally. */ -/* Tile the bezel down one margin, preserving the logo's aspect. - * - * A margin is tall and narrow; a single logo stretched to fill it would be - * grotesque and cover-cropping one would show a sliver. Tiling keeps every - * copy at its authored proportions and fills any margin size, which is what a - * repeating mark is for. The count is derived from the margin width so the - * logo reads at a consistent size regardless of resolution or window shape. */ -static void bezel_draw_rect(int vx, int vy, int vw, int vh) { + * The same quads implement the transition fade: on any viewport-rect change + * (aspect contraction into a menu, a window resize) the padding starts fully + * black and reveals the pattern over ~200 ms, so the eye follows the moving + * game image rather than a full-intensity pattern popping in on frame one. */ +static const char *BEZEL_SHADE_FS = + "#version 330\n" + "in vec2 v_uv; out vec4 frag;\n" + "uniform sampler2D u_tex;\n" + "uniform int u_tex_on;\n" /* 1: dimmed artwork; 0: SDF black shade */ + "uniform vec4 u_view;\n" /* game viewport rect, window px: x,y,w,h */ + "uniform vec4 u_rgba;\n" /* shade color (rgb); .a = art dim factor */ + "uniform vec2 u_a;\n" /* alpha at dist 0 / at dist >= u_fall */ + "uniform float u_fall;\n" /* falloff length in px */ + "void main(){\n" + " if (u_tex_on == 1) {\n" + " vec4 t = texture(u_tex, v_uv);\n" + " frag = vec4(t.rgb, t.a * u_rgba.a);\n" + " return;\n" + " }\n" + " vec2 p = gl_FragCoord.xy;\n" + " vec2 mn = u_view.xy;\n" + " vec2 mx = u_view.xy + u_view.zw;\n" + " vec2 dv = max(vec2(0.0), max(mn - p, p - mx));\n" + " float d = clamp(length(dv) / max(u_fall, 1.0), 0.0, 1.0);\n" + " float a = mix(u_a.x, u_a.y, smoothstep(0.0, 1.0, d));\n" + " frag = vec4(u_rgba.rgb, a);\n" + "}\n"; +static GLuint s_bezel_shade_prog = 0; +static GLint s_bezel_shade_uTex = -1, s_bezel_shade_uTexOn = -1, + s_bezel_shade_uView = -1, s_bezel_shade_uRgba = -1, + s_bezel_shade_uA = -1, s_bezel_shade_uFall = -1, + s_bezel_shade_uRect = -1; +/* Fade-in state: restarted whenever the viewport rect moves. */ +static int s_bezel_prev_rect[4] = { -1, -1, -1, -1 }; +static uint64_t s_bezel_fade_start_ms = 0; +#define BEZEL_FADE_MS 200.0f +#define BEZEL_SHADE_NEAR 0.92f /* beside the game: near-black ground, + * easing out over the wide falloff (the + * user keeps the gradient; only the seam + * hairline is removed) */ +#define BEZEL_SHADE_FAR 0.40f /* outer screen edge: ground shows most */ +#define BEZEL_WATERMARK_A 0.22f /* single mark, pre-shade */ +#define BEZEL_SEAM_A 0.32f /* hairline at the viewport boundary */ + +extern uint64_t psx_host_mono_ms(void); + +/* SDF shade / seam quad: alpha follows distance-to-viewport (u_tex_on=0). */ +static void bezel_sdf_rect(int vx, int vy, int vw, int vh, + float r, float g, float b, + float a0, float a1, + const float view[4], float fall) { + if (vw <= 0 || vh <= 0) return; + glViewport(vx, vy, vw, vh); + p_glUniform1i(s_bezel_shade_uTexOn, 0); + p_glUniform4f(s_bezel_shade_uView, view[0], view[1], view[2], view[3]); + p_glUniform4f(s_bezel_shade_uRgba, r, g, b, 0.0f); + p_glUniform2f(s_bezel_shade_uA, a0, a1); + p_glUniform1f(s_bezel_shade_uFall, fall); + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +/* Tiled marks down a column / across a band, drawn through the shade + * program's texture mode so the whole pattern carries one dim factor. Same + * authored-aspect tiling math as the original bezel: one mark spans the + * margin's short dimension (with a little air), repeats along the long one. */ +/* mark_px floors the drawn mark size: a thin margin no longer shrinks the + * marks into busy little rows — they stay window-proportioned and the band + * crops them symmetrically instead (texture wrap handles the overflow). */ +static void bezel_tiles_col(int vx, int vy, int vw, int vh, float alpha, + float mark_px) { if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; const float img = (float)s_bezel_w / (float)s_bezel_h; - /* FIXED logo size, anchored to the WINDOW height rather than the margin + /* FIXED mark size, anchored to the WINDOW height rather than the margin * width. Width-derived sizing made the mark grow with every extra pixel * of pillarbox: enormous on an ultrawide, a sliver on a barely-wider- * than-4:3 window. The height does not change as the pillarbox widens, * so a height fraction reads as the same object at every window shape; a * wider margin simply shows more breathing room around the same-size - * column of marks. Clamped to 78% of the margin so narrow margins keep - * the old a-little-air-either-side behaviour instead of cropping. - * PSX_BEZEL_SCALE overrides the height fraction (default 0.18). */ + * column of marks. mark_px (window-proportioned, from the caller) floors + * the default so a thin margin crops the marks symmetrically instead of + * shrinking them into busy little rows (texture wrap handles the + * overflow). PSX_BEZEL_SCALE overrides the height fraction (default + * 0.18); an explicit override wins in both directions over the floor. */ static float scale_frac = -1.0f; + static int scale_env = 0; if (scale_frac < 0.0f) { const char *e = getenv("PSX_BEZEL_SCALE"); - scale_frac = (e && e[0]) ? (float)atof(e) : 0.18f; + scale_env = (e && e[0]) ? 1 : 0; + scale_frac = scale_env ? (float)atof(e) : 0.18f; if (scale_frac <= 0.01f || scale_frac > 1.0f) scale_frac = 0.18f; } - float want_px = scale_frac * (float)vh; - if (want_px > 0.78f * (float)vw) want_px = 0.78f * (float)vw; - const float tile_w = (float)vw / want_px; - /* Repeats needed vertically for each tile to keep the logo's own aspect: - * a tile is want_px wide on screen, so it must be want_px/img tall, and - * vh divided by that is the count. */ - const float tile_h = img * (float)vh / want_px; + float mark_w = scale_frac * (float)vh; + if (!scale_env && mark_w < mark_px) mark_w = mark_px; + const float tile_px_w = mark_w / 0.78f; /* mark + air */ + const float u_count = (float)vw / tile_px_w; + const float v_count = (float)vh / (tile_px_w / img); glViewport(vx, vy, vw, vh); - p_glUniform4f(s_present_uUvRect, - -(tile_w - 1.0f) * 0.5f, 0.0f, - (tile_w + 1.0f) * 0.5f, tile_h); + p_glUniform1i(s_bezel_shade_uTexOn, 1); + p_glUniform4f(s_bezel_shade_uRgba, 0.0f, 0.0f, 0.0f, alpha); + p_glUniform4f(s_bezel_shade_uRect, + 0.5f - u_count * 0.5f, 0.0f, + 0.5f + u_count * 0.5f, v_count); + glDrawArrays(GL_TRIANGLES, 0, 3); +} +static void bezel_tiles_band(int vx, int vy, int vw, int vh, float alpha, + float mark_px) { + if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; + const float img = (float)s_bezel_w / (float)s_bezel_h; + float mark_h = (float)vh * 0.78f; + if (mark_h < mark_px) mark_h = mark_px; + const float tile_px_h = mark_h / 0.78f; /* mark + air */ + const float v_count = (float)vh / tile_px_h; + const float u_count = (float)vw / (tile_px_h * img); + glViewport(vx, vy, vw, vh); + p_glUniform1i(s_bezel_shade_uTexOn, 1); + p_glUniform4f(s_bezel_shade_uRgba, 0.0f, 0.0f, 0.0f, alpha); + p_glUniform4f(s_bezel_shade_uRect, + 0.5f - u_count * 0.5f, 0.5f - v_count * 0.5f, + 0.5f + u_count * 0.5f, 0.5f + v_count * 0.5f); glDrawArrays(GL_TRIANGLES, 0, 3); } @@ -5462,11 +5557,20 @@ static void bezel_draw_rect(int vx, int vy, int vw, int vh) { * narrow, so portrait artwork fills them naturally, and each side gets the * whole picture instead of one picture cut in half by the frame. */ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { - (void)ly; (void)lh; if (!s_bezel_tex || ww <= 0 || wh <= 0) return; const int right_x = lx + lw; const int right_w = ww - right_x; - if (lx <= 0 && right_w <= 0) return; /* no margins to fill */ + /* Letterbox bands (top/bottom margins): same treatment as the side + * margins, so a window taller than the content aspect (e.g. 32:9 content + * in a 16:9 window) gets the bezel instead of black bars. The bands span + * only the width between the side columns so no pixel is drawn twice. */ + const int top_y = ly + lh; + const int top_h = wh - top_y; + const int bot_h = ly; + const int band_x = lx > 0 ? lx : 0; + const int band_w = (right_w > 0 ? right_x : ww) - band_x; + if (lx <= 0 && right_w <= 0 && top_h <= 0 && bot_h <= 0) + return; /* no margins to fill */ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); glDisable(GL_BLEND); @@ -5482,13 +5586,96 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { glClearColor(s_bezel_bg[0], s_bezel_bg[1], s_bezel_bg[2], 1.0f); if (lx > 0) { glScissor(0, 0, lx, wh); glClear(GL_COLOR_BUFFER_BIT); } if (right_w > 0) { glScissor(right_x, 0, right_w, wh); glClear(GL_COLOR_BUFFER_BIT); } + if (top_h > 0) { glScissor(band_x, top_y, band_w, top_h); glClear(GL_COLOR_BUFFER_BIT); } + if (bot_h > 0) { glScissor(band_x, 0, band_w, bot_h); glClear(GL_COLOR_BUFFER_BIT); } glDisable(GL_SCISSOR_TEST); glEnable(GL_BLEND); p_glBlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD); p_glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - if (lx > 0) bezel_draw_rect(0, 0, lx, wh); - if (right_w > 0) bezel_draw_rect(right_x, 0, right_w, wh); + /* "Mounted screen" treatment: the game sits on a deep-tinted ground (the + * scissor clears above already laid s_bezel_bg over every padding rect). + * One large watermark mark per padding region — no tiling, so the + * padding can never turn into wallpaper — then a distance-field shade + * that darkens toward the viewport from every direction at once, and a + * hairline seam that terminates the game rect cleanly. The whole layer + * fades in from black for ~200 ms after the viewport rect moves. */ + if (!s_bezel_shade_prog) { + s_bezel_shade_prog = build_program(PRESENT_VS, BEZEL_SHADE_FS); + if (s_bezel_shade_prog) { + s_bezel_shade_uTex = + p_glGetUniformLocation(s_bezel_shade_prog, "u_tex"); + s_bezel_shade_uTexOn = + p_glGetUniformLocation(s_bezel_shade_prog, "u_tex_on"); + s_bezel_shade_uView = + p_glGetUniformLocation(s_bezel_shade_prog, "u_view"); + s_bezel_shade_uRgba = + p_glGetUniformLocation(s_bezel_shade_prog, "u_rgba"); + s_bezel_shade_uA = + p_glGetUniformLocation(s_bezel_shade_prog, "u_a"); + s_bezel_shade_uFall = + p_glGetUniformLocation(s_bezel_shade_prog, "u_fall"); + s_bezel_shade_uRect = + p_glGetUniformLocation(s_bezel_shade_prog, "u_uv_rect"); + } + } + if (s_bezel_shade_prog) { + const uint64_t now = psx_host_mono_ms(); + if (s_bezel_prev_rect[0] != lx || s_bezel_prev_rect[1] != ly || + s_bezel_prev_rect[2] != lw || s_bezel_prev_rect[3] != lh) { + s_bezel_prev_rect[0] = lx; s_bezel_prev_rect[1] = ly; + s_bezel_prev_rect[2] = lw; s_bezel_prev_rect[3] = lh; + s_bezel_fade_start_ms = now; + } + float fade = (float)(now - s_bezel_fade_start_ms) / BEZEL_FADE_MS; + if (fade < 0.0f) fade = 0.0f; + if (fade > 1.0f) fade = 1.0f; + fade = fade * fade * (3.0f - 2.0f * fade); + + p_glUseProgram(s_bezel_shade_prog); + p_glUniform4f(s_bezel_shade_uRect, 0.0f, 0.0f, 1.0f, 1.0f); + p_glUniform1i(s_bezel_shade_uTex, 0); + + /* Tiled pattern per padding region, drawn at watermark subtlety — + * the SDF shade below then recedes it further toward the game. */ + const float wm_a = BEZEL_WATERMARK_A * fade; + const float min_dim = (float)(ww < wh ? ww : wh); + if (wm_a > 0.003f) { + const float mark_px = min_dim * 0.20f; + if (lx > 0) bezel_tiles_col(0, 0, lx, wh, wm_a, mark_px); + if (right_w > 0) bezel_tiles_col(right_x, 0, right_w, wh, wm_a, + mark_px); + if (top_h > 0) bezel_tiles_band(band_x, top_y, band_w, top_h, + wm_a, mark_px); + if (bot_h > 0) bezel_tiles_band(band_x, 0, band_w, bot_h, wm_a, + mark_px); + } + + /* Distance-field shade over ground + watermark: near-black beside + * the game, easing out over a wide falloff. At fade=0 both endpoints + * are 1.0 (solid black) — the transition state. */ + const float a_near = 1.0f - fade * (1.0f - BEZEL_SHADE_NEAR); + const float a_far = 1.0f - fade * (1.0f - BEZEL_SHADE_FAR); + const float fall = min_dim * 0.30f; + const float view[4] = { (float)lx, (float)ly, (float)lw, (float)lh }; + if (lx > 0) + bezel_sdf_rect(0, 0, lx, wh, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + if (right_w > 0) + bezel_sdf_rect(right_x, 0, right_w, wh, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + if (top_h > 0) + bezel_sdf_rect(band_x, top_y, band_w, top_h, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + if (bot_h > 0) + bezel_sdf_rect(band_x, 0, band_w, bot_h, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + + /* No seam: the user wants the game to meet the bezel pattern with + * no separator hairline (removed 2026-08-19; BEZEL_SEAM_A kept for + * reference). */ + (void)BEZEL_SEAM_A; + } glDisable(GL_BLEND); p_glBindVertexArray(0); p_glUseProgram(0); @@ -5685,7 +5872,8 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (s_force_present_remaining <= 0 && + if (!psx_present_swap_paces() && + s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && @@ -5798,7 +5986,8 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (s_force_present_remaining <= 0 && + if (!psx_present_swap_paces() && + s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 4ef73575..0c29be74 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -312,6 +312,8 @@ static inline int64_t geom_slot(uint32_t packed) { return (int64_t)(y + GEOM_BIAS) * GEOM_AXIS + (x + GEOM_BIAS); } +extern "C" void gpu_pgxp_rederive_enable(void); + extern "C" void gte_geometry_correction_set(int enabled) { s_geom_enabled = enabled ? 1 : 0; s_geom_hits = 0; @@ -324,6 +326,7 @@ extern "C" void gte_geometry_correction_set(int enabled) { if (!s_geom_cache) s_geom_enabled = 0; /* fail closed: stay faithful */ } gte_geom_generation_advance(); + gpu_pgxp_rederive_enable(); } /* Lookup census for the debug server: attempted, hit, and the two miss classes. @@ -888,9 +891,17 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) int64_t sx = sx16 >> 16; int64_t sy = sy16 >> 16; gte->push_sxy(sx, sy); - if (!s_gte_replay_sandbox) - pgxp_gte_push_sxy((int32_t)sx16, (int32_t)sy16, gte->SZ[3], + if (!s_gte_replay_sandbox) { + /* Shadow the pre-truncation position, clamped to +/-4096 px so an + * extreme projection blowup can't wrap the 16.16 int32 and land a + * garbage shadow back inside the frame. Well past the +/-1024 + * hardware rails either way, so direction survives for clamp rescue. */ + const int64_t kLim = (int64_t)4096 << 16; + int64_t cx16 = sx16 < -kLim ? -kLim : (sx16 > kLim - 1 ? kLim - 1 : sx16); + int64_t cy16 = sy16 < -kLim ? -kLim : (sy16 > kLim - 1 ? kLim - 1 : sy16); + pgxp_gte_push_sxy((int32_t)cx16, (int32_t)cy16, gte->SZ[3], (uint32_t)gte->SXY[2]); + } geom_note((uint32_t)gte->SXY[2], sx16, sy16); // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS @@ -921,6 +932,19 @@ void gte_rtpt(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- // NCLIP (0x06) — Normal Clipping (2D cross product for backface culling) // --------------------------------------------------------------------------- +/* Exact-sign coverage counters (free-running; debug server geom_correction + * reply). A fallback means the integer sign decided — the regime where the + * widescreen X-squash rounding can flip a thin face for a frame. */ +static uint64_t s_nclip_precise_hits = 0; +static uint64_t s_nclip_fallbacks = 0; +static uint64_t s_nclip_corrected = 0; +extern "C" void gte_nclip_precise_stats(uint64_t *hits, uint64_t *fallbacks, + uint64_t *corrected) { + if (hits) *hits = s_nclip_precise_hits; + if (fallbacks) *fallbacks = s_nclip_fallbacks; + if (corrected) *corrected = s_nclip_corrected; +} + void gte_nclip(GTEState* gte, uint32_t instr) { gte->FLAG = 0; int32_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); @@ -929,23 +953,39 @@ void gte_nclip(GTEState* gte, uint32_t instr) { int32_t sy1 = static_cast(gte->SXY[1] >> 16); int32_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); int32_t sy2 = static_cast(gte->SXY[2] >> 16); - int32_t px0 = 0, py0 = 0, px1 = 0, py1 = 0, px2 = 0, py2 = 0; - if (gpu_ws_precise_nclip_enabled() && - pgxp_get_gte_sxy(0, &px0, &py0) && - pgxp_get_gte_sxy(1, &px1, &py1) && - pgxp_get_gte_sxy(2, &px2, &py2)) { - sx0 = px0 >> 16; - sy0 = py0 >> 16; - sx1 = px1 >> 16; - sy1 = py1 >> 16; - sx2 = px2 >> 16; - sy2 = py2 >> 16; - } int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + (int64_t)sx1 * (sy2 - sy0) + (int64_t)sx2 * (sy0 - sy1); gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0); + int32_t out = static_cast(mac0); + /* Widescreen precise NCLIP: the X-squash scales winding areas by a + * positive factor, but integer SXY rounding flips the sign of thin + * glancing faces (trackside signs vanishing / flickering only at wide + * aspects). When the full sub-pixel shadow of all three vertices is + * live, take the SIGN from the exact cross product; keep the hardware + * magnitude so NCLIP-magnitude consumers (LOD heuristics) are untouched. + * A sign disagreement replaces a near-zero hardware value, so the +/-1 + * substitute is honest about the face's true orientation. */ + int32_t px0, py0, px1, py1, px2, py2; + if (gpu_ws_precise_nclip_enabled() && + !s_gte_replay_sandbox && s_speculative_depth == 0 && + pgxp_get_gte_sxy_checked(0, gte->SXY[0], 1, &px0, &py0) && + pgxp_get_gte_sxy_checked(1, gte->SXY[1], 1, &px1, &py1) && + pgxp_get_gte_sxy_checked(2, gte->SXY[2], 1, &px2, &py2)) { + s_nclip_precise_hits++; + const double fx0 = px0 / 65536.0, fy0 = py0 / 65536.0; + const double fx1 = px1 / 65536.0, fy1 = py1 / 65536.0; + const double fx2 = px2 / 65536.0, fy2 = py2 / 65536.0; + const double cross = fx0 * (fy1 - fy2) + + fx1 * (fy2 - fy0) + + fx2 * (fy0 - fy1); + if (cross > 0.0 && out <= 0) { out = 1; s_nclip_corrected++; } + else if (cross < 0.0 && out >= 0) { out = -1; s_nclip_corrected++; } + } else if (gpu_ws_precise_nclip_enabled() && + !s_gte_replay_sandbox && s_speculative_depth == 0) { + s_nclip_fallbacks++; + } + gte->MAC0 = out; gte->set_error_flag(); } @@ -1543,11 +1583,19 @@ extern "C" int gte_replay_side_effects_begin(void) { if (s_gte_replay_sandbox) return 0; s_gte_replay_saved_caller_ra = s_gte_caller_ra; s_gte_replay_sandbox = 1; + /* Replay passes must be shadow-silent: the record pass already wrote the + * authoritative shadows, and a replay's memory hooks re-validating stale + * FIFO shadows against different vertices drop flags and overwrite live + * slots (arena-wide provenance kill => one-frame integer-sign NCLIP + * blinks under overlay diff mode). Suppression makes every psx_pgxp_* + * hook a no-op for the bracket, like the speculative validation passes. */ + pgxp_suppress_begin(); return 1; } extern "C" void gte_replay_side_effects_end(void) { using namespace PSXRecomp::GTE; if (!s_gte_replay_sandbox) return; + pgxp_suppress_end(); s_gte_caller_ra = s_gte_replay_saved_caller_ra; s_gte_replay_sandbox = 0; } diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index c26fcafa..6621510a 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -1509,6 +1509,8 @@ irq_deliver_eval: * disarms the RFE-flag escape). */ g_rfe_escape_pending = 0; { + extern uint32_t g_async_rfe_resume_pc; /* dirty_ram_interp.c */ + extern uint64_t g_async_rfe_set_count; uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc; /* Top-level flush_resume / savestate: resync cleared the latches and @@ -1552,6 +1554,15 @@ irq_deliver_eval: cpu->cop0[COP0_EPC] = real_pc; /* architectural: the real resume PC */ g_exception_real_epc = real_pc; g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; /* set at the actual RFE/SYSCALL return */ + /* Async-RFE latch (Tomba 2 frame-1997 fix): persist the most + * recent real interruption PC so a game-driven asynchronous + * ReturnFromException (sentinel RFE with in_exception==0, e.g. a + * card-ISR longjmp installed via HookEntryInt) resumes the guest + * here instead of resolving to pc=0 (abnormal top-level exit). + * The latch was documented but never assigned — the whole rescue + * in the traps/dirty sentinel gates was inert. */ + g_async_rfe_resume_pc = real_pc; + g_async_rfe_set_count++; } else { uint32_t sentinel = PSX_EXC_SENTINEL_PC; cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */ @@ -1882,14 +1893,30 @@ irq_deliver_eval: same_thread_resume = 1; { extern int psx_scheduler_top_level_resume_active(void); - if (psx_scheduler_top_level_resume_active()) { + /* pc=0 means "the interrupted native frame is still on the host + * stack below us — return and it continues". That contract also + * breaks whenever dispatch has already collapsed to TOP LEVEL + * (depth 0): statically-baked overlay functions cross into AOT + * text via CPS returns, so the interrupted chain may hold no + * host frames at all, and a published 0 reaches the main loop + * as a bogus GUEST_EXIT (WipEout 3 ntsc120full8 static bake: + * deterministic "execution completed, PC=0" ~10 s into boot, + * ra=0x800D7FC0 epc=0x8015FAEC). Same remedy as the scheduler + * case: prefer the compiled interrupt resume PC. */ + if (psx_scheduler_top_level_resume_active() || + g_psx_dispatch_depth <= 0) { uint32_t resume = s_compiled_interrupt_resume_pc; if (resume == 0u) resume = s_last_interrupt_check_pc; if (resume != 0u) cpu->pc = resume; /* else keep post-RFE cpu->pc — never publish 0 */ + psx_pc0_journal_note(PSX_PC0J_IRQ_RESCUE, cpu, resume, + g_exc_escape_reason); } else { + psx_pc0_journal_note(PSX_PC0J_IRQ_SAME_THREAD, cpu, + s_compiled_interrupt_resume_pc, + g_exc_escape_reason); cpu->pc = 0; /* continue the interrupted live chain */ } } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 5e771d43..1e9e3f49 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -135,6 +135,7 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #include #include #include +#include #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN @@ -144,6 +145,7 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #include #include #include +#include /* GetModuleInformation (self-profile sampler) */ #include #else #include @@ -1189,7 +1191,8 @@ static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; * newly-seen texture. */ static int g_hd_textures = 0; static int g_hd_texture_dump = 0; -static std::string g_hd_texture_dir; /* relocates the whole convention (dev knob) */ +static std::string g_hd_texture_dir; +static std::vector g_hd_texture_exclude; /* [video] hd_texture_exclude */ /* relocates the whole convention (dev knob) */ static std::string g_bezel_path; /* [video] bezel -- margin artwork */ static std::string g_hd_texture_pack; /* the active pack folder itself (manager) */ static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) @@ -1400,10 +1403,27 @@ extern "C" int psx_mod_set_native_vblank_rate( /* * Above the physical panel rate (and in uncapped mode), swap-interval * blocking would silently replace the requested guest cadence with the - * display cadence. The frame pacer owns fixed-rate timing here. - */ - if (frames_per_second == 0 || frames_per_second > 60) + * display cadence. The frame pacer owns fixed-rate timing here — UNLESS + * the panel actually runs at the requested rate: then driver vsync is + * the better cadence owner (it absorbs sub-frame jitter that the wall + * pacer converts into debt/catch-up oscillation — the 99.7-122.5 fps + * seesaw measured on the 120 Hz mod with a 120.0 Hz panel). The + * present-cadence policy (present_vsync_owns_cadence) already contains + * the matching band check and keeps the pacer/vsync XOR intact; only + * force vsync off when the panel genuinely cannot match. */ + if (frames_per_second == 0) g_video_vsync = 0; + /* fps > 60 no longer forces vsync off: the present-cadence policy is + * already an XOR — present_effective_swap_interval() returns the + * configured vsync ONLY when present_vsync_owns_cadence() (which + * requires the panel to match the CRTC rate within 2%), and 0 + * otherwise, with the wall pacer engaged exactly when vsync does not + * own. On a panel that matches the modded rate (120 Hz mod on a + * 120.0 Hz display) driver vsync then owns cadence and absorbs the + * sub-frame jitter the wall pacer converts into debt/catch-up + * oscillation (measured: 99.7-122.5 fps seesaw). On a non-matching + * panel the swap interval resolves to 0 and the pacer owns, same as + * the old forced-off behavior. */ if (frames_per_second) { std::fprintf(stdout, "psxrecomp: mod selected native guest VBlank pacing at %u FPS " @@ -1719,6 +1739,9 @@ static void netplay_local_viewport_projection_aspect( static int g_ws_projection_num = 4; static int g_ws_projection_den = 3; static int g_ws_projection_mode = -1; +extern "C" int psx_ws_scene_wide_active(void); /* gpu.c: ws scene gate */ +extern "C" uint64_t psx_gpu_display_flip_count(void); /* gpu.c: internal fps */ + static void refresh_widescreen_projection() { if (!g_ws_engaged) return; @@ -2892,6 +2915,16 @@ static int present_should_wall_pace(void) { return g_frame_period_ms > 0.0 && !present_vsync_owns_cadence(); } +/* Live query for the GL presenter: nonzero while the blocking Swap is the + * game's frame clock. While true, unchanged-frame present skips must be + * disabled — a skipped swap is an unpaced frame (the "fps readout off makes + * it stupid fast" bug). Evaluated at each skip decision so no cached copy + * can go stale across init order or cadence changes. */ +extern "C" int psx_present_swap_paces(void) { + return present_vsync_owns_cadence() && + present_effective_swap_interval() != 0; +} + static void apply_present_cadence(void) { #ifndef PSX_SDL_NO_RENDER const int interval = present_effective_swap_interval(); @@ -5868,6 +5901,14 @@ static void load_transition_note(int read_active, int load_active, e->read_active = (uint8_t)(read_active != 0); e->load_active = (uint8_t)(load_active != 0); e->turbo_active = (uint8_t)(turbo_active != 0); + /* Pacing-state audit trail: any unpaced/present-skip mode engaging or + * releasing mid-play is exactly the class of bug users report as "the + * speed is busted", and the ring above is only reachable through the + * debug server. One line per transition is cheap and self-documents. */ + if (turbo_active != prev_turbo) + std::fprintf(stdout, "[pace] turbo_loads %s (frame %u, host %ums)\n", + turbo_active ? "ENGAGED" : "released", + e->frame, e->host_ms); prev_read = read_active; prev_load = load_active; prev_turbo = turbo_active; @@ -6512,6 +6553,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const double seconds = (double)(now - s_fps_last_time) / (double)frequency; const double fps = (double)(s_frame_count - s_fps_last_frame) / seconds; const double speed = fps / psx_crtc_frame_hz(); + /* Internal rendered-frame rate (display-origin flips): the game + * actually finishing frames, as distinct from the vblank rate — + * the two diverge under load and users need both on screen. */ + static uint64_t s_fps_last_flips = 0; + const uint64_t flips_now = psx_gpu_display_flip_count(); + const double render_fps = + (double)(flips_now - s_fps_last_flips) / seconds; + s_fps_last_flips = flips_now; double display_fps = 0.0; if (g_frame_interpolation && g_gl_active) { display_fps = g_frame_interpolation_fps > 0 @@ -6522,11 +6571,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { char title[256]; if (display_fps > 0.0) { snprintf(title, sizeof(title), - "%s [Game %.0f fps %.2fx | Display %.0f fps]", - s_fps_base_title.c_str(), fps, speed, display_fps); + "%s [VBlank %.0f/s %.2fx | Render %.0f fps | Display %.0f fps]", + s_fps_base_title.c_str(), fps, speed, render_fps, + display_fps); } else { - snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", - s_fps_base_title.c_str(), fps, speed); + snprintf(title, sizeof(title), + "%s [VBlank %.0f/s %.2fx | Render %.0f fps]", + s_fps_base_title.c_str(), fps, speed, render_fps); } SDL_SetWindowTitle(sdl_window, title); } @@ -6534,11 +6585,12 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { char osd[96]; if (display_fps > 0.0) { snprintf(osd, sizeof(osd), - "Game %.0f FPS %.2fx | Display %.0f FPS", - fps, speed, display_fps); + "VBl %.0f/s %.2fx Render %.0f FPS Disp %.0f", + fps, speed, render_fps, display_fps); } else { - snprintf(osd, sizeof(osd), "Game %.0f FPS %.2fx", - fps, speed); + snprintf(osd, sizeof(osd), + "VBl %.0f/s %.2fx Render %.0f FPS", + fps, speed, render_fps); } host_osd_set_status(osd); } @@ -6561,10 +6613,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * than replay%. Pass: post-settle present_gap_p95 ≤ 33ms. */ netplay_present_gap_stats(&gap_p95, &gap_max); std::fprintf(stderr, - "[FPS] game: %.1f fps (%.2fx) | frames: %llu | " + "[FPS] game: %.1f fps (%.2fx) render: %.1f | frames: %llu | " "guest=%.2f ms/f admit=%.2f ms/f replay=%.0f%% " "present_gap_p95=%u ms max=%u ms (n=%llu)\n", - fps, speed, (unsigned long long)s_frame_count, + fps, speed, render_fps, (unsigned long long)s_frame_count, guest_ms, admit_ms, replay_pct, (unsigned)gap_p95, (unsigned)gap_max, (unsigned long long)s_np_timing_frames); @@ -6572,8 +6624,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { s_np_guest_ticks = 0; s_np_timing_frames = 0; } else { - std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu\n", - fps, speed, (unsigned long long)s_frame_count); + std::fprintf(stderr, + "[FPS] game: %.1f fps (%.2fx) render: %.1f | frames: %llu\n", + fps, speed, render_fps, + (unsigned long long)s_frame_count); } std::fflush(stderr); @@ -6868,6 +6922,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { load_transition_note(cdrom_data_read_active(), logical_load_active, turbo_loads_active, load_run_value); + + /* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC * video together. Detect "MDEC produced a frame since the last vblank AND XA * is streaming"; a short hold rides out brief inter-frame gaps. The present/ @@ -6973,6 +7029,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { else snprintf(msg, sizeof(msg), "Fast forward: %dx", mult); host_osd_push(msg, 900); + std::fprintf(stdout, + "[pace] manual fast-forward ENGAGED (%s, host %ums)\n", + msg, (unsigned)SDL_GetTicks()); /* Release the vsync ceiling for the duration of the hold. */ g_turbo_vsync_forced_off = 1; apply_present_cadence(); @@ -6997,6 +7056,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { /* Restore the configured present cadence on release. */ g_turbo_vsync_forced_off = 0; apply_present_cadence(); + std::fprintf(stdout, + "[pace] manual fast-forward released (host %ums)\n", + (unsigned)SDL_GetTicks()); } turbo_was_down = 0; } @@ -12596,6 +12658,59 @@ static void netplay_resolve_link_mode(PsxNetplayConfig& cfg) { if (occ != 0xFu) cfg.rollback = 0; } +/* PSX_SELF_PROFILE=: 1 kHz instruction-pointer sampler over the main + * (emu) thread, histogrammed by exe-relative offset and dumped to + * psx_self_profile.txt at the end of the window. Play-build-safe hot-spot + * attribution without WPA/ETL symbolization: feed the offsets (plus the PE + * link base 0x140000000) to llvm-addr2line -e . Env-gated diagnostic — + * zero cost when unset. */ +#ifdef _WIN32 +struct SelfProfArgs { HANDLE thread; int seconds; }; +static DWORD WINAPI self_profile_thread(LPVOID param) { + SelfProfArgs *a = (SelfProfArgs *)param; + const int n = a->seconds * 1000; + static uintptr_t rips[600000]; + int count = 0; + HMODULE exe = GetModuleHandleA(NULL); + MODULEINFO mi; memset(&mi, 0, sizeof(mi)); + GetModuleInformation(GetCurrentProcess(), exe, &mi, sizeof(mi)); + uintptr_t base = (uintptr_t)mi.lpBaseOfDll; + uintptr_t size = (uintptr_t)mi.SizeOfImage; + for (int i = 0; i < n && count < 600000; i++) { + Sleep(1); + if (SuspendThread(a->thread) == (DWORD)-1) continue; + CONTEXT ctx; memset(&ctx, 0, sizeof(ctx)); + ctx.ContextFlags = CONTEXT_CONTROL; + if (GetThreadContext(a->thread, &ctx)) rips[count++] = (uintptr_t)ctx.Rip; + ResumeThread(a->thread); + } + /* Bucket by 64-byte line inside the exe; everything else = external. */ + std::map hist; + int external = 0; + for (int i = 0; i < count; i++) { + if (rips[i] >= base && rips[i] < base + size) + hist[(rips[i] - base) & ~63ull]++; + else external++; + } + std::vector> top; + for (auto &kv : hist) top.push_back({kv.second, kv.first}); + std::sort(top.rbegin(), top.rend()); + FILE *f = fopen("psx_self_profile.txt", "wb"); + if (f) { + fprintf(f, "samples=%d external=%d (exe base offsets, 64B buckets; " + "addr2line VA = 0x140000000 + offset)\n", count, external); + for (size_t i = 0; i < top.size() && i < 120; i++) + fprintf(f, "%6d 0x%llx\n", top[i].first, + (unsigned long long)top[i].second); + fclose(f); + } + fprintf(stdout, "psxrecomp: [self-profile] %d samples (%d external) -> psx_self_profile.txt\n", + count, external); + fflush(stdout); + return 0; +} +#endif + int main(int argc, char** argv) { /* Force line-buffered output so messages appear even if killed. */ std::setvbuf(stdout, nullptr, _IOLBF, 0); @@ -12955,6 +13070,7 @@ int main(int argc, char** argv) { g_hd_textures = gc.runtime.video_hd_textures ? 1 : 0; g_hd_texture_dump = gc.runtime.video_hd_texture_dump ? 1 : 0; g_hd_texture_dir = gc.runtime.video_hd_texture_dir; + g_hd_texture_exclude = gc.runtime.video_hd_texture_exclude; g_bezel_path = gc.runtime.video_bezel; if (gc.runtime.runtime_cpu_overclock != 100u) { psx_set_cpu_overclock(gc.runtime.runtime_cpu_overclock); @@ -13231,6 +13347,7 @@ int main(int argc, char** argv) { if (e && e[0] && e[0] != '0') g_gl_fbo_present = 0; } game_entry_pc = gc.entry_pc; fast_boot = gc.runtime.fast_boot; + sio_set_ape_card_unstick(gc.runtime.ape_card_unstick ? 1 : 0); bios_hle = gc.runtime.bios_hle; bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro; /* Developer compatibility finding, applied before BIOS selection. @@ -13551,6 +13668,20 @@ int main(int argc, char** argv) { * PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|90+. */ if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0; if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e); +#ifdef _WIN32 + if (const char *e = std::getenv("PSX_SELF_PROFILE")) { + int secs = atoi(e); + if (secs > 0) { + static SelfProfArgs spa; + DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &spa.thread, + 0, FALSE, DUPLICATE_SAME_ACCESS); + spa.seconds = secs; + CreateThread(NULL, 0, self_profile_thread, &spa, 0, NULL); + std::fprintf(stdout, "psxrecomp: [self-profile] armed for %d s\n", secs); + } + } +#endif if (const char *e = std::getenv("PSX_WAYLAND_ALLOW_VSYNC")) g_wayland_allow_vsync = atoi(e) ? 1 : 0; if (const char *e = std::getenv("PSX_SMOOTH_60FPS")) @@ -14790,6 +14921,14 @@ int main(int argc, char** argv) { } tex_pack_init(resolved_disc.string().c_str(), g_hd_textures, g_hd_texture_dump, g_hd_texture_dir.c_str(), g_hd_texture_pack.c_str()); + /* Config-owned exclusions survive pack-directory regeneration (the pack + * dir is gitignored game content; losing its exclude.txt silently + * re-enabled font-atlas substitution - the doubled-glyph defect). */ + for (const std::string &hx : g_hd_texture_exclude) { + unsigned h = 0; + if (std::sscanf(hx.c_str(), "%x", &h) == 1) + tex_pack_add_excluded((uint32_t)h); + } /* Coverage report for the launcher's per-pack stats. atexit alongside the * other end-of-session flushes; a no-op when no pack is active. */ std::atexit(tex_pack_write_coverage); @@ -16315,6 +16454,20 @@ session_reboot: } std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc); + /* The PC=0 top-level exit is an abnormal-boot class (see interrupts.c: + * approximate exception-resume PCs severing the live native chain). The + * registers at exit name the culprit without needing a debug-tools build: + * ra = who returned/jumped here, epc = the last exception context. */ + { + extern uint32_t g_debug_current_func_addr, g_debug_last_store_pc; + std::fprintf(stdout, + "psxrecomp runtime: [exit regs] ra=0x%08X sp=0x%08X " + "epc=0x%08X sr=0x%08X func=0x%08X last_store_pc=0x%08X " + "v0=0x%08X a0=0x%08X t9=0x%08X\n", + cpu.gpr[31], cpu.gpr[29], cpu.cop0[14], cpu.cop0[12], + g_debug_current_func_addr, g_debug_last_store_pc, + cpu.gpr[2], cpu.gpr[4], cpu.gpr[25]); + } { extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run; extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; @@ -16323,6 +16476,29 @@ session_reboot: (unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want); } + { extern int g_pczero_latched; extern uint32_t g_pczero_addr, g_pczero_ra, g_pczero_in_exc; + extern uint64_t g_pczero_count; + std::fprintf(stdout, "psxrecomp runtime: [pczero tripwire] latched=%u count=%llu addr=0x%08X ra=0x%08X in_exc=%u\n", + g_pczero_latched, (unsigned long long)g_pczero_count, + g_pczero_addr, g_pczero_ra, g_pczero_in_exc); } + /* pc=0 escape journal tail: names the exact runtime site that published + * (or rescued) each recent null PC, with frame/depth/ra/epc context — + * the play-build replacement for the debug-only fntrace ring, which is + * compiled out here (the exit trace JSON was blind: fntrace_seq=0). */ + { + uint64_t n = g_pc0j_seq < PSX_PC0J_CAP ? g_pc0j_seq : PSX_PC0J_CAP; + std::fprintf(stdout, "psxrecomp runtime: [pc0 journal] %llu total, last %llu:\n", + (unsigned long long)g_pc0j_seq, (unsigned long long)n); + for (uint64_t i = 0; i < n; i++) { + const Pc0JournalEntry *e = + &g_pc0j_ring[(g_pc0j_seq - n + i) % PSX_PC0J_CAP]; + std::fprintf(stdout, + " seq=%llu site=%u frame=%u depth=%d a=0x%08X " + "ra=0x%08X epc=0x%08X d=0x%08X\n", + (unsigned long long)e->seq, e->site, e->frame, + e->depth, e->a, e->b, e->c, e->d); + } + } shutdown_runtime(); if (g_gl_active) gl_renderer_shutdown(); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 97abf2bc..76ea1766 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -658,6 +658,28 @@ static uint32_t g_text_exact_last_ref = 0; * PSX_TEXT_GUARD_MEMO=0 disables the memo (bisect switch: if a stale-native * class ever appears, this proves or clears the memo in one run). */ static uint32_t g_text_guard_gen = 1u; +/* Page-granular guard generations: bumped only for the PAGE a diverging + * guarded write touched. The native-ok memo keys on the SUM over a function's + * own range pages (plus the global gen for image-registration/bless events), + * so CD streaming into unrelated text pages — hundreds of transitions per + * frame during races — no longer invalidates every memoized verdict in the + * game (measured: psx_game_text_native_ok at 6.8% of the emu thread with the + * memo never hitting). A diverging write to a covered page still invalidates + * exactly the functions on it. */ +static uint32_t text_guard_page_gen[DIRTY_RAM_PAGE_COUNT]; +static inline uint32_t text_guard_ranges_gen(const uint32_t *lo_len_pairs, + uint32_t count) { + uint32_t sum = g_text_guard_gen; + for (uint32_t i = 0; i < count; i++) { + uint32_t phys = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + uint32_t len = lo_len_pairs[i * 2u + 1u]; + if (len == 0 || phys >= RAM_SIZE || len > RAM_SIZE - phys) continue; + uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; + uint32_t lp = (phys + len - 1u) >> DIRTY_RAM_PAGE_SHIFT; + for (uint32_t pg = fp; pg <= lp; pg++) sum += text_guard_page_gen[pg]; + } + return sum; +} #define TEXT_OK_MEMO_SLOTS 8192u /* power of two */ typedef struct { @@ -716,11 +738,12 @@ 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)); - /* Live text now diverges here: any memoized "native ok" covering this - * address must be re-decided. A store that MATCHES the reference is - * deliberately not invalidated — it can only flip a verdict no->yes, - * and keeping the stale "no" costs interpretation, not correctness. */ - g_text_guard_gen++; + /* Live text now diverges here: any memoized "native ok" covering THIS + * PAGE must be re-decided (page-granular; see text_guard_page_gen). + * A store that MATCHES the reference is deliberately not invalidated — + * it can only flip a verdict no->yes, and keeping the stale "no" costs + * interpretation, not correctness. */ + text_guard_page_gen[page]++; } } @@ -780,7 +803,8 @@ int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, if (text_ok_memo_enabled()) { memo = &s_text_ok_memo[text_ok_memo_slot(lo_len_pairs, exec_pc)]; if (memo->key == lo_len_pairs && memo->exec_pc == exec_pc && - memo->count == count && memo->gen == g_text_guard_gen) + memo->count == count && + memo->gen == text_guard_ranges_gen(lo_len_pairs, count)) return memo->ok; } @@ -829,7 +853,7 @@ done: memo->key = lo_len_pairs; memo->exec_pc = exec_pc; memo->count = count; - memo->gen = g_text_guard_gen; + memo->gen = text_guard_ranges_gen(lo_len_pairs, count); memo->ok = ok; } return ok; diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 77460c45..23493615 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -639,6 +639,57 @@ int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs, return matches; } +/* Memoized per-variant matcher — the static bake emits one private + * state[2] per variant and passes it here, replacing the shared 4096-slot + * cache above for baked identities. That cache serviced 80k+ variants with + * no eviction: the first ~4096 tuples dispatched owned it forever and every + * other variant paid a full-table probe plus a full code-CRC on EVERY + * dispatch (measured as the boot-order-dependent 0.83-1.02x fps seesaw). + * Here a repeat call is the gen-sum loop plus one compare; the CRC runs only + * on first sight or after a real write to a covered page. Invalidation + * semantics are identical (overlay_watch_pagegen_sum). + * state[0] = gen_sum at last verify; state[1] = bit0 valid, bit1 verdict. */ +int psx_overlay_static_code_matches_memo(const uint32_t *lo_len_pairs, + uint32_t count, + uint32_t expected_crc, + uint32_t state[2]) { + const uint8_t *ram = memory_get_ram_ptr(); + if (!ram || !lo_len_pairs || count == 0u || count > 4096u) { + s_static_match_crc_misses++; + return 0; + } + uint32_t gen_sum = 0; + for (uint32_t i = 0; i < count; i++) { + uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + uint32_t len = lo_len_pairs[i * 2u + 1u]; + if (len == 0u || lo >= 8u * 1024u * 1024u || + len > 8u * 1024u * 1024u - lo) { + s_static_match_crc_misses++; + return 0; + } + gen_sum += overlay_watch_pagegen_sum(lo, len); + } + if ((state[1] & 1u) && state[0] == gen_sum) { + s_static_match_gen_fastpath++; + return (int)((state[1] >> 1) & 1u); + } + uint32_t crc = 0xFFFFFFFFu; + for (uint32_t i = 0; i < count; i++) { + uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + uint32_t len = lo_len_pairs[i * 2u + 1u]; + crc = crc32_update(crc, ram + lo, len); + } + crc ^= 0xFFFFFFFFu; + { + int matches = (crc == expected_crc) ? 1 : 0; + s_static_match_rehashes++; + if (!matches) s_static_match_crc_misses++; + state[0] = gen_sum; + state[1] = 1u | (matches ? 2u : 0u); + return matches; + } +} + void overlay_loader_static_match_stats(uint64_t *rehashes, uint64_t *crc_misses, uint64_t *gen_fastpath) { diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index 8c6bc86d..9a634a89 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -81,8 +81,27 @@ static int s_deferred_invalidate = 0; /* Single hot-path gate for every hook. */ static int g_pgxp_active = 0; +/* ALU/MULDIV hooks are needed only by tier-2 CPU propagation (s_cpu_mode) and + * by the correction consumers' dataflow chains (SLL/SRL z-ride, OR-repack — + * texture/geometry correction). The exact-sign NCLIP chain is LOAD/STORE/COP2 + * only (swc2 -> RAM -> lw -> mtc2), so an NCLIP-only arm (corrections off, + * cpu_mode off) can skip the per-ALU-instruction call bodies entirely — + * measured at 3.6% of the emu thread on the WipEout 3 120 Hz profile. + * gpu_pgxp_rederive_enable maintains the flag. */ +static int g_pgxp_full_hooks = 0; +/* Inline gate read by the PGXP_ALU/PGXP_MULDIV macros in every generated TU + * (pgxp_hooks.h): skip even the hook CALL when nothing consumes ALU shadows. */ +extern "C" { int g_pgxp_alu_armed = 0; } +static void recompute_alu_armed(void) { + g_pgxp_alu_armed = (g_pgxp_active && (g_pgxp_full_hooks || s_cpu_mode)) ? 1 : 0; +} +extern "C" void pgxp_set_full_hooks(int on) { + g_pgxp_full_hooks = on ? 1 : 0; + recompute_alu_armed(); +} static inline void recompute_active(void) { g_pgxp_active = (s_enabled && s_suppress == 0) ? 1 : 0; + recompute_alu_armed(); } static PGXPStats s_stats; @@ -93,6 +112,7 @@ static PGXPStats s_stats; extern "C" void pgxp_invalidate_all(void) { if (s_suppress != 0) { s_deferred_invalidate = 1; return; } + s_stats.invalidations++; if (++s_gen == 0) { /* generation wrapped: physically clear so stale slots can't revive */ if (s_ram) std::memset(s_ram, 0, s_ram_words * sizeof(PGXPValue)); @@ -104,6 +124,7 @@ extern "C" void pgxp_invalidate_all(void) { } extern "C" void pgxp_set_enabled(int enabled) { + int resized = 0; if (enabled) { /* Cover the REAL guest RAM, not the stock 2 MB. With the 8 MB mod the * extension is real memory, and a 2 MB-wrapped shadow aliased every @@ -129,11 +150,17 @@ extern "C" void pgxp_set_enabled(int enabled) { std::free(s_ram); s_ram = fresh; s_ram_words = want; + resized = 1; } else if (!s_ram) { enabled = 0; /* fail closed: stay faithful */ } } } + /* Idempotence: a redundant re-derive (config apply, correction toggle, + * debug-server A/B) must not cost a generation bump - every bump kills + * all live shadows and blinks one frame of exact-sign NCLIP coverage. */ + if (s_enabled == (enabled ? 1 : 0) && !resized) + return; s_enabled = enabled ? 1 : 0; pgxp_invalidate_all(); recompute_active(); @@ -141,7 +168,10 @@ extern "C" void pgxp_set_enabled(int enabled) { extern "C" int pgxp_enabled(void) { return s_enabled; } -extern "C" void pgxp_set_cpu_mode(int enabled) { s_cpu_mode = enabled ? 1 : 0; } +extern "C" void pgxp_set_cpu_mode(int enabled) { + s_cpu_mode = enabled ? 1 : 0; + recompute_alu_armed(); +} extern "C" int pgxp_cpu_mode(void) { return s_cpu_mode; } extern "C" void pgxp_set_tolerance(float pixels) { s_tolerance = pixels; } @@ -462,6 +492,7 @@ extern "C" void psx_pgxp_alu(struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2) { (void)cpu; if (!g_pgxp_active) return; + if (!g_pgxp_full_hooks && !s_cpu_mode) return; uint32_t op = f_op(instr); uint32_t dst_reg; @@ -697,6 +728,8 @@ extern "C" void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, uint32_t s1, uint32_t s2) { (void)cpu; (void)instr; (void)s1; (void)s2; if (!g_pgxp_active) return; + if (!g_pgxp_full_hooks && !s_cpu_mode) return; /* see psx_pgxp_alu gate; + * a stale HI/LO/GPR shadow is dropped by pv_validate at any consumer */ /* Products/quotients of screen coordinates are not screen coordinates: * record the results as known-but-imprecise so MFHI/MFLO stay honest. */ pv_reset(&s_gpr[PGXP_REG_HI], hi); @@ -724,10 +757,23 @@ extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, } extern "C" int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16) { + return pgxp_get_gte_sxy_checked(index, 0u, 0, x16, y16); +} + +/* Validate-on-read variant: the engine's single safety invariant is that a + * shadow is believed only while its packed word matches the live guest word. + * gte_nclip was the one consumer skipping that check, so any staleness class + * (e.g. an SXYP MTC2 shifting the real FIFO while shadows 12/13 keep + * describing pre-shift words) could invent a silently WRONG winding sign + * instead of an honest integer fallback. check!=0 enforces the compare. */ +extern "C" int pgxp_get_gte_sxy_checked(uint32_t index, uint32_t expect, + int check, int32_t *x16, int32_t *y16) { if (index >= 4) return 0; const PGXPValue *pv = &s_gte[12 + index]; if (!pv_live(pv) || (pv->flags & PGXP_F_VXY) != PGXP_F_VXY) return 0; + if (check && pv->value != expect) + return 0; if (x16) *x16 = pv->x16; if (y16) *y16 = pv->y16; return 1; diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index 9719b617..86b17633 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -595,6 +595,16 @@ void psx_rewind_note_frame(void) else if (iv >= 2 && s_frame - s_last_capture_frame == iv - 1u && !s_zpend.active && !s_vram_prearmed) s_vram_prearmed = gl_renderer_vram_readback_begin(); + /* Degraded-mode fix: a capture DEFERRED past its interval (previous + * snapshot still compressing at capture time) used to retry with no + * prearm and fall into the synchronous full-pipeline VRAM readback + * (measured 8-17 ms on the emu thread — the 0.4x dip class during + * load-heavy windows, which delay the compression pump and make the + * degraded mode self-selecting exactly where frames are already long). + * Arm the async PBO whenever a capture is pending and no compression is + * in flight, so every capture consumes an async copy. */ + if (s_capture_due && !s_zpend.active && !s_vram_prearmed) + s_vram_prearmed = gl_renderer_vram_readback_begin(); } /* ---- Amortized snapshot compression -------------------------------------- diff --git a/runtime/src/psx_sdl_audio.cpp b/runtime/src/psx_sdl_audio.cpp index 03c64590..df2f818f 100644 --- a/runtime/src/psx_sdl_audio.cpp +++ b/runtime/src/psx_sdl_audio.cpp @@ -46,13 +46,18 @@ extern "C" SDL_AudioDeviceID psx_sdl_audio_open( } const SDL_AudioDeviceID device = SDL_GetAudioStreamDevice(s_audio_stream); - SDL_AudioSpec obtained = requested; - (void)SDL_GetAudioDeviceFormat(device, &obtained, nullptr); if (have) { + /* Report the STREAM'S INPUT spec, not the device format: SDL3 + * converts whatever we feed FROM `requested` TO the device rate + * internally. Reporting the device rate here made the DRC render + * at (typically) 48 kHz intent while SDL re-resampled it as + * 44.1 kHz — a constant ~0.92x pitch-down on every SDL3 build plus + * a perpetual ring overfill drained by overflow_drops clicks. The + * DRC is the only resampler; SDL3 must see honest 44.1 kHz input. */ std::memset(have, 0, sizeof(*have)); - have->freq = obtained.freq; - have->format = obtained.format; - have->channels = obtained.channels; + have->freq = requested.freq; + have->format = requested.format; + have->channels = requested.channels; have->samples = want->samples; } diff --git a/runtime/src/sio.c b/runtime/src/sio.c index f5f6676b..4967923c 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -425,7 +425,13 @@ static int sio_txn_open = 0; static void sio_arm_card_ct_defer_guard(void) { extern uint64_t psx_get_cycle_count(void); uint64_t now = psx_get_cycle_count(); - uint64_t until = now + (uint64_t)gpu_vblank_period_cycles() * 2ull; + /* The libcard A6C10/B4E38 handshake this covers is CYCLE-fixed, so the + * window must be stock-period relative: at CRTC x2 the raw period halves + * and "~2 VBlank periods" would silently mean half the intended cover + * (audited 2026-08-19, finding #6). */ + extern uint32_t gpu_get_crtc_refresh_multiplier(void); + uint64_t until = now + (uint64_t)gpu_vblank_period_cycles() * + (uint64_t)gpu_get_crtc_refresh_multiplier() * 2ull; if (until > s_card_ct_defer_until_cyc) s_card_ct_defer_until_cyc = until; } @@ -495,15 +501,24 @@ static int s_ape_unstick_pending = 0; static uint64_t s_ape_unstick_cool_cyc = 0; static int s_ape_torn_pulses = 0; +/* Config default ([runtime] ape_card_unstick), set at boot. OFF unless the + * game opts in: this pump force-re-edges I_MASK.7/IRQ7, and a misfired arm + * (WipEout 3 under the x2 CRTC) storms the kernel card ISR so completion + * events never deliver and the memcard screen hangs forever. */ +static int s_ape_unstick_cfg = 0; +void sio_set_ape_card_unstick(int on) { s_ape_unstick_cfg = on ? 1 : 0; } + static int ape_unstick_enabled(void) { if (s_ape_unstick_env < 0) { const char *e = getenv("PSX_APE_CARD_UNSTICK"); - if (e && e[0] == '0') - s_ape_unstick_env = 0; + if (e && e[0]) + s_ape_unstick_env = (e[0] == '0') ? 0 : 1; else - s_ape_unstick_env = 1; + s_ape_unstick_env = -2; /* no env: fall through to config */ } - return s_ape_unstick_env; + if (s_ape_unstick_env >= 0) + return s_ape_unstick_env; + return s_ape_unstick_cfg; } /* Defined later; sio_tick calls the pump. */ diff --git a/runtime/src/tex_pack.cpp b/runtime/src/tex_pack.cpp index 571a73b8..53e3be2f 100644 --- a/runtime/src/tex_pack.cpp +++ b/runtime/src/tex_pack.cpp @@ -371,6 +371,12 @@ void dump_upload(const Upload &up, int depth, int clut_x, int clut_y, uint32_t p /* ---- public API -------------------------------------------------------- */ +/* Config-owned exclusions ([video] hd_texture_exclude): merged with the pack + * dir's optional exclude.txt so exclusions survive pack regeneration. */ +extern "C" void tex_pack_add_excluded(uint32_t hash) { + g.excluded.push_back(hash); +} + extern "C" void tex_pack_init(const char *disc_path, int enable_replace, int enable_dump, const char *dir_override, const char *pack_dir) { @@ -809,14 +815,100 @@ State::Repl *repl_get_locked(uint64_t key, const Upload &up, int shift, return ins.pixels.empty() ? nullptr : &ins; } +static int tex_pack_lookup_replacement_uncached(const int lim[4], uint16_t clut_x, + uint16_t clut_y, uint16_t texpage, + TexPackRepl *out); + +/* Per-prim lookup memo. The uncached path pays a CLUT hash plus a newest-first + * containment scan over every live upload (up to 8192) for EVERY textured + * primitive — measured at 9.3% of the emu thread during WipEout 3 races. The + * decision is fully determined by (uv-limits, clut, texpage) and the upload/ + * decode state, so key on those and an epoch that changes whenever any input + * can: uploads added (n_uploads — CLUT re-uploads included), uploads removed + * (n_kills), savestate revalidation (n_restore_kept), and repl-cache growth + * (repl.size() — a realloc moves the Repl objects the returned gl_handle/ + * pixels pointers alias, so any new decode must flush the memo). */ +struct TexLookupMemo { + int l0, l1, l2, l3; + uint16_t cx, cy, tp; + uint8_t valid; + int ret; + /* Snapshot of the mutation counters at store time. Kills and repl-cache + * growth invalidate hard (indices/pointers move). Upload ADDS are checked + * LAZILY on hit: only uploads appended after `uploads_mark` are scanned, + * and the entry survives unless one of them touches this prim's sampled + * rect or its CLUT row — races upload continuously, and a global epoch + * self-flushed the memo exactly when it was needed most. */ + uint64_t kills_mark, repl_mark, restore_mark; + size_t uploads_mark; + int rx, ry, rw, rh; /* sampled VRAM rect at store time */ + int clut_w; /* CLUT row width in halfwords (0 = 15bpp) */ + TexPackRepl out; +}; +static TexLookupMemo s_lookup_memo[8192]; + extern "C" int tex_pack_lookup_replacement(const int lim[4], uint16_t clut_x, uint16_t clut_y, uint16_t texpage, TexPackRepl *out) { if (!out) return 0; if (!tex_pack_active() || !lim) return 0; - std::lock_guard lk(g.mu); if (!g.replace_on || !g.repl_apply || g.known.empty()) return 0; + const uint32_t h = ((uint32_t)lim[0] * 73856093u) ^ + ((uint32_t)lim[1] * 19349663u) ^ + ((uint32_t)lim[2] * 83492791u) ^ + ((uint32_t)lim[3] * 2654435761u) ^ + ((uint32_t)texpage << 16) ^ + ((uint32_t)clut_y << 8) ^ (uint32_t)clut_x; + TexLookupMemo &m = s_lookup_memo[h & 8191u]; + if (m.valid && + m.l0 == lim[0] && m.l1 == lim[1] && m.l2 == lim[2] && m.l3 == lim[3] && + m.cx == clut_x && m.cy == clut_y && m.tp == texpage && + m.kills_mark == g.n_kills && m.restore_mark == g.n_restore_kept && + m.repl_mark == (uint64_t)g.repl.size() && + m.uploads_mark <= g.uploads.size()) { + int fresh_touches = 0; + for (size_t i = m.uploads_mark; i < g.uploads.size(); i++) { + const Upload &up = g.uploads[i]; + if (rects_overlap(up.x, up.y, up.w, up.h, m.rx, m.ry, m.rw, m.rh) || + (m.clut_w && + rects_overlap(up.x, up.y, up.w, up.h, + (int)m.cx, (int)m.cy, m.clut_w, 1))) { + fresh_touches = 1; + break; + } + } + if (!fresh_touches) { + m.uploads_mark = g.uploads.size(); /* re-anchor past clean adds */ + if (m.ret) *out = m.out; + return m.ret; + } + } + int ret = tex_pack_lookup_replacement_uncached(lim, clut_x, clut_y, + texpage, out); + { + int depth = (texpage >> 7) & 3; + if (depth > 2) depth = 2; + const int shift = (depth == 0) ? 2 : (depth == 1) ? 1 : 0; + const int base_x = (texpage & 0xF) * 64; + const int base_y = ((texpage >> 4) & 1) * 256; + sampled_vram_rect(lim, base_x, base_y, shift, &m.rx, &m.ry, &m.rw, &m.rh); + m.clut_w = (depth == 0) ? 16 : (depth == 1) ? 256 : 0; + } + m.l0 = lim[0]; m.l1 = lim[1]; m.l2 = lim[2]; m.l3 = lim[3]; + m.cx = clut_x; m.cy = clut_y; m.tp = texpage; + m.kills_mark = g.n_kills; m.restore_mark = g.n_restore_kept; + m.repl_mark = (uint64_t)g.repl.size(); + m.uploads_mark = g.uploads.size(); + m.ret = ret; + if (ret) m.out = *out; + m.valid = 1; + return ret; +} + +static int tex_pack_lookup_replacement_uncached(const int lim[4], uint16_t clut_x, + uint16_t clut_y, uint16_t texpage, + TexPackRepl *out) { int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2; @@ -991,6 +1083,14 @@ extern "C" void tex_pack_note_armed(unsigned long long id, extern "C" void tex_pack_on_textured_prim(const int lim[4], uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { if (!tex_pack_active() || !lim) return; +#ifdef PSX_NO_DEBUG_TOOLS + /* Play build: everything this hook computes — the matched-key set and the + * dump/census bookkeeping — is a debug-server / authoring surface with no + * reader (substitution itself lives in tex_pack_lookup_replacement). The + * per-prim mutex + CLUT hash + linear scan over up to 8192 uploads cost + * 7.4% of the emu thread on the WipEout 3 120 Hz profile. */ + if (!g.dump_on) return; +#endif int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2; diff --git a/runtime/src/text_xlate.cpp b/runtime/src/text_xlate.cpp index a4f72189..a153b59b 100644 --- a/runtime/src/text_xlate.cpp +++ b/runtime/src/text_xlate.cpp @@ -422,6 +422,7 @@ std::atomic g_msg_sep_pending{0}; // unpatched coun std::mutex g_mtx; std::atomic g_apply_armed{false}; // table non-empty AND language enabled +std::atomic g_string_table_nonempty{false}; // any STRING entries (not vram/glyph fixes) /* The capture inventory is an AUTHORING tool: it ingests every source string the * game draws so a translator can enumerate them. It is not needed to APPLY a * shipped translation (that is g_apply_armed, decided by the loaded tables). @@ -612,6 +613,8 @@ void load_tables_locked() { !g_vram_patches.empty() || !g_msg_inplace.empty() || !g_msg_seps.empty()), std::memory_order_relaxed); + g_string_table_nonempty.store(!lang_off && !g_table.empty(), + std::memory_order_relaxed); // Mark inventory records that now have a translation. for (auto& kv : g_inv) kv.second.translated = (g_table.find(kv.first) != g_table.end()); @@ -883,6 +886,15 @@ extern "C" void text_xlate_on_dispatch(CPUState* cpu, uint32_t target) { msg_seps_patch_locked(ram); } + /* The a0..a3 record scan below serves CAPTURE (inventory) and STRING + * substitution (g_table). A fixes-only load (vram patches / glyph labels / + * in-place messages — all handled by the throttled blocks above) has an + * EMPTY string table, and with capture off the scan can never do anything + * — yet it cost 4.2% of the emu thread on the WipEout 3 120 Hz profile + * (4x per dispatch: RAM-range check, first-byte load, record read). */ + if (!cap && !g_string_table_nonempty.load(std::memory_order_relaxed)) + return; + const uint32_t sp = cpu->gpr[29]; // Scan the argument registers a0..a3 for source-text pointers. KV-gated // apply means swapping only the reg that pointed at a KNOWN record — a diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 2ba29448..3bb7caa7 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -241,6 +241,34 @@ enum { }; uint32_t g_pc0_reason = PSX_PC0_GUEST_RETURN; +/* pc=0 escape journal — see cpu_state.h for the site enum and rationale. */ +Pc0JournalEntry g_pc0j_ring[PSX_PC0J_CAP]; +uint64_t g_pc0j_seq = 0; +void psx_pc0_journal_note(uint32_t site, CPUState *cpu, uint32_t a, uint32_t d) +{ + extern int g_psx_dispatch_depth; + extern uint64_t s_frame_count; /* present frame counter (debug_server.c) */ + Pc0JournalEntry *e = &g_pc0j_ring[g_pc0j_seq % PSX_PC0J_CAP]; + e->seq = g_pc0j_seq++; + e->site = site; + e->frame = (uint32_t)s_frame_count; + e->depth = g_psx_dispatch_depth; + e->a = a; + e->b = cpu->gpr[31]; + e->c = cpu->cop0[14]; + e->d = d; +} + +/* Called by the emitted trampoline when a top-level dispatch loop exits on + * pc==0 (the abnormal-run terminator): journals the LAST dispatched target, + * which no runtime site can otherwise see (the fatal 0 is published inside + * generated code — a null jr or a plain C-return). site 14; a=last target, + * d=$sp. */ +void psx_pc0_trampoline_exit(CPUState *cpu, uint32_t last_target) +{ + psx_pc0_journal_note(14u, cpu, last_target, cpu->gpr[29]); +} + static uint32_t psx_current_tcb_ptr(CPUState* cpu) { uint32_t tcbh = cpu->read_word(0x00000108u); @@ -640,6 +668,7 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb) if (current_tcb == target_tcb) { debug_server_log_thread_event(5, cpu, current_tcb, target_tcb, cpu->gpr[31]); g_pc0_reason = PSX_PC0_CHANGE_SELF; + psx_pc0_journal_note(PSX_PC0J_TRAP_CHANGE_SELF, cpu, target_tcb, 0); cpu->pc = 0; return 1; } @@ -710,6 +739,7 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb) debug_server_log_thread_event(9, cpu, target_tcb, current_tcb, 0); g_pc0_reason = PSX_PC0_FIBER_SWITCH; + psx_pc0_journal_note(PSX_PC0J_TRAP_FIBER_SWITCH, cpu, 0, 0); cpu->pc = 0; return 1; } @@ -1028,6 +1058,7 @@ int psx_syscall(CPUState* cpu, uint32_t code) { cpu->cop0[12] = sr & ~1u; /* clear IEc (bit 0) */ cpu->gpr[2] = sr & 1u; /* return old IEc */ g_pc0_reason = PSX_PC0_CRIT_SECTION; + psx_pc0_journal_note(PSX_PC0J_TRAP_CRIT_ENTER, cpu, sr, 0); cpu->pc = 0; /* Directly handled "void" syscall. Return 0: under CPS the caller * (`if (psx_syscall(...)) return;`) falls through to the inline @@ -1040,6 +1071,7 @@ int psx_syscall(CPUState* cpu, uint32_t code) { cpu->cop0[12] = sr | 0x0401u; /* set IEc (bit 0) + IM[2] (bit 10) */ cpu->gpr[2] = 0; g_pc0_reason = PSX_PC0_CRIT_SECTION; + psx_pc0_journal_note(PSX_PC0J_TRAP_CRIT_EXIT, cpu, sr, 0); cpu->pc = 0; return 0; @@ -1191,6 +1223,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * or uninitialized callback slot is dispatched. */ if (addr == 0) { g_pc0_reason = PSX_PC0_DISPATCH_MISS; /* guest jumped/returned to a null (0) target */ + psx_pc0_journal_note(PSX_PC0J_TRAP_NULL_TARGET, cpu, addr, 0); cpu->pc = 0; return; } @@ -1202,6 +1235,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * the exception handler, longjmp back to psx_check_interrupts to * properly unwind the handler call tree. */ if (addr == 0x80000048u && psx_get_in_exception()) { + psx_pc0_journal_note(PSX_PC0J_TRAP_SENTINEL, cpu, addr, 1); cpu->pc = 0; psx_exception_longjmp(); /* does not return */ } @@ -1221,6 +1255,8 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { g_sentinel_reach_async = g_async_rfe_resume_pc; if (g_async_rfe_resume_pc != 0u) { g_async_rfe_fire_count++; + psx_pc0_journal_note(PSX_PC0J_TRAP_ASYNC_RFE, cpu, + g_async_rfe_resume_pc, 0); cpu->pc = g_async_rfe_resume_pc; return; } @@ -1236,6 +1272,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * the psx_dispatch tail-call loop picking up $ra), it's a no-op — the * continuation was already handled by the merged function. */ if (phys == 0x00000E10u) { + psx_pc0_journal_note(PSX_PC0J_TRAP_E10_NOOP, cpu, addr, 0); cpu->pc = 0; return; } @@ -1507,6 +1544,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * ring-recorded above. Stale registers WILL corrupt the caller — * diagnostic use only. */ g_pc0_reason = PSX_PC0_DISPATCH_MISS; + psx_pc0_journal_note(PSX_PC0J_TRAP_STALE_MISS, cpu, addr, 0); cpu->pc = 0; } } diff --git a/runtime/tests/test_fps_telemetry_opt_in.py b/runtime/tests/test_fps_telemetry_opt_in.py index 3d302172..30af2a14 100644 --- a/runtime/tests/test_fps_telemetry_opt_in.py +++ b/runtime/tests/test_fps_telemetry_opt_in.py @@ -12,8 +12,8 @@ required = ( 'std::getenv("PSX_FPS_TELEMETRY")', "fps_telemetry_enabled() && !psx_netplay_in_load_barrier()", "sdl_window && s_fps_base_title.empty()", - "Game %.0f fps %.2fx | Display %.0f fps", - "Game %.0f FPS %.2fx | Display %.0f FPS", + "VBlank %.0f/s %.2fx | Render %.0f fps | Display %.0f fps", + "VBl %.0f/s %.2fx Render %.0f FPS Disp %.0f", ) for needle in required: if needle not in main_cpp: diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 8697f3bb..7299e8c5 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -2255,6 +2255,13 @@ def generate_overlay_dispatch(variants: list) -> str: 'extern int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs,', ' uint32_t count,', ' uint32_t expected_crc);', + '/* Memoized variant (overlay_loader.c): one private state[2] per', + ' * variant kills the shared match cache (4096 slots for 80k+ variants', + ' * = permanent eviction = full CRC per dispatch for the losers). */', + 'extern int psx_overlay_static_code_matches_memo(const uint32_t *lo_len_pairs,', + ' uint32_t count,', + ' uint32_t expected_crc,', + ' uint32_t state[2]);', 'static uint64_t psx_ov_static_checks = 0;', 'static uint64_t psx_ov_static_hits = 0;', 'static uint64_t psx_ov_static_variant_misses = 0;', @@ -2268,6 +2275,8 @@ def generate_overlay_dispatch(variants: list) -> str: lines.append( f'static const uint32_t {variant["range_symbol"]}[] = ' '{ ' + ', '.join(flat) + ' };') + lines.append( + f'static uint32_t {variant["range_symbol"]}_st[2];') lines += [ '', @@ -2290,9 +2299,9 @@ def generate_overlay_dispatch(variants: list) -> str: count = len(variant['ranges']) lines += [ ' psx_ov_static_checks++;', - f' if (psx_overlay_static_code_matches(' + f' if (psx_overlay_static_code_matches_memo(' f'{variant["range_symbol"]}, {count}u, ' - f'0x{variant["crc"]:08X}u)) {{', + f'0x{variant["crc"]:08X}u, {variant["range_symbol"]}_st)) {{', ' psx_ov_static_hits++;', f' {variant["symbol"]}(cpu);', ' return 1;', @@ -5262,6 +5271,19 @@ def main(): if _env_cap != args.captures: print(f'[cache] PSX_OVERLAY_CAPTURES overrides --captures: {_env_cap}') args.captures = _env_cap + # Flavor is pinned by the spawning runtime the same way as the cache + # locations: --flavor defaults to 0 (play), so an instrumented (flavor 2) + # runtime used to spawn compiles whose shards its loader then rejected — + # every overlay ran interpreted on debug builds, silently. + _env_flavor = os.environ.get('PSX_OVERLAY_FLAVOR') + if _env_flavor: + try: + _fl = int(_env_flavor, 0) + except ValueError: + _fl = None + if _fl is not None and _fl != args.flavor: + print(f'[cache] PSX_OVERLAY_FLAVOR overrides --flavor: {_fl}') + args.flavor = _fl if not args.captures: ap.error('no captures file: set PSX_OVERLAY_CAPTURES (runtime injects it) ' 'or pass --captures for manual/offline use') @@ -6585,6 +6607,10 @@ def main(): # interiors. Compile each as an isolated dispatch-root shard, then give # every block in those fragments the same universal resume treatment. unresolved = sorted(static_requested_entries - existing_entries) + # PSX_STATIC_NO_ISOLATED=1: A/B guard — skip the isolated-fragment pass + # entirely (its universal-resume treatment is the newest machinery). + if os.environ.get('PSX_STATIC_NO_ISOLATED'): + unresolved = [] fragment_built = 0 new_fragment_parts = [] for entry in unresolved: diff --git a/tools/coverage_vault.py b/tools/coverage_vault.py index d49c444e..079140cf 100644 --- a/tools/coverage_vault.py +++ b/tools/coverage_vault.py @@ -162,9 +162,12 @@ def _compact_region(region, drop_invalid_evidence=False): if len(image) != size: raise ValueError("capture size mismatch at 0x%08X: size=%d bytes=%d" % (load, size, len(image))) - phys_lo = load & 0x1FFFFF + # 8 MB dev-RAM aware: titles running the 8MB enhancement stream code into + # the extended banks (0x200000..0x7FFFFF); the old 2 MB mask corrupted + # their capture addresses and the bound rejected legitimate regions. + phys_lo = load & 0x7FFFFF phys_hi = phys_lo + size - if phys_hi > 2 * 1024 * 1024: + if phys_hi > 8 * 1024 * 1024: raise ValueError("capture exceeds PSX RAM at 0x%08X" % load) parsed = {} @@ -178,7 +181,7 @@ def _compact_region(region, drop_invalid_evidence=False): entries = [] for value in values: address = _address(value, field) - phys = address & 0x1FFFFF + phys = address & 0x7FFFFF if phys < phys_lo or phys >= phys_hi or (phys & 3): if not drop_invalid_evidence: raise ValueError( diff --git a/tools/psxasset/README.md b/tools/psxasset/README.md new file mode 100644 index 00000000..233082c2 --- /dev/null +++ b/tools/psxasset/README.md @@ -0,0 +1,88 @@ +# psxasset — game-agnostic PSX disc asset extraction + +Framework tool. Stdlib-only Python (3.11+), like everything else in `tools/`. +Decodes what it can, carves what it can't, and never needs to know which game +it is looking at unless a registry entry teaches it. + +``` +cd tools +python3 psxasset ls # list the ISO9660 filesystem +python3 psxasset extract [paths...] -o DIR # copy files off the disc +python3 psxasset unpack [--raw] # unpack a container (probe or --format) +python3 psxasset scan [--extract] # signature-scan, optionally decode hits +python3 psxasset tim2png [--all-palettes] # TIMs in any blob -> PNG +python3 psxasset vag2wav [--rate N] # VAG (headered/headerless/raw) -> WAV +python3 psxasset rip -o DIR # the whole pipeline + report.json +``` + +`rip` output layout: + +``` +DIR/ + files/ every non-skipped disc file, extracted + unpacked/ per-container entry blobs (NNN.bin stored, NNN.packed with --keep-packed) + decoded/ + textures/ ___bpp[_pN].png + audio/ __hz.wav + report.json +``` + +## What it understands + +- **ISO9660** over raw MODE2/2352 (Redump cue/bin), cooked 2048 images, and + 2448 dumps. Boot serial read from SYSTEM.CNF. +- **TIM** — 4/8/16/24bpp, CLUTs, multi-palette (`--all-palettes` exports each + row). Alpha tiers follow the pack-authoring convention: transparent 0, + STP 127, opaque 255. `formats/tim.py:parse` is strict enough to double as a + scanner validator. +- **VAG** — headered `VAGp`, the headerless 48-byte Psygnosis variant, and + raw SPU-ADPCM with `--rate`. Embedded build-machine filenames are kept in + output names (`crowd.aif22`, `rocket`, ...). +- **Containers** — `containers/` handlers with `probe()`/`unpack()`. + Currently `psyg_pb` (Psygnosis .PB/.PBP, WipEout 3 era). Compressed entries + whose codec is unknown are carved as `.packed` blobs rather than dropped. +- **Signatures** — validated TIM/VAG plus SEQ (`pQES`), VAB (`pBAV`), + PS-X EXE, and low-confidence TMD candidates (`tmd?`). + +## Per-game registry + +`games/.toml`, matched by boot serial. This is the piece that keeps +the engine general — the registry knows which game uses which container and +what to skip: + +```toml +[game] +serial = "SCES-02845" +name = "WipEout 3 Special Edition" + +[[container]] +glob = "WIPEOUT3/*.PBP" # ISO path, case-insensitive fnmatch +format = "psyg_pb" + +[[skip]] +glob = "*.STR" +reason = "FMV stream (MDEC)" +``` + +No registry entry is required: `rip` falls back to probing every file against +every container handler and signature-scanning whatever isn't a container. +First run against an unknown title is exactly how you discover what its +registry entry should say. + +## Measured results (2026-08-20) + +| Disc | Registry | Outcome | +|---|---|---| +| WipEout 3 SE (SCES-02845) | yes | 90 PB/PBP archives unpacked, 146 VAG samples + 4 EXE TIMs decoded, 3090 compressed entries carved | +| Klonoa (SLUS-00585) | none (probe) | 3424 TIMs decoded from FILE.BIN | + +## Known limits / next steps + +- WipEout 3's compressed PB entries use a bespoke Psygnosis codec (stock LZSS + exhaustively ruled out). The plan of record is to recover the game's own + decompressor through the recomp and call it from host code, not to reverse + the bitstream. +- No TMD/model decoding yet — `tmd?` scanner hits are the starting inventory. +- No XA/STR audio demux; `.STR` files are copied but not parsed. +- TIM decode is pure Python and dominates `rip` wall time on TIM-heavy discs + (Klonoa: ~4 min). Fine for a batch tool; revisit if it becomes interactive. diff --git a/tools/psxasset/__init__.py b/tools/psxasset/__init__.py new file mode 100644 index 00000000..665a9a44 --- /dev/null +++ b/tools/psxasset/__init__.py @@ -0,0 +1,13 @@ +"""psxasset — game-agnostic PSX disc asset extraction toolkit. + +Framework tool. Format decoders (TIM, VAG), an ISO9660 walker, a container +framework driven by a per-game registry (``games/*.toml``), and a signature +scanner for unknown blobs. Stdlib only, like every other tool in this repo. + +Run ``python3 tools/psxasset --help`` or ``python3 -m psxasset --help`` from +``tools/``. +""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/tools/psxasset/__main__.py b/tools/psxasset/__main__.py new file mode 100644 index 00000000..10b073cb --- /dev/null +++ b/tools/psxasset/__main__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +if __package__ in (None, ""): + # Invoked as ``python3 tools/psxasset`` — make the package importable. + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from psxasset.cli import main +else: + from .cli import main + +sys.exit(main()) diff --git a/tools/psxasset/cli.py b/tools/psxasset/cli.py new file mode 100644 index 00000000..fbedcb65 --- /dev/null +++ b/tools/psxasset/cli.py @@ -0,0 +1,338 @@ +"""psxasset command line. + + ls list the ISO9660 filesystem of a disc image + extract copy files out of a disc image + unpack unpack a container file (registry- or probe-detected) + scan signature-scan a file or directory, optionally carving hits + tim2png decode TIM(s) in a blob to PNG + vag2wav decode a VAG (headered, headerless, or raw+--rate) to WAV + rip the whole pipeline: extract -> unpack -> scan -> decode + report +""" + +from __future__ import annotations + +import argparse +import json +import sys +import wave +from pathlib import Path + +from . import containers, registry, scan as scanner +from .formats import tim, vag +from .iso import DiscImage, IsoFs +from .pngout import write_png + + +def _open_fs(disc_path: Path) -> IsoFs: + return IsoFs(DiscImage(disc_path)) + + +def _safe_name(iso_path: str) -> Path: + return Path(*[p for p in iso_path.strip("/").split("/") if p not in ("", "..")]) + + +def _write_wav(path: Path, rate: int, samples: list[int]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(vag.pcm_bytes(samples)) + + +def _decode_tims_in(data: bytes, out_dir: Path, stem: str, + all_palettes: bool = False, + findings: list[dict] | None = None) -> list[dict]: + written = [] + for f in (findings if findings is not None else scanner.scan(data)): + if f["type"] != "tim": + continue + img = tim.parse(data, f["offset"]) + variants = range(img.palette_count) if (all_palettes and img.palette_count > 1) else [0] + for pal in variants: + suffix = f"_p{pal}" if len(list(variants)) > 1 or pal else "" + name = f"{stem}_{f['offset']:06x}_{img.width}x{img.height}_{img.bpp}bpp{suffix}.png" + out = out_dir / name + out.parent.mkdir(parents=True, exist_ok=True) + write_png(out, img.width, img.height, tim.to_rgba(img, pal)) + written.append({**f, "png": str(out), "palette": pal}) + return written + + +def _decode_vags_in(data: bytes, out_dir: Path, stem: str, + findings: list[dict] | None = None) -> list[dict]: + written = [] + for f in (findings if findings is not None else scanner.scan(data)): + if f["type"] not in ("vag", "vag_headerless"): + continue + info = vag.probe(data, f["offset"]) + if info is None: + continue + samples = vag.decode(data[info.data_off:info.data_off + info.data_size]) + if not samples: + continue + label = info.name.replace("/", "_") if info.name else f"{f['offset']:06x}" + out = out_dir / f"{stem}_{label}_{info.rate}hz.wav" + _write_wav(out, info.rate, samples) + written.append({**f, "wav": str(out), "samples": len(samples)}) + return written + + +# --- subcommands ----------------------------------------------------------- + +def cmd_ls(args) -> int: + fs = _open_fs(args.disc) + serial = fs.serial() + print(f"volume={fs.volume_id} serial={serial or '?'}") + for e in fs.files(): + print(f" {e.size:>10} lba={e.lba:<8} {e.path}") + return 0 + + +def cmd_extract(args) -> int: + fs = _open_fs(args.disc) + wanted = [w.upper().strip("/") for w in args.paths] + out: Path = args.out + count = 0 + for e in fs.files(): + rel = e.path.strip("/").upper() + if wanted and not any(rel == w or rel.startswith(w + "/") for w in wanted): + continue + dest = out / _safe_name(e.path) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(fs.read(e)) + count += 1 + print(f" {e.path} -> {dest}") + print(f"{count} file(s) extracted") + return 0 if count else 1 + + +def _resolve_format(path: Path, data: bytes, explicit: str | None, + reg: registry.GameRegistry | None, iso_path: str | None) -> str | None: + if explicit: + return explicit + if reg and iso_path: + fmt = reg.container_format(iso_path) + if fmt: + return fmt + return containers.probe_all(data) + + +def cmd_unpack(args) -> int: + data = args.file.read_bytes() + fmt = _resolve_format(args.file, data, args.format, None, None) + if fmt is None: + print(f"no container handler recognizes {args.file}", file=sys.stderr) + return 1 + handler = containers.get_handler(fmt) + entries = handler.unpack(data) + out: Path = args.out or args.file.with_suffix(args.file.suffix + ".d") + out.mkdir(parents=True, exist_ok=True) + stored = 0 + for e in entries: + if e.stored: + (out / f"{e.index:03d}.bin").write_bytes(e.data) + stored += 1 + elif args.raw: + (out / f"{e.index:03d}.{fmt}.packed").write_bytes(e.packed_data) + print(f"{args.file.name}: {fmt}, {len(entries)} entries, " + f"{stored} stored -> {out}") + return 0 + + +def cmd_scan(args) -> int: + targets = ([p for p in args.target.rglob("*") if p.is_file()] + if args.target.is_dir() else [args.target]) + total = 0 + for path in sorted(targets): + data = path.read_bytes() + findings = scanner.scan(data) + if not findings: + continue + total += len(findings) + print(f"{path}:") + for f in findings: + extra = {k: v for k, v in f.items() if k not in ("type", "offset")} + print(f" +{f['offset']:#08x} {f['type']:<14} {extra or ''}") + if args.extract: + out = args.out or path.parent + _decode_tims_in(data, out, path.stem, all_palettes=args.all_palettes) + _decode_vags_in(data, out, path.stem) + print(f"{total} finding(s)") + return 0 + + +def cmd_tim2png(args) -> int: + data = args.file.read_bytes() + out = args.out or args.file.parent + written = _decode_tims_in(data, out, args.file.stem, all_palettes=args.all_palettes) + for w in written: + print(f" {w['png']}") + if not written: + print("no valid TIM found", file=sys.stderr) + return 1 + return 0 + + +def cmd_vag2wav(args) -> int: + data = args.file.read_bytes() + info = vag.probe(data) + if info: + samples = vag.decode(data[info.data_off:info.data_off + info.data_size]) + rate = args.rate or info.rate + elif args.rate: + samples = vag.decode(data) + rate = args.rate + else: + print("no VAG header; pass --rate for raw ADPCM", file=sys.stderr) + return 1 + out = args.out or args.file.with_suffix(".wav") + _write_wav(out, rate, samples) + print(f" {out} ({len(samples)} samples @ {rate} Hz)") + return 0 + + +def cmd_rip(args) -> int: + fs = _open_fs(args.disc) + serial = fs.serial() + reg = registry.find(serial, args.registry_dir) + out: Path = args.out + print(f"disc: volume={fs.volume_id} serial={serial or '?'} " + f"registry={'yes (' + reg.name + ')' if reg else 'none (probe mode)'}") + + report: dict = {"serial": serial, "volume": fs.volume_id, + "registry": str(reg.source) if reg else None, + "files": [], "decoded": {"tim": 0, "vag": 0}, + "packed_entries": 0} + + files_dir = out / "files" + unpacked_dir = out / "unpacked" + decoded_dir = out / "decoded" + + for e in fs.files(): + frec: dict = {"path": e.path, "size": e.size} + report["files"].append(frec) + skip = reg.skip_reason(e.path) if reg else None + if skip: + frec["skipped"] = skip + print(f" skip {e.path} ({skip})") + continue + data = fs.read(e) + dest = files_dir / _safe_name(e.path) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + + fmt = _resolve_format(dest, data, None, reg, e.path) + stem = "_".join(_safe_name(e.path).parts) + if fmt: + handler = containers.get_handler(fmt) + entries = handler.unpack(data) + frec["container"] = fmt + frec["entries"] = len(entries) + edir = unpacked_dir / _safe_name(e.path) + edir.mkdir(parents=True, exist_ok=True) + stored = packed = 0 + for ce in entries: + if ce.stored: + stored += 1 + blob_name = f"{ce.index:03d}.bin" + (edir / blob_name).write_bytes(ce.data) + found = scanner.scan(ce.data) + tims = _decode_tims_in(ce.data, decoded_dir / "textures", + f"{stem}_{ce.index:03d}", + all_palettes=args.all_palettes, + findings=found) + vags = _decode_vags_in(ce.data, decoded_dir / "audio", + f"{stem}_{ce.index:03d}", + findings=found) + report["decoded"]["tim"] += len(tims) + report["decoded"]["vag"] += len(vags) + else: + packed += 1 + if args.keep_packed: + (edir / f"{ce.index:03d}.packed").write_bytes(ce.packed_data) + report["packed_entries"] += packed + frec["stored"] = stored + frec["packed"] = packed + print(f" unpack {e.path}: {len(entries)} entries " + f"({stored} stored, {packed} packed)") + else: + found = scanner.scan(data) + tims = _decode_tims_in(data, decoded_dir / "textures", stem, + all_palettes=args.all_palettes, + findings=found) + vags = _decode_vags_in(data, decoded_dir / "audio", stem, + findings=found) + report["decoded"]["tim"] += len(tims) + report["decoded"]["vag"] += len(vags) + note = "" + if tims or vags: + note = f" [{len(tims)} tim, {len(vags)} vag]" + print(f" copy {e.path}{note}") + + out.mkdir(parents=True, exist_ok=True) + (out / "report.json").write_text(json.dumps(report, indent=2)) + print(f"\ndecoded: {report['decoded']['tim']} textures, " + f"{report['decoded']['vag']} audio samples; " + f"{report['packed_entries']} entries still packed (custom codec)") + print(f"report: {out / 'report.json'}") + return 0 + + +# --- argument wiring ------------------------------------------------------- + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(prog="psxasset", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("ls", help="list the disc filesystem") + p.add_argument("disc", type=Path) + p.set_defaults(fn=cmd_ls) + + p = sub.add_parser("extract", help="copy files out of a disc image") + p.add_argument("disc", type=Path) + p.add_argument("paths", nargs="*", help="ISO paths/prefixes (default: all)") + p.add_argument("-o", "--out", type=Path, default=Path("extracted")) + p.set_defaults(fn=cmd_extract) + + p = sub.add_parser("unpack", help="unpack a container file") + p.add_argument("file", type=Path) + p.add_argument("-o", "--out", type=Path) + p.add_argument("--format", help="container handler (default: probe)") + p.add_argument("--raw", action="store_true", + help="also write compressed entries as .packed blobs") + p.set_defaults(fn=cmd_unpack) + + p = sub.add_parser("scan", help="signature-scan a file or directory") + p.add_argument("target", type=Path) + p.add_argument("--extract", action="store_true", help="decode hits to PNG/WAV") + p.add_argument("-o", "--out", type=Path) + p.add_argument("--all-palettes", action="store_true") + p.set_defaults(fn=cmd_scan) + + p = sub.add_parser("tim2png", help="decode TIM(s) in a blob") + p.add_argument("file", type=Path) + p.add_argument("-o", "--out", type=Path) + p.add_argument("--all-palettes", action="store_true") + p.set_defaults(fn=cmd_tim2png) + + p = sub.add_parser("vag2wav", help="decode a VAG to WAV") + p.add_argument("file", type=Path) + p.add_argument("-o", "--out", type=Path) + p.add_argument("--rate", type=int, help="sample rate for headerless raw ADPCM") + p.set_defaults(fn=cmd_vag2wav) + + p = sub.add_parser("rip", help="full pipeline: extract, unpack, scan, decode") + p.add_argument("disc", type=Path) + p.add_argument("-o", "--out", type=Path, required=True) + p.add_argument("--registry-dir", type=Path, + help="extra directory of game registry TOMLs") + p.add_argument("--keep-packed", action="store_true", + help="carve compressed entries as .packed blobs") + p.add_argument("--all-palettes", action="store_true", + help="export every CLUT variant of paletted textures") + p.set_defaults(fn=cmd_rip) + + args = ap.parse_args(argv) + return args.fn(args) diff --git a/tools/psxasset/containers/__init__.py b/tools/psxasset/containers/__init__.py new file mode 100644 index 00000000..d089360b --- /dev/null +++ b/tools/psxasset/containers/__init__.py @@ -0,0 +1,37 @@ +"""Container format registry. + +A handler module exposes ``probe(data) -> bool`` and +``unpack(data) -> list[ContainerEntry]``. Entries whose codec is unknown come +back with ``data=None`` and their packed payload in ``packed_data`` so they +can still be carved to disk for later work. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ContainerEntry: + index: int + offset: int + unpacked_size: int + packed_size: int + stored: bool # True: data is the final payload + data: bytes | None # decoded payload, if available + packed_data: bytes | None = None # raw compressed payload otherwise + + +def get_handler(name: str): + if name == "psyg_pb": + from . import psyg_pb + return psyg_pb + raise KeyError(f"unknown container format: {name}") + + +def probe_all(data: bytes) -> str | None: + """Return the name of the first handler whose probe accepts ``data``.""" + for name in ("psyg_pb",): + if get_handler(name).probe(data): + return name + return None diff --git a/tools/psxasset/containers/psyg_pb.py b/tools/psxasset/containers/psyg_pb.py new file mode 100644 index 00000000..3abb9145 --- /dev/null +++ b/tools/psxasset/containers/psyg_pb.py @@ -0,0 +1,56 @@ +"""Psygnosis .PB / .PBP archive container (WipEout 3 era). + +Layout (verified against SCES-02845 — every entry chains exactly): + + 0x00 80 bytes zero / reserved + 0x50 u32 entry count + 0x54 u32 0 + 0x58 count x { u32 offset, u32 unpacked_size, u32 packed_size } + payload follows; entries with packed == unpacked are stored + uncompressed (36-54% of bytes per archive on WipEout 3) + +Compressed entries use a bespoke Psygnosis codec that is NOT a stock LZSS +(exhaustively ruled out); they are surfaced as packed blobs until the game's +own decompressor is recovered via the recomp (see docs/MOD_PACKAGES.md for +the plugin route). +""" + +from __future__ import annotations + +import struct + +from . import ContainerEntry + +_HDR = 0x50 +_TAB = 0x58 + + +def probe(data: bytes) -> bool: + if len(data) < _TAB + 12 or data[:_HDR] != b"\x00" * _HDR: + return False + count, zero = struct.unpack("<II", data[_HDR:_TAB]) + if zero != 0 or not 0 < count < 8192: + return False + end = _TAB + count * 12 + if end > len(data): + return False + prev_end = end + for i in range(count): + off, unp, pkd = struct.unpack("<3I", data[_TAB + i * 12:_TAB + i * 12 + 12]) + if off < prev_end - 8 or off + pkd > len(data) or pkd == 0 or pkd > unp: + return False + prev_end = off + pkd + return True + + +def unpack(data: bytes) -> list[ContainerEntry]: + count = struct.unpack("<I", data[_HDR:_HDR + 4])[0] + entries: list[ContainerEntry] = [] + for i in range(count): + off, unp, pkd = struct.unpack("<3I", data[_TAB + i * 12:_TAB + i * 12 + 12]) + blob = data[off:off + pkd] + if pkd == unp: + entries.append(ContainerEntry(i, off, unp, pkd, True, blob)) + else: + entries.append(ContainerEntry(i, off, unp, pkd, False, None, blob)) + return entries diff --git a/tools/psxasset/formats/__init__.py b/tools/psxasset/formats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/psxasset/formats/tim.py b/tools/psxasset/formats/tim.py new file mode 100644 index 00000000..3cb83811 --- /dev/null +++ b/tools/psxasset/formats/tim.py @@ -0,0 +1,154 @@ +"""TIM texture parser and RGBA conversion. + +Alpha tiers follow the pack-authoring convention used elsewhere in this repo +(``tools/textures/rescale_blocky.py``): fully transparent 0, semi-transparent +(STP bit) 127, opaque 255. Black-with-STP is the PSX "opaque black" case and +exports as opaque. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field + +MAGIC = b"\x10\x00\x00\x00" +_BPP = {0: 4, 1: 8, 2: 16, 3: 24} + + +class TimError(ValueError): + pass + + +@dataclass +class TimImage: + pmode: int # 0=4bpp 1=8bpp 2=16bpp 3=24bpp + vram_x: int + vram_y: int + width: int # in pixels, not VRAM halfwords + height: int + pixel_data: bytes # raw pixel block payload (no header) + clut_x: int = 0 + clut_y: int = 0 + palettes: list[bytes] = field(default_factory=list) # raw u16le rows + byte_size: int = 0 # total bytes consumed from the source blob + + @property + def bpp(self) -> int: + return _BPP[self.pmode] + + @property + def palette_count(self) -> int: + return len(self.palettes) + + +def _rgba(c: int) -> tuple[int, int, int, int]: + if c == 0: + return 0, 0, 0, 0 + r = (c & 31) << 3 + g = ((c >> 5) & 31) << 3 + b = ((c >> 10) & 31) << 3 + r |= r >> 5 + g |= g >> 5 + b |= b >> 5 + a = 127 if (c >> 15) and (c & 0x7FFF) else 255 + return r, g, b, a + + +def parse(data: bytes, offset: int = 0) -> TimImage: + """Parse one TIM at ``offset``. Raises :class:`TimError` on anything + structurally wrong, so it doubles as a validator for signature scanning.""" + d = data[offset:] + if len(d) < 20 or d[:4] != MAGIC: + raise TimError("bad magic") + flags = struct.unpack("<I", d[4:8])[0] + if flags & ~0x0B: + raise TimError(f"bad flags {flags:#x}") + pmode = flags & 3 + pos = 8 + + clut_x = clut_y = 0 + palettes: list[bytes] = [] + if flags & 8: + if pos + 12 > len(d): + raise TimError("truncated CLUT header") + blk_len, cx, cy, cw, ch = struct.unpack("<IHHHH", d[pos:pos + 12]) + if blk_len < 12 or pos + blk_len > len(d) or cw == 0 or ch == 0: + raise TimError("bad CLUT block") + if blk_len != 12 + cw * ch * 2: + raise TimError("CLUT size mismatch") + clut_x, clut_y = cx, cy + body = d[pos + 12:pos + blk_len] + row = cw * 2 + palettes = [body[i * row:(i + 1) * row] for i in range(ch)] + pos += blk_len + + if pos + 12 > len(d): + raise TimError("truncated pixel header") + blk_len, px, py, pw, ph = struct.unpack("<IHHHH", d[pos:pos + 12]) + if blk_len < 12 or pos + blk_len > len(d) or pw == 0 or ph == 0: + raise TimError("bad pixel block") + if blk_len != 12 + pw * ph * 2: + raise TimError("pixel size mismatch") + if pw > 1024 or ph > 512: + raise TimError("pixel rect exceeds VRAM") + body = d[pos + 12:pos + blk_len] + pos += blk_len + + if pmode == 0: + width = pw * 4 + elif pmode == 1: + width = pw * 2 + elif pmode == 2: + width = pw + else: + if (pw * 2) % 3: + raise TimError("24bpp width not divisible") + width = pw * 2 // 3 + + return TimImage(pmode=pmode, vram_x=px, vram_y=py, width=width, height=ph, + pixel_data=body, clut_x=clut_x, clut_y=clut_y, + palettes=palettes, byte_size=pos) + + +def to_rgba(tim: TimImage, palette: int = 0) -> bytes: + """Decode to an RGBA8 buffer of ``tim.width * tim.height`` pixels.""" + w, h = tim.width, tim.height + out = bytearray(w * h * 4) + src = tim.pixel_data + + if tim.pmode in (0, 1): + if not tim.palettes: + # CLUT-less paletted TIM: fall back to greyscale indices. + pal16 = [(i * 17,) * 3 + (255,) for i in range(16)] + pal256 = [(i,) * 3 + (255,) for i in range(256)] + pal = pal16 if tim.pmode == 0 else pal256 + else: + raw = tim.palettes[min(palette, len(tim.palettes) - 1)] + colors = struct.unpack(f"<{len(raw) // 2}H", raw) + pal = [_rgba(c) for c in colors] + need = 16 if tim.pmode == 0 else 256 + if len(pal) < need: + pal = list(pal) + [(0, 0, 0, 0)] * (need - len(pal)) + i = 0 + if tim.pmode == 0: + for byte in src: + for idx in (byte & 0xF, byte >> 4): + if i >= w * h: + break + out[i * 4:i * 4 + 4] = bytes(pal[idx]) + i += 1 + else: + for byte in src: + if i >= w * h: + break + out[i * 4:i * 4 + 4] = bytes(pal[byte]) + i += 1 + elif tim.pmode == 2: + colors = struct.unpack(f"<{w * h}H", src[:w * h * 2]) + for i, c in enumerate(colors): + out[i * 4:i * 4 + 4] = bytes(_rgba(c)) + else: # 24bpp + for i in range(w * h): + r, g, b = src[i * 3:i * 3 + 3] + out[i * 4:i * 4 + 4] = bytes((r, g, b, 255)) + return bytes(out) diff --git a/tools/psxasset/formats/vag.py b/tools/psxasset/formats/vag.py new file mode 100644 index 00000000..b7d33939 --- /dev/null +++ b/tools/psxasset/formats/vag.py @@ -0,0 +1,75 @@ +"""VAG / SPU-ADPCM decoder. + +Handles standard ``VAGp`` headers, the headerless 48-byte variant seen inside +Psygnosis archives (magic zeroed, big-endian size/rate fields still valid), +and raw block streams with a caller-supplied rate. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +HEADER = 48 +_FILTERS = ((0.0, 0.0), (60 / 64, 0.0), (115 / 64, -52 / 64), + (98 / 64, -55 / 64), (122 / 64, -60 / 64)) +PLAUSIBLE_RATES = frozenset( + {4000, 8000, 11025, 12000, 16000, 18900, 22050, 24000, 32000, 37800, 44100, 48000}) + + +@dataclass(frozen=True) +class VagInfo: + rate: int + data_off: int + data_size: int + name: str # embedded source filename, often survives from the build + headered: bool + + +def probe(data: bytes, offset: int = 0) -> VagInfo | None: + """Recognize a headered or headerless-but-structured VAG at ``offset``.""" + d = data[offset:offset + HEADER] + if len(d) < HEADER: + return None + magic = d[:4] + size, rate = struct.unpack(">II", d[12:20]) + name = d[32:48].split(b"\x00")[0].decode("latin1", errors="replace") + if magic == b"VAGp": + if size == 0 or size > len(data) - offset: + return None + return VagInfo(rate, offset + HEADER, size, name, True) + if magic == b"\x00\x00\x00\x00" and rate in PLAUSIBLE_RATES: + if 16 <= size <= len(data) - offset and size % 16 == 0: + return VagInfo(rate, offset + HEADER, size, name, False) + return None + + +def decode(data: bytes) -> list[int]: + """Decode a raw SPU-ADPCM block stream to signed 16-bit samples.""" + out: list[int] = [] + s1 = s2 = 0.0 + for i in range(0, len(data) - 15, 16): + blk = data[i:i + 16] + shift = blk[0] & 0xF + pred = min(blk[0] >> 4, 4) + flags = blk[1] + if flags == 7: # end marker block, not audio + break + f0, f1 = _FILTERS[pred] + for j in range(2, 16): + byte = blk[j] + for nib in (byte & 0xF, byte >> 4): + s = nib << 12 + if s & 0x8000: + s -= 0x10000 + s >>= shift + v = s + s1 * f0 + s2 * f1 + s2, s1 = s1, v + out.append(max(-32768, min(32767, int(round(v))))) + if flags & 1: # loop-end: sample stops here + break + return out + + +def pcm_bytes(samples: list[int]) -> bytes: + return struct.pack(f"<{len(samples)}h", *samples) diff --git a/tools/psxasset/games/wipeout3-sces02845.toml b/tools/psxasset/games/wipeout3-sces02845.toml new file mode 100644 index 00000000..73797d37 --- /dev/null +++ b/tools/psxasset/games/wipeout3-sces02845.toml @@ -0,0 +1,32 @@ +# WipEout 3 Special Edition (Europe) — registry entry. +# Container layout verified 2026-08-20; compressed entries use a bespoke +# Psygnosis codec (not stock LZSS — exhaustively ruled out). Stored entries +# (36-54% of bytes) carve directly; VAGs keep original build filenames. + +[game] +serial = "SCES-02845" +name = "WipEout 3 Special Edition" + +[[container]] +glob = "WIPEOUT3/*.PBP" +format = "psyg_pb" + +[[container]] +glob = "WIPEOUT3/*.PB" +format = "psyg_pb" + +[[container]] +glob = "WIPEOUTC/*.PBP" +format = "psyg_pb" + +[[container]] +glob = "WIPEOUTC/*.PB" +format = "psyg_pb" + +[[skip]] +glob = "*.STR" +reason = "FMV stream (MDEC)" + +[[skip]] +glob = "TRACK*.WAV" +reason = "RIFF CD audio, already playable" diff --git a/tools/psxasset/iso.py b/tools/psxasset/iso.py new file mode 100644 index 00000000..55fec3a4 --- /dev/null +++ b/tools/psxasset/iso.py @@ -0,0 +1,162 @@ +"""Cue/bin disc access and a minimal ISO9660 filesystem walker. + +Handles raw MODE2/2352 track images (Redump), cooked 2048-byte images, and +cue sheets (the first ``FILE`` entry is taken as the data track — audio +tracks never carry filesystem data). All the recomp game repos keep discs in +this shape already (see ``prepare_disc.py``). +""" + +from __future__ import annotations + +import re +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + +RAW_SEC = 2352 +USER = 2048 +USER_OFF = 24 # Mode2 Form1: sync(12) + header(4) + subheader(8) +SYNC = bytes([0x00] + [0xFF] * 10 + [0x00]) + + +class DiscError(RuntimeError): + pass + + +class DiscImage: + """Sector-level access to the data track of a disc image.""" + + def __init__(self, path: Path): + path = Path(path) + if path.suffix.lower() == ".cue": + bin_path = self._first_file_from_cue(path) + else: + bin_path = path + if not bin_path.is_file(): + raise DiscError(f"data track not found: {bin_path}") + self.path = bin_path + self._fh = open(bin_path, "rb") + self.sector_size, self.user_off = self._detect_layout() + self.sector_count = bin_path.stat().st_size // self.sector_size + + @staticmethod + def _first_file_from_cue(cue_path: Path) -> Path: + text = cue_path.read_text(errors="replace") + m = re.search(r'FILE\s+"([^"]+)"', text) or re.search(r"FILE\s+(\S+)", text) + if not m: + raise DiscError(f"no FILE entry in cue: {cue_path}") + return cue_path.parent / m.group(1) + + def _detect_layout(self) -> tuple[int, int]: + self._fh.seek(0) + head = self._fh.read(RAW_SEC) + if head[:12] == SYNC: + return RAW_SEC, USER_OFF + # Cooked image: PVD magic should appear at LBA 16 with 2048 sectors. + self._fh.seek(16 * USER) + if self._fh.read(6)[1:6] == b"CD001": + return USER, 0 + # Fall back to raw if the size fits; 2448 dumps get trimmed reads. + size = self.path.stat().st_size + if size % RAW_SEC == 0: + return RAW_SEC, USER_OFF + if size % 2448 == 0: + return 2448, USER_OFF + raise DiscError(f"unrecognized sector layout: {self.path}") + + def read_sector(self, lba: int) -> bytes: + """Return the 2048 user bytes of one sector.""" + self._fh.seek(lba * self.sector_size + self.user_off) + return self._fh.read(USER) + + def read_span(self, lba: int, size: int) -> bytes: + n = (size + USER - 1) // USER + out = bytearray() + for i in range(n): + out += self.read_sector(lba + i) + return bytes(out[:size]) + + def close(self) -> None: + self._fh.close() + + +@dataclass(frozen=True) +class IsoEntry: + path: str # "/WIPEOUT3/TRACKM01.PBP" — version suffix stripped + lba: int + size: int + is_dir: bool + + +class IsoFs: + """ISO9660 walker over a :class:`DiscImage`.""" + + def __init__(self, disc: DiscImage): + self.disc = disc + pvd = disc.read_sector(16) + if pvd[1:6] != b"CD001": + raise DiscError("no ISO9660 primary volume descriptor at LBA 16") + self.volume_id = pvd[40:72].decode("latin1").strip() + root = pvd[156:156 + 34] + self._root_lba = struct.unpack("<I", root[2:6])[0] + self._root_size = struct.unpack("<I", root[10:14])[0] + + def _read_dir(self, lba: int, size: int) -> Iterator[tuple[str, int, int, bool]]: + data = self.disc.read_span(lba, size) + off = 0 + while off < len(data): + rec_len = data[off] + if rec_len == 0: + # Records never span sectors; skip to the next one. + off = (off // USER + 1) * USER + continue + rec = data[off:off + rec_len] + elba = struct.unpack("<I", rec[2:6])[0] + esize = struct.unpack("<I", rec[10:14])[0] + flags = rec[25] + nlen = rec[32] + raw_name = rec[33:33 + nlen] + off += rec_len + if raw_name in (b"\x00", b"\x01"): + continue + name = raw_name.decode("latin1").split(";")[0] + yield name, elba, esize, bool(flags & 2) + + def walk(self) -> Iterator[IsoEntry]: + stack = [("", self._root_lba, self._root_size)] + seen = set() + while stack: + prefix, lba, size = stack.pop() + if lba in seen: + continue + seen.add(lba) + for name, elba, esize, is_dir in self._read_dir(lba, size): + path = f"{prefix}/{name}" + yield IsoEntry(path, elba, esize, is_dir) + if is_dir: + stack.append((path, elba, esize)) + + def files(self) -> list[IsoEntry]: + return sorted((e for e in self.walk() if not e.is_dir), key=lambda e: e.path) + + def find(self, path: str) -> IsoEntry | None: + want = "/" + path.strip("/").upper() + for entry in self.walk(): + if entry.path.upper() == want: + return entry + return None + + def read(self, entry: IsoEntry) -> bytes: + return self.disc.read_span(entry.lba, entry.size) + + def serial(self) -> str | None: + """Read the boot serial (e.g. ``SCES-02845``) from SYSTEM.CNF.""" + entry = self.find("SYSTEM.CNF") + if entry is None: + return None + text = self.read(entry).decode("latin1", errors="replace") + m = re.search(r"BOOT\s*=\s*cdrom:\\?([A-Z]{4})_(\d{3})\.(\d{2})", text) + if not m: + return None + return f"{m.group(1)}-{m.group(2)}{m.group(3)}" diff --git a/tools/psxasset/pngout.py b/tools/psxasset/pngout.py new file mode 100644 index 00000000..233c0997 --- /dev/null +++ b/tools/psxasset/pngout.py @@ -0,0 +1,28 @@ +"""Minimal RGBA8 PNG writer (stdlib zlib only).""" + +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + + +def _chunk(tag: bytes, payload: bytes) -> bytes: + return (struct.pack(">I", len(payload)) + tag + payload + + struct.pack(">I", zlib.crc32(tag + payload))) + + +def write_png(path: Path, width: int, height: int, rgba: bytes) -> None: + if len(rgba) != width * height * 4: + raise ValueError("rgba buffer does not match dimensions") + raw = bytearray() + stride = width * 4 + for y in range(height): + raw.append(0) # filter: none + raw += rgba[y * stride:(y + 1) * stride] + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + data = (b"\x89PNG\r\n\x1a\n" + + _chunk(b"IHDR", ihdr) + + _chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + _chunk(b"IEND", b"")) + Path(path).write_bytes(data) diff --git a/tools/psxasset/registry.py b/tools/psxasset/registry.py new file mode 100644 index 00000000..e40ebef3 --- /dev/null +++ b/tools/psxasset/registry.py @@ -0,0 +1,81 @@ +"""Per-game format registry. + +One TOML per title in ``games/``, keyed by boot serial. This is what keeps +psxasset general: the engine knows formats, the registry knows which game +uses which. Schema: + + [game] + serial = "SCES-02845" + name = "WipEout 3 Special Edition" + + [[container]] + glob = "WIPEOUT3/*.PBP" # against the ISO path, case-insensitive + format = "psyg_pb" + + [[skip]] + glob = "*.STR" # rip copies these but doesn't scan them + reason = "FMV stream" +""" + +from __future__ import annotations + +import fnmatch +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +GAMES_DIR = Path(__file__).resolve().parent / "games" + + +@dataclass +class GameRegistry: + serial: str + name: str + containers: list[tuple[str, str]] = field(default_factory=list) # (glob, format) + skips: list[tuple[str, str]] = field(default_factory=list) # (glob, reason) + source: Path | None = None + + def container_format(self, iso_path: str) -> str | None: + rel = iso_path.lstrip("/").upper() + for glob, fmt in self.containers: + if fnmatch.fnmatch(rel, glob.upper()): + return fmt + return None + + def skip_reason(self, iso_path: str) -> str | None: + rel = iso_path.lstrip("/").upper() + for glob, reason in self.skips: + if fnmatch.fnmatch(rel, glob.upper()): + return reason + return None + + +def _load(path: Path) -> GameRegistry: + doc = tomllib.loads(path.read_text()) + game = doc.get("game", {}) + reg = GameRegistry(serial=game.get("serial", ""), name=game.get("name", ""), + source=path) + for c in doc.get("container", []): + reg.containers.append((c["glob"], c["format"])) + for s in doc.get("skip", []): + reg.skips.append((s["glob"], s.get("reason", ""))) + return reg + + +def load_all(extra_dir: Path | None = None) -> list[GameRegistry]: + regs = [] + dirs = [GAMES_DIR] + ([extra_dir] if extra_dir else []) + for d in dirs: + if d and d.is_dir(): + for path in sorted(d.glob("*.toml")): + regs.append(_load(path)) + return regs + + +def find(serial: str | None, extra_dir: Path | None = None) -> GameRegistry | None: + if not serial: + return None + for reg in load_all(extra_dir): + if reg.serial.upper() == serial.upper(): + return reg + return None diff --git a/tools/psxasset/scan.py b/tools/psxasset/scan.py new file mode 100644 index 00000000..fd0d75a9 --- /dev/null +++ b/tools/psxasset/scan.py @@ -0,0 +1,81 @@ +"""Signature scanner for unknown blobs. + +Signature candidates are located with ``bytes.find`` (C speed — a naive +per-byte Python loop takes minutes on a 300 MB file), then structurally +validated, then overlap-filtered so a hit inside an already-claimed span is +dropped. Findings are plain dicts so they serialize straight into a report. +""" + +from __future__ import annotations + +import struct + +from .formats import tim, vag + + +def _find_all(data: bytes, needle: bytes, align: int = 1): + pos = data.find(needle) + while pos != -1: + if pos % align == 0: + yield pos + pos = data.find(needle, pos + 1) + + +def scan(data: bytes) -> list[dict]: + candidates: list[dict] = [] + + for pos in _find_all(data, tim.MAGIC): + try: + img = tim.parse(data, pos) + except tim.TimError: + continue + candidates.append({ + "type": "tim", "offset": pos, "size": img.byte_size, + "width": img.width, "height": img.height, "bpp": img.bpp, + "palettes": img.palette_count, + }) + + for pos in _find_all(data, b"VAGp"): + info = vag.probe(data, pos) + if info: + candidates.append({ + "type": "vag", "offset": pos, "size": 48 + info.data_size, + "rate": info.rate, "name": info.name, + }) + + for pos in _find_all(data, b"pQES"): + candidates.append({"type": "seq", "offset": pos, "size": 4}) + for pos in _find_all(data, b"pBAV"): + candidates.append({"type": "vab_header", "offset": pos, "size": 4}) + for pos in _find_all(data, b"PS-X EXE"): + candidates.append({"type": "psx_exe", "offset": pos, "size": 8}) + + # TMD candidate: version 0x41, flags 0/1, sane object count. Low + # confidence — flagged with a trailing '?' and given no claimed span. + for pos in _find_all(data, b"\x41\x00\x00\x00", align=4): + if pos + 12 > len(data): + continue + flags, nobj = struct.unpack("<II", data[pos + 4:pos + 12]) + if flags <= 1 and 0 < nobj <= 5000: + candidates.append({"type": "tmd?", "offset": pos, "size": 0, + "objects": nobj}) + + candidates.sort(key=lambda f: (f["offset"], -f.get("size", 0))) + findings: list[dict] = [] + claimed_end = 0 + for f in candidates: + if f["offset"] < claimed_end: + continue + findings.append(f) + claimed_end = max(claimed_end, f["offset"] + f.get("size", 0)) + + # Headerless VAG (Psygnosis variant) at blob start only — the heuristic + # is too loose to run at every offset. + if not findings: + info = vag.probe(data, 0) + if info: + findings.append({ + "type": "vag_headerless", "offset": 0, + "size": 48 + info.data_size, "rate": info.rate, + }) + return findings