From 992905a72ee1347beb0be10ce721613f06c9d882 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sat, 15 Aug 2026 00:16:57 +0300 Subject: [PATCH 01/25] recompiler: discard COP2 reads into zero register --- recompiler/src/code_generator.cpp | 24 ++++++++++++--------- recompiler/tests/recompiler_patch_test.cpp | 25 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index b84dc492..3d882e8b 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1500,17 +1500,21 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) const std::string gte_read = fmt::format( "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt); if (cop_op == 0x00) { // MFC2 - move from COP2 data - if (PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd))) { - code = gte_read + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd); - } else { - code = gte_read + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); - } + const std::string value = + PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd)) + ? fmt::format("gte_read_data(cpu, {})", rd) + : fmt::format("cpu->gte_data[{}]", rd); + code = gte_read + (rt == 0 + ? fmt::format("(void){}; /* mfc2 to $zero: read preserved, write discarded */", value) + : fmt::format("{} = {}; /* mfc2 */", reg_name(rt), value)); } else if (cop_op == 0x02) { // CFC2 - move from COP2 control - if (PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd))) { - code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); - } else { - code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); - } + const std::string value = + PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd)) + ? fmt::format("gte_read_ctrl(cpu, {})", rd) + : fmt::format("cpu->gte_ctrl[{}]", rd); + code = gte_read + (rt == 0 + ? fmt::format("(void){}; /* cfc2 to $zero: read preserved, write discarded */", value) + : fmt::format("{} = {}; /* cfc2 */", reg_name(rt), value)); } else if (cop_op == 0x04) { // MTC2 - move to COP2 data if (PSXRecompGTERegisters::data_write_needs_helper(static_cast(rd))) { code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index dc1df97a..bfea6cb8 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -814,6 +814,19 @@ void gte_codegen_classification_tests() { all_ok = false; } }; + const auto expect_zero_destination = [&](uint32_t word, + const std::string& read, + const char *kind) { + const std::string code = generate_first_instruction(word, {}, false); + if (code.find("cpu->gpr[0] =") != std::string::npos || + code.find(read) == std::string::npos || + code.find("psx_gte_read(cpu, 0)") == std::string::npos) { + fmt::print(stderr, + "FAIL full-game GTE {} reg {} writes $zero or drops read/timing\n", + kind, reg); + all_ok = false; + } + }; const uint32_t cop2 = 0x12u << 26; expect(cop2 | (0x00u << 21) | (2u << 16) | (uint32_t(reg) << 11), @@ -822,6 +835,18 @@ void gte_codegen_classification_tests() { expect(cop2 | (0x02u << 21) | (2u << 16) | (uint32_t(reg) << 11), fmt::format("gte_read_ctrl(cpu, {})", reg), PSXRecompGTERegisters::ctrl_read_needs_helper(reg), "CFC2"); + expect_zero_destination( + cop2 | (0x00u << 21) | (0u << 16) | (uint32_t(reg) << 11), + PSXRecompGTERegisters::data_read_needs_helper(reg) + ? fmt::format("gte_read_data(cpu, {})", reg) + : fmt::format("cpu->gte_data[{}]", reg), + "MFC2"); + expect_zero_destination( + cop2 | (0x02u << 21) | (0u << 16) | (uint32_t(reg) << 11), + PSXRecompGTERegisters::ctrl_read_needs_helper(reg) + ? fmt::format("gte_read_ctrl(cpu, {})", reg) + : fmt::format("cpu->gte_ctrl[{}]", reg), + "CFC2"); expect(cop2 | (0x04u << 21) | (2u << 16) | (uint32_t(reg) << 11), fmt::format("gte_write_data(cpu, {}", reg), PSXRecompGTERegisters::data_write_needs_helper(reg), "MTC2"); From cab7d00f9a02bc70c8e054de1c6d8df1850fee08 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 14 Aug 2026 15:22:29 -0700 Subject: [PATCH 02/25] Fix PSX launcher hotkeys and audio persistence --- recompiler/src/config_loader.cpp | 13 +++ recompiler/src/config_loader.h | 1 + .../tests/video_enhancement_settings_test.cpp | 9 +- runtime/CMakeLists.txt | 11 +++ runtime/include/host_keymap.h | 11 ++- runtime/include/host_osd.h | 3 + runtime/runtime.cmake | 38 ++++++--- runtime/src/host_keymap.c | 49 +++++++++-- runtime/src/host_osd.c | 45 ++++++++-- runtime/src/main.cpp | 57 +++++++++++-- runtime/tests/test_host_keymap.c | 84 +++++++++++++++++++ 11 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 runtime/tests/test_host_keymap.c diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index f35c3782..cd38b9b9 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -2103,6 +2103,10 @@ GameOptions load_game_options(const fs::path& path) { // ---- UserSettings (settings.toml) — launcher-written override layer ---- +static bool valid_user_audio_freq(int n) { + return n == 32040 || n == 32000 || n == 44100 || n == 48000; +} + UserSettings load_user_settings(const fs::path& path) { UserSettings s; std::error_code ec; @@ -2245,6 +2249,13 @@ UserSettings load_user_settings(const fs::path& path) { } if (doc.contains("audio")) { const toml::value& a = toml::find(doc, "audio"); + if (a.contains("frequency")) try_get([&]{ + const auto n = toml::find(a, "frequency"); + if (valid_user_audio_freq((int)n)) { + s.audio_freq = (int)n; + s.has_audio_freq = true; + } + }); if (a.contains("spu_hq")) try_get([&]{ s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; }); @@ -2487,6 +2498,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_rewind_interval) f << "rewind_interval = " << s.rewind_interval << "\n"; f << "\n[audio]\n"; + if (s.has_audio_freq) + f << "frequency = " << s.audio_freq << "\n"; if (s.has_spu_hq) f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; if (s.has_hotkey_pad_rewind || s.has_hotkey_pad_save_state_menu) { diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 011eaf4d..1829e024 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -1115,6 +1115,7 @@ struct UserSettings { bool has_hotkey_pad_save_state_menu = false; int hotkey_pad_save_state_menu = 2040; /* select+r1 */ // [audio] bool has_spu_hq = false; bool spu_hq = false; + bool has_audio_freq = false; int audio_freq = 44100; // [bios] / [disc] / [memcard] bool has_bios_path = false; std::filesystem::path bios_path; bool has_disc_path = false; std::filesystem::path disc_path; diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 3f51f9d8..d099a203 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -122,13 +122,17 @@ static void test_user_settings_read() { fs::path p = write_temp("psxrecomp_pgxp_settings.toml", "[video]\n" "geometry_correction = true\n" - "perspective_texturing = false\n"); + "perspective_texturing = false\n" + "[audio]\n" + "frequency = 48000\n"); auto us = PSXRecompV4::load_user_settings(p); check(!us.parse_error, "settings.toml parses"); check(us.has_geometry_correction && us.geometry_correction, "settings.toml geometry_correction = true read"); check(us.has_perspective_texturing && !us.perspective_texturing, "settings.toml perspective_texturing = false read (explicit off)"); + check(us.has_audio_freq && us.audio_freq == 48000, + "settings.toml audio frequency read"); fs::remove(p); } @@ -150,6 +154,7 @@ static void test_user_settings_round_trip() { PSXRecompV4::UserSettings out; out.geometry_correction = true; out.has_geometry_correction = true; out.perspective_texturing = true; out.has_perspective_texturing = true; + out.audio_freq = 48000; out.has_audio_freq = true; fs::path p = fs::temp_directory_path() / "psxrecomp_pgxp_roundtrip.toml"; check(PSXRecompV4::save_user_settings(p, out), "save_user_settings writes"); @@ -160,6 +165,8 @@ static void test_user_settings_round_trip() { "geometry_correction survives a save/load round trip"); check(back.has_perspective_texturing && back.perspective_texturing, "perspective_texturing survives a save/load round trip"); + check(back.has_audio_freq && back.audio_freq == 48000, + "audio frequency survives a save/load round trip"); fs::remove(p); } diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index b6692c46..45934989 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -52,6 +52,17 @@ if(BUILD_TESTING) PSX_OVERLAY_DLL_BUILD=1) add_test(NAME psx_icache_fastpath_test COMMAND psx_icache_fastpath_test) + add_executable(host_keymap_test + tests/test_host_keymap.c + src/host_keymap.c) + target_include_directories(host_keymap_test PRIVATE + include ${PSX_SDL_INCLUDE_DIRS}) + target_compile_definitions(host_keymap_test PRIVATE + SDL_MAIN_HANDLED + $<$:PSX_SDL3=1>) + target_link_libraries(host_keymap_test PRIVATE ${PSX_SDL_LIBRARIES}) + add_test(NAME host_keymap_test COMMAND host_keymap_test) + # Registered since before this change, but it did NOT LINK on origin/master: # sio.c had grown psx_read_word / psx_get_cycle_count / i_mask / # g_debug_last_store_pc references that this test's stub block never gained, diff --git a/runtime/include/host_keymap.h b/runtime/include/host_keymap.h index 24d78314..7ad5476e 100644 --- a/runtime/include/host_keymap.h +++ b/runtime/include/host_keymap.h @@ -10,13 +10,15 @@ extern "C" { /* * Host hotkeys from recomp-ui config.ini [KeyMap] (SDL key names + Ctrl+/Alt+/ - * Shift+ prefixes). VolumeUp / VolumeDown are honored by the runtime; missing - * lines keep the historic keypad +/- defaults. + * Shift+ prefixes). Missing lines keep the historic runtime defaults. */ typedef enum HostKeymapAction { - HOST_KEYMAP_VOLUME_UP = 0, + HOST_KEYMAP_FULLSCREEN = 0, + HOST_KEYMAP_TURBO, + HOST_KEYMAP_VOLUME_UP, HOST_KEYMAP_VOLUME_DOWN, + HOST_KEYMAP_DISPLAY_PERF, HOST_KEYMAP_REWIND, /* default F8 */ HOST_KEYMAP_SAVE_STATE_MENU, /* default F7 */ HOST_KEYMAP_ACTION_COUNT @@ -28,6 +30,9 @@ void host_keymap_load(const char *config_ini_path); /* 1 if (keycode, mod) matches a binding for `action`. */ int host_keymap_match(HostKeymapAction action, int keycode, int mod); +/* 1 if any configured bind for `action` is currently held. */ +int host_keymap_down(HostKeymapAction action, const uint8_t *keys, int mod); + /* Format the primary bind for `action` into out (e.g. "F8", "Ctrl+R", * "GRAVE"). Punctuation key names are tokenized for the rewind overlay font. * Returns out, or "F8" / "" on empty action. Always NUL-terminates. */ diff --git a/runtime/include/host_osd.h b/runtime/include/host_osd.h index fefab1dd..e4e6f307 100644 --- a/runtime/include/host_osd.h +++ b/runtime/include/host_osd.h @@ -19,6 +19,9 @@ extern "C" { /* Top-left text toast. duration_ms <= 0 uses 2000. */ void host_osd_push(const char *msg, int duration_ms); +/* Persistent top-left status text. Passing NULL/empty clears it. */ +void host_osd_set_status(const char *msg); + /* Right-side vertical volume bar (percent clamped 0..100). */ void host_osd_show_volume(int percent, int duration_ms); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5d13b6a9..8a49dda9 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -441,18 +441,34 @@ endif() # Local rewind: full rbengine when netplay is on; otherwise compile snap_ring.c # only (no recomp-net / sched / hash_confirm). Never both — duplicate symbols. +# Defaults ON because local rewind is useful for every PSX title. If a title +# deliberately opts out with -DPSX_REWIND=OFF, recomp-ui hides the Rewind +# controls instead of exposing dead hotkeys. +option(PSX_REWIND "Build and expose local rewind support" ON) set(PSXRECOMP_HAS_RBENGINE_SNAP FALSE) set(PSXRECOMP_RBENGINE_SNAP_INCLUDE "") -if(PSXRECOMP_HAS_RBENGINE) - set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) -elseif(RECOMP_RBENGINE_ROOT - AND EXISTS "${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c" - AND EXISTS "${RECOMP_RBENGINE_ROOT}/include/retcomm_rbengine/snap_ring.h") - list(APPEND PSXRECOMP_RUNTIME_SOURCES - ${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c) - set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) - set(PSXRECOMP_RBENGINE_SNAP_INCLUDE "${RECOMP_RBENGINE_ROOT}/include") - message(STATUS "psxrecomp: rewind snap_ring (${RECOMP_RBENGINE_ROOT})") +if(PSX_REWIND) + if(PSXRECOMP_HAS_RBENGINE) + set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) + elseif(RECOMP_RBENGINE_ROOT + AND EXISTS "${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c" + AND EXISTS "${RECOMP_RBENGINE_ROOT}/include/retcomm_rbengine/snap_ring.h") + list(APPEND PSXRECOMP_RUNTIME_SOURCES + ${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c) + set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) + set(PSXRECOMP_RBENGINE_SNAP_INCLUDE "${RECOMP_RBENGINE_ROOT}/include") + message(STATUS "psxrecomp: rewind snap_ring (${RECOMP_RBENGINE_ROOT})") + endif() + if(NOT PSXRECOMP_HAS_RBENGINE_SNAP) + message(FATAL_ERROR + "psxrecomp: PSX_REWIND=ON exposes the Rewind launcher controls " + "but no retcomm-rbengine snap-ring backend was found.\n" + " git submodule update --init lib/retcomm-rbengine\n" + " or -DRECOMP_RBENGINE_ROOT=/path/to/retcomm-rbengine\n" + " or configure with -DPSX_REWIND=OFF to hide Rewind.") + endif() +else() + message(STATUS "psxrecomp: local rewind disabled (PSX_REWIND=OFF)") endif() # Lobby WebSocket client helpers are vendored under runtime/src/lobby_ws/ @@ -1358,6 +1374,8 @@ function(psxrecomp_add_runtime_target target) list(APPEND _psx_recomp_ui_args BRAND "${PSXRT_LAUNCHER_BRAND}") endif() recomp_target_launcher_ui(${target} ${_psx_recomp_ui_args}) + target_compile_definitions(${target} PRIVATE + RECOMP_UI_PSX_HAS_REWIND=$) endif() if(WIN32 OR MINGW) diff --git a/runtime/src/host_keymap.c b/runtime/src/host_keymap.c index ff28ea69..da2bbfbf 100644 --- a/runtime/src/host_keymap.c +++ b/runtime/src/host_keymap.c @@ -18,6 +18,7 @@ typedef struct HostKeyBind { int keycode; + int scancode; int mods; /* KMOD_CTRL | KMOD_ALT | KMOD_SHIFT subset */ } HostKeyBind; @@ -65,32 +66,44 @@ static void clear_all(void) { memset(s_actions, 0, sizeof(s_actions)); } -static void add_bind(HostKeymapAction action, int keycode, int mods) { +static void add_bind(HostKeymapAction action, int keycode, int scancode, int mods) { HostKeyAction *a; if (action < 0 || action >= HOST_KEYMAP_ACTION_COUNT) return; if (keycode == 0 || keycode == SDLK_UNKNOWN) return; a = &s_actions[action]; if (a->count >= HOST_KEYMAP_MAX_BINDS) return; a->binds[a->count].keycode = keycode; + a->binds[a->count].scancode = scancode; a->binds[a->count].mods = mods; a->count++; } static void apply_defaults(void) { + if (s_actions[HOST_KEYMAP_FULLSCREEN].count == 0) { + add_bind(HOST_KEYMAP_FULLSCREEN, (int)SDLK_RETURN, (int)SDL_SCANCODE_RETURN, KMOD_ALT); + add_bind(HOST_KEYMAP_FULLSCREEN, (int)SDLK_f, (int)SDL_SCANCODE_F, KMOD_CTRL); + } + if (s_actions[HOST_KEYMAP_TURBO].count == 0) + add_bind(HOST_KEYMAP_TURBO, (int)SDLK_TAB, (int)SDL_SCANCODE_TAB, 0); if (s_actions[HOST_KEYMAP_VOLUME_UP].count == 0) - add_bind(HOST_KEYMAP_VOLUME_UP, (int)SDLK_KP_PLUS, 0); + add_bind(HOST_KEYMAP_VOLUME_UP, (int)SDLK_KP_PLUS, (int)SDL_SCANCODE_KP_PLUS, 0); if (s_actions[HOST_KEYMAP_VOLUME_DOWN].count == 0) - add_bind(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_KP_MINUS, 0); + add_bind(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_KP_MINUS, (int)SDL_SCANCODE_KP_MINUS, 0); + if (s_actions[HOST_KEYMAP_DISPLAY_PERF].count == 0) + add_bind(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_f, (int)SDL_SCANCODE_F, 0); +#if defined(PSX_HAS_RBENGINE_SNAP) if (s_actions[HOST_KEYMAP_REWIND].count == 0) - add_bind(HOST_KEYMAP_REWIND, (int)SDLK_F8, 0); + add_bind(HOST_KEYMAP_REWIND, (int)SDLK_F8, (int)SDL_SCANCODE_F8, 0); +#endif if (s_actions[HOST_KEYMAP_SAVE_STATE_MENU].count == 0) - add_bind(HOST_KEYMAP_SAVE_STATE_MENU, (int)SDLK_F7, 0); + add_bind(HOST_KEYMAP_SAVE_STATE_MENU, (int)SDLK_F7, (int)SDL_SCANCODE_F7, 0); } /* Parse one "Ctrl+Alt+PageUp" token into key+mods. */ static void parse_one_token(HostKeymapAction action, char *tok) { int mods = 0; SDL_Keycode key; + SDL_Scancode sc; trim_inplace(tok); if (!tok[0] || ieq(tok, "(unbound)") || ieq(tok, "None")) return; for (;;) { @@ -114,7 +127,8 @@ static void parse_one_token(HostKeymapAction action, char *tok) { fprintf(stderr, "host_keymap: unknown key '%s'\n", tok); return; } - add_bind(action, (int)key, mods); + sc = SDL_GetScancodeFromName(tok); + add_bind(action, (int)key, (int)sc, mods); } static void parse_value(HostKeymapAction action, const char *value) { @@ -133,8 +147,11 @@ static void parse_value(HostKeymapAction action, const char *value) { } static HostKeymapAction action_for_key(const char *name) { + if (ieq(name, "Fullscreen")) return HOST_KEYMAP_FULLSCREEN; + if (ieq(name, "Turbo")) return HOST_KEYMAP_TURBO; if (ieq(name, "VolumeUp")) return HOST_KEYMAP_VOLUME_UP; if (ieq(name, "VolumeDown")) return HOST_KEYMAP_VOLUME_DOWN; + if (ieq(name, "DisplayPerf")) return HOST_KEYMAP_DISPLAY_PERF; if (ieq(name, "Rewind")) return HOST_KEYMAP_REWIND; if (ieq(name, "SaveStateMenu")) return HOST_KEYMAP_SAVE_STATE_MENU; return HOST_KEYMAP_ACTION_COUNT; @@ -201,6 +218,21 @@ int host_keymap_match(HostKeymapAction action, int keycode, int mod) { return 0; } +int host_keymap_down(HostKeymapAction action, const uint8_t *keys, int mod) { + const HostKeyAction *a; + const int relevant = (int)(KMOD_CTRL | KMOD_ALT | KMOD_SHIFT); + int i; + if (!keys || action < 0 || action >= HOST_KEYMAP_ACTION_COUNT) return 0; + a = &s_actions[action]; + for (i = 0; i < a->count; i++) { + const int sc = a->binds[i].scancode; + if (sc <= 0 || sc >= SDL_NUM_SCANCODES) continue; + if (!keys[sc]) continue; + if ((mod & relevant) == a->binds[i].mods) return 1; + } + return 0; +} + /* Rewind overlay FONT8 only draws ASCII 32..90 (space..Z); lowercase is * uppercased by the drawer. Map punctuation / odd SDL single-glyph names to * short tokens so binds like backtick don't render as "?". */ @@ -257,9 +289,12 @@ const char *host_keymap_label(HostKeymapAction action, char *out, size_t cap) { if (action < 0 || action >= HOST_KEYMAP_ACTION_COUNT) return out; a = &s_actions[action]; if (a->count <= 0) { +#if defined(PSX_HAS_RBENGINE_SNAP) if (action == HOST_KEYMAP_REWIND) snprintf(out, cap, "F8"); - else if (action == HOST_KEYMAP_SAVE_STATE_MENU) + else +#endif + if (action == HOST_KEYMAP_SAVE_STATE_MENU) snprintf(out, cap, "F7"); return out; } diff --git a/runtime/src/host_osd.c b/runtime/src/host_osd.c index 63d27505..62c387d0 100644 --- a/runtime/src/host_osd.c +++ b/runtime/src/host_osd.c @@ -130,6 +130,10 @@ static Uint32 s_expire_ms; static int s_active; static int s_img_dirty = 1; +static char s_status_msg[OSD_MAX_CHARS]; +static int s_status_active; +static int s_status_dirty = 1; + static int s_volume = 100; /* host master 0..100 */ static int s_vol_pct = 100; /* last shown bar fill */ static Uint32 s_vol_expire_ms; @@ -197,8 +201,8 @@ static int vol_visible(void) { return 1; } -static void rasterize_text(void) { - int n = (int)strlen(s_msg); +static void rasterize_text(const char *msg) { + int n = (int)strlen(msg); if (n > OSD_MAX_CHARS) n = OSD_MAX_CHARS; s_img_w = (OSD_PAD_X * 2 + n * OSD_GLYPH_W) * OSD_SCALE; s_img_h = (OSD_PAD_Y * 2 + OSD_GLYPH_H) * OSD_SCALE; @@ -211,7 +215,7 @@ static void rasterize_text(void) { s_img[i] = 0xFF202020u; for (int ci = 0; ci < n; ci++) { - unsigned char ch = (unsigned char)s_msg[ci]; + unsigned char ch = (unsigned char)msg[ci]; if (ch < 32 || ch > 126) ch = '?'; const uint8_t *g = FONT8X8[ch - 32]; for (int row = 0; row < OSD_GLYPH_H; row++) { @@ -338,6 +342,25 @@ void host_osd_push(const char *msg, int duration_ms) { #endif } +void host_osd_set_status(const char *msg) { +#if !HOST_OSD_VISUAL + (void)msg; + return; +#else + if (!msg || !msg[0]) { + if (s_status_active) s_needs_clear = 1; + s_status_msg[0] = '\0'; + s_status_active = 0; + s_status_dirty = 1; + return; + } + snprintf(s_status_msg, sizeof(s_status_msg), "%s", msg); + s_status_active = 1; + s_status_dirty = 1; + s_needs_clear = 0; +#endif +} + void host_osd_show_volume(int percent, int duration_ms) { #if !HOST_OSD_VISUAL (void)percent; @@ -375,7 +398,7 @@ int host_osd_needs_present(void) { #if !HOST_OSD_VISUAL return 0; #else - if (msg_visible() || vol_visible()) return 1; + if (msg_visible() || s_status_active || vol_visible()) return 1; return s_needs_clear; #endif } @@ -387,13 +410,21 @@ int host_osd_image(const uint32_t **pixels, int *w, int *h) { if (h) *h = 0; return 0; #else - if (!msg_visible()) { + const int show_msg = msg_visible(); + if (!show_msg && !s_status_active) { if (pixels) *pixels = NULL; if (w) *w = 0; if (h) *h = 0; return 0; } - if (s_img_dirty) rasterize_text(); + if (show_msg) { + if (s_img_dirty) rasterize_text(s_msg); + } else { + if (s_status_dirty || s_img_dirty) { + rasterize_text(s_status_msg); + s_status_dirty = 0; + } + } if (pixels) *pixels = s_img; if (w) *w = s_img_w; if (h) *h = s_img_h; @@ -426,7 +457,7 @@ void host_osd_present_done(void) { #if !HOST_OSD_VISUAL return; #else - if (!s_active && !s_vol_active) s_needs_clear = 0; + if (!s_active && !s_status_active && !s_vol_active) s_needs_clear = 0; #endif } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c2904bb1..e5d50bde 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -465,6 +465,7 @@ static int post_load_probe_env_on(void) { static Uint64 s_fps_last_time = 0; static uint64_t s_fps_last_frame = 0; static std::string s_fps_base_title; +static int s_fps_telemetry_enabled = -1; /* -1 = unread env */ static FramePacer s_frame_pacer = { 0 }; static int s_turbo_present_skip = 0; static int s_fmv_skip_present_skip = 0; @@ -517,6 +518,27 @@ static void present_session_reset(void) { smooth_60_reset(); } +static int fps_telemetry_enabled(void) { + if (s_fps_telemetry_enabled < 0) { + const char *e = std::getenv("PSX_FPS_TELEMETRY"); + s_fps_telemetry_enabled = (e && e[0] && e[0] != '0') ? 1 : 0; + } + return s_fps_telemetry_enabled; +} + +static void fps_telemetry_toggle(void) { + const int enabled = fps_telemetry_enabled() ? 0 : 1; + s_fps_telemetry_enabled = enabled; + s_fps_last_time = 0; + s_fps_last_frame = 0; + if (!enabled && sdl_window && !s_fps_base_title.empty()) + SDL_SetWindowTitle(sdl_window, s_fps_base_title.c_str()); + if (!enabled) + host_osd_set_status(NULL); + s_fps_base_title.clear(); + host_osd_push(enabled ? "FPS readout on" : "FPS readout off", 1500); +} + static void post_load_probe_stall_pc_note(uint32_t pc) { if (!pc) return; for (int i = 0; i < s_stall_pc_used; i++) { @@ -1065,6 +1087,7 @@ static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderle static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ static int g_video_win_w = 1280; /* window width (height follows aspect) */ static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ +static int g_audio_freq = 44100; /* host device request */ static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ static int g_rewind_depth = 50; /* local rewind snap count (50/100/150/200) */ static int g_rewind_interval = 15; /* frames between snaps (1/4/8/12/15) */ @@ -5635,14 +5658,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * than presents so turbo and skipped-frame modes still report game speed. * Skip during netplay post-load barrier — admit is stalled and the window * is not updating, so a climbing FPS line is misleading. */ - if (!psx_netplay_in_load_barrier()) { + if (fps_telemetry_enabled() && !psx_netplay_in_load_barrier()) { extern uint64_t s_frame_count; const Uint64 now = SDL_GetPerformanceCounter(); const Uint64 frequency = SDL_GetPerformanceFrequency(); if (!s_fps_last_time) { s_fps_last_time = now; s_fps_last_frame = s_frame_count; - if (sdl_window) { + if (sdl_window && s_fps_base_title.empty()) { const char *title = SDL_GetWindowTitle(sdl_window); if (title) s_fps_base_title = title; } @@ -5656,6 +5679,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { s_fps_base_title.c_str(), fps, speed); SDL_SetWindowTitle(sdl_window, title); } + if (!g_headless) { + char osd[64]; + snprintf(osd, sizeof(osd), "FPS %.0f %.2fx", fps, speed); + host_osd_set_status(osd); + } if (netplay_timing_on() && s_np_timing_frames > 0) { const double invf = 1000.0 / (double)frequency; const double admit_ms = @@ -5773,6 +5801,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { debug_force_cd_reinsert(); host_osd_push("CD reinsert", 1500); } + else if (!key_repeat && + host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)key, + (int)mod)) { + fps_telemetry_toggle(); + } /* Host volume: config.ini [KeyMap] VolumeUp/VolumeDown * (defaults: keypad +/-). 5% steps; shows right-side bar. */ else if (host_keymap_match(HOST_KEYMAP_VOLUME_UP, (int)key, @@ -5790,8 +5823,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * set in both SDL_WINDOW_FULLSCREEN and * SDL_WINDOW_FULLSCREEN_DESKTOP, so testing just that bit * detects "currently fullscreen, either mode". */ - else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || - (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { + else if (!key_repeat && + host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)key, + (int)mod)) { Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN; if (is_fs) { @@ -6004,7 +6038,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; const int TURBO_PRESENT_EVERY = 30; - if (keys[SDL_SCANCODE_TAB]) { + if (host_keymap_down(HOST_KEYMAP_TURBO, keys, (int)SDL_GetModState())) { turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; if (turbo_skip != 0) { ep.skip_pace = 1; @@ -10657,6 +10691,7 @@ int main(int argc, char** argv) { g_video_aspect_num = us.aspect_num; g_video_aspect_den = us.aspect_den; } + if (us.has_audio_freq) g_audio_freq = us.audio_freq; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; if (us.has_rewind_depth) g_rewind_depth = us.rewind_depth; if (us.has_rewind_interval) g_rewind_interval = us.rewind_interval; @@ -11073,6 +11108,7 @@ int main(int argc, char** argv) { seed.has_frame_interpolation_fps = true; seed.aspect_num = g_video_aspect_num; seed.aspect_den = g_video_aspect_den; seed.has_aspect_ratio = true; + seed.audio_freq = g_audio_freq; seed.has_audio_freq = true; seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; seed.rewind_depth = g_rewind_depth; seed.has_rewind_depth = true; seed.rewind_interval = g_rewind_interval; seed.has_rewind_interval = true; @@ -11178,7 +11214,7 @@ int main(int argc, char** argv) { ls.widescreen = (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; ls.widescreen_hud = ls.widescreen; ls.enable_audio = 1; - ls.audio_freq = 44100; + ls.audio_freq = seed.audio_freq; ls.volume = host_volume_get(); { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); @@ -11466,6 +11502,7 @@ int main(int argc, char** argv) { seed.screen_kind = ls.screen_kind; seed.has_screen_kind = true; seed.frame_interpolation = ls.frame_interp != 0; seed.has_frame_interpolation = true; seed.frame_interpolation_fps = ls.frame_interp_fps; seed.has_frame_interpolation_fps = true; + seed.audio_freq = ls.audio_freq; seed.has_audio_freq = true; seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; seed.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; seed.has_rewind_depth = true; @@ -11677,6 +11714,7 @@ int main(int argc, char** argv) { g_frame_interpolation_fps = seed.frame_interpolation_fps; g_video_aspect_num = seed.aspect_num; g_video_aspect_den = seed.aspect_den; + g_audio_freq = seed.audio_freq; g_audio_spu_hq = seed.spu_hq; g_rewind_depth = seed.has_rewind_depth && seed.rewind_depth > 0 ? seed.rewind_depth : 50; @@ -12250,7 +12288,7 @@ session_reboot: if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) { PsxSdlAudioSpec want = {}; PsxSdlAudioSpec have = {}; - want.freq = 44100; + want.freq = g_audio_freq; want.format = AUDIO_S16SYS; want.channels = 2; want.samples = 1024; @@ -13008,7 +13046,7 @@ soft_return_lobby: (g_video_aspect_num == 16 && g_video_aspect_den == 9) ? 1 : 0; ls.widescreen_hud = ls.widescreen; ls.enable_audio = 1; - ls.audio_freq = 44100; + ls.audio_freq = g_audio_freq; ls.volume = host_volume_get(); ls.window_width = g_video_win_w; ls.renderer = g_video_renderer; @@ -13279,6 +13317,8 @@ soft_return_lobby: us.has_frame_interpolation = true; us.frame_interpolation_fps = ls.frame_interp_fps; us.has_frame_interpolation_fps = true; + us.audio_freq = ls.audio_freq; + us.has_audio_freq = true; us.spu_hq = ls.spu_hq != 0; us.has_spu_hq = true; us.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; @@ -13337,6 +13377,7 @@ soft_return_lobby: g_fullscreen = ls.fullscreen != 0; g_frame_interpolation = ls.frame_interp ? 1 : 0; g_frame_interpolation_fps = ls.frame_interp_fps; + g_audio_freq = ls.audio_freq; g_audio_spu_hq = ls.spu_hq != 0; if (ls.rewind_depth > 0) { g_rewind_depth = ls.rewind_depth; diff --git a/runtime/tests/test_host_keymap.c b/runtime/tests/test_host_keymap.c new file mode 100644 index 00000000..25ddb8f5 --- /dev/null +++ b/runtime/tests/test_host_keymap.c @@ -0,0 +1,84 @@ +#include "host_keymap.h" +#include "psx_sdl.h" + +#include +#include + +static int failures = 0; + +static void check(int cond, const char *what) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", what); + failures++; + } +} + +static int mod_ctrl(void) { return (int)KMOD_CTRL; } +static int mod_alt(void) { return (int)KMOD_ALT; } + +int main(int argc, char **argv) { + uint8_t keys[SDL_NUM_SCANCODES]; + const char *cfg = "host_keymap_test.ini"; + FILE *f; + (void)argc; + (void)argv; + + memset(keys, 0, sizeof(keys)); + host_keymap_load(NULL); + check(host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_RETURN, mod_alt()), + "default fullscreen includes Alt+Return"); + check(host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_f, mod_ctrl()), + "default fullscreen includes Ctrl+F"); + keys[SDL_SCANCODE_TAB] = 1; + check(host_keymap_down(HOST_KEYMAP_TURBO, keys, 0), + "default turbo is held by Tab"); + keys[SDL_SCANCODE_TAB] = 0; + check(host_keymap_match(HOST_KEYMAP_VOLUME_UP, (int)SDLK_KP_PLUS, 0), + "default volume up is keypad plus"); + check(host_keymap_match(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_KP_MINUS, 0), + "default volume down is keypad minus"); + check(host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_f, 0), + "default display perf is F"); + + f = fopen(cfg, "wb"); + check(f != NULL, "create temporary config.ini"); + if (!f) return 1; + fputs("[KeyMap]\n" + "Fullscreen = F11\n" + "Turbo = Q\n" + "VolumeUp = Up\n" + "VolumeDown = Down\n" + "DisplayPerf = F10\n", + f); + fclose(f); + + host_keymap_load(cfg); + check(!host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_f, mod_ctrl()), + "fullscreen rebind disables Ctrl+F fallback"); + check(host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_F11, 0), + "fullscreen rebind uses F11"); + memset(keys, 0, sizeof(keys)); + keys[SDL_SCANCODE_TAB] = 1; + check(!host_keymap_down(HOST_KEYMAP_TURBO, keys, 0), + "turbo rebind disables Tab fallback"); + keys[SDL_SCANCODE_TAB] = 0; + keys[SDL_SCANCODE_Q] = 1; + check(host_keymap_down(HOST_KEYMAP_TURBO, keys, 0), + "turbo rebind uses Q"); + check(host_keymap_match(HOST_KEYMAP_VOLUME_UP, (int)SDLK_UP, 0), + "volume up rebind uses Up"); + check(host_keymap_match(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_DOWN, 0), + "volume down rebind uses Down"); + check(!host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_f, 0), + "display perf rebind disables F fallback"); + check(host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_F10, 0), + "display perf rebind uses F10"); + + remove(cfg); + if (failures) { + fprintf(stderr, "host_keymap_test: %d failure(s)\n", failures); + return 1; + } + printf("PASS: host keymap defaults and rebinds\n"); + return 0; +} From 32c6b34986ceb72089c0689793a65d193bcafcf7 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 14 Aug 2026 19:12:37 -0700 Subject: [PATCH 03/25] fix(runtime): clarify PSX overlay controls --- runtime/include/psx_rewind.h | 2 +- runtime/src/main.cpp | 106 +++++++++++++++++---- runtime/src/psx_rewind.c | 88 ++++++++++++++++- runtime/src/psx_savestate_menu.c | 90 +++++++++++++++-- runtime/tests/test_fps_telemetry_opt_in.py | 2 + runtime/tests/test_rewind_toggle_combo.py | 6 ++ 6 files changed, 260 insertions(+), 34 deletions(-) diff --git a/runtime/include/psx_rewind.h b/runtime/include/psx_rewind.h index 72069b2c..ad65befc 100644 --- a/runtime/include/psx_rewind.h +++ b/runtime/include/psx_rewind.h @@ -8,7 +8,7 @@ * g_snaps. Disabled while psx_netplay_active(). * * Keyboard: F8 (config.ini [KeyMap] Rewind). Controller: View/Back or L3. - * While open: D-pad / left-stick Left/Right, Cross/A load, Circle/B/Back close. + * While open: D-pad / left-stick Left/Right, Cross load, Circle/Back close. * * Env: PSX_REWIND=0 disables; PSX_REWIND_INTERVAL (1/4/8/12/15, default 15); * PSX_REWIND_FMV_INTERVAL (frames while depth24/MDEC/XA, default 4); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e5d50bde..9117b597 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -539,6 +539,25 @@ static void fps_telemetry_toggle(void) { host_osd_push(enabled ? "FPS readout on" : "FPS readout off", 1500); } +static int manual_fast_forward_multiplier(void) { + static int value = -2; /* -2 = unread, -1 = max/unbounded */ + if (value == -2) { + const char *e = std::getenv("PSX_FAST_FORWARD_SPEED"); + value = 4; + if (e && e[0]) { + if (std::strcmp(e, "max") == 0 || std::strcmp(e, "MAX") == 0 || + std::strcmp(e, "0") == 0) { + value = -1; + } else { + int parsed = std::atoi(e); + if (parsed >= 2 && parsed <= 16) + value = parsed; + } + } + } + return value; +} + static void post_load_probe_stall_pc_note(uint32_t pc) { if (!pc) return; for (int i = 0; i < s_stall_pc_used; i++) { @@ -5318,20 +5337,19 @@ static void savestate_menu_move(int delta) { savestate_menu_sync_overlay(); } -static void savestate_menu_submit(int save) { - const int slot = savestate_menu_slot; +static int savestate_submit_slot(int slot, int save) { if (!save && !savestate_slot_exists(slot)) { char msg[32]; snprintf(msg, sizeof(msg), "Slot %d is empty", slot + 1); host_osd_push(msg, 1200); - return; + return 0; } if (!save) savestate_input_guard_arm(); if (psx_netplay_active()) { if (!psx_netplay_is_host()) { host_osd_push("Save states are host-only in netplay", 1500); - return; + return 0; } if (save) (void)psx_netplay_request_save(slot); @@ -5342,8 +5360,14 @@ static void savestate_menu_submit(int save) { } else { (void)savestate_request_load(slot); } - savestate_menu_open = 0; - savestate_menu_sync_overlay(); + return 1; +} + +static void savestate_menu_submit(int save) { + if (savestate_submit_slot(savestate_menu_slot, save) && savestate_menu_open) { + savestate_menu_open = 0; + savestate_menu_sync_overlay(); + } } static int savestate_menu_slot_from_key(SDL_Keycode key) { @@ -5673,15 +5697,34 @@ 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 / 59.94; + double display_fps = 0.0; + if (g_frame_interpolation && g_gl_active) { + display_fps = g_frame_interpolation_fps > 0 + ? (double)g_frame_interpolation_fps + : g_host_refresh_hz; + } if (!g_headless && sdl_window) { char title[256]; - snprintf(title, sizeof(title), "%s [%.0f fps %.2fx]", - s_fps_base_title.c_str(), fps, speed); + 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); + } else { + snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", + s_fps_base_title.c_str(), fps, speed); + } SDL_SetWindowTitle(sdl_window, title); } if (!g_headless) { - char osd[64]; - snprintf(osd, sizeof(osd), "FPS %.0f %.2fx", fps, speed); + char osd[96]; + if (display_fps > 0.0) { + snprintf(osd, sizeof(osd), + "Game %.0f FPS %.2fx | Display %.0f FPS", + fps, speed, display_fps); + } else { + snprintf(osd, sizeof(osd), "Game %.0f FPS %.2fx", + fps, speed); + } host_osd_set_status(osd); } if (netplay_timing_on() && s_np_timing_frames > 0) { @@ -6029,23 +6072,45 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { } #endif - /* Turbo mode: while TAB is held, skip both VRAM->ARGB conversion and - * SDL_RenderPresent. The recompiled BIOS still advances simulated - * cycles every vblank, so the BIOS proceeds at whatever rate the host - * CPU sustains without graphics-driver vsync overhead. Present once - * every TURBO_PRESENT_EVERY frames so the user sees visual progress. */ + bool manual_turbo_active = false; + bool turbo_load_paced = false; + + /* Manual fast-forward: bounded by default so the game visibly advances and + * audio is less hostile. PSX_FAST_FORWARD_SPEED=2..16 changes the cap; + * PSX_FAST_FORWARD_SPEED=max restores the old unbounded simulation rate. */ { const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; - const int TURBO_PRESENT_EVERY = 30; + static int turbo_was_down = 0; if (host_keymap_down(HOST_KEYMAP_TURBO, keys, (int)SDL_GetModState())) { - turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; + const int mult = manual_fast_forward_multiplier(); + const int present_every = (mult < 0) ? 4 : (mult <= 4 ? 2 : 4); + manual_turbo_active = true; + if (!turbo_was_down) { + char msg[40]; + if (mult < 0) + snprintf(msg, sizeof(msg), "Fast forward: max"); + else + snprintf(msg, sizeof(msg), "Fast forward: %dx", mult); + host_osd_push(msg, 900); + } + turbo_was_down = 1; + if (mult >= 2 && g_frame_period_ms > 0.0) { + uint64_t perf_start = runtime_perf_section_begin(); + frame_pacer_wait(&s_frame_pacer, + g_frame_period_ms / (double)mult); + runtime_perf_section_end(perf_start, + &g_runtime_perf.pacer_ticks); + latency_ring_mark(LAT_PACED); + } + turbo_skip = (turbo_skip + 1) % present_every; if (turbo_skip != 0) { ep.skip_pace = 1; return ep; /* skip render this frame */ } } else { turbo_skip = 0; + turbo_was_down = 0; } } @@ -6057,7 +6122,6 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * real time (2.2-4.8 sectors/frame against a 32-256 IRQ budget), so * host-speed execution is the lever that compresses load wall-time. * Presents 1-in-30 so visual progress stays visible. */ - bool turbo_load_paced = false; if (turbo_loads_active) { g_turbo_loads_frames++; /* A mod may request a bounded wall-clock multiplier instead of the @@ -6130,7 +6194,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * replay runs uncapped like netplay resim. */ if (!psx_netplay_active() && !psx_selfcheck_resim_active()) { uint64_t perf_start = runtime_perf_section_begin(); - if (!turbo_load_paced && g_frame_period_ms > 0.0) + if (!manual_turbo_active && !turbo_load_paced && g_frame_period_ms > 0.0) frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); @@ -12716,14 +12780,14 @@ session_reboot: psx_rewind_set_depth((uint32_t)g_rewind_depth); psx_rewind_set_interval((uint32_t)g_rewind_interval); psx_rewind_configure(memory_get_bios_checksum(), game_entry_pc); - /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) + /* Headless / agent load: PSX_LOAD_SLOT=N stages slot load (0..11) * at the next safe block boundary after boot. */ if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { int slot = atoi(ls); if (slot >= 0 && slot < 12) { if (psx_netplay_active()) { std::fprintf(stdout, - "psxrecomp: PSX_LOAD_SLOT=%d ignored (use host F1–F12 after netplay starts)\n", + "psxrecomp: PSX_LOAD_SLOT=%d ignored (use the save-state menu after netplay starts)\n", slot); } else if (savestate_request_load(slot)) { std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot); diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index 6ed85fe5..9140732e 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -702,6 +702,80 @@ static void blit_thumb(uint32_t *dst, int dw, int x0, int y0, int tw, int th, } } +static void glyph_fill_rect(uint32_t *dst, int dw, int x0, int y0, + int w, int h, uint32_t col) +{ + int x, y; + if (x0 < 0) { w += x0; x0 = 0; } + if (y0 < 0) { h += y0; y0 = 0; } + if (x0 + w > dw) w = dw - x0; + if (y0 + h > RW_PANEL_H) h = RW_PANEL_H - y0; + if (w <= 0 || h <= 0) return; + for (y = y0; y < y0 + h; y++) + for (x = x0; x < x0 + w; x++) + dst[y * dw + x] = col; +} + +static void glyph_fill_disc(uint32_t *dst, int dw, int cx, int cy, + int r, uint32_t col) +{ + int x, y; + const int rr = r * r; + for (y = -r; y <= r; y++) { + for (x = -r; x <= r; x++) { + if (x * x + y * y > rr) continue; + glyph_fill_rect(dst, dw, cx + x, cy + y, 1, 1, col); + } + } +} + +static void glyph_line(uint32_t *dst, int dw, int x0, int y0, int x1, int y1, + int thickness, uint32_t col) +{ + int dx = x1 > x0 ? x1 - x0 : x0 - x1; + int sx = x0 < x1 ? 1 : -1; + int dy = y1 > y0 ? y0 - y1 : y1 - y0; + int sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + int inset = thickness / 2; + + for (;;) { + glyph_fill_rect(dst, dw, x0 - inset, y0 - inset, thickness, thickness, + col); + if (x0 == x1 && y0 == y1) + break; + { + int e2 = err * 2; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + } +} + +static void draw_psx_button(uint32_t *dst, int dw, int x, int y, char kind) +{ + switch (kind) { + case 'x': + glyph_line(dst, dw, x + 4, y + 4, x + 14, y + 14, 2, 0xFF5FA8FFu); + glyph_line(dst, dw, x + 14, y + 4, x + 4, y + 14, 2, 0xFF5FA8FFu); + break; + case 'o': + glyph_fill_disc(dst, dw, x + 9, y + 9, 8, 0xFFFF6B6Bu); + glyph_fill_disc(dst, dw, x + 9, y + 9, 5, 0xE0101218u); + break; + default: + glyph_fill_rect(dst, dw, x + 7, y + 2, 5, 15, 0xFFD7DCE6u); + glyph_fill_rect(dst, dw, x + 2, y + 7, 15, 5, 0xFFD7DCE6u); + break; + } +} + static void draw_char(uint32_t *dst, int dw, int x0, int y0, char c, uint32_t col) { const uint8_t *g; @@ -745,6 +819,7 @@ static void rasterize_panel(void) { uint32_t *d = s_panel; int i, n, card_w, card_h, gap, x, y, sel_w, sel_h; + int fy; int center_x, origin; char buf[96]; char hk[32]; @@ -762,10 +837,15 @@ static void rasterize_panel(void) draw_text(d, RW_PANEL_W, 120, 8, "NO SNAPSHOTS YET", 0xFF888888u); } host_keymap_label(HOST_KEYMAP_REWIND, hk, sizeof(hk)); - /* Overlay font is uppercase-only; keep PS labels dual for pad + KB users. */ - snprintf(buf, sizeof(buf), - "LEFT/RIGHT A/CROSS LOAD B/CIRCLE CLOSE %s", hk); - draw_text(d, RW_PANEL_W, 12, RW_PANEL_H - 16, buf, 0xFFAAAAAAu); + fy = RW_PANEL_H - 22; + draw_psx_button(d, RW_PANEL_W, 12, fy, 'd'); + draw_text(d, RW_PANEL_W, 36, fy + 5, "SEEK", 0xFFE2E5EBu); + draw_psx_button(d, RW_PANEL_W, 116, fy, 'x'); + draw_text(d, RW_PANEL_W, 140, fy + 5, "LOAD", 0xFFE2E5EBu); + draw_psx_button(d, RW_PANEL_W, 216, fy, 'o'); + draw_text(d, RW_PANEL_W, 240, fy + 5, "CLOSE", 0xFFE2E5EBu); + snprintf(buf, sizeof(buf), "%s MENU", hk[0] ? hk : "F8"); + draw_text(d, RW_PANEL_W, 512, fy + 5, buf, 0xFFAAAAAAu); n = (int)s_count; if (n <= 0) { diff --git a/runtime/src/psx_savestate_menu.c b/runtime/src/psx_savestate_menu.c index 77f8b893..1004b944 100644 --- a/runtime/src/psx_savestate_menu.c +++ b/runtime/src/psx_savestate_menu.c @@ -11,14 +11,14 @@ #define SSM_W 640 #define SSM_H 480 -#define SSM_ROW_H 132 -#define SSM_ROW_GAP 8 -#define SSM_ROWS_Y 56 +#define SSM_ROW_H 108 +#define SSM_ROW_GAP 6 +#define SSM_ROWS_Y 58 #define SSM_ROWS_X 28 #define SSM_ROWS_W 584 #define SSM_VISIBLE_ROWS 3 -#define SSM_THUMB_W 168 -#define SSM_THUMB_H 126 +#define SSM_THUMB_W 136 +#define SSM_THUMB_H 102 /* Public-domain 8x8 ASCII 32..90 subset from font8x8_basic. */ static const uint8_t FONT8[59][8] = { @@ -82,6 +82,68 @@ static void stroke_rect(uint32_t *dst, int x, int y, int w, int h, uint32_t col) fill_rect(dst, x + w - 1, y, 1, h, col); } +static void fill_disc(uint32_t *dst, int cx, int cy, int r, uint32_t col) +{ + int x, y; + const int rr = r * r; + for (y = -r; y <= r; y++) { + for (x = -r; x <= r; x++) { + if (x * x + y * y > rr) continue; + fill_rect(dst, cx + x, cy + y, 1, 1, col); + } + } +} + +static void draw_line(uint32_t *dst, int x0, int y0, int x1, int y1, + int thickness, uint32_t col) +{ + int dx = x1 > x0 ? x1 - x0 : x0 - x1; + int sx = x0 < x1 ? 1 : -1; + int dy = y1 > y0 ? y0 - y1 : y1 - y0; + int sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + int inset = thickness / 2; + + for (;;) { + fill_rect(dst, x0 - inset, y0 - inset, thickness, thickness, col); + if (x0 == x1 && y0 == y1) + break; + { + int e2 = err * 2; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + } +} + +static void draw_psx_button(uint32_t *dst, int x, int y, char kind) +{ + switch (kind) { + case 'x': + draw_line(dst, x + 4, y + 4, x + 14, y + 14, 2, 0xFF5FA8FFu); + draw_line(dst, x + 14, y + 4, x + 4, y + 14, 2, 0xFF5FA8FFu); + break; + case 's': + stroke_rect(dst, x + 3, y + 3, 13, 13, 0xFFFF7EB6u); + stroke_rect(dst, x + 4, y + 4, 11, 11, 0xFFFF7EB6u); + break; + case 'o': + fill_disc(dst, x + 9, y + 9, 8, 0xFFFF6B6Bu); + fill_disc(dst, x + 9, y + 9, 5, 0xFF171B25u); + break; + default: + fill_rect(dst, x + 7, y + 2, 5, 15, 0xFFD7DCE6u); + fill_rect(dst, x + 2, y + 7, 15, 5, 0xFFD7DCE6u); + break; + } +} + static void draw_char(uint32_t *dst, int x0, int y0, char c, uint32_t col, int scale) { @@ -203,15 +265,27 @@ static void rasterize_panel(void) SSM_THUMB_W, SSM_THUMB_H, 0xFF242A35u); stroke_rect(s_panel, SSM_ROWS_X + 118, y + 3, SSM_THUMB_W, SSM_THUMB_H, 0xFF3A4352u); - draw_text(s_panel, SSM_ROWS_X + 180, y + 58, "NEW", + draw_text(s_panel, SSM_ROWS_X + 169, y + 46, "NEW", 0xFF707887u, 1); } format_slot_status(i, buf, sizeof(buf)); - draw_text(s_panel, SSM_ROWS_X + 314, y + 50, buf, sub, 1); + draw_text(s_panel, SSM_ROWS_X + 278, y + 42, buf, sub, 1); if (sel) - draw_text(s_panel, SSM_ROWS_X + SSM_ROWS_W - 88, y + 100, + draw_text(s_panel, SSM_ROWS_X + SSM_ROWS_W - 88, y + 82, "SELECT", 0xFFFFD24Du, 1); } + + fill_rect(s_panel, 0, 410, SSM_W, 70, 0xFF171B25u); + draw_psx_button(s_panel, 32, 424, 'd'); + draw_text(s_panel, 56, 428, "SLOT", 0xFFE2E5EBu, 1); + draw_psx_button(s_panel, 132, 424, 'x'); + draw_text(s_panel, 156, 428, "LOAD", 0xFFE2E5EBu, 1); + draw_psx_button(s_panel, 230, 424, 's'); + draw_text(s_panel, 254, 428, "SAVE", 0xFFE2E5EBu, 1); + draw_psx_button(s_panel, 328, 424, 'o'); + draw_text(s_panel, 352, 428, "BACK", 0xFFE2E5EBu, 1); + draw_text(s_panel, 32, 454, "KEYS: ARROWS SLOT ENTER/L LOAD SHIFT+ENTER/S SAVE ESC BACK", + 0xFFB8BDC8u, 1); s_dirty = 0; } diff --git a/runtime/tests/test_fps_telemetry_opt_in.py b/runtime/tests/test_fps_telemetry_opt_in.py index a38562da..3d302172 100644 --- a/runtime/tests/test_fps_telemetry_opt_in.py +++ b/runtime/tests/test_fps_telemetry_opt_in.py @@ -12,6 +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", ) for needle in required: if needle not in main_cpp: diff --git a/runtime/tests/test_rewind_toggle_combo.py b/runtime/tests/test_rewind_toggle_combo.py index 0498a4f9..63c9c533 100644 --- a/runtime/tests/test_rewind_toggle_combo.py +++ b/runtime/tests/test_rewind_toggle_combo.py @@ -25,6 +25,8 @@ assert ( ) not in MAIN assert "HOST_KEYMAP_SAVE_STATE_MENU" in MAIN assert "key >= SDLK_F1 && key <= SDLK_F12" not in MAIN +assert "savestate_slot_from_function_key" not in MAIN +assert "savestate_submit_slot(f_slot" not in MAIN assert "savestate_input_guard_arm();" in MAIN assert "savestate_input_guard_active()" in MAIN assert "out->buttons = 0xFFFFu;" in MAIN @@ -33,5 +35,9 @@ assert "savestate_menu_ignore_toggle_release" in MAIN assert "savestate_menu_open_key" in MAIN assert "savestate_menu_toggle(key)" in MAIN assert "savestate_menu_toggle(0)" in MAIN +assert 'std::getenv("PSX_FAST_FORWARD_SPEED")' in MAIN +assert "bool manual_turbo_active = false;" in MAIN +assert "Fast forward: %dx" in MAIN +assert "g_frame_period_ms / (double)mult" in MAIN print("host shortcut guard passed") From a0b936842c0a3352916c9d733dcad60952384ad3 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Fri, 14 Aug 2026 23:16:00 +0300 Subject: [PATCH 04/25] tests: separate CPU text writes from overlay admission --- recompiler/CMakeLists.txt | 10 ++- .../tests/test_dirty_text_admission_guards.py | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 runtime/tests/test_dirty_text_admission_guards.py diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 47e978b1..c7639880 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -402,6 +402,10 @@ if(BUILD_TESTING) NAME dirty_text_continuation_guards COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_dirty_text_continuation_guards.py) + add_test( + NAME dirty_text_admission_guards + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_dirty_text_admission_guards.py) add_test( NAME bios_backend_constant_codegen COMMAND ${Python3_EXECUTABLE} @@ -471,9 +475,9 @@ if(BUILD_TESTING) hybrid_dev_any_input launcher_vulkan_option mod_controller_override - mod_load_acceleration - overlay_candidate_capacity - release_zip + mod_load_acceleration + overlay_candidate_capacity + release_zip overlay_decodable_fallback overlay_init_guard sdl3_main_single_include diff --git a/runtime/tests/test_dirty_text_admission_guards.py b/runtime/tests/test_dirty_text_admission_guards.py new file mode 100644 index 00000000..c39a5285 --- /dev/null +++ b/runtime/tests/test_dirty_text_admission_guards.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Keep CPU text writes separate from explicit overlay admission.""" + +from pathlib import Path +import argparse +import sys + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for pos in range(brace, len(source)): + if source[pos] == "{": + depth += 1 + elif source[pos] == "}": + depth -= 1 + if depth == 0: + return source[brace : pos + 1] + raise AssertionError(f"unterminated function: {signature}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, + default=Path(__file__).resolve().parents[2]) + root = parser.parse_args().root.resolve() + memory = (root / "runtime/src/memory.c").read_text(encoding="utf-8") + dma = (root / "runtime/src/dma.c").read_text(encoding="utf-8") + + text_write = function_body(memory, "static inline void text_guard_note_write(") + if "text_modified_bitmap" not in text_write: + raise AssertionError("CPU text divergence is no longer recorded") + for admission in ("dirty_ram_mark_page(", "dirty_ram_mark_executable_range("): + if admission in text_write: + raise AssertionError( + "an ordinary CPU write inside game text automatically admits " + f"executable overlay code via {admission}" + ) + + cd_slice = function_body(dma, "void dma_advance(") + read = cd_slice.index("uint32_t word = cdrom_dma_read();") + store = cd_slice.index("psx_write_word(addr, word);", read) + admit = cd_slice.index("dirty_ram_mark_executable_range(addr, 4);", store) + advance = cd_slice.index("addr = (addr + addr_step)", admit) + if not read < store < admit < advance: + raise AssertionError("CD DMA overlay admission is not coupled to each RAM word") + + mark_range = function_body(memory, "void dirty_ram_mark_executable_range(") + for fragment in ( + "dirty_ram_bitmap[page >> 5] |= (1u << (page & 31u));", + "g_dirty_ram_code_gen++;", + ): + if fragment not in mark_range: + raise AssertionError(f"explicit executable-range admission lost: {fragment}") + + print("dirty-text admission guards: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From cebe738fd17cc4c37a76729462e093ff16ee457a Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sat, 15 Aug 2026 12:15:23 +0300 Subject: [PATCH 05/25] runtime: add SBI subchannel replacements for LibCrypt --- runtime/CMakeLists.txt | 8 ++ runtime/include/iso_reader.h | 21 ++++- runtime/src/cdrom.c | 50 ++++++++--- runtime/src/iso_reader.cpp | 124 ++++++++++++++++++++++++-- runtime/src/iso_reader_c.cpp | 14 +++ runtime/tests/test_iso_reader_sbi.cpp | 72 +++++++++++++++ 6 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 runtime/tests/test_iso_reader_sbi.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 45934989..a5c185cf 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -189,6 +189,14 @@ if(BUILD_TESTING) target_link_libraries(iso_reader_cdda_test PRIVATE chdr-static) add_test(NAME iso_reader_cdda_test COMMAND iso_reader_cdda_test) + add_executable(iso_reader_sbi_test + tests/test_iso_reader_sbi.cpp + src/iso_reader.cpp + src/cue_sheet.cpp) + target_include_directories(iso_reader_sbi_test PRIVATE include) + target_link_libraries(iso_reader_sbi_test PRIVATE chdr-static) + add_test(NAME iso_reader_sbi_test COMMAND iso_reader_sbi_test) + add_executable(mod_packages_test tests/test_mod_packages.cpp src/mod_packages.cpp diff --git a/runtime/include/iso_reader.h b/runtime/include/iso_reader.h index ada6db0c..7111c0fb 100644 --- a/runtime/include/iso_reader.h +++ b/runtime/include/iso_reader.h @@ -1,9 +1,11 @@ #pragma once -#include -#include +#include +#include +#include #include #include +#include #include /** @@ -108,7 +110,15 @@ public: * @param buffer Buffer to read into (must be at least 2352 bytes) * @return true if the image stores raw sectors and read succeeded */ - bool ReadRawSector(uint32_t lba, uint8_t* buffer); + bool ReadRawSector(uint32_t lba, uint8_t* buffer); + + /** + * Read generated or SBI-replacement subchannel Q for a disc LBA. + * SBI records intentionally carry invalid CRC data; valid is false for + * those sectors so the CD controller can retain its last valid Q packet. + */ + bool ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) const; + bool HasSubChannelReplacements() const { return !subq_replacements_.empty(); } /** * Check if a file is currently open @@ -213,7 +223,9 @@ private: * Helper: find the BinSegment containing a disc-relative LBA. * @return segment pointer, or nullptr when lba is past the disc end */ - BinSegment* SegmentForLBA(uint32_t lba); + BinSegment* SegmentForLBA(uint32_t lba); + + bool LoadSBICompanion(const std::string& image_path); bool is_open_; std::string volume_id_; @@ -222,6 +234,7 @@ private: std::vector tracks_; // from the .cue TOC; >=1 entry after Open() std::vector segments_; // cue FILE entries in disc order; >=1 after Open() std::unique_ptr chd_; // present only for a directly mounted CHD + std::unordered_map> subq_replacements_; }; } // namespace PS1 diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index b783ecd5..22a7d57d 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -36,6 +36,9 @@ extern void* iso_open(const char* path); extern int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size); extern int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size); +extern int iso_read_subq(void* handle, uint32_t lba, uint8_t* buffer, int size, + int* valid); +extern int iso_has_subq_replacements(void* handle); extern uint32_t iso_sector_count(void* handle); extern void iso_close(void* handle); /* Multi-track TOC accessors (CD-DA / multi-track discs). track is 1-based. */ @@ -684,6 +687,18 @@ static void exec_command(uint8_t cmd); /* ISO reader */ static void* iso_handle = NULL; +static uint8_t last_valid_subq[12]; +static int last_valid_subq_available; +static int subq_replacements_active; + +static void update_last_valid_subq(uint32_t lba) { + uint8_t subq[12]; + int valid = 0; + if (iso_read_subq(iso_handle, lba, subq, sizeof(subq), &valid) && valid) { + memcpy(last_valid_subq, subq, sizeof(last_valid_subq)); + last_valid_subq_available = 1; + } +} static CDROMTraceEntry cdrom_trace[CDROM_TRACE_CAP]; static uint64_t cdrom_trace_seq; @@ -1316,6 +1331,7 @@ static int read_sector_at(int min, int sec, int sect) { } else if (!iso_read_sector(iso_handle, lba, user_data, SECTOR_SIZE)) { memset(user_data, 0, sizeof(user_data)); } + if (subq_replacements_active) update_last_valid_subq((uint32_t)lba); } else { memset(user_data, 0, sizeof(user_data)); } @@ -2013,18 +2029,23 @@ static void exec_command(uint8_t cmd) { } else { lba = msf_to_lba(seek_min, seek_sec, seek_sect); } - int rm, rs, rf; - int am, as, af; - lba_to_msf(lba - track_lba, 0, &rm, &rs, &rf); - lba_to_msf(lba, 150, &am, &as, &af); - response_push(bin_to_bcd(track)); - response_push(0x01); - response_push(bin_to_bcd(rm)); - response_push(bin_to_bcd(rs)); - response_push(bin_to_bcd(rf)); - response_push(bin_to_bcd(am)); - response_push(bin_to_bcd(as)); - response_push(bin_to_bcd(af)); + if (subq_replacements_active) update_last_valid_subq((uint32_t)lba); + if (subq_replacements_active && last_valid_subq_available) { + for (int i = 1; i <= 8; ++i) response_push(last_valid_subq[i]); + } else { + int rm, rs, rf; + int am, as, af; + lba_to_msf(lba - track_lba, 0, &rm, &rs, &rf); + lba_to_msf(lba, 150, &am, &as, &af); + response_push(bin_to_bcd(track)); + response_push(0x01); + response_push(bin_to_bcd(rm)); + response_push(bin_to_bcd(rs)); + response_push(bin_to_bcd(rf)); + response_push(bin_to_bcd(am)); + response_push(bin_to_bcd(as)); + response_push(bin_to_bcd(af)); + } set_irq(CDIRQ_ACK); break; } @@ -2478,6 +2499,9 @@ void cdrom_init(const char* cue_path) { iso_handle = NULL; } iso_handle = iso_open(cue_path); + last_valid_subq_available = 0; + subq_replacements_active = iso_has_subq_replacements(iso_handle); + if (subq_replacements_active) update_last_valid_subq(0); } stat_reg = has_disc() ? CDSTAT_MOTOR : CDSTAT_SHELL; @@ -2924,6 +2948,7 @@ static int cdrom_snap_emit(PstW *w) { WB(response_fifo); WI(response_read); WI(response_count); WB(sector_buffer); WI(sector_read_pos); WI(sector_available); WI(sector_size); WB(last_sector_buffer); WI(last_sector_lba); WI(last_sector_size); + WB(last_valid_subq); WI(last_valid_subq_available); /* last_sector_frame is host s_frame_count — zero on the wire so netplay * digests/pins are not forked by present-skip / FPS skew. */ WU(0u); W8(last_sector_mode); W8(last_sector_have_raw); @@ -2971,6 +2996,7 @@ static int cdrom_snap_parse(PstR *r) { RB(response_fifo); RI(response_read); RI(response_count); RB(sector_buffer); RI(sector_read_pos); RI(sector_available); RI(sector_size); RB(last_sector_buffer); RI(last_sector_lba); RI(last_sector_size); + RB(last_valid_subq); RI(last_valid_subq_available); RU(last_sector_frame); R8(last_sector_mode); R8(last_sector_have_raw); R8(last_sector_raw_mode); R8(last_sector_xa_file); R8(last_sector_xa_channel); R8(last_sector_xa_submode); R8(last_sector_xa_coding); diff --git a/runtime/src/iso_reader.cpp b/runtime/src/iso_reader.cpp index 4a76158c..c4774bb1 100644 --- a/runtime/src/iso_reader.cpp +++ b/runtime/src/iso_reader.cpp @@ -22,6 +22,33 @@ constexpr size_t RAW_DATA_OFFSET = 24; // Primary Volume Descriptor location constexpr uint32_t PVD_SECTOR = 16; +constexpr uint32_t CD_FRAMES_PER_SECOND = 75; +constexpr uint32_t CD_FRAMES_PER_MINUTE = 60 * CD_FRAMES_PER_SECOND; +constexpr uint32_t CD_LEAD_IN_FRAMES = 2 * CD_FRAMES_PER_SECOND; + +static bool valid_bcd(uint8_t value) { + return (value & 0x0f) <= 9 && (value >> 4) <= 9; +} + +static uint32_t bcd_to_binary(uint8_t value) { + return (value >> 4) * 10u + (value & 0x0f); +} + +static uint8_t binary_to_bcd(uint32_t value) { + return static_cast(((value / 10u) << 4) | (value % 10u)); +} + +static uint16_t subq_crc(const uint8_t* data) { + uint16_t crc = 0; + for (int i = 0; i < 10; ++i) { + crc ^= static_cast(data[i]) << 8; + for (int bit = 0; bit < 8; ++bit) + crc = (crc & 0x8000) ? static_cast((crc << 1) ^ 0x1021) + : static_cast(crc << 1); + } + return static_cast(~crc); +} + enum class CHDTrackMode { Mode1, Mode1Raw, @@ -236,6 +263,10 @@ bool ISOReader::Open(const std::string& filename) { root_dir_.lba = 0; root_dir_.size = 0; } + if (!LoadSBICompanion(filename)) { + Close(); + return false; + } return true; } @@ -331,14 +362,19 @@ bool ISOReader::Open(const std::string& filename) { // Parse the volume descriptor to extract filesystem metadata when // present. Runtime CD-ROM access only needs sector reads, so keep the // image mounted even if an ISO9660 header is missing or nonstandard. - if (!ParseVolumeDescriptor()) { + if (!ParseVolumeDescriptor()) { volume_id_.clear(); root_dir_.lba = 0; root_dir_.size = 0; - } - - return true; -} + } + + if (!LoadSBICompanion(filename)) { + Close(); + return false; + } + + return true; +} void ISOReader::Close() { chd_.reset(); @@ -348,13 +384,87 @@ void ISOReader::Close() { } } segments_.clear(); - tracks_.clear(); + tracks_.clear(); + subq_replacements_.clear(); bin_path_.clear(); volume_id_.clear(); root_dir_.lba = 0; root_dir_.size = 0; is_open_ = false; -} +} + +bool ISOReader::LoadSBICompanion(const std::string& image_path) { + std::filesystem::path path(image_path); + path.replace_extension(".sbi"); + if (!std::filesystem::exists(path)) return true; + + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) return false; + char header[4] = {}; + file.read(header, sizeof(header)); + if (file.gcount() != sizeof(header) || + std::memcmp(header, "SBI\0", sizeof(header)) != 0) { + return false; + } + + while (true) { + uint8_t record[14] = {}; + file.read(reinterpret_cast(record), sizeof(record)); + if (file.gcount() == 0 && file.eof()) break; + if (file.gcount() != sizeof(record) || record[3] != 1 || + !valid_bcd(record[0]) || !valid_bcd(record[1]) || + !valid_bcd(record[2])) { + return false; + } + const uint32_t absolute_frame = + bcd_to_binary(record[0]) * CD_FRAMES_PER_MINUTE + + bcd_to_binary(record[1]) * CD_FRAMES_PER_SECOND + + bcd_to_binary(record[2]); + if (absolute_frame < CD_LEAD_IN_FRAMES) return false; + const uint32_t lba = absolute_frame - CD_LEAD_IN_FRAMES; + std::array subq = {}; + std::memcpy(subq.data(), record + 4, 10); + const uint16_t invalid_crc = static_cast(subq_crc(subq.data()) ^ 0xffff); + subq[10] = static_cast(invalid_crc); + subq[11] = static_cast(invalid_crc >> 8); + if (!subq_replacements_.emplace(lba, subq).second) return false; + } + return !subq_replacements_.empty(); +} + +bool ISOReader::ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) const { + if (!buffer || !valid || !is_open_) return false; + const auto replacement = subq_replacements_.find(lba); + if (replacement != subq_replacements_.end()) { + std::memcpy(buffer, replacement->second.data(), replacement->second.size()); + *valid = false; + return true; + } + + int track = 1; + for (const CDTrack& candidate : tracks_) { + if (candidate.start_lba > lba) break; + track = candidate.number; + } + const uint32_t track_lba = TrackStartLBA(track); + const uint32_t relative = lba >= track_lba ? lba - track_lba : 0; + const uint32_t absolute = lba + 150; + std::memset(buffer, 0, 12); + buffer[0] = TrackIsAudio(track) ? 0x01 : 0x41; + buffer[1] = binary_to_bcd(static_cast(track)); + buffer[2] = 0x01; + buffer[3] = binary_to_bcd(relative / CD_FRAMES_PER_MINUTE); + buffer[4] = binary_to_bcd((relative / CD_FRAMES_PER_SECOND) % 60); + buffer[5] = binary_to_bcd(relative % CD_FRAMES_PER_SECOND); + buffer[6] = binary_to_bcd(absolute / CD_FRAMES_PER_MINUTE); + buffer[7] = binary_to_bcd((absolute / CD_FRAMES_PER_SECOND) % 60); + buffer[8] = binary_to_bcd(absolute % CD_FRAMES_PER_SECOND); + const uint16_t crc = subq_crc(buffer); + buffer[10] = static_cast(crc); + buffer[11] = static_cast(crc >> 8); + *valid = true; + return true; +} BinSegment* ISOReader::SegmentForLBA(uint32_t lba) { // Linear scan: images carry a handful of segments (one per track file). diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp index 13c0beb2..0dc8e173 100644 --- a/runtime/src/iso_reader_c.cpp +++ b/runtime/src/iso_reader_c.cpp @@ -36,6 +36,20 @@ int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { return 1; } +int iso_read_subq(void* handle, uint32_t lba, uint8_t* buffer, int size, + int* valid) { + if (!handle || !buffer || size < 12 || !valid) return 0; + bool crc_valid = false; + if (!static_cast(handle)->ReadSubChannelQ( + lba, buffer, &crc_valid)) return 0; + *valid = crc_valid ? 1 : 0; + return 1; +} + +int iso_has_subq_replacements(void* handle) { + return handle && static_cast(handle)->HasSubChannelReplacements(); +} + uint32_t iso_sector_count(void* handle) { if (!handle) return 0; auto* reader = static_cast(handle); diff --git a/runtime/tests/test_iso_reader_sbi.cpp b/runtime/tests/test_iso_reader_sbi.cpp new file mode 100644 index 00000000..f8dcc6d5 --- /dev/null +++ b/runtime/tests/test_iso_reader_sbi.cpp @@ -0,0 +1,72 @@ +#include "iso_reader.h" + +#include +#include +#include +#include +#include +#include + +static int failures; + +static void check(bool condition, const char* message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } +} + +static void write_file(const std::filesystem::path& path, + const std::vector& bytes) { + std::ofstream out(path, std::ios::binary); + out.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +int main() { + const auto root = std::filesystem::current_path() / "iso_reader_sbi_scratch"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + const auto image = root / "protected.bin"; + write_file(image, std::vector(2352 * 20)); + + const std::array replacement = { + 0x41, 0x01, 0x01, 0x23, 0x06, 0x05, 0x00, 0x03, 0x08, 0x01}; + // SBI positions are absolute MSF. 00:02:00 is disc LBA zero after the + // standard 150-frame lead-in. + std::vector sbi = {'S', 'B', 'I', 0, 0x00, 0x02, 0x00, 0x01}; + sbi.insert(sbi.end(), replacement.begin(), replacement.end()); + write_file(root / "protected.sbi", sbi); + + PS1::ISOReader reader; + check(reader.Open(image.string()), "valid same-basename SBI mounts"); + std::array subq = {}; + bool valid = true; + check(reader.ReadSubChannelQ(0, subq.data(), &valid), + "replacement sub-Q can be read"); + check(!valid, "SBI replacement is reported with intentionally invalid CRC"); + check(std::equal(replacement.begin(), replacement.end(), subq.begin()), + "SBI replacement payload is preserved"); + + check(reader.ReadSubChannelQ(1, subq.data(), &valid), + "ordinary sub-Q can be generated"); + check(valid, "ordinary generated sub-Q has valid CRC"); + check(subq[0] == 0x41 && subq[1] == 0x01 && subq[2] == 0x01, + "generated data-track identity is correct"); + check(subq[6] == 0x00 && subq[7] == 0x02 && subq[8] == 0x01, + "generated absolute position includes the 150-sector lead-in"); + reader.Close(); + + write_file(root / "protected.sbi", {'B', 'A', 'D'}); + check(!reader.Open(image.string()), "malformed SBI fails the mount closed"); + + std::filesystem::remove(root / "protected.sbi"); + check(reader.Open(image.string()), "image without SBI still mounts"); + check(!reader.HasSubChannelReplacements(), + "image without SBI keeps the compatibility path inactive"); + check(reader.ReadSubChannelQ(0, subq.data(), &valid) && valid, + "image without SBI generates valid sub-Q"); + reader.Close(); + std::filesystem::remove_all(root); + return failures ? 1 : 0; +} From 992f5b0bf07f52e334dfa813f3a532845e6b5b1d Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sat, 15 Aug 2026 12:19:16 +0300 Subject: [PATCH 06/25] build: keep SDL3 main implementation in one translation unit --- runtime/include/psx_sdl.h | 1 - runtime/src/main.cpp | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/runtime/include/psx_sdl.h b/runtime/include/psx_sdl.h index ef1ceb70..1749b46f 100644 --- a/runtime/include/psx_sdl.h +++ b/runtime/include/psx_sdl.h @@ -11,7 +11,6 @@ #define SDL_ENABLE_OLD_NAMES #define SDL_FUNCTION_POINTER_IS_VOID_POINTER #include -#include #ifndef SDL_WINDOW_SHOWN #define SDL_WINDOW_SHOWN 0 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9117b597..460b184e 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -89,6 +89,14 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #endif #endif #include "psx_sdl.h" +#if defined(PSX_SDL3) +/* + * SDL_main.h is a single-header implementation in SDL3. Keep it in the one + * translation unit that defines main(); including it through psx_sdl.h makes + * every SDL-using source emit WinMain under MinGW. + */ +#include +#endif #include "psx_sdl_audio.h" #if defined(PSX_WEB) #include From 81431498aa43c3dff25e5526d1510d359febc306 Mon Sep 17 00:00:00 2001 From: LarsDuJour <42436050+LarsDuJour@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:13:30 +0200 Subject: [PATCH 07/25] Update setup_project.ps1 Changed various ANSI characters to UTF-8 equivalents. This solves a blocker on systems with some languages, preventing using this script. --- tools/new_project_layout/setup_project.ps1 | 92 +++++++++++----------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 2b3db0e4..2c0a7a1f 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -1,6 +1,6 @@ # New Project Layout scaffolding (Windows). # -# Path params (pass on the CLI — tab-complete in your shell): +# Path params (pass on the CLI -- tab-complete in your shell): # -Disc REQUIRED legal Redump .cue (kept on your dump drive) # -Dir Parent directory (default: .) # -Bios Optional retail BIOS for Generate @@ -10,7 +10,7 @@ # Other options are prompted when interactive, or set via switches / -Yes. # # Usage: -# powershell -File setup_project.ps1 -Disc C:\dumps\game.cue +# powershell -File setup_project.ps1 -Disc C:\dumps\game.cue t # powershell -File setup_project.ps1 -Disc game.cue -Name Foo -Yes # powershell -File setup_project.ps1 -Disc game.cue -StageDisc @@ -129,7 +129,7 @@ if ($Players -eq 0) { } } if ($Players -lt 1 -or $Players -gt 8) { - throw "Players must be 1–8 (got: $Players)" + throw "Players must be 1-8 (got: $Players)" } $env:PYTHONPATH = "$ScriptDir" + $(if ($env:PYTHONPATH) { ";$env:PYTHONPATH" } else { "" }) @@ -171,21 +171,21 @@ $useWizard = $false $useNetplay = $false if (-not $useRecompUi) { if ($EnableWizard -or $EnableNetplay) { - Write-Warning "Wizard/netplay require recomp-ui — forcing both off." + Write-Warning "Wizard/netplay require recomp-ui -- forcing both off." } - Write-Host " (recomp-ui declined — skipping wizard/netplay; PSX_RECOMP_UI=OFF)" + Write-Host " (recomp-ui declined -- skipping wizard/netplay; PSX_RECOMP_UI=OFF)" } else { # Default ON (interactive + non-interactive): setup-host CI needs the wizard. $useWizard = Resolve-BoolOpt -Enable:$EnableWizard -No:$NoWizard -PromptDefault:$true ` -Question "Enable first-run setup wizard + Generate & rebuild?" if (-not $interactive -and -not $NoWizard) { $useWizard = $true } - # 1-player titles cannot use multiplayer netplay — skip prompts entirely. + # 1-player titles cannot use multiplayer netplay -- skip prompts entirely. if ($Players -eq 1) { if ($EnableNetplay) { Write-Warning "-EnableNetplay ignored for 1-player title." } $useNetplay = $false - Write-Host " (1-player title — netplay skipped)" + Write-Host " (1-player title -- netplay skipped)" } else { $useNetplay = Resolve-BoolOpt -Enable:$EnableNetplay -No:$NoNetplay -PromptDefault:$false ` -Question "Enable netplay UI (needs nested recomp-net)?" @@ -196,7 +196,7 @@ $NetplayLobbyWs = "" if ($useNetplay) { if (-not $LobbyUrl) { if ($interactive) { - $LobbyUrl = Prompt-Line "Netplay lobby server URL (host or ws://…)" $DefaultLobbyHost + $LobbyUrl = Prompt-Line "Netplay lobby server URL (host or ws://...)" $DefaultLobbyHost } else { $LobbyUrl = $DefaultLobbyHost } @@ -216,7 +216,7 @@ if ($doGenerate) { $doBuild = Resolve-BoolOpt -Enable:$EnableBuild -No:$NoBuild -PromptDefault:$true ` -Question "Configure & build psx-runtime after Generate?" } elseif ($EnableBuild) { - Write-Warning "-EnableBuild requires Generate — enabling Generate" + Write-Warning "-EnableBuild requires Generate -- enabling Generate" $doGenerate = $true $doBuild = $true } @@ -242,8 +242,8 @@ $GameName = $GameName.Trim() if (-not $GameName) { $GameName = $Name } $DiscHint = "your legally owned $GameName disc" $EntryPc = "0x80010000" -$PublisherDisp = if ($Publisher) { $Publisher } else { "—" } -$YearDisp = if ($Year) { $Year } else { "—" } +$PublisherDisp = if ($Publisher) { $Publisher } else { "-" } +$YearDisp = if ($Year) { $Year } else { "-" } $DescriptionMd = if ($Description) { $Description } else { "_Add a short pitch in catalog_identity.json / README._" } $Root = Join-Path (Resolve-Path $Dir) $Name @@ -257,13 +257,13 @@ $CodegenArgFile = Join-Path $env:TEMP "psxrecomp_codegen_arg.cmake" if ($useRecompUi) { Set-Content -Encoding UTF8 -Path $RecompUiBlockFile -Value "# recomp-ui submodule present (PSX_RECOMP_UI defaults ON)." - Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value ' CODEGEN_SETUP_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/codegen_setup.c"' + Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value " # CODEGEN_SETUP_SOURCES -- add with recomp-ui / wizard later" } else { Set-Content -Encoding UTF8 -Path $RecompUiBlockFile -Value @" set(PSX_RECOMP_UI OFF CACHE BOOL "No recomp-ui submodule in this scaffold" FORCE) "@ - Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value " # CODEGEN_SETUP_SOURCES — add with recomp-ui / wizard later" + Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value " # CODEGEN_SETUP_SOURCES -- add with recomp-ui / wizard later" } if ($useNetplay) { @@ -279,9 +279,9 @@ endif() $NetplayLobbyUrlArg = " NETPLAY_LOBBY_URL `"$NetplayLobbyWs`"" } else { Set-Content -Encoding UTF8 -Path $NetplayBlockFile -Value @" -# Full netplay UI — enable after adding recomp-ui + testing: +# Full netplay UI -- enable after adding recomp-ui + testing: # if(EXISTS "`${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt") -# set(PSX_NETPLAY ON CACHE BOOL "…" FORCE) +# set(PSX_NETPLAY ON CACHE BOOL "..." FORCE) # endif() "@ $NetplayRuntimeArg = " # ENABLE_NETPLAY_IF_PRESENT" @@ -296,8 +296,8 @@ set(PSX_SETUP_WIZARD ON CACHE BOOL $WizardRuntimeArg = " ENABLE_SETUP_WIZARD" } else { Set-Content -Encoding UTF8 -Path $WizardBlockFile -Value @" -# First-run setup wizard — enable after testing: -# set(PSX_SETUP_WIZARD ON CACHE BOOL "…" FORCE) +# First-run setup wizard -- enable after testing: +# set(PSX_SETUP_WIZARD ON CACHE BOOL "..." FORCE) "@ $WizardRuntimeArg = " # ENABLE_SETUP_WIZARD" } @@ -370,7 +370,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $Root "scripts") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "tools") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "mods\preloaded\packages") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "assets") | Out-Null -# Empty mod catalog tree (runtime copies mods/preloaded → beside the exe as mods/). +# Empty mod catalog tree (runtime copies mods/preloaded -> beside the exe as mods/). @' # Preloaded mods @@ -379,7 +379,7 @@ Ship reviewed, default-disabled packages here: ```text packages/// manifest.toml - … + ... ``` Build wiring copies `mods/preloaded` next to the game executable as `mods/`. @@ -456,7 +456,7 @@ if ($useCi) { --set "WINDOW_TITLE=$WindowTitle" Write-Host " wrote .github/workflows/release.yml" } else { - Write-Warning "Missing $WfSrc — skipped CI workflow copy" + Write-Warning "Missing $WfSrc -- skipped CI workflow copy" } } else { Write-Host "== CI workflow skipped ==" @@ -494,7 +494,7 @@ for m in re.finditer(r'FILE\s+"([^"]+)"', text, re.I): print(f" copied track: {src.name}") else: print(f" warning: cue FILE missing: {src}", file=sys.stderr) -print(" disc staged under disc/ (gitignored — never commit dumps)") +print(" disc staged under disc/ (gitignored -- never commit dumps)") '@ $pyFile = Join-Path $env:TEMP "psxrecomp_stage_disc.py" Set-Content -Encoding UTF8 -Path $pyFile -Value $py @@ -546,19 +546,19 @@ print(m.group(1) if m else '') $BootExe = $BootFromToml Fill-Template (Join-Path $TemplateDir "CMakeLists.txt.in") (Join-Path $Root "CMakeLists.txt") Fill-Template (Join-Path $TemplateDir "codegen_setup.c.in") (Join-Path $Root "codegen_setup.c") - Write-Host " synced boot EXE → $BootExe (CMake / codegen)" + Write-Host " synced boot EXE -> $BootExe (CMake / codegen)" } if ($EntryFromToml) { $EntryPc = $EntryFromToml Fill-Template (Join-Path $TemplateDir "symbols.toml.in") (Join-Path $Root "symbols.toml") - Write-Host " symbols.toml BootEntry → $EntryPc" + Write-Host " symbols.toml BootEntry -> $EntryPc" } if (-not $StageDisc) { - Write-Host " game.toml disc → $DiscTomlPath" - Write-Host " (local absolute path — do not commit machine-specific dumps)" + Write-Host " game.toml disc -> $DiscTomlPath" + Write-Host " (local absolute path -- do not commit machine-specific dumps)" } } catch { - Write-Warning "Disc probe failed — left template game.toml; fill by hand." + Write-Warning "Disc probe failed -- left template game.toml; fill by hand." } } else { Write-Warning "-Disc is not a .cue; skipped probe autofill." @@ -589,17 +589,17 @@ try { commit -q -m "Initial New Project Layout scaffold" $committed = $true } catch { - Write-Host " (skipped initial commit — commit manually when ready)" + Write-Host " (skipped initial commit -- commit manually when ready)" } # Create the GitHub repo + origin now; push deferred until after generate/build. $githubCreated = $false if ($createGithub) { - Write-Host "== GitHub repo (gh, create only — push deferred) ==" + Write-Host "== GitHub repo (gh, create only -- push deferred) ==" if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - Write-Warning "gh not installed — skip create; push manually later." + Write-Warning "gh not installed -- skip create; push manually later." } elseif (-not $committed) { - Write-Warning "no initial commit — skip gh repo create." + Write-Warning "no initial commit -- skip gh repo create." } else { $vis = switch ($GithubVisibility) { "public" { "--public" } @@ -611,13 +611,13 @@ if ($createGithub) { Write-Host " created origin ($GithubVisibility); push deferred until end" $githubCreated = $true } catch { - Write-Warning "gh repo create failed — if the repo already exists, add origin and push at the end." + Write-Warning "gh repo create failed -- if the repo already exists, add origin and push at the end." if (-not (git remote get-url origin 2>$null)) { try { $ghUrl = (gh repo view $Name --json url -q .url 2>$null) if ($ghUrl) { git remote add origin $ghUrl - Write-Host " attached existing origin → $ghUrl" + Write-Host " attached existing origin -> $ghUrl" $githubCreated = $true } } catch {} @@ -647,9 +647,9 @@ if ($doGenerate) { } python @genArgs $GeneratedOk = $true - Write-Host " generate OK (generated/ is gitignored — not committed)" + Write-Host " generate OK (generated/ is gitignored -- not committed)" } catch { - Write-Warning "Generate failed — fix seeds/disc and re-run generate by hand." + Write-Warning "Generate failed -- fix seeds/disc and re-run generate by hand." $doBuild = $false } finally { Pop-Location @@ -663,9 +663,9 @@ if ($doBuild -and $GeneratedOk) { cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build-release --target psx-runtime $BuildOk = $true - Write-Host " build OK → build-release/ (gitignored)" + Write-Host " build OK -> build-release/ (gitignored)" } catch { - Write-Warning "Build failed — fix and re-run cmake by hand." + Write-Warning "Build failed -- fix and re-run cmake by hand." } finally { Pop-Location } @@ -700,7 +700,7 @@ function Ensure-ActionsRegistersReleaseYml([string]$OwnerRepo) { if ($i -lt 4) { Start-Sleep -Seconds 2 } } - Write-Host " Actions has not listed release.yml yet — nudging with a follow-up commit…" + Write-Host " Actions has not listed release.yml yet -- nudging with a follow-up commit..." $stamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mmZ") Add-Content -Encoding utf8 -Path $wfPath -Value "`n# Actions registration nudge ($stamp UTC)" git add $wfPath 2>$null @@ -732,7 +732,7 @@ if ($createGithub -and (git remote get-url origin 2>$null)) { try { git push -u origin HEAD $githubPushed = $true - Write-Host " pushed $(git rev-parse --abbrev-ref HEAD) → origin" + Write-Host " pushed $(git rev-parse --abbrev-ref HEAD) -> origin" if ($useCi -and (Test-Path (Join-Path $Root ".github\workflows\release.yml"))) { try { $ownerRepo = (gh repo view --json nameWithOwner -q .nameWithOwner 2>$null) @@ -740,7 +740,7 @@ if ($createGithub -and (git remote get-url origin 2>$null)) { } catch {} } } catch { - Write-Warning "git push failed — fetch/rebase or force-push manually if origin already has a different initial commit." + Write-Warning "git push failed -- fetch/rebase or force-push manually if origin already has a different initial commit." } } } @@ -751,7 +751,7 @@ $Step1 = if ($Probed) { "Edit game.toml / re-run probe_disc.py --write-seeds." } $Step2 = if ($BuildOk) { - "Runtime already built under build-release/ — soak offline boot." + "Runtime already built under build-release/ -- soak offline boot." } elseif ($GeneratedOk) { @" Build playable runtime: @@ -771,15 +771,15 @@ Build emitters, Generate, then playable runtime: } $CiNote = if ($useCi) { $n = "CI: .github/workflows/release.yml ready (zip prefix=$ZipPrefix; submodule gitlinks pin the build)." - if ($githubPushed) { $n += " Pushed — open Actions → Release builds (workflow_dispatch)." } + if ($githubPushed) { $n += " Pushed -- open Actions -> Release builds (workflow_dispatch)." } $n } else { "CI workflow not installed (declined)." } $GhNote = if ($createGithub) { if ($githubPushed) { "GitHub: created/attached origin and pushed ($GithubVisibility)." } - elseif ($githubCreated) { "GitHub: origin ready but push failed — see warnings above." } - else { "GitHub: create/push did not complete — check gh auth / remote." } + elseif ($githubCreated) { "GitHub: origin ready but push failed -- see warnings above." } + else { "GitHub: create/push did not complete -- check gh auth / remote." } } else { "GitHub remote not created (declined)." } @@ -791,11 +791,11 @@ Write-Host @" Next steps: 1. $Step1 2. $Step2 - 3. Label symbols in symbols.toml → python tools/sync_symbols.py - 4. Soak offline boot → netplay QA (if enabled) → tag vX.Y.Z. + 3. Label symbols in symbols.toml -> python tools/sync_symbols.py + 4. Soak offline boot -> netplay QA (if enabled) -> tag vX.Y.Z. 5. $CiNote Pins: submodule gitlinks (framework_pins.txt is an optional snapshot) 6. $GhNote -Docs: psxrecomp\docs\GAME_PROJECT_SETUP.md · psxrecomp\docs\SYMBOLS.md +Docs: psxrecomp\docs\GAME_PROJECT_SETUP.md ; psxrecomp\docs\SYMBOLS.md "@ From 17eddc0f1dd45d0ecefae37963d49b5adc405c2f Mon Sep 17 00:00:00 2001 From: LarsDuJour <42436050+LarsDuJour@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:17:33 +0200 Subject: [PATCH 08/25] Update setup_project.ps1 --- tools/new_project_layout/setup_project.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 2c0a7a1f..94c9acbb 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -10,7 +10,7 @@ # Other options are prompted when interactive, or set via switches / -Yes. # # Usage: -# powershell -File setup_project.ps1 -Disc C:\dumps\game.cue t +# powershell -File setup_project.ps1 -Disc C:\dumps\game.cue # powershell -File setup_project.ps1 -Disc game.cue -Name Foo -Yes # powershell -File setup_project.ps1 -Disc game.cue -StageDisc From 0fb110939c14b0256147f1ae57b030e3d612e76c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 12:39:17 -0700 Subject: [PATCH 09/25] Fix setup_project.ps1 codegen source wiring --- tools/new_project_layout/setup_project.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 94c9acbb..f37edaf5 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -257,7 +257,7 @@ $CodegenArgFile = Join-Path $env:TEMP "psxrecomp_codegen_arg.cmake" if ($useRecompUi) { Set-Content -Encoding UTF8 -Path $RecompUiBlockFile -Value "# recomp-ui submodule present (PSX_RECOMP_UI defaults ON)." - Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value " # CODEGEN_SETUP_SOURCES -- add with recomp-ui / wizard later" + Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value ' CODEGEN_SETUP_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/codegen_setup.c"' } else { Set-Content -Encoding UTF8 -Path $RecompUiBlockFile -Value @" set(PSX_RECOMP_UI OFF CACHE BOOL From adb09981fa517c0b928a5fa9e525bfd4970290b1 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 14:46:26 -0700 Subject: [PATCH 10/25] Fix bilinear sampling seams --- runtime/shaders/geo_tex.frag | 9 +++++--- runtime/src/gpu_gl_renderer.c | 6 +++-- runtime/src/gpu_sw_renderer.c | 41 +++++++++++++++++++++++++---------- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/runtime/shaders/geo_tex.frag b/runtime/shaders/geo_tex.frag index 78948905..02f961c6 100644 --- a/runtime/shaders/geo_tex.frag +++ b/runtime/shaders/geo_tex.frag @@ -66,13 +66,17 @@ void main() { } else { /* Bilinear, Beetle-PSX formulation: the NEAREST texel is the base * (cutout + STP authority), the neighbours lie toward the sub-texel - * offset and clamp to v_limits, and each texel's weight is gated by its - * opacity with the colour renormalised. */ + * offset and clamp to v_limits. Transparent neighbours are ignored and + * the colour is renormalised, but a transparent base stays cut out. + * v_uv is aligned for PS1 top-left point sampling; recenter the + * bilinear footprint so 1x rect tiles sample their own texels. */ + uv += vec2(pc.u_shift); int iu = int(floor(uv.x)), iv = int(floor(uv.y)); float fx = uv.x - float(iu) - 0.5, fy = uv.y - float(iv) - 0.5; int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1; fx = abs(fx); fy = abs(fy); int c00 = fetch_texel(iu, iv); + if (c00 == 0) discard; int c10 = fetch_texel(iu + sx, iv); int c01 = fetch_texel(iu, iv + sy); int c11 = fetch_texel(iu + sx, iv + sy); @@ -81,7 +85,6 @@ void main() { float w01 = (c01 == 0 ? 0.0 : 1.0) * (1.0 - fx) * fy; float w11 = (c11 == 0 ? 0.0 : 1.0) * fx * fy; float opac = w00 + w10 + w01 + w11; - if (opac < 0.5) discard; rgb = (col5(c00) * w00 + col5(c10) * w10 + col5(c01) * w01 + col5(c11) * w11) / opac; float stpf = (float((c00 >> 15) & 1) * w00 + float((c10 >> 15) & 1) * w10 + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index c7b77325..23351637 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -1027,12 +1027,15 @@ static const char *TEX_FS = " * offset and clamp to u_limits, and each texel's weight is gated by\n" " * its opacity with the colour renormalised — so prim edges and\n" " * cutout borders keep their colour instead of dissolving into the\n" - " * transparent (black) neighbour and discarding whole edge columns. */\n" + " * transparent (black) neighbour and discarding whole edge columns.\n" + " * Recenter from PS1 top-left point sampling to the bilinear footprint. */\n" + " uv += vec2(u_shift);\n" " int iu = int(floor(uv.x)), iv = int(floor(uv.y));\n" " float fx = uv.x - float(iu) - 0.5, fy = uv.y - float(iv) - 0.5;\n" " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" " fx = abs(fx); fy = abs(fy);\n" " int c00 = fetch_texel(iu, iv);\n" + " if (c00 == 0) discard;\n" " int c10 = fetch_texel(iu + sx, iv);\n" " int c01 = fetch_texel(iu, iv + sy);\n" " int c11 = fetch_texel(iu + sx, iv + sy);\n" @@ -1041,7 +1044,6 @@ static const char *TEX_FS = " float w01 = (c01 == 0 ? 0.0 : 1.0) * (1.0 - fx) * fy;\n" " float w11 = (c11 == 0 ? 0.0 : 1.0) * fx * fy;\n" " float opac = w00 + w10 + w01 + w11;\n" - " if (opac < 0.5) discard;\n" " rgb = (col5(c00)*w00 + col5(c10)*w10 + col5(c01)*w01 + col5(c11)*w11) / opac;\n" " float stpf = (float((c00 >> 15) & 1) * w00 + float((c10 >> 15) & 1) * w10\n" " + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac;\n" diff --git a/runtime/src/gpu_sw_renderer.c b/runtime/src/gpu_sw_renderer.c index 07f54688..ff40acee 100644 --- a/runtime/src/gpu_sw_renderer.c +++ b/runtime/src/gpu_sw_renderer.c @@ -408,6 +408,7 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage, if (fy > 128) fy = 128; uint16_t c00 = bl_fetch(iu, iv, texpage, clut_x, clut_y); + if (c00 == 0) return 0x0000; uint16_t c10 = bl_fetch(iu + su, iv, texpage, clut_x, clut_y); uint16_t c01 = bl_fetch(iu, iv + sv, texpage, clut_x, clut_y); uint16_t c11 = bl_fetch(iu + su, iv + sv, texpage, clut_x, clut_y); @@ -417,7 +418,6 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage, int w01 = (c01 ? 1 : 0) * (256 - fx) * fy; int w11 = (c11 ? 1 : 0) * fx * fy; int opac = w00 + w10 + w01 + w11; - if (opac < 32768) return 0x0000; /* opacity < 0.5: transparent */ int r = ((c00 & 0x1F) * w00 + (c10 & 0x1F) * w10 + (c01 & 0x1F) * w01 + (c11 & 0x1F) * w11) / opac; @@ -431,6 +431,11 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage, return (uint16_t)(r | (g << 5) | (b << 10) | (stp ? 0x8000 : 0)); } +static inline float bilinear_center_shift_for_target(const RTarget *t) { + int s = (t && t->s > 0) ? t->s : 1; + return 0.5f / (float)s - 1.0f / 64.0f; +} + /* uv sampling bounds (see g_uv_lim): the shared PS1 uv model in gpu_uv.h. * The software DDA truncates exactly like the PS1, so unlike the GL/VK * backends NO mirrored-2D compensation is applied here — these bounds only @@ -982,10 +987,16 @@ static void raster_textured_triangle(const RTarget *t, } } - uint16_t texel = g_texture_filter - ? texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y) - : texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, texpage, clut_x, clut_y); - put_textured(t, x, y, texel, g_mod_r, g_mod_g, g_mod_b, g_raw_texture); + if (g_texture_filter) { + float sh = bilinear_center_shift_for_target(t); + uint16_t texel = texel_fetch_bilinear(fu + sh, fv + sh, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, g_mod_r, g_mod_g, g_mod_b, g_raw_texture); + } else { + uint16_t texel = texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, g_mod_r, g_mod_g, g_mod_b, g_raw_texture); + } } } } @@ -1161,10 +1172,16 @@ static void raster_shaded_textured_triangle(const RTarget *t, if (mg < 0) mg = 0; if (mg > 31) mg = 31; if (mb < 0) mb = 0; if (mb > 31) mb = 31; - uint16_t texel = g_texture_filter - ? texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y) - : texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, texpage, clut_x, clut_y); - put_textured(t, x, y, texel, mr, mg, mb, raw_texture); + if (g_texture_filter) { + float sh = bilinear_center_shift_for_target(t); + uint16_t texel = texel_fetch_bilinear(fu + sh, fv + sh, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, mr, mg, mb, raw_texture); + } else { + uint16_t texel = texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, mr, mg, mb, raw_texture); + } } } } @@ -1300,7 +1317,8 @@ static void raster_textured_rect(const RTarget *t, int x, int y, int w, int h, uint16_t texel; if (g_texture_filter) { float fu = (float)u + (float)col / (float)s; - texel = texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y); + float sh = bilinear_center_shift_for_target(t); + texel = texel_fetch_bilinear(fu + sh, fv + sh, texpage, clut_x, clut_y); } else { int tu = (u + col / s) & 0xFF; texel = texel_fetch(tu, tv, texpage, clut_x, clut_y); @@ -1391,7 +1409,8 @@ static void raster_textured_rect_scaled(const RTarget *t, int x, int y, uint16_t texel; if (g_texture_filter) { float fu = (float)u0 + (float)du * (float)col / (float)w; - texel = texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y); + float sh = bilinear_center_shift_for_target(t); + texel = texel_fetch_bilinear(fu + sh, fv + sh, texpage, clut_x, clut_y); } else { int tu = (int)(u0 + ((int64_t)du * col) / w) & 0xFF; texel = texel_fetch(tu, tv, texpage, clut_x, clut_y); From 6bc9f1fe2942368e8498ecbd7686d1f98d20e281 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 14:52:35 -0700 Subject: [PATCH 11/25] Declare bilinear shader shift uniform --- runtime/src/gpu_gl_renderer.c | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 23351637..8b34a195 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -983,10 +983,11 @@ static const char *TEX_FS = "uniform usampler2D u_vram;\n" "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" - "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" - "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 1 = bilinear */\n" - "int vram_at(int x, int y){\n" + "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" + "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" + "uniform int u_filter; /* 1 = bilinear */\n" + "uniform float u_shift;\n" + "int vram_at(int x, int y){\n" " return int(texelFetch(u_vram, ivec2(x & 1023, y & 511), 0).r);\n" "}\n" "int fetch_texel(int u, int v){\n" From 3de7f9cb52b2e2c423ab83df0b1891fd8d5a30da Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 15:35:21 -0700 Subject: [PATCH 12/25] Bump boot-state version for SBI subchannel state --- runtime/include/boot_state.h | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 4da5e4b0..922f981d 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -38,10 +38,11 @@ extern "C" { #define BOOT_STATE_MAGIC 0x50535842u /* "PSXB" */ /* v1 = incomplete RAM-only; v2 = full machine but host-struct memcpy (padding); * v3 = little-endian field wire (portable Win/Linux/macOS ARM); - * v4 = v3 + optional zlib on large sections (section pad bit0 = compressed). */ -#define BOOT_STATE_VERSION 4u -/* Older readers reject v4; load still accepts v3 uncompressed blobs. */ -#define BOOT_STATE_VERSION_MIN_READ 3u + * v4 = v3 + optional zlib on large sections (section pad bit0 = compressed); + * v5 = v4 + CD-ROM Sub-Q replacement state. */ +#define BOOT_STATE_VERSION 5u +/* v5 intentionally breaks older savestates after the CD-ROM wire grew. */ +#define BOOT_STATE_VERSION_MIN_READ 5u /* Section pad bit0: payload is u32 LE uncompressed_len + zlib deflate bytes. */ #define BOOT_STATE_SEC_ZLIB 1u From 813829a40181ae759c4e1149e8bbd509df5db7e9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 18:20:09 -0700 Subject: [PATCH 13/25] Keep GTE gameplay HUD groups edge anchored --- runtime/src/gpu.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 99f926f6..8ebfd79b 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4343,7 +4343,16 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_ui_prepass[out++] = ws_ui_prepass[i]; } ws_ui_prepass_count = out; - ws_auto_ui_dense = ws_ui_prepass_count >= 32u; + /* + * A high final-layer primitive count is a good "dense 2D menu" signal for + * titles without an explicit gameplay detector, where grouping everything + * around the centre avoids tearing text grids apart. For GTE-gated 3D + * titles, though, reaching this path already means a gameplay frame is + * being stretched. WipEout 3's race HUD is dense enough to trip the old + * threshold, which pinned every HUD group to the 4:3 centre. Keep edge + * groups edge-anchored in those frames so the HUD adapts to the wide view. + */ + ws_auto_ui_dense = ws_ui_prepass_count >= 32u && !ws_gte_game_mode_cfg; if (ws_ui_prepass_count == 0) return; WsUiGroupItem groups[WS_UI_PREPASS_MAX]; From 2ba74f3b4b37adbeee797e8c2b50f764e9ba5e7e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 18:34:47 -0700 Subject: [PATCH 14/25] pgxp: land clean-room value-propagation engine (phases 0+1, issue #92) The G1.2/G1.3 mechanism: per-word RAM/scratchpad + per-GPR + per-GTE-reg shadows carrying sub-pixel projections BY PROVENANCE, validated against the actual guest word on every read (never guessed from rounded position - the measured 93.1%-ambiguous G1.4 dead end). Clean-room from psx-spx + public design docs; vendored duckstation (CC BY-NC-ND) and beetle-psx (GPL) trees remain black-box oracles only - no code ported. - runtime/src/pgxp.cpp + pgxp.h: engine, per-vertex GetPreciseVertex (dataflow -> ambiguity-gated position cache -> native) with truncation agreement + tolerance safeguards; stats; test accessors. - pgxp_hooks.h: 5-entry hook funnel; PGXP_*() macros no-op unless -DPSX_PGXP=1 (codegen-time gate over ONE generated tree); appended PGXPHooks* table member in OverlayCallbacks (ABI bump ships with the Phase-2 emitter change). - Both interpreters wired: loads/stores/COP2 + precision-carrying arithmetic (moves, add/sub, or-merge, 16-bit shifts, LUI, HI/LO). - gte.cpp: RTPS feeds the GTE reg shadows; retired s_precise_sxy FIFO, hashed s_precision_store, and plain-store invalidation (redundant under validate-on-read; store hot path gets 3 calls back). - gpu.c: prepare_precise_triangle rewritten per-vertex; perspective UVs share the same provenance and compose per triangle. - [video] pgxp_cpu_mode / pgxp_tolerance keys; TCP pgxp verb live- toggles geometry/texture/cpu_mode/tolerance; geom_correction census grew the pgxp dataflow counters (G1.9 gate metric). - tests: new pgxp_test (registered); gte_register_access_test updated to precise invalidation contract. 33/33 enabled tests green. Also fixed while landing (both broke a fresh master tree): - runtime.cmake: once-only psx_game_version.txt stamp (multi-target file(GENERATE) collision broke every fresh single-config configure). - test_overlay_capture_retry: SDL3 rot (SDL_SetMainReady gone, inverted SDL_Init check) -> psx_sdl_init. Tracked as beads-eio.3.48; postmortem + this landing: ENHANCEMENTS.md G1-G1.10. Co-Authored-By: Claude Fable 5 --- ENHANCEMENTS.md | 71 ++ recompiler/src/config_loader.cpp | 7 + recompiler/src/config_loader.h | 14 + runtime/CMakeLists.txt | 11 + runtime/codegen_hash_sources.cmake | 1 + runtime/include/cpu_state.h | 6 + runtime/include/gpu.h | 1 + runtime/include/overlay_api.h | 10 + runtime/include/pgxp.h | 124 +++ runtime/include/pgxp_hooks.h | 93 +++ runtime/runtime.cmake | 26 +- runtime/src/debug_server.c | 61 +- runtime/src/dirty_ram_interp.c | 118 ++- runtime/src/gpu.c | 93 ++- runtime/src/gte.cpp | 175 ++-- runtime/src/main.cpp | 7 + runtime/src/memory.c | 7 +- runtime/src/pgxp.cpp | 788 +++++++++++++++++++ runtime/src/psx_interpreter.c | 115 ++- runtime/tests/test_gte_register_access.cpp | 10 +- runtime/tests/test_overlay_capture_retry.cpp | 9 +- runtime/tests/test_pgxp.cpp | 294 +++++++ 22 files changed, 1808 insertions(+), 233 deletions(-) create mode 100644 runtime/include/pgxp.h create mode 100644 runtime/include/pgxp_hooks.h create mode 100644 runtime/src/pgxp.cpp create mode 100644 runtime/tests/test_pgxp.cpp diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index 0b5c1507..c00dabda 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -766,3 +766,74 @@ defect, which is the whole finding of G1.1–G1.8. The withdrawn row, the full post-mortem, the mechanism that would fix it, and the re-test protocol are preserved on `park/pgxp-geometry-correction` (see its G1.9). + +### G1.10 — PGXP value-propagation engine LANDED, phases 0+1 (2026-08-15) + +The G1.2/G1.3 mechanism — precision travelling WITH the data — now exists +(`feat/pgxp-dataflow`, tracked as `beads-eio.3.48`). **Clean-room**: the +vendored `duckstation/` tree is CC BY-NC-ND (NoDerivatives) and `beetle-psx/` +is GPL, both incompatible with this project's PolyForm Noncommercial license, +so NO code was ported from either; they remain black-box behavioral oracles +only. The engine implements the publicly documented technique from psx-spx + +our own G1 analysis. + +**What exists now:** + +- `runtime/src/pgxp.cpp` + `runtime/include/pgxp.h`: per-word RAM+scratchpad + shadows (~10 MB, lazily allocated, fail-closed), per-GPR (+HI/LO) and + per-GTE-data-reg shadows. Each `PGXPValue` records the sub-pixel 16.16 + screen X/Y, the projected SZ depth, per-half validity flags, and the exact + guest word it describes. **The one safety invariant: a shadow is only + believed after validating that word against reality.** Overwrite-and- + validate only — no side-effect modelling, so DMA/memcpy/untracked writers + need no hooks at all (their stale shadows fail validation at use). The + plain-store `gte_precision_invalidate_word` calls in memory.c are removed + as redundant under this model (a small store-path win). +- Hook funnel (`runtime/include/pgxp_hooks.h`): 5 entry points + (`psx_pgxp_load/store/alu/muldiv/cop2`) taking the raw instruction word. + Generated code will call them via `PGXP_*()` macros that expand to nothing + unless compiled with `-DPSX_PGXP=1` — the G1.3 codegen-time gate, as a + preprocessor define over ONE generated tree (no flavour drift, base objects + byte-identical). `OverlayCallbacks` gained an appended-last `PGXPHooks*` + table (NULL-tolerant); the ABI bump arrives with the Phase-2 emitter change. +- Both interpreters (`dirty_ram_interp.c`, `psx_interpreter.c`) call the + hooks directly on loads/stores/COP2 transfers and the precision-carrying + arithmetic (moves, add/sub, or-merge, 16-bit shifts, LUI, HI/LO). Ops that + merely destroy precision are deliberately NOT hooked — validation already + drops their stale shadows. +- `pgxp_cpu_mode` (tier-2 arithmetic propagation, default off, matching the + references' default) and `pgxp_tolerance` (sub-pixel clamp, default off) + are new `[video]` keys and live-tunable via the TCP `pgxp` verb, which also + live-toggles `geometry`/`texture` for same-scene A/B (the G1.6 method rule + no longer needs a restart between arms). +- GPU consumption is now PER-VERTEX (`pgxp_get_precise_vertex`): dataflow + shadow first (validated against the actual packet word), the G1.4 exact + ambiguity-gated position cache second (never carries depth), native + integers last. Two safeguards on every precise vertex: truncation agreement + (integer part must equal the native 11-bit parse) and the tolerance clamp. + Mixed precise/native triangles are correct — a native vertex sits exactly + where the uncorrected pipeline put it, bounding any seam to < 1px of + sub-pixel fraction (the G1.1 cracks came from wrong-vertex substitution and + all-or-nothing triangles, both gone). Geometry and perspective UVs now + share one provenance source and compose per triangle. +- Census: `geom_correction` grew a `pgxp` object (dataflow_hit / + fallback_hit / native / value_mismatch / trunc_reject / tolerance_reject / + w_valid). The G1.9 gate now reads: dataflow-hit share must be dramatically + above the 5.2% position-cache ceiling before any launcher surface returns. +- Tests: `pgxp_test` (roundtrips, half-word semantics, validate-on-read, + DMA-overwrite rejection, repack arithmetic, suppression bracket, + safeguards) + `gte_register_access_test` updated to the precise + invalidation contract. 33/33 enabled runtime tests green. + +**Not yet (Phase 2+):** emitter macros at the ~47 sites, the `-DPSX_PGXP` +dual link targets + flavor-namespaced shard caches + ABI bump, BIOS regen, +and the Ape/Crash/Tomba2 census + `screenshot_hires` A/B campaign. Until the +emitter lands, compiled code feeds the engine only through the v14 swc2 +sites, so dataflow coverage on a running title comes from interpreted code +paths; the full-coverage census gate is a Phase-2/3 measurement. + +Incidental fixes while landing this: the per-target `psx_game_version.txt` +`file(GENERATE)` collision that broke every fresh single-config configure +(runtime.cmake once-only stamp), and `overlay_capture_retry_test` not +compiling under the SDL3 default (SDL_SetMainReady gone + inverted SDL_Init +check — it now uses `psx_sdl_init`). diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index cd38b9b9..cbc32a81 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -571,6 +571,13 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.video_perspective_texturing = toml::find(video, "perspective_texturing"); } + if (video.contains("pgxp_cpu_mode")) { + rt.video_pgxp_cpu_mode = toml::find(video, "pgxp_cpu_mode"); + } + if (video.contains("pgxp_tolerance")) { + rt.video_pgxp_tolerance = + toml::find(video, "pgxp_tolerance"); + } if (video.contains("crt_filter")) { const auto mode = toml::find(video, "crt_filter"); if (mode == "raw") rt.video_screen_kind = 0; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1829e024..3663882d 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -387,6 +387,20 @@ struct RuntimeConfig { // [video] perspective_texturing = true. bool video_perspective_texturing = false; + // pgxp_cpu_mode: propagate sub-pixel precision through CPU arithmetic as + // well as memory moves (the PGXP engine's tier-2 hooks). Off by default — + // the same default as the reference implementations — because some games + // deliberately rely on integer truncation in their own math; value + // validation keeps it SAFE either way, this only trades coverage. + // Meaningful only in a pgxp-flavour build; live-tunable over TCP. + bool video_pgxp_cpu_mode = false; + + // pgxp_tolerance: reject a corrected vertex whose sub-pixel offset from + // the native integer position exceeds this many pixels (a last-ditch + // visual clamp; the truncation-agreement check already bounds offsets to + // < 1px). Negative disables the clamp (default). Live-tunable over TCP. + double video_pgxp_tolerance = -1.0; + // offer_vulkan: expose the experimental Vulkan renderer in the launcher. // Defaults false even for Vulkan-enabled builds; developers must opt in per // game once visuals are validated. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index a5c185cf..f71dff85 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -83,6 +83,7 @@ if(BUILD_TESTING) add_executable(gte_register_access_test tests/test_gte_register_access.cpp src/gte.cpp + src/pgxp.cpp ) target_include_directories(gte_register_access_test PRIVATE include) target_compile_definitions(gte_register_access_test PRIVATE @@ -91,6 +92,16 @@ if(BUILD_TESTING) PSX_ENABLE_BLOCK_CYCLES=1) add_test(NAME gte_register_access_test COMMAND gte_register_access_test) + # PGXP value-propagation engine (ENHANCEMENTS.md G1.2/G1.3): shadow + # load/store/COP2 roundtrips, half-word semantics, validate-on-read + # (foreign overwrite + DMA-overwrite rejection), MOVE/repack arithmetic, + # suppression bracket, invalidate-all, and GetPreciseVertex safeguards + # (truncation agreement + tolerance clamp). White-box: includes src/pgxp.cpp + # and stubs the gte.cpp fallback-cache extern. + add_executable(pgxp_test tests/test_pgxp.cpp src/pgxp.cpp) + target_include_directories(pgxp_test PRIVATE include) + add_test(NAME pgxp_test COMMAND pgxp_test) + add_executable(psx_instr_cost_test tests/test_psx_instr_cost.cpp) target_include_directories(psx_instr_cost_test PRIVATE include) add_test(NAME psx_instr_cost_test COMMAND psx_instr_cost_test) diff --git a/runtime/codegen_hash_sources.cmake b/runtime/codegen_hash_sources.cmake index c6bbd938..3651f39e 100644 --- a/runtime/codegen_hash_sources.cmake +++ b/runtime/codegen_hash_sources.cmake @@ -44,6 +44,7 @@ set(PSXRECOMP_CODEGEN_HASH_SRCS # .inc is the shim every shard links against. ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/overlay_api.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/cpu_state.h + ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/pgxp_hooks.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/psx_cyc.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/psx_cycles.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/overlay_dispatch_preamble.c.inc) diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 6de04789..4162db51 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -220,6 +220,12 @@ extern void gte_geometry_correction_stats(uint32_t *lookups, uint32_t *hits, uint32_t *miss_unrecorded, uint32_t *miss_ambiguous); +/* PGXP dataflow-shadowing hook macros (PGXP_LOAD/STORE/ALU/MULDIV/COP2). + * The emitter writes them unconditionally; they expand to real calls only + * under -DPSX_PGXP=1 (the pgxp build variant) and to ((void)0) otherwise. + * Pulled in here so every generated translation unit sees them. */ +#include "pgxp_hooks.h" + /* ============================================================================ * Dispatch call contract (Bug D / wild-return family fix) * ---------------------------------------------------------------------------- diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c7f4ed56..153095de 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -310,6 +310,7 @@ int psx_ws_auto_cull_on(void); * tracking it needs, so a polygon only qualifies when every position word in * its DMA packet was written by a GTE projection store. Default off. */ void gpu_texture_correction_set(int enabled); +int gpu_texture_correction_enabled(void); /* Triangles drawn with perspective-correct UVs since startup. */ uint32_t gpu_texture_correction_hits(void); /* GTE-activity gameplay detector ([widescreen] gte_game_mode) for 3D titles diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index a0c3a7e8..ae4f8602 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -8,6 +8,7 @@ */ #include "cpu_state.h" +#include "pgxp_hooks.h" #include /* ABI version exported by every overlay DLL as `overlay_abi()`. The loader @@ -291,6 +292,15 @@ typedef struct { /* Aspect-scaled terrain-frustum angle helper (ABI v21). */ uint32_t (*ws_angle_widen)(uint32_t vanilla); + + /* PGXP dataflow-shadowing hook table (pgxp_hooks.h). Only pgxp-flavour + * shards (PSX_OVERLAY_FLAVOR pgxp bit, compiled with -DPSX_PGXP=1) emit + * calls to psx_pgxp_*; their preamble forwards through this table. Base- + * flavour shards never reference it. Appended LAST; NULL on an older host + * no-ops every hook — precision shadowing is visual-only, never + * load-bearing. The ABI version bump that arms this ships with the + * emitter change (Phase 2 of ENHANCEMENTS.md G1 value propagation). */ + const PGXPHooks *pgxp; } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h new file mode 100644 index 00000000..d8bc2e7e --- /dev/null +++ b/runtime/include/pgxp.h @@ -0,0 +1,124 @@ +#ifndef PGXP_H +#define PGXP_H + +/* PGXP value-propagation engine — runtime-internal API (pgxp.cpp). + * + * The engine shadows every 32-bit word of guest RAM/scratchpad, every GPR, + * and every GTE data register with the sub-pixel projection it carries + * (screen X/Y in 16.16 plus the projected SZ depth). The GTE fills shadows at + * RTPS/RTPT; the psx_pgxp_* hooks (pgxp_hooks.h) move them along with the + * data; the GPU asks for the precise position of each GP0 vertex word by the + * packet's RAM address, validated against the actual word — never guessed + * from the rounded position (the measured G1.4 dead end). + * + * Guest-visible state is NEVER touched: shadows are host-only, dropped on + * savestate/rewind, and suppressed during speculative validation passes. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* --- lifecycle / configuration ------------------------------------------ */ + +/* Master arm. Allocates the RAM shadow lazily; fails closed (stays disabled) + * if the allocation fails. Idempotent. */ +void pgxp_set_enabled(int enabled); +int pgxp_enabled(void); + +/* Tier-2 propagation through CPU arithmetic (default off, like the reference + * implementations' default). Off is SAFE — value validation already stops + * stale shadows — it only bounds coverage. Live-tunable (TCP). */ +void pgxp_set_cpu_mode(int enabled); +int pgxp_cpu_mode(void); + +/* 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); +float pgxp_tolerance(void); + +/* Drop all shadows (savestate load, raw RAM restore, timeline breaks). + * O(1) via generation bump. Deferred while suppressed. */ +void pgxp_invalidate_all(void); + +/* Counted suppression bracket for speculative native-validation passes and + * the GTE replay sandbox: hooks and producers no-op inside it. */ +void pgxp_suppress_begin(void); +void pgxp_suppress_end(void); + +/* --- GTE producer (gte.cpp) ---------------------------------------------- */ + +/* Called at the RTPS/RTPT projection with the pre-truncation 16.16 screen + * coordinates, the projected depth (SZ3), and the packed SXY word the guest + * sees. Shifts the shadow FIFO exactly like push_sxy (regs 12..15). */ +void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, uint32_t packed); + +/* 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 + * the SXYP FIFO push on the shadows, others just invalidate/overwrite. */ +void pgxp_gte_reg_written(int reg, uint32_t value); + +/* --- GPU consumer (gpu.c) ------------------------------------------------ */ + +enum { + PGXP_SRC_NATIVE = 0, /* no shadow — caller uses the parsed integers */ + PGXP_SRC_FALLBACK = 1, /* position-cache fallback (never carries depth)*/ + PGXP_SRC_DATAFLOW = 2, /* address-keyed shadow, value-validated */ +}; + +/* Precise position for one GP0 vertex word. + * addr guest address the word was DMA'd from, or 0xFFFFFFFF if + * unknown (immediate GP0 writes) — skips the dataflow tier. + * packet_word the actual word (validation key). + * int_x/int_y the natively parsed 11-bit positions (pre-draw-offset). + * On DATAFLOW/FALLBACK, *x16/*y16 hold the sub-pixel position (16.16, same + * coordinate space as the packet halves); *sz is the projected depth or 0. + * Safeguards applied here: the integer part must match the native parse + * (truncation agreement) and the tolerance clamp. */ +int pgxp_get_precise_vertex(uint32_t addr, uint32_t packet_word, + int32_t int_x, int32_t int_y, + int32_t *x16, int32_t *y16, uint16_t *sz); + +/* --- observability -------------------------------------------------------- */ + +typedef struct PGXPStats { + uint64_t lookups; /* pgxp_get_precise_vertex calls */ + uint64_t dataflow_hit; + uint64_t fallback_hit; + uint64_t native; + uint64_t value_mismatch; /* shadow present but wrong word (stale) */ + uint64_t trunc_reject; /* integer part disagreed with native parse */ + uint64_t tolerance_reject; + uint64_t w_valid; /* lookups that also carried a usable depth */ +} PGXPStats; + +void pgxp_get_stats(PGXPStats *out); + +/* --- gte.cpp forwarding surface (v14 ABI compat) -------------------------- */ + +/* SWC2 site: copy the GTE register shadow (regs 12..15) to the RAM shadow. */ +void pgxp_store_gte_reg(uint32_t addr, uint8_t reg); + +/* Address-keyed precise-word lookup (perspective texturing path). Returns + * nonzero when the tracked word matches `packed` AND carries a depth. */ +int pgxp_load_precise_word(uint32_t addr, uint32_t packed, + int32_t *x16, int32_t *y16, uint16_t *z); + +/* --- test accessors (always compiled; trivial) ---------------------------- */ + +/* index 0..3 selects the SXY0..SXYP register shadow (GTE data regs 12..15). */ +void pgxp_test_seed_gte_sxy(uint32_t index, uint32_t packed, + int32_t x16, int32_t y16, uint16_t z, int valid); +void pgxp_test_get_gte_sxy(uint32_t index, uint32_t *packed, + int32_t *x16, int32_t *y16, uint16_t *z, + uint8_t *valid); +uint32_t pgxp_test_generation(void); +void pgxp_test_set_generation(uint32_t gen); + +#ifdef __cplusplus +} +#endif + +#endif /* PGXP_H */ diff --git a/runtime/include/pgxp_hooks.h b/runtime/include/pgxp_hooks.h new file mode 100644 index 00000000..257525f1 --- /dev/null +++ b/runtime/include/pgxp_hooks.h @@ -0,0 +1,93 @@ +#ifndef PGXP_HOOKS_H +#define PGXP_HOOKS_H + +/* PGXP dataflow-shadowing hook surface (ENHANCEMENTS.md G1.2/G1.3). + * + * The runtime keeps a host-only "shadow" of sub-pixel GTE projection results + * and follows them BY PROVENANCE as the game moves them through registers and + * RAM into a GP0 packet (pgxp.cpp). Generated code participates by calling + * these hooks after the guest operation completes. + * + * Gating model (the G1.3 decision): the emitter writes PGXP_*() macro + * invocations unconditionally; this header expands them to real calls only + * when the translation unit is compiled with -DPSX_PGXP=1 (the pgxp build + * variant). In the base variant the macros expand to ((void)0), so base + * objects compile to exactly the pre-feature code — the feature costs nothing + * unless a title links the pgxp target. + * + * RULES for emit sites: + * - Macro arguments MUST be side-effect free (they vanish in base builds). + * - Hooks run AFTER the guest architectural effect (value = what actually + * loaded/stored/resulted), so the shadow can validate against reality. + * - `cpu` must be in scope at every emit site (it always is in generated C). + * + * The interpreters (dirty_ram_interp.c, psx_interpreter.c) call the + * psx_pgxp_* functions directly — they are runtime code compiled once, and + * every hook early-outs on a single global when the feature is off. + * + * Overlay DLLs cannot link runtime symbols directly; they forward through the + * PGXPHooks table below (OverlayCallbacks.pgxp, overlay_api.h). A NULL table + * no-ops every hook — precision shadowing is a visual enhancement, never + * load-bearing. + * + * LICENSING: this is a clean-room implementation of the publicly documented + * PGXP technique (psx-spx, public design write-ups). Do NOT port code from + * the vendored duckstation/ (CC BY-NC-ND) or beetle-psx/ (GPL) trees; they + * are black-box behavioral oracles only. See ENHANCEMENTS.md G1. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct CPUState; + +/* Funnel entry points (implemented in runtime/src/pgxp.cpp). The raw + * instruction word carries the op/rs/rt/rd/imm decode so the emitted call is + * uniform per class; `value` is the architectural result of the operation. + * + * load: rt <- [addr]; value = loaded word/half/byte (post-extension) + * store: [addr] <- rt; value = the stored word/half/byte + * alu: rd/rt <- op(s1, s2); result = the written value (also LUI, HILO + * moves and MOVE idioms — the body decodes the class from instr) + * muldiv: HI/LO <- op(s1, s2) + * cop2: MFC2/CFC2/MTC2/CTC2 (addr = 0) and LWC2/SWC2 (addr = guest addr); + * value = the transferred word + */ +void psx_pgxp_load (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); +void psx_pgxp_store (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); +void psx_pgxp_alu (struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2); +void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, uint32_t hi, uint32_t lo, uint32_t s1, uint32_t s2); +void psx_pgxp_cop2 (struct CPUState *cpu, uint32_t instr, uint32_t value, uint32_t addr); + +/* Forwarder table for overlay DLLs (OverlayCallbacks.pgxp). Appended-last + * member semantics apply: a NULL pointer (older host) means "no shadowing". */ +typedef struct PGXPHooks { + void (*load) (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); + void (*store) (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); + void (*alu) (struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2); + void (*muldiv)(struct CPUState *cpu, uint32_t instr, uint32_t hi, uint32_t lo, uint32_t s1, uint32_t s2); + void (*cop2) (struct CPUState *cpu, uint32_t instr, uint32_t value, uint32_t addr); +} PGXPHooks; + +#if defined(PSX_PGXP) && PSX_PGXP +#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_COP2(instr, val, addr) psx_pgxp_cop2(cpu, (instr), (val), (addr)) +#else +#define PGXP_LOAD(instr, addr, val) ((void)0) +#define PGXP_STORE(instr, addr, val) ((void)0) +#define PGXP_ALU(instr, res, s1, s2) ((void)0) +#define PGXP_MULDIV(instr, hi, lo, s1, s2) ((void)0) +#define PGXP_COP2(instr, val, addr) ((void)0) +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* PGXP_HOOKS_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 8a49dda9..2c9ae0e4 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -299,6 +299,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/crash_trace.c ${PSXRECOMP_ROOT}/runtime/src/freeze_heartbeat.c ${PSXRECOMP_ROOT}/runtime/src/gte.cpp + ${PSXRECOMP_ROOT}/runtime/src/pgxp.cpp ${PSXRECOMP_ROOT}/runtime/src/nd_intro_ot.c ${PSXRECOMP_ROOT}/runtime/src/crc32.c ${PSXRECOMP_ROOT}/runtime/src/psx_sha256.c @@ -1169,16 +1170,27 @@ function(psxrecomp_add_runtime_target target) # second GENERATE to the same path is rejected ("Files to be generated by # multiple different commands"). Multi-config (VS): exe lives in Release/, # so also drop a copy at the build root for packager lookup. - file(GENERATE - OUTPUT "$/psx_game_version.txt" - CONTENT "${PSXRT_GAME_VERSION}\n" - ) - get_property(_psxrt_ver_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) - if(_psxrt_ver_multi) + # Once per build tree, not per target: with several runtime targets + # (psx-runtime + psx-oracle + psx-beetle) each TARGET_FILE_DIR genex is a + # DIFFERENT command producing the SAME path on single-config generators, + # which CMake rejects even when the content is identical (the ae9e6a4e + # hotfix fixed the same-target duplicate but not the multi-target one). + # All runtime targets in one tree share one PSXRT_GAME_VERSION, so a + # single stamp is the correct semantics anyway. + get_property(_psxrt_ver_stamped GLOBAL PROPERTY PSXRT_GAME_VERSION_STAMPED) + if(NOT _psxrt_ver_stamped) + set_property(GLOBAL PROPERTY PSXRT_GAME_VERSION_STAMPED 1) file(GENERATE - OUTPUT "${CMAKE_BINARY_DIR}/psx_game_version.txt" + OUTPUT "$/psx_game_version.txt" CONTENT "${PSXRT_GAME_VERSION}\n" ) + get_property(_psxrt_ver_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) + if(_psxrt_ver_multi) + file(GENERATE + OUTPUT "${CMAKE_BINARY_DIR}/psx_game_version.txt" + CONTENT "${PSXRT_GAME_VERSION}\n" + ) + endif() endif() # OpenBIOS is part of the native runtime product, not a developer-machine diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 61cd2464..566e42d4 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -24,6 +24,7 @@ #include "code_provider.h" #include "overlay_backend.h" #include "cpu_state.h" +#include "pgxp.h" #include "dma.h" #include "gpu.h" #include "gpu_render.h" /* gr_scale + gr_render_display_hires (screenshot_hires) */ @@ -5196,16 +5197,71 @@ static void handle_geom_correction(int id, const char *json) * distinct vertices are landing on the same pixel. */ uint32_t lookups = 0, hits = 0, unrec = 0, ambig = 0; gte_geometry_correction_stats(&lookups, &hits, &unrec, &ambig); + /* PGXP dataflow census (per-vertex): the primary provenance source. + * dataflow_hit is the number that had to move — the G1.9 gate is a + * dataflow-hit share dramatically above the 5.2% the position table + * measured on its own. value_mismatch counts shadows that were present + * but described a different word (stale = provenance hole to hunt). */ + PGXPStats ps; + pgxp_get_stats(&ps); send_fmt("{\"id\":%d,\"ok\":true," "\"geometry_correction\":%d," "\"geometry_vertex_hits\":%u," "\"perspective_triangles\":%u," - "\"lookups\":%u,\"miss_unrecorded\":%u,\"miss_ambiguous\":%u}", + "\"lookups\":%u,\"miss_unrecorded\":%u,\"miss_ambiguous\":%u," + "\"pgxp\":{\"enabled\":%d,\"cpu_mode\":%d,\"tolerance\":%.3f," + "\"lookups\":%llu,\"dataflow_hit\":%llu,\"fallback_hit\":%llu," + "\"native\":%llu,\"value_mismatch\":%llu,\"trunc_reject\":%llu," + "\"tolerance_reject\":%llu,\"w_valid\":%llu}}", id, gte_geometry_correction_enabled(), (unsigned)hits, (unsigned)gpu_texture_correction_hits(), - (unsigned)lookups, (unsigned)unrec, (unsigned)ambig); + (unsigned)lookups, (unsigned)unrec, (unsigned)ambig, + pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance(), + (unsigned long long)ps.lookups, + (unsigned long long)ps.dataflow_hit, + (unsigned long long)ps.fallback_hit, + (unsigned long long)ps.native, + (unsigned long long)ps.value_mismatch, + (unsigned long long)ps.trunc_reject, + (unsigned long long)ps.tolerance_reject, + (unsigned long long)ps.w_valid); +} + +/* pgxp — live-tune the value-propagation engine for one-toggle isolation runs + * without a rebuild: {"cmd":"pgxp","cpu_mode":0|1,"tolerance":F}. Fields are + * optional; the reply echoes the resulting state (same shape as + * geom_correction's "pgxp" object, flattened). */ +static void handle_pgxp(int id, const char *json) +{ + /* Live toggles for the one-toggle-at-a-time A/B protocol (ENHANCEMENTS.md + * G1.6 method rule): same scene, flip one knob, screenshot_hires. */ + int geom = json_get_int(json, "geometry", -1); + if (geom >= 0) + gte_geometry_correction_set(geom != 0); + int tex = json_get_int(json, "texture", -1); + if (tex >= 0) + gpu_texture_correction_set(tex != 0); + if (geom >= 0 && tex < 0) { + /* keep the engine armed consistently with both flags */ + gpu_texture_correction_set(gpu_texture_correction_enabled()); + } + int cm = json_get_int(json, "cpu_mode", -1); + if (cm >= 0) + pgxp_set_cpu_mode(cm != 0); + /* tolerance is fractional (sub-pixel), so scan it directly — json_get_int + * would truncate 0.5 to 0. */ + const char *p = strstr(json, "\"tolerance\""); + if (p) { + p += 11; + while (*p == ' ' || *p == ':' || *p == '"') p++; + if (*p == '-' || (*p >= '0' && *p <= '9') || *p == '.') + pgxp_set_tolerance((float)strtod(p, NULL)); + } + send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"cpu_mode\":%d," + "\"tolerance\":%.3f}", + id, pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance()); } static void handle_gpu_state(int id, const char *json) @@ -13163,6 +13219,7 @@ static const CmdEntry s_commands[] = { { "write_ram", handle_write_ram }, { "gpu_state", handle_gpu_state }, { "geom_correction", handle_geom_correction }, + { "pgxp", handle_pgxp }, { "ws_aspect_cone_site", handle_ws_aspect_cone_site }, { "ws_margin", handle_ws_margin }, { "ws_hud_mode", handle_ws_hud_mode }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index fc3c5ad0..377f35aa 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1512,30 +1512,48 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, switch (opc) { case 0x00: /* SPECIAL */ switch (fnt) { - case 0x00: /* SLL rd, rt, sh (also nop when all fields are 0) */ - cpu->gpr[rd] = cpu->gpr[rt] << sh; + case 0x00: { /* SLL rd, rt, sh (also nop when all fields are 0) */ + uint32_t a = cpu->gpr[rt]; + cpu->gpr[rd] = a << sh; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, sh); cpu->gpr[0] = 0; return 0; - case 0x02: /* SRL */ - cpu->gpr[rd] = cpu->gpr[rt] >> sh; + } + case 0x02: { /* SRL */ + uint32_t a = cpu->gpr[rt]; + cpu->gpr[rd] = a >> sh; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, sh); cpu->gpr[0] = 0; return 0; - case 0x03: /* SRA */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> sh); + } + case 0x03: { /* SRA */ + uint32_t a = cpu->gpr[rt]; + cpu->gpr[rd] = (uint32_t)((int32_t)a >> sh); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, sh); cpu->gpr[0] = 0; return 0; - case 0x04: /* SLLV */ - cpu->gpr[rd] = cpu->gpr[rt] << (cpu->gpr[rs] & 31); + } + case 0x04: { /* SLLV */ + uint32_t a = cpu->gpr[rt], b = cpu->gpr[rs]; + cpu->gpr[rd] = a << (b & 31); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; - case 0x06: /* SRLV */ - cpu->gpr[rd] = cpu->gpr[rt] >> (cpu->gpr[rs] & 31); + } + case 0x06: { /* SRLV */ + uint32_t a = cpu->gpr[rt], b = cpu->gpr[rs]; + cpu->gpr[rd] = a >> (b & 31); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; - case 0x07: /* SRAV */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> (cpu->gpr[rs] & 31)); + } + case 0x07: { /* SRAV */ + uint32_t a = cpu->gpr[rt], b = cpu->gpr[rs]; + cpu->gpr[rd] = (uint32_t)((int32_t)a >> (b & 31)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x08: { /* JR rs */ uint32_t target = cpu->gpr[rs]; if (target & 3) return interp_exception(cpu, 4, target, pc); /* LoadAddressError */ @@ -1615,25 +1633,30 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ #endif cpu->gpr[rd] = cpu->hi; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->hi, 0); cpu->gpr[0] = 0; return 0; case 0x11: /* MTHI */ cpu->hi = cpu->gpr[rs]; + psx_pgxp_alu(cpu, insn, cpu->hi, cpu->hi, 0); return 0; case 0x12: /* MFLO */ #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ #endif cpu->gpr[rd] = cpu->lo; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->lo, 0); cpu->gpr[0] = 0; return 0; case 0x13: /* MTLO */ cpu->lo = cpu->gpr[rs]; + psx_pgxp_alu(cpu, insn, cpu->lo, cpu->lo, 0); return 0; case 0x18: { /* MULT */ int64_t r = (int64_t)(int32_t)cpu->gpr[rs] * (int64_t)(int32_t)cpu->gpr[rt]; cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)((uint64_t)r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, psx_mult_latency_s(cpu->gpr[rs])); /* completion deadline */ #endif @@ -1643,6 +1666,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint64_t r = (uint64_t)cpu->gpr[rs] * (uint64_t)cpu->gpr[rt]; cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, psx_mult_latency_u(cpu->gpr[rs])); /* completion deadline */ #endif @@ -1661,6 +1685,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->lo = (uint32_t)(a / b); cpu->hi = (uint32_t)(a % b); } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, 37u); /* DIV completion deadline (fixed) */ #endif @@ -1674,31 +1699,41 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->lo = cpu->gpr[rs] / cpu->gpr[rt]; cpu->hi = cpu->gpr[rs] % cpu->gpr[rt]; } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, 37u); /* DIVU completion deadline (fixed) */ #endif return 0; case 0x20: /* ADD - overflow traps are delegated if they occur. */ - case 0x21: /* ADDU rd, rs, rt */ - cpu->gpr[rd] = cpu->gpr[rs] + cpu->gpr[rt]; + case 0x21: { /* ADDU rd, rs, rt */ + uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; + cpu->gpr[rd] = a + b; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x22: /* SUB - overflow traps are delegated if they occur. */ - case 0x23: /* SUBU */ + case 0x23: { /* SUBU */ + uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; if (rs == 0 && psx_ws_is_cull_negsub_site(pc)) - cpu->gpr[rd] = 0u - cpu->gpr[rt] - (uint32_t)psx_ws_x_margin(); + cpu->gpr[rd] = 0u - b - (uint32_t)psx_ws_x_margin(); else - cpu->gpr[rd] = cpu->gpr[rs] - cpu->gpr[rt]; + cpu->gpr[rd] = a - b; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x24: /* AND */ cpu->gpr[rd] = cpu->gpr[rs] & cpu->gpr[rt]; cpu->gpr[0] = 0; return 0; - case 0x25: /* OR */ - cpu->gpr[rd] = cpu->gpr[rs] | cpu->gpr[rt]; + case 0x25: { /* OR */ + uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; + cpu->gpr[rd] = a | b; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x26: /* XOR */ cpu->gpr[rd] = cpu->gpr[rs] ^ cpu->gpr[rt]; cpu->gpr[0] = 0; @@ -1852,24 +1887,28 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, case 0x08: /* ADDI rt, rs, simm — same as ADDIU, sans overflow trap (we don't model traps here) */ { uint32_t widened = 0; + uint32_t a = cpu->gpr[rs]; if (psx_ws_angle_site(pc, insn, &widened)) cpu->gpr[rt] = widened; else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm + cpu->gpr[rt] = a + (uint32_t)simm + (psx_ws_is_cull_bias_site(pc) ? (uint32_t)psx_ws_activation_margin() : 0u); + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, (uint32_t)simm); cpu->gpr[0] = 0; return 0; } case 0x09: /* ADDIU rt, rs, simm */ { uint32_t widened = 0; + uint32_t a = cpu->gpr[rs]; if (psx_ws_angle_site(pc, insn, &widened)) cpu->gpr[rt] = widened; else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm + cpu->gpr[rt] = a + (uint32_t)simm + (psx_ws_is_cull_bias_site(pc) ? (uint32_t)psx_ws_activation_margin() : 0u); + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, (uint32_t)simm); cpu->gpr[0] = 0; return 0; } @@ -1930,10 +1969,13 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = cpu->gpr[rs] & imm; cpu->gpr[0] = 0; return 0; - case 0x0D: /* ORI */ - cpu->gpr[rt] = cpu->gpr[rs] | imm; + case 0x0D: { /* ORI */ + uint32_t a = cpu->gpr[rs]; + cpu->gpr[rt] = a | imm; + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, imm); cpu->gpr[0] = 0; return 0; + } case 0x0E: /* XORI */ cpu->gpr[rt] = cpu->gpr[rs] ^ imm; cpu->gpr[0] = 0; @@ -1943,6 +1985,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = (uint32_t)psx_ws_player_x_bound((int32_t)(imm << 16)); else cpu->gpr[rt] = imm << 16; + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], 0, 0); cpu->gpr[0] = 0; return 0; case 0x10: { /* COP0 */ @@ -2033,6 +2076,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_read(cpu, rt); #endif cpu->gpr[rt] = gte_read_data(cpu, (uint8_t)rd); + psx_pgxp_cop2(cpu, insn, cpu->gpr[rt], 0); cpu->gpr[0] = 0; return 0; } @@ -2041,6 +2085,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_read(cpu, rt); #endif cpu->gpr[rt] = gte_read_ctrl(cpu, (uint8_t)rd); + psx_pgxp_cop2(cpu, insn, cpu->gpr[rt], 0); cpu->gpr[0] = 0; return 0; } @@ -2049,6 +2094,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_stall(cpu); #endif gte_write_data(cpu, (uint8_t)rd, cpu->gpr[rt]); + psx_pgxp_cop2(cpu, insn, cpu->gpr[rt], 0); return 0; } if (cop_op == 0x06) { /* CTC2 */ @@ -2072,6 +2118,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, case 0x20: { /* LB rt, simm(rs) */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; cpu->gpr[rt] = (uint32_t)(int32_t)(int8_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2079,6 +2126,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; if (addr & 1) return interp_exception(cpu, 4, addr, pc); /* LoadAddressError */ cpu->gpr[rt] = (uint32_t)(int32_t)(int16_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2086,6 +2134,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); cpu->gpr[rt] = lwl_merge(addr, word, cpu->gpr[rt]); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2101,12 +2150,14 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); else cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } case 0x24: { /* LBU */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; cpu->gpr[rt] = (uint32_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2114,6 +2165,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; if (addr & 1) return interp_exception(cpu, 4, addr, pc); /* LoadAddressError */ cpu->gpr[rt] = (uint32_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2121,6 +2173,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); cpu->gpr[rt] = lwr_merge(addr, word, cpu->gpr[rt]); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2128,6 +2181,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; g_debug_last_store_pc = pc; cpu->write_byte(addr, (uint8_t)cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt] & 0xFFu); return 0; } case 0x29: { /* SH */ @@ -2143,12 +2197,14 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, val = (uint16_t)psx_ws_backdrop_x((int16_t)val); g_debug_last_store_pc = pc; cpu->write_half(addr, val); + psx_pgxp_store(cpu, insn, addr, val); return 0; } case 0x2A: { /* SWL */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; g_debug_last_store_pc = pc; interp_swl(cpu, addr, cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt]); return 0; } case 0x2B: { /* SW */ @@ -2156,12 +2212,14 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, if (addr & 3) return interp_exception(cpu, 5, addr, pc); /* StoreAddressError */ g_debug_last_store_pc = pc; cpu->write_word(addr, cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt]); return 0; } case 0x2E: { /* SWR */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; g_debug_last_store_pc = pc; interp_swr(cpu, addr, cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt]); return 0; } case 0x32: { /* LWC2 — §1+DO_LDS charged by exec_one's top step (mask 0) */ @@ -2169,7 +2227,11 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #ifdef PSX_ENABLE_BLOCK_CYCLES psx_gte_stall(cpu); /* COP2 reg write stalls to GTE completion */ #endif - gte_write_data(cpu, (uint8_t)rt, psx_cyc_lwc2_read(cpu, addr)); + { + uint32_t lw2 = psx_cyc_lwc2_read(cpu, addr); + gte_write_data(cpu, (uint8_t)rt, lw2); + psx_pgxp_cop2(cpu, insn, lw2, addr); + } return 0; } case 0x3A: { /* SWC2 */ @@ -2178,8 +2240,12 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_stall(cpu); /* COP2 reg read stalls to GTE completion */ #endif g_debug_last_store_pc = pc; - cpu->write_word(addr, gte_read_data(cpu, (uint8_t)rt)); - gte_precision_store_word(addr, (uint8_t)rt); + { + uint32_t sw2 = gte_read_data(cpu, (uint8_t)rt); + cpu->write_word(addr, sw2); + gte_precision_store_word(addr, (uint8_t)rt); + psx_pgxp_cop2(cpu, insn, sw2, addr); + } return 0; } default: diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 99f926f6..4bf9597b 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -11,6 +11,7 @@ */ #include "gpu.h" +#include "pgxp.h" #include "mod_memory.h" #include "gpu_primitive_reject.h" #include "gpu_sw_renderer.h" @@ -2956,48 +2957,64 @@ static void parse_vertex(uint32_t word, int32_t* x, int32_t* y) { *y = sign_extend((word >> 16) & 0x7FFu, 11); } -extern int gte_geometry_correction_lookup(uint32_t packed, - int32_t *x16, int32_t *y16); extern int gte_geometry_correction_enabled(void); static int s_texture_correction_enabled = 0; -extern void gte_precision_tracking_set(int enabled); extern int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z); void gpu_texture_correction_set(int enabled) { s_texture_correction_enabled = enabled ? 1 : 0; - gte_precision_tracking_set(enabled); + /* 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()); +} + +int gpu_texture_correction_enabled(void) { + return s_texture_correction_enabled; } uint32_t gpu_texture_correction_hits(void) { return sw_perspective_triangle_count(); } -/* Match all three GP0 positions to recent GTE projections. Requiring a full - * triangle match prevents screen-space HUD/sprites that happen to share one - * coordinate from receiving world-geometry correction. The integer delta - * folds in draw offsets and any widescreen adjustment already applied. */ -static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, +/* Per-vertex precise positions (PGXP, ENHANCEMENTS.md G1). Each of the three + * packet words is resolved independently: the address-keyed dataflow shadow + * first (validated against the actual word — exact provenance, survives + * ordering-table reordering), the ambiguity-gated position cache second, the + * parsed integers last. Mixing precise and native vertices in one triangle + * is correct — a native vertex is exactly where the uncorrected pipeline put + * it, so shared edges between neighbouring triangles cannot disagree by more + * than the sub-pixel fraction. The integer delta folds in draw offsets and + * any widescreen adjustment already applied by the caller. */ +static void prepare_precise_triangle(int i0, int i1, int i2, const int32_t vx[3], const int32_t vy[3]) { - uint32_t words[3] = { w0, w1, w2 }; - int32_t fx[3], fy[3]; - const int geometry_enabled = gte_geometry_correction_enabled(); gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - if (!geometry_enabled) { + if (!gte_geometry_correction_enabled()) { gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } + const int idx[3] = { i0, i1, i2 }; + int32_t fx[3], fy[3]; + int any_precise = 0; for (int i = 0; i < 3; i++) { + uint32_t word = gp0_cmd_buf[idx[i]]; int32_t raw_x, raw_y; - parse_vertex(words[i], &raw_x, &raw_y); - if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { - gr_set_precise_triangle(0, 0,0, 0,0, 0,0); - return; - } - fx[i] = (int32_t)((int64_t)fx[i] + - (int64_t)(vx[i] - raw_x) * 65536); - fy[i] = (int32_t)((int64_t)fy[i] + - (int64_t)(vy[i] - raw_y) * 65536); + parse_vertex(word, &raw_x, &raw_y); + uint32_t addr = (gp0_cmd_source_addr == 0xFFFFFFFFu) + ? 0xFFFFFFFFu + : gp0_cmd_source_addr + (uint32_t)idx[i] * 4u; + int32_t px, py; + uint16_t sz; + if (pgxp_get_precise_vertex(addr, word, raw_x, raw_y, + &px, &py, &sz) != PGXP_SRC_NATIVE) + any_precise = 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); + } + if (!any_precise) { + gr_set_precise_triangle(0, 0,0, 0,0, 0,0); + return; } gr_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); } @@ -3209,7 +3226,7 @@ static void gp0_exec_mono_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], + prepare_precise_triangle(1, 2, 3, vx, vy); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } @@ -3275,15 +3292,13 @@ static void gp0_exec_mono_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], - gp0_cmd_buf[3], tx, ty); + prepare_precise_triangle(1, 2, 3, tx, ty); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[3], gp0_cmd_buf[2], - gp0_cmd_buf[4], tx, ty); + prepare_precise_triangle(3, 2, 4, tx, ty); gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); } } @@ -3306,7 +3321,7 @@ static void gp0_exec_shaded_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, vx, vy); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], @@ -3352,8 +3367,7 @@ static void gp0_exec_shaded_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], - gp0_cmd_buf[5], tx, ty); + prepare_precise_triangle(1, 3, 5, tx, ty); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], vx[2], vy[2], c[2]); @@ -3361,8 +3375,7 @@ static void gp0_exec_shaded_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], - gp0_cmd_buf[7], tx, ty); + prepare_precise_triangle(5, 3, 7, tx, ty); gr_draw_gouraud_triangle(vx[2], vy[2], c[2], vx[1], vy[1], c[1], vx[3], vy[3], c[3]); @@ -3433,7 +3446,7 @@ static void gp0_exec_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; setup_textured_draw(color24, semi_trans, raw_texture); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, vx, vy); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], @@ -3515,8 +3528,7 @@ static void gp0_exec_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], - gp0_cmd_buf[5], tx, ty); + prepare_precise_triangle(1, 3, 5, tx, ty); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], vx[1], vy[1], u[1], v[1], @@ -3526,8 +3538,7 @@ static void gp0_exec_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], - gp0_cmd_buf[7], tx, ty); + prepare_precise_triangle(5, 3, 7, tx, ty); prepare_texture_triangle(5, 3, 7); gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], vx[1], vy[1], u[1], v[1], @@ -3569,7 +3580,7 @@ static void gp0_exec_shaded_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], + prepare_precise_triangle(1, 4, 7, vx, vy); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], @@ -3620,8 +3631,7 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], - gp0_cmd_buf[7], tx, ty); + prepare_precise_triangle(1, 4, 7, tx, ty); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], vx[1], vy[1], u[1], v[1], c[1], @@ -3631,8 +3641,7 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[7], gp0_cmd_buf[4], - gp0_cmd_buf[10], tx, ty); + prepare_precise_triangle(7, 4, 10, tx, ty); prepare_texture_triangle(7, 4, 10); gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], vx[1], vy[1], u[1], v[1], c[1], diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 7c8ffd94..311d6a16 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1,6 +1,7 @@ #include "gte.h" #include "cpu_state.h" #include "nd_intro_ot.h" +#include "pgxp.h" #include #include #include @@ -222,38 +223,16 @@ static uint32_t s_geom_lookups = 0; /* lookups attempted * static uint32_t s_geom_miss_unrec = 0; /* nothing was ever recorded here */ static uint32_t s_geom_miss_ambig = 0; /* recorded, but not unambiguously */ -/* Exact GTE projection provenance for perspective texture correction. The - * recompiler/interpreters call gte_precision_store after SWC2 writes an SXY - * register into guest RAM. GPU DMA later supplies that exact RAM address, so - * depth lookup follows the packet rather than guessing from rounded X/Y. */ -struct PreciseProjection { - uint32_t packed; - int32_t x16, y16; - uint16_t z; - uint8_t valid; -}; -static PreciseProjection s_precise_sxy[4] = {}; - -#define PRECISION_STORE_SIZE 65536u -struct PrecisionStoreEntry { - uint32_t addr; - PreciseProjection projection; - uint32_t generation; -}; -static PrecisionStoreEntry s_precision_store[PRECISION_STORE_SIZE]; -static uint32_t s_precision_generation = 1; -static int s_precision_tracking = 0; -static PreciseProjection s_speculative_saved_sxy[4]; +/* Exact GTE projection provenance now lives in the PGXP value-propagation + * engine (pgxp.cpp): per-word RAM/scratchpad shadows plus per-GPR and per-GTE- + * register shadows, validated on read (ENHANCEMENTS.md G1.2/G1.3). The + * gte_precision_* entry points below are kept as the stable emit/ABI surface + * (v14 swc2 sites, tests) and forward into that engine. The hashed + * s_precision_store table and the s_precise_sxy FIFO it fed are retired — + * the GTE register shadows ARE the FIFO now. */ static uint32_t s_speculative_depth = 0; static int s_speculative_timeline_invalidated = 0; -static void gte_precision_generation_advance(void) { - if (++s_precision_generation == 0) { - std::memset(s_precision_store, 0, sizeof(s_precision_store)); - s_precision_generation = 1; - } -} - static void gte_geom_generation_advance(void) { if (++s_geom_generation == 0) { if (s_geom_cache) @@ -263,95 +242,63 @@ static void gte_geom_generation_advance(void) { } extern "C" void gte_precision_timeline_invalidate(void) { - for (int i = 0; i < 4; ++i) s_precise_sxy[i].valid = 0; /* Raw machine-state restore is authoritative even if host polling reached * it during a speculative validation pass. Defer the generation advance - * until the outer transaction ends so old provenance cannot be restored. */ + * until the outer transaction ends so old provenance cannot be restored. + * (pgxp_invalidate_all defers the same way behind its suppress bracket.) */ + pgxp_invalidate_all(); if (s_speculative_depth != 0) { s_speculative_timeline_invalidated = 1; return; } - gte_precision_generation_advance(); gte_geom_generation_advance(); } extern "C" void gte_precision_speculative_begin(void) { - if (s_speculative_depth++ == 0) { - std::memcpy(s_speculative_saved_sxy, s_precise_sxy, - sizeof(s_precise_sxy)); + if (s_speculative_depth++ == 0) s_speculative_timeline_invalidated = 0; - } + /* The speculative pass runs real GTE commands and hooks on state that is + * rolled back afterwards: suppress all shadow recording so the shadows + * still describe the restored timeline when the bracket closes. */ + pgxp_suppress_begin(); } extern "C" void gte_precision_speculative_end(void) { if (s_speculative_depth == 0) return; + pgxp_suppress_end(); if (--s_speculative_depth == 0) { if (s_speculative_timeline_invalidated) { - gte_precision_generation_advance(); gte_geom_generation_advance(); s_speculative_timeline_invalidated = 0; - } else { - std::memcpy(s_precise_sxy, s_speculative_saved_sxy, - sizeof(s_precise_sxy)); } } } -static inline uint32_t precision_hash(uint32_t addr) { - addr >>= 2; - addr ^= addr >> 11; - return (addr * 2654435761u) & (PRECISION_STORE_SIZE - 1u); -} - -static inline int precision_ram_address(uint32_t addr, uint32_t *physical) { - uint32_t mapped = addr & 0x1FFFFFFFu; - if (mapped >= 0x00800000u) return 0; - *physical = mapped & 0x1FFFFCu; - return 1; -} - extern "C" void gte_precision_tracking_set(int enabled) { - s_precision_tracking = enabled ? 1 : 0; - gte_precision_generation_advance(); + pgxp_set_enabled(enabled); } +/* v14 ABI emit surface: every swc2 site (all backends, overlay DLLs) calls + * this. The pgxp engine copies the GTE register shadow to the RAM shadow; + * the GPU-side lookup validates against the actual packet word, so a stale + * register shadow can never be believed. */ extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || - reg < 12 || reg > 15) return; - int index = reg == 15 ? 2 : (int)reg - 12; - const PreciseProjection &projection = s_precise_sxy[index]; - if (!projection.valid) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - entry.addr = physical; - entry.projection = projection; - entry.generation = s_precision_generation; + if (s_gte_replay_sandbox || reg < 12 || reg > 15) return; + pgxp_store_gte_reg(addr, reg); } +/* Retired: plain-store invalidation is unnecessary under validate-on-read + * (the GPU compares the tracked word against the actual packet word). Kept + * as a no-op for link compatibility. */ extern "C" void gte_precision_invalidate_word(uint32_t addr) { - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation == s_precision_generation && entry.addr == physical) - entry.generation = 0; + (void)addr; } extern "C" int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z) { - if (s_speculative_depth != 0 || !s_precision_tracking) return 0; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return 0; - const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation != s_precision_generation || entry.addr != physical || - !entry.projection.valid || entry.projection.packed != packed) - return 0; - if (x16) *x16 = entry.projection.x16; - if (y16) *y16 = entry.projection.y16; - if (z) *z = entry.projection.z; - return entry.projection.z != 0; + if (s_speculative_depth != 0) return 0; + return pgxp_load_precise_word(addr, packed, x16, y16, z); } /* Exact slot for a packed SXY pair, or -1 if it is outside the representable @@ -940,16 +887,9 @@ 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) { - s_precise_sxy[0] = s_precise_sxy[1]; - s_precise_sxy[1] = s_precise_sxy[2]; - s_precise_sxy[2].packed = (uint32_t)gte->SXY[2]; - s_precise_sxy[2].x16 = (int32_t)sx16; - s_precise_sxy[2].y16 = (int32_t)sy16; - s_precise_sxy[2].z = gte->SZ[3]; - s_precise_sxy[2].valid = gte->SZ[3] != 0; - s_precise_sxy[3] = s_precise_sxy[2]; - } + if (!s_gte_replay_sandbox) + pgxp_gte_push_sxy((int32_t)sx16, (int32_t)sy16, 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 @@ -2054,24 +1994,32 @@ extern "C" void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val) { cpu->gte_data[29] = packed; } break; + /* Guest writes to the SXY FIFO drop the affected register shadows: + * we never model FIFO side effects on shadows — the shifted regs 12/13 + * now describe words their shadows no longer match, and validation + * drops those on next use. */ case 12: case 13: cpu->gte_data[reg] = val; if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; + pgxp_gte_reg_written(reg, val); break; case 14: cpu->gte_data[14] = val; cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; + if (!PSXRecomp::GTE::s_gte_replay_sandbox) { + pgxp_gte_reg_written(14, val); + pgxp_gte_reg_written(15, val); + } break; case 15: cpu->gte_data[12] = cpu->gte_data[13]; cpu->gte_data[13] = cpu->gte_data[14]; cpu->gte_data[14] = val; cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; + if (!PSXRecomp::GTE::s_gte_replay_sandbox) { + pgxp_gte_reg_written(14, val); + pgxp_gte_reg_written(15, val); + } break; case 23: cpu->gte_data[23] = 0; @@ -2134,26 +2082,31 @@ extern "C" void gte_canonicalize_cpu_state(CPUState* cpu) { } #ifdef PSX_GTE_REGISTER_TEST +/* The precise-projection FIFO is the pgxp GTE register shadow (regs 12..15); + * these keep the historical test entry points working over it. */ extern "C" void gte_test_set_precise_valid_mask(uint32_t mask) { for (uint32_t i = 0; i < 4; ++i) - PSXRecomp::GTE::s_precise_sxy[i].valid = (mask >> i) & 1u; + pgxp_test_seed_gte_sxy(i, 0, 0, 0, 1, (mask >> i) & 1u); } extern "C" uint32_t gte_test_get_precise_valid_mask(void) { uint32_t mask = 0; - for (uint32_t i = 0; i < 4; ++i) - mask |= (PSXRecomp::GTE::s_precise_sxy[i].valid ? 1u : 0u) << i; + for (uint32_t i = 0; i < 4; ++i) { + uint8_t valid = 0; + pgxp_test_get_gte_sxy(i, nullptr, nullptr, nullptr, nullptr, &valid); + mask |= (valid ? 1u : 0u) << i; + } return mask; } extern "C" void gte_test_set_timeline_generations(uint32_t precision, uint32_t geometry) { - PSXRecomp::GTE::s_precision_generation = precision; + pgxp_test_set_generation(precision); PSXRecomp::GTE::s_geom_generation = geometry; } extern "C" uint32_t gte_test_get_precision_generation(void) { - return PSXRecomp::GTE::s_precision_generation; + return pgxp_test_generation(); } extern "C" uint32_t gte_test_get_geometry_generation(void) { @@ -2165,13 +2118,7 @@ extern "C" void gte_test_seed_precise_projection(uint32_t index, int32_t x16, int32_t y16, uint16_t z) { - if (index >= 4) return; - auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - p.packed = packed; - p.x16 = x16; - p.y16 = y16; - p.z = z; - p.valid = 1; + pgxp_test_seed_gte_sxy(index, packed, x16, y16, z, 1); } extern "C" void gte_test_get_precise_projection(uint32_t index, @@ -2180,13 +2127,7 @@ extern "C" void gte_test_get_precise_projection(uint32_t index, int32_t* y16, uint16_t* z, uint8_t* valid) { - if (index >= 4) return; - const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - if (packed) *packed = p.packed; - if (x16) *x16 = p.x16; - if (y16) *y16 = p.y16; - if (z) *z = p.z; - if (valid) *valid = p.valid; + pgxp_test_get_gte_sxy(index, packed, x16, y16, z, valid); } extern "C" void gte_test_seed_geometry(uint32_t packed, int32_t x16, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 460b184e..a0b0c554 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -32,6 +32,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" +#include "pgxp.h" #include "interrupts.h" #include "present_ring.h" #include "load_transition_ring.h" @@ -1108,6 +1109,8 @@ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ * SXY readback are untouched. Default off = the faithful floor. */ static int g_video_geometry_correction = 0; static int g_video_perspective_texturing = 0; +static int g_video_pgxp_cpu_mode = 0; +static float g_video_pgxp_tolerance = -1.0f; static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ @@ -10384,6 +10387,8 @@ int main(int argc, char** argv) { gc.runtime.video_geometry_correction ? 1 : 0; g_video_perspective_texturing = gc.runtime.video_perspective_texturing ? 1 : 0; + g_video_pgxp_cpu_mode = gc.runtime.video_pgxp_cpu_mode ? 1 : 0; + g_video_pgxp_tolerance = (float)gc.runtime.video_pgxp_tolerance; g_video_renderer = gc.runtime.video_renderer; g_video_screen = gc.runtime.video_screen_kind; g_video_aspect_num = gc.runtime.video_aspect_num; @@ -12120,6 +12125,8 @@ session_reboot: * the draw path is the faithful integer one, unchanged. */ gte_geometry_correction_set(g_video_geometry_correction); gpu_texture_correction_set(g_video_perspective_texturing); + pgxp_set_cpu_mode(g_video_pgxp_cpu_mode); + pgxp_set_tolerance(g_video_pgxp_tolerance); if (g_video_geometry_correction || g_video_perspective_texturing) { std::fprintf(stdout, "psxrecomp: geometry correction %s, perspective texturing %s%s\n", diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 74c4df0c..9c309083 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1555,7 +1555,6 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { } static void psx_write_word_raw(uint32_t addr, uint32_t val); -extern void gte_precision_invalidate_word(uint32_t addr); void psx_write_word(uint32_t addr, uint32_t val) { extern void (*g_overlay_flush_pending_cycles)(void); if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); @@ -1572,7 +1571,9 @@ void psx_write_word(uint32_t addr, uint32_t val) { } static void psx_write_word_raw(uint32_t addr, uint32_t val) { g_guest_store_count++; - gte_precision_invalidate_word(addr); + /* (pgxp) plain-store shadow invalidation retired: the PGXP engine + * validates tracked words against the actual packet word on read, so an + * overwritten word can never be believed (ENHANCEMENTS.md G1). */ /* KSEG2 cache control — before physical translation. */ if (addr == 0xFFFE0130u) { cache_ctrl = val; return; } /* KSEG2 guard — see psx_read_word_raw. */ @@ -1772,7 +1773,6 @@ void psx_write_half(uint32_t addr, uint16_t val) { } static void psx_write_half_raw(uint32_t addr, uint16_t val) { g_guest_store_count++; - gte_precision_invalidate_word(addr); if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* KSEG2 guard — see psx_read_word_raw. */ @@ -2108,7 +2108,6 @@ void psx_write_byte(uint32_t addr, uint8_t val) { } static void psx_write_byte_raw(uint32_t addr, uint8_t val) { g_guest_store_count++; - gte_precision_invalidate_word(addr); if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* KSEG2 guard — see psx_read_word_raw. */ diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp new file mode 100644 index 00000000..87f19332 --- /dev/null +++ b/runtime/src/pgxp.cpp @@ -0,0 +1,788 @@ +/* pgxp.cpp — PGXP value-propagation engine (ENHANCEMENTS.md G1.2/G1.3). + * + * CLEAN-ROOM implementation of the publicly documented PGXP technique + * (psx-spx GTE docs + public design write-ups + our own G1 measurements). + * The vendored duckstation/ (CC BY-NC-ND) and beetle-psx/ (GPL) trees are + * black-box behavioral oracles only — no code from them appears here. + * + * Model + * ----- + * Every 32-bit word of guest RAM/scratchpad, every GPR (plus HI/LO), and + * every GTE data register owns a shadow slot recording the sub-pixel screen + * position that word carries (16.16 X/Y + projected SZ depth), the exact + * guest word it describes (`value`), and per-half validity flags. RTPS/RTPT + * fill the SXY shadow FIFO with the pre-truncation projection; the + * psx_pgxp_* hooks copy shadows along with the data (loads, stores, COP2 + * transfers, and — in cpu-mode — the arithmetic games use to repack vertex + * halves); the GPU asks for the precise position of a GP0 vertex word by the + * packet's RAM address. + * + * The single safety invariant: a shadow is only ever BELIEVED after + * validation against the actual guest word it claims to describe. Anything + * that writes guest state without a hook (DMA, memcpy loaders, un-hooked + * instructions) simply leaves a stale shadow behind, and the next validation + * drops it. We never model side effects — overwrite and validate only. The + * one accepted hole (shared with the reference implementations): an untracked + * writer storing the byte-identical word keeps the shadow alive, which is + * harmless because the position it describes is still that word. + * + * Everything here is host-only and visual-only: guest-visible state is never + * read back from shadows, shadows are dropped on savestate/rewind, and the + * speculative native-validation bracket suppresses all recording. + */ + +#include "pgxp.h" +#include "pgxp_hooks.h" +#include "cpu_state.h" + +#include +#include + +/* gte.cpp — position-cache fallback tier (ambiguity-gated, G1.4 exact table) */ +extern "C" int gte_geometry_correction_lookup(uint32_t packed, + int32_t *x16, int32_t *y16); + +/* ------------------------------------------------------------------------- */ +/* Shadow storage */ +/* ------------------------------------------------------------------------- */ + +enum { + PGXP_F_VX = 1u << 0, /* low half (screen X) tracked */ + PGXP_F_VY = 1u << 1, /* high half (screen Y) tracked */ + PGXP_F_VZ = 1u << 2, /* projected depth rode along intact */ + PGXP_F_VXY = PGXP_F_VX | PGXP_F_VY, +}; + +struct PGXPValue { + int32_t x16, y16; /* sub-pixel screen coords, 16.16 */ + uint16_t z; /* projected SZ depth (perspective source), 0 = none */ + uint16_t flags; + uint32_t value; /* the guest word this shadow describes */ + uint32_t gen; /* valid iff == s_gen (O(1) invalidate-all) */ +}; + +#define PGXP_RAM_WORDS (0x200000u >> 2) /* 2 MB RAM */ +#define PGXP_SCRATCH_WORDS (0x400u >> 2) /* 1 KB scratchpad */ +#define PGXP_REG_HI 32 +#define PGXP_REG_LO 33 + +static PGXPValue *s_ram = nullptr; /* lazily allocated, ~10 MB */ +static PGXPValue s_scratch[PGXP_SCRATCH_WORDS]; +static PGXPValue s_gpr[34]; /* 32 GPRs + HI + LO */ +static PGXPValue s_gte[32]; /* GTE data registers */ + +static uint32_t s_gen = 1; +static int s_enabled = 0; +static int s_cpu_mode = 0; +static float s_tolerance = -1.0f; +static uint32_t s_suppress = 0; +static int s_deferred_invalidate = 0; + +/* Single hot-path gate for every hook. */ +static int g_pgxp_active = 0; +static inline void recompute_active(void) { + g_pgxp_active = (s_enabled && s_suppress == 0) ? 1 : 0; +} + +static PGXPStats s_stats; + +/* ------------------------------------------------------------------------- */ +/* Lifecycle */ +/* ------------------------------------------------------------------------- */ + +extern "C" void pgxp_invalidate_all(void) { + if (s_suppress != 0) { s_deferred_invalidate = 1; return; } + if (++s_gen == 0) { + /* generation wrapped: physically clear so stale slots can't revive */ + if (s_ram) std::memset(s_ram, 0, PGXP_RAM_WORDS * sizeof(PGXPValue)); + std::memset(s_scratch, 0, sizeof(s_scratch)); + std::memset(s_gpr, 0, sizeof(s_gpr)); + std::memset(s_gte, 0, sizeof(s_gte)); + s_gen = 1; + } +} + +extern "C" void pgxp_set_enabled(int enabled) { + if (enabled && !s_ram) { + s_ram = (PGXPValue *)std::calloc(PGXP_RAM_WORDS, sizeof(PGXPValue)); + if (!s_ram) enabled = 0; /* fail closed: stay faithful */ + } + s_enabled = enabled ? 1 : 0; + pgxp_invalidate_all(); + recompute_active(); +} + +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" int pgxp_cpu_mode(void) { return s_cpu_mode; } + +extern "C" void pgxp_set_tolerance(float pixels) { s_tolerance = pixels; } +extern "C" float pgxp_tolerance(void) { return s_tolerance; } + +extern "C" void pgxp_suppress_begin(void) { + ++s_suppress; + recompute_active(); +} + +extern "C" void pgxp_suppress_end(void) { + if (s_suppress != 0 && --s_suppress == 0) { + if (s_deferred_invalidate) { + s_deferred_invalidate = 0; + pgxp_invalidate_all(); + } + recompute_active(); + } +} + +extern "C" void pgxp_get_stats(PGXPStats *out) { + if (out) *out = s_stats; +} + +/* ------------------------------------------------------------------------- */ +/* Address mapping + validation */ +/* ------------------------------------------------------------------------- */ + +/* Guest address -> shadow slot, or NULL for BIOS/MMIO/KSEG2 (untrackable). */ +static inline PGXPValue *pgxp_ptr(uint32_t addr) { + uint32_t m = addr & 0x1FFFFFFFu; + if (m < 0x00800000u) /* RAM + its mirrors */ + return s_ram ? &s_ram[(m & 0x1FFFFCu) >> 2] : nullptr; + if ((m & 0xFFFFFC00u) == 0x1F800000u) /* scratchpad */ + return &s_scratch[(m & 0x3FCu) >> 2]; + return nullptr; +} + +static inline int pv_live(const PGXPValue *pv) { + return pv && pv->gen == s_gen && (pv->flags & PGXP_F_VXY) != 0; +} + +/* Drop whichever tracked halves no longer match the actual guest word; the + * depth belongs to the whole vertex, so any half going stale kills it too. */ +static inline void pv_validate(PGXPValue *pv, uint32_t actual) { + if (pv->gen != s_gen) return; + uint32_t diff = pv->value ^ actual; + if ((pv->flags & PGXP_F_VX) && (diff & 0x0000FFFFu)) + pv->flags &= (uint16_t)~(PGXP_F_VX | PGXP_F_VZ); + if ((pv->flags & PGXP_F_VY) && (diff & 0xFFFF0000u)) + pv->flags &= (uint16_t)~(PGXP_F_VY | PGXP_F_VZ); + pv->value = actual; +} + +/* Mark a slot as "known word, no precision" — keeps `value` current so later + * half-merges stay keyed correctly. */ +static inline void pv_reset(PGXPValue *pv, uint32_t value) { + pv->x16 = 0; pv->y16 = 0; pv->z = 0; + pv->flags = 0; + pv->value = value; + pv->gen = s_gen; +} + +static inline void pv_kill(PGXPValue *pv) { pv->gen = 0; } + +/* ------------------------------------------------------------------------- */ +/* Instruction field helpers */ +/* ------------------------------------------------------------------------- */ + +static inline uint32_t f_op(uint32_t i) { return i >> 26; } +static inline uint32_t f_rs(uint32_t i) { return (i >> 21) & 31u; } +static inline uint32_t f_rt(uint32_t i) { return (i >> 16) & 31u; } +static inline uint32_t f_rd(uint32_t i) { return (i >> 11) & 31u; } +static inline uint32_t f_shamt(uint32_t i) { return (i >> 6) & 31u; } +static inline uint32_t f_funct(uint32_t i) { return i & 63u; } +static inline int32_t f_simm(uint32_t i) { return (int32_t)(int16_t)(i & 0xFFFFu); } + +/* ------------------------------------------------------------------------- */ +/* Memory-mode hooks: loads / stores */ +/* ------------------------------------------------------------------------- */ + +extern "C" void psx_pgxp_load(struct CPUState *cpu, uint32_t instr, + uint32_t addr, uint32_t value) { + (void)cpu; + if (!g_pgxp_active) return; + uint32_t rt = f_rt(instr); + if (rt == 0) return; + PGXPValue *dst = &s_gpr[rt]; + + switch (f_op(instr)) { + case 0x23: { /* LW */ + PGXPValue *src = pgxp_ptr(addr); + if (src && src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x21: /* LH */ + case 0x25: { /* LHU */ + PGXPValue *src = pgxp_ptr(addr); + int hi_half = (addr >> 1) & 1; + pv_reset(dst, value); + /* The high half of the extended GPR is a known-exact constant + * (0/-1 for LH, 0 for LHU): track it so re-packing via sll/or in + * cpu-mode keeps working. */ + dst->y16 = ((int32_t)value >> 16) << 16; + dst->flags = PGXP_F_VY; + if (src && src->gen == s_gen) { + uint32_t actual_half = (value & 0xFFFFu) << (hi_half ? 16 : 0); + uint32_t mask = hi_half ? 0xFFFF0000u : 0x0000FFFFu; + uint16_t want = hi_half ? PGXP_F_VY : PGXP_F_VX; + if ((src->flags & want) && ((src->value ^ actual_half) & mask) == 0) { + dst->x16 = hi_half ? src->y16 : src->x16; + dst->flags |= PGXP_F_VX; + } + } + return; + } + default: /* LB/LBU/LWL/LWR: untrackable */ + pv_reset(dst, value); + return; + } +} + +extern "C" void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, + uint32_t addr, uint32_t value) { + (void)cpu; + if (!g_pgxp_active) return; + PGXPValue *dst = pgxp_ptr(addr); + if (!dst) return; + uint32_t rt = f_rt(instr); + PGXPValue *src = (rt != 0) ? &s_gpr[rt] : nullptr; + + switch (f_op(instr)) { + case 0x2B: { /* SW */ + if (src && src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x29: { /* SH */ + int hi_half = (addr >> 1) & 1; + uint32_t half = value & 0xFFFFu; + if (dst->gen != s_gen) pv_reset(dst, half << (hi_half ? 16 : 0)); + /* Patch the stored half into the tracked word; the other half's + * validity (if any) survives untouched. Depth never survives a + * half-write — the vertex it described no longer exists whole. */ + if (hi_half) dst->value = (dst->value & 0x0000FFFFu) | (half << 16); + else dst->value = (dst->value & 0xFFFF0000u) | half; + dst->flags &= (uint16_t)~((hi_half ? PGXP_F_VY : PGXP_F_VX) | PGXP_F_VZ); + dst->z = 0; + if (src && src->gen == s_gen && (src->flags & PGXP_F_VX) && + ((src->value ^ value) & 0xFFFFu) == 0) { + if (hi_half) { dst->y16 = src->x16; dst->flags |= PGXP_F_VY; } + else { dst->x16 = src->x16; dst->flags |= PGXP_F_VX; } + } + return; + } + case 0x28: { /* SB */ + if (dst->gen != s_gen) return; /* nothing tracked: stay dead */ + uint32_t shift = (addr & 3u) * 8u; + dst->value = (dst->value & ~(0xFFu << shift)) | + ((value & 0xFFu) << shift); + dst->flags &= (uint16_t)~(((addr & 2u) ? PGXP_F_VY : PGXP_F_VX) | PGXP_F_VZ); + dst->z = 0; + return; + } + default: /* SWL/SWR: forget the word */ + pv_kill(dst); + return; + } +} + +/* ------------------------------------------------------------------------- */ +/* Memory-mode hooks: COP2 transfers */ +/* ------------------------------------------------------------------------- */ + +extern "C" void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, + uint32_t value, uint32_t addr) { + (void)cpu; + if (!g_pgxp_active) return; + + switch (f_op(instr)) { + case 0x32: { /* LWC2: gte[rt] <- [addr] */ + PGXPValue *src = pgxp_ptr(addr); + PGXPValue *dst = &s_gte[f_rt(instr)]; + if (src && src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x3A: { /* SWC2: [addr] <- gte[rt] */ + PGXPValue *dst = pgxp_ptr(addr); + if (!dst) return; + PGXPValue *src = &s_gte[f_rt(instr)]; + if (src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x12: { /* COP2 register transfers */ + switch (f_rs(instr)) { + case 0x00: { /* MFC2: gpr[rt] <- gte[rd] */ + uint32_t rt = f_rt(instr); + if (rt == 0) return; + PGXPValue *src = &s_gte[f_rd(instr)]; + if (src->gen == s_gen) { + pv_validate(src, value); + s_gpr[rt] = *src; + } else { + pv_reset(&s_gpr[rt], value); + } + return; + } + case 0x04: { /* MTC2: gte[rd] <- gpr[rt] */ + uint32_t rt = f_rt(instr); + PGXPValue *dst = &s_gte[f_rd(instr)]; + if (rt != 0 && s_gpr[rt].gen == s_gen) { + pv_validate(&s_gpr[rt], value); + *dst = s_gpr[rt]; + } else { + pv_reset(dst, value); + } + /* An SXYP write (rd==15) shifts the hardware FIFO; we do not + * model it — regs 12..14 now describe words they no longer + * match, and validation drops them on next use. */ + return; + } + case 0x02: { /* CFC2: control regs carry no + positions */ + uint32_t rt = f_rt(instr); + if (rt != 0) pv_reset(&s_gpr[rt], value); + return; + } + default: /* CTC2: nothing shadowed */ + return; + } + } + default: + return; + } +} + +/* ------------------------------------------------------------------------- */ +/* CPU-mode hooks: arithmetic (tier 2, default off) */ +/* ------------------------------------------------------------------------- */ + +/* One 16-bit operand component in 16.16: a tracked half contributes its + * sub-pixel value; an untracked half contributes its exact integer value + * (adding an exact offset to a tracked coordinate keeps the fraction). */ +static inline int32_t comp16(const PGXPValue *pv, uint32_t value, int hi_half) { + if (pv && pv->gen == s_gen) { + if (!hi_half && (pv->flags & PGXP_F_VX) && + ((pv->value ^ value) & 0x0000FFFFu) == 0) + return pv->x16; + if (hi_half && (pv->flags & PGXP_F_VY) && + ((pv->value ^ value) & 0xFFFF0000u) == 0) + return pv->y16; + } + return ((int32_t)(int16_t)(hi_half ? (value >> 16) : value)) << 16; +} + +static inline int comp_tracked(const PGXPValue *pv, uint32_t value, int hi_half) { + if (!pv || pv->gen != s_gen) return 0; + if (!hi_half) + return (pv->flags & PGXP_F_VX) && + ((pv->value ^ value) & 0x0000FFFFu) == 0; + return (pv->flags & PGXP_F_VY) && + ((pv->value ^ value) & 0xFFFF0000u) == 0; +} + +/* Half-wise add/sub is only meaningful when the guest result shows no carry + * crossed the half boundary; otherwise the halves did not combine + * independently and the shadow must not pretend they did. */ +static inline int halves_independent(uint32_t a, uint32_t b, uint32_t r, int sub) { + uint32_t lo = sub ? ((a & 0xFFFFu) - (b & 0xFFFFu)) + : ((a & 0xFFFFu) + (b & 0xFFFFu)); + return ((lo ^ r) & 0xFFFFu) == 0 && + ((((sub ? a - b : a + b)) ^ r) == 0) && + ((lo >> 16) == 0); /* no carry/borrow out of low */ +} + +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; + + uint32_t op = f_op(instr); + uint32_t dst_reg; + PGXPValue *a = nullptr, *b = nullptr; /* source shadows (may be 0) */ + + if (op == 0) { /* SPECIAL */ + dst_reg = f_rd(instr); + uint32_t rs = f_rs(instr), rt = f_rt(instr); + uint32_t funct = f_funct(instr); + if (funct == 0x11 || funct == 0x13) { /* MTHI / MTLO */ + PGXPValue *hl = &s_gpr[funct == 0x11 ? PGXP_REG_HI : PGXP_REG_LO]; + if (s_cpu_mode && rs != 0 && s_gpr[rs].gen == s_gen) { + pv_validate(&s_gpr[rs], result); + *hl = s_gpr[rs]; + } else pv_reset(hl, result); + return; + } + if (dst_reg == 0) return; + PGXPValue *dst = &s_gpr[dst_reg]; + + switch (funct) { + case 0x10: /* MFHI */ + if (s_cpu_mode && s_gpr[PGXP_REG_HI].gen == s_gen) { + pv_validate(&s_gpr[PGXP_REG_HI], result); + *dst = s_gpr[PGXP_REG_HI]; + } else pv_reset(dst, result); + return; + case 0x12: /* MFLO */ + if (s_cpu_mode && s_gpr[PGXP_REG_LO].gen == s_gen) { + pv_validate(&s_gpr[PGXP_REG_LO], result); + *dst = s_gpr[PGXP_REG_LO]; + } else pv_reset(dst, result); + return; + case 0x00: case 0x02: case 0x03: /* SLL / SRL / SRA */ + case 0x04: case 0x06: case 0x07: { /* SLLV / SRLV / SRAV */ + uint32_t sh = (funct < 4) ? f_shamt(instr) : (s2 & 31u); + uint32_t src_reg = rt; + PGXPValue *src = (src_reg != 0) ? &s_gpr[src_reg] : nullptr; + int right = (funct & 3u) != 0; + if (!s_cpu_mode || sh != 16 || !src || src->gen != s_gen) { + pv_reset(dst, result); + return; + } + pv_validate(src, s1); + pv_reset(dst, result); + if (!right) { /* << 16: low comp -> Y */ + if (src->flags & PGXP_F_VX) { + dst->y16 = src->x16; + dst->flags |= PGXP_F_VY; + } + dst->x16 = 0; + dst->flags |= PGXP_F_VX; /* low half exactly zero */ + } else { /* >> 16: high comp -> X */ + if (src->flags & PGXP_F_VY) { + dst->x16 = src->y16; + dst->flags |= PGXP_F_VX; + } + dst->y16 = ((int32_t)result >> 16) << 16; /* 0 or sign fill */ + dst->flags |= PGXP_F_VY; + } + return; + } + case 0x20: case 0x21: /* ADD / ADDU */ + case 0x22: case 0x23: /* SUB / SUBU */ + case 0x25: { /* OR */ + /* MOVE idioms first — they matter even without full cpu-mode + * arithmetic, and they are exact. */ + int is_or = (funct == 0x25); + int is_sub = (funct == 0x22 || funct == 0x23); + if ((is_or || !is_sub) && rt == 0) { /* op rd, rs, $zero */ + if (rs != 0 && s_gpr[rs].gen == s_gen) { + pv_validate(&s_gpr[rs], result); + *dst = s_gpr[rs]; + } else pv_reset(dst, result); + return; + } + if ((is_or || !is_sub) && rs == 0) { /* op rd, $zero, rt */ + if (rt != 0 && s_gpr[rt].gen == s_gen) { + pv_validate(&s_gpr[rt], result); + *dst = s_gpr[rt]; + } else pv_reset(dst, result); + return; + } + if (!s_cpu_mode) { pv_reset(dst, result); return; } + a = (rs != 0) ? &s_gpr[rs] : nullptr; + b = (rt != 0) ? &s_gpr[rt] : nullptr; + if (is_or) { + /* Merge disjoint halves (the lhu/sll/or repack pattern). */ + if ((s1 & s2) != 0) { pv_reset(dst, result); return; } + pv_reset(dst, result); + const PGXPValue *xsrc; uint32_t xval; + if ((s1 & 0xFFFFu) != 0) { xsrc = a; xval = s1; } + else { xsrc = b; xval = s2; } + const PGXPValue *ysrc; uint32_t yval; + if ((s1 & 0xFFFF0000u) != 0) { ysrc = a; yval = s1; } + else { ysrc = b; yval = s2; } + if (comp_tracked(xsrc, xval, 0)) { + dst->x16 = xsrc->x16; dst->flags |= PGXP_F_VX; + } else if ((result & 0xFFFFu) == 0) { + dst->x16 = 0; dst->flags |= PGXP_F_VX; + } + if (comp_tracked(ysrc, yval, 1)) { + dst->y16 = ysrc->y16; dst->flags |= PGXP_F_VY; + } else if ((result & 0xFFFF0000u) == 0) { + dst->y16 = 0; dst->flags |= PGXP_F_VY; + } + return; + } + /* add/sub: require at least one tracked component and halves + * that combined independently (no cross-half carry). */ + if (!halves_independent(s1, s2, result, is_sub) || + (!comp_tracked(a, s1, 0) && !comp_tracked(a, s1, 1) && + !comp_tracked(b, s2, 0) && !comp_tracked(b, s2, 1))) { + pv_reset(dst, result); + return; + } + pv_reset(dst, result); + dst->x16 = is_sub ? comp16(a, s1, 0) - comp16(b, s2, 0) + : comp16(a, s1, 0) + comp16(b, s2, 0); + dst->y16 = is_sub ? comp16(a, s1, 1) - comp16(b, s2, 1) + : comp16(a, s1, 1) + comp16(b, s2, 1); + dst->flags = PGXP_F_VXY; + return; + } + default: + pv_reset(dst, result); + return; + } + } + + /* immediates */ + dst_reg = f_rt(instr); + if (dst_reg == 0) return; + PGXPValue *dst = &s_gpr[dst_reg]; + uint32_t rs = f_rs(instr); + + switch (op) { + case 0x0F: { /* LUI: both halves exact */ + pv_reset(dst, result); + dst->x16 = 0; + dst->y16 = ((int32_t)result >> 16) << 16; + dst->flags = PGXP_F_VXY; + return; + } + case 0x08: case 0x09: { /* ADDI / ADDIU */ + int32_t imm = f_simm(instr); + if (imm == 0) { /* MOVE idiom */ + if (rs != 0 && s_gpr[rs].gen == s_gen) { + pv_validate(&s_gpr[rs], result); + *dst = s_gpr[rs]; + } else pv_reset(dst, result); + return; + } + a = (rs != 0) ? &s_gpr[rs] : nullptr; + /* A negative immediate is a subtraction of its magnitude: checking + * carry on the sign-extended form would reject nearly every -N. */ + { + int neg = imm < 0; + uint32_t mag = (uint32_t)(neg ? -imm : imm); + if (!s_cpu_mode || + !halves_independent(s1, mag, result, neg) || + (!comp_tracked(a, s1, 0) && !comp_tracked(a, s1, 1))) { + pv_reset(dst, result); + return; + } + int32_t dx = (int32_t)(mag & 0xFFFFu) << 16; + pv_reset(dst, result); + dst->x16 = comp16(a, s1, 0) + (neg ? -dx : dx); + dst->y16 = comp16(a, s1, 1); /* no carry crossed: Y untouched */ + dst->flags = PGXP_F_VXY; + } + return; + } + case 0x0D: { /* ORI */ + uint32_t imm = instr & 0xFFFFu; + if (imm == 0 || (s_cpu_mode && (s1 & 0xFFFFu) == 0)) { + a = (rs != 0) ? &s_gpr[rs] : nullptr; + pv_reset(dst, result); + if (comp_tracked(a, s1, 1)) { + dst->y16 = a->y16; dst->flags |= PGXP_F_VY; + } + if (imm == 0) { + if (comp_tracked(a, s1, 0)) { + dst->x16 = a->x16; dst->flags |= PGXP_F_VX; + } + } else { /* exact constant low half */ + dst->x16 = ((int32_t)(int16_t)imm) << 16; + dst->flags |= PGXP_F_VX; + } + return; + } + pv_reset(dst, result); + return; + } + default: /* ANDI/XORI/SLTI/... */ + pv_reset(dst, result); + return; + } +} + +extern "C" void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, + uint32_t hi, uint32_t lo, + uint32_t s1, uint32_t s2) { + (void)cpu; (void)instr; (void)s1; (void)s2; + if (!g_pgxp_active) return; + /* 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); + pv_reset(&s_gpr[PGXP_REG_LO], lo); +} + +/* ------------------------------------------------------------------------- */ +/* GTE producer */ +/* ------------------------------------------------------------------------- */ + +extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, + uint32_t packed) { + if (!g_pgxp_active) return; + s_gte[12] = s_gte[13]; + s_gte[13] = s_gte[14]; + PGXPValue *pv = &s_gte[14]; + pv->x16 = x16; + pv->y16 = y16; + pv->z = sz3; + pv->flags = (uint16_t)(PGXP_F_VXY | (sz3 != 0 ? PGXP_F_VZ : 0)); + pv->value = packed; + pv->gen = s_gen; + s_gte[15] = *pv; /* SXYP mirrors SXY2 */ +} + +extern "C" void pgxp_gte_reg_written(int reg, uint32_t value) { + /* Invalidation-class bookkeeping: runs even with the engine disarmed so + * seeded/leftover shadows can never outlive a guest register write. Only + * the suppression bracket skips it (the speculative pass rolls the + * machine state back, so its writes must not stick to the shadows). */ + if (s_suppress != 0) return; + if (reg < 0 || reg > 31) return; + pv_kill(&s_gte[reg]); + pv_reset(&s_gte[reg], value); +} + +/* ------------------------------------------------------------------------- */ +/* GPU consumer */ +/* ------------------------------------------------------------------------- */ + +extern "C" int pgxp_get_precise_vertex(uint32_t addr, uint32_t packet_word, + int32_t int_x, int32_t int_y, + int32_t *x16, int32_t *y16, + uint16_t *sz) { + s_stats.lookups++; + + int32_t px = 0, py = 0; + uint16_t pz = 0; + int have = 0; + + if (s_enabled && addr != 0xFFFFFFFFu) { + PGXPValue *pv = pgxp_ptr(addr); + if (pv && pv->gen == s_gen && (pv->flags & PGXP_F_VXY) != 0) { + if (pv->value == packet_word && + (pv->flags & PGXP_F_VXY) == PGXP_F_VXY) { + px = pv->x16; + py = pv->y16; + pz = (pv->flags & PGXP_F_VZ) ? pv->z : 0; + have = PGXP_SRC_DATAFLOW; + } else { + s_stats.value_mismatch++; + } + } + } + + if (!have && gte_geometry_correction_lookup(packet_word, &px, &py)) { + pz = 0; /* fallback never carries depth */ + have = PGXP_SRC_FALLBACK; + } + + if (have) { + /* Truncation agreement: the GPU parsed 11-bit integers out of the + * packet; a precise position whose integer part disagrees (a + * wrapped/CPU-modified coordinate) must not be believed. */ + if ((px >> 16) != int_x || (py >> 16) != int_y) { + s_stats.trunc_reject++; + have = 0; + } else if (s_tolerance >= 0.0f) { + float dx = (float)(px - (int_x << 16)) * (1.0f / 65536.0f); + float dy = (float)(py - (int_y << 16)) * (1.0f / 65536.0f); + if (dx > s_tolerance || dy > s_tolerance) { + s_stats.tolerance_reject++; + have = 0; + } + } + } + + if (!have) { + s_stats.native++; + *x16 = int_x << 16; + *y16 = int_y << 16; + *sz = 0; + return PGXP_SRC_NATIVE; + } + + if (have == PGXP_SRC_DATAFLOW) s_stats.dataflow_hit++; + else s_stats.fallback_hit++; + if (pz != 0) s_stats.w_valid++; + *x16 = px; + *y16 = py; + *sz = pz; + return have; +} + +/* ------------------------------------------------------------------------- */ +/* Legacy v14 SWC2 tracker — kept as the base-flavour feed */ +/* ------------------------------------------------------------------------- */ + +/* Emitted at every swc2 since ABI v14 (all flavours, all backends, overlay + * DLLs). In the pgxp flavour psx_pgxp_cop2 supersedes it for the same swc2 — + * the double write is idempotent (same source shadow, same destination). */ +extern "C" void pgxp_store_gte_reg(uint32_t addr, uint8_t reg) { + if (!g_pgxp_active) return; + PGXPValue *dst = pgxp_ptr(addr); + if (!dst) return; + const PGXPValue *src = &s_gte[reg & 31u]; + if (src->gen != s_gen) return; + *dst = *src; +} + +/* ------------------------------------------------------------------------- */ +/* Test accessors */ +/* ------------------------------------------------------------------------- */ + +extern "C" void pgxp_test_seed_gte_sxy(uint32_t index, uint32_t packed, + int32_t x16, int32_t y16, uint16_t z, + int valid) { + if (index >= 4) return; + PGXPValue *pv = &s_gte[12 + index]; + pv->x16 = x16; + pv->y16 = y16; + pv->z = z; + pv->value = packed; + pv->flags = (uint16_t)(PGXP_F_VXY | (z != 0 ? PGXP_F_VZ : 0)); + pv->gen = valid ? s_gen : 0; +} + +extern "C" void pgxp_test_get_gte_sxy(uint32_t index, uint32_t *packed, + int32_t *x16, int32_t *y16, uint16_t *z, + uint8_t *valid) { + if (index >= 4) return; + const PGXPValue *pv = &s_gte[12 + index]; + if (packed) *packed = pv->value; + if (x16) *x16 = pv->x16; + if (y16) *y16 = pv->y16; + if (z) *z = pv->z; + if (valid) *valid = pv_live(pv) ? 1 : 0; +} + +extern "C" uint32_t pgxp_test_generation(void) { return s_gen; } + +extern "C" void pgxp_test_set_generation(uint32_t gen) { s_gen = gen; } + +/* Address-keyed depth lookup for the shipped perspective-texturing path + * (gpu.c prepare_texture_triangle). Same contract as the retired hashed + * table: hit only when the tracked word matches the packet word exactly. */ +extern "C" int pgxp_load_precise_word(uint32_t addr, uint32_t packed, + int32_t *x16, int32_t *y16, uint16_t *z) { + if (!s_enabled) return 0; + PGXPValue *pv = pgxp_ptr(addr); + if (!pv || pv->gen != s_gen || pv->value != packed || + (pv->flags & PGXP_F_VXY) != PGXP_F_VXY) + return 0; + if (x16) *x16 = pv->x16; + if (y16) *y16 = pv->y16; + if (z) *z = (pv->flags & PGXP_F_VZ) ? pv->z : 0; + return (pv->flags & PGXP_F_VZ) && pv->z != 0; +} diff --git a/runtime/src/psx_interpreter.c b/runtime/src/psx_interpreter.c index 0ca57ba3..340305f0 100644 --- a/runtime/src/psx_interpreter.c +++ b/runtime/src/psx_interpreter.c @@ -211,12 +211,18 @@ static void exec_one(CPUState* cpu) { uint32_t rd = RD(insn); uint32_t sa = SA(insn); switch (func) { - case 0x00: set_reg(cpu, rd, rt_val << sa); break; /* SLL */ - case 0x02: set_reg(cpu, rd, rt_val >> sa); break; /* SRL */ - case 0x03: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> sa)); break; /* SRA */ - case 0x04: set_reg(cpu, rd, rt_val << (rs_val & 31)); break; /* SLLV */ - case 0x06: set_reg(cpu, rd, rt_val >> (rs_val & 31)); break; /* SRLV */ - case 0x07: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> (rs_val & 31))); break; /* SRAV */ + case 0x00: set_reg(cpu, rd, rt_val << sa); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, sa); break; /* SLL */ + case 0x02: set_reg(cpu, rd, rt_val >> sa); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, sa); break; /* SRL */ + case 0x03: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> sa)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, sa); break; /* SRA */ + case 0x04: set_reg(cpu, rd, rt_val << (rs_val & 31)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, rs_val); break; /* SLLV */ + case 0x06: set_reg(cpu, rd, rt_val >> (rs_val & 31)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, rs_val); break; /* SRLV */ + case 0x07: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> (rs_val & 31))); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, rs_val); break; /* SRAV */ case 0x08: { /* JR */ uint32_t target = rs_val; exec_one(cpu); /* delay slot */ @@ -232,41 +238,54 @@ static void exec_one(CPUState* cpu) { } case 0x0C: raise_exception(cpu, 8, 0); return; /* SYSCALL */ case 0x0D: raise_exception(cpu, 9, 0); return; /* BREAK */ - case 0x10: set_reg(cpu, rd, cpu->hi); break; /* MFHI */ - case 0x11: cpu->hi = rs_val; break; /* MTHI */ - case 0x12: set_reg(cpu, rd, cpu->lo); break; /* MFLO */ - case 0x13: cpu->lo = rs_val; break; /* MTLO */ + case 0x10: set_reg(cpu, rd, cpu->hi); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->hi, 0); break; /* MFHI */ + case 0x11: cpu->hi = rs_val; + psx_pgxp_alu(cpu, insn, cpu->hi, rs_val, 0); break; /* MTHI */ + case 0x12: set_reg(cpu, rd, cpu->lo); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->lo, 0); break; /* MFLO */ + case 0x13: cpu->lo = rs_val; + psx_pgxp_alu(cpu, insn, cpu->lo, rs_val, 0); break; /* MTLO */ case 0x18: { /* MULT */ int64_t r = (int64_t)(int32_t)rs_val * (int64_t)(int32_t)rt_val; - cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); break; + cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; } case 0x19: { /* MULTU */ uint64_t r = (uint64_t)rs_val * (uint64_t)rt_val; - cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); break; + cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; } case 0x1A: /* DIV */ if (rt_val == 0) { cpu->lo = (rs_val & 0x80000000u) ? 1 : 0xFFFFFFFFu; cpu->hi = rs_val; } else if (rs_val == 0x80000000u && rt_val == 0xFFFFFFFFu) { cpu->lo = 0x80000000u; cpu->hi = 0; } else { cpu->lo = (uint32_t)((int32_t)rs_val / (int32_t)rt_val); cpu->hi = (uint32_t)((int32_t)rs_val % (int32_t)rt_val); } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; case 0x1B: /* DIVU */ if (rt_val == 0) { cpu->lo = 0xFFFFFFFFu; cpu->hi = rs_val; } else { cpu->lo = rs_val / rt_val; cpu->hi = rs_val % rt_val; } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; case 0x20: { /* ADD (overflow trap) */ int64_t r = (int64_t)(int32_t)rs_val + (int64_t)(int32_t)rt_val; if (r < (int64_t)INT32_MIN || r > (int64_t)INT32_MAX) { raise_exception(cpu, 12, 0); return; } - set_reg(cpu, rd, (uint32_t)(int32_t)r); break; + set_reg(cpu, rd, (uint32_t)(int32_t)r); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; } - case 0x21: set_reg(cpu, rd, rs_val + rt_val); break; /* ADDU */ + case 0x21: set_reg(cpu, rd, rs_val + rt_val); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; /* ADDU */ case 0x22: { /* SUB (overflow trap) */ int64_t r = (int64_t)(int32_t)rs_val - (int64_t)(int32_t)rt_val; if (r < (int64_t)INT32_MIN || r > (int64_t)INT32_MAX) { raise_exception(cpu, 12, 0); return; } - set_reg(cpu, rd, (uint32_t)(int32_t)r); break; + set_reg(cpu, rd, (uint32_t)(int32_t)r); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; } - case 0x23: set_reg(cpu, rd, rs_val - rt_val); break; /* SUBU */ + case 0x23: set_reg(cpu, rd, rs_val - rt_val); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; /* SUBU */ case 0x24: set_reg(cpu, rd, rs_val & rt_val); break; /* AND */ - case 0x25: set_reg(cpu, rd, rs_val | rt_val); break; /* OR */ + case 0x25: set_reg(cpu, rd, rs_val | rt_val); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; /* OR */ case 0x26: set_reg(cpu, rd, rs_val ^ rt_val); break; /* XOR */ case 0x27: set_reg(cpu, rd, ~(rs_val | rt_val)); break; /* NOR */ case 0x2A: set_reg(cpu, rd, (int32_t)rs_val < (int32_t)rt_val ? 1 : 0); break; /* SLT */ @@ -339,15 +358,19 @@ static void exec_one(CPUState* cpu) { case 0x08: { /* ADDI (overflow trap) */ int64_t r = (int64_t)(int32_t)rs_val + (int64_t)SIMM(insn); if (r < (int64_t)INT32_MIN || r > (int64_t)INT32_MAX) { raise_exception(cpu, 12, 0); return; } - set_reg(cpu, RT(insn), (uint32_t)(int32_t)r); break; + set_reg(cpu, RT(insn), (uint32_t)(int32_t)r); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], rs_val, (uint32_t)SIMM(insn)); break; } - case 0x09: set_reg(cpu, RT(insn), rs_val + (uint32_t)SIMM(insn)); break; /* ADDIU */ + case 0x09: set_reg(cpu, RT(insn), rs_val + (uint32_t)SIMM(insn)); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], rs_val, (uint32_t)SIMM(insn)); break; /* ADDIU */ case 0x0A: set_reg(cpu, RT(insn), (int32_t)rs_val < SIMM(insn) ? 1 : 0); break; /* SLTI */ case 0x0B: set_reg(cpu, RT(insn), rs_val < (uint32_t)SIMM(insn) ? 1 : 0); break; /* SLTIU */ case 0x0C: set_reg(cpu, RT(insn), rs_val & IMM16(insn)); break; /* ANDI */ - case 0x0D: set_reg(cpu, RT(insn), rs_val | IMM16(insn)); break; /* ORI */ + case 0x0D: set_reg(cpu, RT(insn), rs_val | IMM16(insn)); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], rs_val, IMM16(insn)); break; /* ORI */ case 0x0E: set_reg(cpu, RT(insn), rs_val ^ IMM16(insn)); break; /* XORI */ - case 0x0F: set_reg(cpu, RT(insn), (uint32_t)IMM16(insn) << 16); break; /* LUI */ + case 0x0F: set_reg(cpu, RT(insn), (uint32_t)IMM16(insn) << 16); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], 0, 0); break; /* LUI */ /* --- COP0 --- */ case 0x10: { @@ -376,14 +399,21 @@ static void exec_one(CPUState* cpu) { case 0x12: { uint32_t cop_op = RS(insn); switch (cop_op) { - case 0x00: /* MFC2 */ - set_load_delay(RT(insn), gte_read_data(cpu, RD(insn))); + case 0x00: { /* MFC2 */ + uint32_t v = gte_read_data(cpu, RD(insn)); + set_load_delay(RT(insn), v); + psx_pgxp_cop2(cpu, insn, v, 0); break; - case 0x02: /* CFC2 */ - set_load_delay(RT(insn), gte_read_ctrl(cpu, RD(insn))); + } + case 0x02: { /* CFC2 */ + uint32_t v = gte_read_ctrl(cpu, RD(insn)); + set_load_delay(RT(insn), v); + psx_pgxp_cop2(cpu, insn, v, 0); break; + } case 0x04: /* MTC2 */ gte_write_data(cpu, RD(insn), rt_val); + psx_pgxp_cop2(cpu, insn, rt_val, 0); break; case 0x06: /* CTC2 */ gte_write_ctrl(cpu, RD(insn), rt_val); @@ -403,12 +433,16 @@ static void exec_one(CPUState* cpu) { /* --- Loads --- */ case 0x20: { /* LB */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)(int32_t)(int8_t)cpu->read_byte(addr)); + uint32_t v = (uint32_t)(int32_t)(int8_t)cpu->read_byte(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x21: { /* LH */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)(int32_t)(int16_t)cpu->read_half(addr)); + uint32_t v = (uint32_t)(int32_t)(int16_t)cpu->read_half(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x22: { /* LWL */ @@ -423,21 +457,28 @@ static void exec_one(CPUState* cpu) { case 3: cur = word; break; } set_reg(cpu, RT(insn), cur); + psx_pgxp_load(cpu, insn, addr, cur); break; } case 0x23: { /* LW */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), cpu->read_word(addr)); + uint32_t v = cpu->read_word(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x24: { /* LBU */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)cpu->read_byte(addr)); + uint32_t v = (uint32_t)cpu->read_byte(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x25: { /* LHU */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)cpu->read_half(addr)); + uint32_t v = (uint32_t)cpu->read_half(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x26: { /* LWR */ @@ -452,6 +493,7 @@ static void exec_one(CPUState* cpu) { case 3: cur = (cur & 0xFFFFFF00u) | (word >> 24); break; } set_reg(cpu, RT(insn), cur); + psx_pgxp_load(cpu, insn, addr, cur); break; } @@ -459,11 +501,13 @@ static void exec_one(CPUState* cpu) { case 0x28: { /* SB */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); cpu->write_byte(addr, (uint8_t)(rt_val & 0xFF)); + psx_pgxp_store(cpu, insn, addr, rt_val & 0xFFu); break; } case 0x29: { /* SH */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); cpu->write_half(addr, (uint16_t)(rt_val & 0xFFFF)); + psx_pgxp_store(cpu, insn, addr, rt_val & 0xFFFFu); break; } case 0x2A: { /* SWL */ @@ -477,11 +521,13 @@ static void exec_one(CPUState* cpu) { case 3: cur = rt_val; break; } cpu->write_word(aligned, cur); + psx_pgxp_store(cpu, insn, addr, rt_val); break; } case 0x2B: { /* SW */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); cpu->write_word(addr, rt_val); + psx_pgxp_store(cpu, insn, addr, rt_val); break; } case 0x2E: { /* SWR */ @@ -495,19 +541,24 @@ static void exec_one(CPUState* cpu) { case 3: cur = (cur & 0x00FFFFFFu) | (rt_val << 24); break; } cpu->write_word(aligned, cur); + psx_pgxp_store(cpu, insn, addr, rt_val); break; } /* --- COP2 load/store (LWC2/SWC2) --- */ case 0x32: { /* LWC2 */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - gte_write_data(cpu, RT(insn), cpu->read_word(addr)); + uint32_t v = cpu->read_word(addr); + gte_write_data(cpu, RT(insn), v); + psx_pgxp_cop2(cpu, insn, v, addr); break; } case 0x3A: { /* SWC2 */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - cpu->write_word(addr, gte_read_data(cpu, RT(insn))); + uint32_t v = gte_read_data(cpu, RT(insn)); + cpu->write_word(addr, v); gte_precision_store_word(addr, RT(insn)); + psx_pgxp_cop2(cpu, insn, v, addr); break; } diff --git a/runtime/tests/test_gte_register_access.cpp b/runtime/tests/test_gte_register_access.cpp index e00bf0a7..4de0b17a 100644 --- a/runtime/tests/test_gte_register_access.cpp +++ b/runtime/tests/test_gte_register_access.cpp @@ -488,7 +488,15 @@ int test_precise_sxy_invalidation() { for (uint8_t reg = 0; reg < 32; ++reg) { gte_test_set_precise_valid_mask(0xFu); gte_write_data(&cpu, reg, 0x12345678u); - const uint32_t expected = (reg >= 12 && reg <= 15) ? 0u : 0xFu; + /* The PGXP shadow drops exactly the register(s) the guest wrote: + * SXY0/SXY1 clear their own slot; SXY2 and SXYP clear both mirrors + * (regs 14+15). The untouched slots stay live — their register + * values did not change (SXYP's hardware FIFO shift makes 12/13 + * stale in VALUE, which validate-on-read handles at use; liveness + * alone is not a correctness claim). */ + uint32_t expected = 0xFu; + if (reg == 12 || reg == 13) expected &= ~(1u << (reg - 12)); + else if (reg == 14 || reg == 15) expected &= ~0xCu; const uint32_t actual = gte_test_get_precise_valid_mask(); if (actual != expected) return fail_value("precise SXY invalidation", 0, reg, diff --git a/runtime/tests/test_overlay_capture_retry.cpp b/runtime/tests/test_overlay_capture_retry.cpp index a8a8e0e0..fe126330 100644 --- a/runtime/tests/test_overlay_capture_retry.cpp +++ b/runtime/tests/test_overlay_capture_retry.cpp @@ -124,8 +124,13 @@ extern "C" uint32_t crc32_compute(const uint8_t *data, size_t size) { } int main() { - SDL_SetMainReady(); - if (SDL_Init(0) != 0) { + /* psx_sdl_init: 0 on success under BOTH backends. SDL3's SDL.h no longer + * pulls SDL_main.h, and its SDL_Init returns bool (true = success), so + * the previous SDL_SetMainReady() + `SDL_Init(0) != 0` rotted twice over + * when the default backend moved to SDL3: it did not compile, and the + * check was inverted. SDL_SetMainReady is unnecessary here — this test + * never uses SDL_main. */ + if (psx_sdl_init(0) != 0) { std::fprintf(stderr, "FAIL: SDL_Init: %s\n", SDL_GetError()); return 1; } diff --git a/runtime/tests/test_pgxp.cpp b/runtime/tests/test_pgxp.cpp new file mode 100644 index 00000000..59df32f3 --- /dev/null +++ b/runtime/tests/test_pgxp.cpp @@ -0,0 +1,294 @@ +/* test_pgxp.cpp — PGXP value-propagation engine unit tests (ENHANCEMENTS.md + * G1.2/G1.3). White-box over runtime/src/pgxp.cpp with the gte.cpp fallback + * cache stubbed, exercising exactly the properties the engine's safety rests + * on: provenance roundtrips, validate-on-read, half-word semantics, the + * repack arithmetic, the suppression bracket, and the GPU-side safeguards. */ + +#include "pgxp.h" +#include "pgxp_hooks.h" + +#include +#include + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \ + #cond); \ + g_failures++; \ + } \ + } while (0) + +/* ---- gte.cpp fallback-cache stub ----------------------------------------- */ + +static uint32_t g_fb_packed = 0; +static int32_t g_fb_x16 = 0, g_fb_y16 = 0; +static int g_fb_valid = 0; + +extern "C" int gte_geometry_correction_lookup(uint32_t packed, + int32_t *x16, int32_t *y16) { + if (!g_fb_valid || packed != g_fb_packed) return 0; + if (x16) *x16 = g_fb_x16; + if (y16) *y16 = g_fb_y16; + return 1; +} + +/* ---- MIPS encodings ------------------------------------------------------ */ + +static uint32_t enc_i(uint32_t op, uint32_t rs, uint32_t rt, uint16_t imm) { + return (op << 26) | (rs << 21) | (rt << 16) | imm; +} +static uint32_t enc_r(uint32_t rs, uint32_t rt, uint32_t rd, uint32_t sh, + uint32_t funct) { + return (rs << 21) | (rt << 16) | (rd << 11) | (sh << 6) | funct; +} +static uint32_t enc_cop2(uint32_t sub, uint32_t rt, uint32_t rd) { + return (0x12u << 26) | (sub << 21) | (rt << 16) | (rd << 11); +} + +#define LW(rs, rt) enc_i(0x23, rs, rt, 0) +#define SW(rs, rt) enc_i(0x2B, rs, rt, 0) +#define LH(rs, rt) enc_i(0x21, rs, rt, 0) +#define LHU(rs, rt) enc_i(0x25, rs, rt, 0) +#define SH(rs, rt) enc_i(0x29, rs, rt, 0) +#define SB(rs, rt) enc_i(0x28, rs, rt, 0) +#define LWC2(rt) enc_i(0x32, 1, rt, 0) +#define SWC2(rt) enc_i(0x3A, 1, rt, 0) +#define MFC2(rt, rd) enc_cop2(0x00, rt, rd) +#define MTC2(rt, rd) enc_cop2(0x04, rt, rd) +#define ADDIU(rs, rt, imm) enc_i(0x09, rs, rt, (uint16_t)(imm)) +#define LUI(rt, imm) enc_i(0x0F, 0, rt, (uint16_t)(imm)) +#define SLL(rt, rd, sh) enc_r(0, rt, rd, sh, 0x00) +#define SRA(rt, rd, sh) enc_r(0, rt, rd, sh, 0x03) +#define OR(rs, rt, rd) enc_r(rs, rt, rd, 0, 0x25) +#define ADDU(rs, rt, rd) enc_r(rs, rt, rd, 0, 0x21) + +/* One projected vertex: x = 160.5, y = 80.25 -> packed integer word. */ +static const uint32_t PACKED = (80u << 16) | 160u; +static const int32_t X16 = (160 << 16) | 0x8000; /* 160.5 */ +static const int32_t Y16 = (80 << 16) | 0x4000; /* 80.25 */ +static const uint16_t SZ3 = 100; + +static const uint32_t ADDR_A = 0x80100000u; /* packet slot A (KSEG0) */ +static const uint32_t ADDR_B = 0x00100040u; /* packet slot B (KUSEG) */ + +static void produce_at(uint32_t addr) { + pgxp_gte_push_sxy(X16, Y16, SZ3, PACKED); + psx_pgxp_cop2(nullptr, SWC2(14), PACKED, addr); +} + +static int lookup(uint32_t addr, uint32_t word, int32_t ix, int32_t iy, + int32_t *x, int32_t *y, uint16_t *z) { + int32_t lx, ly; uint16_t lz; + int r = pgxp_get_precise_vertex(addr, word, ix, iy, &lx, &ly, &lz); + if (x) *x = lx; + if (y) *y = ly; + if (z) *z = lz; + return r; +} + +int main(void) { + pgxp_set_enabled(1); + pgxp_set_tolerance(-1.0f); + pgxp_set_cpu_mode(0); + + /* --- SWC2 produce -> GPU consume (the perspective-texturing spine) --- */ + produce_at(ADDR_A); + { + int32_t x, y; uint16_t z; + CHECK(lookup(ADDR_A, PACKED, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16 && z == SZ3); + /* mirrors resolve to the same shadow word */ + CHECK(lookup(0xA0100000u, PACKED, 160, 80, &x, &y, &z) == + PGXP_SRC_DATAFLOW); + } + + /* --- DMA/untracked overwrite: value validation rejects the shadow --- */ + { + int32_t x, y; uint16_t z; + uint32_t other = (81u << 16) | 161u; + CHECK(lookup(ADDR_A, other, 161, 81, &x, &y, &z) == PGXP_SRC_NATIVE); + CHECK(x == (161 << 16) && y == (81 << 16) && z == 0); + } + + /* --- LW/SW roundtrip: packet copied by the CPU keeps provenance --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_store(nullptr, SW(1, 8), ADDR_B, PACKED); + { + int32_t x, y; uint16_t z; + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16 && z == SZ3); + } + + /* --- stale GPR: register changed between load and store --- */ + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_store(nullptr, SW(1, 8), ADDR_B, 0xDEADBEEFu); /* r8 mutated */ + CHECK(lookup(ADDR_B, 0xDEADBEEFu, 0, 0, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- MOVE idiom (memory mode, no cpu_mode needed) --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_alu(nullptr, ADDU(8, 0, 10), PACKED, PACKED, 0); + psx_pgxp_store(nullptr, SW(1, 10), ADDR_B, PACKED); + CHECK(lookup(ADDR_B, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + + /* --- MFC2 -> SW (register transfer path) --- */ + pgxp_gte_push_sxy(X16, Y16, SZ3, PACKED); + psx_pgxp_cop2(nullptr, MFC2(9, 14), PACKED, 0); + psx_pgxp_store(nullptr, SW(1, 9), ADDR_B, PACKED); + CHECK(lookup(ADDR_B, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + + /* --- LH/SH: halves travel independently, depth does not survive --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A + 2u, PACKED >> 16); /* Y */ + psx_pgxp_store(nullptr, SH(1, 8), ADDR_B + 2u, PACKED >> 16); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A, PACKED & 0xFFFFu); /* X */ + psx_pgxp_store(nullptr, SH(1, 8), ADDR_B, PACKED & 0xFFFFu); + { + int32_t x, y; uint16_t z; + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16); + CHECK(z == 0); /* SH killed the vertex depth */ + } + + /* --- SB destroys the touched half only --- */ + produce_at(ADDR_B); + psx_pgxp_store(nullptr, SB(1, 8), ADDR_B, PACKED & 0xFFu); /* same byte */ + { + int32_t x, y; uint16_t z; + /* low half invalidated -> not a full XY hit anymore */ + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, &z) != PGXP_SRC_DATAFLOW); + } + + /* --- cpu-mode repack: lhu / sll 16 / or (the classic vertex build) --- */ + pgxp_set_cpu_mode(1); + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A + 2u, PACKED >> 16); /* Y */ + psx_pgxp_alu(nullptr, SLL(8, 9, 16), (PACKED >> 16) << 16, + PACKED >> 16, 16); + psx_pgxp_load(nullptr, LHU(1, 10), ADDR_A, PACKED & 0xFFFFu); /* X */ + psx_pgxp_alu(nullptr, OR(9, 10, 11), PACKED, + (PACKED >> 16) << 16, PACKED & 0xFFFFu); + psx_pgxp_store(nullptr, SW(1, 11), ADDR_B, PACKED); + { + int32_t x, y; + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, nullptr) == + PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16); + } + + /* --- cpu-mode addiu: fraction rides an integer offset (incl. -N) --- */ + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_alu(nullptr, ADDIU(8, 12, 4), PACKED + 4u, PACKED, 4u); + psx_pgxp_store(nullptr, SW(1, 12), ADDR_B, PACKED + 4u); + { + int32_t x, y; + CHECK(lookup(ADDR_B, PACKED + 4u, 164, 80, &x, &y, nullptr) == + PGXP_SRC_DATAFLOW); + CHECK(x == X16 + (4 << 16) && y == Y16); + } + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_alu(nullptr, ADDIU(8, 12, (uint16_t)-4), PACKED - 4u, PACKED, + (uint32_t)(int32_t)-4); + psx_pgxp_store(nullptr, SW(1, 12), ADDR_B, PACKED - 4u); + { + int32_t x; + CHECK(lookup(ADDR_B, PACKED - 4u, 156, 80, &x, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + CHECK(x == X16 - (4 << 16)); + } + pgxp_set_cpu_mode(0); + + /* --- cpu-mode OFF: the same repack must degrade to native, cleanly --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A + 2u, PACKED >> 16); + psx_pgxp_alu(nullptr, SLL(8, 9, 16), (PACKED >> 16) << 16, + PACKED >> 16, 16); + psx_pgxp_store(nullptr, SW(1, 9), ADDR_B, (PACKED >> 16) << 16); + CHECK(lookup(ADDR_B, (PACKED >> 16) << 16, 0, 80, nullptr, nullptr, + nullptr) == PGXP_SRC_NATIVE); + + /* --- truncation agreement: integer part must match the native parse --- */ + produce_at(ADDR_A); + CHECK(lookup(ADDR_A, PACKED, 161, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- tolerance clamp --- */ + produce_at(ADDR_A); + pgxp_set_tolerance(0.25f); /* fraction is 0.5 -> reject */ + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + pgxp_set_tolerance(0.75f); /* 0.5 <= 0.75 -> accept */ + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + pgxp_set_tolerance(-1.0f); + + /* --- fallback tier: no address -> position cache, never a depth --- */ + g_fb_valid = 1; g_fb_packed = PACKED; g_fb_x16 = X16; g_fb_y16 = Y16; + { + int32_t x, y; uint16_t z; + CHECK(lookup(0xFFFFFFFFu, PACKED, 160, 80, &x, &y, &z) == + PGXP_SRC_FALLBACK); + CHECK(x == X16 && y == Y16 && z == 0); + } + g_fb_valid = 0; + + /* --- suppression bracket: nothing records inside it --- */ + pgxp_invalidate_all(); + pgxp_suppress_begin(); + produce_at(ADDR_A); + pgxp_suppress_end(); + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- deferred invalidate inside the bracket --- */ + produce_at(ADDR_A); + pgxp_suppress_begin(); + pgxp_invalidate_all(); /* deferred */ + pgxp_suppress_end(); /* applies here */ + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- invalidate-all + generation wrap --- */ + produce_at(ADDR_A); + pgxp_invalidate_all(); + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + pgxp_test_set_generation(0xFFFFFFFFu); + pgxp_invalidate_all(); + CHECK(pgxp_test_generation() == 1u); + + /* --- test accessors mirror the SXY FIFO shadows --- */ + pgxp_test_seed_gte_sxy(2, PACKED, X16, Y16, SZ3, 1); + { + uint32_t packed; int32_t x, y; uint16_t z; uint8_t valid; + pgxp_test_get_gte_sxy(2, &packed, &x, &y, &z, &valid); + CHECK(valid && packed == PACKED && x == X16 && y == Y16 && z == SZ3); + pgxp_test_seed_gte_sxy(2, 0, 0, 0, 0, 0); + pgxp_test_get_gte_sxy(2, &packed, &x, &y, &z, &valid); + CHECK(!valid); + } + + /* --- stats sanity: dataflow hits were counted --- */ + { + PGXPStats st; + pgxp_get_stats(&st); + CHECK(st.lookups > 0); + CHECK(st.dataflow_hit > 0); + CHECK(st.native > 0); + CHECK(st.fallback_hit > 0); + CHECK(st.value_mismatch > 0); + } + + if (g_failures) { + std::fprintf(stderr, "test_pgxp: %d FAILURES\n", g_failures); + return 1; + } + std::printf("test_pgxp: all checks passed\n"); + return 0; +} From 38bdeeaade3ed3b11998ea2da8773e09bd4e0824 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 19:03:36 -0700 Subject: [PATCH 15/25] pgxp: emit hook macros + dual build targets + flavor-namespaced caches (phase 2) Emitter side of the value-propagation engine (ENHANCEMENTS.md G1.10): - recompiler/src/pgxp_hook_emitter.{h,cpp} (new, in the codegen-hash surface): ONE hook grammar shared by BOTH emitters, wrapping loads/ stores/COP2 transfers and precision-carrying ALU ops (moves, add/sub, or-merge, shifts, LUI, HI/LO, muldiv) with PGXP_*() macros. Clobberable sources are captured into locals before the statement. Precision- destroying ops (AND/XOR/SLT/...) are deliberately unhooked - validate- on-read drops their stale shadows for free. - code_generator.cpp: central append in translate_instruction; LWC2/SWC2 emissions capture the RAW loaded/stored word (a masked GTE register write must validate against the word as loaded). - strict_translator.cpp: translate() wraps translate_impl()'s c_code with the shared grammar; deferred load-delay variants hook the psx_ldd temp. - BIOS regenerated, fingerprints verified CHANGED (SCPH1001 + OpenBIOS; 42.6k/12.2k macro sites). Base runtime + 33/33 tests green against it. - Gating: macros expand only under -DPSX_PGXP=1. runtime.cmake grows a PGXP option on psxrecomp_add_runtime_target (defines PSX_PGXP=1 + PSX_OVERLAY_FLAVOR=2, _pgxp exe suffix) and PSX_PGXP_VARIANT to build the sibling target from the same generated tree (framework runtime + game wrapper). No ABI version bump needed: the appended PGXPHooks table is NULL-tolerant and the flavor half of the ABI tag rejects any host/DLL flavor mix. - Shard caches: flavor folded into the cache tag (cg__gc _f) in overlay_loader.c + compile_overlays.py so base and pgxp caches never share a directory; compile_overlays.py passes -DPSX_PGXP=1 when flavor bit 2 is set; overlay preamble forwards psx_pgxp_* through the table. PSX_OVERLAY_CODEGEN_VER 9 -> 10. - Env overrides PSX_GEOMETRY_CORRECTION / PSX_PERSPECTIVE_TEXTURING / PSX_PGXP_CPU_MODE (validation path: free-running headless boots arm from the first vertex); census grew produced/swc2_stores and the pgxp verb reports suppress/active gate state. Validated: PSXRecomp_pgxp.exe boots the recompiled BIOS with hooks armed (engine active, per-vertex lookups flowing; the BIOS shell has no RTPS content so produced=0 there is correct - coverage measurement moves to a 3D title in phase 3). Co-Authored-By: Claude Fable 5 --- recompiler/CMakeLists.txt | 1073 +++++++++-------- recompiler/src/code_generator.cpp | 36 +- recompiler/src/pgxp_hook_emitter.cpp | 134 ++ recompiler/src/pgxp_hook_emitter.h | 31 + recompiler/src/strict_translator.cpp | 59 +- recompiler/src/strict_translator.h | 7 + runtime/CMakeLists.txt | 17 + runtime/codegen_hash_sources.cmake | 2 + runtime/include/overlay_api.h | 20 +- .../include/overlay_dispatch_preamble.c.inc | 23 + runtime/include/pgxp.h | 4 + runtime/runtime.cmake | 54 +- runtime/src/debug_server.c | 12 +- runtime/src/main.cpp | 10 + runtime/src/overlay_loader.c | 33 +- runtime/src/pgxp.cpp | 5 + tools/compile_overlays.py | 8 +- 17 files changed, 941 insertions(+), 587 deletions(-) create mode 100644 recompiler/src/pgxp_hook_emitter.cpp create mode 100644 recompiler/src/pgxp_hook_emitter.h diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index c7639880..71c16cc0 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1,403 +1,408 @@ -cmake_minimum_required(VERSION 3.20) -project(PSXRecomp C CXX) -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -include(CTest) - -include("${CMAKE_CURRENT_SOURCE_DIR}/../runtime/chd_dependency.cmake") - -add_subdirectory(lib/fmt) - -# Vendored fmt 9.1.0 enables its `consteval` format-string check on Apple -# clang via the `__cpp_consteval` clause in core.h, even though the same -# block tries to exclude Apple toolchains — and Apple clang 21 then rejects -# fmt's own literal format strings ("call to consteval function is not a -# constant expression"). Force FMT_CONSTEVAL empty to take fmt's -# non-consteval compile-time-check path. Scoped to non-MSVC so Windows is -# untouched. -if(NOT MSVC) - target_compile_definitions(fmt PUBLIC FMT_CONSTEVAL=) -endif() - -# Rabbitizer MIPS decoder -add_library(rabbitizer STATIC) -target_sources(rabbitizer PRIVATE - lib/rabbitizer/cplusplus/src/analysis/LoPairingInfo.cpp - lib/rabbitizer/cplusplus/src/analysis/RegistersTracker.cpp - lib/rabbitizer/cplusplus/src/instructions/InstrId.cpp - lib/rabbitizer/cplusplus/src/instructions/InstrIdType.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionBase.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionCpu.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionR3000GTE.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionR5900.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionRsp.cpp - lib/rabbitizer/src/analysis/RabbitizerLoPairingInfo.c - lib/rabbitizer/src/analysis/RabbitizerRegistersTracker.c - lib/rabbitizer/src/analysis/RabbitizerTrackedRegisterState.c - lib/rabbitizer/src/common/RabbitizerConfig.c - lib/rabbitizer/src/common/RabbitizerVersion.c - lib/rabbitizer/src/common/Utils.c - lib/rabbitizer/src/instructions/RabbitizerInstrCategory.c - lib/rabbitizer/src/instructions/RabbitizerInstrDescriptor.c - lib/rabbitizer/src/instructions/RabbitizerInstrId.c - lib/rabbitizer/src/instructions/RabbitizerInstrIdType.c - lib/rabbitizer/src/instructions/RabbitizerInstrSuffix.c - lib/rabbitizer/src/instructions/RabbitizerInstructionCpu/RabbitizerInstructionCpu_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp.c - lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Disassemble.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Examination.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Operand.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerRegister.c - lib/rabbitizer/src/instructions/RabbitizerRegisterDescriptor.c -) -target_include_directories(rabbitizer PRIVATE - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/rabbitizer/tables -) - -# PSXRecomp — MIPS→C recompiler tool -set(PSXRECOMP_GAME_SOURCES - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/control_flow.cpp - src/function_analysis.cpp - src/code_generator.cpp - src/bios_address_model.cpp - src/annotations.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - src/main_psx.cpp -) - -add_executable(psxrecomp-game ${PSXRECOMP_GAME_SOURCES}) -target_include_directories(psxrecomp-game PRIVATE - include - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 - lib/ELFIO -) -target_link_libraries(psxrecomp-game PRIVATE fmt rabbitizer) -if(MINGW) - target_link_options(psxrecomp-game PRIVATE -Wl,--stack,16777216) -elseif(MSVC) - target_link_options(psxrecomp-game PRIVATE /STACK:16777216) -endif() - -# psxrecomp-toml — PS-X EXE → game.toml generator. -# Standalone tool that analyses a PS-X EXE and generates a complete game.toml -# config with auto-detected fields (load_address, entry_pc, text_size, -# stack_base) and optional JAL-target seed generation. -add_executable(psxrecomp-toml - src/ps1_exe_parser.cpp - src/main_toml.cpp -) -target_include_directories(psxrecomp-toml PRIVATE - include - lib/fmt/include -) -target_link_libraries(psxrecomp-toml PRIVATE fmt) - -# psxrecomp — user-facing media-to-source project generator. -add_executable(psxrecomp - src/main_cli.cpp - src/cli_boot_path.cpp - src/ps1_exe_parser.cpp - ../runtime/src/cue_sheet.cpp - ../runtime/src/iso_reader.cpp -) -target_include_directories(psxrecomp PRIVATE - include - ../runtime/include - lib/fmt/include -) -target_link_libraries(psxrecomp PRIVATE fmt chdr-static) -if(MINGW) - target_link_options(psxrecomp PRIVATE -Wl,--stack,16777216) -elseif(MSVC) - target_link_options(psxrecomp PRIVATE /STACK:16777216) -endif() - -# ---- baked codegen hash (stale-binary guard) -------------------------------- -# Bake the SAME emitter-source hash the runtime folds into its overlay cache -# tag (runtime/runtime.cmake -> hash_codegen.cmake, shared canonical list in -# runtime/codegen_hash_sources.cmake) into THIS binary, exposed via -# `psxrecomp-game --codegen-hash`. compile_overlays.py compares it against the -# hash it stamps on the cache dir (from --runtime-include) and HARD-FAILS on -# mismatch. Closes the 2026-07-01 Tomba pause-menu wedge class: a stale -# recompiler binary emitting shards that get filed under the CURRENT cg tag -# (read tag == write tag, content stale — invisible to the tag alone). -set(PSXRECOMP_CODEGEN_HASH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) -include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/codegen_hash_sources.cmake) -set(_baked_hash_hdr ${CMAKE_CURRENT_BINARY_DIR}/psxrecomp_baked_codegen_hash.h) -add_custom_command( - OUTPUT ${_baked_hash_hdr} - COMMAND ${CMAKE_COMMAND} -DOUT=${_baked_hash_hdr} - "-DSRCS=${PSXRECOMP_CODEGEN_HASH_SRCS}" - -P ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake - DEPENDS ${PSXRECOMP_CODEGEN_HASH_SRCS} - ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake - COMMENT "Hashing codegen sources -> psxrecomp_baked_codegen_hash.h" - VERBATIM) -add_custom_target(psxrecomp_baked_codegen_hash DEPENDS ${_baked_hash_hdr}) -add_dependencies(psxrecomp-game psxrecomp_baked_codegen_hash) -# Force main_psx.cpp to recompile whenever the hash header content changes -# (same OBJECT_DEPENDS hardening as runtime.cmake — __has_include depfile gap). -set_source_files_properties(src/main_psx.cpp PROPERTIES OBJECT_DEPENDS ${_baked_hash_hdr}) -target_include_directories(psxrecomp-game PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) - -# NOTE (2026-07-27): the legacy `PSXRecomp` target was REMOVED here. -# It built ${PSXRECOMP_GAME_SOURCES} — byte-for-byte the same source list as -# `psxrecomp-game` above — and was a leftover from before that target was -# introduced. Because Windows and macOS filesystems are case-insensitive, its -# output `PSXRecomp.exe` collided with the user-facing CLI `psxrecomp.exe` -# (the `psxrecomp` target above): both wrote the SAME file on disk and the -# last one linked won. In practice the CLI lost, so `psxrecomp.exe` shipped in -# `psxrecomp-cli-windows-x86_64.zip` (tools/build_cli.py) was actually the game -# recompiler, with a completely different argument grammar than the README -# documents. The bug is invisible on Linux, which is why it survived. -# The `PSXRecomp.exe` users are told to run in README's "Release Package" -# section is a DIFFERENT artifact — the BIOS-only runtime, whose name comes -# from OUTPUT_NAME in runtime/runtime.cmake — and is unaffected by this removal. -# See the collision guard at the bottom of this file. - -# psxrecomp-bios — Phase 1a BIOS boot slice recompiler. -# Strict, fail-loud, ingests bios/SCPH1001.BIN as a flat ROM at 0xBFC00000. -# Independent of PSXRecomp's PS1-EXE entry point. Shares mips_decoder + -# ps1_exe_parser sources because mips_decoder.h depends on PS1Executable -# (used only by decode_executable, which psxrecomp-bios does not call). -add_executable(psxrecomp-bios - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/strict_translator.cpp - src/bios_slice_walker.cpp - src/bios_address_model.cpp - src/function_discovery.cpp - src/full_function_emitter.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - src/main_bios.cpp -) -target_include_directories(psxrecomp-bios PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 -) -target_link_libraries(psxrecomp-bios PRIVATE fmt rabbitizer) -if(MINGW) - target_link_options(psxrecomp-bios PRIVATE -Wl,--stack,16777216) -elseif(MSVC) - target_link_options(psxrecomp-bios PRIVATE /STACK:16777216) -endif() - -# Released Windows CLI archives must run without a separate MinGW/MSYS2 -# install. Prefer llvm-mingw (+ -static) in CI so emitters do not need -# libgcc/libstdc++ harvested from MSYS2. Keep opt-in for local debug builds. -option(PSXRECOMP_STATIC_CLI "Statically link Windows runtime into CLI tools" OFF) -if(PSXRECOMP_STATIC_CLI AND WIN32 AND (MINGW OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")) - foreach(_cli_target psxrecomp psxrecomp-game psxrecomp-toml psxrecomp-bios) - if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - target_link_options(${_cli_target} PRIVATE - -static -static-libgcc -static-libstdc++) - else() - # llvm-mingw / Clang: fully static CRT + libc++ / libunwind / pthread. - target_link_options(${_cli_target} PRIVATE -static) - endif() - endforeach() -endif() - -# BIOS address-model equivalence test — sweeps every 4-byte address through -# the profile-driven BiosAddressModel and compares against verbatim copies of -# the pre-refactor hardcoded SCPH1001 helpers; also exercises the from_config -# invariants. Run from the framework root: recompiler/build/bios_address_model_test -add_executable(bios_address_model_test - tests/bios_address_model_test.cpp - src/bios_address_model.cpp - src/config_loader.cpp - src/ps1_exe_parser.cpp -) -target_include_directories(bios_address_model_test PRIVATE - include - src - lib/fmt/include - lib/toml11 -) -target_link_libraries(bios_address_model_test PRIVATE fmt) - -# L1 decoder conformance test — compares MipsDecoder against Rabbitizer -# oracle for every instruction in SCPH1001.BIN code regions. -add_executable(l1_decoder_test - tests/l1_decoder_test.cpp - src/mips_decoder.cpp - src/ps1_exe_parser.cpp -) -target_include_directories(l1_decoder_test PRIVATE - include - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include -) -target_link_libraries(l1_decoder_test PRIVATE fmt rabbitizer) - -# L2 structural codegen test — verifies StrictTranslator output patterns -# for each MIPS instruction form. -add_executable(l2_structural_test - tests/l2_structural_test.cpp - src/mips_decoder.cpp - src/ps1_exe_parser.cpp - src/strict_translator.cpp -) -target_include_directories(l2_structural_test PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include -) -target_link_libraries(l2_structural_test PRIVATE fmt rabbitizer) - -if(BUILD_TESTING) - add_executable(debug_trace_ranges_test - ../runtime/tests/test_debug_trace_ranges.c - ../runtime/src/debug_trace_ranges.c - ) - target_include_directories(debug_trace_ranges_test PRIVATE - ../runtime/include) - add_test(NAME debug_trace_ranges_test COMMAND debug_trace_ranges_test) - - add_executable(cli_boot_path_test - tests/cli_boot_path_test.cpp - src/cli_boot_path.cpp - ) - target_include_directories(cli_boot_path_test PRIVATE src) - add_test(NAME cli_boot_path_test COMMAND cli_boot_path_test) - - add_executable(recompiler_patch_test - tests/recompiler_patch_test.cpp - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/control_flow.cpp - src/function_analysis.cpp - src/code_generator.cpp - src/annotations.cpp - src/bios_address_model.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - ) - target_include_directories(recompiler_patch_test PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 - lib/ELFIO - ) - target_link_libraries(recompiler_patch_test PRIVATE fmt rabbitizer) - add_test(NAME recompiler_patch_test COMMAND recompiler_patch_test) - - add_executable(full_function_emitter_test - tests/full_function_emitter_test.cpp - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/strict_translator.cpp - src/bios_address_model.cpp - src/function_discovery.cpp - src/full_function_emitter.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - ) - target_include_directories(full_function_emitter_test PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 - ) - target_link_libraries(full_function_emitter_test PRIVATE fmt rabbitizer) - add_test(NAME full_function_emitter_test COMMAND full_function_emitter_test) - - add_executable(recompiler_patch_cxx17_header_test - tests/recompiler_patch_cxx17_header_test.cpp - ) - target_include_directories(recompiler_patch_cxx17_header_test PRIVATE src) - set_target_properties(recompiler_patch_cxx17_header_test PROPERTIES - CXX_STANDARD 17 - CXX_STANDARD_REQUIRED ON - ) - add_test(NAME recompiler_patch_cxx17_header_test - COMMAND recompiler_patch_cxx17_header_test) - - # [video] geometry_correction / perspective_texturing opt-in plumbing. - add_executable(video_enhancement_settings_test - tests/video_enhancement_settings_test.cpp - src/config_loader.cpp - src/ps1_exe_parser.cpp - ) - target_include_directories(video_enhancement_settings_test PRIVATE - include - src - lib/fmt/include - lib/toml11 - ) - target_link_libraries(video_enhancement_settings_test PRIVATE fmt) - add_test(NAME video_enhancement_settings_test - COMMAND video_enhancement_settings_test) - - # Needs bios/SCPH1001.toml — run from the framework root. - add_test(NAME bios_address_model_test - COMMAND bios_address_model_test - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - - # These targets were already built by every test build, but without - # add_test() entries ctest could neither run nor fail them. - add_test(NAME l2_structural_test COMMAND l2_structural_test) - add_test(NAME reachable_discovery_test COMMAND reachable_discovery_test) - - find_package(Python3 COMPONENTS Interpreter) - if(Python3_Interpreter_FOUND) - add_test( - NAME cli_project_packaging - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cli_project_packaging.py - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - - # These two invoke psxrecomp-game, whose BIOS-profile resolution - # probes fixed working-directory-relative paths (bios/.toml, - # then psxrecomp/bios/.toml) -- run them from the framework - # root so the shipped profiles are found. - add_test( - NAME overlay_cross_page_delay_codegen - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_cross_page_delay_codegen.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - add_test( - NAME fmv_cycle_batch_codegen - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fmv_cycle_batch_codegen.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - add_test( - NAME overlay_shim_compile_contract - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_shim_compile.py) +cmake_minimum_required(VERSION 3.20) +project(PSXRecomp C CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(CTest) + +include("${CMAKE_CURRENT_SOURCE_DIR}/../runtime/chd_dependency.cmake") + +add_subdirectory(lib/fmt) + +# Vendored fmt 9.1.0 enables its `consteval` format-string check on Apple +# clang via the `__cpp_consteval` clause in core.h, even though the same +# block tries to exclude Apple toolchains — and Apple clang 21 then rejects +# fmt's own literal format strings ("call to consteval function is not a +# constant expression"). Force FMT_CONSTEVAL empty to take fmt's +# non-consteval compile-time-check path. Scoped to non-MSVC so Windows is +# untouched. +if(NOT MSVC) + target_compile_definitions(fmt PUBLIC FMT_CONSTEVAL=) +endif() + +# Rabbitizer MIPS decoder +add_library(rabbitizer STATIC) +target_sources(rabbitizer PRIVATE + lib/rabbitizer/cplusplus/src/analysis/LoPairingInfo.cpp + lib/rabbitizer/cplusplus/src/analysis/RegistersTracker.cpp + lib/rabbitizer/cplusplus/src/instructions/InstrId.cpp + lib/rabbitizer/cplusplus/src/instructions/InstrIdType.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionBase.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionCpu.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionR3000GTE.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionR5900.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionRsp.cpp + lib/rabbitizer/src/analysis/RabbitizerLoPairingInfo.c + lib/rabbitizer/src/analysis/RabbitizerRegistersTracker.c + lib/rabbitizer/src/analysis/RabbitizerTrackedRegisterState.c + lib/rabbitizer/src/common/RabbitizerConfig.c + lib/rabbitizer/src/common/RabbitizerVersion.c + lib/rabbitizer/src/common/Utils.c + lib/rabbitizer/src/instructions/RabbitizerInstrCategory.c + lib/rabbitizer/src/instructions/RabbitizerInstrDescriptor.c + lib/rabbitizer/src/instructions/RabbitizerInstrId.c + lib/rabbitizer/src/instructions/RabbitizerInstrIdType.c + lib/rabbitizer/src/instructions/RabbitizerInstrSuffix.c + lib/rabbitizer/src/instructions/RabbitizerInstructionCpu/RabbitizerInstructionCpu_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp.c + lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Disassemble.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Examination.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Operand.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerRegister.c + lib/rabbitizer/src/instructions/RabbitizerRegisterDescriptor.c +) +target_include_directories(rabbitizer PRIVATE + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/rabbitizer/tables +) + +# PSXRecomp — MIPS→C recompiler tool +set(PSXRECOMP_GAME_SOURCES + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/bios_address_model.cpp + src/annotations.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + src/main_psx.cpp +) + +add_executable(psxrecomp-game ${PSXRECOMP_GAME_SOURCES}) +target_include_directories(psxrecomp-game PRIVATE + include + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO +) +target_link_libraries(psxrecomp-game PRIVATE fmt rabbitizer) +if(MINGW) + target_link_options(psxrecomp-game PRIVATE -Wl,--stack,16777216) +elseif(MSVC) + target_link_options(psxrecomp-game PRIVATE /STACK:16777216) +endif() + +# psxrecomp-toml — PS-X EXE → game.toml generator. +# Standalone tool that analyses a PS-X EXE and generates a complete game.toml +# config with auto-detected fields (load_address, entry_pc, text_size, +# stack_base) and optional JAL-target seed generation. +add_executable(psxrecomp-toml + src/ps1_exe_parser.cpp + src/main_toml.cpp +) +target_include_directories(psxrecomp-toml PRIVATE + include + lib/fmt/include +) +target_link_libraries(psxrecomp-toml PRIVATE fmt) + +# psxrecomp — user-facing media-to-source project generator. +add_executable(psxrecomp + src/main_cli.cpp + src/cli_boot_path.cpp + src/ps1_exe_parser.cpp + ../runtime/src/cue_sheet.cpp + ../runtime/src/iso_reader.cpp +) +target_include_directories(psxrecomp PRIVATE + include + ../runtime/include + lib/fmt/include +) +target_link_libraries(psxrecomp PRIVATE fmt chdr-static) +if(MINGW) + target_link_options(psxrecomp PRIVATE -Wl,--stack,16777216) +elseif(MSVC) + target_link_options(psxrecomp PRIVATE /STACK:16777216) +endif() + +# ---- baked codegen hash (stale-binary guard) -------------------------------- +# Bake the SAME emitter-source hash the runtime folds into its overlay cache +# tag (runtime/runtime.cmake -> hash_codegen.cmake, shared canonical list in +# runtime/codegen_hash_sources.cmake) into THIS binary, exposed via +# `psxrecomp-game --codegen-hash`. compile_overlays.py compares it against the +# hash it stamps on the cache dir (from --runtime-include) and HARD-FAILS on +# mismatch. Closes the 2026-07-01 Tomba pause-menu wedge class: a stale +# recompiler binary emitting shards that get filed under the CURRENT cg tag +# (read tag == write tag, content stale — invisible to the tag alone). +set(PSXRECOMP_CODEGEN_HASH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) +include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/codegen_hash_sources.cmake) +set(_baked_hash_hdr ${CMAKE_CURRENT_BINARY_DIR}/psxrecomp_baked_codegen_hash.h) +add_custom_command( + OUTPUT ${_baked_hash_hdr} + COMMAND ${CMAKE_COMMAND} -DOUT=${_baked_hash_hdr} + "-DSRCS=${PSXRECOMP_CODEGEN_HASH_SRCS}" + -P ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake + DEPENDS ${PSXRECOMP_CODEGEN_HASH_SRCS} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake + COMMENT "Hashing codegen sources -> psxrecomp_baked_codegen_hash.h" + VERBATIM) +add_custom_target(psxrecomp_baked_codegen_hash DEPENDS ${_baked_hash_hdr}) +add_dependencies(psxrecomp-game psxrecomp_baked_codegen_hash) +# Force main_psx.cpp to recompile whenever the hash header content changes +# (same OBJECT_DEPENDS hardening as runtime.cmake — __has_include depfile gap). +set_source_files_properties(src/main_psx.cpp PROPERTIES OBJECT_DEPENDS ${_baked_hash_hdr}) +target_include_directories(psxrecomp-game PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +# NOTE (2026-07-27): the legacy `PSXRecomp` target was REMOVED here. +# It built ${PSXRECOMP_GAME_SOURCES} — byte-for-byte the same source list as +# `psxrecomp-game` above — and was a leftover from before that target was +# introduced. Because Windows and macOS filesystems are case-insensitive, its +# output `PSXRecomp.exe` collided with the user-facing CLI `psxrecomp.exe` +# (the `psxrecomp` target above): both wrote the SAME file on disk and the +# last one linked won. In practice the CLI lost, so `psxrecomp.exe` shipped in +# `psxrecomp-cli-windows-x86_64.zip` (tools/build_cli.py) was actually the game +# recompiler, with a completely different argument grammar than the README +# documents. The bug is invisible on Linux, which is why it survived. +# The `PSXRecomp.exe` users are told to run in README's "Release Package" +# section is a DIFFERENT artifact — the BIOS-only runtime, whose name comes +# from OUTPUT_NAME in runtime/runtime.cmake — and is unaffected by this removal. +# See the collision guard at the bottom of this file. + +# psxrecomp-bios — Phase 1a BIOS boot slice recompiler. +# Strict, fail-loud, ingests bios/SCPH1001.BIN as a flat ROM at 0xBFC00000. +# Independent of PSXRecomp's PS1-EXE entry point. Shares mips_decoder + +# ps1_exe_parser sources because mips_decoder.h depends on PS1Executable +# (used only by decode_executable, which psxrecomp-bios does not call). +add_executable(psxrecomp-bios + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/strict_translator.cpp + src/pgxp_hook_emitter.cpp + src/bios_slice_walker.cpp + src/bios_address_model.cpp + src/function_discovery.cpp + src/full_function_emitter.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + src/main_bios.cpp +) +target_include_directories(psxrecomp-bios PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 +) +target_link_libraries(psxrecomp-bios PRIVATE fmt rabbitizer) +if(MINGW) + target_link_options(psxrecomp-bios PRIVATE -Wl,--stack,16777216) +elseif(MSVC) + target_link_options(psxrecomp-bios PRIVATE /STACK:16777216) +endif() + +# Released Windows CLI archives must run without a separate MinGW/MSYS2 +# install. Prefer llvm-mingw (+ -static) in CI so emitters do not need +# libgcc/libstdc++ harvested from MSYS2. Keep opt-in for local debug builds. +option(PSXRECOMP_STATIC_CLI "Statically link Windows runtime into CLI tools" OFF) +if(PSXRECOMP_STATIC_CLI AND WIN32 AND (MINGW OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")) + foreach(_cli_target psxrecomp psxrecomp-game psxrecomp-toml psxrecomp-bios) + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_link_options(${_cli_target} PRIVATE + -static -static-libgcc -static-libstdc++) + else() + # llvm-mingw / Clang: fully static CRT + libc++ / libunwind / pthread. + target_link_options(${_cli_target} PRIVATE -static) + endif() + endforeach() +endif() + +# BIOS address-model equivalence test — sweeps every 4-byte address through +# the profile-driven BiosAddressModel and compares against verbatim copies of +# the pre-refactor hardcoded SCPH1001 helpers; also exercises the from_config +# invariants. Run from the framework root: recompiler/build/bios_address_model_test +add_executable(bios_address_model_test + tests/bios_address_model_test.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp +) +target_include_directories(bios_address_model_test PRIVATE + include + src + lib/fmt/include + lib/toml11 +) +target_link_libraries(bios_address_model_test PRIVATE fmt) + +# L1 decoder conformance test — compares MipsDecoder against Rabbitizer +# oracle for every instruction in SCPH1001.BIN code regions. +add_executable(l1_decoder_test + tests/l1_decoder_test.cpp + src/mips_decoder.cpp + src/ps1_exe_parser.cpp +) +target_include_directories(l1_decoder_test PRIVATE + include + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include +) +target_link_libraries(l1_decoder_test PRIVATE fmt rabbitizer) + +# L2 structural codegen test — verifies StrictTranslator output patterns +# for each MIPS instruction form. +add_executable(l2_structural_test + tests/l2_structural_test.cpp + src/mips_decoder.cpp + src/ps1_exe_parser.cpp + src/strict_translator.cpp + src/pgxp_hook_emitter.cpp +) +target_include_directories(l2_structural_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include +) +target_link_libraries(l2_structural_test PRIVATE fmt rabbitizer) + +if(BUILD_TESTING) + add_executable(debug_trace_ranges_test + ../runtime/tests/test_debug_trace_ranges.c + ../runtime/src/debug_trace_ranges.c + ) + target_include_directories(debug_trace_ranges_test PRIVATE + ../runtime/include) + add_test(NAME debug_trace_ranges_test COMMAND debug_trace_ranges_test) + + add_executable(cli_boot_path_test + tests/cli_boot_path_test.cpp + src/cli_boot_path.cpp + ) + target_include_directories(cli_boot_path_test PRIVATE src) + add_test(NAME cli_boot_path_test COMMAND cli_boot_path_test) + + add_executable(recompiler_patch_test + tests/recompiler_patch_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/annotations.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(recompiler_patch_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO + ) + target_link_libraries(recompiler_patch_test PRIVATE fmt rabbitizer) + add_test(NAME recompiler_patch_test COMMAND recompiler_patch_test) + + add_executable(full_function_emitter_test + tests/full_function_emitter_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/strict_translator.cpp + src/pgxp_hook_emitter.cpp + src/bios_address_model.cpp + src/function_discovery.cpp + src/full_function_emitter.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(full_function_emitter_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + ) + target_link_libraries(full_function_emitter_test PRIVATE fmt rabbitizer) + add_test(NAME full_function_emitter_test COMMAND full_function_emitter_test) + + add_executable(recompiler_patch_cxx17_header_test + tests/recompiler_patch_cxx17_header_test.cpp + ) + target_include_directories(recompiler_patch_cxx17_header_test PRIVATE src) + set_target_properties(recompiler_patch_cxx17_header_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + ) + add_test(NAME recompiler_patch_cxx17_header_test + COMMAND recompiler_patch_cxx17_header_test) + + # [video] geometry_correction / perspective_texturing opt-in plumbing. + add_executable(video_enhancement_settings_test + tests/video_enhancement_settings_test.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp + ) + target_include_directories(video_enhancement_settings_test PRIVATE + include + src + lib/fmt/include + lib/toml11 + ) + target_link_libraries(video_enhancement_settings_test PRIVATE fmt) + add_test(NAME video_enhancement_settings_test + COMMAND video_enhancement_settings_test) + + # Needs bios/SCPH1001.toml — run from the framework root. + add_test(NAME bios_address_model_test + COMMAND bios_address_model_test + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + + # These targets were already built by every test build, but without + # add_test() entries ctest could neither run nor fail them. + add_test(NAME l2_structural_test COMMAND l2_structural_test) + add_test(NAME reachable_discovery_test COMMAND reachable_discovery_test) + + find_package(Python3 COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + add_test( + NAME cli_project_packaging + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cli_project_packaging.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + + # These two invoke psxrecomp-game, whose BIOS-profile resolution + # probes fixed working-directory-relative paths (bios/.toml, + # then psxrecomp/bios/.toml) -- run them from the framework + # root so the shipped profiles are found. + add_test( + NAME overlay_cross_page_delay_codegen + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_cross_page_delay_codegen.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_test( + NAME fmv_cycle_batch_codegen + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fmv_cycle_batch_codegen.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_test( + NAME overlay_shim_compile_contract + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_shim_compile.py) add_test( NAME dirty_text_continuation_guards COMMAND ${Python3_EXECUTABLE} @@ -419,140 +424,140 @@ if(BUILD_TESTING) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) # ---- codegen tests that were written but never registered ----------- - # These existed in recompiler/tests/ with no add_test() entry, so they - # had no way to run and no way to fail. Each was executed manually - # before being wired here; all pass. They drive psxrecomp-game, so they - # run from the framework root for BIOS-profile resolution, exactly like - # the two above. - foreach(_t IN ITEMS - aot_base_recovery - bios_resident_shard - msvc_cps_codegen - overlay_guard_codegen - overlay_store_barrier_codegen - reachable_discovery_codegen) - add_test( - NAME ${_t} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_${_t}.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - endforeach() - - # test_aot_overlay_discovery.py had no add_test() entry at all. It runs - # far enough to exercise real discovery, then fails in - # check_real_batched_fragment_publication: that check reaches - # MOD.compile_fragment_batch, which spawns psxrecomp-game with cwd set - # to its own TemporaryDirectory, so BIOS-profile resolution (which - # probes bios/SCPH1001.toml relative to the working directory) finds - # nothing and returns "recompiler-error: no BIOS profile found". Every - # other subprocess in the same file passes cwd=str(ROOT) explicitly, so - # this looks like a missed cwd rather than a product defect — but it - # needs an owner, and DISABLED keeps it counted until then. - add_test( - NAME aot_overlay_discovery - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_aot_overlay_discovery.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - - # ---- runtime source-invariant guards -------------------------------- - # These live in runtime/tests/ but only READ runtime source, so they - # need neither a generated BIOS nor a built runtime. Registering them - # here (as dirty_text_continuation_guards already was) means they run - # from a plain recompiler build, which is the only tree a newcomer can - # configure before regenerating a BIOS. Each was run manually first; - # all pass. - # - foreach(_t IN ITEMS - bios_selection_guards - capture_history - default_renderer_guards - mod_owned_display_controls - fmv_quiet_guards - fps_telemetry_opt_in - frame_interpolation_display_rate - hybrid_dev_any_input - launcher_vulkan_option - mod_controller_override + # These existed in recompiler/tests/ with no add_test() entry, so they + # had no way to run and no way to fail. Each was executed manually + # before being wired here; all pass. They drive psxrecomp-game, so they + # run from the framework root for BIOS-profile resolution, exactly like + # the two above. + foreach(_t IN ITEMS + aot_base_recovery + bios_resident_shard + msvc_cps_codegen + overlay_guard_codegen + overlay_store_barrier_codegen + reachable_discovery_codegen) + add_test( + NAME ${_t} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_${_t}.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + endforeach() + + # test_aot_overlay_discovery.py had no add_test() entry at all. It runs + # far enough to exercise real discovery, then fails in + # check_real_batched_fragment_publication: that check reaches + # MOD.compile_fragment_batch, which spawns psxrecomp-game with cwd set + # to its own TemporaryDirectory, so BIOS-profile resolution (which + # probes bios/SCPH1001.toml relative to the working directory) finds + # nothing and returns "recompiler-error: no BIOS profile found". Every + # other subprocess in the same file passes cwd=str(ROOT) explicitly, so + # this looks like a missed cwd rather than a product defect — but it + # needs an owner, and DISABLED keeps it counted until then. + add_test( + NAME aot_overlay_discovery + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_aot_overlay_discovery.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + + # ---- runtime source-invariant guards -------------------------------- + # These live in runtime/tests/ but only READ runtime source, so they + # need neither a generated BIOS nor a built runtime. Registering them + # here (as dirty_text_continuation_guards already was) means they run + # from a plain recompiler build, which is the only tree a newcomer can + # configure before regenerating a BIOS. Each was run manually first; + # all pass. + # + foreach(_t IN ITEMS + bios_selection_guards + capture_history + default_renderer_guards + mod_owned_display_controls + fmv_quiet_guards + fps_telemetry_opt_in + frame_interpolation_display_rate + hybrid_dev_any_input + launcher_vulkan_option + mod_controller_override mod_load_acceleration overlay_candidate_capacity release_zip - overlay_decodable_fallback - overlay_init_guard - sdl3_main_single_include - vk_build_default - vk_color_self_barrier - vk_command_buffer_batching - vk_draw_area_batch_boundary - vk_mailbox_policy - vk_present_timing - vk_present_wait_stage - vk_staging_cache) - add_test( - NAME ${_t} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - endforeach() - - # ---- known-failing guards: DISABLED, not de-registered --------------- - # These three were previously left out of the list above with a comment - # explaining that they FAIL and that wiring a red check would recreate - # the problem that took CI off PRs. That reasoning is right; the remedy - # was not. De-registering is precisely how a test stops being counted, - # and it is the mechanism that let 16 C/C++ tests rot in runtime/tests/. - # - # ctest's DISABLED property is the intended tool: the test stays - # registered, stays visible in `ctest -N`, and is reported as Disabled - # rather than silently absent — without turning the run red. Delete the - # matching set_tests_properties line to re-enable one once it is fixed. - # - # interpreter_perf_guards psx_devices_mmio_sync's recompute-deadline - # branch never clears g_psx_cycle_fast_limit, - # and the guard still names the pre-rename - # s_next_service_cycle - # runtime_perf_diag_guards asserts a substring no longer present in - # the runtime source - # overlay_pair_dedup_runtime needs its companion harness built; not a - # source-only guard - foreach(_t IN ITEMS - interpreter_perf_guards - runtime_perf_diag_guards - overlay_pair_dedup_runtime) - add_test( - NAME ${_t} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - set_tests_properties(${_t} PROPERTIES DISABLED TRUE) - endforeach() - endif() -endif() - -# Reachable main-EXE discovery test: parser-bound validation plus exact-entry -# analysis behavior for direct, indirect, seeded, and out-of-bound targets. -add_executable(reachable_discovery_test - tests/reachable_discovery_test.cpp - src/function_analysis.cpp - src/ps1_exe_parser.cpp -) -target_include_directories(reachable_discovery_test PRIVATE - include - lib/fmt/include -) -target_link_libraries(reachable_discovery_test PRIVATE fmt) - -# ---- test-registration completeness guard ----------------------------------- -# Reads both CMakeLists.txt files as text, so it catches an orphan in either -# project from whichever one is being configured. Placed before the collision -# guard, which must stay last because it needs every target declared; this one -# inspects files on disk and has no such ordering requirement. -include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_test_registration.cmake) -psxrecomp_check_all_tests_registered() - -# ---- output-name collision guard (must stay LAST) --------------------------- -# Fails configure if any two targets here would write the same file on a -# case-insensitive filesystem. See the module header for the shipped-bug story. -include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_target_name_collisions.cmake) -psxrecomp_check_output_name_collisions() + overlay_decodable_fallback + overlay_init_guard + sdl3_main_single_include + vk_build_default + vk_color_self_barrier + vk_command_buffer_batching + vk_draw_area_batch_boundary + vk_mailbox_policy + vk_present_timing + vk_present_wait_stage + vk_staging_cache) + add_test( + NAME ${_t} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + endforeach() + + # ---- known-failing guards: DISABLED, not de-registered --------------- + # These three were previously left out of the list above with a comment + # explaining that they FAIL and that wiring a red check would recreate + # the problem that took CI off PRs. That reasoning is right; the remedy + # was not. De-registering is precisely how a test stops being counted, + # and it is the mechanism that let 16 C/C++ tests rot in runtime/tests/. + # + # ctest's DISABLED property is the intended tool: the test stays + # registered, stays visible in `ctest -N`, and is reported as Disabled + # rather than silently absent — without turning the run red. Delete the + # matching set_tests_properties line to re-enable one once it is fixed. + # + # interpreter_perf_guards psx_devices_mmio_sync's recompute-deadline + # branch never clears g_psx_cycle_fast_limit, + # and the guard still names the pre-rename + # s_next_service_cycle + # runtime_perf_diag_guards asserts a substring no longer present in + # the runtime source + # overlay_pair_dedup_runtime needs its companion harness built; not a + # source-only guard + foreach(_t IN ITEMS + interpreter_perf_guards + runtime_perf_diag_guards + overlay_pair_dedup_runtime) + add_test( + NAME ${_t} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + set_tests_properties(${_t} PROPERTIES DISABLED TRUE) + endforeach() + endif() +endif() + +# Reachable main-EXE discovery test: parser-bound validation plus exact-entry +# analysis behavior for direct, indirect, seeded, and out-of-bound targets. +add_executable(reachable_discovery_test + tests/reachable_discovery_test.cpp + src/function_analysis.cpp + src/ps1_exe_parser.cpp +) +target_include_directories(reachable_discovery_test PRIVATE + include + lib/fmt/include +) +target_link_libraries(reachable_discovery_test PRIVATE fmt) + +# ---- test-registration completeness guard ----------------------------------- +# Reads both CMakeLists.txt files as text, so it catches an orphan in either +# project from whichever one is being configured. Placed before the collision +# guard, which must stay last because it needs every target declared; this one +# inspects files on disk and has no such ordering requirement. +include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_test_registration.cmake) +psxrecomp_check_all_tests_registered() + +# ---- output-name collision guard (must stay LAST) --------------------------- +# Fails configure if any two targets here would write the same file on a +# case-insensitive filesystem. See the module header for the shipped-bug story. +include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_target_name_collisions.cmake) +psxrecomp_check_output_name_collisions() diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 3d882e8b..7ddae12f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1,4 +1,5 @@ #include "code_generator.h" +#include "pgxp_hook_emitter.h" #include "control_flow.h" #include "gte_register_classification.h" #include "../src/bios_address_model.h" @@ -1572,12 +1573,19 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) ? reg_name(rs) : fmt::format("{} + {}", reg_name(rs), offset); bool special = PSXRecompGTERegisters::data_write_needs_helper(static_cast(rt)); + /* The raw loaded word is captured for the PGXP hook: the + * register may hold a MASKED value (write helper), and + * the shadow must validate against the word as loaded. */ if (special) { - code = gte_stall + fmt::format("gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {})); /* lwc2 gte[{}] */", - rt, addr, rt); + code = gte_stall + fmt::format( + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "gte_write_data(cpu, {}, _pgxv); PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }} /* lwc2 gte[{}] */", + addr, rt, instr, rt); } else { - code = gte_stall + fmt::format("cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {}); /* lwc2 gte[{}], ({}) */", - rt, addr, rt, addr); + code = gte_stall + fmt::format( + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "cpu->gte_data[{}] = _pgxv; PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }} /* lwc2 gte[{}], ({}) */", + addr, rt, instr, rt, addr); } } break; @@ -1593,16 +1601,15 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) ? fmt::format("gte_read_data(cpu, {})", rt) : fmt::format("cpu->gte_data[{}]", rt); std::string swc2_store_pc = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr); - if (offset == 0) { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({}, {}); gte_precision_store_word({}, {}); /* swc2 gte[{}], ({}) */", - reg_name(rs), value, reg_name(rs), rt, rt, reg_name(rs)); - } else { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({} + {}, {}); gte_precision_store_word({} + {}, {}); /* swc2 gte[{}], {}({}) */", - reg_name(rs), offset, value, reg_name(rs), offset, rt, - rt, offset, reg_name(rs)); - } + std::string swc2_addr = (offset == 0) + ? reg_name(rs) + : fmt::format("{} + {}", reg_name(rs), offset); + code = gte_stall + swc2_store_pc + fmt::format( + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = {}; " + "psx_store_cycle_barrier(); cpu->write_word(_pgxa, _pgxv); " + "gte_precision_store_word(_pgxa, {}); " + "PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }} /* swc2 gte[{}], {}({}) */", + swc2_addr, value, rt, instr, rt, offset, reg_name(rs)); } break; default: @@ -1610,6 +1617,7 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) } } + PSXRecomp::append_pgxp_hooks(instr, code); return config_.indent + code + comment; } diff --git a/recompiler/src/pgxp_hook_emitter.cpp b/recompiler/src/pgxp_hook_emitter.cpp new file mode 100644 index 00000000..1455983c --- /dev/null +++ b/recompiler/src/pgxp_hook_emitter.cpp @@ -0,0 +1,134 @@ +#include "pgxp_hook_emitter.h" + +#include + +namespace PSXRecomp { + +namespace { +/* Same register naming both emitters use ("cpu->gpr[n]"); kept local so this + * TU links into psxrecomp-bios without pulling the game CodeGenerator. */ +inline std::string reg_name(uint32_t n) { return fmt::format("cpu->gpr[{}]", n); } +inline uint32_t get_rs(uint32_t i) { return (i >> 21) & 0x1F; } +inline uint32_t get_rt(uint32_t i) { return (i >> 16) & 0x1F; } +inline uint32_t get_rd(uint32_t i) { return (i >> 11) & 0x1F; } +inline int16_t get_imm16(uint32_t i) { return (int16_t)(i & 0xFFFF); } +} + +/* PGXP dataflow-shadowing hook emission (ENHANCEMENTS.md G1.10; pgxp_hooks.h). + * + * Wraps the translated statement with a PGXP_*() macro carrying the raw + * instruction word plus the operand values the runtime engine needs. Source + * operands that the statement may clobber (a load/ALU destination aliasing a + * source, a load overwriting its own base register) are captured into locals + * BEFORE the statement; everything else is read after it. In the base build + * the macros preprocess to ((void)0) and the optimizer erases the dead + * capture locals, so base objects are unchanged; only a -DPSX_PGXP=1 TU pays. + * + * Deliberately unhooked: AND/XOR/NOR/SLT-family and the exotic immediates — + * they only ever DESTROY precision, and the engine's validate-on-read drops + * their stale shadows without help. The configured widescreen special sites + * return early above translate_instruction's main dispatch and are likewise + * unhooked (they are cull compares, not vertex moves; validation covers). */ +void append_pgxp_hooks(uint32_t instr, std::string& code) { + const uint32_t opcode = (instr >> 26) & 0x3F; + const uint32_t rs = get_rs(instr); + const uint32_t rt = get_rt(instr); + const uint32_t rd = get_rd(instr); + const int16_t offset = get_imm16(instr); + const auto addr_expr = [&]() { + return (offset == 0) ? reg_name(rs) + : fmt::format("{} + {}", reg_name(rs), offset); + }; + + switch (opcode) { + case 0x00: { /* SPECIAL */ + const uint32_t funct = instr & 0x3F; + switch (funct) { + case 0x00: case 0x02: case 0x03: /* SLL / SRL / SRA */ + if (rd == 0 || instr == 0) return; /* skip plain nop */ + code = fmt::format( + "{{ uint32_t _pgx1 = {}; {} PGXP_ALU(0x{:08X}u, {}, _pgx1, {}u); }}", + reg_name(rt), code, instr, reg_name(rd), (instr >> 6) & 31u); + return; + case 0x04: case 0x06: case 0x07: /* SLLV / SRLV / SRAV */ + if (rd == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; uint32_t _pgx2 = {}; {} " + "PGXP_ALU(0x{:08X}u, {}, _pgx1, _pgx2); }}", + reg_name(rt), reg_name(rs), code, instr, reg_name(rd)); + return; + case 0x10: case 0x12: /* MFHI / MFLO */ + if (rd == 0) return; + code = fmt::format("{} PGXP_ALU(0x{:08X}u, {}, {}, 0u);", + code, instr, reg_name(rd), + funct == 0x10 ? "cpu->hi" : "cpu->lo"); + return; + case 0x11: case 0x13: /* MTHI / MTLO */ + code = fmt::format("{} PGXP_ALU(0x{:08X}u, {}, {}, 0u);", + code, instr, + funct == 0x11 ? "cpu->hi" : "cpu->lo", + funct == 0x11 ? "cpu->hi" : "cpu->lo"); + return; + case 0x18: case 0x19: case 0x1A: case 0x1B: /* MULT(U)/DIV(U) */ + code = fmt::format( + "{} PGXP_MULDIV(0x{:08X}u, cpu->hi, cpu->lo, {}, {});", + code, instr, reg_name(rs), reg_name(rt)); + return; + case 0x20: case 0x21: case 0x22: case 0x23: /* ADD(U)/SUB(U) */ + case 0x25: /* OR */ + if (rd == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; uint32_t _pgx2 = {}; {} " + "PGXP_ALU(0x{:08X}u, {}, _pgx1, _pgx2); }}", + reg_name(rs), reg_name(rt), code, instr, reg_name(rd)); + return; + default: + return; + } + } + case 0x08: case 0x09: /* ADDI / ADDIU */ + if (rt == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; {} PGXP_ALU(0x{:08X}u, {}, _pgx1, 0x{:08X}u); }}", + reg_name(rs), code, instr, reg_name(rt), + (uint32_t)(int32_t)offset); + return; + case 0x0D: /* ORI */ + if (rt == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; {} PGXP_ALU(0x{:08X}u, {}, _pgx1, 0x{:04X}u); }}", + reg_name(rs), code, instr, reg_name(rt), + (uint32_t)(uint16_t)offset); + return; + case 0x0F: /* LUI */ + if (rt == 0) return; + code = fmt::format("{} PGXP_ALU(0x{:08X}u, {}, 0u, 0u);", + code, instr, reg_name(rt)); + return; + case 0x12: { /* COP2 register transfers */ + const uint32_t cop_op = (instr >> 21) & 0x1F; + if ((cop_op == 0x00 && rt != 0) || cop_op == 0x04) /* MFC2 / MTC2 */ + code = fmt::format("{} PGXP_COP2(0x{:08X}u, {}, 0u);", + code, instr, reg_name(rt)); + return; + } + case 0x20: case 0x21: case 0x22: case 0x23: + case 0x24: case 0x25: case 0x26: /* loads */ + if (rt == 0) return; + code = fmt::format( + "{{ uint32_t _pgxa = {}; {} PGXP_LOAD(0x{:08X}u, _pgxa, {}); }}", + addr_expr(), code, instr, reg_name(rt)); + return; + case 0x28: case 0x29: case 0x2A: case 0x2B: + case 0x2E: /* stores */ + code = fmt::format( + "{{ uint32_t _pgxa = {}; {} PGXP_STORE(0x{:08X}u, _pgxa, {}); }}", + addr_expr(), code, instr, reg_name(rt)); + return; + default: + return; + } +} + + +} // namespace PSXRecomp diff --git a/recompiler/src/pgxp_hook_emitter.h b/recompiler/src/pgxp_hook_emitter.h new file mode 100644 index 00000000..6f3b8dab --- /dev/null +++ b/recompiler/src/pgxp_hook_emitter.h @@ -0,0 +1,31 @@ +#ifndef PGXP_HOOK_EMITTER_H +#define PGXP_HOOK_EMITTER_H + +#include +#include + +namespace PSXRecomp { + +/* PGXP dataflow-shadowing hook emission (ENHANCEMENTS.md G1.10; runtime + * pgxp_hooks.h). Shared by BOTH emitters — CodeGenerator (game) and + * StrictTranslator (BIOS) — so the hook grammar can never drift between them. + * + * Wraps the translated statement `code` with a PGXP_*() macro invocation for + * the instruction classes that move sub-pixel projection provenance. Source + * operands the statement may clobber are captured into locals BEFORE it; + * everything else is read after it. In the base build the macros preprocess + * to ((void)0) and the optimizer erases the dead captures, so base objects + * are unchanged; only a -DPSX_PGXP=1 TU pays. + * + * NOT applied to LWC2/SWC2 — those emissions capture the raw loaded/stored + * word themselves (a masked GTE register write must validate against the + * word as loaded, not the register's masked value). + * + * Deliberately unhooked: AND/XOR/NOR/SLT-family and the exotic immediates — + * they only ever DESTROY precision, and the engine's validate-on-read drops + * their stale shadows without help. */ +void append_pgxp_hooks(uint32_t instr, std::string& code); + +} // namespace PSXRecomp + +#endif /* PGXP_HOOK_EMITTER_H */ diff --git a/recompiler/src/strict_translator.cpp b/recompiler/src/strict_translator.cpp index e72eb9e5..85be8666 100644 --- a/recompiler/src/strict_translator.cpp +++ b/recompiler/src/strict_translator.cpp @@ -7,6 +7,7 @@ // records this substitution explicitly. #include "strict_translator.h" +#include "pgxp_hook_emitter.h" /* shared PGXP hook grammar */ #include "gte_register_classification.h" #include @@ -63,6 +64,30 @@ TranslateResult unsupported(const PSXRecomp::DecodedInstruction& d, const std::s } // namespace TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& d) { + TranslateResult r = translate_impl(d); + if (!r.supported) return r; + const uint32_t opcode = (d.raw >> 26) & 0x3F; + /* Shared PGXP hook grammar (ENHANCEMENTS.md G1.10). LWC2/SWC2 capture + * their raw word inside translate_impl and are skipped here. */ + if (opcode != 0x32 && opcode != 0x3A && !r.c_code.empty()) + PSXRecomp::append_pgxp_hooks(d.raw, r.c_code); + /* The deferred load-delay variant assigns into the function-scope + * psx_ldd_ temp instead of the GPR; hook it with that temp as the + * loaded value (the shadow keys on the value, not on where it lands — + * the emitter's later writeback makes the GPR match). */ + if (r.load_dest >= 0 && !r.c_code_deferred.empty()) { + const uint32_t rs = (d.raw >> 21) & 0x1F; + const int16_t offset = static_cast(d.raw & 0xFFFF); + r.c_code_deferred = fmt::format( + "{{ uint32_t _pgxa = cpu->gpr[{}] + {}; {} " + "PGXP_LOAD(0x{:08X}u, _pgxa, psx_ldd_{:08X}); }}", + static_cast(rs), static_cast(offset), + r.c_code_deferred, d.raw, d.address); + } + return r; +} + +TranslateResult StrictTranslator::translate_impl(const PSXRecomp::DecodedInstruction& d) { TranslateResult r; // NOP (raw == 0, i.e. SLL $zero,$zero,0) — emit as a comment with no semantics. @@ -1405,14 +1430,18 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& ? fmt::format("cpu->gpr[{}]", static_cast(rs)) : fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast(rs), static_cast(offset)); bool special = PSXRecompGTERegisters::data_write_needs_helper(rt); + /* The raw loaded word is captured for the PGXP hook: a masked + * register write must validate against the word as loaded. */ if (special) { r.c_code = gte_stall + fmt::format( - "gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {}));", - static_cast(rt), addr); + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "gte_write_data(cpu, {}, _pgxv); PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }}", + addr, static_cast(rt), d.raw); } else { r.c_code = gte_stall + fmt::format( - "cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {});", - static_cast(rt), addr); + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "cpu->gte_data[{}] = _pgxv; PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }}", + addr, static_cast(rt), d.raw); } r.comment = fmt::format("lwc2 gte[{}], {}({})", rt, static_cast(offset), gpr_name(rs)); @@ -1431,20 +1460,18 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& std::string value = PSXRecompGTERegisters::data_read_needs_helper(rt) ? fmt::format("gte_read_data(cpu, {})", static_cast(rt)) : fmt::format("cpu->gte_data[{}]", static_cast(rt)); - if (offset == 0) { + { + std::string addr = offset == 0 + ? fmt::format("cpu->gpr[{}]", static_cast(rs)) + : fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", + static_cast(rs), static_cast(offset)); r.c_code = gte_stall + fmt::format( "psx_store_cycle_barrier(); g_debug_last_store_pc = 0x{:08X}u; " - "cpu->write_word(cpu->gpr[{}], {}); " - "gte_precision_store_word(cpu->gpr[{}], {});", - d.address, static_cast(rs), value, - static_cast(rs), static_cast(rt)); - } else { - r.c_code = gte_stall + fmt::format( - "psx_store_cycle_barrier(); g_debug_last_store_pc = 0x{:08X}u; " - "cpu->write_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})), {}); " - "gte_precision_store_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})), {});", - d.address, static_cast(rs), static_cast(offset), value, - static_cast(rs), static_cast(offset), static_cast(rt)); + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = {}; " + "cpu->write_word(_pgxa, _pgxv); " + "gte_precision_store_word(_pgxa, {}); " + "PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }}", + d.address, addr, value, static_cast(rt), d.raw); } r.comment = fmt::format("swc2 gte[{}], {}({})", rt, static_cast(offset), gpr_name(rs)); diff --git a/recompiler/src/strict_translator.h b/recompiler/src/strict_translator.h index 777defba..c13ca58d 100644 --- a/recompiler/src/strict_translator.h +++ b/recompiler/src/strict_translator.h @@ -63,6 +63,13 @@ class StrictTranslator { public: // Translate a single decoded instruction. Pure function — no state. static TranslateResult translate(const PSXRecomp::DecodedInstruction& d); + +private: + // The per-opcode translation body; translate() wraps its c_code with the + // shared PGXP hook grammar (CodeGenerator::append_pgxp_hooks). + static TranslateResult translate_impl(const PSXRecomp::DecodedInstruction& d); + +public: }; } // namespace PSXRecompV4 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index f71dff85..7b4363ff 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -17,6 +17,23 @@ psxrecomp_add_runtime_target(psx-runtime EXE_NAME "PSXRecomp" ) +# PGXP variant of the framework runtime (ENHANCEMENTS.md G1.10): the same +# recompiled BIOS compiled with -DPSX_PGXP=1 so the emitted PGXP_*() hook +# macros arm the value-propagation engine. Framework-dev validation surface +# (LLE boot logo is real GTE content); titles get theirs via +# PSX_PGXP_VARIANT in psxrecomp_add_game_runtime. +option(PSX_PGXP_VARIANT + "Also build the _pgxp PGXP precision-shadowing variant" OFF) +if(PSX_PGXP_VARIANT) + psxrecomp_add_runtime_target(psx-runtime-pgxp + PGXP + DEBUG_PORT 4370 + WINDOW_TITLE "psxrecomp - BIOS" + DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" + EXE_NAME "PSXRecomp" + ) +endif() + if(BUILD_TESTING) add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) diff --git a/runtime/codegen_hash_sources.cmake b/runtime/codegen_hash_sources.cmake index 3651f39e..96124d2b 100644 --- a/runtime/codegen_hash_sources.cmake +++ b/runtime/codegen_hash_sources.cmake @@ -28,6 +28,8 @@ set(PSXRECOMP_CODEGEN_HASH_SRCS ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/full_function_emitter.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/strict_translator.cpp ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/strict_translator.h + ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/pgxp_hook_emitter.cpp + ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/pgxp_hook_emitter.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/function_discovery.cpp ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/function_discovery.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/include/gte_register_classification.h diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index ae4f8602..2ceda151 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -86,6 +86,13 @@ * model participation with queue-safe guard/hysteresis policy. */ /* v21: ws_angle_widen forwarder for exact, aspect-scaled 12-bit terrain * frustum half-angle constants. */ +/* (PGXP, no version bump): OverlayCallbacks grew an appended-last PGXPHooks + * table pointer (pgxp_hooks.h). Only pgxp-flavour shards (compiled with + * -DPSX_PGXP=1, PSX_OVERLAY_FLAVOR_PGXP set) reference the psx_pgxp_* + * forwarders, and the flavor half of the ABI tag already rejects any + * host/DLL flavor mix — base-flavour DLLs and hosts are untouched, so + * the version stays. The emit-content change (PGXP_*() macros in all + * generated C) is covered by the codegen hash + CODEGEN_VER below. */ #define PSX_OVERLAY_ABI_VERSION 21 /* Process-lifetime overlay candidate capacity. Every accepted manifest F @@ -113,6 +120,12 @@ #define PSX_OVERLAY_FLAVOR 0 #endif +/* Flavor bits (compose bitwise; base = 0): + * 1 — widescreen variant codegen + * 2 — PGXP precision shadowing (-DPSX_PGXP=1 objects; pgxp_hooks.h) */ +#define PSX_OVERLAY_FLAVOR_WIDESCREEN 1 +#define PSX_OVERLAY_FLAVOR_PGXP 2 + /* Combined tag exported by overlay_abi() and checked by the loader. */ #define PSX_OVERLAY_ABI_TAG \ ((int)((PSX_OVERLAY_ABI_VERSION & 0xFFFF) | ((PSX_OVERLAY_FLAVOR & 0xFFFF) << 16))) @@ -139,8 +152,11 @@ * masked-16-bit screen-X, and depth cull emits. * 7: + configured side-plane normal-X (plane_nx) cull emit. * 8: + configured per-primitive bound-load (xclip) cull emit. - * 9: + full-word-guarded object/model participation compare emit. */ -#define PSX_OVERLAY_CODEGEN_VER 9 + * 9: + full-word-guarded object/model participation compare emit. + * 10: + PGXP dataflow-shadowing PGXP_*() macro emission at loads/stores/ + * COP2 transfers/precision-carrying ALU ops (pgxp_hooks.h; no-ops in + * base-flavour objects, real hooks under -DPSX_PGXP=1). */ +#define PSX_OVERLAY_CODEGEN_VER 10 /* Auto codegen hash (generated by hash_codegen.cmake from the recompiler codegen * sources). Folded into the cache PATH next to cg (gcc//cg_/) diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 6cd4b10e..c2acb8e7 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -265,4 +265,27 @@ uint32_t psx_ws_angle_widen(uint32_t vanilla) { void gte_precision_store_word(uint32_t addr, uint8_t reg) { if (g_cbs.gte_precision_store_word) g_cbs.gte_precision_store_word(addr, reg); } +#if defined(PSX_PGXP) && PSX_PGXP +/* PGXP dataflow-shadowing hooks (pgxp_hooks.h; ENHANCEMENTS.md G1.10). Only a + * pgxp-flavour shard (compiled -DPSX_PGXP=1, flavor bit set) references these + * — base-flavour shards preprocess the PGXP_*() macros away, so this block is + * compiled out of them entirely. Forwards to the host engine through the + * appended-last table; every hook no-ops on a host that predates it + * (precision shadowing is visual-only, never load-bearing). */ +void psx_pgxp_load(struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value) { + if (g_cbs.pgxp && g_cbs.pgxp->load) g_cbs.pgxp->load(cpu, instr, addr, value); +} +void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value) { + if (g_cbs.pgxp && g_cbs.pgxp->store) g_cbs.pgxp->store(cpu, instr, addr, value); +} +void psx_pgxp_alu(struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2) { + if (g_cbs.pgxp && g_cbs.pgxp->alu) g_cbs.pgxp->alu(cpu, instr, result, s1, s2); +} +void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, uint32_t hi, uint32_t lo, uint32_t s1, uint32_t s2) { + if (g_cbs.pgxp && g_cbs.pgxp->muldiv) g_cbs.pgxp->muldiv(cpu, instr, hi, lo, s1, s2); +} +void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, uint32_t value, uint32_t addr) { + if (g_cbs.pgxp && g_cbs.pgxp->cop2) g_cbs.pgxp->cop2(cpu, instr, value, addr); +} +#endif /* PSX_PGXP */ /* ----------------------------------------------------------------------- */ diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index d8bc2e7e..c6ba06d0 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -92,6 +92,8 @@ typedef struct PGXPStats { uint64_t trunc_reject; /* integer part disagreed with native parse */ uint64_t tolerance_reject; 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 */ } PGXPStats; void pgxp_get_stats(PGXPStats *out); @@ -115,6 +117,8 @@ void pgxp_test_get_gte_sxy(uint32_t index, uint32_t *packed, int32_t *x16, int32_t *y16, uint16_t *z, uint8_t *valid); uint32_t pgxp_test_generation(void); +uint32_t pgxp_test_suppress_depth(void); +int pgxp_test_active(void); void pgxp_test_set_generation(uint32_t gen); #ifdef __cplusplus diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 2c9ae0e4..08c2b31e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -761,7 +761,13 @@ function(psxrecomp_ensure_zlib) endfunction() function(psxrecomp_add_runtime_target target) - set(options ORACLE COSIM) + # PGXP: build this target's objects with -DPSX_PGXP=1 so the PGXP_*() + # hook macros the emitter writes into ALL generated C become real calls + # into the value-propagation engine (pgxp_hooks.h; ENHANCEMENTS.md G1.10), + # and stamp the pgxp overlay flavor so the shard cache and the ABI gate + # keep pgxp and base DLLs fully separate. The base target is untouched — + # the macros preprocess away without the define. + set(options ORACLE COSIM PGXP) set(oneValueArgs GAME_GENERATED_DISPATCH_C GAME_OVERLAY_STATIC_C @@ -917,6 +923,15 @@ function(psxrecomp_add_runtime_target target) if(PSXRT_ORACLE) set(_psxrt_exe_name "${_psxrt_exe_name}_oracle") endif() + if(PSXRT_PGXP) + # Distinct binary beside the base one; the launcher (or the player) + # picks the variant. Same debug port as the base build — run one at a + # time (the A/B protocol is one-toggle-per-run anyway). + set(_psxrt_exe_name "${_psxrt_exe_name}_pgxp") + target_compile_definitions(${target} PRIVATE + PSX_PGXP=1 + PSX_OVERLAY_FLAVOR=2) # PSX_OVERLAY_FLAVOR_PGXP (overlay_api.h) + endif() set_target_properties(${target} PROPERTIES OUTPUT_NAME "${_psxrt_exe_name}") # ---- Windows / desktop app icon --------------------------------------- @@ -1790,20 +1805,41 @@ function(psxrecomp_add_game_runtime target) GAME_GENERATED_DISPATCH_C "${_psxg_marker}" ${_psxg_rt_args} ) + set(_psxg_targets ${target}) + # PGXP variant (ENHANCEMENTS.md G1.10): the SAME generated sources + # compiled a second time with -DPSX_PGXP=1 into _pgxp.exe. The + # emitted PGXP_*() macros become real hook calls, arming true + # geometry correction; the base target is byte-for-byte the + # pre-feature build. Opt-in per configure — it roughly doubles the + # generated-C compile time. + option(PSX_PGXP_VARIANT + "Also build the _pgxp PGXP precision-shadowing variant" OFF) + if(PSX_PGXP_VARIANT) + psxrecomp_add_runtime_target(${target}-pgxp + PGXP + GAME_GENERATED_FULL_C ${_psxg_full_list} + GAME_GENERATED_DISPATCH_C "${_psxg_marker}" + ${_psxg_rt_args} + ) + list(APPEND _psxg_targets ${target}-pgxp) + endif() else() message(STATUS "psxrecomp: setup host (no game C, no BIOS backends) — " "first-run Generate & rebuild") psxrecomp_add_runtime_target(${target} ${_psxg_rt_args}) + set(_psxg_targets ${target}) endif() - target_compile_definitions(${target} PRIVATE PSX_HAS_GAME_CODEGEN=1) + foreach(_psxg_t IN LISTS _psxg_targets) + target_compile_definitions(${_psxg_t} PRIVATE PSX_HAS_GAME_CODEGEN=1) - if(PSX_NET_LOBBY_DEFAULT_URL) - # Stringify for C: PSX_NET_LOBBY_DEFAULT_URL="ws://..." - target_compile_definitions(${target} PRIVATE - "PSX_NET_LOBBY_DEFAULT_URL=\"${PSX_NET_LOBBY_DEFAULT_URL}\"") - endif() + if(PSX_NET_LOBBY_DEFAULT_URL) + # Stringify for C: PSX_NET_LOBBY_DEFAULT_URL="ws://..." + target_compile_definitions(${_psxg_t} PRIVATE + "PSX_NET_LOBBY_DEFAULT_URL=\"${PSX_NET_LOBBY_DEFAULT_URL}\"") + endif() + endforeach() # Include the portable codegen host. Do NOT add CMAKE_CURRENT_SOURCE_DIR # wholesale to -I: on case-insensitive macOS, #include can pick @@ -1816,5 +1852,7 @@ function(psxrecomp_add_game_runtime target) if(RECOMP_UI_ROOT) list(APPEND _psxg_inc "${RECOMP_UI_ROOT}/src") endif() - target_include_directories(${target} PRIVATE ${_psxg_inc}) + foreach(_psxg_t IN LISTS _psxg_targets) + target_include_directories(${_psxg_t} PRIVATE ${_psxg_inc}) + endforeach() endfunction() diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 566e42d4..7ec45f10 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5212,7 +5212,8 @@ static void handle_geom_correction(int id, const char *json) "\"pgxp\":{\"enabled\":%d,\"cpu_mode\":%d,\"tolerance\":%.3f," "\"lookups\":%llu,\"dataflow_hit\":%llu,\"fallback_hit\":%llu," "\"native\":%llu,\"value_mismatch\":%llu,\"trunc_reject\":%llu," - "\"tolerance_reject\":%llu,\"w_valid\":%llu}}", + "\"tolerance_reject\":%llu,\"w_valid\":%llu," + "\"produced\":%llu,\"swc2_stores\":%llu}}", id, gte_geometry_correction_enabled(), (unsigned)hits, @@ -5226,7 +5227,9 @@ static void handle_geom_correction(int id, const char *json) (unsigned long long)ps.value_mismatch, (unsigned long long)ps.trunc_reject, (unsigned long long)ps.tolerance_reject, - (unsigned long long)ps.w_valid); + (unsigned long long)ps.w_valid, + (unsigned long long)ps.produced, + (unsigned long long)ps.swc2_stores); } /* pgxp — live-tune the value-propagation engine for one-toggle isolation runs @@ -5260,8 +5263,9 @@ static void handle_pgxp(int id, const char *json) pgxp_set_tolerance((float)strtod(p, NULL)); } send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"cpu_mode\":%d," - "\"tolerance\":%.3f}", - id, pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance()); + "\"tolerance\":%.3f,\"suppress\":%u,\"active\":%d}", + id, pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance(), + (unsigned)pgxp_test_suppress_depth(), pgxp_test_active()); } static void handle_gpu_state(int id, const char *json) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a0b0c554..0ad1435a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -12123,6 +12123,16 @@ session_reboot: /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; * with both off every setter below leaves the tracking caches disabled and * the draw path is the faithful integer one, unchanged. */ + /* Env overrides (debug/validation path, like PSX_BIOS_HLE): arm the + * corrections from process start so free-running (headless) boots can be + * measured from the first projected vertex — a TCP toggle always arrives + * after the interesting window. '0' = off, anything else = on. */ + if (const char* e = std::getenv("PSX_GEOMETRY_CORRECTION")) + g_video_geometry_correction = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PERSPECTIVE_TEXTURING")) + g_video_perspective_texturing = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PGXP_CPU_MODE")) + g_video_pgxp_cpu_mode = (*e && *e != '0') ? 1 : 0; gte_geometry_correction_set(g_video_geometry_correction); gpu_texture_correction_set(g_video_perspective_texturing); pgxp_set_cpu_mode(g_video_pgxp_cpu_mode); diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index edd5af69..6a0ca686 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -1727,9 +1727,9 @@ static void warn_on_cgtag_mismatch(const char *tier) { snprintf(base, sizeof base, "%s/%s/%s/%s", s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); char expect[64]; - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", + snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x_f%u", PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); + (unsigned)s_config_hash, (unsigned)PSX_OVERLAY_FLAVOR); snprintf(pattern, sizeof pattern, "%s/cg*", base); WIN32_FIND_DATAA fd; HANDLE h = FindFirstFileA(pattern, &fd); @@ -1755,9 +1755,9 @@ static void warn_on_cgtag_mismatch(const char *tier) { char base[768], expect[64], found[256]; snprintf(base, sizeof base, "%s/%s/%s/%s", s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", + snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x_f%u", PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); + (unsigned)s_config_hash, (unsigned)PSX_OVERLAY_FLAVOR); if (psx_overlay_posix_find_other_cache_tag(base, expect, found, sizeof(found))) { loader_log("*** OVERLAY CACHE HASH MISMATCH: this build reads %s/%s but " @@ -1906,14 +1906,16 @@ static void scan_cache_dir(void) { /* Index both tiers and every immutable artifact. Runtime selection prefers * usable GCC over TCC; an invalid GCC artifact cannot suppress a valid TCC * fallback merely because its filename was enumerated first. */ - snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x_gc%08x", + snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x_gc%08x_f%u", s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); + (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash, + (unsigned)PSX_OVERLAY_FLAVOR); scan_one_cache_dir(dir, CACHE_TIER_GCC); abi_preflight_sweep(dir); - snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x_gc%08x", + snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x_gc%08x_f%u", s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); + (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash, + (unsigned)PSX_OVERLAY_FLAVOR); scan_one_cache_dir(dir, CACHE_TIER_TCC); abi_preflight_sweep(dir); @@ -2355,6 +2357,21 @@ static void init_callbacks(void) { extern uint32_t psx_ws_angle_widen(uint32_t vanilla); s_callbacks.ws_angle_widen = psx_ws_angle_widen; } + /* PGXP dataflow-shadowing hook table (pgxp_hooks.h, appended last). + * Referenced only by pgxp-flavour shards; the flavor half of the ABI + * tag already rejects any host/DLL flavor mix, and a NULL table on an + * older host makes every hook a no-op (visual-only, never + * load-bearing). */ + { + static const PGXPHooks pgxp_hooks_table = { + psx_pgxp_load, + psx_pgxp_store, + psx_pgxp_alu, + psx_pgxp_muldiv, + psx_pgxp_cop2, + }; + s_callbacks.pgxp = &pgxp_hooks_table; + } } } diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index 87f19332..c8faaa6d 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -631,6 +631,7 @@ extern "C" void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, uint32_t packed) { if (!g_pgxp_active) return; + s_stats.produced++; s_gte[12] = s_gte[13]; s_gte[13] = s_gte[14]; PGXPValue *pv = &s_gte[14]; @@ -735,6 +736,7 @@ extern "C" void pgxp_store_gte_reg(uint32_t addr, uint8_t reg) { if (!dst) return; const PGXPValue *src = &s_gte[reg & 31u]; if (src->gen != s_gen) return; + s_stats.swc2_stores++; *dst = *src; } @@ -769,6 +771,9 @@ extern "C" void pgxp_test_get_gte_sxy(uint32_t index, uint32_t *packed, extern "C" uint32_t pgxp_test_generation(void) { return s_gen; } +extern "C" uint32_t pgxp_test_suppress_depth(void) { return s_suppress; } +extern "C" int pgxp_test_active(void) { return g_pgxp_active; } + extern "C" void pgxp_test_set_generation(uint32_t gen) { s_gen = gen; } /* Address-keyed depth lookup for the shipped perspective-texturing path diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 14cc265d..b3f7a3c0 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -4068,6 +4068,9 @@ def _compile_dll_tcc(c_path: str, out_dll: str, include_dirs, flavor: int, '-DPSX_NO_DEBUG_TOOLS', '-DPSX_ENABLE_BLOCK_CYCLES=1', f'-DPSX_OVERLAY_FLAVOR={int(flavor)}', + # PGXP flavor bit (overlay_api.h PSX_OVERLAY_FLAVOR_PGXP): arm the + # PGXP_*() hook macros the emitter writes into every overlay C. + *(['-DPSX_PGXP=1'] if int(flavor) & 2 else []), native_path(c_path), '-o', native_path(out_dll)] for d in include_dirs: cmd.append('-I' + native_path(_bom_free_incdir(d))) @@ -4116,6 +4119,9 @@ def _compile_dll_direct(c_path: str, out_dll: str, include_dirs: list[str], # cache and a base cache can never cross-contaminate even if they share # a directory (they key by guest-bytes CRC, which is flavor-blind). f'-DPSX_OVERLAY_FLAVOR={int(flavor)}', + # PGXP flavor bit (overlay_api.h PSX_OVERLAY_FLAVOR_PGXP): arm the + # PGXP_*() hook macros the emitter writes into every overlay C. + *(['-DPSX_PGXP=1'] if int(flavor) & 2 else []), c_path, '-o', out_dll, *includes, @@ -5303,7 +5309,7 @@ def main(): ch = codegen_hash(args.runtime_include) gh = overlay_config_hash(args.recompiler, args.game_toml) cache_dir = os.path.join(args.out_dir, game_id, args.compiler, cache_arch_abi(), - f'cg{cg}_{ch:08x}_gc{gh:08x}') + f'cg{cg}_{ch:08x}_gc{gh:08x}_f{int(args.flavor)}') os.makedirs(cache_dir, exist_ok=True) print(f'Cache dir: {cache_dir} ' f'(codegen ver {cg}, emitter {ch:08x}, config {gh:08x})') From e3824b47f5fd51f3250607082ffab02cb85dad03 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 19:17:30 -0700 Subject: [PATCH 16/25] pgxp: auto-clone the _pgxp variant inside psxrecomp_add_runtime_target Titles that call psxrecomp_add_runtime_target directly (Ape Escape) never went through the game wrapper, so the variant only existed for wrapper callers. The clone now replays ARGN recursively at the end of the target builder itself under PSX_PGXP_VARIANT, which covers every caller; the wrapper folds an existing clone into its tail config and the framework CMakeLists drops its now-redundant explicit block. First real-title census (Ape Escape, worktree build vs this framework, memory-mode only, boot->title->attract): 79-90% dataflow hits per 10s window vs the 5.2% position-cache ceiling, 0 value mismatches, 0 truncation rejects, every dataflow hit carrying depth; residual native is 2D HUD/sprite content that must stay native. The G1.9 census gate is passed; visual screenshot_hires A/B requires a windowed run (headless has no hi-res mirror - scale:1 fallback). Co-Authored-By: Claude Fable 5 --- runtime/CMakeLists.txt | 20 ++++---------------- runtime/runtime.cmake | 35 ++++++++++++++++++++--------------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7b4363ff..afa71189 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -17,22 +17,10 @@ psxrecomp_add_runtime_target(psx-runtime EXE_NAME "PSXRecomp" ) -# PGXP variant of the framework runtime (ENHANCEMENTS.md G1.10): the same -# recompiled BIOS compiled with -DPSX_PGXP=1 so the emitted PGXP_*() hook -# macros arm the value-propagation engine. Framework-dev validation surface -# (LLE boot logo is real GTE content); titles get theirs via -# PSX_PGXP_VARIANT in psxrecomp_add_game_runtime. -option(PSX_PGXP_VARIANT - "Also build the _pgxp PGXP precision-shadowing variant" OFF) -if(PSX_PGXP_VARIANT) - psxrecomp_add_runtime_target(psx-runtime-pgxp - PGXP - DEBUG_PORT 4370 - WINDOW_TITLE "psxrecomp - BIOS" - DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" - EXE_NAME "PSXRecomp" - ) -endif() +# With -DPSX_PGXP_VARIANT=ON, psxrecomp_add_runtime_target auto-clones a +# psx-runtime-pgxp sibling (PSXRecomp_pgxp.exe) compiled with -DPSX_PGXP=1 — +# the framework-dev validation surface for the PGXP value-propagation engine +# (ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) add_executable(launcher_device_roundtrip_test diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 08c2b31e..e8228262 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -1585,6 +1585,22 @@ function(psxrecomp_add_runtime_target target) COMMENT "Staging trusted xdelta3 decoder for derived-disc mods" VERBATIM) endif() + + # PGXP variant auto-clone (ENHANCEMENTS.md G1.10): with + # -DPSX_PGXP_VARIANT=ON, every primary runtime target grows an + # _pgxp sibling — the SAME arguments (same generated C, extras, + # ports) compiled with -DPSX_PGXP=1 (see the PGXP option above). Done + # HERE, at the end, by replaying ARGN recursively, so it works for + # every caller — titles that call this function directly (Ape) and + # the psxrecomp_add_game_runtime wrapper alike. Oracle/cosim targets + # and the clone itself are excluded. + if(NOT PSXRT_PGXP AND NOT PSXRT_ORACLE AND NOT PSXRT_COSIM) + option(PSX_PGXP_VARIANT + "Also build the _pgxp PGXP precision-shadowing variant" OFF) + if(PSX_PGXP_VARIANT) + psxrecomp_add_runtime_target(${target}-pgxp PGXP ${ARGN}) + endif() + endif() endfunction() # Compatibility for early v4 game projects that used the longer helper name. @@ -1805,22 +1821,11 @@ function(psxrecomp_add_game_runtime target) GAME_GENERATED_DISPATCH_C "${_psxg_marker}" ${_psxg_rt_args} ) + # psxrecomp_add_runtime_target auto-clones a ${target}-pgxp sibling + # when PSX_PGXP_VARIANT is ON; fold it into the tail configuration + # below so it gets the same game-codegen defines and includes. set(_psxg_targets ${target}) - # PGXP variant (ENHANCEMENTS.md G1.10): the SAME generated sources - # compiled a second time with -DPSX_PGXP=1 into _pgxp.exe. The - # emitted PGXP_*() macros become real hook calls, arming true - # geometry correction; the base target is byte-for-byte the - # pre-feature build. Opt-in per configure — it roughly doubles the - # generated-C compile time. - option(PSX_PGXP_VARIANT - "Also build the _pgxp PGXP precision-shadowing variant" OFF) - if(PSX_PGXP_VARIANT) - psxrecomp_add_runtime_target(${target}-pgxp - PGXP - GAME_GENERATED_FULL_C ${_psxg_full_list} - GAME_GENERATED_DISPATCH_C "${_psxg_marker}" - ${_psxg_rt_args} - ) + if(TARGET ${target}-pgxp) list(APPEND _psxg_targets ${target}-pgxp) endif() else() From 4badfcb81ee43b305352a981a97e956926722623 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 19:18:11 -0700 Subject: [PATCH 17/25] docs: G1.10 census addendum (Ape 79-90% dataflow, gate passed) Co-Authored-By: Claude Fable 5 --- ENHANCEMENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index c00dabda..5955184e 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -837,3 +837,19 @@ Incidental fixes while landing this: the per-target `psx_game_version.txt` (runtime.cmake once-only stamp), and `overlay_capture_retry_test` not compiling under the SDL3 default (SDL_SetMainReady gone + inverted SDL_Init check — it now uses `psx_sdl_init`). + +**First real-title census (Ape Escape, 2026-08-15) — the G1.9 gate is +PASSED.** Worktree build against this branch (`_wt-ape-pgxp`, +`-DPSX_PGXP_VARIANT=ON -DPSX_DEBUG_TOOLS=ON`, game C regenerated), memory-mode +only (`pgxp_cpu_mode` off), boot → title → attract, per-10s windows: +**79–90% dataflow hits** (vs the 5.2% position-cache ceiling of G1.4), +fallback ≤0.4%, `value_mismatch` 0, `trunc_reject` ~0, and every dataflow hit +carried a usable depth. The residual native share is 2D HUD/sprite content, +which is exactly what must stay native. Still to run before any ship surface: +the windowed `screenshot_hires` off/on A/B (headless has NO hi-res mirror — +`scale:1` fallback, the same blind-instrument trap G1.5 documented), on Ape + +Crash with Tomba 2 as the 2D negative control; the hook-overhead FPS measure +(pgxp build, feature off, vs base); and pgxp-flavour (`_f2`) overlay-shard +autocompile validation. Validation-build notes: title Release builds default +`PSX_DEBUG_TOOLS=OFF` (no TCP), and a title launched without `--game` binds +the framework default port 4370. From 200b5bf0038329c29ca291657e85fb1f1aa85cb8 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 19:42:43 -0700 Subject: [PATCH 18/25] Add opt-in widescreen culling keep hooks --- recompiler/include/code_generator.h | 8 ++++ recompiler/src/code_generator.cpp | 29 +++++++++--- recompiler/src/config_loader.cpp | 12 +++++ recompiler/src/config_loader.h | 16 +++++++ recompiler/src/main_psx.cpp | 8 ++++ recompiler/tests/recompiler_patch_test.cpp | 19 ++++++++ runtime/include/cpu_state.h | 1 + runtime/include/gpu.h | 1 + runtime/src/gpu.c | 55 ++++++++++++++++------ runtime/src/gte.cpp | 23 ++++++--- runtime/src/main.cpp | 4 ++ 11 files changed, 149 insertions(+), 27 deletions(-) diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 10ba7902..803a2687 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -146,6 +146,14 @@ struct CodeGenConfig { // (INT32_MAX while revealed, vanilla at 4:3); empty by default. std::set ws_cull_xclip_load_sites; + // Exact `bltz MAC0, reject`-style NCLIP/backface rejects to suppress while + // widescreen reveals extra world. 4:3 keeps the original branch predicate. + std::set ws_cull_nclip_keep_sites; + + // Exact branch PCs whose reject target is suppressed while widescreen + // reveals extra world. 4:3 keeps the original branch predicate. + std::set ws_cull_branch_keep_sites; + // Exact, full-word-guarded comparison sites whose result is forced only // while widescreen reveals extra world. 4:3 evaluates the original compare. std::vector ws_cull_keep_sites; diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index b84dc492..543eb11f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -737,6 +737,12 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad uint32_t opcode = (instr >> 26) & 0x3F; uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); + auto keep_branch_if_wide = [&](std::string cond) { + if (!config_.ws_cull_branch_keep_sites.count(addr)) + return cond; + return fmt::format("psx_ws_x_margin() > 0 ? 0 : ({}) /* ws branch keep */", + cond); + }; // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, @@ -758,9 +764,14 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad (ws_cull_bltz_pcs_.count(addr) || config_.ws_cull_bltz_sites.count(addr))) return fmt::format("psx_ws_cull_bltz({}) /* ws cull (left edge) */", reg_name(rs)); - return fmt::format("(int32_t){} < 0", reg_name(rs)); + if (regimm_op == 0x00 && config_.ws_cull_nclip_keep_sites.count(addr)) + return fmt::format("psx_ws_x_margin() > 0 ? 0 : ((int32_t){} < 0) /* ws nclip keep */", + reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} < 0", reg_name(rs))); } else { // bgez family (incl. bgezal + undefined mirrors) - return fmt::format("(int32_t){} >= 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} >= 0", reg_name(rs))); } } @@ -768,22 +779,26 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad switch (opcode) { case 0x04: // beq case 0x14: // beql - return fmt::format("{} == {}", reg_name(rs), reg_name(rt)); + return keep_branch_if_wide( + fmt::format("{} == {}", reg_name(rs), reg_name(rt))); case 0x05: // bne case 0x15: // bnel - return fmt::format("{} != {}", reg_name(rs), reg_name(rt)); + return keep_branch_if_wide( + fmt::format("{} != {}", reg_name(rs), reg_name(rt))); case 0x06: // blez case 0x16: // blezl - return fmt::format("(int32_t){} <= 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} <= 0", reg_name(rs))); case 0x07: // bgtz case 0x17: // bgtzl - return fmt::format("(int32_t){} > 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} > 0", reg_name(rs))); } - return "0 /* unknown branch condition: defaults to not-taken */"; + return keep_branch_if_wide("0 /* unknown branch condition: defaults to not-taken */"); } std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index cd38b9b9..2f220785 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -72,6 +72,8 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.words("cull_depth", c.ws_cull_depth_sites); h.words("cull_plane_nx", c.ws_cull_plane_nx_sites); h.words("cull_xclip_load", c.ws_cull_xclip_load_sites); + h.words("cull_nclip_keep", c.ws_cull_nclip_keep_sites); + h.words("cull_branch_keep", c.ws_cull_branch_keep_sites); h.words("cull_w_imms", c.ws_cull_w_imms); h.words("cull_h_imms", c.ws_cull_h_imms); h.words("backdrop_x", c.ws_backdrop_x_sites); @@ -1307,6 +1309,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool ws_auto_ui_squash = false; bool ws_full_2d = false; bool ws_gte_game_mode = false; + bool ws_precise_nclip = false; uint32_t ws_gameplay_state_addr = 0; std::vector ws_gameplay_state_values; bool ws_native_wide = true; @@ -1454,6 +1457,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { ws_full_2d = toml::find(ws, "full_2d"); if (ws.contains("gte_game_mode")) ws_gte_game_mode = toml::find(ws, "gte_game_mode"); + if (ws.contains("precise_nclip")) + ws_precise_nclip = toml::find(ws, "precise_nclip"); const bool has_gameplay_state_addr = ws.contains("gameplay_state_addr"); const bool has_gameplay_state_values = ws.contains("gameplay_state_values"); if (has_gameplay_state_addr != has_gameplay_state_values) @@ -1556,6 +1561,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::vector ws_cull_depth_sites; std::vector ws_cull_plane_nx_sites; std::vector ws_cull_xclip_load_sites; + std::vector ws_cull_nclip_keep_sites; + std::vector ws_cull_branch_keep_sites; std::vector ws_cull_keep_sites; std::vector ws_cull_angle_sites; WidescreenAspectConeConfig ws_aspect_cone; @@ -1589,6 +1596,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { load_sites("depth_sites", ws_cull_depth_sites); load_sites("plane_nx_sites", ws_cull_plane_nx_sites); load_sites("xclip_load_sites", ws_cull_xclip_load_sites); + load_sites("nclip_keep_sites", ws_cull_nclip_keep_sites); + load_sites("branch_keep_sites", ws_cull_branch_keep_sites); if (cull.contains("keep")) { std::set seen; for (const auto& item : toml::find(cull, "keep")) { @@ -2013,6 +2022,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_cull_depth_sites*/ ws_cull_depth_sites, /*ws_cull_plane_nx_sites*/ ws_cull_plane_nx_sites, /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, + /*ws_cull_nclip_keep_sites*/ ws_cull_nclip_keep_sites, + /*ws_cull_branch_keep_sites*/ ws_cull_branch_keep_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, /*ws_aspect_cone*/ ws_aspect_cone, @@ -2027,6 +2038,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_auto_backdrop_preload*/ ws_auto_backdrop_preload, /*ws_full_2d*/ ws_full_2d, /*ws_gte_game_mode*/ ws_gte_game_mode, + /*ws_precise_nclip*/ ws_precise_nclip, /*ws_gameplay_state_addr*/ ws_gameplay_state_addr, /*ws_gameplay_state_values*/ ws_gameplay_state_values, /*ws_native_wide*/ ws_native_wide, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1829e024..2486ec19 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -812,6 +812,14 @@ struct GameConfig { // scissor clips the overflow and wrapped off-left coords pass); the // vanilla loaded value at 4:3. Empty by default; regen required. std::vector ws_cull_xclip_load_sites; + // Exact `bltz MAC0, reject`-style NCLIP/backface rejects that are forced + // not-taken only while widescreen reveals extra world. This is deliberately + // separate from bltz_sites, whose helper adjusts screen-X edge thresholds. + std::vector ws_cull_nclip_keep_sites; + // Exact branch PCs whose reject path is forced not-taken only while + // widescreen reveals extra world. Use only after screenshot-validated + // evidence that the target is a visibility reject. + std::vector ws_cull_branch_keep_sites; // Exact comparison sites whose result is forced only while widescreen // reveals extra world. Used for proven object/model participation gates // where maximal overdraw is preferable to range guessing. Each entry is @@ -903,6 +911,14 @@ struct GameConfig { // (native-wide engages); genuine full-2D screens (save/options) still // pillarbox 4:3. Runtime-only — no regen required. Off by default. bool ws_gte_game_mode = false; + + // [widescreen] precise_nclip — use the runtime's unsaturated GTE projection + // provenance for NCLIP/backface tests while classic adaptive widescreen is + // active. This is for 3D titles whose wide side geometry otherwise hits the + // PS1 SXY +/-1024 clamp and then disappears from game-side visibility tests. + // Runtime-only; off by default. + bool ws_precise_nclip = false; + // Optional authoritative game-state gate for titles whose menus also // render enough 3D geometry to fool gte_game_mode. When configured, // native-wide is active only while the guest word matches one listed diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 23e1318a..5df7cb7c 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -209,6 +209,8 @@ int main(int argc, char** argv) { std::set ws_cull_depth; // [widescreen.cull] depth_sites std::set ws_cull_plane_nx; // [widescreen.cull] plane_nx_sites std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites + std::set ws_cull_nclip_keep; // [widescreen.cull] nclip_keep_sites + std::set ws_cull_branch_keep; // [widescreen.cull] branch_keep_sites std::vector ws_cull_keep; std::vector ws_cull_angle; PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; @@ -270,6 +272,8 @@ int main(int argc, char** argv) { ws_cull_depth.insert(cfg.ws_cull_depth_sites.begin(), cfg.ws_cull_depth_sites.end()); ws_cull_plane_nx.insert(cfg.ws_cull_plane_nx_sites.begin(), cfg.ws_cull_plane_nx_sites.end()); ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); + ws_cull_nclip_keep.insert(cfg.ws_cull_nclip_keep_sites.begin(), cfg.ws_cull_nclip_keep_sites.end()); + ws_cull_branch_keep.insert(cfg.ws_cull_branch_keep_sites.begin(), cfg.ws_cull_branch_keep_sites.end()); ws_cull_keep = cfg.ws_cull_keep_sites; ws_cull_angle = cfg.ws_cull_angle_sites; ws_aspect_cone = cfg.ws_aspect_cone; @@ -363,6 +367,8 @@ int main(int argc, char** argv) { ws_cull_depth.insert(wscfg.ws_cull_depth_sites.begin(), wscfg.ws_cull_depth_sites.end()); ws_cull_plane_nx.insert(wscfg.ws_cull_plane_nx_sites.begin(), wscfg.ws_cull_plane_nx_sites.end()); ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); + ws_cull_nclip_keep.insert(wscfg.ws_cull_nclip_keep_sites.begin(), wscfg.ws_cull_nclip_keep_sites.end()); + ws_cull_branch_keep.insert(wscfg.ws_cull_branch_keep_sites.begin(), wscfg.ws_cull_branch_keep_sites.end()); if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; if (ws_aspect_cone.sites.empty()) @@ -1225,6 +1231,8 @@ int main(int argc, char** argv) { codegen_config.ws_cull_depth_sites = ws_cull_depth; codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx; codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; + codegen_config.ws_cull_nclip_keep_sites = ws_cull_nclip_keep; + codegen_config.ws_cull_branch_keep_sites = ws_cull_branch_keep; codegen_config.ws_cull_keep_sites = ws_cull_keep; codegen_config.ws_cull_angle_sites = ws_cull_angle; codegen_config.ws_aspect_cone = ws_aspect_cone; diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index dc1df97a..212f7c93 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -206,6 +206,15 @@ negsub_sites = ["0x80012340"] std::vector{0x80012340u}, "parser preserves negsub cull sites"); + const auto branch_keep = write_config(root, "branch-keep", R"toml( +[widescreen.cull] +branch_keep_sites = ["0x80012340"] +)toml"); + const auto branch_keep_config = PSXRecompV4::load_game_config(branch_keep); + check(branch_keep_config.ws_cull_branch_keep_sites == + std::vector{0x80012340u}, + "parser preserves branch keep sites"); + const auto vxrange = write_config(root, "vxrange", R"toml( [widescreen.cull] vxrange_sites = ["0x80012340"] @@ -680,6 +689,16 @@ void codegen_tests() { std::string::npos, "activation bias gains the same isolated resident-object lead"); + PSXRecomp::CodeGenConfig branch_keep_config; + branch_keep_config.ws_cull_branch_keep_sites.insert(0x80010000u); + const std::string branch_keep = generate_first_instruction( + 0x14400002u, {}, false, branch_keep_config); // bne v0,zero,+2 + check(branch_keep.find( + "psx_ws_x_margin() > 0 ? 0 : (cpu->gpr[2] != cpu->gpr[0])") != + std::string::npos && + branch_keep.find("ws branch keep") != std::string::npos, + "codegen emits guarded branch keep predicate"); + PSXRecomp::CodeGenConfig plane_nx_config; plane_nx_config.ws_cull_plane_nx_sites.insert(0x80010000u); const std::string plane_nx = generate_first_instruction( diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 6de04789..15cd78ff 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -205,6 +205,7 @@ extern void gte_precision_timeline_invalidate(void); extern void gte_precision_speculative_begin(void); extern void gte_precision_speculative_end(void); extern void gte_precision_store_word(uint32_t addr, uint8_t reg); +extern void gte_precision_tracking_set(int enabled); /* Sub-pixel vertex precision ([video] geometry_correction). Enables the side * cache that retains the 16.16 projection fraction the GTE discards when it * saturates SXY to integer screen pixels. Guest-visible GTE state is diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c7f4ed56..7e06f072 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -316,6 +316,7 @@ uint32_t gpu_texture_correction_hits(void); * with no sprite-tag helper: gte.cpp notes every RTPS/RTPT projection; a frame * that projects enough vertices is stamped as gameplay. */ void gpu_ws_set_gte_game_mode(int on); +void gpu_ws_set_precise_nclip(int on); void psx_ws_note_gte_project(int nverts); /* Optional authoritative gameplay-state gate. When configured, it replaces * heuristic gameplay classification for native-wide presentation. */ diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 8ebfd79b..78866c24 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -86,6 +86,7 @@ static uint32_t ws_anchor_addr = 0; /* scratchpad addr of anchor SXY */ static int ws_hud_sprt = 0; /* edge-anchor untagged HUD SPRTs */ static int ws_auto_ui_squash; static int ws_auto_ui_dense; +static int ws_active(void); static uint64_t ws_auto_ui_candidate_count; static uint64_t ws_auto_ui_transform_count; #define WS_UI_PREPASS_MAX 2048u @@ -166,6 +167,7 @@ static int ws_gte_game_mode_cfg = 0; static uint32_t ws_gte_frame = (uint32_t)-1; static uint32_t ws_gte_count = 0; static uint32_t ws_last_gte_stamp = (uint32_t)-1000; +static int ws_precise_nclip_cfg = 0; #define WS_GAMEPLAY_STATE_VALUES_MAX 16 static uint32_t ws_gameplay_state_addr = 0; static uint32_t ws_gameplay_state_values[WS_GAMEPLAY_STATE_VALUES_MAX]; @@ -180,6 +182,8 @@ 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; } +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) { if (nvalues < 0) nvalues = 0; @@ -2977,9 +2981,11 @@ uint32_t gpu_texture_correction_hits(void) { * triangle match prevents screen-space HUD/sprites that happen to share one * coordinate from receiving world-geometry correction. The integer delta * folds in draw offsets and any widescreen adjustment already applied. */ -static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, +static void prepare_precise_triangle(int i0, int i1, int i2, + uint32_t w0, uint32_t w1, uint32_t w2, const int32_t vx[3], const int32_t vy[3]) { uint32_t words[3] = { w0, w1, w2 }; + int word_indices[3] = { i0, i1, i2 }; int32_t fx[3], fy[3]; const int geometry_enabled = gte_geometry_correction_enabled(); gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); @@ -2990,7 +2996,16 @@ static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, for (int i = 0; i < 3; i++) { int32_t raw_x, raw_y; parse_vertex(words[i], &raw_x, &raw_y); - if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { + int found = 0; + if (gp0_cmd_source_addr != 0xFFFFFFFFu) { + uint32_t addr = (gp0_cmd_source_addr + + (uint32_t)word_indices[i] * 4u) & 0x1FFFFCu; + found = gte_precision_load_word(addr, words[i], &fx[i], &fy[i], + NULL); + } + if (!found) + found = gte_geometry_correction_lookup(words[i], &fx[i], &fy[i]); + if (!found) { gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } @@ -3209,7 +3224,8 @@ static void gp0_exec_mono_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], + prepare_precise_triangle(1, 2, 3, + gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], vx, vy); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } @@ -3275,14 +3291,16 @@ static void gp0_exec_mono_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], + prepare_precise_triangle(1, 2, 3, + gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], tx, ty); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[3], gp0_cmd_buf[2], + prepare_precise_triangle(3, 2, 4, + gp0_cmd_buf[3], gp0_cmd_buf[2], gp0_cmd_buf[4], tx, ty); gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); } @@ -3306,7 +3324,8 @@ static void gp0_exec_shaded_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], vx, vy); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], @@ -3352,7 +3371,8 @@ static void gp0_exec_shaded_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], tx, ty); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], @@ -3361,7 +3381,8 @@ static void gp0_exec_shaded_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], + prepare_precise_triangle(5, 3, 7, + gp0_cmd_buf[5], gp0_cmd_buf[3], gp0_cmd_buf[7], tx, ty); gr_draw_gouraud_triangle(vx[2], vy[2], c[2], vx[1], vy[1], c[1], @@ -3433,7 +3454,8 @@ static void gp0_exec_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; setup_textured_draw(color24, semi_trans, raw_texture); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], vx, vy); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], @@ -3515,7 +3537,8 @@ static void gp0_exec_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], tx, ty); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], @@ -3526,7 +3549,8 @@ static void gp0_exec_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], + prepare_precise_triangle(5, 3, 7, + gp0_cmd_buf[5], gp0_cmd_buf[3], gp0_cmd_buf[7], tx, ty); prepare_texture_triangle(5, 3, 7); gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], @@ -3569,7 +3593,8 @@ static void gp0_exec_shaded_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], + prepare_precise_triangle(1, 4, 7, + gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], vx, vy); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], @@ -3620,7 +3645,8 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], + prepare_precise_triangle(1, 4, 7, + gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], tx, ty); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], @@ -3631,7 +3657,8 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[7], gp0_cmd_buf[4], + prepare_precise_triangle(7, 4, 10, + gp0_cmd_buf[7], gp0_cmd_buf[4], gp0_cmd_buf[10], tx, ty); prepare_texture_triangle(7, 4, 10); gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 7c8ffd94..db046724 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -7,6 +7,7 @@ #include extern "C" uint32_t psx_read_word(uint32_t addr); +extern "C" int gpu_ws_precise_nclip_enabled(void); namespace PSXRecomp { namespace GTE { @@ -982,12 +983,22 @@ void gte_rtpt(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_nclip(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - int16_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); - int16_t sy0 = static_cast(gte->SXY[0] >> 16); - int16_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); - int16_t sy1 = static_cast(gte->SXY[1] >> 16); - int16_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); - int16_t sy2 = static_cast(gte->SXY[2] >> 16); + int32_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); + int32_t sy0 = static_cast(gte->SXY[0] >> 16); + int32_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); + int32_t sy1 = static_cast(gte->SXY[1] >> 16); + int32_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); + int32_t sy2 = static_cast(gte->SXY[2] >> 16); + if (gpu_ws_precise_nclip_enabled() && + s_precise_sxy[0].valid && s_precise_sxy[1].valid && + s_precise_sxy[2].valid) { + sx0 = s_precise_sxy[0].x16 >> 16; + sy0 = s_precise_sxy[0].y16 >> 16; + sx1 = s_precise_sxy[1].x16 >> 16; + sy1 = s_precise_sxy[1].y16 >> 16; + sx2 = s_precise_sxy[2].x16 >> 16; + sy2 = s_precise_sxy[2].y16 >> 16; + } int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + (int64_t)sx1 * (sy2 - sy0) + (int64_t)sx2 * (sy0 - sy1); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e5d50bde..65de1371 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1387,6 +1387,7 @@ static bool g_ws_hud_sprt = false; /* Runtime-only transition cleanup; kept out of gpu.h because generated game * units include that ABI header and do not need this frontend-only setter. */ extern "C" void gpu_ws_set_clear_reveal(int on); +extern "C" void gpu_ws_set_precise_nclip(int on); extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct); extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int); /* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot @@ -10345,6 +10346,7 @@ int main(int argc, char** argv) { gc.ws_bg2d_packet_cap); /* [widescreen] gte_game_mode — 3D-title gameplay detector (Ape). */ gpu_ws_set_gte_game_mode(gc.ws_gte_game_mode ? 1 : 0); + gpu_ws_set_precise_nclip(gc.ws_precise_nclip ? 1 : 0); gpu_ws_set_gameplay_state_gate( gc.ws_gameplay_state_addr, gc.ws_gameplay_state_values.data(), @@ -12048,6 +12050,8 @@ session_reboot: * the draw path is the faithful integer one, unchanged. */ gte_geometry_correction_set(g_video_geometry_correction); gpu_texture_correction_set(g_video_perspective_texturing); + gte_precision_tracking_set(g_video_geometry_correction || + g_video_perspective_texturing); if (g_video_geometry_correction || g_video_perspective_texturing) { std::fprintf(stdout, "psxrecomp: geometry correction %s, perspective texturing %s%s\n", From ed9ec01abe3206ff64841aa72933280276b8cb1a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 20:03:08 -0700 Subject: [PATCH 19/25] pgxp: default tolerance clamp 0.5px (user-validated seam fix) Interactive isolation on Ape Escape (in-game, live TCP toggles): OFF = baseline jiggle; geometry ON unclamped = sparse hairline background-bleed seams along corrected/uncorrected shared edges (both user-reported artifacts vanished with the toggle off - the THPS2 line class, not wild vertices); ON with tolerance 0.5 = seams gone, only sub-half-pixel misalignment remains. Ship 0.5 as the default; negative still disables. Also documents the screenshot_hires scale-2 windowed readback defect (tiled/black PNG - row pitch) found during the session. Co-Authored-By: Claude Fable 5 --- ENHANCEMENTS.md | 14 ++++++++++++++ recompiler/src/config_loader.h | 12 ++++++++---- runtime/src/main.cpp | 2 +- runtime/src/pgxp.cpp | 2 +- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index 5955184e..e10f983d 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -853,3 +853,17 @@ Crash with Tomba 2 as the 2D negative control; the hook-overhead FPS measure autocompile validation. Validation-build notes: title Release builds default `PSX_DEBUG_TOOLS=OFF` (no TCP), and a title launched without `--game` binds the framework default port 4370. + +**Interactive visual isolation, USER-VALIDATED (Ape Escape, in-game, +2026-08-15).** Same scene, one toggle at a time, player observing live: +OFF = baseline vertex jiggle; geometry ON unclamped = jiggle largely gone but +sparse hairline background-bleed seams (a <1px disagreement along a long +shared edge between a corrected triangle and an uncorrected neighbour — the +THPS2 line class, NOT wild vertices; both reported artifacts vanished with +the toggle off); geometry ON with `pgxp_tolerance = 0.5` = **seams gone**, +only sub-half-pixel misalignment remains. 0.5 is therefore the shipped +default (config_loader.h), live-tunable via the `pgxp` TCP verb. Known +tooling defect found on the way: `screenshot_hires` produces a tiled/black +PNG at 768x480 scale-2 windowed (row-pitch bug in the hires readback) — +the census + the player's own captures carried the session; fix it before +the formal Crash/Tomba2 A/B. diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 3663882d..899d1ec8 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -396,10 +396,14 @@ struct RuntimeConfig { bool video_pgxp_cpu_mode = false; // pgxp_tolerance: reject a corrected vertex whose sub-pixel offset from - // the native integer position exceeds this many pixels (a last-ditch - // visual clamp; the truncation-agreement check already bounds offsets to - // < 1px). Negative disables the clamp (default). Live-tunable over TCP. - double video_pgxp_tolerance = -1.0; + // the native integer position exceeds this many pixels (the truncation- + // agreement check already bounds offsets to < 1px; this narrows them + // further). Default 0.5 — user-validated on Ape Escape (2026-08-15): + // unclamped, sparse hairline background-bleed seams appear where a + // corrected triangle borders an uncorrected one; at 0.5 the seams are + // gone and only sub-half-pixel misalignment remains. Negative disables + // the clamp. Live-tunable over TCP (pgxp verb). + double video_pgxp_tolerance = 0.5; // offer_vulkan: expose the experimental Vulkan renderer in the launcher. // Defaults false even for Vulkan-enabled builds; developers must opt in per diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0ad1435a..268cf320 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1110,7 +1110,7 @@ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ static int g_video_geometry_correction = 0; static int g_video_perspective_texturing = 0; static int g_video_pgxp_cpu_mode = 0; -static float g_video_pgxp_tolerance = -1.0f; +static float g_video_pgxp_tolerance = 0.5f; static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index c8faaa6d..dc42615d 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -74,7 +74,7 @@ static PGXPValue s_gte[32]; /* GTE data registers */ static uint32_t s_gen = 1; static int s_enabled = 0; static int s_cpu_mode = 0; -static float s_tolerance = -1.0f; +static float s_tolerance = 0.5f; /* user-validated seam clamp (G1.10) */ static uint32_t s_suppress = 0; static int s_deferred_invalidate = 0; From 7993f412c0958a8b39b44e54ebf2a3e0ee8ab7e5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 20:09:37 -0700 Subject: [PATCH 20/25] Add opt-in controller device defaults --- recompiler/src/config_loader.cpp | 25 ++++++++++++++++------ recompiler/src/config_loader.h | 9 ++++++++ recompiler/tests/recompiler_patch_test.cpp | 16 ++++++++++++++ runtime/src/main.cpp | 4 ++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 2f220785..c7862e45 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -329,10 +329,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } } } - if (!cfg.contains("runtime")) return rt; - const toml::value& runtime = toml::find(cfg, "runtime"); - if (runtime.contains("language")) // [runtime].language convenience alias - rt.language = toml::find(runtime, "language"); + if (cfg.contains("runtime")) { + const toml::value& runtime = toml::find(cfg, "runtime"); + if (runtime.contains("language")) // [runtime].language convenience alias + rt.language = toml::find(runtime, "language"); if (runtime.contains("debug_port")) { const auto port = toml::find(runtime, "debug_port"); @@ -504,9 +504,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("overlay_backend")) { rt.overlay_backend = toml::find(runtime, "overlay_backend"); } - if (runtime.contains("overlay_native_block")) { - for (const auto& a : toml::find>(runtime, "overlay_native_block")) { - rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); + if (runtime.contains("overlay_native_block")) { + for (const auto& a : toml::find>(runtime, "overlay_native_block")) { + rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); + } } } @@ -704,6 +705,16 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& toml::find(ct, "p2_mode"), PAD_MODE_ANALOG); rt.has_default_mode = true; } + if (ct.contains("p1_device")) { + rt.default_p1_device = toml::find(ct, "p1_device"); + if (!rt.default_p1_device.empty()) + rt.has_default_p1_device = true; + } + if (ct.contains("p2_device")) { + rt.default_p2_device = toml::find(ct, "p2_device"); + if (!rt.default_p2_device.empty()) + rt.has_default_p2_device = true; + } if (ct.contains("lock_mode")) { rt.controller_lock_mode = toml::find(ct, "lock_mode"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 2486ec19..871b9d0d 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -497,6 +497,15 @@ struct RuntimeConfig { int default_p1_mode = PAD_MODE_ANALOG; int default_p2_mode = PAD_MODE_ANALOG; + // p1_device / p2_device: optional per-game default input sources for + // fresh settings and launcher-less boots. Same vocabulary as settings.toml: + // "none", "keyboard", "auto"/"gamepad"/"controller", or an SDL GUID. + // Per-install settings.toml still overrides these defaults. + bool has_default_p1_device = false; + bool has_default_p2_device = false; + std::string default_p1_device; + std::string default_p2_device; + // lock_mode: when true the launcher HIDES the whole pad-mode selector // (Hybrid | Analog | D-Pad) and forces every port to default_p1_mode. For a // game that supports exactly one pad type — e.g. Tomba 2, whose driver only diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index 212f7c93..43cebaf1 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -142,6 +142,22 @@ buffer_ms = 60 check(audio_config.runtime.audio_buffer_ms == 60, "parser preserves per-game audio buffer target"); + const auto controller_defaults = write_config(root, "controller-defaults", R"toml( +[controller] +p1_device = "keyboard" +p2_device = "gamepad" +p2_mode = "digital" +)toml"); + const auto controller_defaults_config = + PSXRecompV4::load_game_config(controller_defaults); + check(controller_defaults_config.runtime.has_default_p1_device && + controller_defaults_config.runtime.default_p1_device == "keyboard" && + controller_defaults_config.runtime.has_default_p2_device && + controller_defaults_config.runtime.default_p2_device == "gamepad" && + controller_defaults_config.runtime.default_p2_mode == + PSXRecompV4::PAD_MODE_DIGITAL, + "parser preserves per-game controller device defaults"); + const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( [runtime] diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 65de1371..3a8d2de4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10504,6 +10504,10 @@ int main(int argc, char** argv) { if (i >= 2) player_mode[i] = gc.runtime.default_p1_mode; } } + if (gc.runtime.has_default_p1_device) + player_device[0] = gc.runtime.default_p1_device; + if (PSX_MAX_PLAYERS >= 2 && gc.runtime.has_default_p2_device) + player_device[1] = gc.runtime.default_p2_device; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) ctrl_locked_mode[i] = player_mode[i]; ctrl_lock_mode = gc.runtime.controller_lock_mode; From b2eeb0f866b1a2b26a570811dfbe0adefa8ab922 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 20:51:23 -0700 Subject: [PATCH 21/25] Add opt-in netplay local viewport extraction --- recompiler/src/config_loader.cpp | 35 ++++++++ recompiler/src/config_loader.h | 9 ++ recompiler/tests/recompiler_patch_test.cpp | 14 ++++ runtime/include/gpu.h | 5 ++ runtime/src/debug_server.c | 4 + runtime/src/gpu.c | 95 +++++++++++++++++++++ runtime/src/main.cpp | 97 ++++++++++++++++++++-- 7 files changed, 253 insertions(+), 6 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index c7862e45..3af4a0c4 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1193,6 +1193,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool has_netplay_required_leadout = false; uint32_t netplay_required_leadout_lba = 0; std::string netplay_required_disc_fp; + std::string netplay_local_viewport; + std::string netplay_local_viewport_aspect; if (cfg.contains("netplay")) { const toml::value& np = toml::find(cfg, "netplay"); if (np.contains("require_cue")) @@ -1209,6 +1211,37 @@ GameConfig load_game_config(const fs::path& config_path_in) { for (char& c : netplay_required_disc_fp) c = (char)std::tolower((unsigned char)c); } + if (np.contains("local_viewport")) { + netplay_local_viewport = toml::find(np, "local_viewport"); + for (char& c : netplay_local_viewport) + c = (char)std::tolower((unsigned char)c); + if (!netplay_local_viewport.empty() && + netplay_local_viewport != "vertical_split") { + throw std::runtime_error(fmt::format( + "[netplay] local_viewport must be \"vertical_split\", got '{}'", + netplay_local_viewport)); + } + } + if (np.contains("local_viewport_aspect")) { + netplay_local_viewport_aspect = + toml::find(np, "local_viewport_aspect"); + for (char& c : netplay_local_viewport_aspect) + c = (char)std::tolower((unsigned char)c); + if (!netplay_local_viewport_aspect.empty() && + netplay_local_viewport_aspect != "16:9" && + netplay_local_viewport_aspect != "21:9" && + netplay_local_viewport_aspect != "adaptive") { + throw std::runtime_error(fmt::format( + "[netplay] local_viewport_aspect must be \"16:9\", " + "\"21:9\", or \"adaptive\", got '{}'", + netplay_local_viewport_aspect)); + } + if (!netplay_local_viewport_aspect.empty() && + netplay_local_viewport.empty()) { + throw std::runtime_error( + "[netplay] local_viewport_aspect requires local_viewport"); + } + } } // [recompiler] @@ -1995,6 +2028,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*has_netplay_required_leadout*/ has_netplay_required_leadout, /*netplay_required_leadout_lba*/ netplay_required_leadout_lba, /*netplay_required_disc_fp*/ netplay_required_disc_fp, + /*netplay_local_viewport*/ netplay_local_viewport, + /*netplay_local_viewport_aspect*/ netplay_local_viewport_aspect, /*seeds_path*/ seeds_path, /*bios_thunks_path*/ bios_thunks_path, /*bios_config_path*/ bios_config_path, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 871b9d0d..d15fb6b5 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -685,6 +685,15 @@ struct GameConfig { bool has_netplay_required_leadout = false; uint32_t netplay_required_leadout_lba = 0; std::string netplay_required_disc_fp; // lowercase hex SHA-256 + // local_viewport = "vertical_split": while real netplay is active, crop + // presentation to this peer's left/right split-screen half. This is a + // presentation-only helper for titles that still render native split-screen + // in netplay; unset keeps every peer seeing the full framebuffer. + std::string netplay_local_viewport; + // Optional display aspect to use with local_viewport. Accepted values: + // "16:9", "21:9", or "adaptive" (initial 16:9, live-window capped 21:9). + // Unset keeps netplay at the title's normal mod-cleared aspect. + std::string netplay_local_viewport_aspect; // [recompiler] block std::filesystem::path seeds_path; // absolute path to seeds (text or json) diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index 43cebaf1..04892dea 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -158,6 +158,20 @@ p2_mode = "digital" PSXRecompV4::PAD_MODE_DIGITAL, "parser preserves per-game controller device defaults"); + const auto netplay_viewport = write_config(root, "netplay-viewport", R"toml( +[runtime] + +[netplay] +local_viewport = "vertical_split" +local_viewport_aspect = "adaptive" +)toml"); + const auto netplay_viewport_config = + PSXRecompV4::load_game_config(netplay_viewport); + check(netplay_viewport_config.netplay_local_viewport == "vertical_split", + "parser preserves netplay local viewport mode"); + check(netplay_viewport_config.netplay_local_viewport_aspect == "adaptive", + "parser preserves netplay local viewport aspect"); + const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( [runtime] diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 7e06f072..2385b3b4 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -75,6 +75,11 @@ typedef struct { int32_t offset_x, offset_y; } GpuDrawArea; void gpu_get_draw_area(GpuDrawArea* out); +/* Non-zero when the frame that just reached vblank used two side-by-side draw + * areas. Presentation-only helpers use this to expand a local split-screen + * viewport without changing the underlying framebuffer or netplay hashes. */ +int gpu_last_frame_vertical_split_screen(void); +void gpu_vertical_split_debug(int *active, int *left_age, int *right_age); uint16_t gpu_vram_peek(int x, int y); /* Shaded quad vertex capture (Phase 4.5 debug). */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 61cd2464..bf15c4e9 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5221,6 +5221,8 @@ static void handle_gpu_state(int id, const char *json) gpu_get_draw_area(&da); uint64_t nop, fill, draw, env, copy; gpu_get_gp0_stats(&nop, &fill, &draw, &env, ©); + int split_active = 0, split_left_age = 0, split_right_age = 0; + gpu_vertical_split_debug(&split_active, &split_left_age, &split_right_age); GpuWsDebug ws; gpu_ws_get_debug(&ws); send_fmt("{\"id\":%d,\"ok\":true," @@ -5235,6 +5237,7 @@ static void handle_gpu_state(int id, const char *json) "\"gp0_nop\":%llu,\"gp0_fill\":%llu,\"gp0_draw\":%llu,\"gp0_env\":%llu,\"gp0_copy\":%llu," "\"draw_area\":[%u,%u,%u,%u]," "\"draw_offset\":[%d,%d]," + "\"vertical_split\":{\"active\":%d,\"left_age\":%d,\"right_age\":%d}," "\"ws\":{\"configured\":%d,\"active\":%d,\"game_mode\":%d," "\"present_native_43\":%d,\"x_margin\":%d," "\"activation_margin\":%d,\"squash\":[%d,%d]," @@ -5263,6 +5266,7 @@ static void handle_gpu_state(int id, const char *json) (unsigned long long)copy, da.left, da.top, da.right, da.bottom, da.offset_x, da.offset_y, + split_active, split_left_age, split_right_age, ws.configured, ws.active, ws.game_mode, ws.present_native_43, ws.x_margin, ws.activation_margin, ws.xnum, ws.xden, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 78866c24..2584bef9 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2169,6 +2169,20 @@ static uint32_t texture_disable; /* bit 15 */ static uint32_t draw_area_left, draw_area_top; static uint32_t draw_area_right, draw_area_bottom; +typedef struct GpuVerticalSplitTrace { + uint8_t left_seen; + uint8_t right_seen; + uint16_t display_w; + uint16_t display_h; +} GpuVerticalSplitTrace; + +static GpuVerticalSplitTrace split_trace_this; +static GpuVerticalSplitTrace split_trace_last; +static uint8_t split_recent_left_age = 255; +static uint8_t split_recent_right_age = 255; +static uint16_t split_recent_display_w = 0; +static uint16_t split_recent_display_h = 0; + /* Draw offset (set by GP0(E5h)) */ static int32_t draw_offset_x, draw_offset_y; /* Instrumentation: per-vblank range/count of GP0(E5) draw-offset-Y sets. If a @@ -2235,6 +2249,59 @@ static int ws_is_fb_base(uint32_t bx) { for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return 1; return 0; } + +static void split_trace_reset(GpuVerticalSplitTrace *trace) { + memset(trace, 0, sizeof(*trace)); +} + +static void split_trace_note_draw_area(void) { + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 256 || di.height < 128) + return; + if ((di.width & 1u) != 0) + return; + if (di.display_x + di.width > 1024u || di.display_y + di.height > 512u) + return; + if (draw_area_right <= draw_area_left || draw_area_bottom <= draw_area_top) + return; + + const int disp_l = (int)di.display_x; + const int disp_r = (int)(di.display_x + di.width - 1u); + const int mid = disp_l + (int)(di.width / 2u); + const int tol = (int)di.width / 32 > 8 ? (int)di.width / 32 : 8; + + const int area_l = (int)draw_area_left; + const int area_r = (int)draw_area_right; + + if (split_trace_this.display_w != 0 && + (split_trace_this.display_w != (uint16_t)di.width || + split_trace_this.display_h != (uint16_t)di.height)) { + split_trace_reset(&split_trace_this); + } + split_trace_this.display_w = (uint16_t)di.width; + split_trace_this.display_h = (uint16_t)di.height; + + if (area_l <= disp_l + tol && + area_r >= mid - 1 - tol && area_r <= mid - 1 + tol) { + split_trace_this.left_seen = 1; + } + if (area_l >= mid - tol && area_l <= mid + tol && + area_r >= disp_r - tol) { + split_trace_this.right_seen = 1; + } +} + +int gpu_last_frame_vertical_split_screen(void) { + return split_recent_left_age <= 8 && split_recent_right_age <= 8; +} + +void gpu_vertical_split_debug(int *active, int *left_age, int *right_age) { + if (active) *active = gpu_last_frame_vertical_split_screen(); + if (left_age) *left_age = split_recent_left_age; + if (right_age) *right_age = split_recent_right_age; +} + /* Point the renderer's wide mirror at the current back buffer (draw_area_left) * when native-wide is active and that buffer is a known display buffer; else * disable mirroring for this draw. Called when the draw env changes. */ @@ -2366,6 +2433,12 @@ static void gpu_reset_state(int clear_vram) { draw_area_top = 0; draw_area_right = 0; draw_area_bottom = 0; + split_trace_reset(&split_trace_this); + split_trace_reset(&split_trace_last); + split_recent_left_age = 255; + split_recent_right_age = 255; + split_recent_display_w = 0; + split_recent_display_h = 0; draw_offset_x = 0; draw_offset_y = 0; texture_window_value = 0; @@ -2658,6 +2731,26 @@ void gpu_vblank_tick(void) { g_doff_cnt_last = g_doff_cnt_this; } g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; + split_trace_last = split_trace_this; + if (split_trace_this.display_w != 0 && + (split_recent_display_w != split_trace_this.display_w || + split_recent_display_h != split_trace_this.display_h)) { + split_recent_left_age = 255; + split_recent_right_age = 255; + split_recent_display_w = split_trace_this.display_w; + split_recent_display_h = split_trace_this.display_h; + } + if (split_trace_this.left_seen) { + split_recent_left_age = 0; + } else if (split_recent_left_age < 255) { + split_recent_left_age++; + } + if (split_trace_this.right_seen) { + split_recent_right_age = 0; + } else if (split_recent_right_age < 255) { + split_recent_right_age++; + } + split_trace_reset(&split_trace_this); gpustat_poll_count = 0; /* Trusted package-selected plugins run on guest VBlank, independent of * host presentation, pacing, turbo, or skipped frames. */ @@ -3934,6 +4027,7 @@ static void gp0_exec_draw_area_tl(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_area_left = param & 0x3FF; draw_area_top = (param >> 10) & 0x3FF; + split_trace_note_draw_area(); gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ @@ -3946,6 +4040,7 @@ static void gp0_exec_draw_area_br(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_area_right = param & 0x3FF; draw_area_bottom = (param >> 10) & 0x3FF; + split_trace_note_draw_area(); gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 3a8d2de4..306606cb 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2374,6 +2374,36 @@ static void shutdown_runtime(void); * there instead of killing the process. */ static int g_netplay_from_lobby = 0; static int g_netplay_vsync_forced_off = 0; +/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, + * present only this peer's native split-screen half and stretch it to the + * window. Presentation-only; the guest still renders the original framebuffer. */ +static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ +/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect + * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ +static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ + +static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { + if (!netplay_enabled || + g_netplay_local_viewport != 1 || + g_netplay_local_viewport_aspect == 0) { + return; + } + + switch (g_netplay_local_viewport_aspect) { + case 1: + (void)psx_mod_set_fixed_display_aspect(16u, 9u); + break; + case 2: + (void)psx_mod_set_fixed_display_aspect(21u, 9u); + break; + case 3: + (void)psx_mod_set_fixed_display_aspect(16u, 9u); + (void)psx_mod_set_adaptive_display_aspect(21u, 9u); + break; + default: + break; + } +} /* Host-only: lockstep already couples peers. Driver vsync on top of the * wall-clock pacer double-blocks the vblank callback (present is before the @@ -4604,6 +4634,36 @@ static void netplay_note_present(void) { s_present_last_ms = now ? now : 1ull; } +static int netplay_local_viewport_slot(void) { + if (g_netplay_local_viewport != 1 || !psx_netplay_active()) + return -1; + if (!gpu_last_frame_vertical_split_screen()) + return -1; + int slot = psx_netplay_local_slot(); + return (slot == 0 || slot == 1) ? slot : -1; +} + +static int crop_present_to_netplay_local_viewport(uint32_t* pixels, + int* width, + int height) { + if (!pixels || !width || *width < 2 || height <= 0) + return 0; + const int slot = netplay_local_viewport_slot(); + if (slot < 0) + return 0; + + const int src_w = *width; + const int crop_w = src_w / 2; + const int crop_x = slot == 0 ? 0 : (src_w - crop_w); + for (int y = 0; y < height; ++y) { + uint32_t* dst = pixels + (size_t)y * (size_t)crop_w; + uint32_t* src = pixels + (size_t)y * (size_t)src_w + crop_x; + memmove(dst, src, (size_t)crop_w * sizeof(uint32_t)); + } + *width = crop_w; + return 1; +} + static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { unsigned n = s_present_gaps_n; unsigned idx; @@ -6285,6 +6345,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_gl_active) gl_renderer_set_interpolation_suspended( fmv_frame || mdec_recently_active(2)); + const bool local_viewport_crop = netplay_local_viewport_slot() >= 0; + bool local_viewport_crop_applied = false; /* Canonical present width. Native-wide does NOT widen the canonical read * (that bled across adjacent framebuffers); it composites into a separate @@ -6295,7 +6357,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * wide compositor, present the wider surface (canonical width + EXTRA) * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && - ws_native_wide_active() && gr_wide_supported()); + ws_native_wide_active() && gr_wide_supported() && + !local_viewport_crop); if (wide_present) present_w = w + (uint32_t)ws_nw_extra(); /* Native-wide invariant: canonical (320-wide) content is NEVER @@ -6321,7 +6384,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * do NOT flush_cpu_uploads (MDEC already wrote the CPU mirror; forcing * FBO uploads every frame cut MotK intro from ~50 to ~30 FPS). */ #ifndef PSX_SDL_NO_RENDER - if (g_gl_active && g_gl_fbo_present && !di.depth24) { + if (g_gl_active && g_gl_fbo_present && !di.depth24 && + !local_viewport_crop) { if (wide_present) { /* GPU-direct native-wide present: blit the displayed buffer's * wide FBO straight to the window (GPU-side, like the canonical @@ -6348,7 +6412,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * VRAM image (deterministic blit, no readback), mirroring the GL path; * 24-bit (FMV) frames go through the CPU present (Phase 3). The Vulkan * window has no SDL_Renderer, so we must never fall through below. */ - if (g_vk_active) { + if (g_vk_active && !local_viewport_crop) { if (di.depth24) { /* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT * sync_cpu (FBO readback clobbers RGB888). Batch per-scanline @@ -6444,9 +6508,18 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, di.display_x); + int present_px_w = (int)present_w * active_scale; + int present_px_h = (int)h * active_scale; + if (crop_present_to_netplay_local_viewport(sdl_pixel_buf, + &present_px_w, + present_px_h)) { + pin_43 = false; + local_viewport_crop_applied = true; + } + smooth_60_present(sdl_pixel_buf, - present_w * (uint32_t)active_scale, - h * (uint32_t)active_scale, + (uint32_t)present_px_w, + (uint32_t)present_px_h, !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); /* Frame blending (CRT-persistence masker for 30fps double-buffered @@ -6471,7 +6544,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { !g_smooth_60fps.load(std::memory_order_acquire)) { static uint32_t prev_buf[640 * 512]; static uint32_t prev_px = 0; - const uint32_t npx = present_w * h; + const uint32_t npx = local_viewport_crop_applied + ? (uint32_t)(present_px_w * present_px_h) + : present_w * h; if (npx <= (uint32_t)(640 * 512)) { if (prev_px == npx) { for (uint32_t i = 0; i < npx; i++) { @@ -6499,6 +6574,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { #ifndef PSX_SDL_NO_RENDER int src_w = (int)present_w * active_scale; int src_h = (int)h * active_scale; + if (netplay_local_viewport_slot() >= 0 && src_w >= 2) + src_w /= 2; if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen * quad. SDL_GL_SwapWindow handles vsync; the wall-clock pacer above @@ -10253,6 +10330,12 @@ int main(int argc, char** argv) { g_netplay_disc_expect.required_leadout_lba = gc.netplay_required_leadout_lba; g_netplay_disc_expect.required_disc_fp = gc.netplay_required_disc_fp; + g_netplay_local_viewport = + (gc.netplay_local_viewport == "vertical_split") ? 1 : 0; + g_netplay_local_viewport_aspect = + (gc.netplay_local_viewport_aspect == "16:9") ? 1 : + (gc.netplay_local_viewport_aspect == "21:9") ? 2 : + (gc.netplay_local_viewport_aspect == "adaptive") ? 3 : 0; if (!gc.discs.empty()) resolved_disc = gc.discs.front(); if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; @@ -11838,6 +11921,7 @@ int main(int argc, char** argv) { g_turbo_loads_enabled = 0; g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); + apply_netplay_local_viewport_aspect(net_cfg.enabled); if (g_mod_controller_mode_override[0] >= 0) player_mode[0] = g_mod_controller_mode_override[0]; if (g_mod_controller_mode_override[1] >= 0) @@ -13472,6 +13556,7 @@ soft_return_lobby: return 1; } } + apply_netplay_local_viewport_aspect(net_cfg.enabled); std::printf("psxrecomp: rematch from lobby (netplay=%d)\n", net_cfg.enabled ? 1 : 0); std::fflush(stdout); From d31ce5ac6d0ca92758a56c4e16f8c056166cae72 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 20:52:21 -0700 Subject: [PATCH 22/25] pgxp: framework-owned PGXP Precision mod, every game, default off psx.enhancement.pgxp (game_id \"*\") in the builtin catalog: one Mods-page toggle that arms sub-pixel vertex precision + perspective-correct texturing with the user-validated defaults (tolerance clamp 0.5px), plus a cpu-mode option. Default off = the faithful floor (G1.9). Fully effective on PGXP-variant binaries; reduced-but-safe coverage elsewhere (the always-emitted swc2 tier + interpreters, validate-on-read). Co-Authored-By: Claude Fable 5 --- .../psx.enhancement.pgxp/1.0.0/manifest.toml | 33 ++++++++++++++ runtime/runtime.cmake | 1 + runtime/src/mod_builtin_pgxp.c | 43 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml create mode 100644 runtime/src/mod_builtin_pgxp.c diff --git a/mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml b/mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml new file mode 100644 index 00000000..8f0e2952 --- /dev/null +++ b/mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml @@ -0,0 +1,33 @@ +format_version = 5 +id = "psx.enhancement.pgxp" +version = "1.0.0" +name = "PGXP Precision (geometry + textures)" +author = "psxrecomp" +description = "Sub-pixel vertex precision and perspective-correct texturing (the PGXP technique): the runtime shadows the GTE's full-precision projections and follows them to the GPU, so 3D polygons stop wobbling between whole pixels and large floor/wall textures stop warping. Visual-only: the game's own math, culling and readback see exactly what real hardware produces." +resolver = "declarative" +save_compatibility = "shared" + +# Sub-pixel precision is a property of the emulated GTE/GPU pair, not of any +# particular disc, so this ships with the framework and applies to every game. +[[target]] +game_id = "*" + +[[feature]] +id = "pgxp" +name = "PGXP Precision" +description = "Disabled by default (the faithful floor). Corrections are validated per vertex against the exact packet words the game drew with and clamped to half a pixel, so 2D sprites and UI are never touched and a vertex the tracker cannot prove simply draws the faithful way. Most effective on a build compiled with the PGXP hook variant; on other builds coverage is reduced but never harmful. Needs supersampling >= 2 to be visible." +group = "Visual" +default_enabled = false + +[[option]] +feature = "pgxp" +id = "cpu_mode" +label = "CPU-mode propagation" +description = "Also track precision through CPU arithmetic (register repacking math). Off matches the reference implementations' default; on can raise coverage in games that rebuild vertices with integer math. Always safe either way - validation rejects anything stale." +group = "Visual" +type = "boolean" +default = "false" + +[[plugin]] +feature = "pgxp" +id = "psx.pgxp" diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index e8228262..5946a3a9 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -332,6 +332,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/event_ring.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c + ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c ${PSXRECOMP_ROOT}/runtime/src/mod_packages.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_runtime.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_keybinds.c diff --git a/runtime/src/mod_builtin_pgxp.c b/runtime/src/mod_builtin_pgxp.c new file mode 100644 index 00000000..4db67d42 --- /dev/null +++ b/runtime/src/mod_builtin_pgxp.c @@ -0,0 +1,43 @@ +/* + * Framework-owned PGXP precision mod, available to every game. + * + * Sub-pixel vertex precision + perspective-correct texturing is a property of + * the emulated GTE/GPU pair, not of any particular disc, so it ships here and + * mods/builtin/packages/psx.enhancement.pgxp targets game_id "*". Default off + * (the faithful floor, ENHANCEMENTS.md G1.9); enabling arms the value- + * propagation engine (pgxp.cpp) with the user-validated defaults — tolerance + * clamp 0.5px, cpu-mode per the mod option. + * + * Coverage note: the engine reaches near-total dataflow coverage on a binary + * compiled with the PGXP hook variant (-DPSX_PGXP=1, PSX_PGXP_VARIANT); on a + * base-flavour binary only the always-emitted swc2 tier and the interpreters + * feed it, so the correction is weaker there but never harmful — every vertex + * is validated against the exact packet word before it is believed. + */ +#include "mod_plugins.h" +#include "pgxp.h" + +#include + +#define PKG_PGXP "psx.enhancement.pgxp" + +/* gte.cpp / gpu.c — the two correction toggles this mod arms. */ +extern void gte_geometry_correction_set(int enabled); +extern void gpu_texture_correction_set(int enabled); + +static int pgxp_option_flag(const char* feature, const char* id) { + char text[16] = ""; + return psx_mod_option_value(PKG_PGXP, feature, id, text, sizeof text) && + strcmp(text, "true") == 0; +} + +static void builtin_pgxp_activate(void) { + gte_geometry_correction_set(1); + gpu_texture_correction_set(1); /* also arms the shadow engine */ + pgxp_set_cpu_mode(pgxp_option_flag("pgxp", "cpu_mode")); +} + +PSX_MOD_CONSTRUCTOR(psx_register_builtin_pgxp_plugin) { + (void)psx_mod_register_activation_plugin("psx.pgxp", + builtin_pgxp_activate); +} From c18d820fb828772a490ccc8a0b67b4c78320476e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 21:02:38 -0700 Subject: [PATCH 23/25] Correct split viewport projection aspect --- runtime/src/main.cpp | 118 ++++++++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 29 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 306606cb..709a5504 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1185,6 +1185,13 @@ static int g_video_aspect_den = 3; static bool g_ws_adaptive_view = false; static int g_ws_adaptive_max_num = 16; static int g_ws_adaptive_max_den = 9; +/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, + * present only this peer's native split-screen half and stretch it to the + * window. Presentation-only; the guest still renders the original framebuffer. */ +static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ +/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect + * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ +static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { @@ -1423,6 +1430,81 @@ static int aspect_gcd(int a, int b) { return a > 0 ? a : 1; } +static int64_t aspect_gcd64(int64_t a, int64_t b) { + if (a < 0) a = -a; + if (b < 0) b = -b; + while (b) { int64_t t = a % b; a = b; b = t; } + return a > 0 ? a : 1; +} + +static void netplay_local_viewport_projection_aspect( + int present_num, int present_den, int* proj_num, int* proj_den) { + if (!proj_num || !proj_den) return; + *proj_num = present_num; + *proj_den = present_den; + + if (g_netplay_local_viewport != 1 || + !psx_netplay_active() || + !gpu_last_frame_vertical_split_screen() || + present_num * 3 <= present_den * 4) { + return; + } + + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 2 || di.height == 0) + return; + + /* The normal widescreen squash assumes a 4:3 source. A split-screen peer + * source is only half the display width, so derive the equivalent aspect + * that produces source_aspect / target_aspect as the X squash factor: + * + * source = (display_w / 2) / display_h + * effective = (4:3) * target / source + */ + int64_t n = (int64_t)present_num * 8 * (int64_t)di.height; + int64_t d = (int64_t)present_den * 3 * (int64_t)di.width; + if (n <= 0 || d <= 0) return; + int64_t gcd = aspect_gcd64(n, d); + n /= gcd; + d /= gcd; + if (n <= 0 || d <= 0 || n > INT32_MAX || d > INT32_MAX) + return; + + *proj_num = (int)n; + *proj_den = (int)d; +} + +static int g_ws_projection_num = 4; +static int g_ws_projection_den = 3; +static int g_ws_projection_mode = -1; +static void refresh_widescreen_projection() { + if (!g_ws_engaged) return; + + const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; + const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; + int proj_num = g_video_aspect_num; + int proj_den = g_video_aspect_den; + if (mode == 1) { + netplay_local_viewport_projection_aspect( + g_video_aspect_num, g_video_aspect_den, &proj_num, &proj_den); + } + + if (mode == g_ws_projection_mode && + proj_num == g_ws_projection_num && + proj_den == g_ws_projection_den) { + return; + } + + g_ws_projection_mode = mode; + g_ws_projection_num = proj_num; + g_ws_projection_den = proj_den; + gte_set_display_aspect(mode == 1 ? proj_num : 4, + mode == 1 ? proj_den : 3); + gpu_ws_configure(proj_num, proj_den, g_ws_anchor_addr, + g_ws_hud_sprt ? 1 : 0, mode); +} + /* Follow the host window without feeding its absolute pixel size into guest * rendering. Only the ratio matters: gpu_ws_configure derives the PSX-native * sidecar width from it, just as the fixed 16:9/21:9 modes do. */ @@ -1455,14 +1537,7 @@ static void update_adaptive_widescreen() { SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); } - if (g_ws_engaged) { - const bool wide = num * 3 != den * 4; - const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - gte_set_display_aspect(mode == 1 ? num : 4, - mode == 1 ? den : 3); - gpu_ws_configure(num, den, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, mode); - } + refresh_widescreen_projection(); } /* SDL GL attributes are global inputs to the next context creation. Set the @@ -1480,13 +1555,8 @@ static void configure_core_gl_context_attributes() { * Re-engages with the chosen mode in place if widescreen is already running. */ extern "C" void psx_ws_set_native_wide(int on) { g_ws_native_wide = on ? 1 : 0; - if (g_ws_engaged && g_video_aspect_num * 3 != g_video_aspect_den * 4) { - int mode = g_ws_native_wide ? 2 : 1; - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); - } + g_ws_projection_mode = -1; + refresh_widescreen_projection(); } extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } @@ -2374,13 +2444,6 @@ static void shutdown_runtime(void); * there instead of killing the process. */ static int g_netplay_from_lobby = 0; static int g_netplay_vsync_forced_off = 0; -/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, - * present only this peer's native split-screen half and stretch it to the - * window. Presentation-only; the guest still renders the original framebuffer. */ -static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ -/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect - * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ -static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { if (!netplay_enabled || @@ -6229,14 +6292,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { extern int fntrace_is_game_started(void); if (fntrace_is_game_started()) { g_ws_engaged = true; - int mode = g_ws_native_wide ? 2 : 1; - /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio - * (identity squash). Squash mode: feed the real wide aspect. */ - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); + g_ws_projection_mode = -1; + refresh_widescreen_projection(); } + } else { + refresh_widescreen_projection(); } /* Rollback resim (§33/§47): short catch-up keeps hold-last; long catch-up From 41c232fb475cfed10e077ebff71a208ab2fcf45a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 21:46:47 -0700 Subject: [PATCH 24/25] Fix precise NCLIP on PGXP-backed GTE shadows --- runtime/include/pgxp.h | 4 ++++ runtime/src/gte.cpp | 18 ++++++++++-------- runtime/src/pgxp.cpp | 10 ++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index c6ba06d0..db026ad6 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -55,6 +55,10 @@ void pgxp_suppress_end(void); * sees. Shifts the shadow FIFO exactly like push_sxy (regs 12..15). */ 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); + /* 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 * the SXYP FIFO push on the shadows, others just invalidate/overwrite. */ diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 838fbc26..4ef73575 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -929,15 +929,17 @@ void gte_nclip(GTEState* gte, uint32_t instr) { int32_t sy1 = static_cast(gte->SXY[1] >> 16); int32_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); int32_t sy2 = static_cast(gte->SXY[2] >> 16); + int32_t px0 = 0, py0 = 0, px1 = 0, py1 = 0, px2 = 0, py2 = 0; if (gpu_ws_precise_nclip_enabled() && - s_precise_sxy[0].valid && s_precise_sxy[1].valid && - s_precise_sxy[2].valid) { - sx0 = s_precise_sxy[0].x16 >> 16; - sy0 = s_precise_sxy[0].y16 >> 16; - sx1 = s_precise_sxy[1].x16 >> 16; - sy1 = s_precise_sxy[1].y16 >> 16; - sx2 = s_precise_sxy[2].x16 >> 16; - sy2 = s_precise_sxy[2].y16 >> 16; + 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) + diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index dc42615d..94f03870 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -644,6 +644,16 @@ extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, s_gte[15] = *pv; /* SXYP mirrors SXY2 */ } +extern "C" int pgxp_get_gte_sxy(uint32_t index, 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 (x16) *x16 = pv->x16; + if (y16) *y16 = pv->y16; + return 1; +} + extern "C" void pgxp_gte_reg_written(int reg, uint32_t value) { /* Invalidation-class bookkeeping: runs even with the engine disarmed so * seeded/leftover shadows can never outlive a guest register write. Only From ad55fecdcf32e61dc762d560468508f104fe2de7 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 22:53:24 -0700 Subject: [PATCH 25/25] Gate WipEout local viewport native-wide to split frames --- runtime/include/gpu.h | 4 + runtime/src/gpu.c | 187 ++++++++++++++++++++++++++++++++++++------ runtime/src/main.cpp | 41 +++++++-- 3 files changed, 199 insertions(+), 33 deletions(-) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 58755663..f5d8c449 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -212,6 +212,10 @@ void psx_ws_sprite_tag(struct CPUState* cpu); * by this; 0 when native-wide is inactive). */ int ws_native_wide_active(void); int ws_nw_extra(void); +int ws_nw_present_width(void); +void gpu_ws_set_netplay_local_viewport(int enabled, int slot); +int gpu_ws_netplay_local_viewport_base_x(void); +int gpu_ws_netplay_local_viewport_width(void); /* True when the current frame must present at native 4:3 (FMV video or a * full-2D menu/title screen), so the squash is suppressed and content drawn * pixel-native. The present path uses the same predicate to pillarbox. */ diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index a82439d9..c4b80f1a 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -128,6 +128,7 @@ static int ws_engaged(void) { return ws_mode != 0; } /* Forward decls: defined later but used by psx_ws_backdrop_x above them. */ static int32_t ws_scale_about(int32_t x, int32_t ax); +static int32_t ws_disp_x(void); static int32_t ws_disp_w(void); static void ws_clear_all_reveal_margins(void); @@ -349,8 +350,48 @@ int ws_native_wide_active(void) { static int ws_native_wide_configured(void) { return ws_mode == 2 && ws_cfg_num * 3 > ws_cfg_den * 4; } + +static int ws_local_viewport_cfg = 0; +static int ws_local_viewport_slot = 0; +static int ws_local_viewport_draw_target(int *base_x); +static int ws_vertical_split_active(void); +void gpu_ws_set_netplay_local_viewport(int enabled, int slot) { + ws_local_viewport_cfg = enabled ? 1 : 0; + ws_local_viewport_slot = slot == 1 ? 1 : 0; + ws_nw_sync_target(); +} + +static int ws_local_viewport_layout(int *base_x, int *source_w, + int *wide_w, int *offset) { + if (!ws_local_viewport_cfg || !ws_native_wide_configured() || + !ws_vertical_split_active()) + return 0; + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 2 || di.height == 0) + return 0; + if ((di.width & 1u) != 0) + return 0; + + int src_w = (int)di.width / 2; + int target_w = ((int)di.height * ws_cfg_num + ws_cfg_den / 2) / ws_cfg_den; + if (target_w < src_w) + target_w = src_w; + int off = (target_w - src_w) / 2; + int final_w = src_w + off * 2; + + if (base_x) *base_x = (int)di.display_x + (ws_local_viewport_slot ? src_w : 0); + if (source_w) *source_w = src_w; + if (wide_w) *wide_w = final_w; + if (offset) *offset = off; + return final_w > src_w && off > 0; +} + static int ws_nw_configured_offset(void) { if (!ws_native_wide_configured()) return 0; + int local_offset = 0; + if (ws_local_viewport_layout(NULL, NULL, NULL, &local_offset)) + return local_offset; int numr = 3 * ws_cfg_num - 4 * ws_cfg_den; int w = (int)ws_disp_w(); return (w * numr + 4 * ws_cfg_den) / (8 * ws_cfg_den); @@ -361,6 +402,23 @@ static int ws_nw_offset(void) { } int ws_nw_extra(void) { return 2 * ws_nw_offset(); } +int ws_nw_present_width(void) { + int wide_w = 0; + if (ws_local_viewport_layout(NULL, NULL, &wide_w, NULL)) + return wide_w; + return (int)ws_disp_w() + ws_nw_extra(); +} + +int gpu_ws_netplay_local_viewport_base_x(void) { + int base = 0; + return ws_local_viewport_layout(&base, NULL, NULL, NULL) ? base : 0; +} + +int gpu_ws_netplay_local_viewport_width(void) { + int wide_w = 0; + return ws_local_viewport_layout(NULL, NULL, &wide_w, NULL) ? wide_w : 0; +} + /* Per-side X cull margin in screen/world units (the game's draw classifier * works in objX-camX where 1 unit ~= 1 native-4:3 screen pixel). The squash * shows a half-view of 160/s pixels (s = squash factor = ws_xnum/ws_xden), so @@ -1413,14 +1471,15 @@ int psx_ws_cull_bltz_at(const uint32_t *words, int n, int idx) { * x is the int16 screenX the handler was about to store. */ int psx_ws_backdrop_x(int x) { if (ws_native_wide_active()) { + int32_t X = ws_disp_x(); int32_t W = (int32_t)ws_disp_w(); int32_t extra = ws_nw_extra(); int32_t cx = W / 2; - int32_t d = (int16_t)x - cx; - return (int)(cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W); + int32_t d = (int16_t)x - X - cx; + return (int)(X + cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W); } if (!ws_active()) return (int16_t)x; - int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ + int32_t cx = ws_disp_x() + ws_disp_w() / 2; /* screen centre (=160 @ 320) */ return ws_scale_about((int16_t)x, cx); } @@ -1828,8 +1887,20 @@ static int32_t ws_scale_len(int32_t w) { return s < 1 ? 1 : s; } -/* Horizontal display width in drawing space (e.g. 320). */ +/* Horizontal display viewport in drawing space (e.g. 320). For a selected + * netplay-local split viewport this is that player's authored half, not the + * whole composed display. */ +static int32_t ws_disp_x(void) { + int base = 0; + if (ws_local_viewport_layout(&base, NULL, NULL, NULL)) + return (int32_t)base; + return 0; +} + static int32_t ws_disp_w(void) { + int src_w = 0; + if (ws_local_viewport_layout(NULL, &src_w, NULL, NULL)) + return (int32_t)src_w; GpuDisplayInfo di; gpu_get_display_info(&di); return di.width ? (int32_t)di.width : 320; @@ -1846,8 +1917,8 @@ static int32_t ws_disp_h(void) { * display; ordinary world-space rectangles remain untouched. */ static void ws_expand_fullscreen_rect(int32_t *x, int32_t y, int *w, int h) { if (!ws_native_wide_active()) return; - int W = (int)ws_disp_w(), H = (int)ws_disp_h(); - if (*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) { + int X = (int)ws_disp_x(), W = (int)ws_disp_w(), H = (int)ws_disp_h(); + if (*x <= X && *x + *w >= X + W && y <= 0 && y + h >= H) { int off = ws_nw_offset(); *x -= off; *w += 2 * off; @@ -1862,11 +1933,12 @@ static void ws_expand_fullscreen_rect(int32_t *x, int32_t y, int *w, int h) { * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ static int32_t ws_hud_pivot(int32_t x, int32_t w) { + int32_t X = ws_disp_x(); int32_t W = ws_disp_w(); - int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ - if (3 * cx < 2 * W) return 0; - if (3 * cx > 4 * W) return W; - return W / 2; + int32_t cx = 2 * (x - X) + w; /* 2*centre, avoids losing the half */ + if (3 * cx < 2 * W) return X; + if (3 * cx > 4 * W) return X + W; + return X + W / 2; } /* Automatic UI correction is deliberately tied to draw provenance, not to @@ -1938,8 +2010,8 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { if (vy[i] > max_y) max_y = vy[i]; } int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || + int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); + if ((min_x <= X && max_x >= X + W && min_y <= 0 && max_y >= H) || (width > W / 2 && height > H / 4)) return 0; @@ -1953,8 +2025,8 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { if (!x || !w || *w <= 0 || psx_ws_prim_is_tagged()) return 0; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) || + int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); + if ((*x <= X && *x + *w >= X + W && y <= 0 && y + h >= H) || (*w > W / 2 && h > H / 4)) return 0; int32_t anchor; @@ -2031,8 +2103,9 @@ static int32_t ws_nw_hud_shift(int32_t x, int32_t w) { * for live A/B: some HUD composites (Tomba's AP counter) render through * the tagged sprite funnel and stay inset without it. */ if (ws_anchor_addr && !ws_nw_hud_tag_rects && psx_ws_prim_is_tagged()) return 0; + int32_t X = ws_disp_x(); int32_t W = ws_disp_w(); - int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ + int32_t cx = 2 * (x - X) + w; /* 2*centre, avoids losing the half */ if (3 * cx < 2 * W) return -off; /* left third -> pull to left edge */ if (3 * cx > 4 * W) return off; /* right third -> push to right edge */ return 0; /* middle third -> stay centred */ @@ -2077,6 +2150,7 @@ static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { if (!ws_nw_backdrop || !ws_native_wide_active()) return 0; int32_t extra = ws_nw_extra(); if (extra <= 0) return 0; + int32_t X = ws_disp_x(); int32_t W = ws_disp_w(); const int32_t EDGE = 24; /* slack for "touches the frame edge" */ int32_t minx = vx[0], maxx = vx[0], miny = vy[0], maxy = vy[0]; @@ -2087,7 +2161,7 @@ static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { if (vy[i] > maxy) maxy = vy[i]; } /* Must span the full display width and a real vertical extent. */ - if (minx > EDGE || maxx < W - EDGE || (maxy - miny) < 64) return 0; + if (minx > X + EDGE || maxx < X + W - EDGE || (maxy - miny) < 64) return 0; /* Axis-aligned: every vertex X sits at either the min or the max edge, and * every Y at the top or bottom edge (a true screen-space rectangle). */ for (int i = 0; i < 4; i++) { @@ -2097,7 +2171,7 @@ static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { } /* Stretch X about the display centre by (W+extra)/W so [0,W] -> [-off, W+off], * which the wide compositor (+off) maps onto the full [0, W+extra] surface. */ - int32_t cx = W / 2; + int32_t cx = X + W / 2; for (int i = 0; i < 4; i++) { int32_t d = vx[i] - cx; vx[i] = cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W; @@ -2170,6 +2244,45 @@ static uint32_t texture_disable; /* bit 15 */ static uint32_t draw_area_left, draw_area_top; static uint32_t draw_area_right, draw_area_bottom; +static int draw_area_intersects_rect(int x, int y, int w, int h) { + if (w <= 0 || h <= 0) + return 0; + int r = x + w - 1; + int b = y + h - 1; + return !((int)draw_area_right < x || + (int)draw_area_left > r || + (int)draw_area_bottom < y || + (int)draw_area_top > b); +} + +static int ws_local_viewport_draw_target(int *base_x) { + int base = 0, src_w = 0; + if (!ws_local_viewport_layout(&base, &src_w, NULL, NULL)) + return 0; + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (draw_area_intersects_rect(base, (int)di.display_y, + src_w, (int)di.height)) { + if (base_x) *base_x = base; + return 1; + } + return 0; +} + +static int ws_display_viewport_draw_target(int *base_x) { + if (!ws_native_wide_active()) + return 0; + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width == 0 || di.height == 0) + return 0; + if (!draw_area_intersects_rect((int)di.display_x, (int)di.display_y, + (int)di.width, (int)di.height)) + return 0; + if (base_x) *base_x = (int)di.display_x; + return 1; +} + typedef struct GpuVerticalSplitTrace { uint8_t left_seen; uint8_t right_seen; @@ -2293,10 +2406,14 @@ static void split_trace_note_draw_area(void) { } } -int gpu_last_frame_vertical_split_screen(void) { +static int ws_vertical_split_active(void) { return split_recent_left_age <= 8 && split_recent_right_age <= 8; } +int gpu_last_frame_vertical_split_screen(void) { + return ws_vertical_split_active(); +} + void gpu_vertical_split_debug(int *active, int *left_age, int *right_age) { if (active) *active = gpu_last_frame_vertical_split_screen(); if (left_age) *left_age = split_recent_left_age; @@ -2308,7 +2425,18 @@ void gpu_vertical_split_debug(int *active, int *left_age, int *right_age) { * disable mirroring for this draw. Called when the draw env changes. */ static void ws_nw_sync_target(void) { if (!ws_native_wide_active()) { gr_wide_disable_target(); return; } - gr_wide_configure((int)ws_disp_w() + ws_nw_extra(), ws_nw_offset()); + gr_wide_configure(ws_nw_present_width(), ws_nw_offset()); + int local_base = 0; + if (ws_local_viewport_draw_target(&local_base)) { + gr_wide_set_target(local_base); + return; + } + if (ws_local_viewport_cfg) { gr_wide_disable_target(); return; } + int display_base = 0; + if (ws_display_viewport_draw_target(&display_base)) { + gr_wide_set_target(display_base); + return; + } uint32_t base = draw_area_left; if (ws_is_fb_base(base)) gr_wide_set_target((int)base); else gr_wide_disable_target(); @@ -3175,7 +3303,11 @@ static void raster_pixel(int32_t x, int32_t y, uint16_t color) { * https://github.com/mstan/psxrecomp/pull/73 * Keep that credit with this guarded framebuffer-target variant. */ static inline int32_t draw_area_wide_x_margin(void) { - if (!ws_native_wide_active() || !ws_is_fb_base(draw_area_left)) return 0; + int local_base = 0; + if (!ws_native_wide_active()) return 0; + if (ws_local_viewport_draw_target(&local_base)) return (int32_t)ws_nw_offset(); + if (ws_display_viewport_draw_target(NULL)) return (int32_t)ws_nw_offset(); + if (!ws_is_fb_base(draw_area_left)) return 0; return (int32_t)ws_nw_offset(); } @@ -3971,7 +4103,11 @@ static void gp0_exec_fill_rect(void) { /* Native-wide: when the game clears a display buffer, clear the full width * of that buffer's wide surface over the same rows — refreshing the centred * content region and keeping the revealed margins clean. */ - if (ws_native_wide_active() && ws_is_fb_base(dst_x)) { + int local_base = 0; + if (ws_native_wide_active() && + (ws_is_fb_base(dst_x) || + (ws_local_viewport_draw_target(&local_base) && + dst_x == (uint32_t)local_base))) { gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); } } @@ -4362,15 +4498,15 @@ static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, } int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || + int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); + if ((min_x <= X && max_x >= X + W && min_y <= 0 && max_y >= H) || (width > W / 2 && height > H / 4)) return; WsUiPrepassItem *item = &ws_ui_prepass[ws_ui_prepass_count++]; item->group.key = ws_auto_ui_group_key_words(words, op, min_y, height); - item->group.x = min_x; + item->group.x = min_x - X; item->group.width = width; item->group.anchor = 0; item->src_addr = source_addr & 0x1FFFFCu; @@ -4463,13 +4599,14 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_auto_ui_dense = ws_ui_prepass_count >= 32u && !ws_gte_game_mode_cfg; if (ws_ui_prepass_count == 0) return; + const int32_t group_origin = ws_disp_x(); WsUiGroupItem groups[WS_UI_PREPASS_MAX]; for (uint32_t i = 0; i < ws_ui_prepass_count; i++) groups[i] = ws_ui_prepass[i].group; ws_ui_group_assign(groups, ws_ui_prepass_count, ws_disp_w(), ws_auto_ui_dense); for (uint32_t i = 0; i < ws_ui_prepass_count; i++) - ws_ui_prepass[i].group.anchor = groups[i].anchor; + ws_ui_prepass[i].group.anchor = group_origin + groups[i].anchor; } /* Per-opcode execution counters (exposed via gpu_get_opcode_stats) */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0c0a3e2a..6230e189 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1425,6 +1425,9 @@ static bool g_ws_hud_sprt = false; * units include that ABI header and do not need this frontend-only setter. */ extern "C" void gpu_ws_set_clear_reveal(int on); extern "C" void gpu_ws_set_precise_nclip(int on); +extern "C" void gpu_ws_set_netplay_local_viewport(int enabled, int slot); +extern "C" int gpu_ws_netplay_local_viewport_base_x(void); +extern "C" int gpu_ws_netplay_local_viewport_width(void); extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct); extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int); /* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot @@ -1512,7 +1515,13 @@ static void refresh_widescreen_projection() { if (!g_ws_engaged) return; const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; - const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; + const bool local_native_wide = + g_netplay_local_viewport == 1 && psx_netplay_active() && + gpu_last_frame_vertical_split_screen(); + const bool native_wide = (g_netplay_local_viewport == 1) + ? local_native_wide + : (g_ws_native_wide != 0); + const int mode = wide ? (native_wide ? 2 : 1) : 0; int proj_num = g_video_aspect_num; int proj_den = g_video_aspect_den; if (mode == 1) { @@ -2479,9 +2488,11 @@ static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { if (!netplay_enabled || g_netplay_local_viewport != 1 || g_netplay_local_viewport_aspect == 0) { + gpu_ws_set_netplay_local_viewport(0, 0); return; } + gpu_ws_set_netplay_local_viewport(1, psx_netplay_local_slot()); switch (g_netplay_local_viewport_aspect) { case 1: (void)psx_mod_set_fixed_display_aspect(16u, 9u); @@ -6426,6 +6437,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { bool pin_43 = false; /* pillarbox this present (FMV, or a native-wide game frame that could not present wide) */ bool depth24_frame = false; + bool local_viewport_crop_applied = false; if (s_force_present_after_load && g_gl_active) gl_renderer_flush_cpu_uploads(); { @@ -6480,8 +6492,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_gl_active) gl_renderer_set_interpolation_suspended( fmv_frame || mdec_recently_active(2)); - const bool local_viewport_crop = netplay_local_viewport_slot() >= 0; - bool local_viewport_crop_applied = false; + const int local_viewport_slot = netplay_local_viewport_slot(); + const bool local_viewport_crop = local_viewport_slot >= 0; + bool local_viewport_wide = + local_viewport_crop && g_ws_engaged && ws_native_wide_active() && + gr_wide_supported() && gpu_ws_netplay_local_viewport_width() > 0; /* Canonical present width. Native-wide does NOT widen the canonical read * (that bled across adjacent framebuffers); it composites into a separate @@ -6493,8 +6508,12 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && ws_native_wide_active() && gr_wide_supported() && - !local_viewport_crop); - if (wide_present) present_w = w + (uint32_t)ws_nw_extra(); + (!local_viewport_crop || local_viewport_wide)); + if (wide_present) { + present_w = local_viewport_wide + ? (uint32_t)gpu_ws_netplay_local_viewport_width() + : (w + (uint32_t)ws_nw_extra()); + } /* Native-wide invariant: canonical (320-wide) content is NEVER * stretched across the wide window — a game frame that cannot present @@ -6597,12 +6616,16 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * hires path if the displayed buffer has no surface yet. */ int s = gr_scale(); int sw = (int)present_w * s; + int base_x = local_viewport_wide + ? gpu_ws_netplay_local_viewport_base_x() + : (int)di.display_x; int n = gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - (int)di.display_x, (int)di.display_y, (int)h); + base_x, (int)di.display_y, (int)h); if (n > 0) { active_scale = s; } else { wide_present = false; + local_viewport_wide = false; present_w = w; pres_entry->path = PRES_PATH_CANONICAL; pres_entry->wide_fellback = 1; @@ -6645,7 +6668,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int present_px_w = (int)present_w * active_scale; int present_px_h = (int)h * active_scale; - if (crop_present_to_netplay_local_viewport(sdl_pixel_buf, + if (!local_viewport_wide && + crop_present_to_netplay_local_viewport(sdl_pixel_buf, &present_px_w, present_px_h)) { pin_43 = false; @@ -6709,7 +6733,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { #ifndef PSX_SDL_NO_RENDER int src_w = (int)present_w * active_scale; int src_h = (int)h * active_scale; - if (netplay_local_viewport_slot() >= 0 && src_w >= 2) + if (local_viewport_crop_applied && src_w >= 2) src_w /= 2; if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen @@ -12831,6 +12855,7 @@ session_reboot: nrc, net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport); return 1; } + apply_netplay_local_viewport_aspect(net_cfg.enabled); std::printf("psxrecomp: netplay transport=%s slot=%d input_player=%d delay=%d " "force_turn=%d bind=%s peer=%s session=%u\n", psx_netplay_transport_name(),