From 67f5a82bba52c55ec145f470a37370638b796e5c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 14:12:26 -0700 Subject: [PATCH 1/4] Anchor GTE-projected HUD pieces by translation; expose recent-GTE-projection query Co-Authored-By: Claude Sonnet 5.5 --- runtime/CMakeLists.txt | 5 ++ runtime/include/gpu.h | 12 ++++ runtime/include/mod_plugins.h | 11 +++ runtime/include/ws_gte_sxy.h | 59 ++++++++++++++++ runtime/include/ws_hud_anchor.h | 62 +++++++++++++--- runtime/src/gpu.c | 82 +++++++++++++++++----- runtime/src/gte.cpp | 3 + runtime/src/mod_runtime.cpp | 6 ++ runtime/tests/test_gte_register_access.cpp | 1 + runtime/tests/test_ws_gte_sxy.c | 44 ++++++++++++ runtime/tests/test_ws_hud_anchor.c | 34 +++++++++ 11 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 runtime/include/ws_gte_sxy.h create mode 100644 runtime/tests/test_ws_gte_sxy.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 59dcc656..00d68318 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1027,6 +1027,11 @@ if(BUILD_TESTING) target_include_directories(ws_hud_anchor_test PRIVATE include) add_test(NAME ws_hud_anchor_test COMMAND ws_hud_anchor_test) + add_executable(ws_gte_sxy_test + tests/test_ws_gte_sxy.c) + target_include_directories(ws_gte_sxy_test PRIVATE include) + add_test(NAME ws_gte_sxy_test COMMAND ws_gte_sxy_test) + add_executable(gpu_textured_dot_nw_shift_exec_test tests/test_gpu_textured_dot_nw_shift_exec.c src/psx_video_timing.c diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 08550135..c7ad881f 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -518,6 +518,18 @@ void gpu_ws_set_adaptive_backdrop_preload(int enabled); * `prim` is the address of the PsyQ P_TAG word; the drawn command starts at * prim+4. anchor: -1 = left, 0 = center, +1 = right. */ void gpu_ws_tag_hud_prim(uint32_t prim, int anchor); +/* Same, for a primitive whose X the game took from GTE-projected screen + * coordinates, which already carry the widescreen X squash about the display + * centre. Projection-and-stretch translates it to the edge instead of scaling + * it again; native-wide behaves as for gpu_ws_tag_hud_prim. */ +void gpu_ws_tag_hud_prim_projected(uint32_t prim, int anchor); +/* GTE-projection provenance under the widescreen squash (ws_gte_sxy.h): the + * GTE reports each SXY it produces; a title plugin asks whether a packet + * vertex is one of them, i.e. whether the game built that primitive from + * projected geometry (its X is already squashed) rather than authored + * screen coordinates. Recorded only while the squash is engaged. */ +void gpu_ws_note_gte_sxy(int32_t x, int32_t y); +int gpu_ws_gte_sxy_recent(int32_t x, int32_t y); /* Stretch a trusted authored background primitive about the display center in * the native-wide output only (GL/SW). prim+4 is the command address, including * for a command embedded inside a longer DMA packet. Only opaque polygons and diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 671b41fe..9ad2122b 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -360,6 +360,17 @@ void psx_mod_tag_hud_primitive(uint32_t primitive, int edge); * 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); +/* As psx_mod_anchor_hud_primitive, for a packet whose X coordinates came out + * of the GTE (so they already carry the widescreen squash about the display + * centre, and the guest wrote them verbatim). Projection-and-stretch moves the + * piece to the selected edge by translation, keeping its proportions; scaling + * it about the edge again would squash it twice. */ +void psx_mod_anchor_hud_primitive_projected(uint32_t primitive, int edge); +/* Did the GTE project a vertex at screen (x, y) in this or the previous + * vblank? Under the widescreen squash a primitive whose every vertex answers + * yes was built from projected geometry (already squashed); one that does not + * carries authored screen coordinates. Always 0 while the squash is off. */ +int psx_mod_gte_sxy_recent(int32_t x, int32_t y); /* 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. diff --git a/runtime/include/ws_gte_sxy.h b/runtime/include/ws_gte_sxy.h new file mode 100644 index 00000000..a37f1a33 --- /dev/null +++ b/runtime/include/ws_gte_sxy.h @@ -0,0 +1,59 @@ +#ifndef PSXRECOMP_WS_GTE_SXY_H +#define PSXRECOMP_WS_GTE_SXY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Which screen positions did the GTE project recently? + * + * Under projection-and-stretch widescreen the GTE squashes every projected X + * about the display centre. A title that builds HUD from GTE-projected + * geometry (vector digits and lettering run through its model renderer) then + * writes already-squashed X into its packets, whereas screen-space HUD packets + * carry authored X. The two need different corrections, and the packet alone + * does not say which it is. The GTE stamps every SXY it produces here with the + * vblank it happened in; a packet vertex that matches a recent stamp was + * projected. + * + * "Recent" is WS_GTE_SXY_WINDOW vblanks: games commonly rebuild their packets + * every few vblanks and resubmit the same ordering table in between (Army Men + * rebuilds every fourth), so a retained packet is still younger than its + * projection plus one rebuild period. The cell grid is 1024 x 512, covering + * the signed 11-bit GP0 vertex range. A hit is evidence, not proof: with ~1000 + * projections a frame the chance that a whole polygon matches by accident is + * negligible, so require every vertex of a primitive to hit. */ +#define WS_GTE_SXY_W 1024u +#define WS_GTE_SXY_H 512u +#define WS_GTE_SXY_CELLS (WS_GTE_SXY_W * WS_GTE_SXY_H) +#define WS_GTE_SXY_WINDOW 8u + +typedef struct { + uint16_t stamp[WS_GTE_SXY_CELLS]; /* 0x8000 | vblank (15 bits); 0 = never */ +} WsGteSxy; + +static inline uint32_t ws_gte_sxy_cell(int32_t x, int32_t y) { + return (((uint32_t)(y + 256) & (WS_GTE_SXY_H - 1u)) * WS_GTE_SXY_W) + + ((uint32_t)(x + 512) & (WS_GTE_SXY_W - 1u)); +} + +static inline void ws_gte_sxy_note(WsGteSxy *s, uint32_t frame, + int32_t x, int32_t y) { + s->stamp[ws_gte_sxy_cell(x, y)] = (uint16_t)(0x8000u | (frame & 0x7FFFu)); +} + +static inline int ws_gte_sxy_recent(const WsGteSxy *s, uint32_t frame, + int32_t x, int32_t y) { + const uint16_t st = s->stamp[ws_gte_sxy_cell(x, y)]; + if (!st) return 0; + return ((frame - st) & 0x7FFFu) <= WS_GTE_SXY_WINDOW; +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/include/ws_hud_anchor.h b/runtime/include/ws_hud_anchor.h index 40480f23..ce5d2773 100644 --- a/runtime/include/ws_hud_anchor.h +++ b/runtime/include/ws_hud_anchor.h @@ -20,6 +20,11 @@ typedef struct { uint32_t frame; int8_t anchor; uint8_t used; + /* The primitive's X came out of the GTE and already carries the + * widescreen squash about the display centre (see ws_gte_sxy.h). The + * projection path then translates it to the anchor instead of scaling it + * about the anchor, and never lets automatic UI layout squash it again. */ + uint8_t projected; WsPrepassPacketGuard guard; } WsHudAnchorTag; @@ -54,10 +59,12 @@ static inline void ws_hud_anchor_clear(WsHudAnchorTag *tags, tags[i].used = 0; } -static inline void ws_hud_anchor_insert(WsHudAnchorTag *tags, uint32_t count, - uint32_t command_addr, int anchor, - const WsPrepassPacketGuard *guard, - uint32_t frame) { +static inline void ws_hud_anchor_insert_stored(WsHudAnchorTag *tags, + uint32_t count, + uint32_t command_addr, + int anchor, int projected, + const WsPrepassPacketGuard *guard, + uint32_t frame) { if (!tags || !count || !guard) return; uint32_t idx = ws_hud_anchor_slot(command_addr) & (count - 1u); uint32_t victim = idx; @@ -75,9 +82,29 @@ static inline void ws_hud_anchor_insert(WsHudAnchorTag *tags, uint32_t count, tags[victim].command_addr = command_addr; tags[victim].frame = frame; tags[victim].anchor = (int8_t)ws_hud_anchor_clamp(anchor); + tags[victim].projected = projected ? 1u : 0u; tags[victim].guard = *guard; } +static inline void ws_hud_anchor_insert(WsHudAnchorTag *tags, uint32_t count, + uint32_t command_addr, int anchor, + const WsPrepassPacketGuard *guard, + uint32_t frame) { + ws_hud_anchor_insert_stored(tags, count, command_addr, anchor, 0, guard, + frame); +} + +/* Tag a GTE-projected piece (see WsHudAnchorTag.projected). */ +static inline void ws_hud_anchor_insert_projected(WsHudAnchorTag *tags, + uint32_t count, + uint32_t command_addr, + int edge, + const WsPrepassPacketGuard *guard, + uint32_t frame) { + ws_hud_anchor_insert_stored(tags, count, command_addr, edge, 1, guard, + frame); +} + /* Lookup outcome, so callers can keep always-on counters that say WHY a tagged * primitive was or was not honoured (absent, expired, or packet rewritten). */ enum { @@ -87,13 +114,14 @@ enum { WS_HUD_ANCHOR_GUARD_MISMATCH = -2 }; -static inline int ws_hud_anchor_lookup_result(const WsHudAnchorTag *tags, - uint32_t count, - uint32_t command_addr, - const uint32_t *words, - uint32_t word_count, - uint32_t frame, - int *out_anchor) { +static inline int ws_hud_anchor_lookup_result_ex(const WsHudAnchorTag *tags, + uint32_t count, + uint32_t command_addr, + const uint32_t *words, + uint32_t word_count, + uint32_t frame, + int *out_anchor, + int *out_projected) { if (!tags || !count || !words) return WS_HUD_ANCHOR_ABSENT; uint32_t idx = ws_hud_anchor_slot(command_addr) & (count - 1u); for (uint32_t i = 0; i < WS_HUD_ANCHOR_PROBES && i < count; i++) { @@ -106,11 +134,23 @@ static inline int ws_hud_anchor_lookup_result(const WsHudAnchorTag *tags, if (!ws_prepass_packet_matches(&tag->guard, words, word_count)) return WS_HUD_ANCHOR_GUARD_MISMATCH; if (out_anchor) *out_anchor = tag->anchor; + if (out_projected) *out_projected = tag->projected; return WS_HUD_ANCHOR_HIT; } return WS_HUD_ANCHOR_ABSENT; } +static inline int ws_hud_anchor_lookup_result(const WsHudAnchorTag *tags, + uint32_t count, + uint32_t command_addr, + const uint32_t *words, + uint32_t word_count, + uint32_t frame, + int *out_anchor) { + return ws_hud_anchor_lookup_result_ex(tags, count, command_addr, words, + word_count, frame, out_anchor, 0); +} + static inline int ws_hud_anchor_lookup(const WsHudAnchorTag *tags, uint32_t count, uint32_t command_addr, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 2b1deaac..2733250f 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -43,6 +43,7 @@ #include "ws_backdrop_extent.h" #include "ws_prepass_guard.h" #include "ws_hud_anchor.h" +#include "ws_gte_sxy.h" #include "ws_repeat_rect.h" #include "ws_feedback_rect.h" #include "ws_screen_mask.h" @@ -2581,7 +2582,18 @@ static void ws_tag_stats_note_lookup(GpuWsTagStats *stats, int result) { } } -void gpu_ws_tag_hud_prim(uint32_t prim, int anchor) { +/* Positions the GTE projected this and the previous vblank (ws_gte_sxy.h). + * Only recorded while the squash is engaged: nothing else reads it. */ +static WsGteSxy ws_gte_sxy; +void gpu_ws_note_gte_sxy(int32_t x, int32_t y) { + if (!ws_active()) return; + ws_gte_sxy_note(&ws_gte_sxy, (uint32_t)s_frame_count, x, y); +} +int gpu_ws_gte_sxy_recent(int32_t x, int32_t y) { + return ws_gte_sxy_recent(&ws_gte_sxy, (uint32_t)s_frame_count, x, y); +} + +static void ws_tag_hud_prim(uint32_t prim, int anchor, int projected) { ws_hud_tag_stats.tag_calls++; uint32_t command_addr = prim + 4u; uint32_t words[12]; @@ -2595,11 +2607,23 @@ void gpu_ws_tag_hud_prim(uint32_t prim, int anchor) { WsPrepassPacketGuard guard = ws_prepass_packet_guard(words, word_count); - ws_hud_anchor_insert(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE, - GPU_RAM_KEY(command_addr), anchor, &guard, - (uint32_t)s_frame_count); + if (projected) + ws_hud_anchor_insert_projected(ws_hud_anchor_tags, + WS_HUD_ANCHOR_TABLE_SIZE, + GPU_RAM_KEY(command_addr), anchor, + &guard, (uint32_t)s_frame_count); + else + ws_hud_anchor_insert(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE, + GPU_RAM_KEY(command_addr), anchor, &guard, + (uint32_t)s_frame_count); ws_hud_tag_stats.last_tag_frame = (uint32_t)s_frame_count; } +void gpu_ws_tag_hud_prim(uint32_t prim, int anchor) { + ws_tag_hud_prim(prim, anchor, 0); +} +void gpu_ws_tag_hud_prim_projected(uint32_t prim, int anchor) { + ws_tag_hud_prim(prim, anchor, 1); +} void gpu_ws_tag_background_prim(uint32_t prim) { ws_background_tag_stats.tag_calls++; @@ -2730,7 +2754,7 @@ static int ws_nw_radial_mask_transform(int32_t x[4], int32_t y[4]) { return 1; } -static int ws_explicit_hud_anchor(int *out_anchor) { +static int ws_explicit_hud_anchor_ex(int *out_anchor, int *out_projected) { if (gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; if (gp0_words_needed <= 0 || gp0_words_needed > 12) @@ -2739,17 +2763,21 @@ static int ws_explicit_hud_anchor(int *out_anchor) { if ((command_addr & 3u) || (command_addr >= psx_ram_live_bytes() && !psx_mod_gpu_dma_memory_contains(command_addr, 4u))) return 0; - int anchor = 0; - int result = ws_hud_anchor_lookup_result( + int anchor = 0, projected = 0; + int result = ws_hud_anchor_lookup_result_ex( ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE, GPU_RAM_KEY(command_addr), gp0_cmd_buf, (uint32_t)gp0_words_needed, (uint32_t)s_frame_count, - &anchor); + &anchor, &projected); ws_tag_stats_note_lookup(&ws_hud_tag_stats, result); if (result != WS_HUD_ANCHOR_HIT) return 0; if (out_anchor) *out_anchor = anchor; + if (out_projected) *out_projected = projected; return 1; } +static int ws_explicit_hud_anchor(int *out_anchor) { + return ws_explicit_hud_anchor_ex(out_anchor, NULL); +} static int ws_nw_explicit_hud_delta(int32_t *out_delta) { if (out_delta) *out_delta = 0; @@ -2764,19 +2792,36 @@ static int ws_nw_explicit_hud_delta(int32_t *out_delta) { /* A title can identify one widget whose pieces arrive in separate DMA lists. * Per-list auto grouping cannot see that ownership. Use the same guarded * edge/centre role as native-wide, expressed as a squash pivot here. */ -static int ws_projection_hud_anchor(int32_t *out_anchor) { +static int ws_projection_hud_anchor_ex(int32_t *out_anchor, int *out_projected) { if (!ws_active()) return 0; - int edge; - if (!ws_explicit_hud_anchor(&edge)) return 0; + int edge, projected = 0; + if (!ws_explicit_hud_anchor_ex(&edge, &projected)) return 0; + if (out_projected) *out_projected = projected; if (out_anchor) *out_anchor = ws_disp_x() + (edge < 0 ? 0 : edge > 0 ? ws_disp_w() : ws_disp_w() / 2); return 1; } +static int ws_projection_hud_anchor(int32_t *out_anchor) { + return ws_projection_hud_anchor_ex(out_anchor, NULL); +} + +/* Shift that takes an X already squashed about the display centre to where + * scaling it about the anchor would have put it: (anchor - centre) * (1 - s). */ +static int32_t ws_projected_hud_shift(int32_t anchor) { + return ws_scale_about(0, anchor) - ws_scale_about(0, ws_disp_x() + ws_disp_w() / 2); +} + static int ws_projection_hud_transform(int32_t *x, int count) { int32_t anchor; - if (!ws_projection_hud_anchor(&anchor)) return 0; - for (int i = 0; i < count; i++) x[i] = ws_scale_about(x[i], anchor); + int projected = 0; + if (!ws_projection_hud_anchor_ex(&anchor, &projected)) return 0; + if (projected) { + const int32_t shift = ws_projected_hud_shift(anchor); + for (int i = 0; i < count; i++) x[i] += shift; + } else { + for (int i = 0; i < count; i++) x[i] = ws_scale_about(x[i], anchor); + } return 1; } @@ -3279,9 +3324,14 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], int32_t vy[4]) { static int ws_auto_ui_transform_rect(int32_t *x, int32_t *y, int *w, int *h) { if (!x || !y || !w || !h || *w <= 0) return 0; int32_t explicit_anchor; - if (ws_projection_hud_anchor(&explicit_anchor)) { - *x = ws_scale_about(*x, explicit_anchor); - *w = (int)ws_scale_len(*w); + int explicit_projected = 0; + if (ws_projection_hud_anchor_ex(&explicit_anchor, &explicit_projected)) { + if (explicit_projected) { + *x += ws_projected_hud_shift(explicit_anchor); + } else { + *x = ws_scale_about(*x, explicit_anchor); + *w = (int)ws_scale_len(*w); + } return 1; } if (ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 5d60ef36..4e81a5e4 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -17,6 +17,7 @@ extern "C" int psx_netplay_active(void); extern "C" uint32_t psx_read_word(uint32_t addr); extern "C" int gpu_ws_precise_nclip_enabled(void); +extern "C" void gpu_ws_note_gte_sxy(int32_t x, int32_t y); /* 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); @@ -1259,6 +1260,8 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) int64_t sx = sx16 >> 16; int64_t sy = sy16 >> 16; gte->push_sxy(sx, sy); + gpu_ws_note_gte_sxy((int16_t)(gte->SXY[2] & 0xFFFF), + (int16_t)((uint32_t)gte->SXY[2] >> 16)); if (!s_gte_replay_sandbox) { /* Bound the 16.16 transport so an extreme projection cannot wrap an * int32 and masquerade as a valid on-screen shadow. */ diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index e2e09ba2..e17f0eec 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -3469,6 +3469,12 @@ extern "C" void psx_mod_tag_hud_primitive(uint32_t primitive, int edge) { extern "C" void psx_mod_anchor_hud_primitive(uint32_t primitive, int edge) { gpu_ws_tag_hud_prim(primitive, edge); } +extern "C" int psx_mod_gte_sxy_recent(int32_t x, int32_t y) { + return gpu_ws_gte_sxy_recent(x, y); +} +extern "C" void psx_mod_anchor_hud_primitive_projected(uint32_t primitive, int edge) { + gpu_ws_tag_hud_prim_projected(primitive, edge); +} extern "C" void psx_mod_tag_screen_mask_quad(uint32_t primitive) { gpu_ws_tag_screen_mask_quad(primitive); } diff --git a/runtime/tests/test_gte_register_access.cpp b/runtime/tests/test_gte_register_access.cpp index c8f1f279..999787a2 100644 --- a/runtime/tests/test_gte_register_access.cpp +++ b/runtime/tests/test_gte_register_access.cpp @@ -61,6 +61,7 @@ extern "C" void gte_test_execute_reference(CPUState *cpu, uint32_t cmd); /* gte.cpp runtime dependencies that are irrelevant to register-transfer tests. */ extern "C" int gpu_ws_present_native_43(void) { return 0; } +extern "C" void gpu_ws_note_gte_sxy(int32_t, int32_t) {} static int g_test_precise_nclip_enabled; static int g_test_render_projective; extern "C" int gpu_render_projective_enabled(void) { return g_test_render_projective; } diff --git a/runtime/tests/test_ws_gte_sxy.c b/runtime/tests/test_ws_gte_sxy.c new file mode 100644 index 00000000..c2154b1b --- /dev/null +++ b/runtime/tests/test_ws_gte_sxy.c @@ -0,0 +1,44 @@ +#include "ws_gte_sxy.h" + +#include +#include +#include + +int main(void) { + WsGteSxy *s = (WsGteSxy *)calloc(1, sizeof *s); + assert(s); + + /* Nothing recorded yet, including at the zero cell. */ + assert(!ws_gte_sxy_recent(s, 10u, 100, 50)); + assert(!ws_gte_sxy_recent(s, 0u, -512, -256)); + + ws_gte_sxy_note(s, 10u, 397, 58); + ws_gte_sxy_note(s, 10u, -20, -8); + assert(ws_gte_sxy_recent(s, 10u, 397, 58)); + assert(ws_gte_sxy_recent(s, 10u, -20, -8)); + assert(!ws_gte_sxy_recent(s, 10u, 398, 58)); + assert(!ws_gte_sxy_recent(s, 10u, 397, 59)); + + /* A packet built in one vblank and resubmitted for the next several + * (the ordering table is rebuilt every fourth vblank) still matches. */ + for (uint32_t f = 10u; f <= 10u + WS_GTE_SXY_WINDOW; ++f) + assert(ws_gte_sxy_recent(s, f, 397, 58)); + assert(!ws_gte_sxy_recent(s, 10u + WS_GTE_SXY_WINDOW + 1u, 397, 58)); + + /* A fresh projection of the same position refreshes it. */ + ws_gte_sxy_note(s, 16u, 397, 58); + assert(ws_gte_sxy_recent(s, 20u, 397, 58)); + + /* A reader behind the stamp (render thread lag) does not match. */ + assert(!ws_gte_sxy_recent(s, 3u, 397, 58)); + + /* The 15-bit stamp wraps without turning ancient entries into hits. */ + ws_gte_sxy_note(s, 32767u, 1, 1); + assert(ws_gte_sxy_recent(s, 32767u, 1, 1)); + assert(ws_gte_sxy_recent(s, 32768u + 2u, 1, 1)); + assert(!ws_gte_sxy_recent(s, 32768u + 100u, 1, 1)); + + free(s); + puts("ws_gte_sxy_test: PASS"); + return 0; +} diff --git a/runtime/tests/test_ws_hud_anchor.c b/runtime/tests/test_ws_hud_anchor.c index 5f3a7089..630d7b3e 100644 --- a/runtime/tests/test_ws_hud_anchor.c +++ b/runtime/tests/test_ws_hud_anchor.c @@ -65,6 +65,40 @@ int main(void) { assert(!ws_hud_anchor_lookup(tags, WS_HUD_ANCHOR_TABLE_SIZE, 0x80010008u, packet, 4u, 20u, &anchor)); + /* GTE-projected tags keep their edge (native-wide still translates by it) + * and carry the projected flag; plain tags do not. */ + { + int projected = 7; + ws_hud_anchor_insert_projected(tags, WS_HUD_ANCHOR_TABLE_SIZE, + 0x80010004u, 5, &guard, 20u); + anchor = 0; + assert(ws_hud_anchor_lookup_result_ex( + tags, WS_HUD_ANCHOR_TABLE_SIZE, 0x80010004u, packet, 4u, + 20u, &anchor, &projected) == WS_HUD_ANCHOR_HIT); + assert(anchor == 1 && projected == 1); + ws_hud_anchor_insert_projected(tags, WS_HUD_ANCHOR_TABLE_SIZE, + 0x80010004u, -3, &guard, 20u); + assert(ws_hud_anchor_lookup_result_ex( + tags, WS_HUD_ANCHOR_TABLE_SIZE, 0x80010004u, packet, 4u, + 20u, &anchor, &projected) == WS_HUD_ANCHOR_HIT); + assert(anchor == -1 && projected == 1); + assert(ws_hud_anchor_native_delta(1, 53, anchor) == -53); + /* A projected centre tag is not the same as no tag: it must stop + * automatic layout from squashing the piece a second time. */ + ws_hud_anchor_insert_projected(tags, WS_HUD_ANCHOR_TABLE_SIZE, + 0x80010004u, 0, &guard, 20u); + assert(ws_hud_anchor_lookup_result_ex( + tags, WS_HUD_ANCHOR_TABLE_SIZE, 0x80010004u, packet, 4u, + 20u, &anchor, &projected) == WS_HUD_ANCHOR_HIT); + assert(anchor == 0 && projected == 1); + ws_hud_anchor_insert(tags, WS_HUD_ANCHOR_TABLE_SIZE, + 0x80010004u, -7, &guard, 20u); + assert(ws_hud_anchor_lookup_result_ex( + tags, WS_HUD_ANCHOR_TABLE_SIZE, 0x80010004u, packet, 4u, + 20u, &anchor, &projected) == WS_HUD_ANCHOR_HIT); + assert(anchor == -1 && projected == 0); + } + ws_hud_anchor_clear(tags, WS_HUD_ANCHOR_TABLE_SIZE); assert(!ws_hud_anchor_lookup(tags, WS_HUD_ANCHOR_TABLE_SIZE, 0x80010004u, packet, 4u, 20u, &anchor)); From dffe037ac70ea67895b3a73149bbcc488e791649 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 14:47:44 -0700 Subject: [PATCH 2/4] Make GTE-projection tracking opt-in; fix ws_aspect doc; stub new symbols in mod tests Co-Authored-By: Claude Sonnet 5.5 --- docs/WIDESCREEN.md | 3 ++- runtime/include/gpu.h | 4 +++- runtime/include/mod_plugins.h | 4 ++++ runtime/src/gpu.c | 9 +++++++-- runtime/src/mod_runtime.cpp | 3 +++ runtime/tests/test_mod_resident.cpp | 3 +++ runtime/tests/test_mod_runtime.cpp | 3 +++ runtime/tests/test_netplay_view_mods.cpp | 3 +++ runtime/tests/test_pgxp_session.cpp | 3 +++ 9 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index 710624cb..f5cad96a 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -130,7 +130,8 @@ User has visually confirmed: world geometry, characters, HUD, menus, FMVs, and proportions on the 16:9 stretch. **Live diagnostics (TCP debug server, committed `1e28a94`/earlier):** -`ws_aspect num den` (toggle the GTE squash on/off in place — `1 1` = off), +`ws_aspect num den` (set the GTE squash for a target aspect in place: `4 3` = off/identity, `16 9` = 0.75x; +note `1 1` is NOT off, it expands X by 4/3), `ws_far_threshold [t]` (set the SZ depth split + read the backdrop driver's SZ stats so the split is data-driven), `ws_census on|off| ` (always-on per-prim draw ring → CSV), `ws_margin ` (force the cull margin), and the diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c7ad881f..df0c92bc 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -527,7 +527,9 @@ void gpu_ws_tag_hud_prim_projected(uint32_t prim, int anchor); * GTE reports each SXY it produces; a title plugin asks whether a packet * vertex is one of them, i.e. whether the game built that primitive from * projected geometry (its X is already squashed) rather than authored - * screen coordinates. Recorded only while the squash is engaged. */ + * screen coordinates. Opt-in (gpu_ws_set_gte_sxy_tracking) and recorded + * only while the squash is engaged. */ +void gpu_ws_set_gte_sxy_tracking(int on); void gpu_ws_note_gte_sxy(int32_t x, int32_t y); int gpu_ws_gte_sxy_recent(int32_t x, int32_t y); /* Stretch a trusted authored background primitive about the display center in diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 9ad2122b..cc9b8934 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -371,6 +371,10 @@ void psx_mod_anchor_hud_primitive_projected(uint32_t primitive, int edge); * yes was built from projected geometry (already squashed); one that does not * carries authored screen coordinates. Always 0 while the squash is off. */ int psx_mod_gte_sxy_recent(int32_t x, int32_t y); +/* Opt in to (1) or out of (0) the recent-projection record behind + * psx_mod_gte_sxy_recent. Off by default: until a plugin enables it nothing is + * recorded and the query answers 0. Call from an activation callback. */ +void psx_mod_set_gte_sxy_tracking(int enabled); /* 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. diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 2733250f..b496c7b1 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2583,13 +2583,18 @@ static void ws_tag_stats_note_lookup(GpuWsTagStats *stats, int result) { } /* Positions the GTE projected this and the previous vblank (ws_gte_sxy.h). - * Only recorded while the squash is engaged: nothing else reads it. */ + * Opt-in: nothing is recorded (and every query answers no) until a title + * plugin enables it, and only while the squash is engaged, so titles that do + * not use the query pay nothing and see no change. */ static WsGteSxy ws_gte_sxy; +static int ws_gte_sxy_tracking; /* opt-in: a title plugin turns it on */ +void gpu_ws_set_gte_sxy_tracking(int on) { ws_gte_sxy_tracking = on ? 1 : 0; } void gpu_ws_note_gte_sxy(int32_t x, int32_t y) { - if (!ws_active()) return; + if (!ws_gte_sxy_tracking || !ws_active()) return; ws_gte_sxy_note(&ws_gte_sxy, (uint32_t)s_frame_count, x, y); } int gpu_ws_gte_sxy_recent(int32_t x, int32_t y) { + if (!ws_gte_sxy_tracking) return 0; return ws_gte_sxy_recent(&ws_gte_sxy, (uint32_t)s_frame_count, x, y); } diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index e17f0eec..cf1a23b4 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -3469,6 +3469,9 @@ extern "C" void psx_mod_tag_hud_primitive(uint32_t primitive, int 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_set_gte_sxy_tracking(int enabled) { + gpu_ws_set_gte_sxy_tracking(enabled); +} extern "C" int psx_mod_gte_sxy_recent(int32_t x, int32_t y) { return gpu_ws_gte_sxy_recent(x, y); } diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index 0cc057b1..e8be7b65 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -60,6 +60,9 @@ extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { *out = GpuDisplayInf extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} 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_hud_prim_projected(uint32_t, int) {} +extern "C" void gpu_ws_set_gte_sxy_tracking(int) {} +extern "C" int gpu_ws_gte_sxy_recent(int32_t, int32_t) { return 0; } 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; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 5e5e7b08..366654e1 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -192,6 +192,9 @@ static int test_disc_request(int *ready) { ++disc_request_calls; *ready = 1; ret static void test_disc_request_activate(void) { psx_mod_set_disc_request_provider(test_disc_request); } /* Widescreen tag passthroughs mod_runtime forwards to the GPU; unused here. */ extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} +extern "C" void gpu_ws_tag_hud_prim_projected(uint32_t, int) {} +extern "C" void gpu_ws_set_gte_sxy_tracking(int) {} +extern "C" int gpu_ws_gte_sxy_recent(int32_t, int32_t) { return 0; } 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; } diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp index 3c3b6b1b..e1787d2f 100644 --- a/runtime/tests/test_netplay_view_mods.cpp +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -156,6 +156,9 @@ extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} extern "C" int fntrace_is_game_started(void) { return 1; } /* Widescreen tag passthroughs mod_runtime forwards to the GPU; unused here. */ extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} +extern "C" void gpu_ws_tag_hud_prim_projected(uint32_t, int) {} +extern "C" void gpu_ws_set_gte_sxy_tracking(int) {} +extern "C" int gpu_ws_gte_sxy_recent(int32_t, int32_t) { return 0; } 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; } diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index 33321813..41f6feb5 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -135,6 +135,9 @@ extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { *out = GpuDisplayInf extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} 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_hud_prim_projected(uint32_t, int) {} +extern "C" void gpu_ws_set_gte_sxy_tracking(int) {} +extern "C" int gpu_ws_gte_sxy_recent(int32_t, int32_t) { return 0; } extern "C" void gpu_ws_tag_world_primitive(uint32_t, int) {} extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {} extern "C" void psx_projection_reset_session(void) {} From de5fa2356264620a88d352256135f4de0b04f34a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 16:54:04 -0700 Subject: [PATCH 3/4] Scale widescreen cull margin to the display width; keep ratio sites on the 320 basis Co-Authored-By: Claude Sonnet 5.5 --- docs/WIDESCREEN.md | 12 ++++ runtime/CMakeLists.txt | 14 +++++ runtime/include/gpu.h | 2 + runtime/src/gpu.c | 72 ++++++++++++++++++----- runtime/tests/test_ws_cull_margin_width.c | 54 +++++++++++++++++ 5 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 runtime/tests/test_ws_cull_margin_width.c diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index f5cad96a..8106bf5c 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -584,3 +584,15 @@ To enable 21:9 later: widen the backdrop generation, then promote the launcher's 2-state toggle to a 3-way Off / 16:9 / 21:9 (the `cycle_aspect` callback already cycles `aspect_index` 0/1/2 and is the scaffold). Full feasibility analysis in the session memory `native_wide_fov_autocull.md`. + +## Cull margin follows the display width (2026-10-24) + +`psx_ws_x_margin()` on the squash path was `160 * (1/s - 1)`, correct only for +320-pixel modes. The reveal is a fraction of the game's own half-width, so it +is now `(W/2) * (1/s - 1)` with `W` the live horizontal display width (512 in +Army Men's gameplay, 368 in Ape Escape): pixel-domain screen-X culls +(`[widescreen.cull]`) get that margin. Ratio-style consumers (terrain angle, +aspect cone, `cull_scale`, the gameplay-field scaler) read +`psx_ws_x_margin_320()`, the same reveal on the 320 basis, and keep their +behaviour in every mode. 320-wide titles are unchanged bit for bit. +Test: `ws_cull_margin_width_test`. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 00d68318..5bd0a08f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1095,6 +1095,20 @@ if(BUILD_TESTING) endif() add_test(NAME ws_local_view_margin_test COMMAND ws_local_view_margin_test) + add_executable(ws_cull_margin_width_test + tests/test_ws_cull_margin_width.c + src/ws_ui_group.c + src/psx_video_timing.c + src/gpu_timeline.c) + target_include_directories(ws_cull_margin_width_test PRIVATE include src) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(ws_cull_margin_width_test PRIVATE + -ffunction-sections -fdata-sections) + target_link_options(ws_cull_margin_width_test PRIVATE + $,-Wl$-dead_strip,-Wl$--gc-sections>) + endif() + add_test(NAME ws_cull_margin_width_test COMMAND ws_cull_margin_width_test) + # Depth24 GP1(07h) present hold (#636): hold count, upload span and the # staged frame agree across windowed/headless/deferred presents, rollback # replay and disk loads; stale trailing FMV pixels never reach a frame. diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index df0c92bc..9812b462 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -316,6 +316,8 @@ int gpu_ws_present_native_43(void); /* Per-side X cull-margin (screen/world units) emitted into the game's draw- * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ int psx_ws_x_margin(void); +/* The same reveal on the 320-pixel basis (ratio-style sites: angles, cones). */ +int psx_ws_x_margin_320(void); /* Netplay own-view mods: while on, psx_ws_x_margin() is 0 (the stock cull) * except inside a sandboxed local-view render (render_pass.c sets the scope). */ void gpu_ws_set_local_view_only(int on); diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index b496c7b1..a778c4b8 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -717,6 +717,8 @@ int gpu_ws_netplay_local_viewport_width(void) { * unconditionally so a probe can sweep the cull margin live (0 = force 4:3 * cull while still stretching; large = over-draw) at a fixed camera position. * -1 = normal computed margin. */ +static uint32_t gpu_h_display_width(void); +static int ws_px_to_320(int px); static int ws_margin_override = -1; static int ws_cull_guard_pixels = 0; static int ws_activation_guard_pixels = 0; @@ -1170,7 +1172,7 @@ void gpu_ws_set_cull_scale_sites(const uint32_t *addresses, * (x < half*z/H) widens to the live view by scaling the bound by * (half + margin) / half. 64-bit intermediate; saturates to int32. */ int32_t psx_ws_cull_scale(int32_t bound, int32_t half_extent) { - const int32_t margin = psx_ws_x_margin(); + const int32_t margin = psx_ws_x_margin_320(); if (margin <= 0 || half_extent <= 0) return bound; int64_t v = (int64_t)bound * (int64_t)(half_extent + margin) / half_extent; if (v > INT32_MAX) v = INT32_MAX; @@ -1226,7 +1228,7 @@ void gpu_ws_set_angle_sites(const uint32_t *addresses, uint32_t psx_ws_angle_widen(uint32_t vanilla) { ws_angle_calls++; - const int margin = psx_ws_x_margin(); + const int margin = psx_ws_x_margin_320(); if (margin <= 0) { ws_angle_43_identity++; return vanilla; @@ -1449,7 +1451,7 @@ uint32_t psx_ws_aspect_cone_result(uint32_t site_address, uint32_t vanilla, ws_aspect_cone_calls++; site->calls++; - const int total_margin = psx_ws_x_margin(); + const int total_margin = psx_ws_x_margin_320(); if (total_margin <= 0) { ws_aspect_cone_43_identity++; site->identity_43++; @@ -1474,8 +1476,8 @@ uint32_t psx_ws_aspect_cone_result(uint32_t site_address, uint32_t vanilla, } const int visible_margin = - (total_margin > ws_cull_guard_pixels) - ? total_margin - ws_cull_guard_pixels : 0; + (total_margin > ws_px_to_320(ws_cull_guard_pixels)) + ? total_margin - ws_px_to_320(ws_cull_guard_pixels) : 0; const int32_t fx = (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 0u); const int32_t fz = @@ -1494,7 +1496,7 @@ uint32_t psx_ws_aspect_cone_result(uint32_t site_address, uint32_t vanilla, !in_visible && !in_guard && state->active && psx_ws_aspect_cone_contains( x, z, y, fx, fz, fy, threshold, - total_margin + (int)ws_aspect_cone_hysteresis_pixels); + total_margin + ws_px_to_320((int)ws_aspect_cone_hysteresis_pixels)); if (!in_visible && !in_guard && !in_hysteresis) { state->active = 0; @@ -1547,6 +1549,18 @@ void gpu_ws_set_local_view_only(int on) { ws_local_view_only = on ? 1 : 0; } void gpu_ws_set_local_view_scope(int on) { ws_local_view_scope = on ? 1 : 0; } int gpu_ws_local_view_only(void) { return ws_local_view_only; } +/* Half of the game's current horizontal display width (160 for 320 modes). */ +static int ws_cull_half_width(void) { + int src_w = 0; + uint32_t w; + if (ws_local_viewport_layout(NULL, &src_w, NULL, NULL)) + w = (uint32_t)src_w; + else + w = gpu_h_display_width(); + if (w < 64u) w = 320u; + return (int)(w / 2u); +} + int psx_ws_x_margin(void) { if (ws_local_view_only && !ws_local_view_scope) return 0; if (ws_margin_override >= 0) return ws_margin_override; @@ -1565,10 +1579,28 @@ int psx_ws_x_margin(void) { return ws_nw_configured_offset() * (ws_view_camera_addr ? 2 : 1) + ws_cull_guard_pixels; if (!ws_active()) return 0; - return (160 * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum + /* The reveal is a fraction of the game's own half-width: a 512-wide mode + * (Army Men in-game) hides 256*(1/s-1) px per side, not the 160*(1/s-1) + * a 320 mode does. The screen-X compares live in that mode's pixels. */ + return (ws_cull_half_width() * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum + ws_cull_guard_pixels; } +/* A pixel quantity of the current display mode on the 320-pixel basis. */ +static int ws_px_to_320(int px) { + const int half = ws_cull_half_width(); + return half == 160 ? px : (int)(((int64_t)px * 160 + half / 2) / half); +} + +/* The same margin on the 320-pixel basis. Camera-space frustum angles, aspect + * cones, scaled bounds and gameplay-field scalers are ratios authored against + * a 160-pixel half-screen; they must not grow with the display mode. */ +int psx_ws_x_margin_320(void) { + const int m = psx_ws_x_margin(); + const int half = ws_cull_half_width(); + return half == 160 ? m : (int)(((int64_t)m * 160 + half / 2) / half); +} + int psx_ws_activation_margin(void) { const int margin = psx_ws_x_margin(); return margin > 0 ? margin + ws_activation_guard_pixels : 0; @@ -1576,7 +1608,7 @@ int psx_ws_activation_margin(void) { int32_t psx_ws_player_x_bound(int32_t vanilla) { - const int margin = psx_ws_x_margin(); + const int margin = psx_ws_x_margin_320(); if (margin <= 0) return vanilla; return (int32_t)(((int64_t)vanilla * (320 + 2 * margin)) / 320); } @@ -4591,12 +4623,10 @@ int gpu_display_is_depth24(void) { return (int)(display_depth & 1u); } -void gpu_get_display_info(GpuDisplayInfo* out) { - out->display_x = display_area_x; - out->display_y = display_area_y; - out->depth24 = (int)(display_depth & 1u); - out->disabled = (int)display_disabled; - +/* Visible horizontal pixel width (GP1(08h) mode + GP1(06h) range). Shared by + * gpu_get_display_info and the widescreen cull margin, which is called per + * vertex and must not pay for the vertical layout. */ +static uint32_t gpu_h_display_width(void) { /* Dot-clock divider from GP1(08h) hres (PSX-SPX graphicsprocessingunitgpu, * "Horizontal Timings", Dotclocks: /10, /8, /7, /5, /4). */ uint32_t cycles; @@ -4620,6 +4650,18 @@ void gpu_get_display_info(GpuDisplayInfo* out) { if (w == 0u) w = 4u; } + if (w > 640) w = 640; + return w; +} + +void gpu_get_display_info(GpuDisplayInfo* out) { + out->display_x = display_area_x; + out->display_y = display_area_y; + out->depth24 = (int)(display_depth & 1u); + out->disabled = (int)display_disabled; + + uint32_t w = gpu_h_display_width(); + /* DuckStation GetFullDisplayResolution: clamp Y1/Y2 to the broadcast * active region before taking the difference. Unclamped Y2 past the * active end (common overscan programming) includes a flickering junk @@ -4645,7 +4687,7 @@ void gpu_get_display_info(GpuDisplayInfo* out) { * of the video — do not reintroduce it. Right-edge junk is a separate * present/filter issue, not a reason to shrink CRTC width. */ - /* Clamp to sane maximums */ + /* Clamp to sane maximums (gpu_h_display_width clamps too) */ if (w > 640) w = 640; if (out->depth24) { if (screen_h > PSX_DISPLAY_PRESENT_MAX_HEIGHT) diff --git a/runtime/tests/test_ws_cull_margin_width.c b/runtime/tests/test_ws_cull_margin_width.c new file mode 100644 index 00000000..526e6017 --- /dev/null +++ b/runtime/tests/test_ws_cull_margin_width.c @@ -0,0 +1,54 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif + +/* The squash path reveals a fraction of the game's own half-width, so the + * per-side cull margin follows the display mode: 320 px keeps the historical + * 160*(1/s-1), a 512 px mode (Army Men in-game) hides 256*(1/s-1) px per side. + * Ratio-style sites (angles, cones, scaled bounds) read the 320-basis margin and + * never change with the mode. */ +#include +#include +#include +#include +#include "../src/gpu.c" +#include "gpu_exec_stubs.inc" + +static void set_mode(int hres, uint32_t x2) { + hres1 = (uint32_t)hres; hres2 = 0; video_mode = 0; + h_display_x1 = 0x260; h_display_x2 = x2; + v_display_y1 = 0x10; v_display_y2 = 0x100; +} + +int main(void) { + ws_mode = 1; + ws_gte_game_mode_cfg = 1; /* game frame: squash is active */ + ws_last_gte_stamp = (uint32_t)s_frame_count; + ws_cfg_num = 16; ws_cfg_den = 9; + ws_xnum = 3; ws_xden = 4; /* 16:9 squash */ + + set_mode(1, 0xC60); /* 320 wide */ + assert(gpu_h_display_width() == 320); + assert(psx_ws_x_margin() == 53); + assert(psx_ws_x_margin_320() == 53); + + set_mode(2, 0xC60); /* 512 wide */ + assert(gpu_h_display_width() == 512); + assert(psx_ws_x_margin() == 85); /* 256 * (4/3 - 1) */ + assert(psx_ws_x_margin_320() == 53); /* ratio sites unchanged */ + assert(psx_ws_player_x_bound(160) == 160 * (320 + 2 * 53) / 320); + + ws_xnum = 8; ws_xden = 15; /* 2.5:1 */ + assert(psx_ws_x_margin() == 224); /* 256 * (15/8 - 1) */ + assert(psx_ws_x_margin_320() == 140); + + gpu_ws_set_cull_guard_pixels(32); + assert(psx_ws_x_margin() == 256); + gpu_ws_set_cull_guard_pixels(0); + + ws_xnum = ws_xden = 1; /* true 4:3 stays identity */ + assert(psx_ws_x_margin() == 0); + assert(psx_ws_x_margin_320() == 0); + puts("ws_cull_margin_width_test: ok"); + return 0; +} From b11aab7598eb9e3a1b15a55a46ecdf6974a9becb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 25 Oct 2026 01:32:07 -0700 Subject: [PATCH 4/4] Poll pending save requests at native IRQ fast edges --- runtime/include/savestate.h | 3 +++ runtime/src/interrupts.c | 7 +++--- runtime/src/savestate.c | 22 ++++++++--------- runtime/tests/restore_edge_irq_stubs.c | 10 +++++++- runtime/tests/test_restore_edge_irq_replay.c | 25 ++++++++++++++++++++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index 276127b1..a431c7a3 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -165,6 +165,9 @@ void psx_frontend_on_savestate_refused(int is_load, int slot, const char *reason * pending, serialize with cpu->pc = resume_pc; if a load is pending, restore and * longjmp to the scheduler (never returns in that case). Near-free when idle. */ void savestate_poll(CPUState* cpu, uint32_t resume_pc); +/* Host request latch for the IRQ maintenance fast paths. Read-only outside + * savestate.c: a staged request must not wait thousands of quiet block edges. */ +extern int g_psx_savestate_pending; #ifdef __cplusplus } diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index c229733b..4d8e3626 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -26,6 +26,7 @@ */ #include "interrupts.h" +#include "savestate.h" #include "mod_runtime.h" #include "psx_memory.h" #include "sio.h" @@ -1208,7 +1209,7 @@ void psx_check_interrupts(CPUState* cpu) { uint32_t sr = cpu->cop0[COP0_SR]; if (!(sr & 0x01u) || !(sr & (1u << 10))) { s_irq_path_fast_sr++; - if ((++s_fast_maintenance & 0x3FFFu) == 0) { + if ((++s_fast_maintenance & 0x3FFFu) == 0 || g_psx_savestate_pending) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); @@ -1262,7 +1263,7 @@ void psx_check_interrupts(CPUState* cpu) { } if ((i_stat & i_mask) == 0 && sw_pending == 0) { s_irq_path_fast_none++; - if ((++s_fast_maintenance & 0x3FFFu) == 0) { + if ((++s_fast_maintenance & 0x3FFFu) == 0 || g_psx_savestate_pending) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); @@ -1291,7 +1292,7 @@ void psx_check_interrupts(CPUState* cpu) { s_last_interrupt_check_cycle = psx_get_cycle_count(); g_ls_suppress_record++; total_checks++; - if ((total_checks & 0x3FFFu) == 0) { + if ((total_checks & 0x3FFFu) == 0 || g_psx_savestate_pending) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 4ea99231..3b5301ea 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -64,7 +64,7 @@ static int s_load_pending = -1; static int s_load_completed = 0; static int s_load_failed = 0; static uint32_t s_status_generation = 0; -static int s_status_pending = 0; +int g_psx_savestate_pending = 0; static int s_status_last_ok = 0; static int s_status_last_load = 0; static int s_status_last_slot = -1; @@ -202,7 +202,7 @@ static void cancel_pending_states(void) { if (s_load_pending >= 0) s_load_failed = 1; s_save_pending = s_load_pending = s_save_defer_slot = -1; s_last_save_pc = 0; - s_status_pending = 0; + g_psx_savestate_pending = 0; clear_load_blob(); } @@ -788,7 +788,7 @@ static int request_save_inner(int slot) { s_last_save_pc = 0; /* block netplay transfer until this write stamps a PC */ s_save_defer_slot = -1; s_save_pending = slot; - s_status_pending = 1; + g_psx_savestate_pending = 1; s_status_last_load = 0; s_status_last_slot = slot; return 1; @@ -814,7 +814,7 @@ static int request_load_inner(int slot) { s_load_failed = 0; s_load_completed = 0; s_load_pending = slot; - s_status_pending = 1; + g_psx_savestate_pending = 1; s_status_last_load = 1; s_status_last_slot = slot; return 1; @@ -876,7 +876,7 @@ int savestate_pending(void) { void savestate_get_status(PSXSavestateStatus* out) { if (!out) return; out->generation = s_status_generation; - out->pending = s_status_pending; + out->pending = g_psx_savestate_pending; out->last_ok = s_status_last_ok; out->last_load = s_status_last_load; out->last_slot = s_status_last_slot; @@ -887,7 +887,7 @@ void savestate_status_json(char* buf, size_t cap) { snprintf(buf, cap, "\"generation\":%u,\"pending\":%d,\"last_ok\":%d," "\"last_op\":\"%s\",\"last_slot\":%d", - (unsigned)s_status_generation, s_status_pending, + (unsigned)s_status_generation, g_psx_savestate_pending, s_status_last_ok, s_status_last_load ? "load" : "save", s_status_last_slot); } @@ -953,7 +953,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { s_save_defer_slot = -1; s_last_save_pc = 0; s_save_failed = 1; - s_status_pending = 0; + g_psx_savestate_pending = 0; s_status_last_ok = 0; s_status_generation++; fprintf(stderr, @@ -992,7 +992,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { s_last_save_pc = 0; s_save_failed = 1; } - s_status_pending = 0; + g_psx_savestate_pending = 0; s_status_last_ok = ok ? 1 : 0; s_status_generation++; fprintf(stderr, "savestate: %s slot %d @ pc=0x%08X -> %s\n", @@ -1001,7 +1001,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { } else { s_last_save_pc = 0; s_save_failed = 1; - s_status_pending = 0; + g_psx_savestate_pending = 0; s_status_last_ok = 0; s_status_generation++; psx_frontend_on_savestate_notify(0, slot, 0); @@ -1083,7 +1083,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { #endif free(rollback); if (!loaded) { - s_status_pending = 0; + g_psx_savestate_pending = 0; s_status_last_ok = 0; s_status_generation++; } @@ -1100,7 +1100,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { cdrom_accelerate_after_savestate(); /* Netplay post-load barrier observes this before the longjmp. */ s_load_completed = 1; - s_status_pending = 0; + g_psx_savestate_pending = 0; s_status_last_ok = 1; s_status_generation++; /* Restage FBO/present latch so the restored frame is visible diff --git a/runtime/tests/restore_edge_irq_stubs.c b/runtime/tests/restore_edge_irq_stubs.c index 1f1007c8..67212711 100644 --- a/runtime/tests/restore_edge_irq_stubs.c +++ b/runtime/tests/restore_edge_irq_stubs.c @@ -92,7 +92,15 @@ void psx_idle_note_check(struct CPUState *c, uint32_t pc) { (void)c; (void)pc; } int psx_netplay_active(void) { return 0; } int psx_selfcheck_enabled(void) { return 0; } int psx_mod_function_entry_active(void) { return 0; } -void savestate_poll(struct CPUState *c, uint32_t pc) { (void)c; (void)pc; } +int g_psx_savestate_pending; +unsigned g_test_savestate_polls; +uint32_t g_test_savestate_pc; +void savestate_poll(struct CPUState *c, uint32_t pc) { + (void)c; + ++g_test_savestate_polls; + g_test_savestate_pc = pc; + g_psx_savestate_pending = 0; +} void psx_netplay_poll_snap(struct CPUState *c, uint32_t pc) { (void)c; (void)pc; } void psx_selfcheck_poll(struct CPUState *c, uint32_t pc) { (void)c; (void)pc; } void psx_rewind_poll(struct CPUState *c, uint32_t pc) { (void)c; (void)pc; } diff --git a/runtime/tests/test_restore_edge_irq_replay.c b/runtime/tests/test_restore_edge_irq_replay.c index 411504b8..657f74a4 100644 --- a/runtime/tests/test_restore_edge_irq_replay.c +++ b/runtime/tests/test_restore_edge_irq_replay.c @@ -510,6 +510,31 @@ int main(void) } } + /* A quiet native menu may never accumulate 16K block checks before its + * attract timer expires. Staged host requests must poll at the next edge, + * including the no-IRQ and masked-IRQ fast paths. */ + { + extern int g_psx_savestate_pending; + extern unsigned g_test_savestate_polls; + extern uint32_t g_test_savestate_pc; + static CPUState cpu; + for (int masked = 0; masked < 2; ++masked) { + Program p = game; + reset_machine(&cpu, &p); + i_mask = 1; + i_stat = masked ? 1 : 0; + cpu.cop0[C0_SR] = masked ? 0 : (SR_IEC | SR_IM2); + unsigned before = g_test_savestate_polls; + g_psx_savestate_pending = 1; + psx_check_interrupts_at(&cpu, p.base); + if (g_test_savestate_polls != before + 1 || + g_test_savestate_pc != p.base || g_psx_savestate_pending) { + fprintf(stderr, "pending snapshot missed %s fast edge\n", + masked ? "masked IRQ" : "no IRQ"); + fail = 1; + } + } + } if (fail) { puts("restore_edge_irq_replay_test: FAIL"); return 1; } puts("restore_edge_irq_replay_test: PASS"); return 0;