From 214620c4c0ca6a6f2c3d1c30aa90b00e324c8583 Mon Sep 17 00:00:00 2001 From: Shane Michael Mathews Date: Mon, 6 Jul 2026 19:47:27 -0400 Subject: [PATCH 1/3] fix(runtime): program only display read circuit 2 in applyGsDispEnv 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. --- .../src/lib/Kernel/Stubs/Helpers/Support.h | 7 ++- ps2xTest/src/ps2_gs_tests.cpp | 58 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index a98e548d4..89126592b 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1875,8 +1875,11 @@ namespace auto ®s = runtime->memory().gs(); regs.pmode = env.pmode; regs.smode2 = env.smode2; - regs.dispfb1 = env.dispfb; - regs.display1 = env.display; + // Circuit 1 (DISPFB1/DISPLAY1) is NOT programmed here: sceGsPutDispEnv's + // env struct describes read circuit 2 only; the game drives circuit 1 + // itself via GS privileged MMIO. Writing circuit 1 from the single- + // circuit env forces both read circuits onto the same surface and + // collapses any two-buffer blend (e.g. movie underlay + UI overlay). regs.dispfb2 = env.dispfb; regs.display2 = env.display; regs.bgcolor = env.bgcolor; diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 4b0edc038..40cef2bbb 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -459,12 +459,66 @@ void register_ps2_gs_tests() setRegU32(ctx, 5, 1u); ps2_stubs::sceGsSwapDBuffDc(rdram.data(), &ctx, &runtime); - t.Equals(runtime.memory().gs().dispfb1 & 0x1FFull, 151ull, + t.Equals(runtime.memory().gs().dispfb2 & 0x1FFull, 151ull, "sceGsSwapDBuffDc should program GS to the selected display page"); - t.Equals((runtime.memory().gs().display1 >> 32) & 0x0FFFull, 639ull, + t.Equals((runtime.memory().gs().display2 >> 32) & 0x0FFFull, 639ull, "sceGsSwapDBuffDc should preserve the display width from the seeded env"); }); + tc.Run("sceGsPutDispEnv programs read circuit 2 only and leaves circuit 1 untouched", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + + runtime.memory().gs().dispfb1 = 0xDEADBEEFDEADBEEFull; + runtime.memory().gs().display1 = 0xCAFEF00DCAFEF00Dull; + runtime.memory().gs().dispfb2 = 0u; + runtime.memory().gs().display2 = 0u; + runtime.memory().gs().pmode = 0u; + runtime.memory().gs().smode2 = 0u; + runtime.memory().gs().bgcolor = 0u; + + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x6000u; + constexpr uint32_t kPmodeOffset = 0u; + constexpr uint32_t kSmode2Offset = 8u; + constexpr uint32_t kDispFbOffset = 16u; + constexpr uint32_t kDisplayOffset = 24u; + constexpr uint32_t kBgColorOffset = 32u; + + const uint64_t pmode = 0x8007ull; + const uint64_t smode2 = 0x1ull; + const uint64_t dispfb = 0x9070ull; + const uint64_t display = 0x1234000000005678ull; + const uint64_t bgcolor = 0x00445566ull; + + std::memcpy(rdram.data() + kEnvAddr + kPmodeOffset, &pmode, sizeof(pmode)); + std::memcpy(rdram.data() + kEnvAddr + kSmode2Offset, &smode2, sizeof(smode2)); + std::memcpy(rdram.data() + kEnvAddr + kDispFbOffset, &dispfb, sizeof(dispfb)); + std::memcpy(rdram.data() + kEnvAddr + kDisplayOffset, &display, sizeof(display)); + std::memcpy(rdram.data() + kEnvAddr + kBgColorOffset, &bgcolor, sizeof(bgcolor)); + + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + ps2_stubs::sceGsPutDispEnv(rdram.data(), &ctx, &runtime); + + t.Equals(runtime.memory().gs().pmode, pmode, + "sceGsPutDispEnv should program PMODE from the env"); + t.Equals(runtime.memory().gs().smode2, smode2, + "sceGsPutDispEnv should program SMODE2 from the env"); + t.Equals(runtime.memory().gs().dispfb2, dispfb, + "sceGsPutDispEnv should program DISPFB2 from the env"); + t.Equals(runtime.memory().gs().display2, display, + "sceGsPutDispEnv should program DISPLAY2 from the env"); + t.Equals(runtime.memory().gs().bgcolor, bgcolor, + "sceGsPutDispEnv should program BGCOLOR from the env"); + + t.Equals(runtime.memory().gs().dispfb1, 0xDEADBEEFDEADBEEFull, + "sceGsPutDispEnv must not clobber circuit-1 DISPFB1"); + t.Equals(runtime.memory().gs().display1, 0xCAFEF00DCAFEF00Dull, + "sceGsPutDispEnv must not clobber circuit-1 DISPLAY1"); + }); + tc.Run("sceGsSetDefDBuffDc seeds a clear packet and swap clears the draw buffer", [](TestCase &t) { PS2Runtime runtime; From 1bd14ffbe720fbf52bcc2b72e437f2f0198ec44b Mon Sep 17 00:00:00 2001 From: Shane Michael Mathews Date: Mon, 6 Jul 2026 20:17:24 -0400 Subject: [PATCH 2/3] fix(runtime): seed only read circuit 2 in GS double-buffer PMODE 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. --- ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp | 14 ++++++-- ps2xTest/src/ps2_gs_tests.cpp | 44 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp index 7e46972fc..0ffc3e6f5 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/GS.cpp @@ -1050,7 +1050,12 @@ namespace ps2_stubs } const uint32_t fbw = std::max(1u, (w + 63u) / 64u); - const uint64_t pmode = makePmode(1u, 1u, 0u, 0u, 0u, 0x80u); + // Double-buffering drives read circuit 2 only: on swap, applyGsDispEnv + // programs DISPFB2/DISPLAY2 and never touches circuit 1. Enabling EN1 + // here would advertise a circuit this path never programs, leaving the + // compositor to blend circuit 1's stale reset-default surface over the + // correct circuit-2 page. Seed EN2 only; circuit 1 stays guest-owned. + const uint64_t pmode = makePmode(0u, 1u, 0u, 0u, 0u, 0x80u); const uint64_t smode2 = (static_cast(g_gparam.interlace & 0x1u) << 0) | (static_cast(g_gparam.ffmode & 0x1u) << 1); @@ -1121,7 +1126,12 @@ namespace ps2_stubs } const uint32_t fbw = std::max(1u, (w + 63u) / 64u); - const uint64_t pmode = makePmode(1u, 1u, 0u, 0u, 0u, 0x80u); + // Double-buffering drives read circuit 2 only: on swap, applyGsDispEnv + // programs DISPFB2/DISPLAY2 and never touches circuit 1. Enabling EN1 + // here would advertise a circuit this path never programs, leaving the + // compositor to blend circuit 1's stale reset-default surface over the + // correct circuit-2 page. Seed EN2 only; circuit 1 stays guest-owned. + const uint64_t pmode = makePmode(0u, 1u, 0u, 0u, 0u, 0x80u); const uint64_t smode2 = (static_cast(g_gparam.interlace & 0x1u) << 0) | (static_cast(g_gparam.ffmode & 0x1u) << 1); diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index 40cef2bbb..d5c728b79 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -465,6 +465,50 @@ void register_ps2_gs_tests() "sceGsSwapDBuffDc should preserve the display width from the seeded env"); }); + tc.Run("sceGsSwapDBuffDc drives read circuit 2 only and preserves circuit 1", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + + // Seed distinct circuit-1 sentinels AFTER initialize(); reset + // defaults are fbw=10 / 639x447, so these differ from them. + runtime.memory().gs().dispfb1 = 0xDEADBEEFDEADBEEFull; + runtime.memory().gs().display1 = 0xCAFEF00DCAFEF00Dull; + runtime.memory().gs().pmode = 0u; + + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x7000u; + constexpr uint32_t kDBuffSize = 0x330u; + + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, 0u); // psm + setRegU32(ctx, 6, 640u); // width + setRegU32(ctx, 7, 448u); // height + std::memset(rdram.data() + kEnvAddr, 0xCD, kDBuffSize); + ps2_stubs::sceGsSetDefDBuffDc(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, 1u); // swap to page 1 + ps2_stubs::sceGsSwapDBuffDc(rdram.data(), &ctx, &runtime); + + // The double-buffer path programs read circuit 2 only. Its seeded + // PMODE must NOT enable read circuit 1 (EN1); otherwise the + // compositor blends the never-programmed circuit-1 surface (frozen + // at reset defaults) over the correct circuit-2 page. + t.Equals(runtime.memory().gs().pmode & 0x1ull, 0x0ull, + "sceGsSwapDBuffDc must not enable read circuit 1 (EN1) in PMODE"); + t.Equals(runtime.memory().gs().pmode & 0x2ull, 0x2ull, + "sceGsSwapDBuffDc should enable read circuit 2 (EN2) in PMODE"); + + // The shared circuit-2-only helper must leave circuit 1 untouched. + t.Equals(runtime.memory().gs().dispfb1, 0xDEADBEEFDEADBEEFull, + "sceGsSwapDBuffDc must not clobber circuit-1 DISPFB1"); + t.Equals(runtime.memory().gs().display1, 0xCAFEF00DCAFEF00Dull, + "sceGsSwapDBuffDc must not clobber circuit-1 DISPLAY1"); + }); + tc.Run("sceGsPutDispEnv programs read circuit 2 only and leaves circuit 1 untouched", [](TestCase &t) { PS2Runtime runtime; From cc2ff60ccb7224644446537e3ad190764f6702ea Mon Sep 17 00:00:00 2001 From: Shane Michael Mathews Date: Mon, 6 Jul 2026 20:36:32 -0400 Subject: [PATCH 3/3] test(gs): cover non-Dc sceGsSwapDBuff circuit-2-only PMODE seed 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. --- ps2xTest/src/ps2_gs_tests.cpp | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index d5c728b79..ca7dddf0d 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -509,6 +509,55 @@ void register_ps2_gs_tests() "sceGsSwapDBuffDc must not clobber circuit-1 DISPLAY1"); }); + tc.Run("sceGsSwapDBuff drives read circuit 2 only and preserves circuit 1", [](TestCase &t) + { + PS2Runtime runtime; + t.IsTrue(runtime.memory().initialize(), "runtime memory initialize should succeed"); + + // Seed distinct circuit-1 sentinels AFTER initialize(); reset + // defaults are fbw=10 / 639x447, so these differ from them. + runtime.memory().gs().dispfb1 = 0xDEADBEEFDEADBEEFull; + runtime.memory().gs().display1 = 0xCAFEF00DCAFEF00Dull; + runtime.memory().gs().pmode = 0u; + + std::vector rdram(PS2_RAM_SIZE, 0u); + constexpr uint32_t kEnvAddr = 0x8000u; + constexpr uint32_t kDBuffSize = 0x330u; + constexpr uint32_t kStackAddr = 0xB00u; + + R5900Context ctx{}; + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, 0u); // psm + setRegU32(ctx, 6, 640u); // width + setRegU32(ctx, 7, 448u); // height + // sceGsSetDefDBuff (non-Dc) reads its trailing ztest/zpsm/clear + // args from the guest stack via sp+16/sp+20/sp+24, so sp (r29) + // must point at valid, zero-initialized rdram. + setRegU32(ctx, 29, kStackAddr); + std::memset(rdram.data() + kEnvAddr, 0xCD, kDBuffSize); + ps2_stubs::sceGsSetDefDBuff(rdram.data(), &ctx, &runtime); + + std::memset(&ctx, 0, sizeof(ctx)); + setRegU32(ctx, 4, kEnvAddr); + setRegU32(ctx, 5, 1u); // swap to page 1 + ps2_stubs::sceGsSwapDBuff(rdram.data(), &ctx, &runtime); + + // The double-buffer path programs read circuit 2 only. Its seeded + // PMODE must NOT enable read circuit 1 (EN1); otherwise the + // compositor blends the never-programmed circuit-1 surface (frozen + // at reset defaults) over the correct circuit-2 page. + t.Equals(runtime.memory().gs().pmode & 0x1ull, 0x0ull, + "sceGsSwapDBuff must not enable read circuit 1 (EN1) in PMODE"); + t.Equals(runtime.memory().gs().pmode & 0x2ull, 0x2ull, + "sceGsSwapDBuff should enable read circuit 2 (EN2) in PMODE"); + + // The shared circuit-2-only helper must leave circuit 1 untouched. + t.Equals(runtime.memory().gs().dispfb1, 0xDEADBEEFDEADBEEFull, + "sceGsSwapDBuff must not clobber circuit-1 DISPFB1"); + t.Equals(runtime.memory().gs().display1, 0xCAFEF00DCAFEF00Dull, + "sceGsSwapDBuff must not clobber circuit-1 DISPLAY1"); + }); + tc.Run("sceGsPutDispEnv programs read circuit 2 only and leaves circuit 1 untouched", [](TestCase &t) { PS2Runtime runtime;