Skip to content

applyGsDispEnv programs display read circuit 2 only - #2

Closed
smmathews wants to merge 3 commits into
mainfrom
feature/05-gs-dispenv-circuit2
Closed

applyGsDispEnv programs display read circuit 2 only#2
smmathews wants to merge 3 commits into
mainfrom
feature/05-gs-dispenv-circuit2

Conversation

@smmathews

@smmathews smmathews commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Problem

applyGsDispEnv — the shared helper behind the sceGsPutDispEnv HLE and the
sceGsSwapDBuff/sceGsSwapDBuffDc double-buffer paths — translated a single
sceGsDispEnv struct into GS privileged register writes and programmed both
display read circuits: dispfb1/display1 and dispfb2/display2, from
the one-circuit env. Real libgraph sceGsPutDispEnv programs only PMODE,
SMODE2, DISPFB2, DISPLAY2 and BGCOLOR; the env describes read circuit 2,
and circuit 1 is left for the game to drive itself via GS privileged MMIO.

Titles that composite two different buffers (for example a movie/scene underlay
beneath a UI overlay, PMODE=0x8007) program DISPFB1/DISPLAY1 themselves.
The HLE's circuit-1 clobber forced both read circuits onto the same surface,
collapsing the blend.

Fix

ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.happlyGsDispEnv: deleted
the two circuit-1 assignments (regs.dispfb1 = env.dispfb; regs.display1 = env.display;)
and replaced them with an explanatory comment. PMODE, SMODE2, DISPFB2,
DISPLAY2, BGCOLOR are unchanged. The guest's own MMIO writes to
DISPFB1/DISPLAY1 now survive.

Collateral: double-buffer PMODE seeds

Making applyGsDispEnv circuit-2-only exposed the double-buffer seed helpers.
sceGsSetDefDBuffDc and sceGsSetDefDBuff
(ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp) seeded PMODE = makePmode(1,1,...) = 0x8007 (both read circuits enabled), but the only thing that programs a display
circuit on the swap path is applyGsDispEnv(db.disp[which]), which post-fix
writes DISPFB2/DISPLAY2 only. So the double-buffer path drives circuit 2
exclusively; the buffer flip only ever moved circuit 2.

Before the fix the shared helper wrote both circuits, so each swap dragged
circuit 1 along in lockstep (equal to circuit 2) — the only reason EN1 ever
looked valid. Post-fix, circuit 1 would freeze at the runtime's reset defaults
(dispfb1 fbw=10, display1 639×447) while circuit 2 flipped, so the compositor
(ps2_gs_gpu.cpp) would blend that stale circuit-1 surface (fbp 0) over the
correct circuit-2 page for any title using the stock double-buffer library
without driving circuit 1 itself.

Remedy: seed the double-buffer PMODE with read circuit 2 only —
makePmode(1u, 1u, ...)makePmode(0u, 1u, ...) at both sites, i.e.
0x80070x8006. A seed that enables a circuit the path never programs is
wrong; EN2-only matches real-hardware semantics, because plain libgraph
double-buffering is single-circuit (one visible circuit alternating
framebuffers). GsSetCrt / sceGsResetGraph, which enable EN1, also program
the circuit-1 registers consistently and are a different, correct code path —
left untouched.

Known residual

The runtime's ps2_memory.cpp reset defaults for dispfb1/display1 are
non-zero (fbw=10 / 639×447) rather than the null display region a real GS reset
leaves. This is why circuit 1 could pass the compositor's validCrt1 /
hasDisplaySetup check even though nothing programmed it. A game that manually
enables EN1 without ever programming circuit 1 would therefore show garbage
here versus nothing on real hardware. The collateral remedy above fully closes
the issue for every stock-library title via the HLE swap paths; this residual is
latent and unreachable through the HLE paths, so zeroing the boot defaults — a
broader change with early-boot-presentation / full-suite blast radius — is left
as a known residual rather than folded into this change.

Tests (ps2xTest/src/ps2_gs_tests.cpp)

Three tests cover the behavior, each asserting EN1==0 / EN2==1 and
circuit-1 sentinel preservation:

  • sceGsPutDispEnv circuit-2-only: pre-seeds dispfb1/display1 sentinels,
    invokes the HLE with a known env, asserts circuit-2 + shared registers carry
    env values while the circuit-1 sentinels are untouched.
  • sceGsSwapDBuffDc swap path: after sceGsSetDefDBuffDc + sceGsSwapDBuffDc,
    asserts the seeded PMODE does not enable read circuit 1 (EN1==0) and does
    enable read circuit 2 (EN2==1), and that pre-seeded circuit-1
    DISPFB1/DISPLAY1 sentinels survive the swap.
  • sceGsSwapDBuff swap path: the non-Dc twin, driving sceGsSetDefDBuff +
    sceGsSwapDBuff with identical assertions so the non-Dc seed is directly
    exercised rather than relying on correct-by-symmetry.

The migrated sceGsSetDefDBuffDc test had its post-swap assertions corrected
from dispfb1/display1 to dispfb2/display2.

Verification

  • ps2x_tests: 299 tests, 0 failed.
  • Revert behavior: reverting the makePmode seeds back to (1,1)/0x8007 at
    both sites fails exactly the two swap-path EN1==0 assertions — one from the
    Dc test, one from the non-Dc test (297/299); restoring returns to 299/299.
  • Build/toolchain note: GCC 16 host; verification build configured with
    -DPS2X_BUILD_STUDIO=OFF and -march=native -include cstdint in
    CMAKE_CXX_FLAGS (host-local only, not committed).

🤖 Generated with Claude Code

smmathews added 3 commits July 6, 2026 19:47
Real libgraph sceGsPutDispEnv programs PMODE, SMODE2, DISPFB2, DISPLAY2 and
BGCOLOR only; the single-circuit env struct describes read circuit 2. The HLE
also wrote DISPFB1/DISPLAY1 from that env, forcing both read circuits onto the
same surface and collapsing any two-buffer blend (e.g. movie underlay + UI
overlay). Stop clobbering circuit 1 so a game's own GS privileged MMIO writes
to DISPFB1/DISPLAY1 survive.

Migrates the existing sceGsSwapDBuffDc display-page test from circuit 1 to
circuit 2 (it was asserting the pre-fix behavior) and adds a byte-level
regression test proving circuit 1 is left untouched.
The double-buffer helpers (sceGsSetDefDBuffDc, sceGsSetDefDBuff) seeded
PMODE with both read circuits enabled (EN1+EN2), but their swap path
programs read circuit 2 only via applyGsDispEnv (DISPFB2/DISPLAY2). Since
the circuit-2-only fix, circuit 1 is never programmed on this path and
stays frozen at reset defaults, so the compositor blended that stale
surface over the correct circuit-2 page for any title using the stock
double-buffer library without driving circuit 1 itself.

Seed EN2 only; circuit 1 stays guest-owned. Add a regression test on the
sceGsSwapDBuffDc path asserting PMODE does not enable EN1 and that
circuit-1 DISPFB1/DISPLAY1 are left untouched.
Add a sibling regression test driving sceGsSetDefDBuff + sceGsSwapDBuff
(the non-Dc double-buffer path) with the same assertions as the existing
Dc test: seeded PMODE has EN1==0 and EN2==1, and pre-seeded circuit-1
DISPFB1/DISPLAY1 sentinels survive the swap. Previously only the Dc
variant was exercised, leaving the byte-identical non-Dc seed uncovered.
@smmathews smmathews changed the title Spec 05: applyGsDispEnv programs display read circuit 2 only applyGsDispEnv programs display read circuit 2 only Jul 7, 2026
@smmathews

Copy link
Copy Markdown
Owner Author

Superseded by the upstream draft PR against ran-j/PS2Recomp: ran-j#152

@smmathews smmathews closed this Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant