mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
56b4a3d08a
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
599 lines
35 KiB
Markdown
599 lines
35 KiB
Markdown
# Widescreen support (`feat/widescreen`)
|
||
|
||
## Finite 2D backdrop falls back to 4:3 (2026-10-07)
|
||
|
||
Pre-rendered 2D scenes draw their background as a few large textured
|
||
rectangles before any polygon. Rects are screen-space blits, so the image is
|
||
exactly as wide as those rects. Polygon overhang alone cannot see that: widened
|
||
2D actor culls put sprite quads past the canonical edge, and Tomba's overhead
|
||
village (384x256 ground image, fence/sign quads out to x=428) presented wide
|
||
with black margins and props floating over them (`beads-eio.4.28`).
|
||
|
||
`ws_backdrop_extent.h` takes each frame's leading run of rectangles (closed by
|
||
the first polygon or line), keeps the untagged, unstretched textured ones and
|
||
grids them over `[-reveal, W + reveal)` using the configured reveal. If they
|
||
cover at least 90% of the canonical display but leave any reveal cell empty in
|
||
the rows they occupy, the frame is short. Two short frames within six frames
|
||
veto native-wide in the 2D-only-scene classifier until short frames stop for
|
||
`WS_2D_SCENE_HYSTERESIS` frames. Tagged, stretched or host-extended backdrops,
|
||
untextured rects and everything after the first polygon are neutral. The veto
|
||
uses the same scope as the 2D-only classifier (sprite-tag titles; not
|
||
`full_2d` or `gte_game_mode`), and an explicit title world-scene predicate
|
||
still wins. Observability: `gpu_state.ws.backdrop` and the last field
|
||
(`bd_veto`) of each `present_ring` event. Tests: `ws_backdrop_extent_test`,
|
||
`ws_backdrop_scene_test`.
|
||
|
||
## Opt-in native-wide view anchoring (2026-09-12)
|
||
|
||
`gpu_ws_set_view_anchor(camera, min, max, active)` reads a game's signed
|
||
16-bit camera and clamp bounds at BG renderer setup. It shifts only the host
|
||
wide mirror, preserving the guest camera and canonical VRAM. The native
|
||
view's left edge appears at `extra + shift`; `extra` remains the symmetric
|
||
width budget. The geometry helper distributes reveal toward the room interior
|
||
and centers rooms narrower than the frame with explicit padding.
|
||
|
||
SW, OpenGL and Vulkan implement `gr_wide_set_view`. Explicit HUD packets keep
|
||
their existing physical origin. OpenGL's canonical-center copy is disabled
|
||
while anchoring is active because world and UI no longer share an origin.
|
||
|
||
For the existing Capcom BG2D layout, bracket each layer's packet producer with
|
||
`gpu_ws_bg2d_begin_view_layer` / `gpu_ws_bg2d_end_view_layer`. The independent
|
||
layer mask permits parallax backdrops to anchor to their own map bounds;
|
||
tile generation and ring refill use that layer's actual left/right coverage.
|
||
Actor activation uses a constant envelope covering either edge origin.
|
||
Zero configuration is inactive; 4:3 retains the original path.
|
||
|
||
Tests: `ws_view_anchor_test`, `ws_view_anchor_gpu_test`, and the hidden real
|
||
OpenGL `test_gl_readback_region.c` fixture. MMX6 is the first consumer;
|
||
owner gameplay validation is still pending (`beads-eio.3.146`).
|
||
|
||
## Earlier squash-mode implementation
|
||
## Explicit native-wide background polygons (September 2026)
|
||
|
||
A title plugin can call `gpu_ws_tag_background_prim(prim)` for each opaque
|
||
background polygon in a verified composite, before submitting its ordering
|
||
table. The command starts at `prim + 4`; an embedded command can therefore use
|
||
`command_address - 4` even when that preceding word is not a DMA header.
|
||
The framework validates command type, RAM bounds, complete packet contents,
|
||
address and freshness. The title owns the proof that these are backgrounds
|
||
and that their union covers the authored viewport. No fixed game addresses or
|
||
background guesses belong in the shared API.
|
||
|
||
OpenGL and software rendering stretch only those polygons about the display
|
||
center in the native-wide surface, preserving canonical VRAM and the default
|
||
4:3 path. Tag every part, including flat fills behind textured sky quads, so
|
||
the composite remains continuous. Tags expire after two frames and reject
|
||
reused packets whose words changed. This method performs no guest writes or
|
||
extra guest allocations.
|
||
|
||
OpenGL preserves the tag across flat and textured batching. Once a valid
|
||
background has been tagged, it renders the full wide composite instead of
|
||
copying the canonical center over it, which would create a scale seam. Packet
|
||
freshness and displayed-pixel lifetime are separate: a slow game or held display
|
||
can retain stretched pixels after the two-frame packet guard expires. The
|
||
full-composite requirement therefore remains latched until GPU reset or snapshot
|
||
restore; packet-address/content freshness is not relaxed. Sessions that have not
|
||
tagged a background retain the center-copy optimization. Measure performance in
|
||
the title and validate the visible sky at the requested aspect before release.
|
||
Vulkan's native-wide compositor is not implemented; this API does not add one.
|
||
|
||
### Parked validation checkpoint (2026-09-12)
|
||
|
||
The focused GPU packet/tag regression passes, including expired packets,
|
||
changed packet contents, disabled widescreen and retained composite lifetime.
|
||
The V8 Windows runtime builds and both game CTests pass. In the oil-field
|
||
21:9 playtest the owner reports that terrain looks fine and the background
|
||
flickers less badly, but still flickers. This is partial improvement, not visual
|
||
acceptance. State replay required the HLE scheduler; repeat the comparison with
|
||
matched scheduler settings before attributing the improvement solely to this
|
||
change. Work is parked in a draft; no release is approved.
|
||
|
||
## Earlier projection-and-stretch implementation
|
||
|
||
Status as of 2026-06-13. Branch `feat/widescreen` in **both** `psxrecomp`
|
||
(framework) and `TombaRecomp` (game opt-in + config), pushed to remote.
|
||
Experimental. Latest: the far-backdrop edge void is largely fixed (2D backdrop
|
||
screenX squash + a depth-gated GTE-3D un-squash); a native-wide rewrite that
|
||
would retire the squash entirely was spiked and shelved (see below).
|
||
|
||
The framework feature is **generic and parameterized**: every game gets the
|
||
plain behaviour for free, all knobs default to inert, and `aspect_ratio = "4:3"`
|
||
(the default) is a mathematical identity — byte-for-byte the original
|
||
presentation. A game opts in via `[video] aspect_ratio` plus an optional
|
||
`[widescreen]` block. One build serves both aspects; the launcher's
|
||
**Settings → Aspect ratio** toggle selects it at runtime (persisted to
|
||
`settings.toml`).
|
||
|
||
This is the DuckStation/Beetle "widescreen hack" (squash the GTE projection,
|
||
present stretched to the wide aspect → wider field of view), but implemented
|
||
in our GTE library + GPU so it covers generated code, the interpreter, and
|
||
overlay DLLs uniformly — and extended well past what emulators ship with
|
||
(per-prim proportion correction, so sprites/HUD are NOT stretched).
|
||
|
||
---
|
||
|
||
## What works (committed, user-validated)
|
||
|
||
| Commit | What |
|
||
|---|---|
|
||
| `f8a29e8` | `[video] aspect_ratio = "W:H"` → GTE X-squash (`gte_set_display_aspect`, `runtime/src/gte.cpp`) + aspect-aware letterbox in both renderers + window opens at aspect. Config plumbed through `game.toml`, `settings.toml` (load+save), launcher seed (`recompiler/src/config_loader.{h,cpp}`, `runtime/src/main.cpp`, `runtime/src/gpu_gl_renderer.c`). |
|
||
| `970670b` | Launcher **Aspect ratio** setting: 4:3 (Native) / 16:9 (Widescreen) / 21:9 (Ultrawide). One build does all. |
|
||
| `cad8c96` | **Per-prim proportion correction.** Recompiler emits `psx_ws_sprite_tag(cpu)` at the entry of each `[widescreen] sprite_tag_funcs` function; runtime records prim ptr (`$a0`) → GTE-projected anchor and re-squashes tagged prims around their own anchor at GP0 execution, so characters/billboards are NOT stretched. Untagged textured rects (SPRT = screen-space HUD/menus) center-squash. (`recompiler/src/code_generator.cpp`, `runtime/src/gpu.c`) |
|
||
| `0792086` | **FMV 4:3 pillarbox** (24-bit OR streamed 15-bit MDEC video, detected via `mdec_recently_active`), **menu-2D coherence** (lines/flat-rects/mono-sprites squash on full-2D screens so dialog borders match the SPRT boxes; fades exempt), **HUD edge anchoring** (in-game untagged SPRTs pivot by thirds — outer-third elements keep their wide-screen corner position at native proportions). |
|
||
| `5151e5b` | **Authentic 4:3 BIOS boot** — squash held off until game entry PC fires (`fntrace_is_game_started`); Sony/PS logos + shell render 4:3, widescreen engages at game start. |
|
||
| TombaRecomp `124e3c9` | Tomba `[widescreen]` block (see config below). Tomba `game.toml` defaults `aspect_ratio = "4:3"`; the dev instance opts into 16:9 via `build-stable/settings.toml`. |
|
||
| `8ae73c6` / TombaRecomp `01478fd` | **2D parallax-backdrop screenX squash** (`psx_ws_backdrop_x`, `runtime/src/gpu.c`). The actor-table backdrop (far mountains, midground bush row) is overlay 2D sprites computing `screenX = (worldX-camX)>>parallax` in pure integer math — never touches the GTE, so the GTE squash misses it and far pieces clip past the 320 edge. Fix squashes that stored screenX around screen-centre by the same factor. Applied on BOTH paths: native via `[widescreen.backdrop] x_sites` recompiler emit (overlay DLLs via OverlayCallbacks, ABI v2→v3), and the dirty-RAM interp SH hook (the path that runs in dev). Sites: type-0 `0x801217B4`, type-1 `0x8012196C`. |
|
||
| `1e28a94` / TombaRecomp `66e13e5` | **Depth-gated far-backdrop GTE un-squash** (`gte_ws_set_suppress`, `runtime/src/gte.cpp`; `[widescreen.backdrop] unsquash_funcs` recompiler emit). The far ocean/cloud/distant-mountain is **GTE-3D** drawn by main-EXE `FUN_8004db3c`; the global squash compresses it toward centre → blue void at the revealed edges. Bracket that driver with a squash-suppress so its draws fill the frame skybox-style. Because the driver draws a MIX (far backdrop + near props), the suppress is **depth-gated**: only verts with projected SZ ≥ `s_ws_far_threshold` (default 900, live-tunable) un-squash; near props stay squashed/aligned. |
|
||
|
||
User has visually confirmed: world geometry, characters, HUD, menus, FMVs, and
|
||
(2026-06-13) the far-backdrop edge fill all present at correct (native)
|
||
proportions on the 16:9 stretch.
|
||
|
||
**Live diagnostics (TCP debug server, committed `1e28a94`/earlier):**
|
||
`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|<start> <end>` (always-on
|
||
per-prim draw ring → CSV), `ws_margin <v|-1>` (force the cull margin), and the
|
||
`gpu_state` `ws.{configured,active,game_mode,present_native_43,x_margin,squash}`
|
||
readout. Plus `tools/_press.py` (TCP pad), `tools/_objtab.py` (0x800A3D08 actor
|
||
table decoder).
|
||
|
||
---
|
||
|
||
## Config reference
|
||
|
||
```toml
|
||
[video]
|
||
aspect_ratio = "16:9" # "4:3" (default/identity) | "16:9" | "21:9" | any "W:H"
|
||
# in [4:3, 32:9]. Wider than 4:3 engages the hack.
|
||
|
||
[widescreen] # all optional; inert if absent
|
||
sprite_tag_funcs = ["0x8005E08C"] # guest addrs of the per-prim helper(s)
|
||
# every character/billboard render fn
|
||
# calls with the prim ptr in $a0.
|
||
sprite_anchor_addr = "0x1F800070" # scratchpad holding the prim's
|
||
# GTE-projected anchor SXY at tag time.
|
||
hud_sprt_squash = true # center/edge-squash untagged SPRTs
|
||
# (screen-space HUD/menus).
|
||
auto_ui_squash = true # pre-scan the current GPU linked list,
|
||
# select its highest populated UI rank,
|
||
# and share one anchor across each
|
||
# complete glyph/icon run.
|
||
auto_ui_anchor = "edges" # "edges" (default): each run pins to
|
||
# its left/centre/right third.
|
||
# "in_place": each run squashes about
|
||
# its own centre.
|
||
auto_ui_size = "proportional" # "original" (default): the HUD keeps
|
||
# the display height's scale.
|
||
# "proportional": beyond 16:9 it shrinks
|
||
# by sqrt((16:9) / aspect).
|
||
clear_reveal = true # clear synthetic native-wide side margins
|
||
# at opted-in scene/map boundaries (default false).
|
||
nw_left_hud_packet_lo = "0x000E3400" # optional targeted left-HUD packet range
|
||
nw_left_hud_packet_hi = "0x000E4100" # (half-open); avoids shifting 2D scenery.
|
||
offer_ultrawide = true # inert on PSX (see below)
|
||
adaptive_view = true # inert on PSX (see below)
|
||
|
||
# Proven object/model participation comparisons may be forced only while a
|
||
# widened world view is configured. The complete instruction word guards
|
||
# same-address overlay variants; true 4:3 executes the vanilla comparison.
|
||
[[widescreen.cull.keep]]
|
||
address = "0x8002B310"
|
||
expected = "0x28A21C01" # SLT/SLTU/SLTI/SLTIU only
|
||
result = 1 # forced comparison result, 0 or 1
|
||
```
|
||
|
||
**`offer`, `offer_ultrawide` and `adaptive_view` are inert on PSX.** They are
|
||
still parsed, but the runtime no longer offers widescreen in Settings
|
||
(`main.cpp` pins `ws_offered`/`ws_ultrawide_offered` false) and nothing reads
|
||
`adaptive_view`. Widescreen is owned by each title's mod catalog: a trusted
|
||
plugin selects the view on activation with `psx_mod_set_fixed_display_aspect()`
|
||
(which also shapes the first window) and, for Fit to Window,
|
||
`psx_mod_set_adaptive_display_aspect(0, 0)` (follow every resize from 4:3 with
|
||
no upper limit; pass a maximum to cap it). Once the game window exists,
|
||
resizing it updates the projection, cull margins, wide render target and
|
||
present aspect. BIOS, FMV, menus and other title-classified 2D frames keep
|
||
their 4:3 pillarbox policy. TombaRecomp's and RidgeRacerType4Recomp's
|
||
`*.enhancement.widescreen` packages are worked examples.
|
||
|
||
The native-wide draw-area early-out correction was independently identified
|
||
and contributed by **OpokXeno** in
|
||
[PR #73](https://github.com/mstan/psxrecomp/pull/73). The landed variant keeps
|
||
that mirror-space correction while restricting it to active framebuffer
|
||
targets, so offscreen texture targets retain the original host-side pruning.
|
||
|
||
Tomba's values are Ghidra-evidenced: `0x8005E08C` is the shared per-prim helper
|
||
all ~23 character render functions (RTPS cluster `0x800459E0`–`0x8004FB54`) call;
|
||
the RTPS preamble stores the anchor SXY to scratchpad `0x1F800070`.
|
||
|
||
`auto_ui_squash` is a runtime-only opt-in and does not require regenerated game
|
||
code. It applies only on the projection-and-stretch path: eligible
|
||
axis-aligned quads (textured, flat or gouraud) and rectangles in the front
|
||
populated ordering-table layer are grouped by texture and screen row before any
|
||
command is transformed. Untextured quads are included because HUD fills (health
|
||
gradients, meter bars) sit inside textured frames and must squash with them.
|
||
Widget pieces drawn one rank behind the HUD are also admitted, from the next
|
||
populated rank only (it also carries world geometry): an untextured,
|
||
axis-aligned panel that fully encloses an admitted piece (a text box under its
|
||
glyphs), or a small (at most a quarter of the display each way) axis-aligned
|
||
piece that overlaps or stacks within `WS_UI_GROUP_STACK_GAP` rows on an
|
||
admitted piece (a gauge segment and its fill). This repeats to a fixed point.
|
||
`ws_ui_groups` counts these as `backing_panels`.
|
||
Depth-sorted world packets, full-frame backdrops, and true 4:3 frames remain
|
||
untouched.
|
||
|
||
By default each run is pinned to the nearest screen edge or centre third, so
|
||
corner HUD moves to the wide-frame corners. Some HUD widgets mix those flat
|
||
quads with GTE-projected parts the correction cannot move: Spider-Man's compass
|
||
is two ring quads around a 3D arrow. Edge-anchoring the ring pulled it ~70 px
|
||
off its arrow at 32:9. `auto_ui_anchor = "in_place"` squashes each run about
|
||
its own centre instead, which corrects its proportions and leaves it where the
|
||
stretched 4:3 layout places it. `gpu_state` reports the mode as
|
||
`ws.auto_ui.in_place`.
|
||
|
||
The HUD is proportion-corrected horizontally but keeps the display height's
|
||
scale, so on a very wide window it reads large against the much wider view.
|
||
`auto_ui_size = "proportional"` leaves it unchanged up to 16:9 and shrinks it
|
||
by sqrt((16:9) / aspect) beyond (0.87 at 21:9, 0.71 at 32:9), on both axes:
|
||
each run about its horizontal anchor and its vertical anchor (its top edge in
|
||
the upper half of the display, its bottom edge in the lower half), scaling
|
||
both edges of rectangles and sprites so neighbouring parts stay butted. A mod
|
||
may switch it per session with `psx_mod_set_widescreen_hud_size()`.
|
||
Projection widescreen only (native-wide does not squash the HUD).
|
||
|
||
**Changing `sprite_tag_funcs` requires a game regen** (the tag callback is
|
||
emitted into the generated C). `widescreen.cull.keep` is consumed by both the
|
||
native recompiler and the dirty-RAM interpreter: regenerate main/overlay native
|
||
code after changing it, while the interpreter reads the same guarded records
|
||
from the runtime config.
|
||
|
||
### Explicit screen-X cull sites
|
||
|
||
When a title's per-vertex screen-X rejects are not the shapes
|
||
`auto_screen_x` recognises, list them. Every kind below is identity at 4:3
|
||
(the live margin `m = psx_ws_x_margin()` is 0), emits nothing when its list is
|
||
empty, and requires a regen after a change. In the main EXE a listed address
|
||
holding anything but the instruction in the table is a hard build error
|
||
(psxrecomp-game exits 1 and names the site); in captured overlays a different
|
||
instruction at the same address is left vanilla, and a matching one is
|
||
widened like the main EXE. Native code, overlay shards (the helpers are
|
||
defined in `overlay_dispatch_preamble.c.inc`) and the dirty-RAM interpreter
|
||
apply the same helpers.
|
||
|
||
| Key | Instruction | While widened |
|
||
|---|---|---|
|
||
| `slti_sites` | `slti rt, SX, W` (right edge) | `rt = SX < W + m` |
|
||
| `slti_lower_sites` | `slti rt, SX, -W` | `rt = SX < -W - m` |
|
||
| `bltz_sites` | `bltz SX, reject` (last vertex of a left chain) | taken while `SX < -m` |
|
||
| `bgez_sites` | `bgez SX, keep` (other vertices of a left chain) | taken while `SX >= -m` |
|
||
| `clip_edge_x_load_sites` | `lh`/`lhu`/`lw` of a clip-rectangle X bound, to a nonzero register | a loaded `0` becomes `-m`, a loaded `clip_edge_width` becomes `W + m`; other values unchanged |
|
||
| `branch_keep_sites` | a conditional reject branch (`beq`, `bne`, `blez`, `bgtz` or REGIMM) | not taken |
|
||
|
||
`bgez_sites` pairs with `bltz_sites`. A compiled renderer often tests the left
|
||
edge as `bgez x0,keep; bgez x1,keep; bgez x2,keep; bltz x3,reject`: widening
|
||
only the `bltz` still rejects a quad whose first vertices sit in the revealed
|
||
band (x0 in `[-m, 0)`, x3 `< -m`), so the quad pops out at the left edge.
|
||
With both kinds listed the chain rejects exactly when every vertex is left of
|
||
`-m`.
|
||
|
||
`clip_edge_x_load_sites` covers renderers that compare vertices against a
|
||
clip rectangle they load (R4 keeps one in scratchpad for each viewport). Only
|
||
bounds equal to a screen edge move, so full-width viewports widen on each side
|
||
that touches the display edge while interior viewports (a rear-view mirror,
|
||
one half of a split screen) keep their vanilla culls. `clip_edge_width`
|
||
defaults to the first `screen_w_imms` entry (`0x140`).
|
||
|
||
```toml
|
||
[widescreen.cull]
|
||
slti_sites = ["0x80013F60"] # slti v0,v0,0x140
|
||
bgez_sites = ["0x80013F40", "0x80013F48", "0x80013F50"]
|
||
bltz_sites = ["0x80013F58"] # bltz a3,reject
|
||
clip_edge_x_load_sites = ["0x8005F5F4"] # lh v0,0x70(a0)
|
||
clip_edge_width = 320 # optional; default screen_w_imms[0]
|
||
```
|
||
|
||
The interpreter's copies of these lists are sorted and capped at 256 entries
|
||
per key; a longer list is logged rather than silently truncated. `bgez_sites`
|
||
and `clip_edge_x_load_sites` enter the overlay-cache identity only when they
|
||
are non-empty, so titles that do not use them keep their caches.
|
||
|
||
`screen_x_sites` is the explicit form of the `auto_screen_x` compare, for a
|
||
title whose width is not in `screen_w_imms` or whose function has no paired
|
||
height compare. It takes five instructions:
|
||
|
||
| Instruction at the site | While widened |
|
||
|---|---|
|
||
| `sltiu rt, SX, W` | `rt = -m <= SX < W + m` |
|
||
| `lui rt, W` (a screen edge kept as `W << 16`) | `rt = (W + m) << 16`; a negative `W` moves left instead |
|
||
| `bltz x, outside` where `x` is `SX << 16` | taken while `SX < -m` |
|
||
| `slti rt, x, 0` where `x` is `SX << 16` | `rt = SX < -m` (the `bltz` test as a flag) |
|
||
| `sltu rd, SX, W` with `W` in a register | `rd = -4m <= (int16)SX < W` |
|
||
|
||
The `sltu` form covers a renderer that compares a 16-bit screen X
|
||
(`andi SX, sxy, 0xffff`) against a width it keeps in a register, typically a
|
||
clip bound loaded from RAM. The unsigned compare rejects every negative X, so
|
||
widening the bound (`clip_edge_x_load_sites`) only reveals the right band; the
|
||
band helper `psx_ws_cull_sltu_band` also accepts negative X. Its left reach
|
||
is four margins: these tests check single vertices, so a large near polygon
|
||
whose corners all lie past one margin would still drop, while an unbounded
|
||
left side lets every off-screen polygon through and overflows a renderer's
|
||
fixed primitive lists (Kula World's world-10 intro stalled for 5 s). Pair it with the
|
||
load site so `W` already carries the right margin. A bound derived from the
|
||
loaded one (`x + r < W + 2r` bounding-sphere tests) works the same way.
|
||
Kula World (SCES-01000) is the first user: its object, level-quad and sky
|
||
tests use this shape, and forcing them true instead overflowed its renderer.
|
||
|
||
The last two cover a renderer that keeps a screen X in the high half of a
|
||
register (`sll x, sx, 16`), tests the sign of differences against a `lui`-held
|
||
edge and masks whole points at once; polygon splitters do this. `bltz_sites`
|
||
does not fit that `bltz`: it compares the whole register with `-m`, and every
|
||
negative `SX << 16` is far below that. Widen such tests rather than keeping
|
||
the branch: a splitter that takes its pieces from a fixed list runs out of
|
||
entries when every off-screen half is kept, and then drops polygons inside
|
||
the 4:3 area.
|
||
|
||
```toml
|
||
[widescreen.cull]
|
||
screen_x_sites = [
|
||
"0x80010100", # sltiu s0,v1,0x200 midpoint inside the screen?
|
||
"0x80010200", # lui s6,0x200 right edge as 512 << 16
|
||
"0x80010210", # bltz t0,outside t0 = SX << 16
|
||
]
|
||
```
|
||
|
||
`screen_x_sites` is applied when code is generated (native code and overlay
|
||
shards) and, at runtime, by the dirty-RAM interpreter: code that runs from
|
||
RAM through the interpreter (including under `PSX_FORCE_INTERP=1`) widens the
|
||
same five instruction forms at the listed addresses, with the same helpers and
|
||
the same identity at 4:3. A listed `bltz` follows the precedence `bltz_sites`,
|
||
then `screen_x_sites`, then the `auto_screen_x` classifier. In the main EXE
|
||
a listed address holding another non-branch instruction is a hard build error;
|
||
a branch other than `bltz` at a listed address is left as it is, and a
|
||
`slti` with a nonzero immediate is a build error (it is not this form).
|
||
|
||
### Packed-SXY all-vertices reject (`[[widescreen.cull.sxy]]`)
|
||
|
||
Some renderers trivially reject a whole triangle or quad by ANDing the packed
|
||
GTE `SXY` words of its vertices (Y in the high half, X in the low half) with a
|
||
sign mask and branching on the result:
|
||
|
||
```
|
||
and at, t0, s1 ; s1 = 0x80008000
|
||
and at, s4, at
|
||
and at, t3, at ; (and at, t1, at for a quad)
|
||
bne at, zero, reject ; bit 31: all above, bit 15: all left of the screen
|
||
```
|
||
|
||
`screen_x_sites`, `bltz_sites` and the packed-coordinate helpers test one
|
||
value at a time and cannot widen this fold. Listing it widens only the left
|
||
reject: each AND of the fold becomes `psx_ws_cull_sxy_and(acc, sxy)`, the
|
||
vanilla AND except that bit 15 also needs this vertex's `X < -m`. Bit 31 (the
|
||
vertical reject) and every other bit are the vanilla AND, the guest's own
|
||
branch decides, and right/bottom rejects (a separate chain) are untouched.
|
||
At 4:3 (`m = 0`) every AND produces the vanilla word.
|
||
|
||
```toml
|
||
[[widescreen.cull.sxy]]
|
||
kind = "tri" # or "quad" (4 vertices)
|
||
vertex_regs = [8, 20, 11] # the SXY operand of each AND
|
||
fold_addresses = ["0x8001A7F0", "0x8001A7F8", "0x8001A800"]
|
||
fold_expected = ["0x01110824", "0x02810824", "0x01610824"]
|
||
consumer_address = "0x8001A808"
|
||
consumer_expected = "0x14200034" # bne at, zero, reject
|
||
```
|
||
|
||
The loader derives the shape from the words and refuses anything else: one
|
||
AND per vertex, in increasing address order within 16 instructions, all into
|
||
one register `r`; the first combines `vertex_regs[0]` with a separate mask
|
||
register, each later one combines its vertex with `r`; the consumer is
|
||
`beq`/`bne r, zero` within four instructions after the last AND. Every word
|
||
of the fold and the consumer is part of the identity: the main-EXE build
|
||
fails on any mismatch (checked against the image up front and again when the
|
||
AND is emitted), and a captured overlay or interpreted variant with any other
|
||
word in the fold is left vanilla. Native code, overlay shards and the
|
||
dirty-RAM interpreter use the same helper and the same whole-fold check.
|
||
Changing a site changes the overlay-cache identity; regenerate after a change.
|
||
Wild 9 (SLUS-00425) is the first user: its triangle and quad rejects use this
|
||
shape, and its shifted-X lower edge is the `slti` form of `screen_x_sites`.
|
||
|
||
---
|
||
|
||
## Strategic direction: native-wide rendering (explored 2026-06-13, shelved)
|
||
|
||
Everything above is the **squash hack**: compress a wider FOV into the 320
|
||
framebuffer, present stretched. Its cost is a long tail of per-vertex-squash
|
||
artifacts (backdrop void, prop drift, the mountain grass-top straddle — see
|
||
ISSUES #8C). The **proper** fix is to stop squashing and render the wider FOV
|
||
**natively** into a wider framebuffer. No squash → that entire artifact class
|
||
disappears by construction (and it would likely fix the 8F dialogue split too).
|
||
|
||
A runtime PoC (`ws_native_wide` toggle) was built and **proved viable, then
|
||
reverted** (kept only as the checkpoint's history, not committed): drop the GTE
|
||
X-squash, widen the GPU draw-area + display by `WS_WIDE_EXTRA=106` (320→426 =
|
||
16:9 @ 240), and shift everything by `WS_WIDE_OFFSET=53` via `draw_offset` so 3D
|
||
and 2D move together and stay aligned. Result: content rendered un-squashed at
|
||
natural proportions with HUD/world alignment intact — the hypothesis held.
|
||
|
||
**Why a native-recomp project should prefer this:** we run on native hardware, so
|
||
**over-rendering off-screen geometry is essentially free** — we don't need to
|
||
cull the reveal precisely, just render more and not worry about the waste.
|
||
|
||
Remaining work to finish it (the PoC flushed these out):
|
||
1. **Widen the game's screen-space rejects** so the revealed side strips draw
|
||
instead of being culled — e.g. `FUN_80027600`'s `SX>=0x140` (320) trivial
|
||
reject. Over-render: set the bound generously (recompiler emit, regen-class).
|
||
2. **Paint the widened buffer region** — the game's framebuffer is 320 wide, so a
|
||
426-wide display read shows stale VRAM where nothing was drawn (the PoC's
|
||
right-edge garbage). The reveal must be covered (backdrop draws there once the
|
||
cull is widened) and/or the region cleared.
|
||
3. **Re-anchor the 2D HUD/dialogue** to the wider frame (the `draw_offset` shift
|
||
centres them; corner HUD needs to map to the true edges).
|
||
|
||
---
|
||
|
||
## Known issues / outstanding work
|
||
|
||
### 1. World-space culling pop-in — ROOT-CAUSED, patch set ready, NOT YET IMPLEMENTED
|
||
|
||
**This is the top remaining item.** Background scenery and some enemies pop
|
||
in/out while still on-screen in 16:9, because the game's per-object draw
|
||
classifier tests `objX − camX` against a **4:3-screen-derived window** that the
|
||
GTE squash never sees (it's a pre-projection world-space test).
|
||
|
||
Ghidra investigation (2026-06-12) located the entire classifier family and the
|
||
exact patch set. All constants are **code immediates** (no data tables).
|
||
|
||
- **camera X** (left edge of the 4:3 view) is mirrored each frame to scratchpad
|
||
`*(u16*)0x1F800176` by the camera updater at `0x8002D90C` (adjacent to the
|
||
H=544 re-assert at `0x8002D89C`).
|
||
- **Main draw classifier `FUN_80022E44`** (24 call sites — every "register for
|
||
draw" path). Sets per-object draw flag at `obj+1`; the draw walker
|
||
(`0x8004AFAC` / drainer `0x8004B1D8`) skips objects with `obj+1 == 0`
|
||
(`0x8004B230`). Window math:
|
||
```
|
||
0x80022E78: addiu v0,v0,0x40 ; +64 margin <- widen
|
||
0x80022E80: sltiu v0,v0,0x1C1 ; pass if < 449 (320+2*64+1) <- widen
|
||
```
|
||
With the 4/3 squash showing world `[-53.3, +373.3]`, the effective margin
|
||
collapses to ~11 px → anything wider than ~22 px pops. **Draw-level, not
|
||
despawn.**
|
||
- Widen by `E = 320*(4/3 − 1)/2 ≈ 54 (0x36)` symmetrically. Patch sites:
|
||
|
||
| Addr | Fn | Current → New |
|
||
|---|---|---|
|
||
| `0x80022E78` | `0x80022E44` draw classifier | `addiu v0,v0,0x40` → `0x76` |
|
||
| `0x80022E80` | " | `sltiu v0,v0,0x1C1` → `0x22D` |
|
||
| `0x80022F94` | `0x80022F6C` predicate(obj) | `addiu v0,v0,0x40` → `0x76` |
|
||
| `0x80022F9C` | " | `sltiu v0,v0,0x1C1` → `0x22D` |
|
||
| `0x80022FE8` | `0x80022FD8` predicate(x,y) | `addiu a0,a0,0x40` → `0x76` |
|
||
| `0x80022FF0` | " | `sltiu a0,a0,0x1C1` → `0x22D` |
|
||
| `0x80023068` | `0x80023020` despawn (160 margin) | `addiu v0,v0,0xA0` → `0xD6` |
|
||
| `0x80023070` | " | `sltiu v0,v0,0x280` → `0x2EC` |
|
||
|
||
Param-margin variants (margin in `a1`, width `0x140` hard-coded) at
|
||
`0x80022C08` / `0x80022D3C` / `0x800230BC`: convert the load-delay `nop`
|
||
before the subtract (`0x80022C34` / `0x80022D68` / `0x800230FC`) into
|
||
`addiu a1,a1,0x36` (`0x24A50036`) — hazard-free, inflates both bias and the
|
||
`2*a1` bound. These are called from disc overlays (enemy AI) with no
|
||
boot-EXE xrefs, but patching the shared main-EXE function covers all overlay
|
||
callers.
|
||
|
||
- **Do NOT touch** `0x800344CC` (world→screen coord conversion).
|
||
|
||
**Recommended implementation (do NOT hand-patch generated code — Rule 4):**
|
||
make this a recompiler feature like `sprite_tag_funcs`. Add a `[widescreen]`
|
||
list of `(addr, kind)` cull-widen sites; emit the immediate as a
|
||
**runtime-computed term** `imm + psx_ws_x_margin()` where `psx_ws_x_margin()`
|
||
returns 0 at 4:3/boot and `~54` (aspect-derived) once engaged. That keeps one
|
||
build correct for both aspects and makes 21:9 automatic. The per-instruction
|
||
emit site is `CodeGenerator::translate_instruction` (`code_generator.cpp:670`);
|
||
the `addiu`/`sltiu` immediate handlers are where the substitution goes,
|
||
gated by the configured site set. **Requires a game regen.**
|
||
|
||
### 2. World-edge exposure (left side)
|
||
|
||
16:9 can reveal past the authored edge of an area's geometry. Candidate
|
||
gameplay clamps that pin an object to `camX+0x134`/`camX+0x136` (the 4:3 right
|
||
edge) live at `0x80057D94` / `0x80058580` — these are *gameplay* clamps, not
|
||
the cull, and widening them is a separate, lower-priority decision. Some areas
|
||
may simply have to accept a little edge reveal.
|
||
|
||
### 3. HUD edge anchoring is heuristic
|
||
|
||
In-game untagged SPRTs pivot by thirds (outer third → screen edge, middle →
|
||
centre). Works for Tomba's sparse corner HUD; a game with mid-screen HUD
|
||
elements could need per-element tuning. The "game vs full-2D screen" mode
|
||
detector (`ws_game_mode` in `gpu.c`) keys off "any tagged prim within 2 frames."
|
||
|
||
### 4. 21:9 untested
|
||
|
||
The launcher offers 21:9 and the math generalizes, but it has not been
|
||
playtested. Wider aspects worsen culling pop-in until issue #1 is fixed.
|
||
|
||
### 5. `sprite_tag_funcs` discovery is per-game manual
|
||
|
||
Finding the shared per-prim helper + anchor scratch requires a Ghidra pass per
|
||
title. Documented for Tomba; any new title needs the same investigation.
|
||
|
||
---
|
||
|
||
## Key files
|
||
|
||
- `runtime/src/gte.cpp` — `gte_set_display_aspect`, RTPS/RTPT X-squash.
|
||
- `runtime/src/gpu.c` — `gpu_ws_configure`, `psx_ws_sprite_tag`, tag table,
|
||
per-prim transforms in the GP0 exec handlers.
|
||
- `runtime/src/gpu_gl_renderer.c` — `gl_renderer_set_display_aspect`,
|
||
`letterbox_rect_aspect`, `force_4_3` present paths.
|
||
- `runtime/src/main.cpp` — config resolution, `g_ws_engaged` (boot gate),
|
||
`fmv_frame` 4:3 pin, window sizing.
|
||
- `runtime/src/mdec.c` — `mdec_recently_active` (FMV detector).
|
||
- `recompiler/src/code_generator.cpp` — `psx_ws_sprite_tag` emit at tagged fn
|
||
entry. **This is where the cull-widen emit (issue #1) belongs.**
|
||
- `recompiler/src/config_loader.{h,cpp}` — `[widescreen]` parse, `aspect_ratio`.
|
||
- Launcher aspect control — aspect toggle.
|
||
- `TombaRecomp/tools/capture_game_window.ps1` — window-capture helper for
|
||
visual verification (the VRAM `screenshot_file` shows the pre-stretch frame;
|
||
this shows the post-stretch window).
|
||
|
||
## Verify / rebuild
|
||
|
||
Runtime-only changes (everything except `sprite_tag_funcs` / cull-widen) just
|
||
need a rebuild:
|
||
```
|
||
PATH=/c/msys64/mingw64/bin:$PATH
|
||
cmake --build ../TombaRecomp/build-stable --target psx-runtime -j16
|
||
```
|
||
Changes that emit into generated C (`sprite_tag_funcs`, future cull-widen) need
|
||
a regen first (see `memory/regen_build_recipe.md`):
|
||
```
|
||
recompiler/build/psxrecomp-game.exe --config game.toml
|
||
```
|
||
`build-stable` is RelWithDebInfo + debug tools (TCP port 4470) + launcher ON.
|
||
Always `taskkill /F /IM psx-runtime.exe` before relaunching.
|
||
|
||
## Native-wide 16:9 (shipped) and 21:9 (stubbed, hidden)
|
||
|
||
The shipped enhancement is **native-wide 16:9**, exposed as the launcher's
|
||
experimental Widescreen On/Off toggle (software renderer only; hidden on
|
||
OpenGL). It is off by default (`game.toml [video] aspect_ratio = "4:3"`), so the
|
||
default build is byte-identical to faithful. On = `16:9`. The dominant fix is
|
||
`[widescreen.cull] auto_screen_x` — the recompiler auto-detects the GTE
|
||
render-funnel screen-extent reject (`sltiu …,0x140` width paired with
|
||
`sltiu …,0xE0` height) and widens every width compare through
|
||
`psx_ws_x_margin()`, sign-extended so both margins reveal. Full-screen 2D
|
||
overlays (pause dim, load fade) are extended across the wide compositor surface.
|
||
|
||
**21:9 is intentionally stubbed and hidden.** Everything numeric is
|
||
aspect-derived (`ws_nw_offset` etc.), so `aspect_ratio = "21:9"` already widens
|
||
the cull, compositor, and present correctly — the **3D world and foreground fill
|
||
21:9 for free**. It is *not* offered in the UI because two layers don't reach
|
||
21:9:
|
||
- **Background** — the parallax + far-backdrop pipeline only *generates* ~16:9
|
||
of coverage (per-layer spawn windows in the `FUN_80027600` builders +
|
||
`FUN_801216BC`-family overlay handlers). The per-prim cull already widens past
|
||
this; the tiles simply aren't produced, so 21:9 voids the far background. This
|
||
is the 8C generation work, and may hit the game's *authored* coverage limit
|
||
(beyond which only tile/stretch/void remain) — which is what caps clean
|
||
widescreen at 16:9.
|
||
- **Foreground edge tips** — the cull widens exactly to the display surface
|
||
edge; fixable by over-rendering (decouple the render aspect from the display
|
||
aspect) — low effort, but pointless until the background is solved.
|
||
|
||
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`.
|