From 02e1246a3b4a2d21f26ecee5c8d4971f4418a568 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 15:40:36 -0700 Subject: [PATCH 01/17] runtime: keep the host relay out of offline builds 1982e7dc included recomp_net/host_relay.h unguarded, so PSX_NETPLAY=OFF builds fail to compile (optional_netplay_auth caught it). Co-Authored-By: Claude Opus 5.5 (1M context) --- recompiler/tests/test_optional_netplay_auth.py | 5 ++++- runtime/src/main.cpp | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/recompiler/tests/test_optional_netplay_auth.py b/recompiler/tests/test_optional_netplay_auth.py index b1073c88..16ff877f 100644 --- a/recompiler/tests/test_optional_netplay_auth.py +++ b/recompiler/tests/test_optional_netplay_auth.py @@ -30,6 +30,7 @@ def main(): (root / "recomp_net").mkdir() (root / "recomp_net/auth.h").write_text("RNET_AUTH_HEADER_INCLUDED\n") (root / "recomp_net/chat_filter.h").write_text("RNET_CHAT_HEADER_INCLUDED\n") + (root / "recomp_net/host_relay.h").write_text("RNET_HOST_RELAY_HEADER_INCLUDED\n") flags = ["RECOMP_LAUNCHER=1", "RECOMP_LAUNCHER_HAS_ACCOUNT=1", "SDL_VERSION_ATLEAST(x,y,z)=1", "DEFAULT_DEBUG_PORT=4370"] if enabled: @@ -45,11 +46,13 @@ def main(): assert p.returncode == 0, p.stderr if enabled: for token in ("RNET_AUTH_HEADER_INCLUDED", "rnet_account_pump()", - "ae_np_account_login_begin", "account_available = ae_np_account_available"): + "ae_np_account_login_begin", "account_available = ae_np_account_available", + "RNET_HOST_RELAY_HEADER_INCLUDED", "RNetHostRelayStatus st"): assert token in p.stdout, f"enabled path lost {token}" else: assert "rnet_account_" not in p.stdout, "offline launcher retained account linkage" assert "RNET_ACCOUNT_" not in p.stdout, "offline launcher retained account state dependency" + assert "RNetHostRelayStatus st" not in p.stdout, "offline launcher retained the recomp-net host relay" # Compile the REAL offline lobby TU too: its early chat-report include # used to leak an independent optional dependency before the #else. lobby = args.source.parent / "psx_lobby_client.c" diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4078fdcd..205b90b8 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -62,12 +62,12 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "psx_netplay_rb.h" #include "psx_selfcheck.h" #include "psx_lobby_client.h" -#include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */ #include "netplay_bios_settle.h" #include "netplay_exit_reason.h" #include "host_time.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/auth.h" +#include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */ #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif #include "spu.h" @@ -10941,6 +10941,7 @@ namespace { int ae_np_relay_status(void*, char* out, size_t out_cap) { if (!out || !out_cap) return 0; out[0] = '\0'; +#if defined(PSX_HAS_RECOMP_NET) if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_in_lobby()) return 0; RNetHostRelayStatus st; if (!psx_lobby_host_relay_status(&st)) return 0; @@ -10984,6 +10985,9 @@ namespace { return 1; } return 0; +#else + return 0; /* offline build: no lobby host relay */ +#endif } int ae_np_force_turn_set(void*, int force) { if (g_lnch_hosting_lan || g_lnch_joined_lan) From 256ab7d36de99e0004aaf24872b819febbf1b14a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 19:40:21 -0700 Subject: [PATCH 02/17] tools: decode counted MIPS relocation containers --- docs/COUNTED_MIPS_RELOCATIONS.md | 52 +++++++++++ tools/counted_mips_relocations.py | 93 ++++++++++++++++++++ tools/tests/test_counted_mips_relocations.py | 70 +++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 docs/COUNTED_MIPS_RELOCATIONS.md create mode 100644 tools/counted_mips_relocations.py create mode 100644 tools/tests/test_counted_mips_relocations.py diff --git a/docs/COUNTED_MIPS_RELOCATIONS.md b/docs/COUNTED_MIPS_RELOCATIONS.md new file mode 100644 index 00000000..f0728634 --- /dev/null +++ b/docs/COUNTED_MIPS_RELOCATIONS.md @@ -0,0 +1,52 @@ +# Counted MIPS relocation containers + +`tools/counted_mips_relocations.py` reads image containers with this layout: + +| Area | Little-endian words | +| --- | --- | +| Header | Member count | +| Records | Image offset, image size, relocation-stream offset for each member | +| Images | Concatenated image bytes | +| Streams | Two-bit tagged relocation words, each stream ending in `0xFFFFFFFF` | + +Image offsets are relative to the image area. Stream offsets are relative to +the area following the **sum** of the image sizes. Both areas must partition +exactly; overlapping members, invalid targets, duplicate writes, missing +terminators and unaccounted bytes are rejected. + +The tag's low two bits select the operation; the remaining bits are the aligned +image offset. Offset zero is valid. Kind 1 consumes an additional addend word. + +| Kind | Operation | +| --- | --- | +| 0 | Add the image's RAM base to an absolute 32-bit word | +| 1 | Preserve the instruction's upper half; insert `(base + addend + 0x8000) >> 16`, with 32-bit wrapping | +| 2 | Preserve the instruction's upper half; add the base to its low half | +| 3 | Preserve the opcode; relocate the masked 26-bit jump target | + +```python +from counted_mips_relocations import parse, relocate + +members = parse(container_bytes) +image = relocate(members[0], independently_verified_image_base) +``` + +This is a separate contract from `mips_tagged_relocations.py`: that format's +leading image-size word and full-instruction jump addition do not describe +these containers. Neither reader decompresses data. + +The container has **no destination addresses**. `relocate` takes an aligned +KSEG0 main-RAM image base; this is not the allocation base of a container whose +headers remain in memory. Callers must establish placement and executable/data +boundaries from the original loader before constructing native AOT producers. +The decoder alone does not provide dynamically placed native module dispatch. + +The implementation was compared byte for byte with the owned USA MediEvil II +relocation routine (`0x80078328`) for 24 modules at two distinct RAM bases: +48 complete-image comparisons passed. Owned bytes and diagnostic captures are +not part of this repository. Synthetic contract tests cover multiple members, +all four relocation kinds, opcode preservation and malformed inputs: + +```sh +python -m unittest discover -s tools/tests -p test_counted_mips_relocations.py +``` diff --git a/tools/counted_mips_relocations.py b/tools/counted_mips_relocations.py new file mode 100644 index 00000000..129dce34 --- /dev/null +++ b/tools/counted_mips_relocations.py @@ -0,0 +1,93 @@ +"""Read counted image containers with separate two-bit MIPS relocation streams. + +A count precedes 12-byte records (image offset, image size, stream offset). +Images follow the records; streams follow the sum of image sizes. Offsets are +relative to their respective areas. The format has no destination addresses. +Callers must establish placement from the original loader before using AOT. +""" +from dataclasses import dataclass +import struct + +from mips_tagged_relocations import Relocation + + +@dataclass(frozen=True) +class Member: + image_offset: int + image: bytes + relocations: tuple[Relocation, ...] + stream_offset: int + stream_end: int + + +def parse(data: bytes) -> tuple[Member, ...]: + if len(data) < 20 or len(data) % 4: + raise ValueError('Container must contain aligned records, images and streams') + count, = struct.unpack_from(' len(data): + raise ValueError('Invalid image count') + records = [struct.unpack_from(' len(data) - 4: + raise ValueError('Images exceed container bounds') + images, streams, result = [], [], [] + for offset, size, stream in records: + if offset % 4 or size < 4 or size % 4 or offset + size > image_bytes: + raise ValueError('Invalid image interval') + images.append((offset, offset + size)) + cursor = stream_start + stream + if stream % 4 or cursor > len(data) - 4: + raise ValueError('Invalid relocation stream offset') + start, written, relocations = cursor, set(), [] + while True: + if cursor > len(data) - 4: + raise ValueError('Missing relocation terminator') + tag, = struct.unpack_from(' size or target in written: + raise ValueError('Invalid or repeated relocation target') + written.add(target) + if kind == 1: + if cursor > len(data) - 4: + raise ValueError('Truncated HI16 addend') + addend, = struct.unpack_from(' bytes: + if load_addr % 4 or not 0x80000000 <= load_addr < load_addr + len(member.image) <= 0x80800000: + raise ValueError('Relocated image must fit aligned main RAM') + image = bytearray(member.image) + for item in member.relocations: + word, = struct.unpack_from('> 16 + value = (word & 0xffff0000) | high + elif item.kind == 2: + value = (word & 0xffff0000) | ((word + load_addr) & 0xffff) + else: + target = (((word & 0x03ffffff) << 2) + load_addr) & 0xffffffff + value = (word & 0xfc000000) | ((target >> 2) & 0x03ffffff) + struct.pack_into(' Date: Fri, 2 Oct 2026 20:37:29 -0700 Subject: [PATCH 03/17] Support explicitly owned adaptive screen masks and centred HUD packets --- runtime/CMakeLists.txt | 3 + runtime/include/gpu.h | 8 ++- runtime/include/mod_plugins.h | 11 ++++ runtime/include/ws_radial_screen_mask.h | 55 ++++++++++++++++++ runtime/include/ws_screen_mask.h | 27 +++++++++ runtime/src/gpu.c | 77 +++++++++++++++++++------ runtime/src/mod_runtime.cpp | 9 +++ runtime/tests/test_ws_screen_mask.c | 61 ++++++++++++++++++++ 8 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 runtime/include/ws_radial_screen_mask.h diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 56a208d9..b0b860c2 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -469,6 +469,9 @@ if(BUILD_TESTING) add_test(NAME ws_repeat_rect_test COMMAND ws_repeat_rect_test) add_executable(ws_screen_mask_test tests/test_ws_screen_mask.c) target_include_directories(ws_screen_mask_test PRIVATE include) + if(UNIX) + target_link_libraries(ws_screen_mask_test PRIVATE m) + endif() add_test(NAME ws_screen_mask_test COMMAND ws_screen_mask_test) add_executable(mod_gpu_dma_aperture_test diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 75c30d66..b4191114 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -429,10 +429,14 @@ void gpu_ws_tag_black_reveal_rect(uint32_t prim); * reveal margins only. The caller verifies the composite's source period. * Original UVs, texel density, and canonical VRAM writes are unchanged. */ void gpu_ws_tag_repeat_rect(uint32_t prim, int32_t period); -/* Title-identified flat screen mask: extend its off-screen vertical boundary - * to the adaptive edge, preserving the interior opening and canonical image. +/* Title-identified flat screen mask: extend vertical sides or horizontal + * letterbox panels to adaptive edges, preserving the interior opening. * Packet guarded, cleared on reset/load, inert outside native-wide gameplay. */ void gpu_ws_tag_screen_mask_quad(uint32_t prim); +/* Title-owned flat/Gouraud radial screen mask. Render-only scale about the + * display centre; guest coordinates and timing stay unchanged. Scale >= 1, + * inert at 4:3, packet guarded and reset alongside other transient tags. */ +void gpu_ws_tag_radial_screen_mask_quad(uint32_t prim, float scale); /* Pure emulation-thread predicate for a known native-only effect/scene. * Nonzero forces native 4:3; zero defers. No GPU calls or guest writes inside. * Host configuration survives reset/load; classification reads live state. */ diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 2bb49d20..b83950eb 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -114,6 +114,17 @@ int32_t psx_mod_widescreen_x_margin(void); * compositor translates it by the live reveal, excluding culling guards. * Guest coordinates, world sprites, and native 4:3 remain unchanged. */ void psx_mod_tag_hud_primitive(uint32_t primitive, int edge); +/* Packet-guarded screen-space anchor: -1 left, +1 right, 0 stays centred. + * Unlike the legacy role tag above, zero explicitly protects centred text + * from automatic layout transforms. Word immediately before the GP0 colour + * command; for a compound E1+SPRT packet this is its E1 word (P_TAG+4). */ +void psx_mod_anchor_hud_primitive(uint32_t primitive, int edge); +/* Screen-space masks identified by their title's producer, P_TAG addresses. + * Flat panels extend only their exterior boundaries. Radial flat/Gouraud + * quads scale around the display centre for an aspect-aware iris transition. + * Both services are render-only, word guarded and inert at native 4:3. */ +void psx_mod_tag_screen_mask_quad(uint32_t primitive); +void psx_mod_tag_radial_screen_mask_quad(uint32_t primitive, float scale); /* Exclude a known world packet from screen-space backdrop stretching, even * if it sorts before the first shaded polygon. Zero clears a recycled tag. */ void psx_mod_tag_world_primitive(uint32_t primitive, int is_world); diff --git a/runtime/include/ws_radial_screen_mask.h b/runtime/include/ws_radial_screen_mask.h new file mode 100644 index 00000000..f2b103ea --- /dev/null +++ b/runtime/include/ws_radial_screen_mask.h @@ -0,0 +1,55 @@ +#ifndef PSXRECOMP_WS_RADIAL_SCREEN_MASK_H +#define PSXRECOMP_WS_RADIAL_SCREEN_MASK_H +#include "ws_hud_anchor.h" +#include + +/* Explicit title-owned screen-space rings, never a world-geometry heuristic. + * Independent packet guards let adjacent solid and feathered ring segments + * share one scale without allowing an arena's next use to inherit the effect. */ +#define WS_RADIAL_MASK_TAG_COUNT 256u +typedef struct { + WsHudAnchorTag packet; + float scale; +} WsRadialScreenMaskTag; + +static inline void ws_radial_mask_clear(WsRadialScreenMaskTag *tags) { + for (unsigned i=0; i 64.f) return; + unsigned slot=(key >> 2) & (WS_RADIAL_MASK_TAG_COUNT-1u), victim=slot; + for (unsigned i=0; iused || p->command_addr==key) { victim=j; break; } + if (frame-p->frame > WS_HUD_ANCHOR_FRESH_FRAMES) victim=j; + } + tags[victim].packet=(WsHudAnchorTag){.command_addr=key, .frame=frame, + .used=1, .guard=*guard}; + tags[victim].scale=scale; +} +static inline int ws_radial_mask_lookup(const WsRadialScreenMaskTag *tags, + uint32_t key, const uint32_t *words, uint32_t count, uint32_t frame, + float *scale) { + unsigned slot=(key >> 2)&(WS_RADIAL_MASK_TAG_COUNT-1u); + for (unsigned i=0; ipacket.used || t->packet.command_addr != key) continue; + if (framepacket.frame || frame-t->packet.frame>WS_HUD_ANCHOR_FRESH_FRAMES || + !ws_prepass_packet_matches(&t->packet.guard,words,count)) return 0; + *scale=t->scale; + return 1; + } + return 0; +} +static inline void ws_radial_mask_transform(int32_t x[4], int32_t y[4], + int width, int height, float scale) { + if (width<=0 || height<=0 || !isfinite(scale) || scale<1.f || scale>64.f) return; + double cx=width*.5, cy=height*.5; + for (unsigned i=0; i<4; ++i) { + x[i]=(int32_t)llround(cx+(x[i]-cx)*scale); + y[i]=(int32_t)llround(cy+(y[i]-cy)*scale); + } +} +#endif diff --git a/runtime/include/ws_screen_mask.h b/runtime/include/ws_screen_mask.h index 9e228bf8..8d11a152 100644 --- a/runtime/include/ws_screen_mask.h +++ b/runtime/include/ws_screen_mask.h @@ -24,4 +24,31 @@ static inline int ws_screen_mask_band(const int32_t x[4], const int32_t y[4], } return 0; } + +/* A tagged horizontal letterbox panel covers the native width but only the + * top or bottom of the picture. Extend both outer edges, keeping its animated + * height and leaving the opening alone. Full-screen filters use the renderer's + * existing single-rectangle path instead, avoiding a second blend in the sides. + * At most two disjoint bands are returned, all outside canonical VRAM. */ +static inline int ws_screen_mask_bands(const int32_t x[4], const int32_t y[4], + int width, int height, int margin, + WsScreenMaskBand out[2]) { + if (!out || width <= 0 || height <= 0 || margin <= 0) return 0; + if (x[0] == x[2] && x[1] == x[3] && + y[0] == y[1] && y[2] == y[3] && + x[0] <= 0 && x[1] >= width && y[2] > y[0]) { + if (y[0] <= 0 && y[2] >= height) return 0; + if (y[0] <= 0 || y[2] >= height) { + int count = 0; + if (x[0] > -margin) + out[count++] = (WsScreenMaskBand){-margin, y[0], + margin + x[0], y[2] - y[0]}; + if (x[1] < width + margin) + out[count++] = (WsScreenMaskBand){x[1], y[0], + width + margin - x[1], y[2] - y[0]}; + return count; + } + } + return ws_screen_mask_band(x, y, width, height, margin, out); +} #endif diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 6f0236ae..c2e15fe3 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -44,6 +44,7 @@ #include "ws_hud_anchor.h" #include "ws_repeat_rect.h" #include "ws_screen_mask.h" +#include "ws_radial_screen_mask.h" #include #include #include @@ -192,6 +193,7 @@ static WsTag ws_tags[WS_TAG_BUCKETS]; static WsHudAnchorTag ws_hud_anchor_tags[WS_HUD_ANCHOR_TABLE_SIZE]; static WsHudAnchorTag ws_reveal_clear_tags[WS_HUD_ANCHOR_TABLE_SIZE]; static WsHudAnchorTag ws_screen_mask_tags[WS_HUD_ANCHOR_TABLE_SIZE]; +static WsRadialScreenMaskTag ws_radial_screen_mask_tags[WS_RADIAL_MASK_TAG_COUNT]; static WsRepeatRectTag ws_repeat_rect_tags[WS_REPEAT_RECT_TAG_TABLE_SIZE]; static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */ static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */ @@ -208,6 +210,7 @@ static int ws_engaged(void) { return ws_mode != 0; } 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 int32_t ws_disp_h(void); static void ws_clear_all_reveal_margins(void); /* Gameplay vs full-2D screen. Character/billboard prims tag (psx_ws_sprite_tag) @@ -2251,10 +2254,34 @@ void gpu_ws_tag_screen_mask_quad(uint32_t prim) { ((words[0] >> 24) & 0xfdu) != 0x28u) return; WsPrepassPacketGuard guard = ws_prepass_packet_guard(words, count); ws_hud_anchor_insert(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE, - (prim + 4u) & 0x1ffffcu, 0, &guard, + GPU_RAM_KEY(prim + 4u), 0, &guard, (uint32_t)s_frame_count); } +void gpu_ws_tag_radial_screen_mask_quad(uint32_t prim, float scale) { + if (!ws_native_wide_configured() || (prim & 3u) || prim > UINT32_MAX - 4u) + return; + uint32_t words[12], count=0; + if (!ws_hud_command_words(prim+4u, words, &count)) return; + unsigned op=words[0]>>24; + if (!((count==5u && (op&0xfdu)==0x28u) || + (count==8u && (op&0xfdu)==0x38u))) return; + WsPrepassPacketGuard guard=ws_prepass_packet_guard(words,count); + ws_radial_mask_insert(ws_radial_screen_mask_tags,GPU_RAM_KEY(prim+4u), + &guard,(uint32_t)s_frame_count,scale); +} + +static int ws_nw_radial_mask_transform(int32_t x[4], int32_t y[4]) { + if (!ws_native_wide_active() || psx_ws_x_margin()<=0 || + gp0_cmd_source_addr==UINT32_MAX) return 0; + float scale; + if (!ws_radial_mask_lookup(ws_radial_screen_mask_tags, + GPU_RAM_KEY(gp0_cmd_source_addr),gp0_cmd_buf,(uint32_t)gp0_words_needed, + (uint32_t)s_frame_count,&scale)) return 0; + ws_radial_mask_transform(x,y,ws_disp_w(),ws_disp_h(),scale); + return 1; +} + static int ws_nw_explicit_hud_delta(int32_t *out_delta) { if (out_delta) *out_delta = 0; if (!ws_native_wide_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) @@ -2597,7 +2624,7 @@ static int ws_axis_aligned_quad(const int32_t vx[4], const int32_t vy[4]) { } static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { - if (psx_ws_prim_is_tagged() || !ws_axis_aligned_quad(vx, vy)) + if (ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged() || !ws_axis_aligned_quad(vx, vy)) return 0; int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; @@ -2622,7 +2649,7 @@ 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()) + if (!x || !w || *w <= 0 || ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) return 0; 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) || @@ -2781,7 +2808,7 @@ static int ws_nw_flat_backdrop = 0; void gpu_ws_set_nw_flat_backdrop(int on) { ws_nw_flat_backdrop = on ? 1 : 0; } int gpu_ws_nw_flat_backdrop_enabled(void) { return ws_nw_flat_backdrop; } static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { - if (!ws_nw_backdrop || !ws_native_wide_active()) return 0; + if (!ws_nw_backdrop || !ws_native_wide_active() || ws_nw_explicit_hud_delta(NULL)) return 0; int32_t extra = ws_nw_extra(); if (extra <= 0) return 0; int32_t X = ws_disp_x(); @@ -3229,6 +3256,7 @@ static void gpu_reset_state(int clear_vram) { ws_hud_anchor_clear(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE); ws_hud_anchor_clear(ws_reveal_clear_tags, WS_HUD_ANCHOR_TABLE_SIZE); ws_hud_anchor_clear(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE); + ws_radial_mask_clear(ws_radial_screen_mask_tags); ws_repeat_rect_tag_clear(ws_repeat_rect_tags); polyline_color = 0; polyline_prev_x = polyline_prev_y = 0; @@ -4235,20 +4263,25 @@ static void gp0_exec_mono_quad(void) { int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); if (rej_a && rej_b) return; - ws_auto_ui_transform_quad(vx, vy); + int radial_mask=ws_nw_radial_mask_transform(vx,vy); + int screen_mask=ws_native_wide_active() && gp0_cmd_source_addr != UINT32_MAX && + ws_hud_anchor_lookup(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE, + GPU_RAM_KEY(gp0_cmd_source_addr), gp0_cmd_buf, 5u, + (uint32_t)s_frame_count, NULL); + if (!radial_mask && !screen_mask) ws_auto_ui_transform_quad(vx, vy); /* Each added band is outside the original screen and executes in the * mask's own OT position/blend mode. Canonical VRAM clips it away. */ - if (ws_native_wide_active() && gp0_cmd_source_addr != UINT32_MAX && - ws_hud_anchor_lookup(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE, - gp0_cmd_source_addr & 0x1ffffcu, gp0_cmd_buf, 5u, - (uint32_t)s_frame_count, NULL)) { - WsScreenMaskBand band; - if (ws_screen_mask_band(vx, vy, ws_disp_w(), ws_disp_h(), - draw_area_wide_x_margin(), &band)) { + if (screen_mask) { + WsScreenMaskBand bands[2]; + int count = ws_screen_mask_bands(vx, vy, ws_disp_w(), ws_disp_h(), + draw_area_wide_x_margin(), bands); + if (count) { gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(band.x + draw_offset_x, band.y + draw_offset_y, - band.w, band.h, color); + for (int i = 0; i < count; i++) + gr_draw_flat_rect(bands[i].x + draw_offset_x, + bands[i].y + draw_offset_y, + bands[i].w, bands[i].h, color); } } @@ -4273,8 +4306,10 @@ static void gp0_exec_mono_quad(void) { gr_draw_flat_rect(x, y, w, h, color); return; } - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); + if (!radial_mask && !screen_mask) { + ws_nw_backdrop_stretch_quad(vx, vy); + ws_nw_hud_shift_vertices(vx, 4); + } for (int i = 0; i < 4; i++) { vx[i] += draw_offset_x; vy[i] += draw_offset_y; @@ -4358,9 +4393,12 @@ static void gp0_exec_shaded_quad(void) { int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); if (rej_a && rej_b) return; - ws_auto_ui_transform_quad(vx, vy); - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (sky gradient; no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); + int radial_mask=ws_nw_radial_mask_transform(vx,vy); + if (!radial_mask) { + ws_auto_ui_transform_quad(vx, vy); + ws_nw_backdrop_stretch_quad(vx, vy); + ws_nw_hud_shift_vertices(vx, 4); + } for (int i = 0; i < 4; i++) { vx[i] += draw_offset_x; vy[i] += draw_offset_y; @@ -6788,6 +6826,7 @@ int gpu_snapshot_read(const uint8_t *p, uint32_t len) { ws_hud_anchor_clear(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE); ws_hud_anchor_clear(ws_reveal_clear_tags, WS_HUD_ANCHOR_TABLE_SIZE); ws_hud_anchor_clear(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE); + ws_radial_mask_clear(ws_radial_screen_mask_tags); ws_repeat_rect_tag_clear(ws_repeat_rect_tags); /* Sync renderer clip/scissor to restored GP0(E3/E4); vars alone leave GL * on a stale draw area after savestate load. */ diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 2c6e4a6a..91478239 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1530,6 +1530,15 @@ extern "C" int32_t psx_mod_widescreen_x_margin(void) { extern "C" void psx_mod_tag_hud_primitive(uint32_t primitive, int edge) { gpu_ws_tag_hud_primitive(primitive, edge); } +extern "C" void psx_mod_anchor_hud_primitive(uint32_t primitive, int edge) { + gpu_ws_tag_hud_prim(primitive, edge); +} +extern "C" void psx_mod_tag_screen_mask_quad(uint32_t primitive) { + gpu_ws_tag_screen_mask_quad(primitive); +} +extern "C" void psx_mod_tag_radial_screen_mask_quad(uint32_t primitive, float scale) { + gpu_ws_tag_radial_screen_mask_quad(primitive, scale); +} extern "C" void psx_mod_tag_world_primitive(uint32_t primitive, int is_world) { gpu_ws_tag_world_primitive(primitive, is_world); diff --git a/runtime/tests/test_ws_screen_mask.c b/runtime/tests/test_ws_screen_mask.c index b8571340..f8379bb3 100644 --- a/runtime/tests/test_ws_screen_mask.c +++ b/runtime/tests/test_ws_screen_mask.c @@ -4,6 +4,7 @@ #include #include "ws_screen_mask.h" #include "ws_hud_anchor.h" +#include "ws_radial_screen_mask.h" int main(void) { int32_t x[4]={-96,160,-96,68},y[4]={0,0,240,240}; WsScreenMaskBand b; @@ -17,13 +18,73 @@ int main(void) { assert(b.x==416 && b.w==598); x[3]=415; assert(!ws_screen_mask_band(x,y,320,240,694,&b)); x[3]=416;y[3]=239;assert(!ws_screen_mask_band(x,y,320,240,694,&b)); + WsScreenMaskBand bands[2]; + x[0]=x[2]=0; x[1]=x[3]=512; + y[0]=y[1]=0; y[2]=y[3]=25; + for (int margin=0; margin<16000; margin+=53) { + int count=ws_screen_mask_bands(x,y,512,240,margin,bands); + assert(count==(margin ? 2 : 0)); + if (count) { + assert(bands[0].x==-margin && bands[0].w==margin); + assert(bands[1].x==512 && bands[1].w==margin); + assert(bands[0].y==0 && bands[0].h==25); + assert(bands[1].y==0 && bands[1].h==25); + } + } + y[0]=y[1]=215; y[2]=y[3]=240; + assert(ws_screen_mask_bands(x,y,512,240,170,bands)==2); + assert(bands[0].y==215 && bands[0].h==25); + /* Already extended edges need no second draw; one-sided overlap is safe. */ + x[0]=x[2]=-200; x[1]=x[3]=600; + assert(ws_screen_mask_bands(x,y,512,240,170,bands)==1); + assert(bands[0].x==600 && bands[0].w==82); + x[1]=x[3]=700; + assert(!ws_screen_mask_bands(x,y,512,240,170,bands)); + /* A world quad, middle strip, empty panel or full-screen blend stays alone. */ + x[0]=x[2]=0; x[1]=x[3]=512; + y[0]=y[1]=20; y[2]=y[3]=40; + assert(!ws_screen_mask_bands(x,y,512,240,170,bands)); + y[0]=y[1]=0; y[2]=y[3]=240; + assert(!ws_screen_mask_bands(x,y,512,240,170,bands)); + y[2]=y[3]=0; + assert(!ws_screen_mask_bands(x,y,512,240,170,bands)); + y[2]=y[3]=25; x[3]=511; + assert(!ws_screen_mask_bands(x,y,512,240,170,bands)); /* Same packet address with different geometry/colour must lose its tag. */ WsHudAnchorTag tags[WS_HUD_ANCHOR_TABLE_SIZE]={0}; uint32_t words[5]={0x2a7b7b7b,0x0000ffa0,0xa0,0xf0ffa0,0xf00044}; WsPrepassPacketGuard guard=ws_prepass_packet_guard(words,5); ws_hud_anchor_insert(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,0,&guard,20); assert(ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0)); + /* Expanded render memory must not alias the same packet offset at 2 MiB. */ + assert(!ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0x2a68b4,words,5,20,0)); + ws_hud_anchor_insert(tags,WS_HUD_ANCHOR_TABLE_SIZE,0x2a68b4,0,&guard,20); + assert(ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0x2a68b4,words,5,20,0)); + assert(ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0)); words[2]++;assert(!ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0)); words[2]--;ws_hud_anchor_clear(tags,WS_HUD_ANCHOR_TABLE_SIZE); assert(!ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0)); + WsRadialScreenMaskTag radial[WS_RADIAL_MASK_TAG_COUNT]={0}; + float scale=0; + ws_radial_mask_insert(radial,0x4a68b4,&guard,20,1.5f); + assert(ws_radial_mask_lookup(radial,0x4a68b4,words,5,22,&scale)); + assert(scale==1.5f); + assert(!ws_radial_mask_lookup(radial,0x2a68b4,words,5,22,&scale)); + assert(!ws_radial_mask_lookup(radial,0x4a68b4,words,5,23,&scale)); + words[1]++; + assert(!ws_radial_mask_lookup(radial,0x4a68b4,words,5,20,&scale)); + words[1]--; + ws_radial_mask_insert(radial,0x4a68b4,&guard,20,NAN); + assert(ws_radial_mask_lookup(radial,0x4a68b4,words,5,20,&scale)); + assert(scale==1.5f); + int32_t rx[4]={256,356,-128,640}, ry[4]={120,170,-72,312}; + ws_radial_mask_transform(rx,ry,512,240,1.f); + assert(rx[1]==356 && ry[1]==170); + ws_radial_mask_transform(rx,ry,512,240,1.5f); + assert(rx[0]==256 && ry[0]==120); + assert(rx[1]==406 && ry[1]==195); + assert(rx[2]==-320 && ry[2]==-168); + assert(rx[3]==832 && ry[3]==408); + ws_radial_mask_clear(radial); + assert(!ws_radial_mask_lookup(radial,0x4a68b4,words,5,20,&scale)); } From 900e18b306fc4c31cd6cbdc9a2b3ff5634ff6ca4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 20:43:41 -0700 Subject: [PATCH 04/17] Expose visible widescreen reveal separately from culling guards --- runtime/include/gpu.h | 3 +++ runtime/include/mod_plugins.h | 5 ++++- runtime/src/gpu.c | 1 + runtime/src/mod_runtime.cpp | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index b4191114..059b7430 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -261,6 +261,9 @@ 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); +/* Configured per-side reveal, excluding the world-culling safety envelope. + * Available before scene classification so first-frame overlays can fit. */ +int gpu_ws_configured_x_reveal(void); int ws_nw_present_width(void); /* Display width (native px) the native-wide margins are derived from. */ int gpu_ws_display_width(void); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index b83950eb..775806ef 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -106,8 +106,11 @@ uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t size, uint32_t alignment); */ int psx_mod_set_main_ram_8mb(int enabled); -/* Current per-side widescreen reveal in native game pixels (zero at 4:3). */ +/* World-culling envelope in native game pixels, including safety guards. */ int32_t psx_mod_widescreen_x_margin(void); +/* Configured per-side visible reveal, excluding culling guards; zero at 4:3. + * Use for screen-space layout, including the first frame of a new scene. */ +int32_t psx_mod_widescreen_view_x_margin(void); /* Mark a guest GPU packet (P_TAG address) as persistent screen-space HUD. * edge = -1 left, +1 right, 0 clears a reused packet's tag. The native-wide diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index c2e15fe3..e384cf2a 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -548,6 +548,7 @@ static int ws_nw_offset(void) { if (!ws_native_wide_active()) return 0; return ws_nw_configured_offset(); } +int gpu_ws_configured_x_reveal(void) { return ws_nw_configured_offset(); } int ws_nw_extra(void) { return 2 * ws_nw_offset(); } static uint32_t ws_view_camera_addr, ws_view_min_addr, ws_view_max_addr, ws_view_active_addr; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 91478239..6470aa92 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1526,6 +1526,9 @@ extern "C" uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t size, extern "C" int32_t psx_mod_widescreen_x_margin(void) { return (int32_t)psx_ws_x_margin(); } +extern "C" int32_t psx_mod_widescreen_view_x_margin(void) { + return (int32_t)gpu_ws_configured_x_reveal(); +} extern "C" void psx_mod_tag_hud_primitive(uint32_t primitive, int edge) { gpu_ws_tag_hud_primitive(primitive, edge); From 128684b6a3526120aa26394f2bd553f991ee03e8 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 21:00:01 -0700 Subject: [PATCH 05/17] Document owned mask and centered HUD contracts --- docs/ADAPTIVE_SCREEN_MASKS.md | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/ADAPTIVE_SCREEN_MASKS.md diff --git a/docs/ADAPTIVE_SCREEN_MASKS.md b/docs/ADAPTIVE_SCREEN_MASKS.md new file mode 100644 index 00000000..39f6f24f --- /dev/null +++ b/docs/ADAPTIVE_SCREEN_MASKS.md @@ -0,0 +1,46 @@ +# Owned screen-space masks and HUD + +Adaptive world projection needs separate layout rules for cutscene overlays. +Game adapters identify the original producers, wait for their packet writes +to finish, and tag completed packets before GPU submission. The renderer +keeps guest packet bytes unchanged. + +The public functions in `runtime/include/mod_plugins.h` are: + +| API | Contract | +| --- | --- | +| `psx_mod_tag_screen_mask_quad(prim)` | Extend a flat quad's full-width top/bottom panel or existing vertical side band into the visible reveal. | +| `psx_mod_tag_radial_screen_mask_quad(prim, scale)` | Scale an owned flat/Gouraud quad about the display centre; tag every piece of the transition. | +| `psx_mod_anchor_hud_primitive(prim, anchor)` | Translate left/right HUD by the visible side margin, or preserve centered text with `anchor == 0`. | +| `psx_mod_widescreen_view_x_margin()` | Return the configured visible per-side reveal, excluding culling safety guards. | + +`prim` addresses the packet word immediately before the GP0 color/command. +For a compound draw-mode-plus-SPRT glyph packet, pass the word before the +SPRT command, rather than the compound packet's initial P_TAG. Tags bind the +complete command words, retain expanded-RAM identity, expire after two +frames and clear on reset/state load. Reused or modified packets do not +inherit the layout rule. + +Centered HUD tags bypass automatic UI and backdrop transforms. They retain +glyph coordinates, UVs and dimensions, while left/right tags translate the +whole widget. This guarded anchor API differs from the older role-tagging +API where role zero clears the tag. + +Panel reveal bands are disjoint from the authored quad, preserving animated +height and avoiding repeated blending. Full-frame panels use the existing +single expansion. Slanted quads and interior strips do not acquire panel +coverage. Adapters must establish ownership; screen position alone is not +an ownership test. + +Radial scale must be finite and between 1 and 64. Derive it from the +producer's authored geometry and pixel aspect; scale both feathered and +solid rings together. The original transition timer remains authoritative. +At 4:3 there is no reveal transform. The older +`psx_mod_widescreen_x_margin()` returns a culling envelope and includes +safety guards, so it is unsuitable for deriving the visible fade radius. + +`runtime/tests/test_ws_screen_mask.c` covers bands, whole-frame handling, +expanded-RAM identity, radial transforms, expiration and packet reuse. +The MediEvil II integration independently checked full-width museum bars +and identical subtitle glyph packets/bounds at 4:3, 16:9, 21:9 and 32:9. +That live evidence does not qualify every game's transitions or HUD. From 88e8d59de3278d7c1830d59a444d4815a32cea54 Mon Sep 17 00:00:00 2001 From: Yves Date: Sun, 4 Oct 2026 05:54:03 +0800 Subject: [PATCH 06/17] runtime: fix missing online-player lobby symbols in netplay-disabled builds Adapted from the original feature commits, retaining their author credit: [ce63101f](https://github.com/FractalEngineer/psxrecomp/commit/ce63101f0f964845d51fb7d4b1fc96ab35192509) Source scope and current-master validation: docs/internal/upstream/netplay-disabled-online-stubs.md Co-authored-by: Yves --- .../upstream/netplay-disabled-online-stubs.md | 41 +++++++++++++++++++ runtime/src/psx_lobby_client.c | 2 + 2 files changed, 43 insertions(+) create mode 100644 docs/internal/upstream/netplay-disabled-online-stubs.md diff --git a/docs/internal/upstream/netplay-disabled-online-stubs.md b/docs/internal/upstream/netplay-disabled-online-stubs.md new file mode 100644 index 00000000..26cbb7b2 --- /dev/null +++ b/docs/internal/upstream/netplay-disabled-online-stubs.md @@ -0,0 +1,41 @@ +# Disabled-lobby online-player API: source and validation + +Reconstructed from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp), developed on +`vr-dev` at `9976567eb8e95510ca7eeeeccd9af3a260885de5`. +Exact source commits: + +- [ce63101f](https://github.com/FractalEngineer/psxrecomp/commit/ce63101f0f964845d51fb7d4b1fc96ab35192509) + +Original author: Yves . The feature commit retains +that exact human author identity. The focused port consolidates the selected +source hunks and corrections. Its commit also cites these sources and carries +the original human contributor's `Co-authored-by: Yves ` trailer. + +Adapted: The two missing host lobby online-count/get symbols in the feature-disabled path. + +Deliberately excluded: Enabled lobby behavior, account/netplay policy, guest BIOS behavior and game files. + +The feature was reconstructed on upstream master +`db62eec15f81c88aef3d6dab51d4e566a7167d23`, retaining current upstream APIs, +renderer/input policy and build integration. Validation after reconstruction: +Disabled-client compile/link/executable checks and enabled-client compilation against the pinned network headers. + +The motivating consumer is [Medal of Honor, SLUS-00974 (NTSC-U)](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442). +Game-specific integration remains in that repository. Consuming this change +requires a deliberate framework pin update in the game repo and a rebuild. +This is a host/runtime/tool change; it requires no BIOS or game C regeneration. + +Historical alpha acceptance used the older framework checkpoint above. It is +separate from current-master branch verification. Fresh game, renderer and +headset checks, where applicable, are recorded in the PR validation notes; +uncertain hardware/runtime behavior must not be inferred from a clean link. + +## Current-master integration validation + +Windows, GCC 13.2, Ninja, Release. Runtime test checks remain active with NDEBUG enabled. + +Disabled lobby-client compile/link and executable checks passed for both symbols. Enabled-client compilation passed against the upstream-pinned network headers. Full enabled-netplay game linkage was not rerun. + +Integration: [Medal of Honor, SLUS-00974 NTSC-U](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442), built against the corrected framework stack with `PSXRECOMP_ROOT`. Bundled OpenBIOS LLE cold boot, menu navigation, mission gameplay, fresh savestate creation/loading and post-load input/frame advancement were checked with screenshots. An older alpha slot-5 savestate was refused; compatibility with historical states is not established. Memory-card round trips, attract-demo completion, other games and fresh headset visuals were not tested. + +The registered recompiler suite reports 132 passes, four failures and three disabled tests. The four failures reproduce on unchanged upstream master in this Windows toolchain: `portable_settings_paths_test`, `optional_netplay_auth`, `compile_overlays_static_tail` and `aot_overlay_discovery`. The introduced BIOS-fingerprint and GL-guard failures are fixed. diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index b8e43a26..b959171c 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -33,6 +33,8 @@ void psx_lobby_pump(void) {} void psx_lobby_request_list(void) {} int psx_lobby_list_count(void) { return 0; } int psx_lobby_list_get(int index, PsxLobbyRow *out) { (void)index; (void)out; return 0; } +int psx_lobby_online_count(void) { return 0; } +int psx_lobby_online_get(int index, PsxLobbyOnlinePlayer *out) { (void)index; (void)out; return 0; } void psx_lobby_set_game_identity(const char *a, const char *b) { (void)a; (void)b; } const char *psx_lobby_game_version(void) { return PSX_GAME_VERSION; } void psx_lobby_set_disc_fp(const char *disc_fp) { (void)disc_fp; } From 564607e8bca8461b4534be61b1b588a38d29c369 Mon Sep 17 00:00:00 2001 From: Yves Date: Sun, 4 Oct 2026 05:54:03 +0800 Subject: [PATCH 07/17] release: preserve the .exe suffix when selecting Windows release binaries Adapted from the original feature commits, retaining their author credit: [9976567e](https://github.com/FractalEngineer/psxrecomp/commit/9976567eb8e95510ca7eeeeccd9af3a260885de5) Source scope and current-master validation: docs/internal/upstream/windows-release-executable-suffix.md Co-authored-by: Yves --- .../windows-release-executable-suffix.md | 39 +++++++++++++++++++ tools/package_game_release.sh | 6 ++- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 docs/internal/upstream/windows-release-executable-suffix.md diff --git a/docs/internal/upstream/windows-release-executable-suffix.md b/docs/internal/upstream/windows-release-executable-suffix.md new file mode 100644 index 00000000..d7be8e6f --- /dev/null +++ b/docs/internal/upstream/windows-release-executable-suffix.md @@ -0,0 +1,39 @@ +# Windows release executable selection: source and validation + +Reconstructed from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp), developed on +`vr-dev` at `9976567eb8e95510ca7eeeeccd9af3a260885de5`. +Exact source commits: + +- [9976567e](https://github.com/FractalEngineer/psxrecomp/commit/9976567eb8e95510ca7eeeeccd9af3a260885de5) + +Original author: Yves . The feature commit retains +that exact human author identity. The focused port consolidates the selected +source hunks and corrections. Its commit also cites these sources and carries +the original human contributor's `Co-authored-by: Yves ` trailer. + +Adapted: Literal .exe candidate ordering in normal discovery and the CMake OUTPUT_NAME fallback. + +Deliberately excluded: Game release settings, alpha distribution artifacts, private discs and Windows CI Python setup. + +The feature was reconstructed on upstream master +`db62eec15f81c88aef3d6dab51d4e566a7167d23`, retaining current upstream APIs, +renderer/input policy and build integration. Validation after reconstruction: +Git Bash syntax and 30 bundled-release-layout regressions. + +The motivating consumer is [Medal of Honor, SLUS-00974 (NTSC-U)](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442). +Game-specific integration remains in that repository. Consuming this change +requires a framework pin update to use the corrected packaging tool; the executable does not need rebuilding. +This is a host/runtime/tool change; it requires no BIOS or game C regeneration. + +Historical alpha acceptance used the older framework checkpoint above. It is +separate from current-master branch verification. Fresh game, renderer and +headset checks, where applicable, are recorded in the PR validation notes; +uncertain hardware/runtime behavior must not be inferred from a clean link. + +## Current-master integration validation + +Windows, GCC 13.2, Ninja, Release. Runtime test checks remain active with NDEBUG enabled. + +Git Bash syntax and all 30 bundled-release-layout tests passed. The cold packaged-game startup described above is historical alpha evidence; the newly rebuilt game was tested outside a package. + +The registered recompiler suite reports 132 passes, four failures and three disabled tests. The four failures reproduce on unchanged upstream master in this Windows toolchain: `portable_settings_paths_test`, `optional_netplay_auth`, `compile_overlays_static_tail` and `aot_overlay_discovery`. The introduced BIOS-fingerprint and GL-guard failures are fixed. diff --git a/tools/package_game_release.sh b/tools/package_game_release.sh index 7a8490d6..f4b6487a 100755 --- a/tools/package_game_release.sh +++ b/tools/package_game_release.sh @@ -151,7 +151,9 @@ REQUESTED="$(printf '%s' "${REQUESTED}" | tr -d '[:space:]')" REQUESTED="${REQUESTED#v}" EXE="" -for cand in "${BUILD_DIR}/${EXE_NAME}" "${BUILD_DIR}/${EXE_NAME}.exe" "${BUILD_DIR}/Release/${EXE_NAME}.exe"; do +# Git Bash's -f accepts a Windows PE through its unsuffixed alias. Prefer the +# literal .exe path so extension-based DLL bundling and signing cannot be skipped. +for cand in "${BUILD_DIR}/${EXE_NAME}.exe" "${BUILD_DIR}/Release/${EXE_NAME}.exe" "${BUILD_DIR}/${EXE_NAME}"; do if [[ -f "${cand}" ]]; then EXE="${cand}"; break; fi done if [[ -z "${EXE}" ]]; then @@ -160,7 +162,7 @@ if [[ -z "${EXE}" ]]; then marker="${BUILD_DIR}/psxrecomp_exe_name-${RUNTIME_TARGET}.txt" if [[ -f "${marker}" ]]; then chosen="$(tr -d '[:space:]' <"${marker}")" - for cand in "${BUILD_DIR}/${chosen}" "${BUILD_DIR}/${chosen}.exe"; do + for cand in "${BUILD_DIR}/${chosen}.exe" "${BUILD_DIR}/${chosen}"; do if [[ -f "${cand}" ]]; then EXE="${cand}"; break; fi done if [[ -n "${EXE}" ]]; then From d221cc221e7c13e052e71829eeb4c880706d5794 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 3 Oct 2026 16:53:41 -0700 Subject: [PATCH 08/17] Discover counted relocatable modules and build declared native placements --- docs/AOT_SHARDING.md | 2 + docs/COUNTED_MIPS_RELOCATIONS.md | 25 ++++++++- tools/aot_overlay_pipeline.py | 56 ++++++++++++++++++- tools/aot_overlay_spike/extract_generic.py | 30 ++++++++++ tools/tests/test_aot_overlay_pipeline.py | 51 +++++++++++++++++ .../test_counted_relocation_discovery.py | 31 ++++++++++ 6 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tools/tests/test_counted_relocation_discovery.py diff --git a/docs/AOT_SHARDING.md b/docs/AOT_SHARDING.md index 5b374368..bbd4eed9 100644 --- a/docs/AOT_SHARDING.md +++ b/docs/AOT_SHARDING.md @@ -62,6 +62,8 @@ Profiles declare these reusable methods: | `mod_packages[]` / `mod_package` | A named, verified selection of a trusted mod package. Images and checks carrying `mod_package` read the original disc with that selection applied; see below. | | `images[].transfer_entries` | `{from: "mod_package_writes", count}`: J/JAL targets inside the image, taken only from words the selected package wrote. The count pins the inventory. Optional `exclude_writes: [{start, end, reason}]` names intervals of package-written DATA (for example a pack linked into the EXE that the package replaced) whose words are not transfers. | | `images[].method = tagged_relocated_files` | Apply a trailing two-bit ABS32/HI16/LO16/J26 relocation stream to a verified `load_addr`, retaining only the original loader's image interval. Destinations are explicit evidence, never inferred from a historical RAM capture. | +| `images[].method = counted_relocated_members` | Verify each original container's SHA-256, classify every counted member, and relocate its image at each separately established heap placement. Pin `image_size` and `relocation_count`; each member needs nonempty unique `placements` or an explained exclusion. New placements remain interpreted. See [counted containers](COUNTED_MIPS_RELOCATIONS.md). | +| `images[].entry_pointer_offsets` | Aligned image-relative offsets of loader-established callable exports. Read the pointer after relocation, ignore null callbacks, and require every nonnull callback to target aligned code inside the image. Pointer-shaped script/data fields are not callable exports. | | `images[].method = psx_exe` | Read the load address and image from a PS-X EXE header; the generic extractor may split resident and overlay floors. | | `images[].method = packed_sector_members` | Decode u32 count/offset sector descriptors across ordered payload files. Verify every member classification and requested full-payload coverage; use loader-established addresses for executable members. | | `images[].method = sector_extent_members` | Decode `{sector offset, byte size}` pairs in an archive. Use archive-relative offsets, verify the exact count and terminator, reject gaps/overlaps, and account for every member after sector rounding. Each member needs a verified load address or an explicit exclusion. | diff --git a/docs/COUNTED_MIPS_RELOCATIONS.md b/docs/COUNTED_MIPS_RELOCATIONS.md index f0728634..d0d4a85e 100644 --- a/docs/COUNTED_MIPS_RELOCATIONS.md +++ b/docs/COUNTED_MIPS_RELOCATIONS.md @@ -39,7 +39,28 @@ The container has **no destination addresses**. `relocate` takes an aligned KSEG0 main-RAM image base; this is not the allocation base of a container whose headers remain in memory. Callers must establish placement and executable/data boundaries from the original loader before constructing native AOT producers. -The decoder alone does not provide dynamically placed native module dispatch. +The original-disc AOT pipeline accepts `counted_relocated_members` with +`containers: [{file, sha256, members: [...]}]`. Each member pins its `index`, +`image_size`, `relocation_count` and nonempty `placements: [{load_addr, ...}]`. +Every member must be classified, including an explicit reason for exclusions. +The placement is the image address, which can differ from the container's +allocation address by the header size. Native emission uses relocated original +bytes and the normal guarded dispatcher. A newly encountered heap placement +continues through the interpreter until its own native variant is established. + +`entry_pointer_offsets` declares image-relative callback fields proven by the +loader's consumers. Reading those fields after relocation lets the same export +declaration apply at multiple bases. Null callbacks are ignored; unaligned, +out-of-image and excluded required entries fail extraction. Script pointers and +other data fields must not be nominated as MIPS entry points merely because they +look like addresses. + +The generic disc discoverer now recognizes complete counted containers and +writes `.relocatable.json` alongside its positioned recipes. The sidecar +records original hashes, member extents, sizes and relocation counts with +`placement_required: true`. It does not infer a heap address or claim native +coverage. This exposes unresolved loader work during initial discovery instead +of waiting for gameplay to reveal interpreter callbacks. The implementation was compared byte for byte with the owned USA MediEvil II relocation routine (`0x80078328`) for 24 modules at two distinct RAM bases: @@ -49,4 +70,6 @@ all four relocation kinds, opcode preservation and malformed inputs: ```sh python -m unittest discover -s tools/tests -p test_counted_mips_relocations.py +python -m unittest discover -s tools/tests -p test_counted_relocation_discovery.py +python -m unittest discover -s tools/tests -p test_aot_overlay_pipeline.py ``` diff --git a/tools/aot_overlay_pipeline.py b/tools/aot_overlay_pipeline.py index 5f95622b..88abc4c1 100644 --- a/tools/aot_overlay_pipeline.py +++ b/tools/aot_overlay_pipeline.py @@ -25,6 +25,7 @@ from sector_extent_archive import extract_members as extract_extent_members from aligned_lzss_banks import banks as extract_lzss_banks from indexed_lzss_pack import members as extract_pack_members from mips_tagged_relocations import parse as parse_tagged_relocations, relocate as relocate_tagged_image +from counted_mips_relocations import parse as parse_counted_relocations, relocate as relocate_counted_image from mod_package_images import ModPackageView FRAMEWORK = Path(__file__).resolve().parents[1] @@ -369,6 +370,8 @@ def positioned_sources(disc, specifications, views=None, ram_bytes=RETAIL_RAM_BY def spec_sources(disc, spec): sources = [] method = spec['method'] + if method == 'counted_relocated_members': + return counted_relocated_sources(disc, spec) if method == 'fixed_address_extents': return extent_sources(disc, spec) if method == 'aligned_lzss_banks': @@ -469,6 +472,48 @@ def spec_sources(disc, spec): return sources +def counted_relocated_sources(disc, spec): + """Original members at declared heap placements, never captured RAM bytes. + + Counted containers describe image boundaries and relocation writes, but no + destination. Each placement must be established separately from the loader. + A new heap placement remains interpreted until a guarded variant is added. + """ + sources = [] + for container in spec['containers']: + file = container['file'].upper() + data = disc.read(file) + require(hashlib.sha256(data).hexdigest() == container['sha256'], + f'Counted relocation container changed: {file}') + members = parse_counted_relocations(data) + configured = {number(item['index']): item for item in container['members']} + excluded = {number(item['index']): item for item in container.get('excluded_members', [])} + require(len(configured) == len(container['members']) and + len(excluded) == len(container.get('excluded_members', [])), + 'Duplicate counted member inventory') + require(not configured.keys() & excluded.keys() and + configured.keys() | excluded.keys() == set(range(len(members))), + 'Counted member inventory has missing or conflicting classifications') + require(all(item.get('reason', '').strip() for item in excluded.values()), + 'Excluded counted member needs a reason') + for index, member in enumerate(members): + item = configured.get(index) + if item is None: + continue + require(len(member.image) == number(item['image_size']) and + len(member.relocations) == number(item['relocation_count']), + f'Counted member inventory changed: {file} {index}') + require(item.get('placements'), 'Counted member needs an established placement') + bases = [number(p['load_addr']) for p in item['placements']] + require(len(bases) == len(set(bases)), 'Duplicate counted member placement') + for placement, base in zip(item['placements'], bases): + body = relocate_counted_image(member, base) + sources.append(dict(name=f'{file}:IMAGE_{index:04X}@{base:08X}', + base=base, body=body, spec={**spec, **item, **placement, 'allow_missing': True}, + source_file=file, source_offset=member.image_offset, aliases=[])) + return sources + + def match_sources(record, sources): require(not record.get('executed_pcs'), 'Runtime observations cannot be AOT inputs') data = base64.b64decode(record['bytes_b64'], validate=True) @@ -495,6 +540,15 @@ def declared_entries(source, disc): offset = number(item.get('file_offset', 0)) + number(item['address']) - number(item.get('base', 0)) entries.add(struct.unpack_from(' one shard set, not N (same as hand tool). dcrc=binascii.crc32(data)&0xFFFFFFFF @@ -1271,6 +1298,9 @@ def main(): f"sha256={record['bios_sha256'][:12]}...") with open(a.out, 'w') as f: json.dump(records, f) + with open(a.out + '.relocatable.json', 'w') as f: + json.dump(dict(schema='psxrecomp unresolved original-disc relocations v1', + containers=relocatable), f, indent=2) print(f"producers: {np} PS-X EXE (full-discovery), {nh} header-table, " f"{nr} raw-code, {na} indexed-archive members, " f"{ne} HED-companion members, " diff --git a/tools/tests/test_aot_overlay_pipeline.py b/tools/tests/test_aot_overlay_pipeline.py index 91e8a6f4..fa634ae2 100644 --- a/tools/tests/test_aot_overlay_pipeline.py +++ b/tools/tests/test_aot_overlay_pipeline.py @@ -348,6 +348,57 @@ class ModPackageImageTest(unittest.TestCase): class AotMethodsTest(unittest.TestCase): + def test_counted_original_members_at_multiple_heap_placements(self): + # A relocated callback pointer enters a frameless return stub. + image = struct.pack('<4I', 8, 0x12345678, 0x03e00008, 0) + original = struct.pack('<4I', 1, 0, len(image), 0) + image + struct.pack('<2I', 0, 0xffffffff) + disc = FakeDisc({'RELOCS/MODULE.LVB': original}) + member = dict(index=0, image_size=16, relocation_count=1, + entry_pointer_offsets=[0], placements=[dict(load_addr='0x80101010'), + dict(load_addr='0x80102020')]) + container = dict(file='RELOCS/MODULE.LVB', sha256=sha(original), members=[member]) + spec = dict(method='counted_relocated_members', containers=[container]) + sources = pipeline.positioned_sources(disc, [spec]) + self.assertEqual([s['base'] for s in sources], [0x80101010, 0x80102020]) + self.assertEqual([pipeline.declared_entries(s, disc) for s in sources], + [{0x80101018}, {0x80102028}]) + self.assertEqual(disc.read('RELOCS/MODULE.LVB'), original) + with tempfile.TemporaryDirectory() as directory: + disc.binary = Path(directory) / 'source.bin'; disc.binary.write_bytes(original) + inventory = pipeline.prepare(dict(game_id='TEST', images=[spec], strict_bounds=True, + expected_records=2), disc, [], Path(directory)) + self.assertEqual(inventory['recipe_count'], 2) + self.assertEqual([j['required_entries'] for j in inventory['jobs']], + [[0x80101018], [0x80102028]]) + self.assertEqual(inventory['jobs'][0]['known_ranges'], [(0x80101010, 0x80101020)]) + + def test_counted_sources_reject_inventory_and_callback_drift(self): + image = struct.pack('<4I', 8, 0, 0x03e00008, 0) + original = struct.pack('<4I', 1, 0, 16, 0) + image + struct.pack('<2I', 0, 0xffffffff) + disc = FakeDisc({'MODULE.LVB': original}) + item = dict(index=0, image_size=16, relocation_count=1, + placements=[dict(load_addr='0x80100000')]) + container = dict(file='MODULE.LVB', sha256=sha(original), members=[item]) + def sources(c): + return pipeline.positioned_sources(disc, [dict(method='counted_relocated_members', + containers=[c])]) + for change, error in ((dict(sha256='0'*64), 'container changed'), + (dict(members=[]), 'missing'), + (dict(members=[{**item, 'relocation_count': 2}]), 'inventory changed'), + (dict(members=[{**item, 'placements': []}]), 'placement'), + (dict(members=[{**item, 'placements': item['placements']*2}]), 'Duplicate')): + with self.subTest(change=change), self.assertRaisesRegex(ValueError, error): + sources({**container, **change}) + source, = sources(container) + for offset in (-4, 1, 16): + with self.subTest(offset=offset), self.assertRaisesRegex(ValueError, 'outside image'): + pipeline.declared_entries({**source, 'spec': {**source['spec'], + 'entry_pointer_offsets': [offset]}}, disc) + source['body'] = struct.pack('<4I', 0x80020000, 0, 0x03e00008, 0) + with self.assertRaisesRegex(ValueError, 'Entry outside image'): + pipeline.declared_entries({**source, 'spec': {**source['spec'], + 'entry_pointer_offsets': [0]}}, disc) + def test_tagged_relocated_original_file_and_inventory_check(self): original = struct.pack('<8I', 16, 8, 0x03e00008, 0, 4, 0xFFFFFFFF, 0, 0)[:24] disc = FakeDisc({'MODULE.DLL': original}) diff --git a/tools/tests/test_counted_relocation_discovery.py b/tools/tests/test_counted_relocation_discovery.py new file mode 100644 index 00000000..1e9e5573 --- /dev/null +++ b/tools/tests/test_counted_relocation_discovery.py @@ -0,0 +1,31 @@ +"""Unpositioned module discovery must report facts without inventing a base.""" +from pathlib import Path +import struct +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from aot_overlay_spike.extract_generic import counted_relocation_inventory + + +class CountedDiscoveryTest(unittest.TestCase): + def test_complete_container_reports_members_and_requires_placement(self): + image = struct.pack('<4I', 8, 0x0c000002, 0x03e00008, 0) + original = struct.pack('<4I', 1, 0, 16, 0) + image + struct.pack('<3I', 0, 7, 0xffffffff) + inventory = counted_relocation_inventory(original) + self.assertTrue(inventory['placement_required']) + self.assertNotIn('load_addr', inventory) + self.assertEqual(inventory['members'][0]['image_offset'], 16) + self.assertEqual(inventory['members'][0]['relocation_count'], 2) + self.assertEqual(inventory['members'][0]['image_size'], 16) + + def test_partial_stream_and_pointer_header_are_not_relocation_containers(self): + for data in (struct.pack('<4I', 1, 0x80100000, 0x03e00008, 0), + struct.pack('<4I', 1, 0, 4, 0) + struct.pack('<2I', 0, 0), + b'PS-X EXE' + bytes(2040)): + with self.subTest(data=data[:16]): + self.assertIsNone(counted_relocation_inventory(data)) + + +if __name__ == '__main__': + unittest.main() From 194cde626422a7e2dbe60d978ebfef146d8610b1 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 3 Oct 2026 17:39:09 -0700 Subject: [PATCH 09/17] Register counted relocation discovery in CTest --- recompiler/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 3113aee3..1563689a 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -938,6 +938,11 @@ if(BUILD_TESTING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_aot_overlay_pipeline.py WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_test( + NAME counted_relocation_discovery + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_counted_relocation_discovery.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) # psxrecomp_cli.py generate builds the static overlay shard a title's # aot/overlays.json declares (static_output) and reports every outcome. # The pipeline it drives needs Python 3.11+, so the CLI refuses older From 030cb00c455faad077c950c667e593f32dea4c66 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 3 Oct 2026 23:46:24 -0700 Subject: [PATCH 10/17] Keep upstream PGXP session fixture linked with mask APIs --- runtime/tests/test_pgxp_session.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index e59c2c02..d94ed6be 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -117,6 +117,10 @@ extern "C" int fntrace_is_game_started(void) { return 1; } extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} extern "C" void gpu_ws_tag_world_primitive(uint32_t, int) {} extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {} +extern "C" int gpu_ws_configured_x_reveal(void) { return 0; } +extern "C" void gpu_ws_tag_hud_prim(uint32_t, int) {} +extern "C" void gpu_ws_tag_screen_mask_quad(uint32_t) {} +extern "C" void gpu_ws_tag_radial_screen_mask_quad(uint32_t, float) {} /* ---- staging ------------------------------------------------------------- */ From d2c8f03e76e3a56bf58fbe7a177ce2f2e666f6b4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 3 Oct 2026 23:35:11 -0700 Subject: [PATCH 11/17] Add guarded native draw replay, motion interpolation and stable world filtering --- docs/MOD_PACKAGES.md | 61 +++ docs/RENDER_PASSES.md | 119 +++++ docs/STABLE_TEXTURE_FILTERING.md | 39 ++ docs/internal/FAITHFUL_TIMING_PLAN.md | 67 +++ generated/OpenBIOS.emitter.sha | 2 +- generated/OpenBIOS_dispatch.c | 7 +- generated/SCPH1001.emitter.sha | 2 +- generated/SCPH1001_dispatch.c | 7 +- recompiler/include/code_generator.h | 2 + recompiler/src/code_generator.cpp | 16 +- recompiler/src/config_loader.cpp | 10 + recompiler/src/config_loader.h | 2 + recompiler/src/full_function_emitter.cpp | 6 +- recompiler/src/main_psx.cpp | 4 + .../tests/full_function_emitter_test.cpp | 14 + recompiler/tests/recompiler_patch_test.cpp | 43 ++ runtime/CMakeLists.txt | 43 ++ runtime/include/boot_state.h | 7 +- runtime/include/dirty_ram_interp.h | 28 ++ runtime/include/fntrace.h | 3 + runtime/include/gpu_gl_renderer.h | 3 + runtime/include/mod_packages.h | 10 + runtime/include/mod_plugins.h | 102 ++++ runtime/include/overlay_api.h | 11 +- .../include/overlay_dispatch_preamble.c.inc | 4 + runtime/include/pgxp.h | 6 + runtime/include/render_pass.h | 2 + runtime/include/render_pass_motion.h | 123 +++++ runtime/include/render_pass_plan.h | 16 +- runtime/include/render_pass_projection.h | 32 ++ runtime/include/render_pass_replay.hpp | 84 ++++ runtime/runtime.cmake | 3 + runtime/src/boot_state.c | 22 +- runtime/src/debug_server.c | 57 ++- runtime/src/dirty_ram_interp.c | 120 ++++- runtime/src/fntrace.c | 1 + runtime/src/gpu.c | 25 +- runtime/src/gpu_gl_renderer.c | 67 ++- runtime/src/gte.cpp | 79 +++ runtime/src/main.cpp | 27 +- runtime/src/memory.c | 79 +++ runtime/src/mod_packages.cpp | 42 +- runtime/src/mod_runtime.cpp | 181 ++++++- runtime/src/overlay_loader.c | 2 + runtime/src/pgxp.cpp | 122 ++++- runtime/src/render_pass.c | 48 +- runtime/src/render_pass_frame.c | 58 +++ runtime/src/render_pass_motion.c | 456 ++++++++++++++++++ runtime/src/render_pass_plan.c | 15 +- runtime/src/render_pass_projection.cpp | 198 ++++++++ runtime/tests/gpu_exec_stubs.inc | 3 + runtime/tests/run_gl_texture_filter.py | 54 +++ runtime/tests/test_boot_state_handoff.c | 85 ++++ runtime/tests/test_gl_texture_filter.c | 128 +++++ runtime/tests/test_gte_register_access.cpp | 53 ++ runtime/tests/test_interp_mod_entry_guards.py | 5 + runtime/tests/test_mod_memory_snapshot.c | 21 + runtime/tests/test_mod_runtime.cpp | 135 +++++- runtime/tests/test_render_pass_abort.c | 65 ++- runtime/tests/test_render_pass_freeze.c | 3 + runtime/tests/test_render_pass_motion.c | 237 +++++++++ runtime/tests/test_render_pass_plan.c | 21 + runtime/tests/test_render_pass_projection.cpp | 86 ++++ runtime/tests/test_render_pass_sandbox.c | 37 ++ .../tests/test_ws_auto_ui_untextured_exec.c | 51 ++ tools/measure_render_quality.py | 69 +++ 66 files changed, 3454 insertions(+), 76 deletions(-) create mode 100644 docs/STABLE_TEXTURE_FILTERING.md create mode 100644 runtime/include/render_pass_motion.h create mode 100644 runtime/include/render_pass_projection.h create mode 100644 runtime/include/render_pass_replay.hpp create mode 100644 runtime/src/render_pass_frame.c create mode 100644 runtime/src/render_pass_motion.c create mode 100644 runtime/src/render_pass_projection.cpp create mode 100644 runtime/tests/run_gl_texture_filter.py create mode 100644 runtime/tests/test_boot_state_handoff.c create mode 100644 runtime/tests/test_gl_texture_filter.c create mode 100644 runtime/tests/test_render_pass_motion.c create mode 100644 runtime/tests/test_render_pass_projection.cpp create mode 100644 tools/measure_render_quality.py diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index b4331a2b..f225cf1e 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -860,6 +860,50 @@ they are not copied into the package. Optional resources with no selected path are omitted from the committed plan. Required resources reject launch while the feature is enabled and unset. + +Format 8 adds engine-verified donor media to the same picker and state format: + +```toml +[[resource]] +feature = "arena" +id = "donor-rom" +label = "Source ROM" +format = "n64-rom" +file_patterns = "*.z64,*.v64,*.n64" +required = true +size = 8388608 +sha256 = "<64 lowercase hexadecimal digits from the canonical image>" +``` + +`size` and `sha256` must both be present. Verification runs only for enabled +features. Missing required media, a removed selected file, an incorrect size, +or a hash mismatch rejects the launch plan before plugin activation. Disabling +the feature restores the stock launch; the selected path is preserved. + +Canonical identity domains: + +| `format` | Size and SHA-256 domain | +|---|---| +| `file` with identity fields | Exact file bytes, at most 512 MiB | +| `n64-rom` | Big-endian `.z64` bytes; `.v64` halfword swaps and `.n64` word swaps normalize first; 64 bytes to 64 MiB, word aligned | +| `psx-disc` | First data track's 2048-byte sector payloads, at most 512 MiB; CUE/BIN/ISO/CHD use the shared disc reader; audio tracks are excluded | + +For `psx-disc`, Mode 1 payloads start at raw-sector offset 16 and Mode 2 +payloads at 24. This domain retains the first 2048 bytes of Form 2 sectors; +it is intended for asset data, and is not a full XA/CD-audio identity. Mixed-mode +retail ISO headers can declare a volume size extending into audio tracks; the +reader uses the actual first-track boundary from the TOC or single-track image +length. An audio-first disc or a nonzero first-track start is rejected. Use a +CUE for a raw image containing multiple tracks. Declared volume metadata must +have matching byte orders and 2048-byte logical sectors. + +Verified resource fingerprints include format, canonical size and SHA-256, +rather than the selected path, so moving identical media or changing N64 byte +order does not change save compatibility. Donor bytes are never package files, +and donor executable code is not dispatched by this API. N64 ZIPs must be +extracted before selecting the ROM. Format 8 prevents older runtimes from +silently accepting manifests whose identity checks they cannot enforce. + `resolver = "builtin:"` selects a resolver statically registered by the game. Format-5 plugin ids likewise select only statically registered implementations. Packages cannot load arbitrary native code or select @@ -870,6 +914,23 @@ rejects encrypted entries and unsafe or absolute paths, limits archives to 4096 files and 256 MiB expanded size, stages extraction, validates the manifest, and publishes the version atomically. +### Guarded instruction callbacks + +Trusted plugins can register +`psx_mod_register_instruction_plugin(id, address, expected_word, callback)`. +List native code locations in `[recompiler] mod_instruction_sites` and regenerate. +Callbacks run immediately before the instruction; the registered full word, +executing word and current RAM word must agree. Segment aliases share a site. +Only active resolved plugins run, with their feature's resource context. +The dirty-RAM backend uses the same checks, including nested delay slots. +Overlay ABI v27 carries the forwarder; site metadata changes the codegen hash. + +Callbacks may change registers and data. They must preserve PC and must not +re-enter guest execution, because pending branch/load state is live. +`psx_mod_finish_function` is unavailable in this callback scope. Inactive plans +do not change guest behavior. Function entry replacement remains the separate +`psx_mod_register_function_entry_plugin` interface. + ### Presentation bezel packages The framework registers `psx.bezel`, a trusted presentation plugin for OpenGL diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 6275f333..3090994e 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -73,6 +73,125 @@ back to `HOLD` when the status is `READY` again. A backend mode that cannot host passes (for example a renderer path whose VRAM is not one texture) should report `BACKEND` from `gl_renderer_pass_unavailable()`. +## Flip modes + +`psx_mod_set_render_pass_flip()` says how the game flips relative to the pass +point (default `PENDING`, reset at every session start): + +- `PENDING`: frame N is drawn and waits; the next flip shows the pass rect. + This is the PsyQ `VSync(0)`-then-`PutDispEnv` loop, and also any game that + flips only after the next frame is submitted (Vigilante 8: 2nd Offense + flips from a VBlank callback armed by the next submit). Phase 0 is + re-captured when the flip is seen, so a game that adds a last layer to the + shown buffer after the pass point (V8:2 draws its HUD at the next submit) + shows its own final image at phase 0. +- `SHOWN`: the game flips each frame as soon as it is drawn, so at the pass + point frame N is already on screen. The rect is that on-screen rect; frame + N and its in-betweens are shown from the next flip, one game frame later + than stock (the latency of any interpolator with one frame of history). + +Decide by decoding the game's DISPENV / DRAWENV tables and where it calls +`PutDispEnv` and `DrawOTag`, not by assumption: in V8:2 `DISPENV[i]` shows +the buffer `DRAWENV[1-i]` draws. + +## Replaying a span of frame code + +Re-implementing a game's per-frame draw call sequence in a plugin copies one +path through it (HUD, messages, pause menu, replay mode each branch +differently) and drifts from it. Instead, a plugin records the registers at +the start of the game's draw section during the real frame (an instruction +hook, `mod_instruction_sites`) and, inside a pass, replays that section: + +```c +int psx_mod_run_guest_span(struct CPUState *cpu, uint32_t start_pc, uint32_t stop_pc); +``` + +Every PC in `[start_pc, stop_pc)` is interpreted from its RAM bytes, even +where a compiled body or a compiled resume point (the return address of a +call) begins, so the span may start in the middle of a compiled function. +Calls the span makes leave the range as plain transfers and run with the +full dispatch trampoline back to their return address (compiled code is CPS: +a callee's own calls and returns go through the trampoline). A call may be +the span's last instruction. Anything else that leaves the range ends the +span with failure. Only inside a pass: the sandbox rolls a failed or runaway +span back, and the watchdog landing closes an open span. The span is a +replay of the game's own instructions, never a substitute for code the +recompiler could not translate; it stays out of capture, alias seeding and +per-PC entry statistics, and fires instruction hooks as compiled code does. +`render_pass_stats` counts `spans` / `span_failures` and keeps the last +failure (`span_fail`: reason, exit PC, `$ra`, post-call PC). + +## Blending game state (render_pass_motion.h) + +What makes a pass an in-between image is the state the draw code reads, +placed part of the way between two logic ticks. `render_pass_motion.h` holds +that for any title: + +```c +psx_motion_begin(set, tick); /* each frame, capture point */ +psx_motion_track(set, PSX_MOTION_MATRIX, obj + 0x20, psx_motion_identity(type, fn, mesh)); +psx_motion_prepare(set, &limits, &stats); /* pair with the previous frame */ +/* in each pass */ +psx_motion_apply(set, alpha); /* write blends to guest RAM */ +psx_motion_blend_at(set, kind, addr, alpha, &out); /* a value a hook hands on */ +``` + +Kinds: PsyQ `MATRIX` (slerped rotation, linear translation), its translation +only (billboards), `VECTOR`, `SVECTOR`, 12-bit `ANGLES` (shorter arc) and a +`SCALAR`. Values pair by address and identity, so a heap block reused by +another object never blends between the two. `limits.move_per_tick` (game +units) and `limits.turn_radians` separate motion from placement: a value +past them was respawned, teleported or cut to and keeps its current value. +Measure the game's real motion first; V8:2's world spans about 2^27 units +per axis and moves up to 2^16 per frame. + +## Per-frame driver + +```c +int psx_mod_activate_render_pass_rate(const char *pkg, const char *feature, + const char *option, uint32_t flip_mode); +uint32_t psx_mod_render_pass_frame(struct CPUState *cpu, + const PSXModRenderPassFrame *frame, + PSXModRenderPassFn fn, void *user); +``` + +The first reads a choice option (`display` or a rate) and selects the FLIP +source, HOLD blend, the flip mode and the rate. The second plans one game +frame, runs `fn` at each phase into the frame's rect, and switches the +presenter to a crossfade while passes are unavailable for a lasting reason +(back to HOLD when they return). + +## Porting a title + +1. Find the main loop's draw section: the range from camera setup through + OT build and HUD to the submit call, and every call in it. +2. Decode DISPENV/DRAWENV and the submit/flip path: which buffer the next + flip shows at the pass point, and in what order the game draws into it + (immediate `DrawOTag`, VBlank callback). Pick the flip mode. +3. Find the pass point (a function entry after the section's OT build, before + submit) and the logic tick counter. +4. Find what the draw code reads that moves: the camera input (often the + argument of a set-camera function: track it with `blend_at` from an entry + hook), object transforms and their identity, particles. +5. Measure motion per frame with histograms (mod counters) before setting + limits. +6. Plugin: instruction hook at the section start (registers), capture at the + pass point, `psx_mod_render_pass_frame` with a pass function that applies + the blend, replays the span and submits as the screen gets it. +7. Verify in `--hidden-window` (below): `render_pass_stats` passes with no + span failures, `render_pass_dump` images between their neighbours, + `PSX_RENDER_PASS_VERIFY=1` with `verify_mismatch` 0. + +`Vigilante82PSXRecomp/src/mods/v82_frame_rate_plugin.cpp` is the worked +example. + +## Testing without a window + +`--hidden-window` runs the full windowed runtime (presenter, interpolation, +render passes) in a window that is never shown and never opens a modal or a +file picker. `--headless` skips the presenter, so plans there are always +empty. Drive it over TCP; set `SDL_AUDIODRIVER=dummy` to keep it silent. + ## What a pass may and may not do While `fn` runs (`g_psx_render_pass_active`): diff --git a/docs/STABLE_TEXTURE_FILTERING.md b/docs/STABLE_TEXTURE_FILTERING.md new file mode 100644 index 00000000..878fd03c --- /dev/null +++ b/docs/STABLE_TEXTURE_FILTERING.md @@ -0,0 +1,39 @@ +# Stable world texture filtering + +Trusted title plugins can call `psx_mod_set_texture_filter(2)` to enable stable +minification on OpenGL. Modes 0 and 1 select nearest and bilinear. A mod-session +reset restores the player's Display setting; the override is not saved there. + +The stable shader uses screen-space UV derivatives and at most sixteen +bilinear samples over a footprint bounded to eight texels per axis. It decodes +4-bit/8-bit palettes or direct 15-bit colors before averaging. Each sample reads +the current CLUT, honors texture windows, and clamps primitive bounds before +UV wrapping. This avoids stale palette mipmaps and sampling across atlas edges. + +The center texel owns the cutout and semi-transparency classification. Neighbors +of a different STP class cannot contribute, and surviving sample weights are +renormalized. Only polygons with proven projected geometry receive the stable +filter. Sprites and untracked UI packets retain nearest sampling. Other backends +fall back to their existing bilinear mode. + +This is a bounded minification filter, not an asset mipmap or texture replacement +system. Extreme minification can still alias; filtering also adds texture fetch +cost. The title catalog offers nearest, bilinear and stable choices. + +`gl_texture_filter_test` runs a hidden real GL context over source-owned fixtures: +325 checks cover all three texture depths, minification, live palettes, cutouts, +STP separation, sharp UI, texture windows and wrap/atlas edges. On the checkerboard +fixture, red/blue chroma variation falls from 1116 to 22 for every depth. + +For a running debug build, compare the same scene and resolution with: + +```text +python tools/measure_render_quality.py --port 4370 --counter-prefix medievil.fr --output measurement.json +``` + +The resulting JSON records guest cadence, original and extra draw counters, +renderer settings, timing and statistical execution phases. The phases are host +wall-time samples, including waits and render replay; they are not a percentage +of guest instructions compiled. Extra draw counts also do not establish how +many unique images reached the monitor. Keep workload and other system activity +consistent, and report the measured rates separately from the selected target. diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 3710263b..ff03478b 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,13 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-10-03 - Render replay validation and texture streaming.** Increased the bounded small-transfer VRAM journal from 16 to 64 entries; Jersey Devil streams 31 texture/CLUT uploads in a draw. The 32-transfer rollback regression compares all VRAM bytes. Jersey Devil configures the SDK draw environment before replay packet allocation, preventing its command storage from overwriting a world quad. Final live runs completed at least 1,040 Jersey Devil and 1,088 A Bug's Life extra passes with zero state-verification mismatches, VRAM leaks, watchdogs or span failures. Changes remain local beyond base 9c1e716a. + +- **2026-10-03 - Ape Escape wall-flicker regression.** Dense frame/VRAM capture isolated world GT4s incorrectly promoted to UI behind a front-layer fade. UI grouping now selects the actual populated drawing layer, retaining empty trailing OT behavior. Captured-packet tests fail on the prior core and pass on the fix; software and 4K OpenGL/PGXP captures show no gaps in the affected camera interval. No guest timing or draw replay changes. + +- **2026-10-02 - Native high-rate rendering for Jersey Devil, A Bug's Life, and Ape Escape.** Added final RTPS/RTPT transform capture and guarded replay through the original draw sections. Projection overrides restore rotation/translation registers after each command. Camera cuts, unmatched geometry, stale captures, overflow, and aborted spans retain current poses or skip extra frames. Authoritative CPU/RAM/VRAM and guest timing remain isolated; live verification found zero mismatches in the sampled Ape Escape and A Bug's Life routes. GTE register and projection replay regressions pass. Ape Escape's card-menu scene predicate now applies in squash projection mode as well as native-wide; session resets clear it. Keyboard routing preserves analog mode on analog-locked titles. Changes are local beyond base 9c1e716a. + + - **2026-10-01 — MMX6 adaptive framework integration.** The opt-in function filters from the MMX6 branch are being reconciled with the active-plan function-entry registry and master overlay store-PC forwarding. A handled @@ -222,6 +229,66 @@ on a fixed region -> next. view, host tile packets, retained texture banks and screen masks remain presentation-only enhancements; faithful guest timing is unchanged. +- **2026-09-30 (Phase 5 owner-requested source checkpoint):** Preserve the + existing V8:2 combined-content title and generic callback/disc-extent support + on local `checkpoint/v82-combined-20260930` branches. Source, tests and + attribution only; donor media, generated outputs and playtest evidence stay + external. Owner explicitly paused further implementation, including + widescreen, original music and netplay, pending a check-in. No publication. + +- **2026-09-30 (Phase 5 Dreamland mushroom freeze):** `beads-eio.12.5` + identified an imported projectile flag write altering its callback, followed + by a skipped prologue and invalid return into the BIOS exception loop. The + title converter now tracks the local projectile factory and target pointers. + Added 4163 relocation/flag property cases and an opt-in native mushroom-event + stress driver; the old overlay fails and the repaired overlay completes both + families. All 16 title checks pass. No framework behavior changes. + +- **2026-09-30 (Phase 5 Dreamland effect cleanup):** `beads-eio.12.2` traced + the owner's frozen V8:2 match to an imported projectile effect writing its + matrix over the native child link. The title's input-overlay converter now + recognizes both effect-factory object returns. Five corrected field accesses, + owner-media regression checks, complete overlay code generation and a live + explosion allocation/free check validate the repair. No framework execution, + timing or generated-source changes; owner handoff remains at 1x rendering. + +- **2026-09-30 (Phase 5 instruction callbacks):** `beads-kbj0` adds plan-owned, + exact-word-guarded callbacks before opt-in game instructions, including branch + and delay slots. Native/overlay metadata participates in the codegen hash; + dirty-RAM execution routes through the same guard after load retirement. + Overlay ABI v27 forwards the callback. Runtime, parser/emitter and interpreter + routing checks pass. V8:2 uses seven seams for independent vehicle stats, + upgrades, transformation parts and special dependencies. Chassey's live match, + input/pause and savestate roundtrip were observed after correcting the title + allocator to retain shrinking allocations, matching its source implementation. + +- **2026-09-30 (Phase 5 mod-defined callbacks):** `beads-eio.3.212` adds + trusted mod-defined guest functions in an unused address range, separate + from original function-entry hooks. Active plans own availability and + callback resource context. Generated BIOS dispatch handles these through + its normal return/stack contract, with no synthetic guest instructions. + Runtime tests cover aliases, collisions, activation/clearing and nested + callback scopes; emitter tests cover routing before hardware dispatch. + OpenBIOS/SCPH1001 regenerated; V8:2 title rebuilt. Imported content still + needs its title-side runtime/selection adapters (`beads-eio.12.1`). + +- **2026-09-30 (Phase 5 combined V8 project):** Owner selected USA 2nd Offense + as the game-code base while retaining psxrecomp as the runtime/recompiler. + New local `Vigilante82PSXRecomp` builds against `071847ab`, reaches a stock + match, responds to driving input and reloads a savestate in an isolated run. + The reference's thirteen original-car conversions roundtrip successfully; + imported runtime/selector adapters remain title work (`beads-eio.12.1`). + No generic execution or hardware change was needed for this bring-up. + +- **2026-09-30 (Phase 5 callback reentrancy):** Plugin activation, VBlank and + function-entry callbacks now use the same scoped resource/completion context. + A native call inside an entry hook can deliver VBlank; previously those + callbacks cleared its plugin, making explicit completion fail and executing + the original function again. A regression fails before the fix and passes + after it, also refusing VBlank completion of the interrupted function. The + runtime test and seven V8 checks pass; isolated six-contact construction now + produces one chassis and survives drive/save/load. Tracked by `beads-i3ow`. + - **2026-09-30 — Original-disc overlay discovery (beads-eio.3.215).** Ape Escape Primordial Ooze exposed return-adjacent functions omitted by the archive scanner: a scheduled LUI/load before stack setup and an RA save beyond diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index cf0e347a..9b5600be 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -ae1e806fb3be31443f462df2f81314fb3288ee16bfd212bdafdbb2a03820497b +8b10cee63c750fee313839c132742e52441938a70c5736576c140383240036df diff --git a/generated/OpenBIOS_dispatch.c b/generated/OpenBIOS_dispatch.c index 0c21063d..53da932f 100644 --- a/generated/OpenBIOS_dispatch.c +++ b/generated/OpenBIOS_dispatch.c @@ -33,6 +33,9 @@ extern void debug_server_trace_dispatch(uint32_t func_addr); * the tier. */ extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys); +extern uint32_t g_psx_mod_guest_functions; +extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t); + #ifdef PSX_HAS_GAME_DISPATCH extern int psx_game_address_in_text(uint32_t addr); #endif @@ -9090,6 +9093,8 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) cpu->pc = 0; int lo = 0, hi = 3990 - 1; int found = 0; + if (g_psx_mod_guest_functions) + found = psx_mod_dispatch_guest_function(cpu, addr); /* BIOS HLE tier: consult the hook FIRST, on the pre-normalize * physical address. It must see (a) the A0/B0/C0 service vectors * (game thunks jr there with the function number in $t1) and (b) @@ -9098,7 +9103,7 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) * backends. Handled (rc 1) ⇒ the service completed against guest * state and the guest resumes at $ra via the trampoline's normal * return/tail contract below. rc 0 ⇒ pure LLE fall-through. */ - if (g_psx_bios_hle_hook && + if (!found && g_psx_bios_hle_hook && g_psx_bios_hle_hook(cpu, addr & 0x1FFFFFFFu)) { cpu->pc = cpu->gpr[31]; found = 1; diff --git a/generated/SCPH1001.emitter.sha b/generated/SCPH1001.emitter.sha index 19204fcf..739b11d0 100644 --- a/generated/SCPH1001.emitter.sha +++ b/generated/SCPH1001.emitter.sha @@ -1 +1 @@ -530a85d6f2c9c6ab3927f8071f9494799755017fd44f0f2fdb8f1cddc1a86833 +3e8f77cfe0b52d865e06c2375cac130df6e7a50dc3516fae30c945c44a9b2341 diff --git a/generated/SCPH1001_dispatch.c b/generated/SCPH1001_dispatch.c index e86cccbc..f420ba67 100644 --- a/generated/SCPH1001_dispatch.c +++ b/generated/SCPH1001_dispatch.c @@ -33,6 +33,9 @@ extern void debug_server_trace_dispatch(uint32_t func_addr); * the tier. */ extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys); +extern uint32_t g_psx_mod_guest_functions; +extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t); + #ifdef PSX_HAS_GAME_DISPATCH extern int psx_game_address_in_text(uint32_t addr); #endif @@ -27537,6 +27540,8 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) cpu->pc = 0; int lo = 0, hi = 13012 - 1; int found = 0; + if (g_psx_mod_guest_functions) + found = psx_mod_dispatch_guest_function(cpu, addr); /* BIOS HLE tier: consult the hook FIRST, on the pre-normalize * physical address. It must see (a) the A0/B0/C0 service vectors * (game thunks jr there with the function number in $t1) and (b) @@ -27545,7 +27550,7 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) * backends. Handled (rc 1) ⇒ the service completed against guest * state and the guest resumes at $ra via the trampoline's normal * return/tail contract below. rc 0 ⇒ pure LLE fall-through. */ - if (g_psx_bios_hle_hook && + if (!found && g_psx_bios_hle_hook && g_psx_bios_hle_hook(cpu, addr & 0x1FFFFFFFu)) { cpu->pc = cpu->gpr[31]; found = 1; diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 0530939c..0cde2e0f 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -66,6 +66,7 @@ struct CodeGenConfig { // Trusted game-mod entry hooks ([recompiler] mod_function_entry_funcs). // Only explicitly listed guest functions call the runtime dispatcher. std::set mod_function_entry_funcs; + std::set mod_instruction_sites; // [recompiler] hot_funcs: emit __attribute__((hot)) on these guest // addresses (MotK VLC leaves, etc.). Host locality hint only. @@ -490,6 +491,7 @@ private: std::string translate_instruction_core(uint32_t addr, uint32_t instr); // The [[draw_distance.clamp]] statement for (addr, instr), or "". std::string draw_distance_clamp_prefix(uint32_t addr, uint32_t instr) const; + std::string mod_instruction_call(uint32_t addr, uint32_t instr) const; // Register name mapping static std::string reg_name(int reg_num); diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 611722b3..d753447f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -895,6 +895,13 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad return keep_branch_if_wide("0 /* unknown branch condition: defaults to not-taken */"); } +std::string CodeGenerator::mod_instruction_call(uint32_t addr, uint32_t instr) const { + const bool selected = std::any_of(config_.mod_instruction_sites.begin(), + config_.mod_instruction_sites.end(), [=](uint32_t site) { + return (site & 0x1FFFFFFFu) == (addr & 0x1FFFFFFFu); + }); + return selected ? fmt::format("psx_mod_instruction(cpu, 0x{:08X}u, 0x{:08X}u);\n", addr, instr) : ""; +} // [[draw_distance.clamp]]: a main-EXE ordering-table range guard. While a // mod has the clamps on, the site's register is clamped to `max` before the // instruction runs, so the guard keeps a far primitive in the last OT slot @@ -935,9 +942,10 @@ std::string CodeGenerator::draw_distance_clamp_prefix(uint32_t addr, std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { std::string clamp = draw_distance_clamp_prefix(addr, instr); - if (clamp.empty()) return translate_instruction_core(addr, instr); + const std::string hook = mod_instruction_call(addr, instr); + if (clamp.empty()) return hook + translate_instruction_core(addr, instr); // The core translation carries its own indent; give the clamp the same. - return config_.indent + clamp + "\n" + translate_instruction_core(addr, instr); + return hook + config_.indent + clamp + "\n" + translate_instruction_core(addr, instr); } std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t instr) { @@ -1673,7 +1681,7 @@ std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t in case 0x2A: code = translate_slt(instr); break; // slt case 0x2B: code = translate_sltu(instr); break; // sltu default: - code = fmt::format("/* TODO: SPECIAL funct=0x{:02X} */", funct); + code = fmt::format("/* undefined SPECIAL funct=0x{:02X}: R3000A-compatible no-op */", funct); } } // Immediate and load/store instructions @@ -2204,6 +2212,7 @@ std::string CodeGenerator::translate_basic_block( // Jumps (j/jal/jr/jalr) and reserved words never reach // generate_branch_condition; check them here. check_explicit_branch_site(addr, block.exit_instr.instruction); + ss << config_.indent << mod_instruction_call(addr, instr); std::string delay_saved_cond; // branch condition captured before delay std::string delay_saved_target; // JR/JALR target captured before delay @@ -3518,6 +3527,7 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "#endif\n"; ss << "extern int psx_game_text_native_ok(uint32_t addr); /* stale-static guard (dispatch shard) */\n"; ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; + ss << "extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t);\n"; ss << "extern int psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ss << "extern int psx_ws_masked_reject(uint32_t flags, uint32_t mask); /* guarded packed-coordinate reject */\n"; ss << "extern int psx_ws_nclip_branch(uint32_t pc, uint32_t instr, int32_t mac0, int vanilla);\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index be40fc3b..2ff83a51 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -76,6 +76,7 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.u32(site.reject_mask); } } + h.words("mod_instruction_sites", c.mod_instruction_sites); h.words("cull_bias", c.ws_cull_bias_sites); if (!c.ws_cull_bias_lower_sites.empty()) h.words("cull_bias_lower", c.ws_cull_bias_lower_sites); @@ -1668,6 +1669,14 @@ GameConfig load_game_config(const fs::path& config_path_in) { } // Optional [recompiler] hot_funcs — __attribute__((hot)) on emitted C. // Optional trusted, statically linked game-mod entry hooks. + std::vector mod_instruction_sites; + if (recomp.contains("mod_instruction_sites")) + for (const auto& a : toml::find>(recomp, "mod_instruction_sites")) { + const auto pc = parse_hex(a, "recompiler.mod_instruction_sites"); + if ((pc & 3u) || pc >= 0xC0000000u || (pc & 0x1FFFFFFFu) >= 0x00800000u) + throw std::runtime_error("mod instruction site must be aligned main RAM"); + mod_instruction_sites.push_back(pc); + } std::vector mod_function_entry_funcs; if (recomp.contains("mod_function_entry_funcs")) { const auto& arr = toml::find>( @@ -2396,6 +2405,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_auto_ui_in_place*/ ws_auto_ui_in_place, /*data_shard_funcs*/ data_shard_funcs, /*mod_function_entry_funcs*/ mod_function_entry_funcs, + /*mod_instruction_sites*/ mod_instruction_sites, /*hot_funcs*/ hot_funcs, /*load_charge_batch*/ load_charge_batch, /*load_charge_batch_funcs*/ load_charge_batch_funcs, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 6a71b5ee..e5e22036 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -954,6 +954,8 @@ struct GameConfig { // entries that dispatch trusted, statically linked mod callbacks. Empty by // default, so projects that do not opt in emit no callback overhead. std::vector mod_function_entry_funcs; + // Opt-in pre-instruction callbacks; plugins supply exact-word guards. + std::vector mod_instruction_sites; // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) // on their generated C bodies (profile/host locality; no guest semantics). diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 43cf7d08..78dd7d53 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1837,6 +1837,8 @@ void FullFunctionEmitter::emit_dispatch( out += " * NULL (the default) = pure LLE, dispatch identical to a build without\n"; out += " * the tier. */\n"; out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\n\n"; + out += "extern uint32_t g_psx_mod_guest_functions;\n"; + out += "extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t);\n\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += "extern int psx_game_address_in_text(uint32_t addr);\n"; out += "#endif\n\n"; @@ -2265,6 +2267,8 @@ void FullFunctionEmitter::emit_dispatch( out += " cpu->pc = 0;\n"; out += fmt::format(" int lo = 0, hi = {} - 1;\n", total_entries); out += " int found = 0;\n"; + out += " if (g_psx_mod_guest_functions)\n"; + out += " found = psx_mod_dispatch_guest_function(cpu, addr);\n"; out += " /* BIOS HLE tier: consult the hook FIRST, on the pre-normalize\n"; out += " * physical address. It must see (a) the A0/B0/C0 service vectors\n"; out += " * (game thunks jr there with the function number in $t1) and (b)\n"; @@ -2273,7 +2277,7 @@ void FullFunctionEmitter::emit_dispatch( out += " * backends. Handled (rc 1) \xE2\x87\x92 the service completed against guest\n"; out += " * state and the guest resumes at $ra via the trampoline's normal\n"; out += " * return/tail contract below. rc 0 \xE2\x87\x92 pure LLE fall-through. */\n"; - out += " if (g_psx_bios_hle_hook &&\n"; + out += " if (!found && g_psx_bios_hle_hook &&\n"; out += " g_psx_bios_hle_hook(cpu, addr & 0x1FFFFFFFu)) {\n"; out += " cpu->pc = cpu->gpr[31];\n"; out += " found = 1;\n"; diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 2b8e977f..8fe6013e 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -219,6 +219,7 @@ static int psxrecomp_game_main(int argc, char** argv) { bool reachable_discovery = false; std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs std::set ds_funcs; // [data_shards] funcs + std::set mod_instruction_sites; std::set mod_entry_funcs; // trusted game-mod entry hooks std::vector ws_cull_masked_reject; std::set hot_funcs; // [recompiler] hot_funcs @@ -285,6 +286,7 @@ static int psxrecomp_game_main(int argc, char** argv) { ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), cfg.mod_function_entry_funcs.end()); + mod_instruction_sites.insert(cfg.mod_instruction_sites.begin(), cfg.mod_instruction_sites.end()); hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); if (cfg.load_charge_batch) { load_charge_batch_funcs.insert(cfg.load_charge_batch_funcs.begin(), @@ -400,6 +402,7 @@ static int psxrecomp_game_main(int argc, char** argv) { ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end()); mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), wscfg.mod_function_entry_funcs.end()); + mod_instruction_sites.insert(wscfg.mod_instruction_sites.begin(), wscfg.mod_instruction_sites.end()); ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); ws_cull_bias_lower.insert(wscfg.ws_cull_bias_lower_sites.begin(), wscfg.ws_cull_bias_lower_sites.end()); ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); @@ -1464,6 +1467,7 @@ static int psxrecomp_game_main(int argc, char** argv) { codegen_config.data_shard_funcs = ds_funcs; codegen_config.mod_function_entry_funcs = mod_entry_funcs; codegen_config.ws_cull_masked_reject_sites = ws_cull_masked_reject; + codegen_config.mod_instruction_sites = mod_instruction_sites; codegen_config.hot_funcs = hot_funcs; codegen_config.load_charge_batch_funcs = load_charge_batch_funcs; codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; diff --git a/recompiler/tests/full_function_emitter_test.cpp b/recompiler/tests/full_function_emitter_test.cpp index f30f49af..64ea8556 100644 --- a/recompiler/tests/full_function_emitter_test.cpp +++ b/recompiler/tests/full_function_emitter_test.cpp @@ -260,6 +260,19 @@ void patch_range_delay_boundaries(BiosConfig config) { } } +void mod_guest_dispatch_precedes_hardware_routes() { + const auto result = run_case("mod-guest-dispatch", {0x03E00008u, 0u}, + {function_at(kBase, kBase + 4u, {kBase})}); + const auto dispatch = result.dispatch.find("static void psx_dispatch_impl("); + const auto hook = result.dispatch.find("found = psx_mod_dispatch_guest_function(cpu, addr)", dispatch); + const auto hle = result.dispatch.find("if (!found && g_psx_bios_hle_hook", dispatch); + const auto hardware = result.dispatch.find("if (!found && psx_bios_try_native_call_stub", dispatch); + expect(dispatch != std::string::npos && hook > dispatch && hook < hle && hle < hardware, + "mod-defined guest calls are handled before address normalization and hardware dispatch"); + expect(result.dispatch.find("if (g_psx_mod_guest_functions)", dispatch) < hook, + "no active mod guest functions keeps the dispatch path dormant"); +} + int main() { BiosConfig config{}; config.config_path = "test://full-function-emitter"; @@ -282,6 +295,7 @@ int main() { fragment_split_load_falls_back(); noncomplementary_lwl_falls_back(); complementary_lwl_lwr_stays_native(); + mod_guest_dispatch_precedes_hardware_routes(); patch_range_guards(config); patch_range_delay_boundaries(config); diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index a827ce49..4058f028 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -1160,6 +1160,47 @@ void cfg_codegen_load_delay_test() { "CFG codegen preserves MIPS-I dependent load-delay value semantics"); } +void mod_function_completion_codegen_test() { + PSXRecomp::CodeGenConfig config{}; + config.mod_function_entry_funcs.insert(0x80010000u); + for (bool overlay : {false, true}) { + const auto code = generate_first_instruction(0x2402002Au, {}, overlay, config); + check(code.find("if (psx_mod_function_entry(cpu, 0x80010000u)) return;") != std::string::npos, + overlay ? "overlay body honors trusted mod completion" : "main body honors trusted mod completion"); + } + const auto stock = generate_first_instruction(0x2402002Au, {}, false); + check(stock.find("psx_mod_function_entry(cpu,") == std::string::npos, + "unconfigured functions retain the stock entry path"); +} + +void mod_instruction_codegen_test(const fs::path& root) { + const auto path = write_config(root, "instruction-hooks", + "mod_instruction_sites = [\"0x80010000\", \"0x80010004\"]\n"); + const auto parsed = PSXRecompV4::load_game_config(path); + check(parsed.mod_instruction_sites.size() == 2, "instruction sites parse"); + auto changed = parsed; + changed.mod_instruction_sites.clear(); + check(PSXRecompV4::overlay_codegen_config_hash(parsed) != + PSXRecompV4::overlay_codegen_config_hash(changed), "instruction sites invalidate cached code"); + const auto invalid = write_config(root, "instruction-unaligned", + "mod_instruction_sites = [\"0x80010001\"]\n"); + check_throws([&] { PSXRecompV4::load_game_config(invalid); }, "aligned main RAM", + "instruction sites reject invalid addresses"); + PSXRecomp::CodeGenConfig config{}; + config.mod_instruction_sites = {0x80010000u, 0x80010004u}; + for (bool overlay : {false, true}) { + for (uint32_t word : {0x2402002Au, 0x10400002u}) { + const auto code = generate_first_instruction(word, {}, overlay, config); + check(code.find(fmt::format("psx_mod_instruction(cpu, 0x80010000u, 0x{:08X}u);", word)) != std::string::npos, + "native/overlay emits instruction guard for ALU and branch"); + check(code.find("psx_mod_instruction(cpu, 0x80010004u, 0x00000000u);") != std::string::npos, + "native/overlay includes sequential and branch delay-slot hooks"); + } + } + check(generate_first_instruction(0x2402002Au, {}, false).find("psx_mod_instruction(cpu,") == std::string::npos, + "unconfigured code emits no instruction callbacks"); +} + void cfg_fallthrough_reachability_test() { constexpr uint32_t base = 0x80010000u; PSXRecomp::PS1Executable exe{}; @@ -1209,6 +1250,8 @@ int main() { gte_codegen_classification_tests(); jump_table_producer_codegen_test(); cfg_codegen_load_delay_test(); + mod_function_completion_codegen_test(); + mod_instruction_codegen_test(root); cfg_fallthrough_reachability_test(); } catch (const std::exception& e) { fmt::print(stderr, "FAIL unexpected exception: {}\n", e.what()); diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c89221db..d54f5d5b 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -72,6 +72,14 @@ if(BUILD_TESTING) target_compile_options(ws_world_scene_test PRIVATE -O2 -flto -ffunction-sections -fdata-sections) target_link_options(ws_world_scene_test PRIVATE -flto -Wl,--gc-sections) add_test(NAME ws_world_scene_test COMMAND ws_world_scene_test) + add_executable(boot_state_handoff_test tests/test_boot_state_handoff.c) + target_include_directories(boot_state_handoff_test PRIVATE include src) + target_compile_definitions(boot_state_handoff_test PRIVATE PSX_COSIM=1) + target_compile_options(boot_state_handoff_test PRIVATE -O2 -flto -ffunction-sections -fdata-sections -UNDEBUG) + target_link_options(boot_state_handoff_test PRIVATE -flto -Wl,--gc-sections) + target_link_libraries(boot_state_handoff_test PRIVATE ZLIB::ZLIB) + add_test(NAME boot_state_handoff_test COMMAND boot_state_handoff_test) + add_executable(mod_memory_snapshot_test tests/test_mod_memory_snapshot.c src/psx_ram_geometry.c) target_include_directories(mod_memory_snapshot_test PRIVATE include src) @@ -95,6 +103,8 @@ if(BUILD_TESTING) set(PSX_GL_READBACK_COMPILER_BIN "${_gl_test_default_bin}" CACHE PATH "GCC/MinGW bin for GL fixture") find_package(Python3 QUIET COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + add_test(NAME counted_mips_relocations_test COMMAND "${Python3_EXECUTABLE}" + "${PSXRECOMP_ROOT}/tools/tests/test_counted_mips_relocations.py") add_test(NAME interp_mod_entry_guards_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_interp_mod_entry_guards.py") add_test(NAME mirror_pc_preserved_guards_test COMMAND "${Python3_EXECUTABLE}" @@ -151,6 +161,17 @@ if(BUILD_TESTING) else() message(STATUS "gl_scale_invariance_test: not registered (needs a non-Windows host, Python 3 and SDL3)") endif() + if(Python3_Interpreter_FOUND AND TARGET SDL3::SDL3-static) + add_test(NAME gl_texture_filter_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_texture_filter.py" + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_texture_filter.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "$" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-filter-results") + set_tests_properties(gl_texture_filter_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180) + endif() add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) target_include_directories(launcher_device_roundtrip_test PRIVATE include) @@ -706,6 +727,7 @@ if(BUILD_TESTING) add_executable(mod_runtime_test tests/test_mod_runtime.cpp + src/iso_reader_c.cpp src/mod_runtime.cpp src/mod_packages.cpp src/disc_path.cpp @@ -841,6 +863,27 @@ if(BUILD_TESTING) endif() add_test(NAME render_pass_plan_test COMMAND render_pass_plan_test) + # Motion kit and per-frame driver for render-pass plugins: blending, + # identity matching, limits and the crossfade fallback, on mock RAM. + add_executable(render_pass_motion_test + tests/test_render_pass_motion.c + src/render_pass_motion.c + src/render_pass_frame.c) + target_include_directories(render_pass_motion_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(render_pass_motion_test PRIVATE m) + endif() + add_test(NAME render_pass_motion_test COMMAND render_pass_motion_test) + add_executable(render_pass_projection_test + tests/test_render_pass_projection.cpp + src/render_pass_projection.cpp + src/render_pass_motion.c) + target_include_directories(render_pass_projection_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(render_pass_projection_test PRIVATE m) + endif() + add_test(NAME render_pass_projection_test COMMAND render_pass_projection_test) + # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test tests/test_render_pass_freeze.c diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 3ee203e1..27a94b44 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -42,9 +42,11 @@ extern "C" { * v5 = v4 + CD-ROM Sub-Q replacement state; * v6 = v5 + per-word GPU DMA2 linked-list progress; * v7 = v6 + pending XA DATA_END IRQ state. */ -#define BOOT_STATE_VERSION 8u -/* v8 adds allocated enhancement memory. Vanilla writers remain v7; older +#define BOOT_STATE_VERSION 9u +/* v8 added allocated enhancement memory (vanilla v8-era writers kept v7); older * runtimes must not accept enhanced snapshots while ignoring their arenas. */ +/* v9 persists the BIOS-to-game handoff latch. Engine overlays may have + * replaced the original entry code by the time a gameplay state is loaded. */ /* v7 intentionally breaks older savestates after the CD-ROM wire grew. Reject * them at the header before any section changes the live machine. */ #define BOOT_STATE_VERSION_MIN_READ 7u @@ -84,6 +86,7 @@ typedef struct { * never allowed) -> normal boot + recapture. */ enum { + BS_SEC_GAME_START = 0x12, /* u32 LE boolean: BIOS-to-game handoff completed */ BS_SEC_MODMEM = 0x11, /* allocated opt-in CPU/GPU enhancement arenas */ BS_SEC_CPU = 0x01, /* CPUState: gpr/pc/hi/lo/cop0/gte_data/gte_ctrl */ BS_SEC_RAM = 0x02, /* 2 MB main RAM */ diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index 98ad41dc..e9a9620f 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -42,6 +42,34 @@ extern "C" { * corruption, 2026-06-10). */ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr); +/* Replay [start_pc, stop_pc) of already-loaded guest code from the CPU state + * given: every PC in the range is interpreted from its live RAM bytes, even + * where a compiled body or a compiled resume point (the return address of a + * call) begins, so a span can start in the middle of a compiled function. + * Calls the span makes run on their normal backend and return into it. + * Returns 1 when control reaches stop_pc, 0 when it leaves the range any other + * way (a return, a jump out, a nested span) or runs past max_insns + * interpreted instructions. The range is half-open, CODE identity, one span + * at a time. A span is a replay, not a function entry: it fires no + * function-entry hooks at its own PCs and stays out of capture, alias seeding + * and per-PC entry statistics. Instruction hooks fire as in compiled code. + * Mods reach it through psx_mod_run_guest_span (render passes only). */ +int dirty_ram_run_span(CPUState* cpu, uint32_t start_pc, uint32_t stop_pc, + uint64_t max_insns); +/* Why the last span that failed stopped: 1 returned (pc 0), 2 left the range, + * 3 call-bail unwind, 4 instruction budget, 5 the interpreter declined the PC, + * 6 bad arguments or a span already open, 7 a call did not return to the + * span (`after`). `pc` is where control was. */ +typedef struct DirtyRamSpanFailure { + uint32_t reason, pc, start_pc, stop_pc; + uint32_t ra, after; /* $ra at the exit; pc after a call that did not return */ + uint64_t insns; +} DirtyRamSpanFailure; +void dirty_ram_span_last_failure(DirtyRamSpanFailure *out); +/* The open span (0,0 = none), for landings that longjmp past its exit. */ +void dirty_ram_span_get(uint32_t *lo, uint32_t *hi); +void dirty_ram_span_set(uint32_t lo, uint32_t hi); + /* Retire a deferred R3000A load-delay writeback (see dirty_ram_interp.c). The * interpreter defers a load's destination-register write past the delay-slot * instruction, as hardware does; call this anywhere control leaves the diff --git a/runtime/include/fntrace.h b/runtime/include/fntrace.h index 35867c05..2519d6f6 100644 --- a/runtime/include/fntrace.h +++ b/runtime/include/fntrace.h @@ -89,6 +89,9 @@ void fntrace_record(CPUState* cpu, uint32_t target); * First dispatch into [lo, hi) calls cdrom_notify_game_started(). */ void fntrace_set_game_range(uint32_t lo, uint32_t hi); int fntrace_is_game_started(void); +/* Snapshot restore only: the handoff side effects are already in the saved + * machine. Do not clear restored RAM or capture another boot snapshot. */ +void fntrace_restore_game_started(int started); /* One-shot game-start handoff. Idempotent — safe to call from any path * (compiled dispatch, dirty-RAM interpreter, generated entry function). * Performs dirty-image baseline clear, low-boot scratch clear, CD speed diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 3fb4a6f9..fa291473 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -66,6 +66,9 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, * rect at the same scale. Returns 0 when refused (nothing changed). */ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period_vblanks, int reuse_backup); +/* Generations opened from now on are for a frame already on screen (1) or + * for the next flip (0, default); render_pass_gen_flip_matches. */ +void gl_renderer_pass_set_flip_shown(int shown); /* Capture the drawn rect at alpha_q16 (keep) and roll the rect back. */ void gl_renderer_pass_end(uint32_t alpha_q16, int keep); uint32_t gl_renderer_pass_leaks(void); diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index efcda533..788301c4 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -525,6 +525,8 @@ bool mod_register_function_entry_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); bool mod_register_function_filter_plugin(const std::string& id, uint32_t address, PSXModFunctionFilterCallback callback); +bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, + PSXModFunctionEntryCallback callback); bool mod_plugin_registered(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); @@ -536,6 +538,14 @@ struct ModFunctionEntryHook { /* Hooks one implementation registered, in registration order. mod_runtime * flattens these into an address table when the plan's plugins activate. */ std::vector mod_function_entry_hooks(const std::string& id); +std::vector mod_guest_functions(const std::string& id); +struct ModInstructionHook { + uint32_t address, expected; + PSXModFunctionEntryCallback callback; +}; +bool mod_register_instruction_plugin(const std::string& id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback); +std::vector mod_instruction_hooks(const std::string& id); void mod_clear_plugins_for_tests(); } // namespace PSXRecompV4 diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index b8b6db76..8f8bad6e 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -27,6 +27,26 @@ int psx_mod_register_vblank_plugin(const char* id, PSXModVBlankCallback callback); int psx_mod_register_function_entry_plugin( const char* id, uint32_t address, PSXModFunctionEntryCallback callback); +/* Mod-defined guest functions have no original machine-code body. Addresses + * must be aligned and in physical 0x0F000000..0x0FFFFFFF (an unused bus range), + * never hardware/BIOS/game text. Only statically linked code can register one; + * the active package plan owns its availability and resource context. Address + * aliases share one globally unique registration. The callback supplies the + * full function behavior; return publishes $ra through normal dispatch. */ +int psx_mod_register_guest_function_plugin( + const char* id, uint32_t address, PSXModFunctionEntryCallback callback); +int psx_mod_dispatch_guest_function(struct CPUState* cpu, uint32_t address); +extern uint32_t g_psx_mod_guest_functions; +/* Run immediately before a configured instruction, including delay slots. + * The complete instruction word must match both registration and live RAM. + * Callbacks may update registers/data but cannot redirect PC, finish a guest + * function, or re-enter guest execution (pending load/branch state is live). + * Native emits opt in with [recompiler] mod_instruction_sites; dirty-RAM + * execution uses the same guarded table. Inactive plans are inert. */ +int psx_mod_register_instruction_plugin(const char* id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback); +void psx_mod_instruction(struct CPUState* cpu, uint32_t address, uint32_t instruction); +extern uint32_t g_psx_mod_instruction_hooks; /* Called from generated functions listed by the game config and from every * interpreted entry, so the hook contract does not depend on the backend. * Hooks match by code address (segment bits ignored) and run only for plugins @@ -34,9 +54,26 @@ int psx_mod_register_function_entry_plugin( int psx_mod_register_function_filter_plugin( const char* id, uint32_t address, PSXModFunctionFilterCallback callback); int psx_mod_function_entry(struct CPUState* cpu, uint32_t address); +/* Complete a guest function from its trusted entry callback after supplying + * its full result. Valid only for that callback's CPU. Publishes pc=$ra and + * prevents the original body from executing. Nested callbacks have separate + * completion scopes; requests outside an entry callback return zero. */ +int psx_mod_finish_function(struct CPUState* cpu); /* Active function-entry hook count (0 = none). Hot callers test it before the * call, so a run without an active hook pays one load per interpreted entry. */ extern uint32_t g_psx_mod_function_entry_hooks; + +/* Always-on named event counters for trusted plugins (observability, not + * logging): e.g. how often each guard in a hook rejected. `name` should be a + * string literal of the form "."; up to 128 distinct names are + * kept, further names are counted in an overflow bucket. Emulation-thread only. + * TCP: {"cmd":"mod_counters"} lists every counter with its last frame. */ +void psx_mod_counter_add(const char* name, uint32_t delta); +/* Presentation-only filtering: 0 nearest, 1 bilinear, 2 stable minification. + * Mode 2 uses a bounded palette-aware footprint for proven 3D polygons on + * OpenGL; untracked UI stays nearest. Other backends use bilinear. A session + * reset restores the player's configured filter. */ +void psx_mod_set_texture_filter(int mode); /* Entry callbacks can make nested guest calls while retaining host registers. * Save/load and rewind must wait until that host context has returned. */ int psx_mod_function_entry_active(void); @@ -227,6 +264,10 @@ int psx_mod_option_value(const char* package_id, const char* feature_id, */ int psx_mod_current_resource_path(const char* resource_id, char* out, uint32_t out_size); +/* Display aspects have no framework ceiling: the native-wide surfaces size + * themselves from the live width, and each title caps its own view at what + * it has validated (fixed ratio, or the adaptive maximum below). Requests + * must be at least native 4:3, with numerator and denominator in 1..99. */ /* * Request a fixed host display aspect before renderer/window initialization. @@ -372,6 +413,67 @@ enum { PSX_MOD_RENDER_PASS_BUSY = 6 }; uint32_t psx_mod_render_pass_status(void); + +/* + * When the game flips relative to the pass point. PENDING (default, reset at + * every session start): the pass rect is the one the next flip will show -- + * the PsyQ VSync(0)-then-PutDispEnv loop, whose frame N is drawn and waits. + * SHOWN: the game flips each frame as soon as it is drawn (for example + * PutDispEnv in a VBlank callback armed by the DrawSync callback), so by the + * time frame N+1's logic is done frame N is already on screen. The pass rect + * is then that on-screen rect, and frame N's own image and its in-between + * images are shown from the game's next flip on: one game frame later than + * the game shows them, as a host interpolator with one frame of history + * would. Set it from activation, before the first plan. + */ +enum { + PSX_MOD_RENDER_PASS_FLIP_PENDING = 0, + PSX_MOD_RENDER_PASS_FLIP_SHOWN = 1 +}; +int psx_mod_set_render_pass_flip(uint32_t mode); + +/* + * The per-frame plumbing every frame-rate plugin repeats (render_pass_frame.c). + * Blending the game state itself: render_pass_motion.h. + * + * Activation: read a choice option holding "display" (follow the measured + * display refresh) or a frame rate, and select the FLIP source, HOLD blend, + * `flip_mode` and that rate. Returns 0 if any setting was refused. + */ +int psx_mod_activate_render_pass_rate(const char* package, const char* feature, + const char* option, uint32_t flip_mode); +typedef struct PSXModRenderPassFrame { + uint32_t struct_size; /* sizeof(PSXModRenderPassFrame) */ + uint32_t period_vblanks; /* VBlanks this game frame stays on screen */ + uint32_t shown_after_vblanks; /* VBlank presents before it is shown */ + uint16_t x, y, w, h; /* VRAM rect the passes draw */ +} PSXModRenderPassFrame; +/* + * Plan this game frame's passes and run `fn` at each phase. While passes are + * unavailable for a lasting reason (BACKEND, DISABLED) the presenter is + * switched to a motion-adaptive crossfade, and back to HOLD once they return. + * Returns how many passes produced an image. + */ +uint32_t psx_mod_render_pass_frame(struct CPUState* cpu, + const PSXModRenderPassFrame* frame, + PSXModRenderPassFn fn, void* user); + +/* + * Replay part of an already-loaded guest function inside a render pass: + * run from start_pc with the CPU state given until control reaches stop_pc. + * Every PC in [start_pc, stop_pc) is interpreted from its RAM bytes, so the + * span may start anywhere in a compiled function, including just after one + * of its calls; calls the span makes run normally and return into it. + * A plugin uses this to redraw with the game's own frame code, branches and + * all, from registers it captured at start_pc during the real frame (an + * instruction hook), instead of re-implementing that code's call sequence. + * Returns 1 when stop_pc is reached; 0 when control leaves the range another + * way (a return or jump out, a nested span), after 1M interpreted + * instructions, or outside a pass. The pass restores the machine either way. + * Both PCs are 4-aligned, in one segment, start_pc < stop_pc. + */ +int psx_mod_run_guest_span(struct CPUState* cpu, uint32_t start_pc, + uint32_t stop_pc); int psx_mod_set_auto_skip_fmv(int enabled); /* * Draw still artwork behind the game image in OpenGL letterbox/pillarbox diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index 3ff8fb33..91fdb1d9 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -117,7 +117,8 @@ /* v26: guarded packed-coordinate rejection callback, preserving the selected * rejection axes while the native-wide renderer clips horizontal margins. */ /* v27: full-word-bound native-wide winding branch callback. */ -#define PSX_OVERLAY_ABI_VERSION 27 +/* v28: guarded instruction callbacks and scoped guest-function completion. */ +#define PSX_OVERLAY_ABI_VERSION 28 /* Process-lifetime overlay candidate capacity. Every accepted manifest F * record consumes one slot, even when another DLL carries an identical @@ -185,7 +186,7 @@ /* 13: merge adaptive backdrop v12: independent quotient consumers, proven * copied/biased camera inputs, and two biased bounds for terrain strips. */ /* 14: guarded saturation-aware NCLIP branch consumers and ABI v27 forwarding. */ -#define PSX_OVERLAY_CODEGEN_VER 14 +#define PSX_OVERLAY_CODEGEN_VER 15 /* Auto codegen hash (generated by hash_codegen.cmake from the recompiler codegen * sources). Folded into the cache PATH next to cg (gcc//cg_/) @@ -363,6 +364,12 @@ typedef struct OverlayCallbacks { uint32_t *last_store_pc; int (*ws_masked_reject)(uint32_t flags, uint32_t mask); int (*ws_nclip_branch)(uint32_t pc, uint32_t instr, int32_t mac0, int vanilla); + /* Frustum-edge bound scale helper (ABI v24). */ + int32_t (*ws_cull_scale)(int32_t bound, int32_t half_extent); + /* Exact-NCLIP branch sign (ABI v25). */ + int32_t (*nclip_exact_sign)(int32_t native_mac0, uint32_t pc); + /* Guarded pre-instruction mod callback (ABI v27). */ + void (*mod_instruction)(CPUState*, uint32_t, uint32_t); } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index a7fa4918..3ea2a7e0 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -256,6 +256,10 @@ int32_t psx_ws_player_x_bound(int32_t vanilla) { int32_t psx_ws_screen_x_bound(int32_t vanilla) { return g_cbs.ws_screen_x_bound ? g_cbs.ws_screen_x_bound(vanilla) : vanilla; } +void psx_mod_instruction(CPUState *cpu, uint32_t address, uint32_t word) { + overlay_flush_cycles(); + if (g_cbs.mod_instruction) g_cbs.mod_instruction(cpu, address, word); +} int psx_mod_function_entry(CPUState *cpu, uint32_t address) { overlay_flush_cycles(); return g_cbs.mod_function_entry ? g_cbs.mod_function_entry(cpu, address) : 0; diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index 86f46502..1eadd418 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -266,6 +266,12 @@ void pgxp_store_gte_reg(uint32_t addr, uint8_t reg); int pgxp_load_precise_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z); +/* Render-pass checkpoint: journal every shadow a sandboxed pass mutates and + * put the shadows back when the sandbox restores the machine. Use through + * gte_precision_checkpoint_begin / _rollback (which also cover gte.cpp). */ +void pgxp_checkpoint_begin(void); +void pgxp_checkpoint_rollback(void); + /* --- test accessors (always compiled; trivial) ---------------------------- */ /* index 0..3 selects the SXY0..SXYP register shadow (GTE data regs 12..15). */ diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index d1b9a84c..87736a2c 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -38,6 +38,8 @@ typedef struct RenderPassStats { uint64_t nesting_repairs; /* aborts whose skipped exits the restore undid */ uint64_t verify_checks; /* PSX_RENDER_PASS_VERIFY comparisons */ uint64_t verify_mismatch; /* ... that found a difference */ + uint64_t spans; /* psx_mod_run_guest_span calls that reached stop */ + uint64_t span_failures; /* ... that left the span or overran */ uint64_t dropped[RENDER_PASS_DROP_CLASSES]; double last_pass_ms; /* host time of the last pass (sandbox + draw) */ double avg_pass_ms; /* smoothed */ diff --git a/runtime/include/render_pass_motion.h b/runtime/include/render_pass_motion.h new file mode 100644 index 00000000..c28ddc3b --- /dev/null +++ b/runtime/include/render_pass_motion.h @@ -0,0 +1,123 @@ +#ifndef PSX_RENDER_PASS_MOTION_H +#define PSX_RENDER_PASS_MOTION_H +/* + * Motion kit for render-pass plugins (docs/RENDER_PASSES.md, "Blending game + * state"). A pass redraws the scene with the game's own code between two + * logic ticks; what makes the image an in-between one is the game state the + * draw code reads, placed part of the way from the previous tick to the + * current one. This kit owns that part for any title: + * + * per game frame, at the plugin's capture point + * psx_motion_begin(set, tick) the game's logic tick counter + * psx_motion_track(set, kind, addr, identity) for each value the draw + * code reads (object matrices, + * positions, angles, the camera) + * psx_motion_prepare(set, &limits, &stats) pair with the previous frame + * in each pass + * psx_motion_apply(set, alpha) write the blends into guest RAM + * psx_motion_blend_at(set, addr, ...) or blend one value a hook hands on + * + * Values are matched by address AND identity: game objects are heap blocks, + * and a freed address reused by another object must never blend between the + * two. A value that moved further than the limits allow in the ticks between + * the frames was placed (respawn, teleport, camera cut), not moved; it keeps + * its current value, as do values without history. Nothing is written + * outside psx_motion_apply, which a pass's sandbox rolls back. + * + * Guest RAM is read and written through psx_mod_read_* / psx_mod_write_*. + */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* What lives at a tracked address (guest RAM layout). */ +enum { + /* PsyQ MATRIX: 3x3 s16 rotation in 4.12 at +0x00..+0x11, s32 t[3] at + * +0x14. Rotation slerps when both rows are orthonormal, otherwise it + * blends elementwise; translation blends linearly. */ + PSX_MOTION_MATRIX = 1, + /* Only the translation of a MATRIX (+0x14..+0x1F): for matrices whose + * rotation the draw code rebuilds itself (billboards). */ + PSX_MOTION_MATRIX_TRANSLATION = 2, + PSX_MOTION_VECTOR = 3, /* s32 x, y, z */ + PSX_MOTION_SVECTOR = 4, /* s16 x, y, z */ + /* s16 x, y, z angles, 4096 per turn: each blends along the shorter arc. */ + PSX_MOTION_ANGLES = 5, + PSX_MOTION_SCALAR = 6, /* s32 */ + /* A bare 3x3 s16 rotation in 4.12 (18 bytes), blended like a MATRIX's; + * pair it with a VECTOR / SVECTOR kind wherever its translation lives + * (skeleton pose records, matrices stored apart from their position). */ + PSX_MOTION_ROTATION = 7 +}; + +typedef struct PSXMotionLimits { + /* Distance one value may move per logic tick and still blend + * (translation, VECTOR, SVECTOR, SCALAR, in the game's units). */ + double move_per_tick; + /* Rotation between the two frames beyond which a value snaps + * (MATRIX rotation, ANGLES). 0 means pi (any turn up to a half turn). */ + double turn_radians; +} PSXMotionLimits; + +typedef struct PSXMotionStats { + uint32_t tracked; /* values captured this frame */ + uint32_t blended; /* ... that will blend */ + uint32_t placed; /* ... that moved or turned past the limits */ + uint32_t unmatched; /* ... new, or a different identity at the address */ + uint32_t ticks; /* logic ticks between the two frames (0 = no history) */ +} PSXMotionStats; + +typedef struct PSXMotionMatrix { + int16_t r[9]; + int32_t t[3]; +} PSXMotionMatrix; + +typedef struct PSXMotionSet PSXMotionSet; + +PSXMotionSet* psx_motion_set_create(uint32_t capacity); +void psx_motion_set_destroy(PSXMotionSet* set); + +/* Start this frame's capture. `tick` is the game's logic tick counter at + * this frame; the previous capture becomes the history. */ +void psx_motion_begin(PSXMotionSet* set, uint32_t tick); +/* Record the value of `kind` at `addr` as the draw code will read it. + * `identity` names what lives there (psx_motion_identity of its type, + * handler, model...). One value per (addr, kind) per frame; a repeat + * replaces the earlier one. Returns 0 when the set is full. */ +int psx_motion_track(PSXMotionSet* set, uint32_t kind, uint32_t addr, + uint32_t identity); +/* Pair this capture with the previous one. Returns how many values will + * blend. History counts only when it is the capture just before this one + * with 1..8 ticks between them; otherwise nothing blends. */ +uint32_t psx_motion_prepare(PSXMotionSet* set, const PSXMotionLimits* limits, + PSXMotionStats* stats); +/* Write every prepared value at t (0 = previous frame, 1 = this frame). */ +void psx_motion_apply(const PSXMotionSet* set, double t); +/* Blend one prepared value at t into `out` without writing guest RAM: + * a PSXMotionMatrix for the MATRIX kinds and ROTATION (translation 0), int32_t[3] for VECTOR / SVECTOR / + * ANGLES, int32_t for SCALAR. Returns 0 when that value does not blend this + * frame (out then holds its current value) or was not tracked (out + * untouched, returns -1). */ +int psx_motion_blend_at(const PSXMotionSet* set, uint32_t kind, uint32_t addr, + double t, void* out); +/* Forget all history (scene change, load, a frame the plugin skipped). */ +void psx_motion_invalidate(PSXMotionSet* set); + +/* Mix up to three words into an identity tag. */ +uint32_t psx_motion_identity(uint32_t a, uint32_t b, uint32_t c); + +/* The math, for plugins that blend values the set does not hold. Returns 0 + * when the rotation turned past turn_radians (out = b). */ +int psx_motion_blend_matrix(const PSXMotionMatrix* a, const PSXMotionMatrix* b, + double t, double turn_radians, + PSXMotionMatrix* out); +void psx_motion_read_matrix(uint32_t addr, PSXMotionMatrix* out); +void psx_motion_write_matrix(uint32_t addr, const PSXMotionMatrix* m, + int rotation); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 294efee6..72b5ee5b 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -65,6 +65,18 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, uint32_t *lo, uint32_t *hi, float *t); +/* Is the flip the FLIP source just saw the one a pending generation waits + * for? A generation built for the next flip (`shown` 0, the canonical PsyQ + * VSync-then-PutDispEnv point) waits for the display to flip to its own rect. + * One built for a frame already on screen (`shown` 1: the game flips each + * frame as soon as it is drawn) waits for the next flip, to any other rect; + * its images then show one game frame late. Either way the presented + * geometry must still be the one the generation was captured at. */ +int render_pass_gen_flip_matches(int shown, int gen_x, int gen_y, + int gen_source, int gen_w, int gen_h, + int flip_x, int flip_y, int flip_source, + int flip_w, int flip_h); + /* Exponential moving average used for the per-pass host cost. */ double render_pass_ema(double current, double sample); @@ -149,7 +161,9 @@ int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr, /* VRAM journal for a pass's writes outside its display rect * (gpu_gl_renderer.c pass_refuse_write): the CPU-side policy and the CPU VRAM * rows. The renderer keeps the matching GPU copies per entry. */ -#define RENDER_PASS_JOURNAL_MAX 16 +/* Jersey Devil streams more than 16 small texture/CLUT rectangles in a draw. + * Keep the transaction bounded, but allow the complete set to roll back. */ +#define RENDER_PASS_JOURNAL_MAX 64 enum { RENDER_PASS_VRAM_ALLOW = 0, /* inside the rect, empty, or already journaled */ RENDER_PASS_VRAM_JOURNAL = 1, /* outside: back up, then allow */ diff --git a/runtime/include/render_pass_projection.h b/runtime/include/render_pass_projection.h new file mode 100644 index 00000000..cd8189a2 --- /dev/null +++ b/runtime/include/render_pass_projection.h @@ -0,0 +1,32 @@ +#pragma once +#include +#ifdef __cplusplus +extern "C" { +#endif +struct CPUState; +typedef struct PSXProjectionHistory PSXProjectionHistory; +typedef struct PSXProjectionStats { + uint32_t captured, matched, changed, unmatched, discontinuities, overflow, replayed; +} PSXProjectionStats; +/* Capture the final model/view transforms that the game actually projects. + * Replay uses the same guest draw code, with interpolated transforms only for + * identified vertices. Unmatched vertices and camera cuts keep current state. + * A history belongs to one game drawing section; emulation thread only. */ +PSXProjectionHistory* psx_projection_create(uint32_t capacity); +void psx_projection_destroy(PSXProjectionHistory* history); +void psx_projection_invalidate(PSXProjectionHistory* history); +void psx_projection_reset_session(void); +void psx_projection_capture_begin(PSXProjectionHistory* history); +void psx_projection_capture_end(PSXProjectionHistory* history, uint32_t ticks, + double max_translation_per_tick); +void psx_projection_replay_begin(PSXProjectionHistory* history, uint32_t alpha_q16); +void psx_projection_replay_end(PSXProjectionHistory* history); +void psx_projection_stats(const PSXProjectionHistory* history, PSXProjectionStats* out); +/* GTE bridge: before RTPS/RTPT imports its registers. Returns nonzero only + * when a render pass temporarily replaced rotation/translation. The bridge + * restores those eight control registers after exporting command results. */ +int psx_projection_command(struct CPUState* cpu, uint32_t command); +extern int (*g_psx_projection_command)(struct CPUState*, uint32_t); +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/render_pass_replay.hpp b/runtime/include/render_pass_replay.hpp new file mode 100644 index 00000000..fa3dff83 --- /dev/null +++ b/runtime/include/render_pass_replay.hpp @@ -0,0 +1,84 @@ +#pragma once +#include "cpu_state.h" +#include "mod_plugins.h" +#include "psx_cycle_freeze.h" +#include "render_pass_projection.h" +#include "pgxp.h" +#include +#include + +extern "C" uint8_t* memory_get_ram_ptr(void); +extern "C" uint8_t* memory_get_scratchpad_ptr(void); +extern "C" uint32_t memory_get_ram_bytes(void); + +/* A draw section may calculate temporary matrices, consume random seeds or + * update effect caches. Start every replay from the original section's RAM, + * rather than applying those calculations twice to the completed frame. Only + * restore inside the framework's machine/VRAM sandbox. */ +class PSXDrawReplay { + CPUState entry{}; + std::vector ram; + uint8_t scratch[1024]{}; + uint32_t start = 0, stop = 0, previous_tick = 0; + bool captured = false, ready = false, have_tick = false; + PSXProjectionHistory* history = nullptr; +public: + PSXProjectionStats stats{}; + uint32_t ticks = 0; + ~PSXDrawReplay() { psx_projection_destroy(history); } + void invalidate() { + captured = ready = have_tick = false; ticks = 0; + psx_projection_invalidate(history); + } + void capture(CPUState* cpu, uint32_t first, uint32_t last) { + if (g_psx_render_pass_active) return; + if (captured) invalidate(); // previous drawing section never completed + if (!history) history = psx_projection_create(65536); + if (!history || first >= last || ((first | last) & 3)) return; + entry = *cpu; start = first; stop = last; + ram.resize(memory_get_ram_bytes()); + std::memcpy(ram.data(), memory_get_ram_ptr(), ram.size()); + std::memcpy(scratch, memory_get_scratchpad_ptr(), sizeof scratch); + captured = true; ready = false; + psx_projection_capture_begin(history); + } + bool prepare(uint32_t tick) { + if (!captured) { invalidate(); return false; } + captured = false; + ticks = have_tick ? tick - previous_tick : 0; + previous_tick = tick; have_tick = true; + psx_projection_capture_end(history, ticks, 512.0); + psx_projection_stats(history, &stats); + ready = ticks && ticks <= 8 && stats.changed && !stats.overflow; + return ready; + } + bool restore(CPUState* cpu, uint32_t alpha) { + if (!ready || !g_psx_render_pass_active || ram.size() != memory_get_ram_bytes()) return false; + std::memcpy(memory_get_ram_ptr(), ram.data(), ram.size()); + std::memcpy(memory_get_scratchpad_ptr(), scratch, sizeof scratch); + // Raw RAM is now from section entry. Its current-frame precision + // shadows are stale; the pass checkpoint restores them afterwards. + pgxp_invalidate_all(); + *cpu = entry; + psx_projection_replay_begin(history, alpha); + return true; + } + bool draw(CPUState* cpu) { + const bool reached = psx_mod_run_guest_span(cpu, start, stop) != 0; + psx_projection_replay_end(history); + psx_projection_stats(history, &stats); + return reached && stats.replayed != 0; + } + static uint32_t call(CPUState* cpu, uint32_t function, uint32_t a0=0, uint32_t a1=0) { + const CPUState saved = *cpu; + cpu->pc = 0; cpu->gpr[4] = a0; cpu->gpr[5] = a1; + psx_dispatch_call(cpu, function, saved.gpr[31]); + const uint32_t result = cpu->gpr[2]; *cpu = saved; return result; + } + static void rate(uint32_t fps, uint32_t flip) { + psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP); + psx_mod_set_render_pass_flip(flip); + psx_mod_set_frame_interpolation_blend(PSX_MOD_FRAME_INTERPOLATION_HOLD); + psx_mod_set_frame_interpolation(fps); + } +}; diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index f2beb82b..6df1d524 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -362,6 +362,9 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/frame_interpolation.c ${PSXRECOMP_ROOT}/runtime/src/render_pass.c ${PSXRECOMP_ROOT}/runtime/src/render_pass_plan.c + ${PSXRECOMP_ROOT}/runtime/src/render_pass_motion.c + ${PSXRECOMP_ROOT}/runtime/src/render_pass_projection.cpp + ${PSXRECOMP_ROOT}/runtime/src/render_pass_frame.c ${PSXRECOMP_ROOT}/runtime/src/host_time.c ${PSXRECOMP_ROOT}/runtime/src/psx_fiber.c ${PSXRECOMP_ROOT}/runtime/src/sio.c diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 8c263fc8..c48e929e 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -1,4 +1,5 @@ #include "boot_state.h" +#include "fntrace.h" #include "mod_memory.h" #include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ #include "dirty_ram_interp.h" @@ -383,14 +384,14 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, int ok; memset(&h, 0, sizeof h); h.magic = BOOT_STATE_MAGIC; - h.version = psx_mod_memory_snapshot_bytes() ? BOOT_STATE_VERSION : 7u; + h.version = BOOT_STATE_VERSION; h.reserved = boot_state_layout_cookie(); h.bios_checksum = bios_checksum; h.entry_pc = entry_pc; h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; - h.section_count = 16 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u); + h.section_count = 17 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u); ok = write_header_le(o, &h); @@ -481,6 +482,13 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, free(db); } } + if (ok) { + uint8_t handoff[4]; + PstW w; + pst_w_init(&w, handoff, sizeof handoff); + ok = pst_w_u32(&w, fntrace_is_game_started() ? 1u : 0u) && + write_section(o, BS_SEC_GAME_START, handoff, sizeof handoff); + } return ok; } @@ -693,6 +701,9 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, } case BS_SEC_MODMEM: return psx_mod_memory_snapshot_read(p, len); + case BS_SEC_GAME_START: + fntrace_restore_game_started(p[0]); + return 1; case BS_SEC_ICACHE: { PstR r; if (len != 1024u * 4u) return 0; @@ -749,6 +760,8 @@ static int validate_section(uint32_t tag, const uint8_t* p, uint32_t len) { case BS_SEC_DIRTY: return len % 4u == 0u && len / 4u <= dirty_ram_get_bitmap_word_count(); case BS_SEC_MODMEM: return psx_mod_memory_snapshot_validate(p, len); + case BS_SEC_GAME_START: + return len == 4u && p[0] <= 1u && !p[1] && !p[2] && !p[3]; case BS_SEC_ICACHE: return len == 1024u * 4u; default: return 1; } @@ -970,10 +983,15 @@ int boot_state_load_buffer(const uint8_t* file, size_t file_len, } s->tag = tag; if (!validate_section(tag, s->p, s->len)) { ok = 0; break; } + if (tag == BS_SEC_GAME_START && (seen & (1u << tag))) { + ok = 0; break; + } if (tag < 32) seen |= (1u << tag); } if (ok && (seen & required) != required) ok = 0; + if (ok && h.version >= 9u && !(seen & (1u << BS_SEC_GAME_START))) + ok = 0; /* PASS 2 -- apply. Every section passed validate_section, whose checks * are exactly the preconditions apply_section can fail on, so this pass diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index dffcc360..01b3af95 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7833,6 +7833,45 @@ static void handle_input_route_status(int id, const char *json) * {"cmd":"ws_hud_mode","tag_rects":0|1} * tag_rects=1 lets TAGGED rect-family prims re-anchor too (Tomba's AP * counter renders through the tagged sprite funnel). */ +/* Named plugin counters (psx_mod_counter_add), always on: + * {"cmd":"mod_counters"} + * -> counters: [{name, count, last_frame}], overflow. */ +static void handle_mod_counters(int id, const char *json) +{ + extern int psx_mod_counters_snapshot(const char **names, uint64_t *counts, + uint64_t *last_frames, int cap, + uint64_t *overflow); + (void)json; + enum { CAP = 128 }; + const char *names[CAP]; + uint64_t counts[CAP], last[CAP], overflow = 0; + int n = psx_mod_counters_snapshot(names, counts, last, CAP, &overflow); + if (n > CAP) n = CAP; + size_t cap = 256u + (size_t)n * 160u; + char *buf = (char *)malloc(cap); + if (!buf) { send_err(id, "alloc failed"); return; } + size_t off = 0; + off += (size_t)snprintf(buf + off, cap - off, + "{\"id\":%d,\"ok\":true,\"frame\":%llu,\"counters\":[", + id, (unsigned long long)s_frame_count); + for (int i = 0; i < n && off < cap; i++) { + char esc[96]; + size_t e = 0; + for (const char *p = names[i]; *p && e + 2 < sizeof esc; p++) + if (*p != '"' && *p != '\\' && (unsigned char)*p >= 0x20) esc[e++] = *p; + esc[e] = '\0'; + off += (size_t)snprintf(buf + off, cap - off, + "%s{\"name\":\"%s\",\"count\":%llu,\"last_frame\":%llu}", + i ? "," : "", esc, (unsigned long long)counts[i], + (unsigned long long)last[i]); + } + if (off < cap) + snprintf(buf + off, cap - off, "],\"overflow\":%llu}", + (unsigned long long)overflow); + debug_server_send_line(buf); + free(buf); +} + static void handle_ws_hud_mode(int id, const char *json) { int v = json_get_int(json, "tag_rects", -1); @@ -7991,7 +8030,9 @@ static void handle_render_pass_stats(int id, const char *json) RenderPassStats st; uint64_t gd[10], image_bytes = 0; uint32_t image_textures; + DirtyRamSpanFailure sf; render_pass_get_stats(&st); + dirty_ram_span_last_failure(&sf); gl_renderer_pass_diag(gd); image_textures = gl_renderer_pass_image_textures(&image_bytes); send_fmt("{\"id\":%d,\"ok\":true,\"plans\":%llu,\"planned\":%llu," @@ -8011,7 +8052,10 @@ static void handle_render_pass_stats(int id, const char *json) "\"cost_us\":%llu,\"cost_rewarms\":%llu,\"frame_images\":%llu," "\"journaled\":%llu," "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u," - "\"backups_reused\":%llu}", + "\"backups_reused\":%llu,\"spans\":%llu,\"span_failures\":%llu," + "\"span_fail\":{\"reason\":%u,\"pc\":\"0x%08X\",\"start\":\"0x%08X\"," + "\"stop\":\"0x%08X\",\"ra\":\"0x%08X\",\"after\":\"0x%08X\"," + "\"insns\":%llu}}", id, (unsigned long long)st.plans, (unsigned long long)st.planned, (unsigned long long)st.wanted, (unsigned long long)st.refused, (unsigned long long)st.passes, (unsigned long long)st.aborted, @@ -8037,7 +8081,11 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gl_renderer_pass_journaled(), (unsigned)image_textures, (unsigned long long)image_bytes, (unsigned)psx_mod_render_pass_status(), - (unsigned long long)gl_renderer_pass_backups_reused()); + (unsigned long long)gl_renderer_pass_backups_reused(), + (unsigned long long)st.spans, (unsigned long long)st.span_failures, + (unsigned)sf.reason, (unsigned)sf.pc, (unsigned)sf.start_pc, + (unsigned)sf.stop_pc, (unsigned)sf.ra, (unsigned)sf.after, + (unsigned long long)sf.insns); } /* render_pass_refuse on=<0|1>: make the OpenGL backend decline render passes @@ -9133,7 +9181,7 @@ static void handle_video_info(int id, const char *json) gpu_get_display_info(&di); int eff = gr_scale(); send_fmt("{\"id\":%d,\"ok\":true,\"backend\":\"%s\",\"preset\":%d," - "\"reference_lines\":%d,\"requested_scale\":%d,\"effective_scale\":%d," + "\"reference_lines\":%d,\"requested_scale\":%d,\"effective_scale\":%d,\"texture_filter\":%d," "\"internal_lines\":%u,\"gl\":%d,\"gl_max_dim\":%d,\"gl_max_scale\":%d," "\"gl_clamp_reason\":%d,\"gl_alloc_retries\":%d,\"gl_budget_mib\":%d," "\"fbo_w\":%d,\"fbo_h\":%d,\"hidpi_window\":%d,\"window_w\":%d," @@ -9145,7 +9193,7 @@ static void handle_video_info(int id, const char *json) "\"hires_window_mib\":%d}", id, gr_backend() == GR_BACKEND_OPENGL ? "opengl" : gr_backend() == GR_BACKEND_VULKAN ? "vulkan" : "software", - preset, ref, req, eff, di.height * (unsigned)(eff > 0 ? eff : 1), gl, + preset, ref, req, eff, gr_texture_filter(), di.height * (unsigned)(eff > 0 ? eff : 1), gl, si.max_dim, si.max_scale, si.clamp_reason, si.alloc_retries, si.budget_mib, si.fbo_w, si.fbo_h, hidpi, ww, wh, pw, ph, di.display_x, di.display_y, di.width, di.height, @@ -14147,6 +14195,7 @@ static const CmdEntry s_commands[] = { { "ws_aspect_cone_site", handle_ws_aspect_cone_site }, { "ws_margin", handle_ws_margin }, { "ws_hud_mode", handle_ws_hud_mode }, + { "mod_counters", handle_mod_counters }, { "kernel_bless", handle_kernel_bless }, { "ws_aspect", handle_ws_aspect }, { "display_aspect", handle_display_aspect }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index be4d3cfc..e43373d9 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -526,6 +526,17 @@ static int interp_refused_game_text(uint32_t pc) { #endif } +/* Guest span replay (dirty_ram_run_span, psx_mod_run_guest_span): while a span + * is open, every PC in [s_span_lo, s_span_hi) (CODE identity) is interpreted + * from the live RAM bytes, even where a compiled body or one of its resume + * points starts there. Calls the span makes leave the range and run on their + * normal backend. A span is a replay, not an entry: it stays out of capture, + * seeding and entry statistics. */ +static uint32_t s_span_lo, s_span_hi; +static inline int span_forced(uint32_t phys) { + return phys >= s_span_lo && phys < s_span_hi; +} + /* Forward decls from memory.c — used to read instruction bytes. */ extern uint8_t *memory_get_ram_ptr(void); extern void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); @@ -1510,6 +1521,10 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, } } + extern uint32_t g_psx_mod_instruction_hooks; + extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t); + if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn); + /* op 0x20..0x26 = LB/LH/LWL/LW/LBU/LHU/LWR. LWC2 (GTE, 0x32) targets a COP2 * register, not a GPR, so it needs no deferral here. */ const uint32_t ld_op = op_field(insn); @@ -1751,7 +1766,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #endif uint32_t _cr = callret_begin(cpu, pc, target); /* call-resolution ring */ #define CRET(code, rv) do { callret_end(_cr, cpu, (code)); return (rv); } while (0) - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; CRET(CRES_PLAIN, 1); } /* slice / lockstep-replay: plain transfer, never execute the callee */ + /* slice / lockstep-replay / guest span: plain transfer, never execute the + * callee (a span's runner calls it with the full trampoline). */ + if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; CRET(CRES_PLAIN, 1); } if (rd != 31) { /* No architectural $ra contract: preserve the transfer as a * pc-chain and let the callee's eventual JR choose the real @@ -1941,7 +1958,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, return 0; } default: - return abort_unsupported(pc, insn, "SPECIAL funct"); + /* R3000A ignores undefined SPECIAL encodings; psx_interpreter.c + * already models this, so the dirty interpreter must agree. */ + return 0; } break; @@ -1969,7 +1988,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #else #define XRES(code) do { (void)(code); } while (0) #endif - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; return 1; } /* slice / lockstep-replay: plain transfer, never execute the callee */ + /* slice / lockstep-replay / guest span: plain transfer, never execute the + * callee (a span's runner calls it with the full trampoline). */ + if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; return 1; } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { @@ -2459,6 +2480,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr); + /* Public entry point. Caller (psx_dispatch) has translated `addr` to a * KSEG-stripped form already in some cases, so accept any address and * mask. Returns 1 if interpretation handled the basic block; 0 if the @@ -2598,6 +2620,74 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { return r; } +void dirty_ram_span_get(uint32_t *lo, uint32_t *hi) { + if (lo) *lo = s_span_lo; + if (hi) *hi = s_span_hi; +} + +void dirty_ram_span_set(uint32_t lo, uint32_t hi) { + s_span_lo = lo; + s_span_hi = hi; +} + +static DirtyRamSpanFailure s_span_failure; + +void dirty_ram_span_last_failure(DirtyRamSpanFailure *out) { + if (out) *out = s_span_failure; +} + +int dirty_ram_run_span(CPUState* cpu, uint32_t start_pc, uint32_t stop_pc, + uint64_t max_insns) { + const uint32_t lo = start_pc & 0x1FFFFFFFu, hi = stop_pc & 0x1FFFFFFFu; + DirtyRamSpanFailure f = {0u, start_pc, start_pc, stop_pc, 0u, 0u, 0u}; + if (!cpu || s_span_hi || ((start_pc | stop_pc) & 3u) || lo >= hi || + (start_pc ^ stop_pc) & 0xE0000000u) { + f.reason = 6u; + s_span_failure = f; + return 0; + } + const uint64_t first = g_dirty_ram_insns_run; + s_span_lo = lo; + s_span_hi = hi; + cpu->pc = start_pc; + for (;;) { + const uint32_t pc = cpu->pc; + f.pc = pc; + f.ra = cpu->gpr[31]; + if (pc == stop_pc) break; + if (!pc) { f.reason = 1u; break; } + if (g_psx_call_bail) { f.reason = 3u; break; } + if (!span_forced(pc & 0x1FFFFFFFu)) { + /* The interpreter hands a call out of clean code back as a tail + * transfer (pc = callee, $ra = the return address). A call made + * by the span returns into it: run it as the caller would. A + * transfer out that is not a call ends the span. */ + const uint32_t ra = cpu->gpr[31]; + const uint32_t site = ra - 8u; + /* The span's last instruction may be a call: it returns to stop. */ + const uint32_t w = span_forced(site & 0x1FFFFFFFu) && + (span_forced(ra & 0x1FFFFFFFu) || ra == stop_pc) + ? fetch_word(site & 0x1FFFFFFFu) : 0u; + const int is_call = (w >> 26) == 0x03u || /* jal */ + ((w >> 26) == 0u && (w & 0x3Fu) == 0x09u); /* jalr */ + if (!is_call) { f.reason = 2u; break; } + cpu->pc = 0; + psx_dispatch_call(cpu, pc, ra); + if (g_psx_call_bail) { f.reason = 3u; break; } + if (cpu->pc != 0u && cpu->pc != ra) { f.after = cpu->pc; f.reason = 7u; break; } + cpu->pc = ra; + continue; + } + if (g_dirty_ram_insns_run - first > max_insns) { f.reason = 4u; break; } + if (!dirty_ram_dispatch(cpu, pc, stop_pc)) { f.reason = 5u; break; } + } + s_span_lo = s_span_hi = 0; + dirty_ram_ld_delay_flush(cpu); + f.insns = g_dirty_ram_insns_run - first; + if (f.reason) s_span_failure = f; + return f.reason == 0u; +} + /* ===== Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md) ===== */ /* Is a hardware interrupt deliverable to the guest at this exact instruction @@ -2865,7 +2955,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * They differ only for a PC in a retail RAM mirror. */ uint32_t phys = addr & 0x1FFFFFFFu; const uint32_t ram_phys = psx_ram_map_read(phys); - int clean_game_text_miss = 0; + const int forced = span_forced(phys); + int clean_game_text_miss = forced; if (addr == 0x80000048u) { g_sentinel_reach_dirty++; @@ -2893,7 +2984,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ /* Run the statically-compiled game function only while the target is still * native-safe. Dirty overlay pages and pages whose text bytes diverged from * the original EXE image fall through to interpret the live RAM bytes. */ - if (psx_game_text_native_ok(addr)) { + if (!forced && psx_game_text_native_ok(addr)) { g_mixed_depth++; { ls_func_enter(addr, cpu); @@ -2906,7 +2997,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ if (_gc) return 1; } clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0; - } else if (psx_game_address_in_text(addr)) { + } else if (!forced && psx_game_address_in_text(addr)) { /* RAM at a game-text address diverged from the static EXE image * (runtime-relocated / overlaid / self-modified code the compiled * static function no longer reflects). The live RAM is the truth: @@ -2924,7 +3015,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * another segment misses above and is interpreted here like any clean * text miss, never run through the other segment's body. Record it with * the full PC; the fix is a segment-qualified seed and regeneration. */ - if (clean_game_text_miss) + if (clean_game_text_miss && !forced) (void)psx_segment_miss_note(addr, psx_game_is_function_entry, cpu->gpr[31], cpu->gpr[29], (uint32_t)s_frame_count); @@ -2937,7 +3028,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); int previous_phase = g_exec_phase; g_exec_phase = 3; - int handled = psx_overlay_dispatch(cpu, addr); + int handled = !forced && psx_overlay_dispatch(cpu, addr); g_exec_phase = previous_phase; if (handled) return 1; } @@ -2954,7 +3045,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ extern void overlay_regs_snap(uint32_t out[34], const CPUState *cpu); extern void overlay_fp_log(uint32_t addr, const uint32_t *in_regs, const CPUState *cpu, int native); - int _ovfp = overlay_fp_enabled() && + int _ovfp = !forced && overlay_fp_enabled() && overlay_cache_window_contains(phys) && overlay_loader_is_candidate(phys); uint32_t _in_regs[34]; @@ -2969,7 +3060,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); - if (overlay_loader_dispatch(cpu, addr)) { + if (!forced && overlay_loader_dispatch(cpu, addr)) { if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); return 1; } @@ -3004,7 +3095,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * counts dispatches the interpreter actually handles inside a capture * window. The autocapture tick reads-and-resets this to decide whether * an unseen region variant is being interp-executed right now. */ - if (overlay_cache_window_contains(phys)) g_dirty_window_dispatches++; + if (!forced && overlay_cache_window_contains(phys)) g_dirty_window_dispatches++; /* Reset soft-fail state at block entry. */ g_unsupported_seen = 0; @@ -3022,13 +3113,14 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern uint32_t g_psx_mod_function_entry_hooks; extern int psx_mod_function_entry(CPUState *cpu, uint32_t address); - if (g_psx_mod_function_entry_hooks && psx_mod_function_entry(cpu, addr)) return 1; + if (!forced && g_psx_mod_function_entry_hooks && psx_mod_function_entry(cpu, addr)) + return 1; } /* Per-PC entry counter (visible via dirty_ram_stats). */ - DirtyRamPcEntry *pc_entry = pc_table_get_or_insert(phys); + DirtyRamPcEntry *pc_entry = forced ? NULL : pc_table_get_or_insert(phys); if (pc_entry) pc_entry->hits++; - { + if (!forced) { uint32_t word = phys >> 2; g_dirty_ram_dispatch_pc_bitmap[word >> 5] |= 1u << (word & 31u); /* ...and the segment it entered through, from the full PC diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c index b767ee6e..6b08fcd7 100644 --- a/runtime/src/fntrace.c +++ b/runtime/src/fntrace.c @@ -46,6 +46,7 @@ void fntrace_set_game_range(uint32_t lo, uint32_t hi) { } int fntrace_is_game_started(void) { return s_game_started; } +void fntrace_restore_game_started(int started) { s_game_started = started != 0; } /* Centralised game-start transition. Idempotent — safe to call from both * the dispatcher (fntrace_record) and the generated entry-point function. diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index fd313da7..c1dc3932 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -445,7 +445,7 @@ void gpu_ws_set_native_scene_predicate(int (*predicate)(void)) { int gpu_ws_present_native_43(void) { if (!ws_engaged()) return 0; - if (ws_mode == 2 && s_ws_native_scene_predicate && s_ws_native_scene_predicate()) + if (s_ws_native_scene_predicate && s_ws_native_scene_predicate()) return 1; int game_mode = ws_game_mode(); if (!game_mode) ws_scene_latch.confirmed = 0; @@ -5679,6 +5679,7 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { uint32_t addr = psx_mod_gpu_dma_resolve_address(start_addr); uint32_t safety = 0; uint16_t rank = 0xFFFFu; + uint16_t max_rank = 0xFFFFu; const uint32_t max_nodes = 0x40000u; int area_left = (int)draw_area_left, area_right = (int)draw_area_right; GpuDisplayInfo di; @@ -5727,6 +5728,13 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { /* CPU->VRAM data follows its 3-word header and is not a command * stream. Such transfers are not UI draws; stop this node. */ if (op >= 0xA0u && op <= 0xBFu) break; + /* Select the frontmost populated drawing layer, including + * full-screen fades and non-rectangular world primitives. + * Choosing only among UI-shaped candidates promotes a deeper + * wall quad to HUD when the real front layer has no widgets. */ + if (op >= 0x20u && op <= 0x7Fu && rank != 0xFFFFu && + (max_rank == 0xFFFFu || rank > max_rank)) + max_rank = rank; uint32_t words[12] = {0}; for (int i = 0; i < count && i < 12; i++) { words[i] = psx_read_word( @@ -5752,14 +5760,13 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { return; } - /* Empty ordering-table buckets can trail the actual frontmost layer. - * Selecting the last empty bucket made the memory-card glyph layer (rank - * 4095 followed by an empty rank 4096) disappear from the correction - * pass. Pick the highest rank that contains an eligible UI primitive. */ - uint16_t max_rank = ws_ui_prepass[0].ot_rank; - for (uint32_t i = 1; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].ot_rank > max_rank) - max_rank = ws_ui_prepass[i].ot_rank; + /* Empty trailing OT buckets do not pick the layer. A populated front + * layer without eligible widgets also must not fall back to a deeper + * UI-shaped world surface (Ape Escape's intro walls). */ + if (max_rank == 0xFFFFu) { + ws_ui_reject.rank = ws_ui_prepass_count; + ws_ui_prepass_count = 0; + return; } ws_ui_prepass_rank = max_rank; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 1a01ce2c..0ed2d939 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -1328,7 +1328,7 @@ static const char *TEX_FS = "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 int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 1 = bilinear */\n" + "uniform int u_filter; /* 0 nearest, 1 bilinear, 2 stable */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" " ivec2 p = ivec2(x & 1023, y & 511);\n" @@ -1364,17 +1364,46 @@ static const char *TEX_FS = "vec3 col5(int raw){\n" " return vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31)) / 31.0;\n" "}\n" + "int stable_texel(ivec2 p){\n" + " /* Clamp before wrap, so a footprint left of u=0 cannot pick up u=255. */\n" + " if ((u_twin.x | u_twin.y) == 0) p=clamp(p,v_limits.xy,v_limits.zw);\n" + " return fetch_texel(p.x,p.y);\n" + "}\n" + "vec4 stable_bilinear(vec2 uv,int stp){\n" + " vec2 p=uv-vec2(0.5), f=fract(p); ivec2 b=ivec2(floor(p));\n" + " vec4 sum=vec4(0.0);\n" + " for(int y=0;y<2;++y) for(int x=0;x<2;++x){\n" + " int raw=stable_texel(b+ivec2(x,y));\n" + " float w=(x==0 ? 1.0-f.x : f.x)*(y==0 ? 1.0-f.y : f.y);\n" + " if(raw!=0 && ((raw>>15)&1)==stp) sum+=vec4(col5(raw),1.0)*w;\n" + " }\n" + " return sum;\n" + "}\n" "void main(){\n" " int stp; vec3 rgb;\n" " /* v_persp is 0 for every prim unless [video] perspective_texturing is on\n" " * AND this prim's packet carried full GTE projection provenance, so the\n" " * default is the PS1's affine (noperspective) mapping. */\n" " vec2 uv = (v_persp != 0) ? v_uv_p : v_uv;\n" + " vec2 dx=dFdx(uv), dy=dFdy(uv);\n" " if (u_filter == 0) {\n" " int raw = fetch_texel(int(floor(uv.x)), int(floor(uv.y)));\n" " if (raw == 0) discard;\n" " rgb = col5(raw);\n" " stp = (raw >> 15) & 1;\n" + " } else if (u_filter == 2) {\n" + " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" + " if(raw==0) discard; stp=(raw>>15)&1;\n" + " float lx=length(dx), ly=length(dy);\n" + " dx*=min(1.0,8.0/max(lx,0.001)); dy*=min(1.0,8.0/max(ly,0.001));\n" + " int nx=clamp(int(ceil(lx)),1,4), ny=clamp(int(ceil(ly)),1,4);\n" + " vec4 sum=vec4(0.0);\n" + " for(int y=0;y0.00001 ? sum.rgb/sum.a : col5(raw);\n" " } else {\n" " /* Bilinear, Beetle-PSX formulation: the NEAREST texel is the base\n" " * (cutout + STP authority), the neighbours lie toward the sub-texel\n" @@ -3256,6 +3285,9 @@ static void gpu_textured_triangle(const int *xs, const int *ys, const float *col, uint16_t texpage, uint16_t clut_x, uint16_t clut_y, int rawtex, int semi, const int *lim) { + // Mode 2 needs proven world geometry. Sprites, HUD and untracked packets + // retain point sampling; their cutout pixels must stay sharp. + const int filter=s_tex_filter==2 && (lim || (!s_pc_valid && !s_pq_valid)) ? 0 : s_tex_filter; int lim_buf[4]; int uv_buf[6]; if (!lim) { @@ -3323,7 +3355,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, else if (isolate) reason = 0; else if (batch_semi != s_tb_semi) reason = 1; else if (s_mask_set != s_tb_mask) reason = 2; - else if (s_tex_filter != s_tb_filter) reason = 3; + else if (filter != s_tb_filter) reason = 3; else if (gate != s_tb_gate) reason = 4; else if ((!s_twin_batching || s_mask_check) && (twx != s_tb_twin[0] || twy != s_tb_twin[1] || @@ -3335,7 +3367,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, } if (s_tb_n + 3 > TEXBATCH_MAXV) { s_batch_reason[6]++; flush_tex_batch(); } if (s_tb_n == 0) { /* opening a batch: capture its keyed state */ - s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = s_tex_filter; s_tb_gate = gate; + s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = filter; s_tb_gate = gate; s_tb_bank_tex = s_selected_bank_tex; s_tb_bank_live_clut = s_selected_bank_live_clut; s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy; @@ -3595,7 +3627,7 @@ static int glb_scale(void) { return s_out_scale; } /* real internal SSAA scal native-wide CPU present path + gr_scale() callers need the true scale — the FBO-direct present is unaffected since it never reads gr_scale()) */ -static void glb_set_texture_filter(int b) { s_tex_filter = b ? 1 : 0; sw_set_texture_filter(b); } +static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 2 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } @@ -5810,8 +5842,10 @@ typedef struct PassGen { uint32_t n; /* images: [0] = the game's own */ uint32_t phase[PASS_SLOTS]; /* Q16, ascending */ uint32_t period; /* guest VBlanks the frame stays on screen */ + int shown; /* built for a frame already on screen */ double t_start, t_len; /* host ticks, set on promotion */ } PassGen; +static int s_pass_flip_shown = 0; static PassGen s_pgen[2]; /* Slot textures are made as slots fill: [0, s_pgen_alloc_n) exist, all at * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so two @@ -6224,6 +6258,7 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, g->tex_w = tw; g->tex_h = th; g->source_path = wide ? GL_PRES_WIDE : GL_PRES_VRAM; g->period = period_vblanks ? period_vblanks : 1u; + g->shown = s_pass_flip_shown; pass_capture_into(s_pgen_tex[gi][0], g); /* the game's own image */ g->phase[0] = 0; g->n = 1; @@ -6449,14 +6484,17 @@ static uint64_t s_present_ticks_accum_fwd(uint64_t add) { return s_present_ticks_accum; } +void gl_renderer_pass_set_flip_shown(int shown) { s_pass_flip_shown = shown ? 1 : 0; } + /* FLIP source saw a new frame: it is the flip a pending generation was built - * for (same rect, same presented geometry) or a frame without passes. */ + * for (render_pass_gen_flip_matches) or a frame without passes. */ static void pass_note_new_frame(int origin_x, int origin_y, int source_path, int pw, int ph) { PassGen *pend = &s_pgen[1 - s_pgen_cur]; - if (pend->valid && !pend->promoted && pend->x == origin_x && - pend->y == origin_y && pend->source_path == source_path && - pend->tex_w == pw && pend->tex_h == ph) { + if (pend->valid && !pend->promoted && + render_pass_gen_flip_matches(pend->shown, pend->x, pend->y, + pend->source_path, pend->tex_w, pend->tex_h, + origin_x, origin_y, source_path, pw, ph)) { s_pgen_promote = 1; } else { s_pgen_promote = 0; @@ -6512,6 +6550,19 @@ static void pass_apply_promotion(void) { g->promoted = 1; g->t_start = s_interp_schedule.frame_start; g->t_len = (double)g->period * sp; + /* Phase 0 is the image the game actually flipped to. The capture at + * the pass point is the same for a game that draws nothing more into + * the rect before its flip; a game that finishes the frame later (V8:2 + * draws its HUD at the next submit, onto the rect about to be shown) + * would otherwise show its own frame without that last layer. The + * flip was matched to this generation's rect and presented geometry, + * and no guest code ran since it, so the rect holds that image. A + * FLIP_SHOWN generation's rect is not the one flipped to: it keeps + * the image taken when it opened. */ + if (!g->shown) { + pass_capture_into(s_pgen_tex[s_pgen_cur][0], g); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + } s_pgen_promotions++; if (s_pdump_left > 0) pass_dump_generation(s_pgen_cur); } diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 33252bf0..611bb52b 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -2,6 +2,7 @@ #include "cpu_state.h" #include "nd_intro_ot.h" #include "pgxp.h" +#include "render_pass_projection.h" #include #include #include @@ -12,6 +13,7 @@ extern "C" int gpu_ws_precise_nclip_enabled(void); /* overlay_loader.c: nonzero while an overlay shadow diff (interpreter record * plus native replay of one candidate) is running. */ extern "C" int psx_overlay_shadow_diff_active(void); +extern "C" { int (*g_psx_projection_command)(CPUState*, uint32_t) = nullptr; } namespace PSXRecomp { namespace GTE { @@ -372,6 +374,76 @@ extern "C" int gte_geometry_correction_lookup(uint32_t packed, return 1; } +/* Render-pass checkpoint for the gte.cpp side of precision tracking: the + * position cache entries a pass writes are journaled and put back, and the + * speculative bracket depth is restored (a watchdog abort can leave one open). + * The PGXP shadows roll back through pgxp_checkpoint_*. */ +struct GeomJournalEntry { GeomVertex *slot; GeomVertex old; }; +static uint32_t s_gck_depth = 0; +static GeomJournalEntry *s_gck_log = nullptr; +static size_t s_gck_n = 0, s_gck_cap = 0; +static int s_gck_lossy = 0; +static uint32_t s_gck_generation = 0; +static GeomVertex *s_gck_cache = nullptr; +static uint32_t s_gck_spec_depth = 0; +static int s_gck_spec_invalidated = 0; + +static uint8_t *s_gck_bits = nullptr; /* one bit per cache slot */ + +static inline void geom_ck_note(GeomVertex *slot) { + if (s_gck_depth == 0) return; + if (s_geom_cache != s_gck_cache || !s_gck_bits) { s_gck_lossy = 1; return; } + size_t i = (size_t)(slot - s_geom_cache); + uint8_t bit = (uint8_t)(1u << (i & 7u)); + if (s_gck_bits[i >> 3] & bit) return; /* journaled this pass */ + if (s_gck_n == s_gck_cap) { + size_t cap = s_gck_cap ? s_gck_cap * 2 : 16384; + GeomJournalEntry *p = (GeomJournalEntry *)std::realloc( + s_gck_log, cap * sizeof *p); + if (!p) { s_gck_lossy = 1; return; } + s_gck_log = p; + s_gck_cap = cap; + } + s_gck_bits[i >> 3] |= bit; + s_gck_log[s_gck_n].slot = slot; + s_gck_log[s_gck_n].old = *slot; + s_gck_n++; +} + +extern "C" void gte_precision_checkpoint_begin(void) { + pgxp_checkpoint_begin(); + if (s_gck_depth++ != 0) return; + if (s_geom_cache && !s_gck_bits) + s_gck_bits = (uint8_t *)std::calloc(GEOM_CACHE_SIZE / 8u, 1); + s_gck_n = 0; + s_gck_generation = s_geom_generation; + s_gck_cache = s_geom_cache; + s_gck_spec_depth = s_speculative_depth; + s_gck_spec_invalidated = s_speculative_timeline_invalidated; +} + +extern "C" void gte_precision_checkpoint_rollback(void) { + if (s_gck_depth != 0 && --s_gck_depth == 0) { + for (size_t i = s_gck_n; i-- > 0;) { + GeomVertex *slot = s_gck_log[i].slot; + *slot = s_gck_log[i].old; + size_t k = (size_t)(slot - s_gck_cache); + s_gck_bits[k >> 3] &= (uint8_t)~(1u << (k & 7u)); + } + s_gck_n = 0; + s_speculative_depth = s_gck_spec_depth; + s_speculative_timeline_invalidated = s_gck_spec_invalidated; + if (s_gck_lossy || s_geom_cache != s_gck_cache || + s_geom_generation < s_gck_generation) { + s_gck_lossy = 0; + gte_geom_generation_advance(); /* fail closed */ + } else { + s_geom_generation = s_gck_generation; + } + } + pgxp_checkpoint_rollback(); +} + static inline void geom_note(uint32_t packed, int64_t x16, int64_t y16) { if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_geom_enabled || !s_geom_cache) return; @@ -384,6 +456,7 @@ static inline void geom_note(uint32_t packed, int64_t x16, int64_t y16) { int64_t slot = geom_slot(packed); if (slot < 0) return; GeomVertex &entry = s_geom_cache[slot]; + geom_ck_note(&entry); if (entry.generation == s_geom_generation) { /* Already occupied this generation: only flag ambiguity if the stored * sub-pixel position actually DIFFERS — re-projecting the same vertex @@ -1897,6 +1970,11 @@ static void gte_run_command(PSXRecomp::GTE::GTEState* gte, uint32_t cmd) { extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { using namespace PSXRecomp::GTE; + uint32_t original_transform[8]; + const bool observe_projection = g_psx_projection_command && + ((cmd & 63) == 1 || (cmd & 63) == 0x30); + if (observe_projection) std::memcpy(original_transform, cpu->gte_ctrl, sizeof original_transform); + const bool intermediate = observe_projection && g_psx_projection_command(cpu, cmd); #ifndef PSX_NO_DEBUG_TOOLS if (!s_gte_replay_sandbox) s_gte_exec_count++; s_gte_caller_ra = cpu->gpr[31]; /* dome-locate probe: game fn that issued this projection */ @@ -1930,6 +2008,7 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { #endif gte_export_cpu_state(cpu, >e); + if (intermediate) std::memcpy(cpu->gte_ctrl, original_transform, sizeof original_transform); #ifndef PSX_NO_DEBUG_TOOLS /* ND digit-rain: NdIntroSiblingFaceLoop (jal 0x80069CC4; $ra stays 69C3C.. diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 33b3203a..f844afc8 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14,6 +14,7 @@ #include "psx_interpreter.h" #include "cdrom.h" #include "fntrace.h" +#include "render_pass_projection.h" #include "text_xlate.h" #include "boot_state.h" #include "bios_hle.h" @@ -1040,6 +1041,7 @@ extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) } extern "C" void psx_frontend_on_savestate_loaded(void) { + psx_projection_reset_session(); mod_runtime_on_savestate_loaded(); #ifndef PSX_NO_DEBUG_TOOLS debug_server_note_savestate_loaded(); @@ -1230,6 +1232,12 @@ static inline int cfg_fmv_filter_to_launcher(int cfg_value) { return cfg_value + 1; } static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ +static int g_mod_texfilter = -1; +extern "C" void psx_mod_set_texture_filter(int mode) { + if(mode < 0 || mode > 2) return; + g_mod_texfilter = mode; + gr_set_texture_filter(mode); +} /* Sub-pixel vertex precision + perspective-correct UVs (PGXP-style). Visual * only: the PS1-visible GTE SXY FIFO stays integer, so guest-side culling and * SXY readback are untouched. Default off = the faithful floor. */ @@ -1531,6 +1539,8 @@ extern "C" int psx_mod_set_adaptive_display_aspect( static PSXModSessionBaseline g_mod_owned_baseline; static void reset_mod_owned_presentation(void) { + g_mod_texfilter = -1; + gr_set_texture_filter(g_video_texfilter); PSXModSessionScalars live; live.video_vsync = g_video_vsync; live.frame_interpolation = g_frame_interpolation; @@ -1561,6 +1571,7 @@ static void reset_mod_owned_presentation(void) { g_ws_adaptive_max_num = 16; g_ws_adaptive_max_den = 9; psx_mod_set_world_scene_predicate(nullptr); + gpu_ws_set_native_scene_predicate(nullptr); psx_mod_set_retained_scene_predicate(nullptr); psx_mod_set_adaptive_backdrop_preload(0); (void)psx_mod_set_draw_distance_clamp(0); @@ -1895,6 +1906,12 @@ 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 && !fntrace_is_game_started()) { + g_ws_engaged = false; + g_ws_projection_mode = -1; + gte_set_display_aspect(4, 3); + gpu_ws_configure(4, 3, g_ws_anchor_addr, 0, 0); + } if (!g_ws_engaged) return; const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; @@ -4960,12 +4977,10 @@ static int pad_mode_boot_analog(int mode) { return mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; } -/* Keyboard has no DualShock sticks/config handshake — always present as a - * plain digital pad (SCPH-1080). Leaving keyboard slots in ANALOG/policy mode made - * P2–P5 show as connected while games that expect digital multitap pads never - * saw usable button input. */ +/* Keyboard mappings can drive both DualShock sticks. Honor the configured + * mode, including analog-only title locks; multitap policy belongs to the + * SIO seat below and applies equally to keyboards and physical controllers. */ static int effective_player_mode(const PlayerInput& p) { - if (p.kind == 1) return (int)PSXRecompV4::PAD_MODE_DIGITAL; return p.mode; } @@ -16013,7 +16028,7 @@ session_reboot: (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ - gr_set_texture_filter(g_video_texfilter); + gr_set_texture_filter(g_mod_texfilter < 0 ? g_video_texfilter : g_mod_texfilter); /* 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. */ diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 6370c8bd..60602bd1 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1806,9 +1806,88 @@ static void render_pass_mmio_write(uint32_t phys, uint32_t val, /* The policy is render_pass_store_to (render_pass_plan.c, unit-tested by * render_pass_sandbox_test); this only wires it to memory.c's arrays. */ +/* Mod arenas (Expansion-1 mod memory, the GPU-DMA aperture) are guest + * memory a pass may draw into: a title that moves its primitive buffers there + * (an extended draw distance) has its passes build their packets there. Each + * arena page a pass writes is backed up on its first write and put back by + * render_pass_mod_arenas_rollback(), with the rest of the pass restore. */ +#define RP_MOD_PAGE 4096u +#define RP_MOD_PAGES ((MOD_MEMORY_SIZE + PSX_MOD_GPU_DMA_APERTURE_SIZE) / RP_MOD_PAGE) +typedef struct { uint8_t *page; uint32_t index; uint8_t data[RP_MOD_PAGE]; } RpModPage; +static uint8_t s_rp_mod_bits[RP_MOD_PAGES / 8u]; +static RpModPage *s_rp_mod_log = NULL; +static uint32_t s_rp_mod_n = 0, s_rp_mod_cap = 0; +static int s_rp_mod_lossy = 0; +uint64_t g_render_pass_mod_pages = 0; /* pages journaled, total */ + +static int rp_mod_journal(uint8_t *base, uint32_t page_base, uint32_t off, + uint32_t width) { + for (uint32_t pg = off / RP_MOD_PAGE; pg <= (off + width - 1u) / RP_MOD_PAGE; pg++) { + const uint32_t index = page_base + pg; + const uint8_t bit = (uint8_t)(1u << (index & 7u)); + if (s_rp_mod_bits[index >> 3] & bit) continue; + if (s_rp_mod_n == s_rp_mod_cap) { + uint32_t cap = s_rp_mod_cap ? s_rp_mod_cap * 2u : 64u; + RpModPage *p = (RpModPage *)realloc(s_rp_mod_log, cap * sizeof *p); + if (!p) { s_rp_mod_lossy = 1; return 0; } + s_rp_mod_log = p; + s_rp_mod_cap = cap; + } + s_rp_mod_bits[index >> 3] |= bit; + s_rp_mod_log[s_rp_mod_n].page = base + pg * RP_MOD_PAGE; + s_rp_mod_log[s_rp_mod_n].index = index; + memcpy(s_rp_mod_log[s_rp_mod_n].data, base + pg * RP_MOD_PAGE, RP_MOD_PAGE); + s_rp_mod_n++; + g_render_pass_mod_pages++; + } + return 1; +} + +/* 1 when the store landed in a mod arena (journaled and written). */ +static int render_pass_mod_store(uint32_t addr, uint32_t val, uint32_t width) { + uint32_t phys, off; + uint8_t *base = NULL; + uint32_t page_base = 0; + if (addr >= 0xC0000000u) return 0; + phys = addr & 0x1FFFFFFFu; + if (phys < psx_ram_live_bytes()) return 0; + if (phys < 0x00800000u && psx_ram_live_bytes() < 0x00800000u) return 0; /* mirrors */ + if (mod_gpu_dma_memory_offset(phys, width, &off)) { + base = mod_gpu_dma_memory; + page_base = MOD_MEMORY_SIZE / RP_MOD_PAGE; + } else if (mod_memory_offset(phys, width, &off)) { + base = mod_memory; + } else { + return 0; + } + if (!rp_mod_journal(base, page_base, off, width)) return 1; /* lossy: drop */ + for (uint32_t i = 0; i < width; i++) base[off + i] = (uint8_t)(val >> (8u * i)); + return 1; +} + +void render_pass_mod_arenas_rollback(void) { + for (uint32_t i = s_rp_mod_n; i-- > 0;) { + memcpy(s_rp_mod_log[i].page, s_rp_mod_log[i].data, RP_MOD_PAGE); + s_rp_mod_bits[s_rp_mod_log[i].index >> 3] &= + (uint8_t)~(1u << (s_rp_mod_log[i].index & 7u)); + } + s_rp_mod_n = 0; + s_rp_mod_lossy = 0; +} + +/* FNV-1a over the used part of both arenas (PSX_RENDER_PASS_VERIFY). */ +uint64_t render_pass_mod_arenas_hash(void) { + uint64_t h = 1469598103934665603ULL; + for (uint32_t i = 0; i < mod_memory_used; i++) h = (h ^ mod_memory[i]) * 1099511628211ULL; + for (uint32_t i = 0; i < mod_gpu_dma_memory_used; i++) + h = (h ^ mod_gpu_dma_memory[i]) * 1099511628211ULL; + return h; +} + static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) { RenderPassStoreTarget t; int cls; + if (render_pass_mod_store(addr, val, width)) return; t.ram = ram; t.ram_size = psx_ram_live_bytes(); /* live geometry, as psx_ram_map_write */ t.scratchpad = scratchpad; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 7423e9bb..82839aa4 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -38,6 +38,8 @@ struct RegisteredPlugin { /* Generated-function entry hooks, keyed by guest address. One id may * observe several functions. */ std::vector function_entries; + std::vector guest_functions; + std::vector instructions; }; std::map& registered_plugins() { @@ -1478,7 +1480,45 @@ bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && (found->second.activation || found->second.vblank || - !found->second.function_entries.empty()); + !found->second.function_entries.empty() || !found->second.guest_functions.empty() || + !found->second.instructions.empty()); +} + +bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, + PSXModFunctionEntryCallback callback) { + const uint32_t key = address & 0x1FFFFFFFu; + if (!valid_id(id) || !callback || (address & 3u) || + key < 0x0F000000u || key >= 0x10000000u || address >= 0xC0000000u) + return false; + for (const auto& plugin : registered_plugins()) + for (const auto& function : plugin.second.guest_functions) + if (function.address == key) return false; + registered_plugins()[id].guest_functions.push_back({key, callback}); + return true; +} + +std::vector mod_guest_functions(const std::string& id) { + const auto found = registered_plugins().find(id); + return found == registered_plugins().end() + ? std::vector{} : found->second.guest_functions; +} + +bool mod_register_instruction_plugin(const std::string& id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback) { + if (!valid_id(id) || !callback || (address & 3u) || address >= 0xC0000000u || + (address & 0x1FFFFFFFu) >= 0x00800000u) return false; + auto& hooks = registered_plugins()[id].instructions; + const uint32_t key = address & 0x1FFFFFFFu; + for (const auto& hook : hooks) + if (hook.address == key) return false; + hooks.push_back({key, expected, callback}); + return true; +} + +std::vector mod_instruction_hooks(const std::string& id) { + const auto found = registered_plugins().find(id); + return found == registered_plugins().end() + ? std::vector{} : found->second.instructions; } void mod_invoke_activation_plugin(const std::string& id) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 6470aa92..c5525f5e 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -7,7 +7,9 @@ #include "mod_plugins.h" #include "gpu.h" #include "psx_memory.h" +#include "render_pass_projection.h" #include "psx_sha256.h" +#include "cpu_state.h" #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" @@ -54,6 +56,10 @@ extern "C" int fntrace_is_game_started(void); /* Declared in mod_plugins.h; see active_function_entry_hooks() below. */ uint32_t g_psx_mod_function_entry_hooks = 0; +uint32_t g_psx_mod_guest_functions = 0; +uint32_t g_psx_mod_instruction_hooks = 0; +/* Guest vblanks seen by the mod runtime: the plugin counters' time base. */ +static uint64_t g_mod_vblanks = 0; namespace PSXRecompV4 { namespace { @@ -78,6 +84,8 @@ struct RuntimeMods { bool disc_enabled = false; bool disc_guard_failed = false; const ModResolution::Plugin* current_plugin = nullptr; + CPUState* current_function_cpu = nullptr; + bool current_function_finished = false; }; RuntimeMods& state() { @@ -85,6 +93,33 @@ RuntimeMods& state() { return value; } +/* Guest calls made from an entry hook can deliver VBlank callbacks before + * returning. Every callback owns its resource/completion context; restoring + * only entry hooks would let VBlank erase the interrupted plugin or complete + * a function that belongs to another callback. */ +class PluginCallbackScope { + RuntimeMods& runtime; + const ModResolution::Plugin* previous_plugin; + CPUState* previous_cpu; + bool previous_finished; +public: + PluginCallbackScope(RuntimeMods& s, const ModResolution::Plugin* plugin, + CPUState* cpu = nullptr) + : runtime(s), previous_plugin(s.current_plugin), + previous_cpu(s.current_function_cpu), previous_finished(s.current_function_finished) { + s.current_plugin = plugin; + s.current_function_cpu = cpu; + s.current_function_finished = false; + } + ~PluginCallbackScope() { + runtime.current_plugin = previous_plugin; + runtime.current_function_cpu = previous_cpu; + runtime.current_function_finished = previous_finished; + } + PluginCallbackScope(const PluginCallbackScope&) = delete; + PluginCallbackScope& operator=(const PluginCallbackScope&) = delete; +}; + /* Function-entry hooks of the ACTIVE plan, flattened at plugin activation into * one table sorted by code key (address with the segment bits stripped, so a * KUSEG or KSEG1 PC reaches the same hook as KSEG0). The interpreter consults @@ -105,6 +140,21 @@ std::vector& active_function_entry_hooks() { } unsigned function_entry_depth; +std::vector& active_guest_functions() { + static std::vector value; + return value; +} + +struct ActiveInstructionHook { + uint32_t key, expected; + PSXModFunctionEntryCallback callback; + const ModResolution::Plugin* plugin; +}; +std::vector& active_instruction_hooks() { + static std::vector value; + return value; +} + inline uint32_t function_entry_key(uint32_t address) { return address & 0x1FFFFFFFu; } @@ -112,6 +162,10 @@ inline uint32_t function_entry_key(uint32_t address) { void clear_function_entry_hooks() { active_function_entry_hooks().clear(); g_psx_mod_function_entry_hooks = 0; + active_guest_functions().clear(); + g_psx_mod_guest_functions = 0; + active_instruction_hooks().clear(); + g_psx_mod_instruction_hooks = 0; } void build_function_entry_hooks(const RuntimeMods& s) { @@ -127,6 +181,20 @@ void build_function_entry_hooks(const RuntimeMods& s) { [](const ActiveFunctionEntryHook& a, const ActiveFunctionEntryHook& b) { return a.key < b.key; }); g_psx_mod_function_entry_hooks = (uint32_t)table.size(); + auto& functions = active_guest_functions(); + for (const auto& plugin : s.plan.plugins) + for (const auto& function : mod_guest_functions(plugin.id)) + functions.push_back({function.address, function.callback, nullptr, &plugin}); + std::sort(functions.begin(), functions.end(), + [](const auto& a, const auto& b) { return a.key < b.key; }); + g_psx_mod_guest_functions = (uint32_t)functions.size(); + auto& instructions = active_instruction_hooks(); + for (const auto& plugin : s.plan.plugins) + for (const auto& hook : mod_instruction_hooks(plugin.id)) + instructions.push_back({hook.address, hook.expected, hook.callback, &plugin}); + std::stable_sort(instructions.begin(), instructions.end(), + [](const auto& a, const auto& b) { return a.key < b.key; }); + g_psx_mod_instruction_hooks = (uint32_t)instructions.size(); } const ModPackage* selected_package(const std::string& id) { @@ -1414,24 +1482,25 @@ extern "C" int psx_mod_read_disc_file(const char* path, void* buffer, extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; RuntimeMods& s = state(); + psx_projection_reset_session(); + gpu_ws_set_native_scene_predicate(nullptr); psx_ram_reset_size_request(); if (!s.initialized || !s.plan.ok) return; for (const ModResolution::Plugin& plugin : s.plan.plugins) { - s.current_plugin = &plugin; + PluginCallbackScope scope(s, &plugin); mod_invoke_activation_plugin(plugin.id); - s.current_plugin = nullptr; } build_function_entry_hooks(s); } extern "C" void mod_runtime_on_vblank(void) { using namespace PSXRecompV4; + ++g_mod_vblanks; RuntimeMods& s = state(); if (!s.initialized || !s.plan.ok) return; for (const ModResolution::Plugin& plugin : s.plan.plugins) { - s.current_plugin = &plugin; + PluginCallbackScope scope(s, &plugin); mod_invoke_vblank_plugin(plugin.id); - s.current_plugin = nullptr; } } @@ -1523,6 +1592,53 @@ extern "C" uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t size, return psx_mod_gpu_dma_memory_alloc(size, alignment); } +namespace { +struct ModCounter { + const char* name = nullptr; /* caller literal; compared by content */ + uint64_t count = 0; + uint64_t last_frame = 0; +}; +constexpr size_t kModCounterCap = 128; +ModCounter g_mod_counters[kModCounterCap]; +size_t g_mod_counter_count = 0; +uint64_t g_mod_counter_overflow = 0; +} // namespace + +extern "C" void psx_mod_counter_add(const char* name, uint32_t delta) { + if (!name || !name[0]) return; + for (size_t i = 0; i < g_mod_counter_count; i++) { + ModCounter& c = g_mod_counters[i]; + if (c.name == name || std::strcmp(c.name, name) == 0) { + c.count += delta; + c.last_frame = g_mod_vblanks; + return; + } + } + if (g_mod_counter_count == kModCounterCap) { + g_mod_counter_overflow += delta; + return; + } + ModCounter& c = g_mod_counters[g_mod_counter_count++]; + c.name = name; + c.count = delta; + c.last_frame = g_mod_vblanks; +} + +/* Debug-server accessor: copies up to `cap` entries; returns the total count + * of distinct counters and writes the overflow bucket. */ +extern "C" int psx_mod_counters_snapshot(const char** names, uint64_t* counts, + uint64_t* last_frames, int cap, + uint64_t* overflow) { + const int n = (int)g_mod_counter_count; + for (int i = 0; i < n && i < cap; i++) { + names[i] = g_mod_counters[i].name; + counts[i] = g_mod_counters[i].count; + last_frames[i] = g_mod_counters[i].last_frame; + } + if (overflow) *overflow = g_mod_counter_overflow; + return n; +} + extern "C" int32_t psx_mod_widescreen_x_margin(void) { return (int32_t)psx_ws_x_margin(); } @@ -1586,6 +1702,55 @@ extern "C" int psx_mod_register_function_filter_plugin( return mod_register_function_filter_plugin(id, address, callback) ? 1 : 0; } +extern "C" int psx_mod_finish_function(CPUState* cpu) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!cpu || s.current_function_cpu != cpu || !s.current_plugin) return 0; + s.current_function_finished = true; + return 1; +} + +extern "C" int psx_mod_register_guest_function_plugin( + const char* id, uint32_t address, PSXModFunctionEntryCallback callback) { + return id && PSXRecompV4::mod_register_guest_function_plugin(id, address, callback); +} + +extern "C" int psx_mod_dispatch_guest_function(CPUState* cpu, uint32_t address) { + using namespace PSXRecompV4; + if (!g_psx_mod_guest_functions || !cpu || address >= 0xC0000000u) return 0; + const auto& functions = active_guest_functions(); + const uint32_t key = function_entry_key(address); + auto it = std::lower_bound(functions.begin(), functions.end(), key, + [](const auto& function, uint32_t k) { return function.key < k; }); + if (it == functions.end() || it->key != key) return 0; + PluginCallbackScope scope(state(), it->plugin, cpu); + it->callback(cpu, address); + cpu->pc = cpu->gpr[31]; + return 1; +} + +extern "C" int psx_mod_register_instruction_plugin(const char* id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback) { + return id && PSXRecompV4::mod_register_instruction_plugin(id, address, expected, callback); +} + +extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t instruction) { + using namespace PSXRecompV4; + if (!g_psx_mod_instruction_hooks || !cpu || address >= 0xC0000000u) return; + const auto& hooks = active_instruction_hooks(); + const auto key = function_entry_key(address); + auto it = std::lower_bound(hooks.begin(), hooks.end(), key, + [](const auto& h, uint32_t k) { return h.key < k; }); + for (; it != hooks.end() && it->key == key; ++it) { + if (it->expected != instruction || psx_mod_read_word(address) != instruction) continue; + PluginCallbackScope scope(state(), it->plugin, nullptr); + const uint32_t pc = cpu->pc; + it->callback(cpu, address); + if (cpu->pc != pc) std::abort(); + cpu->gpr[0] = 0; + } +} + extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_function_entry_hooks || !cpu) return 0; @@ -1596,14 +1761,12 @@ extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { table.begin(), table.end(), key, [](const ActiveFunctionEntryHook& hook, uint32_t k) { return hook.key < k; }); for (; it != table.end() && it->key == key; ++it) { - const ModResolution::Plugin* previous = s.current_plugin; - s.current_plugin = it->plugin; + PluginCallbackScope scope(s, it->plugin, cpu); ++function_entry_depth; if (it->callback) it->callback(cpu, address); - const int handled = it->filter && it->filter(cpu, address); + const bool finished = s.current_function_finished || (it->filter && it->filter(cpu, address)); --function_entry_depth; - s.current_plugin = previous; - if (handled) { + if (finished) { cpu->pc = cpu->gpr[31]; return 1; } diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 0317ecf8..f6274f5b 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -2605,6 +2605,8 @@ static void init_callbacks(void) { extern int psx_mod_function_entry(CPUState *cpu, uint32_t address); s_callbacks.ws_screen_x_bound = psx_ws_screen_x_bound; s_callbacks.mod_function_entry = psx_mod_function_entry; + extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t); + s_callbacks.mod_instruction = psx_mod_instruction; } /* ABI v14: GTE precision-store tracker — the emitter emits a direct * gte_precision_store_word() call for every swc2 (GTE store-word), diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index 29503fa3..0c601fa1 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -97,9 +97,12 @@ static PGXPStats s_stats; /* Lifecycle */ /* ------------------------------------------------------------------------- */ +static void ck_wrapped(void); + extern "C" void pgxp_invalidate_all(void) { if (s_suppress != 0) { s_deferred_invalidate = 1; return; } if (++s_gen == 0) { + ck_wrapped(); /* 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)); @@ -219,6 +222,115 @@ static inline PGXPValue *pgxp_ptr(uint32_t addr) { return nullptr; } +/* ------------------------------------------------------------------------- */ +/* Checkpoint (render passes) */ +/* ------------------------------------------------------------------------- */ + +/* A render pass (render_pass.c) runs guest draw code on a sandboxed machine + * and puts RAM, scratchpad and registers back with raw copies afterwards. The + * shadows must roll back with them: otherwise they describe the words the + * pass wrote (interpolated vertices), and the live frame's packets - consumed + * after the passes - fail validation. Copying the shadow arrays whole per + * pass would cost tens of MB; instead every RAM / scratchpad slot a pass + * mutates is journaled on its first write, and register shadows (small) are + * copied whole. A journal that cannot grow, or a generation wrap during the + * pass, fails closed: rollback invalidates everything. */ +struct PGXPJournalEntry { + PGXPValue *slot; + PGXPValue old; +}; + +static uint32_t s_ck_depth = 0; +static uint8_t *s_ck_bits = nullptr; /* one bit per shadow slot */ +static PGXPValue *s_ck_ram = nullptr; /* s_ram the bits index */ +static PGXPJournalEntry *s_ck_log = nullptr; +static size_t s_ck_n = 0, s_ck_cap = 0; +static int s_ck_lossy = 0; +static PGXPValue s_ck_gpr[34], s_ck_gte[32]; +static uint32_t s_ck_gen = 0, s_ck_suppress = 0; +static int s_ck_deferred = 0; + +static inline size_t ck_index(const PGXPValue *pv) { + if (pv >= s_scratch && pv < s_scratch + PGXP_SCRATCH_WORDS) + return (size_t)PGXP_RAM_WORDS + (size_t)(pv - s_scratch); + return (size_t)(pv - s_ram); +} + +/* Journal a RAM / scratchpad slot before its first mutation in a pass. */ +static inline void ck_note(PGXPValue *pv) { + if (s_ck_depth == 0 || !pv) return; + if (s_ram != s_ck_ram && !(pv >= s_scratch && + pv < s_scratch + PGXP_SCRATCH_WORDS)) { + s_ck_lossy = 1; /* shadow RAM appeared mid-pass */ + return; + } + size_t i = ck_index(pv); + uint8_t bit = (uint8_t)(1u << (i & 7u)); + if (s_ck_bits[i >> 3] & bit) return; + if (s_ck_n == s_ck_cap) { + size_t cap = s_ck_cap ? s_ck_cap * 2 : 65536; + PGXPJournalEntry *p = (PGXPJournalEntry *)std::realloc( + s_ck_log, cap * sizeof *p); + if (!p) { s_ck_lossy = 1; return; } + s_ck_log = p; + s_ck_cap = cap; + } + s_ck_bits[i >> 3] |= bit; + s_ck_log[s_ck_n].slot = pv; + s_ck_log[s_ck_n].old = *pv; + s_ck_n++; +} + +static void ck_wrapped(void) { + if (s_ck_depth != 0) s_ck_lossy = 1; /* shadows were cleared */ +} + +static inline PGXPValue *pgxp_ptr_w(uint32_t addr) { + PGXPValue *pv = pgxp_ptr(addr); + ck_note(pv); + return pv; +} + +extern "C" void pgxp_checkpoint_begin(void) { + if (s_ck_depth++ != 0) return; /* the outermost pass journals */ + if (!s_ck_bits) { + s_ck_bits = (uint8_t *)std::calloc( + ((size_t)PGXP_RAM_WORDS + PGXP_SCRATCH_WORDS + 7u) / 8u, 1); + if (!s_ck_bits) s_ck_lossy = 1; + } + s_ck_ram = s_ram; + s_ck_n = 0; + std::memcpy(s_ck_gpr, s_gpr, sizeof s_gpr); + std::memcpy(s_ck_gte, s_gte, sizeof s_gte); + s_ck_gen = s_gen; + s_ck_suppress = s_suppress; + s_ck_deferred = s_deferred_invalidate; +} + +extern "C" void pgxp_checkpoint_rollback(void) { + if (s_ck_depth == 0) return; + if (--s_ck_depth != 0) return; + for (size_t i = s_ck_n; i-- > 0;) { + PGXPJournalEntry *e = &s_ck_log[i]; + *e->slot = e->old; + size_t k = ck_index(e->slot); + s_ck_bits[k >> 3] &= (uint8_t)~(1u << (k & 7u)); + } + s_ck_n = 0; + std::memcpy(s_gpr, s_ck_gpr, sizeof s_gpr); + std::memcpy(s_gte, s_ck_gte, sizeof s_gte); + s_gen = s_ck_gen; + /* A watchdog abort can leave a suppress bracket open: the machine it + * interrupted is gone, so its bracket is too. */ + s_suppress = s_ck_suppress; + s_deferred_invalidate = s_ck_deferred; + recompute_active(); + if (s_ck_lossy || s_ram != s_ck_ram) { + s_ck_lossy = 0; + pgxp_invalidate_all(); + } +} + static inline int pv_live(const PGXPValue *pv) { return pv && pv->gen == s_gen && (pv->flags & PGXP_F_VXY) != 0; } @@ -272,7 +384,7 @@ extern "C" void psx_pgxp_load(struct CPUState *cpu, uint32_t instr, switch (f_op(instr)) { case 0x23: { /* LW */ - PGXPValue *src = pgxp_ptr(addr); + PGXPValue *src = pgxp_ptr_w(addr); /* validation may mutate it */ if (src && src->gen == s_gen) { pv_validate(src, value); *dst = *src; @@ -312,7 +424,7 @@ 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); + PGXPValue *dst = pgxp_ptr_w(addr); if (!dst) return; uint32_t rt = f_rt(instr); PGXPValue *src = (rt != 0) ? &s_gpr[rt] : nullptr; @@ -371,7 +483,7 @@ extern "C" void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, switch (f_op(instr)) { case 0x32: { /* LWC2: gte[rt] <- [addr] */ - PGXPValue *src = pgxp_ptr(addr); + PGXPValue *src = pgxp_ptr_w(addr); /* validation may mutate it */ PGXPValue *dst = &s_gte[f_rt(instr)]; if (src && src->gen == s_gen) { pv_validate(src, value); @@ -382,7 +494,7 @@ extern "C" void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, return; } case 0x3A: { /* SWC2: [addr] <- gte[rt] */ - PGXPValue *dst = pgxp_ptr(addr); + PGXPValue *dst = pgxp_ptr_w(addr); if (!dst) return; PGXPValue *src = &s_gte[f_rt(instr)]; if (src->gen == s_gen) { @@ -936,7 +1048,7 @@ extern "C" int pgxp_probe_precise_vertex(uint32_t addr, uint32_t packet_word, * 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); + PGXPValue *dst = pgxp_ptr_w(addr); if (!dst) return; const PGXPValue *src = &s_gte[reg & 31u]; if (src->gen != s_gen) return; diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 6f849051..b853c292 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -40,6 +40,13 @@ extern uint8_t *memory_get_scratchpad_ptr(void); extern uint32_t memory_get_ram_bytes(void); extern uint32_t i_stat; extern uint32_t i_mask; +/* gte.cpp: precision-tracking shadows (PGXP + the position cache) roll back + * with the machine, or they describe the words a pass wrote. */ +extern void gte_precision_checkpoint_begin(void); +extern void gte_precision_checkpoint_rollback(void); +/* memory.c: mod arena pages a pass wrote, and their hash for verify mode. */ +extern void render_pass_mod_arenas_rollback(void); +extern uint64_t render_pass_mod_arenas_hash(void); extern uint32_t dma_snapshot_bytes(void); extern void dma_snapshot_write(uint8_t *p); extern int dma_snapshot_read(const uint8_t *p, uint32_t len); @@ -108,6 +115,7 @@ typedef struct RenderPassNesting { int ov_active_depth; uint32_t ov_inprogress; void (*ov_flush)(void); + uint32_t span_lo, span_hi; /* open guest span (dirty_ram_run_span) */ } RenderPassNesting; static void nesting_save(RenderPassNesting *n) { @@ -125,6 +133,7 @@ static void nesting_save(RenderPassNesting *n) { dirty_ram_ld_delay_save(&n->ld_delay); overlay_loader_native_nesting(&n->ov_active_depth, &n->ov_inprogress); n->ov_flush = g_overlay_flush_pending_cycles; + dirty_ram_span_get(&n->span_lo, &n->span_hi); } static void nesting_restore(const RenderPassNesting *n) { @@ -142,6 +151,7 @@ static void nesting_restore(const RenderPassNesting *n) { dirty_ram_ld_delay_restore(&n->ld_delay); overlay_loader_set_native_nesting(n->ov_active_depth, n->ov_inprogress); g_overlay_flush_pending_cycles = n->ov_flush; + dirty_ram_span_set(n->span_lo, n->span_hi); } /* PSX_RENDER_PASS_VERIFY: a pass that returned normally must leave the @@ -158,7 +168,8 @@ static int nesting_balanced(const RenderPassNesting *n) { now.exec_phase == n->exec_phase && now.precise_mode == n->precise_mode && now.ov_active_depth == n->ov_active_depth && - now.ov_flush == n->ov_flush; + now.ov_flush == n->ov_flush && + now.span_lo == n->span_lo && now.span_hi == n->span_hi; } /* After a watchdog abort, before the restore: which exits the longjmp @@ -193,6 +204,7 @@ static int nesting_describe(const RenderPassNesting *n, int bb_defer_ck, RP_NOTE(now.exec_phase != n->exec_phase, "exec phase %d->%d", now.exec_phase, n->exec_phase); RP_NOTE(now.ld_delay.armed != n->ld_delay.armed, "pending load"); + RP_NOTE(now.span_hi != n->span_hi, "guest span"); #undef RP_NOTE return count; } @@ -220,6 +232,7 @@ static RenderPassCheckpoint s_ck; static PsxCycleFreeze s_freeze; static jmp_buf s_abort_jmp; static volatile int s_abort_armed; +static const CPUState* s_pass_cpu; static int s_nesting; static RenderPassStats s_stats; @@ -270,6 +283,15 @@ void render_pass_reset_session(void) { memset(g_render_pass_dropped_writes, 0, sizeof g_render_pass_dropped_writes); s_open_generation = 0; s_restored_valid = 0; + gl_renderer_pass_set_flip_shown(0); +} + +int psx_mod_set_render_pass_flip(uint32_t mode) { + if (mode != PSX_MOD_RENDER_PASS_FLIP_PENDING && + mode != PSX_MOD_RENDER_PASS_FLIP_SHOWN) + return 0; + gl_renderer_pass_set_flip_shown(mode == PSX_MOD_RENDER_PASS_FLIP_SHOWN); + return 1; } /* Everything that must hold before guest code may run frozen, as a @@ -298,6 +320,16 @@ uint32_t psx_mod_render_pass_status(void) { return pass_status(); } +int psx_mod_run_guest_span(CPUState *cpu, uint32_t start_pc, uint32_t stop_pc) { + if (!g_psx_render_pass_active || !cpu) return 0; + if (dirty_ram_run_span(cpu, start_pc, stop_pc, 1000000u)) { + s_stats.spans++; + return 1; + } + s_stats.span_failures++; + return 0; +} + uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, uint32_t shown_after_vblanks, uint32_t *alpha_q16, uint32_t max) { @@ -354,6 +386,10 @@ static uint64_t state_hash(const CPUState *cpu) { h = fnv(h, cpu->gte_ctrl, sizeof cpu->gte_ctrl); h = fnv(h, memory_get_ram_ptr(), memory_get_ram_bytes()); h = fnv(h, memory_get_scratchpad_ptr(), RP_SPAD_SIZE); + { + const uint64_t mh = render_pass_mod_arenas_hash(); + h = fnv(h, &mh, sizeof mh); + } h = fnv(h, g_psx_icache_tv, sizeof g_psx_icache_tv); h = fnv(h, &i_stat, sizeof i_stat); h = fnv(h, &i_mask, sizeof i_mask); @@ -426,6 +462,7 @@ static int checkpoint_save(const CPUState *cpu) { s_ck.call_bail = g_psx_call_bail; memset(&s_ck.nest, 0, sizeof s_ck.nest); nesting_save(&s_ck.nest); + gte_precision_checkpoint_begin(); return 1; } @@ -449,6 +486,8 @@ static void checkpoint_restore(CPUState *cpu) { g_psx_dispatch_depth = s_ck.dispatch_depth; g_psx_call_bail = s_ck.call_bail; nesting_restore(&s_ck.nest); + render_pass_mod_arenas_rollback(); + gte_precision_checkpoint_rollback(); } static double s_ms_per_tick = 0.0; @@ -460,6 +499,11 @@ static double ema_ms(double cur, uint64_t ticks) { static void watchdog_overrun(void) { s_stats.watchdog++; s_stats.watchdog_flag = 1; + if (s_stats.watchdog <= 4 && s_pass_cpu) + fprintf(stderr, "psxrecomp: render pass watchdog pc=0x%08X " + "function=0x%08X ra=0x%08X last_store=0x%08X\n", + s_pass_cpu->pc, g_debug_current_func_addr, + s_pass_cpu->gpr[31], g_debug_last_store_pc); if (s_abort_armed) { s_abort_armed = 0; longjmp(s_abort_jmp, 1); @@ -531,6 +575,7 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, (void)psx_cycle_freeze_begin(&s_freeze, watchdog_cycles(), watchdog_overrun); if (setjmp(s_abort_jmp) == 0) { + s_pass_cpu = cpu; s_abort_armed = 1; ok = fn(cpu, user, pass->alpha_q16) ? 1 : 0; s_abort_armed = 0; @@ -554,6 +599,7 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, } s_stats.guest_cycles_last = psx_cycle_count - cycles_before; tg = gl_renderer_perf_ticks(); + s_pass_cpu = NULL; leaks = gl_renderer_pass_leaks() - s_leaks_before; if (leaks) { diff --git a/runtime/src/render_pass_frame.c b/runtime/src/render_pass_frame.c new file mode 100644 index 00000000..9a4d8156 --- /dev/null +++ b/runtime/src/render_pass_frame.c @@ -0,0 +1,58 @@ +/* Per-frame driver and activation for render-pass frame-rate plugins: the + * parts every title repeats around psx_mod_render_pass_plan / _pass. */ +#include "mod_plugins.h" + +#include +#include + +#define FRAME_MAX_PHASES 16u + +static int s_crossfade; /* passes lastingly unavailable: presenter crossfades */ + +int psx_mod_activate_render_pass_rate(const char* package, const char* feature, + const char* option, uint32_t flip_mode) { + char value[16]; + unsigned long fps = 0; /* 0 follows the measured display refresh */ + if (package && feature && option && + psx_mod_option_value(package, feature, option, value, sizeof value) && + strcmp(value, "display") != 0) { + char* end = value; + const unsigned long parsed = strtoul(value, &end, 10); + if (end != value && *end == '\0') fps = parsed; + } + s_crossfade = 0; + if (!psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP) || + !psx_mod_set_render_pass_flip(flip_mode) || + !psx_mod_set_frame_interpolation_blend(PSX_MOD_FRAME_INTERPOLATION_HOLD)) + return 0; + return psx_mod_set_frame_interpolation((uint32_t)fps); +} + +uint32_t psx_mod_render_pass_frame(struct CPUState* cpu, + const PSXModRenderPassFrame* frame, + PSXModRenderPassFn fn, void* user) { + uint32_t phases[FRAME_MAX_PHASES], n, kept = 0; + const uint32_t status = psx_mod_render_pass_status(); + const int lasting = status == PSX_MOD_RENDER_PASS_BACKEND || + status == PSX_MOD_RENDER_PASS_DISABLED; + PSXModRenderPass pass; + if (lasting != s_crossfade) { + s_crossfade = lasting; + psx_mod_set_frame_interpolation_blend( + lasting ? PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE + : PSX_MOD_FRAME_INTERPOLATION_HOLD); + } + if (!cpu || !frame || !fn || frame->struct_size < sizeof *frame || + frame->period_vblanks == 0 || frame->w == 0 || frame->h == 0) + return 0; + n = psx_mod_render_pass_plan(frame->period_vblanks, frame->shown_after_vblanks, + phases, FRAME_MAX_PHASES); + memset(&pass, 0, sizeof pass); + pass.struct_size = sizeof pass; + pass.x = frame->x; pass.y = frame->y; pass.w = frame->w; pass.h = frame->h; + for (uint32_t i = 0; i < n; i++) { + pass.alpha_q16 = phases[i]; + if (psx_mod_render_pass(cpu, &pass, fn, user)) kept++; + } + return kept; +} diff --git a/runtime/src/render_pass_motion.c b/runtime/src/render_pass_motion.c new file mode 100644 index 00000000..98810907 --- /dev/null +++ b/runtime/src/render_pass_motion.c @@ -0,0 +1,456 @@ +/* Motion kit for render-pass plugins: capture, pair and blend the game state + * a pass redraws from. See include/render_pass_motion.h. */ +#include "render_pass_motion.h" +#include "mod_plugins.h" + +#include +#include +#include + +#define MOTION_MAX_TICKS 8u +#define MOTION_PI 3.14159265358979323846 + +typedef struct MotionEntry { + uint32_t addr, identity, seq; + uint8_t kind; + int32_t v[12]; /* MATRIX: r[0..8], t at [9..11]; others from v[0] */ +} MotionEntry; + +typedef struct MotionPair { uint32_t prev, cur; } MotionPair; + +struct PSXMotionSet { + uint32_t cap; + MotionEntry* cap_buf[2]; + uint32_t n[2]; + uint32_t tick[2]; + int began[2]; /* a capture was made into this buffer */ + int sorted[2]; + int cur; /* buffer the current capture writes */ + int history; /* the other buffer holds the capture just before */ + MotionPair* pairs; + uint32_t npairs; + uint32_t ticks; +}; + +uint32_t psx_motion_identity(uint32_t a, uint32_t b, uint32_t c) { + uint32_t h = 2166136261u; + const uint32_t w[3] = {a, b, c}; + for (int i = 0; i < 3; i++) + for (int k = 0; k < 4; k++) h = (h ^ ((w[i] >> (8 * k)) & 0xFFu)) * 16777619u; + return h; +} + +PSXMotionSet* psx_motion_set_create(uint32_t capacity) { + PSXMotionSet* s; + if (capacity == 0) return NULL; + s = (PSXMotionSet*)calloc(1, sizeof *s); + if (!s) return NULL; + s->cap = capacity; + s->cap_buf[0] = (MotionEntry*)calloc(capacity, sizeof(MotionEntry)); + s->cap_buf[1] = (MotionEntry*)calloc(capacity, sizeof(MotionEntry)); + s->pairs = (MotionPair*)calloc(capacity, sizeof(MotionPair)); + if (!s->cap_buf[0] || !s->cap_buf[1] || !s->pairs) { + psx_motion_set_destroy(s); + return NULL; + } + return s; +} + +void psx_motion_set_destroy(PSXMotionSet* s) { + if (!s) return; + free(s->cap_buf[0]); + free(s->cap_buf[1]); + free(s->pairs); + free(s); +} + +void psx_motion_invalidate(PSXMotionSet* s) { + if (!s) return; + s->history = 0; + s->began[0] = s->began[1] = 0; + s->n[0] = s->n[1] = 0; + s->npairs = 0; + s->ticks = 0; +} + +void psx_motion_begin(PSXMotionSet* s, uint32_t tick) { + if (!s) return; + s->history = s->began[s->cur]; + s->cur ^= 1; + s->n[s->cur] = 0; + s->tick[s->cur] = tick; + s->began[s->cur] = 1; + s->sorted[s->cur] = 0; + s->npairs = 0; + s->ticks = 0; +} + +/* ---- guest RAM --------------------------------------------------------- */ + +void psx_motion_read_matrix(uint32_t addr, PSXMotionMatrix* m) { + for (int i = 0; i < 9; i++) m->r[i] = (int16_t)psx_mod_read_half(addr + 2u * (uint32_t)i); + for (int i = 0; i < 3; i++) m->t[i] = (int32_t)psx_mod_read_word(addr + 0x14u + 4u * (uint32_t)i); +} + +void psx_motion_write_matrix(uint32_t addr, const PSXMotionMatrix* m, int rotation) { + if (rotation) + for (int i = 0; i < 9; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)m->r[i]); + for (int i = 0; i < 3; i++) psx_mod_write_word(addr + 0x14u + 4u * (uint32_t)i, (uint32_t)m->t[i]); +} + +static void read_entry(MotionEntry* e) { + switch (e->kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_MATRIX_TRANSLATION: { + PSXMotionMatrix m; + psx_motion_read_matrix(e->addr, &m); + for (int i = 0; i < 9; i++) e->v[i] = m.r[i]; + for (int i = 0; i < 3; i++) e->v[9 + i] = m.t[i]; + break; + } + case PSX_MOTION_ROTATION: + for (int i = 0; i < 9; i++) e->v[i] = (int16_t)psx_mod_read_half(e->addr + 2u * (uint32_t)i); + break; + case PSX_MOTION_VECTOR: + for (int i = 0; i < 3; i++) e->v[i] = (int32_t)psx_mod_read_word(e->addr + 4u * (uint32_t)i); + break; + case PSX_MOTION_SVECTOR: + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) e->v[i] = (int16_t)psx_mod_read_half(e->addr + 2u * (uint32_t)i); + break; + case PSX_MOTION_SCALAR: + e->v[0] = (int32_t)psx_mod_read_word(e->addr); + break; + } +} + +int psx_motion_track(PSXMotionSet* s, uint32_t kind, uint32_t addr, uint32_t identity) { + MotionEntry* e; + if (!s || kind < PSX_MOTION_MATRIX || kind > PSX_MOTION_ROTATION) return 0; + if (!s->began[s->cur] || s->n[s->cur] >= s->cap) return 0; + e = &s->cap_buf[s->cur][s->n[s->cur]]; + memset(e, 0, sizeof *e); + e->addr = addr; + e->kind = (uint8_t)kind; + e->identity = identity; + e->seq = s->n[s->cur]++; + s->sorted[s->cur] = 0; + read_entry(e); + return 1; +} + +/* ---- math ---------------------------------------------------------------- */ + +static int orthonormal(const int16_t* r) { + for (int i = 0; i < 3; i++) { + double len = 0; + for (int j = 0; j < 3; j++) len += (double)r[3 * i + j] * r[3 * i + j]; + if (fabs(sqrt(len) - 4096.0) > 4096.0 * 0.04) return 0; + for (int k = i + 1; k < 3; k++) { + double dot = 0; + for (int j = 0; j < 3; j++) dot += (double)r[3 * i + j] * r[3 * k + j]; + if (fabs(dot) > 4096.0 * 4096.0 * 0.04) return 0; + } + } + return 1; +} + +static void to_quat(const int16_t* r, double q[4]) { + double a[9], n; + for (int i = 0; i < 9; i++) a[i] = r[i] / 4096.0; + const double tr = a[0] + a[4] + a[8]; + if (tr > 0) { + double s = sqrt(tr + 1.0) * 2; + q[0] = 0.25 * s; q[1] = (a[7] - a[5]) / s; q[2] = (a[2] - a[6]) / s; q[3] = (a[3] - a[1]) / s; + } else if (a[0] > a[4] && a[0] > a[8]) { + double s = sqrt(1.0 + a[0] - a[4] - a[8]) * 2; + q[0] = (a[7] - a[5]) / s; q[1] = 0.25 * s; q[2] = (a[1] + a[3]) / s; q[3] = (a[2] + a[6]) / s; + } else if (a[4] > a[8]) { + double s = sqrt(1.0 + a[4] - a[0] - a[8]) * 2; + q[0] = (a[2] - a[6]) / s; q[1] = (a[1] + a[3]) / s; q[2] = 0.25 * s; q[3] = (a[5] + a[7]) / s; + } else { + double s = sqrt(1.0 + a[8] - a[0] - a[4]) * 2; + q[0] = (a[3] - a[1]) / s; q[1] = (a[2] + a[6]) / s; q[2] = (a[5] + a[7]) / s; q[3] = 0.25 * s; + } + n = sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + for (int i = 0; i < 4; i++) q[i] /= n; +} + +static int16_t fixed12(double v) { + double x = v * 4096.0; + if (x > 32767.0) x = 32767.0; + if (x < -32768.0) x = -32768.0; + return (int16_t)lround(x); +} + +static double turn_limit(double turn) { return turn > 0 ? turn : MOTION_PI; } + +/* Angle between two orientations, or -1 when either is not a rotation. */ +static double rotation_angle(const int16_t* a, const int16_t* b) { + double p[4], q[4], d; + if (!orthonormal(a) || !orthonormal(b)) return -1; + to_quat(a, p); + to_quat(b, q); + d = fabs(p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]); + if (d > 1.0) d = 1.0; + return 2.0 * acos(d); +} + +static int blend_rotation(const int16_t* a, const int16_t* b, double t, double turn, int16_t* out) { + if (orthonormal(a) && orthonormal(b)) { + double p[4], q[4], d, angle, wa, wb, r[4]; + to_quat(a, p); + to_quat(b, q); + d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]; + if (d < 0) { for (int i = 0; i < 4; i++) q[i] = -q[i]; d = -d; } + if (d > 1.0) d = 1.0; + angle = acos(d); + if (2.0 * angle > turn_limit(turn)) { memcpy(out, b, 9 * sizeof *out); return 0; } + wa = 1 - t; wb = t; + if (angle > 1e-6) { wa = sin((1 - t) * angle) / sin(angle); wb = sin(t * angle) / sin(angle); } + for (int i = 0; i < 4; i++) r[i] = wa * p[i] + wb * q[i]; + { + const double w = r[0], x = r[1], y = r[2], z = r[3]; + const double m[9] = {1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), + 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), + 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)}; + for (int i = 0; i < 9; i++) out[i] = fixed12(m[i]); + } + return 1; + } + /* Scaled or sheared: elementwise, refusing jumps no motion produces. */ + for (int i = 0; i < 9; i++) + if (abs((int)a[i] - (int)b[i]) > 4096) { memcpy(out, b, 9 * sizeof *out); return 0; } + for (int i = 0; i < 9; i++) out[i] = (int16_t)lround(a[i] + (b[i] - a[i]) * t); + return 1; +} + +int psx_motion_blend_matrix(const PSXMotionMatrix* a, const PSXMotionMatrix* b, + double t, double turn, PSXMotionMatrix* out) { + const int ok = blend_rotation(a->r, b->r, t, turn, out->r); + for (int i = 0; i < 3; i++) + out->t[i] = ok ? (int32_t)llround(a->t[i] + ((double)b->t[i] - a->t[i]) * t) : b->t[i]; + return ok; +} + +static double dist3(const int32_t* a, const int32_t* b) { + double s = 0; + for (int i = 0; i < 3; i++) { const double d = (double)b[i] - a[i]; s += d * d; } + return sqrt(s); +} + +static int32_t arc12(int32_t from, int32_t to) { /* shortest signed delta */ + int32_t d = (to - from) & 4095; + return d > 2048 ? d - 4096 : d; /* an exact half turn goes forward */ +} + +/* ---- pairing ------------------------------------------------------------- */ + +static int entry_cmp(const void* x, const void* y) { + const MotionEntry* a = (const MotionEntry*)x; + const MotionEntry* b = (const MotionEntry*)y; + if (a->addr != b->addr) return a->addr < b->addr ? -1 : 1; + if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1; + return a->seq < b->seq ? -1 : a->seq > b->seq; +} + +/* Sort by (addr, kind); of repeats the last tracked wins. */ +static void sort_capture(PSXMotionSet* s, int b) { + MotionEntry* e = s->cap_buf[b]; + uint32_t n = s->n[b], w = 0; + if (s->sorted[b]) return; + qsort(e, n, sizeof *e, entry_cmp); + for (uint32_t i = 0; i < n; i++) { + if (w && e[w - 1].addr == e[i].addr && e[w - 1].kind == e[i].kind) e[w - 1] = e[i]; + else e[w++] = e[i]; + } + s->n[b] = w; + s->sorted[b] = 1; +} + +/* 1 when the pair moved within the limits over `ticks`. */ +static int within_limits(const MotionEntry* a, const MotionEntry* b, + const PSXMotionLimits* lim, uint32_t ticks) { + const double move = lim->move_per_tick > 0 ? lim->move_per_tick * ticks : HUGE_VAL; + switch (a->kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_ROTATION: { + double ang; + int16_t ra[9], rb[9]; + if (a->kind == PSX_MOTION_MATRIX && dist3(&a->v[9], &b->v[9]) > move) return 0; + for (int i = 0; i < 9; i++) { ra[i] = (int16_t)a->v[i]; rb[i] = (int16_t)b->v[i]; } + ang = rotation_angle(ra, rb); + if (ang >= 0) return ang <= turn_limit(lim->turn_radians); + for (int i = 0; i < 9; i++) if (abs(a->v[i] - b->v[i]) > 4096) return 0; + return 1; + } + case PSX_MOTION_MATRIX_TRANSLATION: + return dist3(&a->v[9], &b->v[9]) <= move; + case PSX_MOTION_VECTOR: + case PSX_MOTION_SVECTOR: + return dist3(a->v, b->v) <= move; + case PSX_MOTION_ANGLES: { + const double lim12 = turn_limit(lim->turn_radians) * 4096.0 / (2.0 * MOTION_PI); + for (int i = 0; i < 3; i++) if (fabs((double)arc12(a->v[i], b->v[i])) > lim12) return 0; + return 1; + } + case PSX_MOTION_SCALAR: + return fabs((double)b->v[0] - a->v[0]) <= move; + } + return 0; +} + +uint32_t psx_motion_prepare(PSXMotionSet* s, const PSXMotionLimits* lim, + PSXMotionStats* stats) { + PSXMotionStats st; + const PSXMotionLimits none = {0, 0}; + const int c = s ? s->cur : 0, h = c ^ 1; + memset(&st, 0, sizeof st); + if (!lim) lim = &none; + if (!s || !s->began[c]) { if (stats) *stats = st; return 0; } + sort_capture(s, c); + st.tracked = s->n[c]; + s->npairs = 0; + s->ticks = 0; + if (s->history && s->began[h]) { + const uint32_t ticks = s->tick[c] - s->tick[h]; + if (ticks >= 1 && ticks <= MOTION_MAX_TICKS) s->ticks = ticks; + } + if (!s->ticks) { + st.unmatched = st.tracked; + } else { + const MotionEntry* pe = s->cap_buf[h]; + const MotionEntry* ce = s->cap_buf[c]; + uint32_t i = 0; + sort_capture(s, h); + for (uint32_t j = 0; j < s->n[c]; j++) { + while (i < s->n[h] && (pe[i].addr < ce[j].addr || + (pe[i].addr == ce[j].addr && pe[i].kind < ce[j].kind))) + i++; + if (i >= s->n[h] || pe[i].addr != ce[j].addr || pe[i].kind != ce[j].kind || + pe[i].identity != ce[j].identity) { + st.unmatched++; + } else if (!within_limits(&pe[i], &ce[j], lim, s->ticks)) { + st.placed++; + } else { + s->pairs[s->npairs].prev = i; + s->pairs[s->npairs].cur = j; + s->npairs++; + } + } + } + st.blended = s->npairs; + st.ticks = s->ticks; + if (stats) *stats = st; + return s->npairs; +} + +/* Blend one pair into `v` (entry layout). */ +static void blend_entry(const MotionEntry* a, const MotionEntry* b, double t, int32_t* v) { + switch (a->kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_ROTATION: { + int16_t ra[9], rb[9], ro[9]; + for (int i = 0; i < 9; i++) { ra[i] = (int16_t)a->v[i]; rb[i] = (int16_t)b->v[i]; } + blend_rotation(ra, rb, t, MOTION_PI * 2, ro); /* limits were applied at prepare */ + for (int i = 0; i < 9; i++) v[i] = ro[i]; + for (int i = 9; i < 12; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + } + case PSX_MOTION_MATRIX_TRANSLATION: + for (int i = 0; i < 9; i++) v[i] = b->v[i]; + for (int i = 9; i < 12; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) + v[i] = (int32_t)lround(a->v[i] + arc12(a->v[i], b->v[i]) * t); + break; + default: + for (int i = 0; i < 3; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + } +} + +static void write_entry(uint8_t kind, uint32_t addr, const int32_t* v) { + switch (kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_MATRIX_TRANSLATION: { + PSXMotionMatrix m; + for (int i = 0; i < 9; i++) m.r[i] = (int16_t)v[i]; + for (int i = 0; i < 3; i++) m.t[i] = v[9 + i]; + psx_motion_write_matrix(addr, &m, kind == PSX_MOTION_MATRIX); + break; + } + case PSX_MOTION_VECTOR: + for (int i = 0; i < 3; i++) psx_mod_write_word(addr + 4u * (uint32_t)i, (uint32_t)v[i]); + break; + case PSX_MOTION_SVECTOR: + for (int i = 0; i < 3; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)v[i]); + break; + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)(v[i] & 4095)); + break; + case PSX_MOTION_SCALAR: + psx_mod_write_word(addr, (uint32_t)v[0]); + break; + case PSX_MOTION_ROTATION: + for (int i = 0; i < 9; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)v[i]); + break; + } +} + +void psx_motion_apply(const PSXMotionSet* s, double t) { + if (!s || !s->npairs) return; + const MotionEntry* pe = s->cap_buf[s->cur ^ 1]; + const MotionEntry* ce = s->cap_buf[s->cur]; + for (uint32_t k = 0; k < s->npairs; k++) { + int32_t v[12]; + const MotionEntry* a = &pe[s->pairs[k].prev]; + const MotionEntry* b = &ce[s->pairs[k].cur]; + blend_entry(a, b, t, v); + write_entry(b->kind, b->addr, v); + } +} + +static void export_value(uint8_t kind, const int32_t* v, void* out) { + if (kind == PSX_MOTION_MATRIX || kind == PSX_MOTION_MATRIX_TRANSLATION || + kind == PSX_MOTION_ROTATION) { + PSXMotionMatrix* m = (PSXMotionMatrix*)out; + for (int i = 0; i < 9; i++) m->r[i] = (int16_t)v[i]; + for (int i = 0; i < 3; i++) m->t[i] = v[9 + i]; + } else if (kind == PSX_MOTION_SCALAR) { + *(int32_t*)out = v[0]; + } else { + memcpy(out, v, 3 * sizeof(int32_t)); + } +} + +int psx_motion_blend_at(const PSXMotionSet* s, uint32_t kind, uint32_t addr, + double t, void* out) { + const MotionEntry* ce; + uint32_t lo = 0, hi; + if (!s || !out || !s->began[s->cur]) return -1; + ce = s->cap_buf[s->cur]; + hi = s->n[s->cur]; + if (!s->sorted[s->cur]) { + for (uint32_t j = hi; j-- > 0;) /* unprepared: newest wins */ + if (ce[j].addr == addr && ce[j].kind == kind) { export_value(ce[j].kind, ce[j].v, out); return 0; } + return -1; + } + while (lo < hi) { + const uint32_t mid = (lo + hi) / 2; + if (ce[mid].addr < addr || (ce[mid].addr == addr && ce[mid].kind < kind)) lo = mid + 1; + else hi = mid; + } + if (lo >= s->n[s->cur] || ce[lo].addr != addr || ce[lo].kind != kind) return -1; + for (uint32_t k = 0; k < s->npairs; k++) { + if (s->pairs[k].cur == lo) { + int32_t v[12]; + blend_entry(&s->cap_buf[s->cur ^ 1][s->pairs[k].prev], &ce[lo], t, v); + export_value(ce[lo].kind, v, out); + return 1; + } + } + export_value(ce[lo].kind, ce[lo].v, out); + return 0; +} diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index b04ef525..b2875815 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -111,6 +111,16 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, return 1; } +int render_pass_gen_flip_matches(int shown, int gen_x, int gen_y, + int gen_source, int gen_w, int gen_h, + int flip_x, int flip_y, int flip_source, + int flip_w, int flip_h) { + const int same_rect = gen_x == flip_x && gen_y == flip_y; + if (gen_source != flip_source || gen_w != flip_w || gen_h != flip_h) + return 0; + return shown ? !same_rect : same_rect; +} + int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, uint32_t *lo, uint32_t *hi, float *t) { if (!(p <= RENDER_PASS_GEN_HOLD_MAX)) return 0; @@ -240,8 +250,9 @@ int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr, t->mmio_write(phys, val, width); return -1; } - /* Mod memory, expansion, ROM, unmapped: never written by a pass (mod - * arenas are not part of the pass restore). */ + /* Expansion, ROM, unmapped: never written by a pass. (Mod arenas are + * written and journaled before this policy: memory.c + * render_pass_mod_store.) */ return RENDER_PASS_DROP_OTHER; } diff --git a/runtime/src/render_pass_projection.cpp b/runtime/src/render_pass_projection.cpp new file mode 100644 index 00000000..f264f1bb --- /dev/null +++ b/runtime/src/render_pass_projection.cpp @@ -0,0 +1,198 @@ +#include "render_pass_projection.h" +#include "render_pass_motion.h" +#include "cpu_state.h" +#include "psx_cycle_freeze.h" +#include +#include +#include +#include +#include +#include + +namespace { +struct Projection { + uint64_t key; + uint32_t vertices[6]; + uint32_t caller, command; + PSXMotionMatrix current{}, previous{}; + bool blend = false; +}; +PSXMotionMatrix matrix(const CPUState* cpu) { + PSXMotionMatrix m{}; + for (unsigned i = 0; i < 9; ++i) + m.r[i] = int16_t(cpu->gte_ctrl[i / 2] >> (16 * (i % 2))); + for (unsigned i = 0; i < 3; ++i) m.t[i] = int32_t(cpu->gte_ctrl[5 + i]); + return m; +} +void set_matrix(CPUState* cpu, const PSXMotionMatrix& m) { + for (unsigned i = 0; i < 4; ++i) + cpu->gte_ctrl[i] = uint16_t(m.r[2*i]) | (uint32_t(uint16_t(m.r[2*i+1])) << 16); + cpu->gte_ctrl[4] = uint32_t(int32_t(m.r[8])); + for (unsigned i = 0; i < 3; ++i) cpu->gte_ctrl[5+i] = uint32_t(m.t[i]); +} +Projection projection(const CPUState* cpu, uint32_t command) { + Projection p{}; + p.current = matrix(cpu); + p.command = command; + p.caller = cpu->gpr[31] & 0x1FFFFFFFu; + const unsigned count = (command & 63) == 0x30 ? 6 : 2; + p.key = 1469598103934665603ull; + auto hash = [&](uint32_t v) { p.key = (p.key ^ v) * 1099511628211ull; }; + hash(p.caller); hash(command); + for (unsigned i = 0; i < count; ++i) { + // VZ is signed 16-bit; ignore unused high halves in backing registers. + p.vertices[i] = (i & 1) ? uint16_t(cpu->gte_data[i]) : cpu->gte_data[i]; + hash(p.vertices[i]); + } + return p; +} +bool same_vertices(const Projection& a, const Projection& b) { + return a.caller == b.caller && a.command == b.command && + std::memcmp(a.vertices, b.vertices, sizeof a.vertices) == 0; +} +double distance(const PSXMotionMatrix& a, const PSXMotionMatrix& b) { + double d = 0; + for (unsigned i=0; i<3; ++i) { double v=double(a.t[i])-b.t[i]; d += v*v; } + // Distinguish coincident instances with different rotations. + for (unsigned i=0; i<9; ++i) { double v=(double(a.r[i])-b.r[i])/16; d += v*v; } + return d; +} +bool same_matrix(const PSXMotionMatrix& a, const PSXMotionMatrix& b) { + return std::memcmp(a.r, b.r, sizeof a.r) == 0 && + std::memcmp(a.t, b.t, sizeof a.t) == 0; +} +} + +struct PSXProjectionHistory { + PSXProjectionHistory* next = nullptr; + uint32_t capacity; + std::vector current, previous; + std::unordered_map> index; + std::vector used; + PSXProjectionStats stats{}; + bool capturing = false, replaying = false, history_valid = false; + double alpha = 1; +}; +static PSXProjectionHistory* active = nullptr; +// Intrusive list has no global destructor: title replay objects may release +// their histories from static destructors, in either link initialization order. +static PSXProjectionHistory* histories = nullptr; +extern "C" void psx_projection_reset_session(void) { + // A load normally arrives between frames, when active is null. Invalidate + // idle histories too so a new timeline cannot blend against the old one. + for (auto* h = histories; h; h = h->next) psx_projection_invalidate(h); + g_psx_projection_command = nullptr; +} + +extern "C" PSXProjectionHistory* psx_projection_create(uint32_t capacity) { + if (!capacity || capacity > 262144) return nullptr; + auto* h = new (std::nothrow) PSXProjectionHistory; + if (h) { + h->capacity = capacity; h->current.reserve(capacity); h->previous.reserve(capacity); + h->next = histories; histories = h; + } + return h; +} +extern "C" void psx_projection_invalidate(PSXProjectionHistory* h) { + if (!h) return; + if (active == h) { active = nullptr; g_psx_projection_command = nullptr; } + h->current.clear(); h->previous.clear(); h->index.clear(); h->used.clear(); + h->stats = {}; h->capturing = h->replaying = h->history_valid = false; +} +extern "C" void psx_projection_destroy(PSXProjectionHistory* h) { + psx_projection_invalidate(h); + for (auto** link = &histories; *link; link = &(*link)->next) { + if (*link == h) { *link = h->next; break; } + } + delete h; +} +extern "C" void psx_projection_capture_begin(PSXProjectionHistory* h) { + if (!h || g_psx_render_pass_active) return; + h->previous.swap(h->current); h->current.clear(); + h->stats = {}; h->capturing = true; h->replaying = false; + active = h; g_psx_projection_command = psx_projection_command; +} +extern "C" void psx_projection_capture_end(PSXProjectionHistory* h, uint32_t ticks, + double max_move) { + if (!h) return; + h->capturing = false; + if (active == h) { active = nullptr; g_psx_projection_command = nullptr; } + h->stats.captured = uint32_t(h->current.size()); + h->index.clear(); + if (h->history_valid && ticks && ticks <= 8 && !h->stats.overflow) { + for (uint32_t i=0; iprevious.size(); ++i) h->index[h->previous[i].key].push_back(i); + h->used.assign(h->previous.size(), 0); + const double limit = max_move * ticks; + for (auto& p : h->current) { + auto it = h->index.find(p.key); + uint32_t best = UINT32_MAX; + double best_d = INFINITY; + if (it != h->index.end()) for (uint32_t i : it->second) { + if (h->used[i] || !same_vertices(p, h->previous[i])) continue; + const double d = distance(p.current, h->previous[i].current); + if (d < best_d) { best = i; best_d = d; } + } + if (best == UINT32_MAX) { ++h->stats.unmatched; continue; } + h->used[best] = 1; + p.previous = h->previous[best].current; + bool placed = false; + for (unsigned i=0; i<3; ++i) + placed |= std::abs(double(p.current.t[i])-p.previous.t[i]) > limit; + PSXMotionMatrix midpoint; + p.blend = !placed && psx_motion_blend_matrix(&p.previous, &p.current, .5, 1.2, &midpoint); + if (p.blend) { + ++h->stats.matched; + if (!same_matrix(p.previous,p.current)) ++h->stats.changed; + } else ++h->stats.discontinuities; + } + } else h->stats.unmatched = h->stats.captured; + h->history_valid = !h->stats.overflow && !h->current.empty(); + // Index the current frame for replay; visibility/subdivision may reorder + // projections. Never blend a different vertex because an ordinal shifted. + h->index.clear(); + for (uint32_t i=0; icurrent.size(); ++i) h->index[h->current[i].key].push_back(i); +} +extern "C" void psx_projection_replay_begin(PSXProjectionHistory* h, uint32_t alpha) { + if (!h || !g_psx_render_pass_active) return; + h->capturing = false; h->replaying = true; + h->used.assign(h->current.size(), 0); + h->alpha = std::min(alpha, 65536u) / 65536.0; + h->stats.replayed = 0; active = h; g_psx_projection_command = psx_projection_command; +} +extern "C" void psx_projection_replay_end(PSXProjectionHistory* h) { + if (!h) return; + h->replaying = false; + if (active == h) { active = nullptr; g_psx_projection_command = nullptr; } +} +extern "C" void psx_projection_stats(const PSXProjectionHistory* h, PSXProjectionStats* out) { + if (out) *out = h ? h->stats : PSXProjectionStats{}; +} +extern "C" int psx_projection_command(CPUState* cpu, uint32_t command) { + auto* h = active; + if (!h || ((command & 63) != 1 && (command & 63) != 0x30)) return 0; + // A watchdog may unwind a replay callback without its normal end call. + // The sandbox flag, rather than a plugin-local flag, owns replay lifetime. + if (h->replaying && !g_psx_render_pass_active) { + active = nullptr; g_psx_projection_command = nullptr; h->replaying = false; return 0; + } + Projection p = projection(cpu, command); + if (h->capturing && !g_psx_render_pass_active) { + if (h->current.size() == h->capacity) ++h->stats.overflow; + else h->current.push_back(p); + return 0; + } + if (!h->replaying || !g_psx_render_pass_active) return 0; + auto it = h->index.find(p.key); + if (it == h->index.end()) return 0; + for (uint32_t i : it->second) { + const auto& c = h->current[i]; + if (h->used[i] || !same_vertices(c,p) || !same_matrix(c.current,p.current)) continue; + h->used[i] = 1; + if (!c.blend || same_matrix(c.previous,c.current)) return 0; + PSXMotionMatrix intermediate; + psx_motion_blend_matrix(&c.previous, &c.current, h->alpha, 1.2, &intermediate); + set_matrix(cpu, intermediate); ++h->stats.replayed; + return 1; + } + return 0; +} diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index 270e7b7d..48345249 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -282,6 +282,9 @@ void gr_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, uint32_t color2, uint16_t clut_x, uint16_t clut_y, uint16_t texpage, int raw_texture) { +#ifdef GPU_EXEC_RECORD_SHADED_TEXTURED + gpu_exec_record_triangle(x0, y0, x1, y1, x2, y2); +#endif (void)x0; (void)y0; (void)u0; (void)v0; (void)color0; (void)x1; (void)y1; (void)u1; (void)v1; (void)color1; (void)x2; (void)y2; (void)u2; (void)v2; (void)color2; (void)clut_x; (void)clut_y; diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py new file mode 100644 index 00000000..1ba0d33f --- /dev/null +++ b/runtime/tests/run_gl_texture_filter.py @@ -0,0 +1,54 @@ +"""Compile and check palette-aware minification on a hidden real GL context.""" +import argparse +import json +from pathlib import Path +import platform +import subprocess +import sys + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cc',required=True) + parser.add_argument('--sdl-include',required=True) + parser.add_argument('--sdl-library',required=True) + parser.add_argument('--output',required=True) + parser.add_argument('--fixture',type=Path) + args=parser.parse_args() + fw=Path(__file__).resolve().parents[2] + out=Path(args.output).resolve(); out.mkdir(parents=True,exist_ok=True) + receipt=[] + def run(command): + command=[str(x) for x in command] + r=subprocess.run(command,cwd=out,capture_output=True,text=True,encoding='utf-8',errors='replace') + receipt.append(dict(cmd=command,exit=r.returncode,stdout=r.stdout,stderr=r.stderr)) + (out/'receipt.json').write_text(json.dumps(receipt,indent=2)+'\n',encoding='utf-8') + print(r.stdout,r.stderr[-4000:],flush=True) + if r.returncode: raise subprocess.CalledProcessError(r.returncode,command) + flags=['-std=gnu11','-O1','-DPSX_SDL3=1','-DPSX_NO_DEBUG_TOOLS=1', + '-ffunction-sections','-fdata-sections','-I',fw/'runtime/include','-I',fw/'runtime/src'] + for include in args.sdl_include.split(';'): + if include: flags+=['-I',include] + sources=[('probe',args.fixture or fw/'runtime/tests/test_gl_texture_filter.c'), + ('sw',fw/'runtime/src/gpu_sw_renderer.c'),('fi',fw/'runtime/src/frame_interpolation.c'), + ('rp',fw/'runtime/src/render_pass_plan.c')] + objects=[] + for name,source in sources: + obj=out/(name+'.o'); run([args.cc,*flags,'-c',source,'-o',obj]); objects.append(obj) + exe=out/('filter.exe' if platform.system()=='Windows' else 'filter') + libraries=['-lm'] + if platform.system()=='Windows': + libraries+=['-static','-lopengl32']+['-l'+x for x in ('kernel32','user32','gdi32','winmm','imm32', + 'ole32','oleaut32','version','uuid','advapi32','setupapi','shell32','dinput8')] + elif platform.system()=='Darwin': + from run_gl_scale_invariance import MAC_FRAMEWORKS + for framework in MAC_FRAMEWORKS: libraries+=['-framework',framework] + libraries+=['-liconv','-Wl,-dead_strip'] + else: libraries+=['-lGL','-ldl','-lpthread'] + if platform.system()!='Darwin': libraries+=['-Wl,--gc-sections'] + run([args.cc,*objects,args.sdl_library,'-o',exe,*libraries]) + run([exe]) + return 0 + +if __name__=='__main__': + try: sys.exit(main()) + except subprocess.CalledProcessError as exc: sys.exit(exc.returncode) diff --git a/runtime/tests/test_boot_state_handoff.c b/runtime/tests/test_boot_state_handoff.c new file mode 100644 index 00000000..74040d65 --- /dev/null +++ b/runtime/tests/test_boot_state_handoff.c @@ -0,0 +1,85 @@ +/* Exercise the real snapshot writer/loader and handoff latch with hardware + * doubles. In particular, restore must never run the destructive boot hook. */ +#include "boot_state.c" +#include "fntrace.c" +#include + +static uint8_t ram[2*1024*1024], spad[1024], spuram[512*1024]; +static uint16_t vram[1024*512]; +static int boot_clears, cd_handoffs; +uint32_t i_stat, i_mask, g_psx_icache_tv[1024]; +uint64_t psx_cycle_count; +uint8_t* memory_get_ram_ptr(void) { return ram; } +uint32_t memory_get_ram_bytes(void) { return sizeof ram; } +uint8_t* memory_get_scratchpad_ptr(void) { return spad; } +int psx_ram_8mb_active(void) { return 0; } +void memory_clear_low_boot_scratch(void) { ++boot_clears; memset(ram,0,sizeof ram); } +void dirty_ram_clear_image_baseline(void) { ++boot_clears; } +void cdrom_notify_game_started(void) { ++cd_handoffs; } +uint32_t dirty_ram_get_bitmap_word_count(void) { return 16; } +uint32_t dirty_ram_get_bitmap_word(uint32_t i) { (void)i; return 0; } +void dirty_ram_set_bitmap_words(const uint32_t* p,uint32_t n) { (void)p; (void)n; } +void overlay_watch_invalidate_after_ram_restore(void) {} +void psx_kernel_bless_note_range(uint32_t p,uint32_t n) { (void)p; (void)n; } +void gte_canonicalize_cpu_state(CPUState* cpu) { cpu->gpr[0]=0; } +uint32_t interrupts_get_cycles_since_vblank(void) { return 42; } +void interrupts_set_cycles_since_vblank(uint32_t n) { (void)n; } +void timers_get_snapshot(uint16_t c[3],uint32_t m[3],uint16_t t[3],int32_t irq[3],uint32_t f[3]) { + memset(c,0,6); memset(m,0,12); memset(t,0,6); memset(irq,0,12); memset(f,0,12); +} +void timers_set_snapshot(const uint16_t c[3],const uint32_t m[3],const uint16_t t[3],const int32_t irq[3],const uint32_t f[3]) { + (void)c;(void)m;(void)t;(void)irq;(void)f; +} +#define MODULE(name) uint32_t name##_snapshot_bytes(void){return 4;} \ + void name##_snapshot_write(uint8_t* p){memset(p,0,4);} \ + int name##_snapshot_read(const uint8_t* p,uint32_t n){(void)p;return n==4;} +MODULE(gpu) MODULE(spu) MODULE(cdrom) MODULE(dma) MODULE(sio) MODULE(mdec) +int sio_snapshot_validate(const uint8_t* p,uint32_t n){(void)p;return n==4;} +int mdec_snapshot_validate(const uint8_t* p,uint32_t n){(void)p;return n==4;} +uint8_t* spu_get_ram_ptr(void){return spuram;} +uint32_t spu_get_ram_bytes(void){return sizeof spuram;} +uint32_t psx_mod_memory_snapshot_bytes(void){return 0;} +uint32_t psx_mod_memory_layout_cookie(void){return 0;} +void psx_mod_memory_snapshot_write(uint8_t* p){(void)p;} +int psx_mod_memory_snapshot_read(const uint8_t* p,uint32_t n){(void)p;return n==0;} +int psx_mod_memory_snapshot_validate(const uint8_t* p,uint32_t n){(void)p;return n==0;} +int gpu_vram_dirty_tracking(void){return 0;} +uint32_t gpu_vram_dirty_row_count(void){return 0;} +const uint64_t* gpu_vram_dirty_mask(void){static const uint64_t mask[8]={0};return mask;} +void gpu_vram_dirty_clear(void){} +void gr_vram_transfer_in(int x,int y,int w,int h,const uint16_t* p){(void)x;(void)y;memcpy(vram,p,(size_t)w*h*2);} +void gr_vram_transfer_out(int x,int y,int w,int h,uint16_t* p){(void)x;(void)y;memcpy(p,vram,(size_t)w*h*2);} + +static void u32(uint8_t* p,uint32_t n){PstW w;pst_w_init(&w,p,4);assert(pst_w_u32(&w,n));} +int main(void) { + CPUState cpu={0}; + fntrace_set_game_range(0x801b0000,0); + for(int compressed=0;compressed<2;++compressed) for(int started=0;started<2;++started) { + uint8_t* saved=NULL; size_t len=0; + cpu.pc=0x80050044; cpu.gpr[4]=0x12345678; ram[0x100]=0xab; + fntrace_restore_game_started(started); + assert(compressed ? boot_state_save_buffer(&cpu,123,0x801b0000,&saved,&len) + : boot_state_save_buffer_raw(&cpu,123,0x801b0000,&saved,&len)); + assert(saved[4]==9 && saved[len-4]==started); /* handoff is last */ + fntrace_restore_game_started(!started); ram[0x100]=0; cpu.pc=0; + assert(boot_state_load_buffer(saved,len,123,0x801b0000,&cpu)); + assert(fntrace_is_game_started()==started && ram[0x100]==0xab && cpu.pc==0x80050044); + assert(!boot_clears && !cd_handoffs); + /* Malformed, missing and duplicate phase sections must reject before + * changing CPU, RAM or the previously active handoff latch. */ + cpu.pc=0xdeadbeef; ram[0x100]=0x55; fntrace_restore_game_started(!started); + saved[len-4]=2; + assert(!boot_state_load_buffer(saved,len,123,0x801b0000,&cpu)); + saved[len-4]=started; + u32(saved+28,16); /* omit the last, required phase section */ + assert(!boot_state_load_buffer(saved,len-20,123,0x801b0000,&cpu)); + u32(saved+28,18); + uint8_t* duplicate=malloc(len+20); assert(duplicate); + memcpy(duplicate,saved,len); memcpy(duplicate+len,saved+len-20,20); + assert(!boot_state_load_buffer(duplicate,len+20,123,0x801b0000,&cpu)); + assert(cpu.pc==0xdeadbeef && ram[0x100]==0x55 && fntrace_is_game_started()==!started); + free(duplicate); free(saved); + } + puts("snapshot handoff: cold/warm, BIOS/game, compressed/raw and atomic rejection passed"); + return 0; +} diff --git a/runtime/tests/test_gl_texture_filter.c b/runtime/tests/test_gl_texture_filter.c new file mode 100644 index 00000000..b0aa8ea5 --- /dev/null +++ b/runtime/tests/test_gl_texture_filter.c @@ -0,0 +1,128 @@ +/* Real GL fixture, sharing the source-owned renderer stubs and setup helpers. */ +#define main scale_fixture_main +#include "test_gl_scale_invariance.c" +#undef main +void present_shot_done(int ok) { (void)ok; } +int host_osd_needs_present(void) { return 0; } +int psx_present_vsync_owns_cadence(void) { return 0; } +void latency_ring_mark(LatencyStage stage) { (void)stage; } +void psx_host_sleep_ms(unsigned ms) { SDL_Delay(ms); } +int present_shot_take(char* out,int n) { (void)out; (void)n; return 0; } +int host_osd_image(const uint32_t** p,int* w,int* h) { (void)p; (void)w; (void)h; return 0; } +int host_osd_volume_image(const uint32_t** p,int* w,int* h) { return host_osd_image(p,w,h); } +void host_osd_present_done(void) {} +int psx_rewind_overlay_image(const uint32_t** p,int* w,int* h) { return host_osd_image(p,w,h); } +float psx_rewind_slide(void) { return 0; } +int psx_savestate_menu_overlay_image(const uint32_t** p,int* w,int* h) { return host_osd_image(p,w,h); } + +static void upload_checker(int depth,int stp) { + uint16_t page[128*128], palette[256]; + for(int i=0;i<256;++i) palette[i]=0x03e0; + palette[1]=0x001f; palette[2]=0x7c00 | (stp?0x8000:0); + const int pitch=depth==0?32:depth==1?64:128; + for(int y=0;y<128;++y) for(int x=0;x>10)&31)); + check(((c>>5)&31)==0,"palette decode does not pick unrelated green entries"); + } + return total; +} +static void atlas_edge(int depth) { + uint16_t page[256*256], palette[256]; + const int word_pixels=depth==0?4:depth==1?2:1, pitch=256/word_pixels; + for(int i=0;i<256;++i) palette[i]=0x03e0; + palette[2]=0x7c00; + for(int y=0;y<256;++y) for(int x=0;x