Merge PR #156 (feat/ws-exact-nclip) into feat/rbengine

Widescreen precision: exact-sign NCLIP, GTE clamp rescue,
backend-invariant entry hooks — plus perf round 1, pacing fixes,
self-profiler, and IRQ-redirect recompiler fixes from the branch.

Conflict resolutions:
- runtime/src/gpu_gl_renderer.c (bezel_tiles_col): combine both bezel
  mark-sizing fixes — height-anchored size with PSX_BEZEL_SCALE
  (rbengine, fixes ultrawide growth) floored by the caller's
  window-proportioned mark_px (PR, fixes thin-margin shrink); an
  explicit env override wins in both directions.
- runtime/src/main.cpp: keep both additions — netplay_resolve_link_mode
  (rbengine) and the PSX_SELF_PROFILE sampler (PR).
- runtime/tests/test_fps_telemetry_opt_in.py: re-pin the structural
  guard's format needles to the PR's dual speed readout strings
  (VBlank/Render/Display); the opt-in guards are unchanged.

Verified: psxrecomp-game rebuilds; gpu_gl_renderer.c compiles clean;
ctest failure set identical to pre-merge HEAD (no regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdzpVVH912V9ktt4n4kntj
This commit is contained in:
Alex Vanderveen
2026-08-20 14:48:29 -04:00
48 changed files with 2424 additions and 115 deletions
+101 -3
View File
@@ -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<GeneratedFunction> 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<Generated
h << "/* Generated by PSXRecomp - shared declarations for split full.c shards. DO NOT EDIT. */\n";
h << "#pragma once\n";
h << "#include \"psx_runtime.h\"\n\n";
// Per-insn i-cache fetch (see generate_file): inline tag-hit in-process,
// exported slow entry in DLL shards.
h << "#ifdef PSX_OVERLAY_DLL_BUILD\n"
"#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch((c),(a))\n"
"#else\n"
"#include \"psx_icache.h\"\n"
"#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n"
"#endif\n\n";
emit_runtime_externs(h);
emit_unaligned_helpers(h, /*as_inline=*/true);
if (!gen_funcs.empty()) {
@@ -3331,6 +3418,17 @@ std::string CodeGenerator::generate_file(
// Include the generic PSX runtime header.
// This provides CPUState, GTE/trap declarations, and call_by_address().
ss << "#include \"psx_runtime.h\"\n\n";
// Per-instruction i-cache fetch: in-process builds (static bake / AOT)
// use the inline tag-hit (psx_icache.h) — the out-of-line call per
// instruction was a measured steady-state tax. DLL shards cannot touch
// host globals directly and keep the exported slow entry (identical
// cache evolution; the inline's miss path IS that entry).
ss << "#ifdef PSX_OVERLAY_DLL_BUILD\n"
"#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch((c),(a))\n"
"#else\n"
"#include \"psx_icache.h\"\n"
"#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n"
"#endif\n\n";
emit_runtime_externs(ss);
emit_unaligned_helpers(ss, /*as_inline=*/false);
+7
View File
@@ -466,6 +466,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
if (runtime.contains("fast_boot")) {
rt.fast_boot = toml::find<bool>(runtime, "fast_boot");
}
if (runtime.contains("ape_card_unstick")) {
rt.ape_card_unstick = toml::find<bool>(runtime, "ape_card_unstick");
}
if (runtime.contains("openbios")) {
rt.openbios = toml::find<bool>(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<std::string>(video, "hd_texture_dir");
}
if (video.contains("hd_texture_exclude")) {
rt.video_hd_texture_exclude =
toml::find<std::vector<std::string>>(video, "hd_texture_exclude");
}
if (video.contains("geometry_correction")) {
rt.video_geometry_correction =
toml::find<bool>(video, "geometry_correction");
+15
View File
@@ -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<std::string> 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
+29 -6
View File
@@ -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";
+7
View File
@@ -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);
+45
View File
@@ -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
-1
View File
@@ -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,
@@ -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);
}
+12
View File
@@ -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);
+9 -2
View File
@@ -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)
+2
View File
@@ -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);
+2
View File
@@ -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);
+10
View File
@@ -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
+7 -1
View File
@@ -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,
+13 -2
View File
@@ -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
+30 -1
View File
@@ -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();
}
+8
View File
@@ -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
+90 -7
View File
@@ -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) */
}
+223 -34
View File
@@ -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 &&
+63 -15
View File
@@ -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<int16_t>(gte->SXY[0] & 0xFFFF);
@@ -929,23 +953,39 @@ void gte_nclip(GTEState* gte, uint32_t instr) {
int32_t sy1 = static_cast<int16_t>(gte->SXY[1] >> 16);
int32_t sx2 = static_cast<int16_t>(gte->SXY[2] & 0xFFFF);
int32_t sy2 = static_cast<int16_t>(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<int32_t>(mac0);
int32_t out = static_cast<int32_t>(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;
}
+28 -1
View File
@@ -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 */
}
}
+192 -16
View File
@@ -135,6 +135,7 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv);
#include <string>
#include <unordered_map>
#include <vector>
#include <map>
#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 <ws2tcpip.h>
#include <iphlpapi.h>
#include <windows.h>
#include <psapi.h> /* GetModuleInformation (self-profile sampler) */
#include <commdlg.h>
#else
#include <arpa/inet.h>
@@ -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<std::string> 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=<seconds>: 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 <exe>. 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<uintptr_t, int> 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<std::pair<int, uintptr_t>> 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();
+31 -7
View File
@@ -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;
+51
View File
@@ -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) {
+47 -1
View File
@@ -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;
+10
View File
@@ -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 --------------------------------------
+10 -5
View File
@@ -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;
}
+20 -5
View File
@@ -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. */
+101 -1
View File
@@ -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<std::mutex> 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;
+12
View File
@@ -422,6 +422,7 @@ std::atomic<int> g_msg_sep_pending{0}; // unpatched coun
std::mutex g_mtx;
std::atomic<bool> g_apply_armed{false}; // table non-empty AND language enabled
std::atomic<bool> 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
+38
View File
@@ -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;
}
}
+2 -2
View File
@@ -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:
+28 -2
View File
@@ -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:
+6 -3
View File
@@ -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(
+88
View File
@@ -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 <disc.cue|bin> # list the ISO9660 filesystem
python3 psxasset extract <disc> [paths...] -o DIR # copy files off the disc
python3 psxasset unpack <file> [--raw] # unpack a container (probe or --format)
python3 psxasset scan <file|dir> [--extract] # signature-scan, optionally decode hits
python3 psxasset tim2png <file> [--all-palettes] # TIMs in any blob -> PNG
python3 psxasset vag2wav <file> [--rate N] # VAG (headered/headerless/raw) -> WAV
python3 psxasset rip <disc> -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/ <src>_<offset>_<WxH>_<bpp>bpp[_pN].png
audio/ <src>_<name-or-offset>_<rate>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/<title>.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.
+13
View File
@@ -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"
+13
View File
@@ -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())
+338
View File
@@ -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)
+37
View File
@@ -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
+56
View File
@@ -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
View File
+154
View File
@@ -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)
+75
View File
@@ -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)
@@ -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"
+162
View File
@@ -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)}"
+28
View File
@@ -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)
+81
View File
@@ -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
+81
View File
@@ -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