mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 02:56:36 -04:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 --------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user