Commit Graph

976 Commits

Author SHA1 Message Date
tetrisgm a29bb7cddb pgxp: measure the Z distribution instead of guessing znear
Adds a per-frame census of the Z the title actually emits (min/max/mean plus
log2 buckets), read over the wire via pgxp_depth. This should have existed
before any znear was chosen; two wrong values shipped because it did not.

What it says for WipEout 3, on the demo savestate: min 125, max 28443, mean
5761, with the mass spread across 2^9..2^13. That immediately falsifies both
earlier choices. znear=1 maps the whole scene into NDC [0.984, 0.99993] --
0.8% of the depth range, so the test between any two objects is decided by
float noise, which is exactly what the depth readout showed (ship 254-255,
scene 254-255). And znear=128 does NOT "swallow the near field" as the previous
commit message claimed: only 0.3% of vertices sit below 128.

Sweeping znear at 32/64/100 against ship visibility gives 0.52/0.55/0.52 mean
coverage versus 1.38 with depth off -- identical within noise. So the mapping is
not the discriminator at all, and the reciprocal-vs-linear and znear work, while
individually correct, were never the reason the ship vanished.

Where it actually stands: at znear=64 the ship's median depth is 219 against 227
for the track around it, i.e. correctly NEARER. But the surrounding sample bottoms
out at 0 -- fragments claiming the very near plane, which under GL_LEQUAL punch
holes straight through the ship. Since the census says no vertex is below 125,
those cannot come from real geometry, so the next step is validating which
primitives get armed rather than tuning the projection any further.

Depth stays OFF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:05:13 -07:00
tetrisgm d0ead48928 Merge remote-tracking branch 'origin/feat/rbengine' into feat/ws-cull-widen
# Conflicts:
#	runtime/src/debug_server.c
#	runtime/src/gpu.c
#	runtime/src/gpu_gl_renderer.c
#	runtime/src/tex_pack.cpp
2026-08-17 17:42:40 -07:00
tetrisgm 774b326fca pgxp: declare the depth API and wire [video] pgxp_depth through config
The header contract and config plumbing for the depth work; the implementation
landed in the preceding commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:38:25 -07:00
tetrisgm 7b5886d85a pgxp: own the depth clear, fix znear, and keep depth OFF -- the Z is not
comparable between objects

Two real fixes, and an honest negative result.

The per-frame depth clear was keyed on the debug server's s_frame_count, which
is incremented from two different places depending on whether PSX_DEBUG_TOOLS is
compiled in. A Release build could therefore clear the depth buffer once and
never again, letting depth accumulate until nearly all geometry is rejected --
the ground vanishing mid-race. It is now armed at Swap by this file, which is
the real frame boundary and needs nobody else's counter. This class of bug is
invisible to a savestate A/B, because that only ever compares single frames.

znear was 128, chosen by a sweep that scored "distinct depth levels used". That
metric was exactly backwards: the map clamps with max(a_z, zn), so a large zn
collapses the near field onto one plane and CONCENTRATES the remainder into more
visible levels -- the broken value scored best. At 128 the track under the camera
was swallowed and then occluded the ship entirely. Now 1, which clamps nothing.

But depth stays OFF, because that did not fix it. Measured over 12s of motion,
sampling the ship's own livery pixels: off gives 5.05/0.23/1.16/1.34/0.82/...,
on gives 2.78/0.22/0.03/0.57/0.01/... -- consistently lower and twice near zero.
With clamping gone the mapping is no longer the suspect; the recovered Z simply
is not comparable BETWEEN objects. SZ is the GTE's post-RTPS projected depth,
and two objects projected with different h or shift factors do not share a
scale, so the ship can sit "behind" track it is plainly in front of. Resolving
that means reconstructing view-space Z rather than reading SZ, which is what
PGXP proper does.

The depth readout (pgxp_depth zdebug=1) and the wire-tunable znear stay, since
they are what turned this from guesswork into measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:33:00 -07:00
Alex Vanderveen 37ef57e667 Merge origin/feat/rbengine into pr/ws-ui-groups.
Keep ws_ui_groups diagnostic root and feat/rbengine's ram-mapped src_addr.
Keep feat/rbengine's recomp-net pointer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 20:27:34 -04:00
tetrisgm 09d6004703 pgxp: reciprocal depth, znear 128, and ON by default
The fragmentation was the mapping, not the data. A linear map has to pick a far
value and CLAMP past it, so everything beyond collapsed onto a single plane with
no ordering left among it -- which is most of a race track, and why the ship's
fuselage was being chopped apart by geometry behind it. 1 - 2*zn/z is monotonic
over the whole range with no clamp and spends its precision near the camera
where overlaps actually happen, exactly as a hardware perspective depth buffer
does.

znear 128 from a sweep against a savestate, measured on the depth readout rather
than picked: it resolves 135 distinct depth levels where 2 and 8 resolve 10 and
16.

Verified on two independent savestates at 6x internal resolution, same-frame
A/B (savestate replay is deterministic to the pixel -- the control is a 0-pixel
diff, so any difference is the setting under test). Both render the ship intact
and correctly ordered against the track, where the previous linear mapping left
it fragmented or missing entirely.

Default ON, here and in the framework. This is the point of the exercise: the
ordering table sorts one bucket per primitive and cannot resolve intersecting
geometry at all, so a recovered per-vertex depth is a real improvement over what
the hardware could do, not a compatibility risk to be hidden behind a flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:04 -07:00
Alex Vanderveen e40df3092b Merge pull request #150 from tetrisgm/pr/present-shot-and-hires-pitch
Fix screenshot_hires pitch; add present_shot for the composed present surface
2026-08-17 20:24:46 -04:00
tetrisgm 97e8d6a634 Revert the live sample rect: it reintroduced the glyph seams
Taking src_w/src_h/origin_u/origin_v from the live draw is more correct in
principle -- origin is relative to the CURRENT texture page, and a cached one
goes stale as soon as an entry serves a draw from a different page. But it
visibly brought back the double white lines through the F500 title text that the
texel-centre fix removed, so the two are coupled: the cached origin and the
current sampling agree with each other, and changing one alone breaks that.

Reverted rather than left in, because a real user-visible regression outweighs a
latent correctness issue that has not been shown to bite. The "top row clipped,
bottom row duplicated" symptom it was meant to fix therefore still stands and
has some other cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:23:28 -07:00
tetrisgm 11c202e40c tex_pack: take the sample rect from the live draw, not the cached entry
Repl caches src_w/src_h/origin_u/origin_v on its first decode, but origin is
relative to the CURRENT primitive's texture page. As soon as one entry serves a
draw from a different page, a stale origin shifts the whole sample rect. On a
font that reads exactly as reported: every glyph sampled about a texel too low,
the top row clipped off and the bottom row duplicated by the clamp -- "FORMAT"
and "PSYGNOSIS" cut off on top with an extra line underneath.

Also switches PGXP depth from a linear map to a reciprocal one. Linear has to
pick a far value and CLAMP past it, and everything beyond collapses onto the far
plane with no ordering left among it -- which is most of a race track. 1 - 2*zn/z
is monotonic over the whole range with no clamp and spends its precision near
the camera where overlaps happen, as a hardware perspective depth buffer does.
Swept against a savestate: znear 128 resolves 135 distinct depth levels where 2
and 8 resolve 10 and 16.

PGXP depth remains default-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:22:47 -07:00
tetrisgm b0ea3637c5 pgxp: scale depth to the Z range the title actually emits, and add a depth readout
Three findings, each from measurement rather than inference.

A depth readout (pgxp_depth zdebug=1) paints window-space depth as greyscale.
It lives in the FRAGMENT shaders deliberately: TEX_VS is shared by two programs,
and a uniform added there is unset for whichever one forgets it, which silently
divides by zero -- a trap this change fell into once already. gl_FragCoord.z
needs no new varying.

What it showed: 95.3% of the frame sat in the first 6% of the depth range, and
the remaining 4.7% at exactly 0.5. Dividing by 65535 was wrong -- that is the
SZ register's range, but this title emits values under ~4000, so every projected
vertex was crushed onto the near plane and all depth resolution was lost. The
0.5 population was unarmed geometry: zc = 0 reads as "no depth" but after the
divide it is NDC 0, the MIDDLE of the range, so unprojected primitives sat at
0.5 and lost to projected ones nearer than that. That is why the ship vanished
behind the track.

So: scale is now a tunable (default 4096, wire-settable via pgxp_depth
zscale=N) because the right value is a property of a title's projection
distance rather than a constant, and unarmed vertices pin to the near plane,
which is the painter's order they had before the feature existed. Swept against
a savestate: 512 uses 115 distinct depth levels where 256 and 8192 use 21 and
17.

Still not correct, and still default-off. At 512 the ship is largely visible
where it was almost entirely culled, but armed triangles still fragment against
the track. The likely reason is structural: SZ is the GTE's PROJECTED screen Z,
so two objects projected with different h are not comparable on it. Fixing that
means reconstructing view-space Z, which is what PGXP proper does.

Verification here used savestates: load, toggle, capture. Replay is
deterministic to the pixel -- the control (same state, same settings, twice) is
a 0-pixel diff -- so any difference is attributable to the setting under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:12:06 -07:00
tetrisgm ab04da86e3 pgxp: depth from the absolute GTE screen Z, not the normalised perspective weight
PGXP depth shattered the scene, and the reason was a category error rather than
a tuning problem. It derived depth from a_q, which is q/qmax -- a perspective
weight normalised WITHIN one triangle. Every triangle therefore got its own
private 0..1 range, so the value said nothing about where one triangle sat
relative to another, which is the only thing a depth buffer is for.

The real depth was already in hand and being thrown away. prepare_texture_triangle
reads a 16-bit GTE screen Z per vertex via gte_precision_load_word, then
normalises it away on the very next line because UV correction only needs
relative weights. That Z is a single global scale, so it is exactly what a depth
buffer wants.

Adds gr_set_depth_triangle alongside gr_set_perspective_triangle rather than
widening it: the two carry different quantities for different purposes, and a
null-safe hook leaves the software backend untouched. The GL backend takes it as
its own vertex attribute (a_z, location 10) and maps it straight to NDC.

Verified in 3D attract-mode gameplay: with pgxp_depth on the scene renders
intact -- track, perspective-correct texturing and effects all correct -- where
before it shattered. On the 2D title screen the on/off frames are bit-identical,
because no primitive there carries GTE provenance, so a_z is 0 and depth stays
off. Still default-off in config; this makes the feature viable, it does not
turn it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:49:15 -07:00
tetrisgm e1061c6458 tex_pack: read the fragment's real palette index instead of guessing the ink
Hardens the palette-agnostic fallback against defects an adversarial review of
the first cut turned up. The feature is dormant on this title's title screen
(every draw hits its exact key), so none of this changed a pixel here -- it is
the difference between a mechanism that happens not to fire and one that is
correct when it does.

The recolour shader guessed: it scanned the CLUT for "the first non-zero entry
after index 0" and called that the ink. That assumed the hole is always index
0, applied one colour to the whole primitive regardless of which entry a given
fragment actually addresses, and at 8bpp could run 255 dependent texelFetches
per fragment. It now reads the index the hardware would have read -- one fetch,
the same arithmetic as TEX_FS's fetch_texel, but with the index and the CLUT
lookup split, because HD ink overhanging a hole texel must resolve a different
entry than the one under it. Falls back to the image's own ink index for those
overhang pixels (which is every glyph edge, since the pack shape is finer than
the texel grid), and discards only when the ink itself has been blanked -- which
is what vanilla does there too.

Colour is now rescaled by the pack pixel's intensity against the image's full-ink
maximum. Under the mono gate that is exactly 1.0 in the interior, so it is a
no-op there; at an edge it carries LINEAR's ramp onto the live colour, so the
fallback antialiases like the exact-key path instead of hardening into a dilated
silhouette.

Four more, each a real defect:
- The donor was whichever filename directory_iterator yielded first, and that
  order is unspecified -- which variant donated a texture's shape could differ
  between runs and machines. Now the lowest key in the sorted set.
- No depth gate: a 15bpp draw has pal_n == 0 so its exact key never matches, and
  it would have been served a 4bpp-dumped image whose src_w is 4x wrong.
- The non-mask path did `return 0`, abandoning the whole upload scan, while the
  neighbouring key and exclude tests both `continue`. A newer non-mask upload
  could therefore block an older one that matched on its exact key.
- The recolour flag was not cleared on set_replacement's early returns, so a
  cleared slot left it armed for whichever primitive got a replacement next.

Also gates on the image having been analysed at this draw's texel depth and
grid, and unbinds the VRAM mirror from unit 1 after the draw -- pack_flush
renders into that same texture, and leaving it bound for reading is undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:33:41 -07:00
tetrisgm e1a55da286 tex_pack: palette-agnostic substitution with runtime CLUT recolour
A pack entry is keyed (texture, palette), so a draw whose CLUT ships no file
falls back to the original low-res texture even when the pack plainly has the
right IMAGE -- the texture hash identifies it, only the colour is unknown.

On an exact-key miss we now borrow any palette variant of the same texture for
its shape and take the colour from the live CLUT in the shader. REPL_FS gains
the VRAM usampler (on unit 1; unit 0 already holds the replacement image, and
sharing it would have the sampler read the pack PNG as if it were VRAM) and
reads the first non-zero palette entry as the ink. STP comes from that live
entry too, since a different CLUT may set the bit differently and reading it
off the stale image would blend the glyph wrongly. Alpha still decides the
cutout: that is the SHAPE, and the pack is the authority on it.

The gate is a property of the DATA, not a list of texture hashes we happen to
know are fonts: recolour only where the pack image has a single ink colour, so
there is no colour structure to flatten. image_is_mask computes it once at
decode. Multi-colour images decline and fall through unchanged, counted in
repl_multicol.

Also adds repl_calls / repl_nocontain / repl_notex, because "hit + miss" could
not distinguish "the pack has no image for this texture" from "no upload backs
this primitive", and those want opposite fixes. That distinction is what showed
the fallback does not fire on the WipEout 3 title screen at all -- every draw
there already hits its exact key -- so the substitution path is byte-identical
where it does not apply, and the blocky title-screen text has some other cause.
In gameplay the misses are overwhelmingly repl_notex: textures with no pack
entry of any palette, which recolour cannot help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:20:59 -07:00
Alex Vanderveen b423486563 toolchain, texture pack update 2026-08-17 18:55:34 -04:00
tetrisgm 88bf1bed72 gl: drop mipmaps for HD replacements
Added speculatively alongside the texel-centre fix, on the theory that
minification aliasing was shredding thin strokes. It was not: the seams were
entirely the half-texel sampling offset, and that fix stands on its own.

Mipmapping an atlas is only safe if its cells are padded, and the pack's sans
font packs glyphs touching, with no separator column. Even capped at the
original texel grid, the filter kernel then reaches half a source pixel into the
neighbouring glyph, so every letter picked up a faint ghost of its neighbours --
visible as smudges beside the sans text that were not there before. The F500
atlases have a one-pixel gap and so were unaffected, which is what made the
regression look font-specific rather than structural.

Plain LINEAR, as before. Minification aliasing is a real but secondary concern,
and the correct fix for it (per-cell padded mip chains) is not something we can
apply to a third-party pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:29:41 -07:00
tetrisgm 769839bb5f gl: sample HD replacements at texel centres, and let screenshot_hires see them
Three fixes to the HD texture replacement path, all found by looking at the
same artifact: faint vertical hairlines along every glyph boundary in the
replaced font, plus a stray fragment floating above the loading screen's
cursor glyph.

REPL_FS mapped a PS1 texel index straight to a normalised coordinate:
(uv - origin) / src. An integer u addresses texel u, whose span in the
replacement is [u, u+1), so that lands on the texel's left EDGE, half a texel
short of its centre. On the PS1 path NEAREST still resolves to the right texel
and the omission is invisible; this path is LINEAR (the whole point is detail
the texel grid does not carry), and there the shift blends in whatever sits one
texel over. On a font atlas the neighbour is the adjacent glyph's cell, so
every glyph quad drew a hairline of its neighbour along its own edge --
background-coloured slivers through letters, ink-coloured ones in the gaps, and
at the cursor cell a whole fragment of the glyph above it. Sample at +0.5 and
clamp to v_limits.zw rather than +0.999, so the far edge lands on the last
texel's centre instead of past it.

Replacement uploads are also mipmapped now. A pack image is an integer upscale
of a source the game draws at or near native texel size, so on screen it is
minified; unmipmapped LINEAR samples roughly one texel in N and drops the rest,
which shreds thin hard-edged strokes. The deepest mip a draw can select sits
around the original texel grid, where a level is the box-average of one source
pixel -- so this is never worse than vanilla and sharpens as internal
resolution rises.

Both of those were diagnosed at 508x256, because screenshot_hires on the GL
backend was ensure_cpu() + sw_render_display_hires(): ensure_cpu() packs the
FBO down to the 1x 15-bit CPU mirror, so every supersampled pixel was averaged
away BEFORE the "hires" resolve ran. It then filled dw*dh of a (dw*S)*(dh*S)
buffer and the caller honestly downgraded to "scale 1". The command silently
could not see anything that exists only at high internal resolution -- exactly
the class of work it was written to verify. Read the display rect out of the hr
FBO directly instead. Verified: 3048x1536 at scale 6, correct colours and
orientation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:16:31 -07:00
tetrisgm 37fdd3bd42 Give the replacement program its own TEX_VS uniforms
Root cause of every "enabling a pack erases the UI" failure, and it is not any
of the things that were blamed for it.

Uniforms are per-PROGRAM. s_repl_prog shares TEX_VS's source with s_tex_prog
but not its uniform state, and startup only initialises s_geo_prog and
s_tex_prog — with a comment saying exactly why that matters:

    /* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL
     * would otherwise zero them, collapsing all x to 0. */

So every replaced primitive ran with u_xscale = 0, collapsing all x to
u_xcenter, and u_xhalf = 0, dividing by zero on top. Degenerate, off-screen,
rasterizing nothing. No replaced primitive had EVER drawn — including the 3D
ones that were previously reported as working; that sharpness was perspective
texturing, not the pack.

Initialise the replacement program's copies to the same no-op defaults, and
give the native-wide mirror inside draw_repl_prim this program's uniform
locations instead of s_tex_*'s, which would otherwise write the wide transform
into the other program while this one kept its defaults.

Rects are armed again. gpu_textured_rect decomposes into gpu_textured_triangle,
so the 2D layer was always routed correctly; withholding it was a mitigation
for this bug, not a fix for a missing path.

Measured in-race at 32:9 with princepeter64's Wip3out pack: 292 entries
indexed, 79 decoded, zero failures, 33215 replaced draws, and the complete HUD
present — lap counter, pickup indicators, timer, lap readout, speed and
acceleration bars.

The magenta probe (tex_pack repl_debug=1) is kept. Forcing replaced fragments
to a flat colour with no discard is what separated "the draw never lands" from
"it lands and samples nothing"; four hypotheses were tested and discarded
before that, each costing a rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:31:50 -07:00
tetrisgm 0bdf338571 tex_pack: substitute HD replacement textures at draw time
Completes the feature tex_pack.h has always described. Until now it identified
replacement textures and never swapped them: it opened files only for writing,
and its known-set was built by parsing filenames, so a pack could report
hundreds of "matched" entries while changing nothing on screen.

Decode a matched entry lazily via stb_image (declarations only; the
implementation is compiled in psx_window_icon.cpp, whose STBI_NO_STDIO must be
matched here, hence reading the file ourselves), cache it per key, and hand the
draw path the image plus the mapping from texture-page UVs into it:

    s = (u - origin_u) / src_w     t = (v - origin_v) / src_h

so the replacement's own resolution never appears at the call site — a 16x
atlas and a 1:1 image are addressed identically.

The GL side draws a replaced prim through its own program (REPL_FS, sharing
TEX_VS so positions, the perspective divide and the native-wide transforms stay
bit-identical) and its own draw, leaving the batch path untouched. Two things
that cost real debugging time and must not be re-derived:

  - frag.a is the PS1 MASK BIT, not opacity, and blend_factor's alpha is the
    dual-source destination factor. Writing either as coverage sets the mask
    bit on every replaced pixel and makes later masked draws vanish.
  - The two-pass mask/stencil protocol is NOT duplicated. tex_batch_draw_passes
    is parameterised on the program's uniform locations and the mask flag, and
    both paths call it. A hand-written second copy dropped the s_mask_check
    fixup and the semi == 4 dual-source case.

RECTS AND SPRITES ARE TRACKED BUT NOT SUBSTITUTED. The backend consumes a
replacement in its textured-triangle path only; rect prims take their own route
and never sample it, so arming one produced primitives that drew nothing. On
WipEout 3 that is the whole 2D layer — HUD meters, boxes, pickup indicators,
and every glyph, since the font atlases are addressed by sprites — which is why
enabling a pack erased the interface while the 3D scene rendered correctly.
Identity tracking still runs, so the dumper, the match set and the UV census
are unaffected; only application is withheld until that path is wired.

Also adds the instrumentation that found this after four wrong guesses:
"tex_pack repl=0/1" toggles substitution live (an A/B needing a rebuild does
not get run), and "tex_pack sub=armed" reports WHICH textures were substituted
and where they landed on screen, which is what distinguished "the HUD is being
replaced by the wrong image" from "replacement corrupts state the HUD needs".

Measured in-race at 32:9 with princepeter64's Wip3out pack: 292 entries
indexed, 7 decoded, zero decode failures, 8006 replaced draws, full HUD intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:18:24 -07:00
Alex Vanderveen a643fab71d Make the FMV present filter a real setting
The reconstruction added in the previous commit was env-only (PSX_FMV_FILTER),
which is fine for trying it out and no good for shipping it. Promote it to
[video] fmv_filter in both game.toml and settings.toml, spelled
"nearest" | "bilinear" | "sharp" | "bicubic" (default bicubic), and push the
resolved value to the renderer instead of having the renderer read getenv.

PSX_FMV_FILTER still overrides for a single run without touching the user's
settings, which is what an env knob is actually for.

Also bridges the value to recomp-ui's launcher settings in both directions so
the Display settings row round-trips: recomp-ui stores it 1-based (0 = unset,
so a host predating the field gets the default rather than Nearest), the
config enum is 0-based, and the conversion is confined to two helpers at the
boundary.

Verified end to end: with fmv_filter = "bilinear" in settings.toml the
presented pixels match the bilinear reference exactly (mean |dx| 0.930,
hard-step fraction 0.14%), where the default build produces the bicubic
numbers (1.038 / 0.34%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:34:23 -04:00
Alex Vanderveen c8a1734b81 gl: reconstruct the low-res FMV present instead of point-sampling it
24-bit FMV was pinned to nearest, so a 320x192 movie was point-sampled up to
the whole window: hard pixel edges, and at a non-integer scale the source
pixels land unevenly (some 3 window pixels wide, their neighbours 4).

The reason for the pin was that plain GL_LINEAR fringed the right edge of
low-res 24-bit scanouts into adjacent texels — but gl_renderer_present has
since grown a half-texel UV inset and a content_w crop for the depth24
margin, which is that bleed. Reading back the drawable confirms it: the
right-edge jump is 0.00 with filtering on.

So FMV now follows the video AA setting like everything else, and the
present shader reconstructs the source rather than smearing it.
PSX_FMV_FILTER picks how, since this is a taste call on a blocky source:

  nearest   previous behaviour (also what video AA = off gives)
  bilinear  plain GL_LINEAR
  sharp     sharp-bilinear, ramp confined to a one-output-pixel band
  bicubic   Catmull-Rom (default)

Measured on the intro at 1280x960 — fraction of adjacent pixel pairs
differing by >=24 luma (visible staircase) vs mean |dx| (overall sharpness):

  nearest   1.00%  1.028
  sharp     0.87%  1.008
  bicubic   0.34%  1.038
  bilinear  0.14%  0.930

Bicubic is the default because it removes two thirds of the staircase while
holding gradient at the nearest level; bilinear removes the most but costs
10% of it, which reads as blur.

s_present_prog is shared with both VRAM/FBO quad paths and program uniforms
persist, so every user now states its mode explicitly; the rasterized paths
pass 0 (they render at internal scale and have no low-res source to
reconstruct) and are byte-identical to before. No FMV cost (56.3 vs 55.4
fps) and no in-race change (57.0/57.6 vs 56.6/56.4).

Also extends the present dump with PSX_PRESENT_DUMP_OUT=1, which reads the
drawn result back off the drawable — the source-side dump cannot show what a
present filter did, and this is what produced the numbers above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:14:38 -04:00
Alex Vanderveen 408f829a13 gl: sync the CPU mirror when entering 24-bit, fixing FMV letterbox flicker
In depth24 the frame is presented from the CPU mirror and MDEC writes only
the movie's own rows. A letterboxed movie never touches the bars, so they
scan out whatever the mirror already held — and anything the game drew as
a GPU PRIMITIVE (its pre-movie clear of exactly those bars) lives only in
the FBO and never reached the mirror.

WipEout 3's intro is 320x192 written at +32 inside a 320x240 band, double
buffered (rows 32-223 shown as band 0-239, rows 288-479 shown as band
256-495). The uncleared bars kept the previous SCEA screen, and since the
two buffers hold it at different rows it alternated between the top and
bottom bar every couple of frames — a flicker rather than a static smear.

ensure_cpu() once at the 24-bit entry transition fixes it: verified by
dumping the presented pixels, peak brightness in the bar rows goes 255 -> 0
on every sampled frame, early and late in the movie. Cost is one full-VRAM
readback per entry (a handful per movie), and it is a no-op when the FBO is
already clean.

Also adds the two diagnostics this was found with, both env-gated and free
when unset: PSX_PRESENT_DIAG logs present-path/display-mode transitions,
and PSX_PRESENT_DUMP writes the actual presented pixels as PNGs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:43:33 -04:00
Alex Vanderveen 2298c90cb7 rewind: do not densify the FMV capture interval at 8 MB
An 8 MB snapshot is ~10 MiB raw and do_capture streams its deflate at
RW_ZSLICE (1 MiB) per vblank, so one capture takes ~10 vblanks to drain
and any capture requested while s_zpend is active is skipped. Asking for
a capture every 4 frames therefore never achieves 4 — it just pins the
compressor at 100% duty on the emu thread for the whole movie, buying
rewind granularity the pump cannot actually deliver.

Measured on the WipEout 3 ntscfull8 intro: 17% of FMV frame rate.

Give the FMV interval the same 8 MB floor RW_DEF_INTERVAL_8MB already
gives the normal one. PSX_REWIND_FMV_INTERVAL still overrides (its clamp
grows to 60 so the new default is reachable).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:43:33 -04:00
Alex Vanderveen 661f81de6f text_xlate: default the capture inventory off in release builds
text_xlate_on_dispatch runs at every dispatch. With capture on it scans
a0..a3, classifies each argument as text-ish, reads the record and hashes
it — for every call, forever. The capture inventory is an authoring tool
(it enumerates source strings for a translator); applying a shipped
translation is a separate flag and is unaffected.

On the WipEout 3 ntscfull8 mod (~33k dispatches/frame) it was the single
largest CPU consumer in the intro FMV: gprofng attributed 18.6% of samples
to it, and turning it off measured +22% fps over guest frames 800-2200.

Default it to the build flavour that owns the rest of the authoring
tooling: on under debug tools, off in release. PSX_XLATE_CAPTURE now
overrides in both directions rather than only being able to disable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:43:33 -04:00
tetrisgm ddbd92dfab tex_pack: record the distinct UV cells each texture is sampled with
Authoring a replacement for an ATLAS needs its cell layout, and a proportional
font atlas does not carry one. Rows are packed with no separating scanline,
every glyph is a different width, and in WipEout 3's Arial-ish atlases adjacent
glyphs touch outright -- one ink run there spans 84 px and covers many
characters. Reading cells back out of the bitmap is guesswork.

The game states them exactly, once per draw, and tex_pack_on_textured_prim
already sees every one. Record the distinct rects per <texhash>-<palhash>, in
texel coordinates relative to the texture page, alongside the upload's origin
in that page so they map 1:1 onto the dumped PNG. Exposed as
"tex_pack sub=uvs".

Only populated while dumping: the dedup scan is linear and this is an authoring
tool, not something a play session should carry. Capped at 4096 rects; a font
atlas needs a couple of hundred.

Cross-checked against the F500 atlas, where glyphs happen to be separated by
exactly one blank column so the bitmap can be segmented independently: the two
agree on every observed cell, with cell = ink_start-1 .. ink_end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:42:32 -07:00
tetrisgm d4e7c25a9b Fill the pillarbox margins of 4:3-pinned presents with the frame's edges
WipEout 3 gates widescreen to GTE-active frames (game.toml gte_game_mode), so
the title, menus and loading screens present at native 4:3 by design. On a 32:9
display that leaves them as a small island with black over most of the screen.

Extend the frame's own leftmost and rightmost columns across those margins.
PRESENT_VS interpolates v_uv.x as mix(u_uv_rect.x, u_uv_rect.z, p.x), so
passing the same u for both ends makes a quad sample exactly one texture
column -- clamp-to-edge, which the present texture is already set to. The 4:3
content is untouched: same position, same scale, no distortion. Only the
margins change, which is why this beats stretching (2.67x-wide text at 32:9)
and cropping (cuts off menu rows you have to read).

Both GL present paths need it. gl_renderer_present is the CPU-readback path;
the FBO path (GL_PRES_VRAM, via present_target_quad) is the one that actually
runs at internal scale > 1, which is every configuration this matters for --
patching only the first looked like the feature silently doing nothing.

Not applied to MDEC video (depth24), whose edge pixels are real picture
content and would smear, nor to the depth24 trailing-margin crop, which leaves
black on the right deliberately. Live A/B via the ws_menu_edge_fill command
rather than a rebuild, since this is a look and not a correctness fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:24:19 -07:00
tetrisgm c80b971198 Expose tex_pack over the debug server; emit paths as valid JSON
tex_pack.cpp has carried tex_pack_debug_json since it was written, but nothing
registered it as a command, so "is the HD pack actually matching?" could only
be answered by staring at the screen. That is the one question that decides
whether a Beetle-authored pack is compatible at all, and "missing" -- pack
entries no draw has claimed -- is the authoring aid for building a new one.

Wiring it up surfaced a second defect: the stats object printed pack_dir and
dump_dir with a raw %s of a filesystem path. On Windows every separator in
"C:\Users\..." reads as a JSON escape and "\U" is not a legal one, so the
response could not be parsed at all. Escape the strings at the emitter, where
the path becomes JSON, rather than at each caller.

Measured with princepeter64's Wip3out pack installed as
<disc stem>-texture-replacements: 292 entries indexed, 80 matched in a single
race, 186452 textured primitives and 50951 palette hashes observed. The
framework's hashing is Beetle-compatible in practice, not just by intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:02:07 -07:00
Alex Vanderveen 6a9002032f memory optimizations for 8mb wipeout, performance in general, turbo++ 2026-08-17 14:32:01 -04:00
Alex Vanderveen 958e3c6c5c vsync true fix 2026-08-17 11:40:25 -04:00
Alex Vanderveen fa160f2fec cadence fix vsync 2026-08-17 10:42:56 -04:00
tetrisgm 2cecfccb58 Squash untextured HUD rects too; report what the rank filter drops
ws_ui_prepass_add admitted only the TEXTURED rectangle/sprite families, and
only the textured draw paths carried a widescreen transform. Every flat
-coloured HUD mark was therefore left at its raw 4:3 X while the textured
primitives of the same widget were squashed toward their anchor -- so at a
wide aspect it is stranded in open screen, to the left of the cluster it
belongs to. In 4:3 it sits on its neighbour's edge and reads as part of it,
which is why this is invisible until the frame widens.

Admit the whole GP0 rectangle range instead, decoding size from bits 4-3 and
textured from bit 2, and wire the matching transform into gp0_exec_mono_rect,
gp0_exec_mono_dot, gp0_exec_mono_8x8 and the inline mono 16x16. A GP0
rectangle is always screen-space and never carries GTE output, so this cannot
reach world geometry. The polygon families are deliberately NOT admitted here:
a mono or gouraud quad CAN be projected world geometry.

ws_ui_groups also now reports the geometry of the primitives the max_rank
filter discards, not just how many. That distinguishes "stray world geometry,
fine to drop" from "HUD mark whose cluster is being squashed without it". In
race at 32:9 the drops are all small far-rank scenery (op 2c/3c, 2-9 px, ranks
0-576 against a max of 1073), which rules the filter out as a source of
stranded HUD marks.

Confirmed in-race at 32:9: HUD unchanged at 7 runs with the same anchors, so
admitting the untextured families disturbs no existing grouping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 03:13:38 -07:00
tetrisgm 572b9210d8 auto_ui_squash: join on submission order; instrument the silent gates
Three changes, one fix and two pieces of visibility.

1. ws_ui_group_assign gains a third join: consecutive in the ordering-table
walk AND touching. WipEout 3's speed/shield readout ends at x=288 and the
meter bar it belongs to starts at x=306 with a different CLUT, so neither
shared columns nor a matching key could join them. The readout stood alone,
its bbox midpoint 256.5 landed in the middle third against a screen centre of
254, and it stayed pinned near centre while its own meters went to the right
edge -- a 2 px gap in 4:3 opening to 159 framebuffer pixels at 32:9.

No anchor threshold can fix that: the genuinely centred "pit ... check"
message sits FARTHER RIGHT (midpoint 300.5) than the mis-anchored readout, so
any threshold reaching the readout drags the message with it. Submission order
is the only signal that separates them, and the array already carries it.

Simulated against seven captures spanning three aspects and two code states,
this forms three unions and changes exactly one anchor: [225,288] centre
becomes [225,486] right. The other two unions ([208,240]+[240,361],
[364,447]+[450,473]) are within an anchor. Confirmed in-race at 32:9: 7 runs,
lap readout left, speed readout and meters together on the right.

2. ws_ui_groups reports why primitives were NOT admitted to the partition.
Each gate in ws_ui_prepass_add is a silent return, so a primitive left at its
raw 4:3 X while its neighbours are squashed -- which is how a HUD mark ends up
stranded mid-screen -- was indistinguishable from one never drawn. In-race at
32:9: opcode 367, not_axis 410, everything else 0.

3. geom_correction reports perspective-UV arming with a real denominator.
perspective_triangles could previously only be compared against gp0_draw,
which counts untextured primitives that are correctly never armed, so it read
as a coverage figure without being one. texcorr.attempts counts exactly the
textured triangles reaching the predicate. In-race at 32:9: 199599 submitted,
96.15% armed, 3.85% rejected for a missing per-vertex Z, 0% for correction
disabled or a missing packet address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 03:03:33 -07:00
tetrisgm 72196d3dc9 auto_ui_squash: group HUD primitives that are one visual element
The thirds anchor is chosen per union-find run, and runs only joined when the
group key matched within JOIN_GAP. That key folds CLUT, texpage, a 24px Y band
and the poly-vs-rect family together, so a digit and the box it is drawn on
could never land in the same run. They got independent anchors and were
squashed about different points, which pulls one piece of a readout toward a
screen edge and leaves the rest behind -- worse the wider the frame.

On WipEout 3's race HUD at 32:9 the lap readout draws its digits at
y=[219,227] x=[137,202] and the box they label at y=[228,244] x=[138,202].
The digits anchored left, the box anchored centre, and the box landed about
500 screen pixels from the digits it belongs to.

Join on being one visual element as well as on the key: share screen columns
and touch vertically within STACK_GAP. Both halves are needed. Column overlap
alone merged the top-left lap counter with the bottom-left lap timer 200
scanlines below, and since union is transitive that chained all 71 HUD
primitives into one run spanning the full width, which anchors centre and
drags the corners inward. A strict row intersection missed the stacked case
above, whose seam is one pixel.

WsUiGroupItem carries y/height for this; the anchor stays horizontal. After:
9 runs, bottom-left readout whole and anchored left, timer and energy bar
right, speed centre -- matching the 4:3 layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:31:31 -07:00
tetrisgm 777117c1f2 Fix screenshot_hires pitch; add present_shot for the composed frame
screenshot_hires passed the pixel width as out_pitch, but the resolves
(sw_render_display / sw_render_display_hires) step rows in BYTES, as the
present path does (main.cpp: sw * sizeof(uint32_t)). Each row advanced a
quarter of a row, tiling the frame 4x across and leaving the bottom three
quarters black. Verified on WipEout 3: content rows 65/256 -> 256/256.

The same handler could also emit untouched allocation: gr_scale() reports
the GL backend's internal scale, but sw_render_display_hires falls back to
the native resolve when the CPU hi-res mirror does not exist, filling w*h
of an ow*oh buffer while still returning non-zero. The buffer is now
calloc'd and the resolve must report FULL cover or the capture redoes
itself honestly at native size.

Add present_shot / present_shot_seq: a capture of the COMPOSED present
surface. Every existing capture resolves the display buffer BEFORE the
backend fits it to the window, so on a 508x256 display in a 4:3 window
they answer 508x256 while the player sees 640x480. That is correct for
faithfulness work but wrong for anything aspect-shaped -- a widescreen
change moves the GTE squash and the present fit, which is exactly the
stage those captures skip.

Fulfilled by whichever backend owns the present: SDL_RenderReadPixels on
the software path, glReadPixels before SwapWindow on GL (with the
bottom-up flip). Notes on the contract:

- GL fulfilment can run on the frame-interpolation thread (interp_present
  -> gl_swap_with_osd), not just the emu thread, so the path buffer and
  pending flag are mutex-guarded and the counters are atomic.
  present_shot_take() claims the request under the lock, so two present
  paths cannot fulfil the same shot.
- A staged shot is OVERWRITTEN by a later request rather than refused,
  as savestate's request_save_inner does. A present that never arrives
  therefore cannot wedge the command.
- Vulkan presents through its own swapchain and has no readback hook, so
  the request is refused there instead of being accepted and never
  fulfilled (CLAUDE.md rule 15).
- present_shot_seq advances on success AND failure so a polling client
  always terminates; its `wrote` field reports whether a PNG landed.
- The SDL2 branch sizes its buffer from SDL_GetRendererOutputSize rather
  than a viewport*scale reconstruction, which could be smaller than the
  region SDL_RenderReadPixels fills.

Also: debug_client gains positional-path screenshot_file/present_shot and
turbo/turbo_state mappings (they previously fell through to the raw
key=value handler); TCP_COMMANDS.md marks screenshot_hires as native (it
is registered on the native server and the blank N column read as
Beetle-only), documents the two new commands, and the generated index is
regenerated via tools/gen_tcp_commands.py.

Tested on WipEout 3 SE (PAL, OpenGL, RelWithDebInfo + PSX_DEBUG_TOOLS=ON):
capture matches the on-screen window at 1280x960; seq/wrote reports 1 on
success and 0 for an unwritable path; back-to-back staging is accepted.
The SDL software path, the SDL2 branches and Vulkan refusal are
compile-verified only.
2026-08-17 02:09:54 -07:00
tetrisgm 3949cb1aa7 ws_ui_groups: copy the union-find root back to the prepass
The group array is a local copy; only .anchor was copied back, so the root the
command reported was the stale insert-time index. Every primitive therefore
looked like its own run even when runs had formed -- which is precisely the
question the command exists to answer.

Caught by the reported anchors contradicting the reported roots: primitives at
an identical x=[319,340] showed anchor 508 and 254, and ws_ui_anchor_for_bounds
is a pure function of (x, width, display_width), so identical inputs cannot
differ unless those items actually merged into runs of different extent.
2026-08-17 01:50:27 -07:00
tetrisgm fd625f0996 Interpreter parity for cull.widen; raise the GL supersampling ceiling
Two follow-ups to [[widescreen.cull.widen]], both found by testing it.

INTERPRETER PARITY. The widen site type was recompiler-only: the AOT image
evaluated the widened compare while the dirty-RAM interpreter, and any overlay
shard built without the config, still evaluated the vanilla one. The same site
would then cull differently depending on which backend happened to run it.
Adds a runtime site registry mirroring psx_ws_cull_keep_site, wires it from the
config at startup, and consults it from the interpreter's SLT and SLTI paths
before the keep lookup so a config migrated from keep to widen cannot be served
the pinned answer by a stale entry.

GL SUPERSAMPLING CEILING 4x -> 16x. SW_MAX_INTERNAL_SCALE was applied to every
backend, which is the wrong constraint for GL. It exists because the software
path allocates a VRAM-sized hi-res MIRROR costing 1 MB * scale^2 (256 MB at
16x); under GL that mirror stays at 1x (glb_set_scale) and the cost is an FBO
instead. Capping GL at 4x limited it to about 2048x960 internal on hardware
that handles far more.

GL_MAX_INTERNAL_SCALE moves to gpu_render.h (main.cpp needs it to pick the
ceiling before a context exists), main.cpp selects the ceiling per backend, and
the config range becomes 1..16. init_gpu_raster now also clamps to the driver's
real GL_MAX_TEXTURE_SIZE / GL_MAX_RENDERBUFFER_SIZE and says so: previously an
over-large scale reached glTexImage2D, failed silently, and surfaced only as
"GL FBO incomplete" with no hint that the scale caused it.

Measured on WipEout 3 SE (PAL, NVIDIA, Release build + debug tools), sustained
present rate in-race, same track:

    scale  internal     fps
      1    508x256      50.0
      6    3048x1536    49.8
      9    4572x2304    50.2
     16    8128x4096    50.0

Flat across the range - the title is guest-bound, so 8K internal is free on this
hardware. 50 fps is full speed for a PAL disc.
2026-08-17 01:41:58 -07:00
tetrisgm a05ad08651 Add [[widescreen.cull.widen]]: move the cull bound instead of pinning it
[[widescreen.cull.keep]] forces a comparison's verdict while wide. That is
correct for a separately proven binary decision, and config_schema.md already
says as much -- "keep has no queue policy and is appropriate only for a
separately proven binary verdict", and prefer an aspect cone "when the original
predicate is a camera-frustum test".

At a clip-code packer it is actively wrong. Pinning the classifier tells the
clipper that nothing crosses the screen edge, so polygons that do are never
subdivided; they are submitted whole with coordinates outside the GPU's legal
primitive range and the hardware discards the entire primitive. The visible
result is geometry disappearing as the camera moves -- widening the view draws
LESS, not more.

Measured on a WipEout 3 game repo, same track and camera path: ~323 primitives
emitted with the pinned sites active against ~761 with the margin at 0. Live
A/B via the ws_margin debug command also showed inverted ship steering and
broken AI with the pins active, both of which cleared at margin 0 while the
16:9 image was retained.

This adds the widening alternative:

    [[widescreen.cull.widen]]
    address  = "0x8017032C"
    expected = "0x28850000"
    mode     = "imm_lower"

Modes name which operand carries the bound and which way it travels:
  imm_upper  SLTI/SLTIU  coord <  imm + m
  imm_lower  SLTI/SLTIU  coord <  imm - m
  bound_rt   SLT/SLTU    rs    <  rt  + m
  bound_rs   SLT/SLTU    rs + m <  rt

Every mode reduces to the vanilla comparison at margin 0, so 4:3 output stays
bit-for-bit identical -- the same guarantee keep sites give. The immediate modes
reuse the existing psx_ws_cull_slti / psx_ws_cull_slti_lower helpers; the
register-bound modes need psx_ws_cull_slt_widen, added here.

The parser rejects a mode that does not match the instruction shape (an
immediate mode on a register compare would widen an operand that does not
exist), rejects duplicate addresses, and full-word guards the site as keep does
so an overlay variant at the same VA is left alone. Widen sites contribute to
overlay cache identity, otherwise migrating a site from keep to widen would
silently reuse the pinned overlay.

No game config uses this yet; migrating a title's sites is a separate change
that needs each site's operand roles established first.
2026-08-17 00:42:30 -07:00
tetrisgm 6de1ad4a05 Add ws_ui_groups: expose the auto_ui_squash partition
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.

Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.

ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".

Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
  three paths (dense, alloc-failure fallback, and the union-find path). Never
  read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.

Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
2026-08-17 00:27:36 -07:00
tetrisgm 6bddaab930 Add ws_ui_groups: expose the auto_ui_squash partition
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.

Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.

ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".

Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
  three paths (dense, alloc-failure fallback, and the union-find path). Never
  read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.

Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
2026-08-16 23:02:17 -07:00
Alex Vanderveen aa208a9100 loader optimizations pushing for 8mb performance 2026-08-17 00:42:48 -04:00
Alex Vanderveen 15699d4005 new project consistency patching project naming with retcomm-studio, toolchain patch 2026-08-16 22:13:37 -04:00
Alex Vanderveen 84ea64d613 texture pack support 2026-08-16 21:55:51 -04:00
Alex Vanderveen cf742f3190 Treat RAM below a high-loading boot EXE as overlay region
The overlay-region model assumed the boot EXE sits at the bottom of RAM:
[KERNEL_END, text_end) is main-EXE text, [text_end, RAM) is runtime-loaded
overlay code. That holds for a load_address of 0x80010000, where the text
base equals the kernel-window end and the region below the text is empty.

It is false for a boot EXE that loads HIGH and streams its gameplay code
into the RAM beneath itself. Klonoa loads a 46 KB stub at 0x80180000 and
runs its overlays from 0x10000-0x130000, so the floor landed at 0x18B000
and the entire overlay region fell below it:

  - dirty_ram_clear_image_baseline() wiped the dirty bits across
    [0x10000, 0x18B000) believing it was the clean compiled text image.
  - the dirty_ram_dispatch_inner admit heuristic requires phys >= floor,
    so a JALR into an overlay page the CD had DMA'd there (never touched
    by the CPU write hooks, so never marked dirty) was refused by the
    interpreter, fell through to psx_unknown_dispatch and fail-fast
    exit(1) — Klonoa, frame 687, target 0x80123D00.

Pin the text BASE (g_text_image_lo) alongside the floor and route the
gates through one predicate: the overlay region is both sides of the text
image, not just above it. Street Fighter Alpha 3 (0x80113B00) and
Bomberman Fantasy Race (0x8003004C) load high too.

No-op for bottom-loading games: g_text_image_lo stays at its 0x10000
default, the below-text clause is empty, and the baseline clear starts at
the same page as before.

Klonoa now runs 6874+ frames at a locked 60 fps with fail-fast on and
zero unknown dispatches; it previously died at frame 687.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 21:21:28 -04:00
Alex Vanderveen 76945d3a65 8mb ram patches + toolchain windows hotfix 2026-08-16 21:19:05 -04:00
tetrisgm 8fee64f178 Fix screenshot_hires pitch; add present_shot for the composed frame
screenshot_hires passed the pixel width as out_pitch, but the resolves
(sw_render_display / sw_render_display_hires) step rows in BYTES, as the
present path does (main.cpp: sw * sizeof(uint32_t)). Each row advanced a
quarter of a row, tiling the frame 4x across and leaving the bottom three
quarters black. Verified on WipEout 3: content rows 65/256 -> 256/256.

The same handler could also emit untouched allocation: gr_scale() reports
the GL backend's internal scale, but sw_render_display_hires falls back to
the native resolve when the CPU hi-res mirror does not exist, filling w*h
of an ow*oh buffer while still returning non-zero. The buffer is now
calloc'd and the resolve must report FULL cover or the capture redoes
itself honestly at native size.

Add present_shot / present_shot_seq: a capture of the COMPOSED present
surface. Every existing capture resolves the display buffer BEFORE the
backend fits it to the window, so on a 508x256 display in a 4:3 window
they answer 508x256 while the player sees 640x480. That is correct for
faithfulness work but wrong for anything aspect-shaped -- a widescreen
change moves the GTE squash and the present fit, which is exactly the
stage those captures skip.

Fulfilled by whichever backend owns the present: SDL_RenderReadPixels on
the software path, glReadPixels before SwapWindow on GL (with the
bottom-up flip). Notes on the contract:

- GL fulfilment can run on the frame-interpolation thread (interp_present
  -> gl_swap_with_osd), not just the emu thread, so the path buffer and
  pending flag are mutex-guarded and the counters are atomic.
  present_shot_take() claims the request under the lock, so two present
  paths cannot fulfil the same shot.
- A staged shot is OVERWRITTEN by a later request rather than refused,
  as savestate's request_save_inner does. A present that never arrives
  therefore cannot wedge the command.
- Vulkan presents through its own swapchain and has no readback hook, so
  the request is refused there instead of being accepted and never
  fulfilled (CLAUDE.md rule 15).
- present_shot_seq advances on success AND failure so a polling client
  always terminates; its `wrote` field reports whether a PNG landed.
- The SDL2 branch sizes its buffer from SDL_GetRendererOutputSize rather
  than a viewport*scale reconstruction, which could be smaller than the
  region SDL_RenderReadPixels fills.

Also: debug_client gains positional-path screenshot_file/present_shot and
turbo/turbo_state mappings (they previously fell through to the raw
key=value handler); TCP_COMMANDS.md marks screenshot_hires as native (it
is registered on the native server and the blank N column read as
Beetle-only), documents the two new commands, and the generated index is
regenerated via tools/gen_tcp_commands.py.

Tested on WipEout 3 SE (PAL, OpenGL, RelWithDebInfo + PSX_DEBUG_TOOLS=ON):
capture matches the on-screen window at 1280x960; seq/wrote reports 1 on
success and 0 for an unwritable path; back-to-back staging is accepted.
The SDL software path, the SDL2 branches and Vulkan refusal are
compile-verified only.
2026-08-16 17:09:26 -07:00
Alex Vanderveen 1181c9c9e9 fix(runtime): restore missing brace after present-cadence log
The vsync hotfix replaced the closing brace of the non-headless SDL
init else with log_present_cadence(), leaving main.cpp unbalanced.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 18:08:58 -04:00
Alex Vanderveen 16bfa4144f 8mb support 2026-08-16 17:59:20 -04:00
Alex Vanderveen ae9432a334 cmake hotfix windows 2026-08-16 17:49:17 -04:00
Alex Vanderveen 28c2790361 Update main.cpp 2026-08-16 14:32:00 -04:00
Alex Vanderveen 1be8f88839 Merge origin/master into feat/rbengine.
Keep the 8 MB RAM builtin alongside PGXP Precision after the master merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 14:11:58 -04:00
Alex Vanderveen 353da99795 Merge origin/master into feat/vsync_hotfix.
Keep vsync/pacer XOR and skip the full-period wait while manual fast-forward already paces at the turbo multiplier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 14:01:35 -04:00