The native-wide centre-only fast path read batch x extents with a truncating
(int) cast. With geometry_correction those are fractional, and truncation
rounds a negative x TOWARD the centre — so a batch just touching the left
margin could be misclassified as fully inside the 4:3 frame and have its
wide-mirror draw skipped, leaving a one-pixel sliver unmirrored. Take floor
for the low edge and ceil for the high edge instead; whole-valued positions
(the faithful path, and every prim with the feature off) are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the parked projection/perspective work reachable and gets it into the
renderers people actually use. Addresses issue #92 (PGXP-style geometry and
texture correction).
The GTE side cache, SWC2 RAM provenance, GP0 triangle preparation and the
software-renderer consumption path already landed in 2ceaf5a ("park projection
and perspective correction", from kem0x PR #14), but were unreachable: the
generic setters gte_geometry_correction_set() / gpu_texture_correction_set()
had no production caller, and the corrected vertices went straight to
sw_set_precise_triangle(), bypassing the renderer vtable — while the default
renderer is OpenGL.
This adds:
- [video] geometry_correction and [video] perspective_texturing, in game.toml
and settings.toml, both defaulting false. Independent knobs; the launcher
seed marks them present so a save round-trips a hand-edited key instead of
dropping it. Startup names the active pair, and notes when geometry
correction is on below supersampling 2 (where it cannot be seen).
- gr_set_precise_triangle() / gr_set_perspective_triangle() on the
GpuRenderBackend vtable, so gpu.c hands the correction to whichever backend
is live rather than to the software rasterizer directly. NULL entries no-op.
- OpenGL and Vulkan consumption. Positions are already float end-to-end, so
sub-pixel vertices drop in; the readback dirty rect widens by one pixel to
cover them. Perspective UVs use a per-vertex a_q weight and clip coordinates
pre-multiplied by w = 1/q, letting the hardware's own perspective divide
interpolate a smooth UV varying beside the affine noperspective one. a_q == 0
— the default, and what every prim gets with the feature off — makes w
exactly 1.0 and selects the affine varying, so the faithful path is the
pre-feature pipeline unchanged.
The PS1-visible GTE SXY FIFO stays integer either way: guest-side screen-bounds
culling and SXY readback are untouched, and this remains visual-only.
Also fixes a pre-existing concurrent-build race found while validating: every
runtime target runs embed_spirv.py over the same shaders, and each wrote
"<shader>.spv" next to the source, so under -j N one process deleted the file
another had just written (FileNotFoundError on a shader that compiled fine).
The intermediates now go to a per-invocation temp dir.
Parked-work provenance retained; see ENHANCEMENTS.md G1.
Co-authored-by: Kareem Olim <kareemolim@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
it stops checking the entire PSX disc for full hash check every time the launcher runs, assuming the user's hash checked it when selecting the disc the first time and saving it to settings, so it just validates the file name and size instead, then it shares lifecycle with the launcher and the game to load the game faster, and several optimizations were made to loadstate paths which I think in the end relied on reanchoring host timings to the restored clock that came with the state
Two present-path fixes:
1. The short GP1(07h)-band letterbox (MotK 128-line FMV) fired for any
frame under 240 lines, which also catches games whose native display
mode is short full-time (Xenogears runs 320x216): every 4:3
menu/load and every FMV got a ~10% vertical squash. No numeric
threshold separates the cases (216 < 224 too), so split by purpose:
15-bit game frames never shrink (adjustment removed from
gl_renderer_present_vram), and the FMV paths keep the correction
only for genuinely windowed video bands (gl_renderer_present, and
the SDL path gated on pin_43 && depth24_frame) with the band under
80% of the 240-line field. MotK's 128-line FMV keeps its aspect fix;
native 216/224-line display modes fill the rect as before.
2. present_target_quad mapped quad corners to texel edges, so with
GL_LINEAR the outermost dest pixels blend the border texel with
neighboring VRAM outside the content rect (visible 1-2px edge
stripes with linear present filtering, e.g. menu frames showing the
adjacent framebuffer). Center-map the UVs with a half-texel inset;
interior sampling is unchanged. Covers both callers (canonical VRAM
present and wide-FBO present).
Release compiles out the debug server, so leave the GL timer-query
plumbing disabled there too: a native-wide frame can otherwise issue
hundreds of unused mirror timestamp queries per frame. Developer
builds keep the always-on ring unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- gpu_uv.h: the ONE shared PS1 uv-sampling model for GL/VK/SW (limits +
Beetle Calc_UVOffsets mirrored-2D compensation)
- fixes the MMX6 sprite-sliver class (detached 1px sprite-colored lines at
quad seams of X/Y-flipped sprites; pink line under wheel mechaniloids):
poly path AND the gpu.c axis-aligned-mirrored-quad -> rect_scaled path
- always-on display ring (display_ring_get/aux/stats): frame-exact display
+ full-VRAM capture for cross-renderer forensics
- user-validated clean on MMX6 (GL); cross-title gate: Tomba GL boot +
attract verified clean against this branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The uv limits + mirrored-2D compensation math lived in five hand-copied
sites across three backends (GL/VK: tri_uv_mirror_offset + tri_uv_limits +
the rect-path block; SW: sw_tri_uv_limits + sw_rect_uv_limits). That
duplication is exactly how the sliver bug family happened — the copies
drifted, and the fix then had to be mirrored by hand into every site
(e86099f missed the rect path; 148f171 patched it separately).
gpu_uv.h now carries the model once:
psx_uv_tri_limits / psx_uv_rect_limits exact inclusive sampled range,
direction-aware on the ORIGINAL uvs (forward [lo,hi-1], mirrored
[lo+1,hi]), page-wrap widen
psx_uv_tri_mirror_offset / psx_uv_rect_mirror_offset the Beetle
center-sampling +1 bump — GL/VK only; the SW DDA is PS1-exact and
takes none
GL/VK compute limits from the original uvs then bump; SW delegates its two
limit functions. Verified byte-identical: deterministic attract frame 2850
display-ring capture hashes equal pre/post refactor. Semantics note: for
rare half-diagonal prims the limits are now per-axis (the old blunt rule
also backed off the diagonal axis); SW mirrored-rect bilinear clamp
tightens to the actually-sampled range — both only observable with
texture_filter on (non-default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The e86099f fix put the Beetle Calc_UVOffsets mirrored-uv bump in the
textured-POLY path only — but gpu.c's axis-aligned-quad fast path converts
mirrored quads (X/Y-flipped 2D sprites, e.g. every right-facing MMX entity
cel) into gr_draw_textured_rect_scaled calls with u0>u1 / v0>v1, so they
enter gpu_textured_rect and never see the poly compensation. That is why
the sprite slivers persisted on X (worst facing right / crouching) after
e86099f: the visible producers were all rect-path prims.
Fix: same +1 bump along each decreasing uv axis in gpu_textured_rect (GL
and VK), with the sampling bounds tightened to [min, max-1] post-bump
(max is the exclusive edge in both directions). Forward rects are
unaffected (bump is a no-op; bounds identical to before).
Frame-exact verification (deterministic attract, frame 2850, X idle
facing right): pre-fix GL vs software full-frame diff = 3707 px spread
across the sprite area; post-fix = 0 px in the entire gameplay area (the
only remaining diff is the attract logo overlay blink-phase offset caused
by the known GL blend-rounding timeline fork, tracked separately).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Modern-GPU center-sampled interpolation vs the PS1 DDA's top-left-corner
latch: the sample-grid shift (u_shift = 0.5/S - 1/64) makes floor(uv) land
on the exact PS1 texel for FORWARD axis-aligned mappings, but MIRRORED ones
(u or v decreasing along their screen axis - X/Y-flipped 2D sprites, e.g.
right-facing MMX entity quads whose cels are stored facing left) interpolate
1/64 short of each integer and floor one texel LOW. That painted the cel's
never-sampled edge column/row (opaque body texels) as detached 1px slivers
at quad seams: stray sprite-colored lines right of X when facing right, a
pink line under the rolling wheel mechaniloids. The old tri_uv_limits
comment explicitly 'accepted' this.
Fix = the Beetle-PSX / parallel-psx Calc_UVOffsets_Adjust_Verts model:
bump the prim's uv by +1 along the decreasing axis (tri_uv_mirror_offset,
applied in the GL and VK poly paths before limits are derived). This also
puts the max-uv vertex back on the exclusive raster edge, so the existing
unconditional max back-off in tri_uv_limits is correct for both directions.
Forward mappings are untouched (no regression surface for the original
Tomba seam fix). The software renderer's native DDA truncates exactly like
the PS1 and needs no compensation; its limits function only feeds the
bilinear neighbour clamp (comment clarified).
Verified on MMX6 attract demo (deterministic boot): pre-fix GL showed
detached X-palette 1px columns at quad seam x=173/175/179/181 (frames
~2088/2250) while software was clean; post-fix GL matches software and
user-confirmed clean live. Residual detector hits classified via GP0 ring
dumps as legit charge-spark effect tiles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- wide_blit_center now copies the canonical column into the wide-surface CENTRE
over the FULL VRAM height, not just [disp_y, disp_h]. Ape vertical-double-
buffers (display_y 0<->256, both bands in one wide surface); a scrolling
cutscene (the cityscape intro) exposed centre rows outside the display band,
which the disp_h-only blit left black -> horizontal black-band tearing. Full-
height blit makes the wide centre bit-identical to the full-mirror output for
every band. Fixes the regression; keeps the perf win.
- sticky gte_game_mode threshold/hysteresis retained (cutscene flicker fix).
- New GL wide-surface inspection over TCP: wide_dump_full backend method
(glb_wide_dump_full) so the `wide_full` command dumps the ENTIRE GL compositor
surface (both double-buffer bands + margins), matching the SW path. wide_shot
(displayed band) already worked on GL. Lets native-wide be debugged entirely
over the TCP server, no window focus.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds full 16:9/21:9 native-wide support for fully-3D titles (Ape Escape,
SCUS-94423) as a generic, opt-in, 4:3-byte-identical framework capability, plus
a native-wide GL compositor perf fix and a cutscene present-mode flicker fix.
Widescreen (generic, per-game opt-in via [widescreen*]):
- Shared screen-extent cull detector (runtime/include/ws_cull_detect.h) used by
all three backends (recompiler, dirty-RAM interp, sljit). Per-game signature
immediates ([widescreen.cull] screen_w_imms/screen_h_imms) — Ape's funnel
tests 0x181/0xF1 on its 368px display, not Tomba's 0x140/0xE0.
- Three funnel idioms widened: unsigned per-vertex sltiu (psx_ws_cull_sltiu),
signed min/max slti right edge + bare-bltz left edge (psx_ws_cull_slti/bltz,
new helpers + SLJIT_HLP appends), and billboard center+/-halfwidth. Explicit
[widescreen.cull] slti_sites for X-only funnels the auto-detector can't
qualify.
- gte_game_mode: GTE-activity gameplay detector for 3D titles with no sprite-tag
helper (gte.cpp notes every RTPS/RTPT). STICKY (>=3 verts, 45-frame hysteresis)
so sparse cutscene frames never flip to a 4:3 pillarbox = fixes intro flicker.
- nw_hud_corners: push outer-third screen-space HUD sprites to the true wide
corners in native-wide (else inset by the reveal).
- nw_backdrop: stretch a full-frame axis-aligned 2D backdrop quad to fill the
wide frame (for titles with a flat sky plane).
- Pattern-scanned interp/sljit widen hooks now gated per-game (auto_screen_x /
auto_backdrop): fixes an ungated auto_backdrop false-positive that rewrote a
live GPR = the ~24s native-wide crash on Ape.
Native-wide GL perf (fixes 16:9 frame drops):
- The wide surface centre equals the canonical 4:3 framebuffer, so wide_blit_center
copies canonical->wide-centre at present (both present paths) and the per-prim
mirror SKIPS prims fully inside the 4:3 frame (mirror_x/geo/batch_center_only).
Only reveal-margin prims re-rasterize. Peak frame time 38.8ms -> 17.5ms, no
vsync misses. Toggle gl_wide_fast; 4:3 untouched (all gated on g_wide_cur).
Runtime-only except the recompiler cull emit (regen needed for cull widening).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proven by ablation on Tomba2 16:9 attract (12fps): a full mirror pass cost
~616us GPU per pass, but ablate=2 (FBO binds + state churn, no draws) = 3us
and ablate=3 (identical draws into the stencil-equipped hr FBO) = 1.5us.
The only difference between the fast and catastrophic configurations is
drawing with stencil test/write enabled (every batch's mask fixup pass)
into the one FBO with no stencil attachment - the driver handles that
combination with ~0.6ms of per-draw work. This also explains why the
deferred once-per-frame replay experiment (ws branch a8181dd) did not help:
it still drew into the stencil-less wide FBO, so the per-draw cost stayed.
Fix: each wide surface gets a DEPTH24_STENCIL8 renderbuffer like the hr FBO.
This is also a correctness fix: the wide surface now carries a real PSX
mask-bit stencil mirror (previously mask set/check silently no-opped on the
wide pass). wide_clear clears the stencil to bit15 alongside the color, and
surface creation clears both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tomba2 16:9 GL reproduced the reported collapse (12-14fps) and the GPU-side
split EXONERATED the GPU: scene_gpu ~10ms, mirror ~0.2ms, while emu_cpu is
~82ms (vs ~15ms in squash). gl_ws_ablate=1 (mirror passes fully off) leaves
it at ~75ms, so the wedge is CPU work outside the mirror draws. The GL
submission paths run INSIDE guest emulation (GP0/DMA processing), so driver
CPU cost lands in emu_cpu where no GPU timer sees it.
frame_perf now reports, per wide/4:3 frame class: CPU wall inside
flush_tex_batch (cpu_flush_ms_avg), CPU wall inside the glb_wide_* entry
points (cpu_wide_ms_avg), batches/frame, wide target sets/frame, and wide
FBO creations/frame (a nonzero steady value = surface recreation churn).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wide-mirror pass scissored the FULL wide surface (both X and Y). The SW
reference (rt_wide) only widens X; its Y clip stays the canonical draw-area
clip. Under a vertical double-buffer (MMX6: draw area alternates y=0/y=240,
both bands in ONE full-height wide surface keyed by base_x) the full-height
Y scissor let draws that canonically clip at the band boundary bleed into
the OTHER band's rows, presented a frame later as user-visible top/bottom
edge flicker (16:9 GL only). Scissor is now full-width X, draw-area Y,
matching rt_wide exactly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native-wide perf attribution instrumentation (permanent):
- Each wide-mirror pass (tex-batch mirror, flat-geometry mirror, overlay
full-width rect, wide_clear) is bracketed with a GL_TIMESTAMP pair
(glQueryCounter; TIME_ELAPSED cannot nest inside the frame scene query).
Pairs pool per buffered frame (1024/frame), summed at readback into
mirror_gpu_ms + mirror_passes per sample; frame_perf now reports
mirror_gpu_ms_avg/max, canon_gpu_ms_avg (scene minus mirror),
mirror_passes_avg and mirror_pass_us for wide frames.
- New debug cmd gl_ws_ablate mode=<0..3>: 0 normal, 1 skip mirror passes
entirely, 2 mirror state churn without draws, 3 mirror draws stay on the
hr FBO (no per-pass FBO rebind; content-corrupting perf probe).
First MMX6 16:9 measurements with the split: mirror passes cost 20-125us
EACH (FBO ping-pong penalty per pass) but MMX6 2D batches to ~3-15
passes/frame so 16:9 attract holds ~60fps on this branch; the per-pass
cost x ~900 immediate flat prims is exactly the Tomba2 3D 60ms scene.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (proven via the new present ring + gl_coh_ring correlation):
flush_cpu_upload() merged all pending CPU->VRAM writes into ONE union
bounding box. A frame with two disjoint uploads (sprite column + tile row —
routine MMX6 texture streaming) produced a union spanning the display
framebuffers, which the flush painted from the STALE CPU VRAM mirror (the
FBO is authoritative under GL) — stomping live frames with black for
exactly two presents (one per double-buffer parity). Scene-dependence of
the uploads made it look config/timing dependent; the software renderer is
immune because its CPU array is authoritative.
Fix: exact pending-rect list (16 rects; merges only when zero uncovered
pixels are added — containment or same-band extension; wrap-aware GP0(A0)
transfers split into up to 4 exact rects instead of the old "wrapped: take
all" full-VRAM union; overflow -> order-preserving flush-all; pre-context
union retained where the CPU array is authoritative). Merge rule verified
by a 20k-randomized-rect host test (0 stale / 0 missing painted pixels).
Observability (always-on): gl_present_ring records every SwapWindow (path,
src/letterbox rects, drained glGetError, wall-ms, backbuffer + blit-source
pixel samples) — the instrument that gave the 1:1 black-capture
correlation. debug_server_send_fmt now heap-formats responses >64KB (they
previously truncated into unparseable JSON, breaking large ring dumps).
(debug_server.c also carries the Vulkan screenshot_file sync hunk from the
concurrent VK bring-up; see the next commit.)
Validated: ~18-min MMX6 GL attract soak, ~1600 window captures across 3+
attract cycles — zero isolated black frames, zero GL errors (pre-fix:
19/40 captures black at the intro). Tomba1 build-gl boots clean; its full
attract soak follows. ENHANCEMENTS.md added (framework enhancement ledger,
R1 OpenGL / R2 Vulkan state).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GL-only painter's-order bug: the player drew IN FRONT of sprites he should be
behind (an in-world AP item-block's letters, a save post) — correct on the
software renderer. GP0 capture + USER-confirmed isolation pinned it to the
textured-prim batch.
flush_tex_batch drew every batch in two passes split by the per-texel STP bit:
pass 1 = all prims' STP=0 texels, pass 2 = all prims' STP=1 texels. The split
ran for OPAQUE batches too (it also drives the PSX mask bit, which STP=1 texels
must set), so a BEHIND opaque prim's STP=1 texels (pass 2) overwrote a FRONT
opaque prim's STP=0 colour (pass 1) — the in-world objects are opaque prims with
STP=1 cutout texels, so they reordered against the character.
For an OPAQUE primitive the STP bit must not gate COLOUR (every texel is opaque).
So opaque batches now draw colour in ONE ordered pass over all texels (frag.a
still carries the per-texel mask bit → colour-attachment mask correct), followed
by a COLOUR-MASKED pass that only fixes the STENCIL for STP=1 texels. Same draw
count, submission order preserved, mask preserved. Semi-transparent batches keep
the genuine blended two-pass and are isolated to one prim per batch so their
cross-prim order is also correct. Opaque foliage/terrain batching (the perf win)
is untouched. USER-VALIDATED fixed on Tomba GL.
A future single-pass for semi modes 0/1/3 (GL_ONE,GL_SRC_ALPHA with a per-
fragment destination factor) could re-batch semi prims; mode 2 needs isolation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vulkan backend alongside software + OpenGL: geometry/textured-prim
pipelines, GPU-authoritative VRAM image, deferred-submit + drain-at-present
perf model, GPU-direct present (vk_renderer_present_vram), and a
full-VRAM-pack readback path (ensure_cpu rect_adds the whole VRAM rect
before pack_flush so screenshots/guest VRAM reads reflect live content,
not just the incremental pack-dirty rect — readback-only, no per-frame cost).
Runtime plumbing: --debug-port / --renderer / --window-title CLI overrides
(multi-instance fleet), latency_ring, SPIR-V shaders + embed_spirv.py,
config_loader renderer selection, debug_server screenshot wiring.
All on feat/vulkan-renderer; snapshot in backup/vulkan-wip-20260617.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live-tunable selectable stretch gate to identify which drawn prims form the
native-wide 2D-backdrop void: ws_dbg_stretch modes = OT-range / clut / tpage /
tagged / untagged-tex / all-tex, with per-frame matched + matched-tagged counts
(psx_ws_dbg_gate_frame_snapshot). Routes through the existing GL centre-stretch.
Confirmed live: the void layer is the early-drawn, SPRITE-TAGGED 0x65 background
grid (6 cols x 7, step-64, cluts unique to the backdrop); stretching it about
screen-centre fills the void with no inter-tile gaps (GL scales width too).
Tagged fills it; untagged (3D) does not. The prior content-match plan is
DISPROVEN (template transformed beyond recognition before draw) -- corrected
model recorded in BACKDROP_PRELOAD.md. Fix gate chosen: native-wide + tagged +
background-phase (draw-time), since backdrop X is written by a shared sprite
helper with no clean per-emit source signal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 16:9 dwarf/flower-field void is native-wide 2D-backdrop POSITIONING, not a
column-loading problem. The earlier auto_backdrop whole-row preload is proven
IRRELEVANT (ws_backdrop_ring: whole 41-col row loads, void persists; margin off
vs whole-row pixel-identical). The flower-field is a fixed-screen 2D tile field
(static 11-bit vertices, ~4:3 width) that bypasses the GTE, so native-wide (which
widens 3D via the GTE) never widens it. Fix = geometric X-stretch about
screen-centre of the 2D backdrop in the GL wide-mirror — USER-CONFIRMED it fills
the void (with the gate disabled). Full record: BACKDROP_PRELOAD.md (2026-06-16).
Remaining: gate the stretch to ONLY the backdrop (currently also stretches Tomba/
HUD/3D). Path forward = draw-time gp0_cmd_source_addr / sprite-tag classification.
Runtime WIP checkpoint (all behind native-wide + runtime toggles, 4:3 untouched):
- gpu_gl_renderer.c: u_xscale/u_xcenter geometric stretch in the geo+tex
wide-mirror passes; per-frame backdrop-phase reset moved to gl_perf_present_enter
(both present paths); textured-only phase mode; per-prim trace + dbg counters.
- gpu.c: ws_backdrop_ring (always-on rewrite ring); ws_backdrop_margin live
column-widen (proven irrelevant); psx_ws_backdrop_value whole-row path.
- dirty_ram_interp.c: ring capture (extent/camx/count/a1/dl) at the interp hook.
- debug_server.c: ws_backdrop_ring / ws_backdrop_margin / ws_backdrop_stretch.
- main.cpp: keyboard ALWAYS drives player 1 (OR'd with any controller).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Queue textured prims sharing blend/mask/texwindow/filter into a single
glDrawArrays instead of one draw (x2 STP passes, x2 wide) per primitive. With
per-prim texture state already in vertex attributes (prior commit), only those
keys force a flush. flush_tex_batch() drains the queue before any op that reads
VRAM, writes it outside the batch, or changes batch state (flush_cpu_upload on
upload, flush_pack_if_sampling on pack, ensure_cpu readback, flat/fill/copy,
draw-area/offset + wide-target setters, every present path); the batch carries
its own captured blend/mask/twin state so those changes split it cleanly.
Measured (Tomba village, frame_perf, debug build): 16:9 scene_gpu 28.3->16.0ms
=> ~34fps to ~60fps; 4:3 16.5->5.5ms (3x headroom). Visually identical across
the opening scene. The cost was ~14us per glDrawArrays x ~1000-2000 draws/frame;
batching collapses the draw count. Remaining 16:9 cost is the native-wide
double-draw (single-pass wide render is the next lever if more headroom needed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move texpage/clut/depth/raw/uv-limits from per-prim uniforms into flat vertex
attributes (TEXV=18-float vertex). Keeps per-prim draws for now — verified
pixel-identical in the Tomba village and perf-neutral (scene_gpu 28.3 vs 28.4ms;
the cost is draw-call bound, not vertex-data bound). This is the foundation for
coalescing consecutive same-state textured prims into one draw: with texture
state per-vertex, only blend/mask/texwindow remain as batch keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measured Tomba village = 93% textured prims, so draw batching must target the
textured path (per-prim uv-limits/texpage/clut/depth move to vertex attributes
so consecutive textured prims coalesce into one draw). Flat batching alone
would touch only 7% of prims.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds prims/frame and per_prim_us to the frame_perf aggregate. Proved Tomba's
village is draw-call bound: ~1000 prims/frame at ~14us of GPU time PER
glDrawArrays (a 1-triangle draw should be <1us) — pipeline-flush overhead from
re-binding state between every primitive. 16:9 is 2x slower purely because the
native-wide double-draw issues 2x the draw calls. Points squarely at draw
batching (group consecutive same-state prims) as the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Always-on per-frame ring in the GL renderer: two GL_TIME_ELAPSED queries
bracket each frame's scene draws vs the present clear+blit (true GPU time,
independent of CPU/GPU overlap), plus SDL perf-counter CPU wall time
(present-to-present total, present call). Exposed via the debug server
"frame_perf" command, aggregated over the recent ring and split by 4:3 vs
native-wide so a 16:9-vs-4:3 A/B reads off one query.
Built to answer where a frame goes without guessing. First use proved
Tomba's 16:9 village lag is the native-wide DOUBLE-DRAW (every primitive
re-issued into the wide FBO), NOT supersampling (SS2==SS1) and NOT the
compositor (present_gpu ~1ms): scene_gpu 16:9=28ms vs 4:3=17ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GL native-wide present (Option B) did glFinish + glReadPixels of the whole
wide FBO to the CPU and re-uploaded it EVERY frame — a hard GPU pipeline stall +
round-trip that made OpenGL 16:9 run far slower than the software renderer (whose
wide path is pure CPU, no GPU sync). Confirmed by isolation: GL 4:3 smooth, GL
16:9 slow, independent of SS/AA.
Add gl_renderer_present_wide_fbo: blit the displayed buffer's wide FBO straight to
the window with glBlitFramebuffer (GPU-side, V-flipped, into the letterbox),
exactly like the canonical gl_renderer_present_vram — no readback, no glFinish,
no re-upload. main.cpp routes wide GL frames here (falling back to the CPU readout
only if the wide surface doesn't exist yet). USER-VALIDATED smooth. The CPU
readback path (glb_render_wide_display) stays as the fallback + the wide_shot
probe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in the GL native-wide present (both root-caused via the new wide_shot
capture):
1. ZOOM + wide_shot crash: glb_scale() was a stub returning 1 while the GL
renderer actually renders at s_scale (e.g. 2). main.cpp:1801
`g_video_scale = gr_scale()` collapsed g_video_scale to 1 on GL, so the wide
CPU present sized buffers/dims for scale 1 (426x224) while gr_render_wide_
display produced scale-2 data (852x448) -> present uploaded it as 426-wide and
showed a magnified sub-region (the zoom); the wide_shot probe overflowed its
buffer (crash). Fixed glb_scale() to return s_scale (matches the SW backend's
sw_renderer_scale contract; both gr_scale() callers want the real scale; the
GL FBO-direct present is unaffected — it never reads gr_scale()).
2. UPSIDE-DOWN: glb_render_wide_display flipped rows on readback, but the wide
FBO stores PS1-y inverted (vram_y=0 -> NDC -1 -> FBO bottom) and glReadPixels
reads bottom-up, so the two inversions already cancel — the extra flip turned
the frame upside-down. Removed the reversal (copy straight = top-down, matching
sw_render_wide_display + the PRESENT_VS V-flip).
Adds the `wide_shot` debug command (debug_server.c): dumps the composited
native-wide present surface to a PNG (gr_render_wide_display), unlike
screenshot_file which captures only the canonical pre-compositor VRAM. This is
how both bugs were seen + verified.
Verified: wide_shot returns a right-side-up, full-frame 16:9 capture (no zoom,
no flip, no crash). Final visual sign-off pending (human).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors gpu_sw_renderer.c's native-wide model on the GL backend. Canonical
VRAM (the hr FBO) stays 100% faithful; native-wide lives in SEPARATE per-base_x
wide FBOs, each framebuffer-targeting primitive mirrored at local x = vram_x -
base_x + OFFSET. Present reads the displayed buffer's wide FBO via the CPU
present path (Option B: lowest-risk reuse). 4:3 / non-opted games never call
wide_configure, so the wide path never runs and the canonical pass is identical.
- GEO_VS/TEX_VS: add u_xoff (x translation) + u_xhalf (x clip half-extent);
x term is now (a_pos.x+u_shift+u_xoff)/u_xhalf-1.0. Defaults 0/512 set once at
init, restored after every wide pass, so canonical reduces to (x+shift)/512-1.
- Wide FBO management (WIDE_MAX_SURF=4): lazy RGBA8 color tex + FBO per base_x;
glb_wide_configure/set_target/disable_target/clear mirror the SW functions.
- Double-draw: each draw func emits its canonical pass first (unchanged), then if
g_wide_cur re-issues the SAME glDrawArrays into the wide FBO via shared
wide_target_begin/end helpers (geometry / shaded+textured: both STP passes with
identical blend + u_semipass). Flat-rect full-screen-overlay extend replicated
(covers [0, native_w) -> fills the full wide width) via a dedicated full-width
wide pass with the generic per-tri mirror suppressed.
- glb_render_wide_display: glFinish then glReadPixels(GL_BGRA, GL_UNSIGNED_BYTE)
yields ARGB8888 byte-identical to sw_render_wide_display / upload_present_tex;
rows flipped (GL bottom-left origin) and alpha forced 0xFF to match SW.
- Vtable: wire the 5 .wide_* ptrs so gr_wide_supported() returns nonzero for GL.
- main.cpp present: guard the VRAM-FBO early-return with !wide_present so a wide
GL frame falls through to the existing CPU readout block; all other paths same.
Builds clean. Visual validation pending (human).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- FMV: 24-bit frames AND streamed 15-bit MDEC video (Tomba plays its
movies 15-bit; depth24 alone missed them — user-reported) present
pinned to native 4:3. Detector = colour MDEC decode within the last
30 vblanks (mdec_recently_active); texture-decompression luma
decodes do not stamp. Both GL present paths take force_4_3; SW path
centers a 4:3 dst rect. User-verified on the intro movies.
- Menu garble: full-2D screens (no tagged prims in flight for 2
frames) now squash lines, polylines, flat rects and mono sprites
too — dialog borders are drawn with these and stuck out past the
squashed SPRT boxes. Full-width TILEs (fades) stay exempt; in-game
non-textured prims stay untouched (may be world-anchored).
- HUD positioning: in-game untagged SPRTs pivot by thirds — outer-
third elements anchor to their screen edge (16:9 corner position,
native proportions), middle third to centre. Full-2D screens keep
the single centre pivot so composites stay coherent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>