diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index 710624cb..413ea23a 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -583,3 +583,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 59dcc656..7ea3ba1f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1090,6 +1090,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 08550135..cd37ecf3 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 2b1deaac..94f40776 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -716,6 +716,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; @@ -1169,7 +1171,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; @@ -1225,7 +1227,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; @@ -1448,7 +1450,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++; @@ -1473,8 +1475,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 = @@ -1493,7 +1495,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; @@ -1546,6 +1548,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; @@ -1564,10 +1578,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; @@ -1575,7 +1607,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); } @@ -4536,12 +4568,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; @@ -4565,6 +4595,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 @@ -4590,7 +4632,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; +}