From d50ed4e300322348f307691803e59615bc1751f6 Mon Sep 17 00:00:00 2001 From: "Shane Michael Mathews (Personal Account)" Date: Fri, 10 Jul 2026 11:06:20 -0400 Subject: [PATCH] test(gs): H-format shared-plane texture regression coverage (T8H/T4HL/T4HH) (#155) Add byte-level regression tests for the GS "high" indexed texture formats, which store their index in the alpha byte of a shared PSMCT32 word -- T4HL in bits 24-27, T4HH in bits 28-31, T8H in the full byte 24-31. The format-aware VRAM path (the #140 refactor) already implements these at main; these tests pin the plane-separation and over-transfer behavior so it can't silently regress. - Plane separation: uploading distinct T4HL then T4HH patterns to the same dbp leaves both planes intact (RMW into the shared CT32 word), cross-checked against GSMem::ReadP4HL/ReadP4HH. - T8H coverage: uploads the full byte into bits 24-31 via a real BITBLTBUF+GIF IMAGE transfer (one byte per texel through WriteP8H) and round-trips it via GSMem::ReadP8H. T8H takes the plain byte-store branch of PixelStorageTraits::Write and its own upload loop, distinct from the T4HL/T4HH nibble RMW path, so it needs independent coverage. - Clobber interaction: a T4HL nibble upload over a prior T8H byte overwrites bits 24-27 with the nibble and preserves bits 28-31 (the T8H byte's high nibble) -- the hardware-modeled RMW outcome. - Sampled channels: each plane resolves through its own 16-entry CT32 CLUT to the expected RGBA, with distractor CLUT entries so a cross-plane nibble read produces a non-matching color. - Over-transfer hard-stop: an ~8x oversized T4HL IMAGE payload writes only rrw*rrh texels and deactivates the transfer (trxdir=3), and a subsequent transfer to a different dbp is byte-correct. Test-only; no product code change. ps2x_tests: 299/299. --- ps2xTest/src/ps2_gs_tests.cpp | 472 ++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) diff --git a/ps2xTest/src/ps2_gs_tests.cpp b/ps2xTest/src/ps2_gs_tests.cpp index d35cf7b..d73e4eb 100644 --- a/ps2xTest/src/ps2_gs_tests.cpp +++ b/ps2xTest/src/ps2_gs_tests.cpp @@ -4,6 +4,7 @@ #include "ps2_stubs.h" #include "ps2_syscalls.h" #include "runtime/ps2_gs_gpu.h" +#include "runtime/ps2_gs_memory.h" #include "runtime/ps2_gs_psmct32.h" #include "runtime/ps2_gs_psmt4.h" #include "runtime/ps2_gs_psmt8.h" @@ -3337,5 +3338,476 @@ void register_ps2_gs_tests() notifyRuntimeStop(); ps2_stubs::resetGsSyncVCallbackState(); }); + + tc.Run("GS T4HL/T4HH shared-plane upload preserves both index planes via RMW", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + constexpr uint32_t kDbp = 64u; + constexpr uint32_t kDbw = 1u; + constexpr uint32_t kRrw = 8u; + constexpr uint32_t kRrh = 8u; + constexpr uint64_t kRect = (static_cast(kRrw) << 0) | (static_cast(kRrh) << 32); + + // Two independent, differing index patterns for the T4HL and T4HH planes. + auto indexA = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x * 3u + y * 5u + 1u) & 0xFu); + }; + auto indexB = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x * 7u + y * 2u + 9u) & 0xFu); + }; + + auto buildPacked = [&](const auto &indexFn) -> std::vector + { + std::vector packed((kRrw * kRrh) / 2u, 0u); + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; x += 2u) + { + const uint8_t lo = indexFn(x, y) & 0xFu; + const uint8_t hi = indexFn(x + 1u, y) & 0xFu; + packed[(y * kRrw + x) / 2u] = static_cast(lo | (hi << 4)); + } + } + return packed; + }; + + const std::vector packedA = buildPacked(indexA); + const std::vector packedB = buildPacked(indexB); + + constexpr uint64_t kUploadHLBitblt = + (static_cast(kDbp) << 32) | + (static_cast(kDbw) << 48) | + (static_cast(GS_PSM_T4HL) << 56); + constexpr uint64_t kUploadHHBitblt = + (static_cast(kDbp) << 32) | + (static_cast(kDbw) << 48) | + (static_cast(GS_PSM_T4HH) << 56); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadHLBitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + std::vector packetA; + appendU64(packetA, makeGifTag(static_cast(packedA.size() / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packetA, 0ull); + packetA.insert(packetA.end(), packedA.begin(), packedA.end()); + gs.processGIFPacket(packetA.data(), static_cast(packetA.size())); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadHHBitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + std::vector packetB; + appendU64(packetB, makeGifTag(static_cast(packedB.size() / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packetB, 0ull); + packetB.insert(packetB.end(), packedB.begin(), packedB.end()); + gs.processGIFPacket(packetB.data(), static_cast(packetB.size())); + + bool planesMatch = true; + bool memReadersMatch = true; + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + const uint32_t off = GSPSMCT32::addrPSMCT32(kDbp, kDbw, x, y); + uint32_t word = 0u; + std::memcpy(&word, vram.data() + off, sizeof(word)); + + const uint8_t expectedA = indexA(x, y); + const uint8_t expectedB = indexB(x, y); + const uint8_t gotA = static_cast((word >> 24) & 0xFu); + const uint8_t gotB = static_cast((word >> 28) & 0xFu); + if (gotA != expectedA || gotB != expectedB) + planesMatch = false; + + const uint32_t memA = GSMem::ReadP4HL(vram.data(), kDbp, kDbw, x, y); + const uint32_t memB = GSMem::ReadP4HH(vram.data(), kDbp, kDbw, x, y); + if (memA != expectedA || memB != expectedB) + memReadersMatch = false; + } + } + t.IsTrue(planesMatch, + "T4HL and T4HH uploads to the same shared CT32 word must not clobber each other's nibble"); + t.IsTrue(memReadersMatch, + "GSMem::ReadP4HL/ReadP4HH should agree with the raw shared-word nibble extraction"); + + // --- T8H coverage: full-byte upload, round-trip via GSMem::ReadP8H, and the + // --- clobber interaction when a later T4HL nibble upload lands on the same word. + + constexpr uint32_t kDbpT8H = 128u; + constexpr uint32_t kDbwT8H = 1u; + + // Full 0..255 range so both nibbles of the uploaded byte vary independently. + auto byteT8H = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x * 11u + y * 13u + 7u) & 0xFFu); + }; + + std::vector packedT8H(kRrw * kRrh, 0u); + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + packedT8H[y * kRrw + x] = byteT8H(x, y); + } + } + + constexpr uint64_t kUploadT8HBitblt = + (static_cast(kDbpT8H) << 32) | + (static_cast(kDbwT8H) << 48) | + (static_cast(GS_PSM_T8H) << 56); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadT8HBitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + std::vector packetT8H; + appendU64(packetT8H, makeGifTag(static_cast(packedT8H.size() / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packetT8H, 0ull); + packetT8H.insert(packetT8H.end(), packedT8H.begin(), packedT8H.end()); + gs.processGIFPacket(packetT8H.data(), static_cast(packetT8H.size())); + + bool t8hByteMatches = true; + bool t8hMemReaderMatches = true; + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + const uint32_t off = GSPSMCT32::addrPSMCT32(kDbpT8H, kDbwT8H, x, y); + uint32_t word = 0u; + std::memcpy(&word, vram.data() + off, sizeof(word)); + + const uint8_t expected = byteT8H(x, y); + const uint8_t got = static_cast((word >> 24) & 0xFFu); + if (got != expected) + t8hByteMatches = false; + + const uint32_t memByte = GSMem::ReadP8H(vram.data(), kDbpT8H, kDbwT8H, x, y); + if (memByte != expected) + t8hMemReaderMatches = false; + } + } + t.IsTrue(t8hByteMatches, + "T8H upload must land the full byte in bits 24-31 of the shared CT32 word"); + t.IsTrue(t8hMemReaderMatches, + "GSMem::ReadP8H should agree with the raw shared-word byte extraction after a T8H upload"); + + // Clobber interaction: upload a T8H byte plane, then upload a T4HL nibble plane to + // the same shared word. WriteP4HL's nibble RMW should overwrite bits 24-27 with the + // new nibble while preserving bits 28-31 (the T8H byte's high nibble). + constexpr uint32_t kDbpMix = 192u; + constexpr uint32_t kDbwMix = 1u; + + auto byteMix = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x * 7u + y * 5u + 3u) & 0xFFu); + }; + auto nibbleN = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x * 3u + y + 1u) & 0xFu); + }; + + std::vector packedMixT8H(kRrw * kRrh, 0u); + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + packedMixT8H[y * kRrw + x] = byteMix(x, y); + } + } + + constexpr uint64_t kUploadMixT8HBitblt = + (static_cast(kDbpMix) << 32) | + (static_cast(kDbwMix) << 48) | + (static_cast(GS_PSM_T8H) << 56); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadMixT8HBitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + std::vector packetMixT8H; + appendU64(packetMixT8H, + makeGifTag(static_cast(packedMixT8H.size() / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packetMixT8H, 0ull); + packetMixT8H.insert(packetMixT8H.end(), packedMixT8H.begin(), packedMixT8H.end()); + gs.processGIFPacket(packetMixT8H.data(), static_cast(packetMixT8H.size())); + + std::vector packedMixNibble((kRrw * kRrh) / 2u, 0u); + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; x += 2u) + { + const uint8_t lo = nibbleN(x, y) & 0xFu; + const uint8_t hi = nibbleN(x + 1u, y) & 0xFu; + packedMixNibble[(y * kRrw + x) / 2u] = static_cast(lo | (hi << 4)); + } + } + + constexpr uint64_t kUploadMixHLBitblt = + (static_cast(kDbpMix) << 32) | + (static_cast(kDbwMix) << 48) | + (static_cast(GS_PSM_T4HL) << 56); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadMixHLBitblt); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + std::vector packetMixNibble; + appendU64(packetMixNibble, + makeGifTag(static_cast(packedMixNibble.size() / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packetMixNibble, 0ull); + packetMixNibble.insert(packetMixNibble.end(), packedMixNibble.begin(), packedMixNibble.end()); + gs.processGIFPacket(packetMixNibble.data(), static_cast(packetMixNibble.size())); + + bool mixClobberMatches = true; + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + const uint32_t off = GSPSMCT32::addrPSMCT32(kDbpMix, kDbwMix, x, y); + uint32_t word = 0u; + std::memcpy(&word, vram.data() + off, sizeof(word)); + + const uint8_t gotLow = static_cast((word >> 24) & 0xFu); + const uint8_t gotHigh = static_cast((word >> 28) & 0xFu); + const uint8_t expectedLow = nibbleN(x, y); + const uint8_t expectedHigh = static_cast((byteMix(x, y) >> 4) & 0xFu); + if (gotLow != expectedLow || gotHigh != expectedHigh) + mixClobberMatches = false; + } + } + t.IsTrue(mixClobberMatches, + "T4HL nibble upload over a T8H byte must overwrite bits 24-27 with the nibble and preserve " + "bits 28-31 from the T8H byte's high nibble"); + }); + + tc.Run("GS T4HL/T4HH sampling reads only its own plane through independent CLUTs", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + constexpr uint32_t kTexTbp = 64u; + constexpr uint32_t kClutCbpA = 128u; + constexpr uint32_t kClutCbpB = 192u; + constexpr uint8_t kIndexA = 4u; // T4HL plane index at the sampled texel (0..7 -> identity swizzle) + constexpr uint8_t kIndexB = 0u; // T4HH plane index at the sampled texel; must differ from kIndexA + + // Shared CT32 word at texel (0,0): T4HL nibble occupies bits 24-27, T4HH bits 28-31. + const uint32_t sharedWordOff = GSPSMCT32::addrPSMCT32(kTexTbp, 1u, 0u, 0u); + const uint32_t sharedWord = + (static_cast(kIndexB) << 28) | (static_cast(kIndexA) << 24); + std::memcpy(vram.data() + sharedWordOff, &sharedWord, sizeof(sharedWord)); + + constexpr uint32_t kExpectedColorA = 0x800000FFu; // RGBA = (255,0,0,128) + constexpr uint32_t kExpectedColorB = 0x8000FF00u; // RGBA = (0,255,0,128) + constexpr uint32_t kDistractorColor = 0x800000AAu; + + // Place each plane's expected color at its own CLUT's entry for the sampled index. + const uint32_t clutAOff = GSPSMCT32::addrPSMCT32(kClutCbpA, 1u, kIndexA, 0u); + const uint32_t clutBOff = GSPSMCT32::addrPSMCT32(kClutCbpB, 1u, kIndexB, 0u); + std::memcpy(vram.data() + clutAOff, &kExpectedColorA, sizeof(kExpectedColorA)); + std::memcpy(vram.data() + clutBOff, &kExpectedColorB, sizeof(kExpectedColorB)); + + // Seed distractor entries at the *other* plane's index in each CLUT so that a + // cross-plane nibble read (a bug reading the wrong plane, or the wrong CLUT) would + // resolve to a non-matching color instead of accidentally matching by coincidence. + const uint32_t clutADistractorOff = GSPSMCT32::addrPSMCT32(kClutCbpA, 1u, kIndexB, 0u); + const uint32_t clutBDistractorOff = GSPSMCT32::addrPSMCT32(kClutCbpB, 1u, kIndexA, 0u); + std::memcpy(vram.data() + clutADistractorOff, &kDistractorColor, sizeof(kDistractorColor)); + std::memcpy(vram.data() + clutBDistractorOff, &kDistractorColor, sizeof(kDistractorColor)); + + constexpr uint64_t kFrameReg = + (0ull << 0) | + (1ull << 16) | + (static_cast(GS_PSM_CT32) << 24); + constexpr uint64_t kZbuf = (1ull << 32); + constexpr uint64_t kPrim = + static_cast(GS_PRIM_SPRITE) | + (1ull << 4) | // TME + (1ull << 8); // FST + + const uint64_t kTex0HL = + (static_cast(kTexTbp) << 0) | + (1ull << 14) | + (static_cast(GS_PSM_T4HL) << 20) | + (0ull << 26) | + (0ull << 30) | + (1ull << 34) | + (1ull << 35) | + (static_cast(kClutCbpA) << 37) | + (static_cast(GS_PSM_CT32) << 51); + + gs.writeRegister(GS_REG_FRAME_1, kFrameReg); + gs.writeRegister(GS_REG_ZBUF_1, kZbuf); + gs.writeRegister(GS_REG_SCISSOR_1, 0ull); + gs.writeRegister(GS_REG_XYOFFSET_1, 0ull); + gs.writeRegister(GS_REG_TEST_1, 0x30000ull); + gs.writeRegister(GS_REG_ALPHA_1, 0ull); + gs.writeRegister(GS_REG_TEX0_1, kTex0HL); + gs.writeRegister(GS_REG_PRIM, kPrim); + gs.writeRegister(GS_REG_RGBAQ, 0x80808080ull); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, 0ull); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, 0ull); + + uint32_t pixelHL = 0u; + std::memcpy(&pixelHL, vram.data(), sizeof(pixelHL)); + t.Equals(pixelHL, kExpectedColorA, + "T4HL sampling should resolve through its own CLUT plane, unaffected by the co-resident T4HH nibble"); + + const uint64_t kTex0HH = + (static_cast(kTexTbp) << 0) | + (1ull << 14) | + (static_cast(GS_PSM_T4HH) << 20) | + (0ull << 26) | + (0ull << 30) | + (1ull << 34) | + (1ull << 35) | + (static_cast(kClutCbpB) << 37) | + (static_cast(GS_PSM_CT32) << 51); + + gs.writeRegister(GS_REG_TEX0_1, kTex0HH); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, 0ull); + gs.writeRegister(GS_REG_UV, 0ull); + gs.writeRegister(GS_REG_XYZ2, 0ull); + + uint32_t pixelHH = 0u; + std::memcpy(&pixelHH, vram.data(), sizeof(pixelHH)); + t.Equals(pixelHH, kExpectedColorB, + "T4HH sampling should resolve through its own CLUT plane, unaffected by the co-resident T4HL nibble"); + }); + + tc.Run("GS T4HL upload deactivates the transfer at total_pixels and discards excess bytes", [](TestCase &t) + { + std::vector vram(PS2_GS_VRAM_SIZE, 0u); + GS gs; + gs.init(vram.data(), static_cast(vram.size()), nullptr); + + constexpr uint32_t kDbw = 1u; + constexpr uint32_t kRrw = 8u; + constexpr uint32_t kRrh = 8u; + constexpr uint64_t kRect = (static_cast(kRrw) << 0) | (static_cast(kRrh) << 32); + + auto buildPacked = [&](const auto &indexFn) -> std::vector + { + std::vector packed((kRrw * kRrh) / 2u, 0u); + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; x += 2u) + { + const uint8_t lo = indexFn(x, y) & 0xFu; + const uint8_t hi = indexFn(x + 1u, y) & 0xFu; + packed[(y * kRrw + x) / 2u] = static_cast(lo | (hi << 4)); + } + } + return packed; + }; + + // --- First transfer: an ~8x oversized IMAGE payload (256 bytes / 16 qwords) for a + // rect that only needs 32 bytes (64 texels). The first 32 bytes carry a known + // pattern; the remaining 224 bytes are a 0xFF sentinel that must be discarded. + constexpr uint32_t kDbp1 = 0u; + constexpr uint64_t kUploadBitblt1 = + (static_cast(kDbp1) << 32) | + (static_cast(kDbw) << 48) | + (static_cast(GS_PSM_T4HL) << 56); + + auto indexPattern1 = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x + y * 3u + 2u) & 0xFu); + }; + const std::vector packed1 = buildPacked(indexPattern1); + t.Equals(packed1.size(), static_cast(32), "sanity: packed rect should be 32 bytes (64 texels)"); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadBitblt1); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + constexpr uint32_t kOversizedBytes = 256u; // 16 qwords, 8x the required 32 bytes + std::vector packet; + appendU64(packet, makeGifTag(static_cast(kOversizedBytes / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packet, 0ull); + const size_t payloadOffset = packet.size(); + packet.resize(payloadOffset + kOversizedBytes, 0xFFu); + std::memcpy(packet.data() + payloadOffset, packed1.data(), packed1.size()); + gs.processGIFPacket(packet.data(), static_cast(packet.size())); + + const GSDebugSnapshot snap1 = gs.getDebugSnapshot(); + t.Equals(snap1.transferCopiedPixels, 64u, "T4HL transfer should stop after copying exactly rrw*rrh texels"); + t.Equals(snap1.trxdir, 3u, "T4HL transfer should deactivate (trxdir=3) once total_pixels is reached"); + + bool pattern1Ok = true; + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + const uint32_t off = GSPSMCT32::addrPSMCT32(kDbp1, kDbw, x, y); + uint32_t word = 0u; + std::memcpy(&word, vram.data() + off, sizeof(word)); + if (((word >> 24) & 0xFu) != indexPattern1(x, y)) + pattern1Ok = false; + } + } + t.IsTrue(pattern1Ok, + "the first 64 texels of the oversized T4HL transfer should match the known pattern; sentinel bytes must not leak in"); + + // --- Second, correctly-sized transfer to a different DBP: proves the discarded + // excess bytes from the first transfer were not mis-accounted into later state. + constexpr uint32_t kDbp2 = 128u; + constexpr uint64_t kUploadBitblt2 = + (static_cast(kDbp2) << 32) | + (static_cast(kDbw) << 48) | + (static_cast(GS_PSM_T4HL) << 56); + + auto indexPattern2 = [](uint32_t x, uint32_t y) -> uint8_t + { + return static_cast((x * 5u + y + 3u) & 0xFu); + }; + const std::vector packed2 = buildPacked(indexPattern2); + + gs.writeRegister(GS_REG_BITBLTBUF, kUploadBitblt2); + gs.writeRegister(GS_REG_TRXPOS, 0ull); + gs.writeRegister(GS_REG_TRXREG, kRect); + gs.writeRegister(GS_REG_TRXDIR, 0ull); + + std::vector packet2; + appendU64(packet2, makeGifTag(static_cast(packed2.size() / 16u), GIF_FMT_IMAGE, 0u, true)); + appendU64(packet2, 0ull); + packet2.insert(packet2.end(), packed2.begin(), packed2.end()); + gs.processGIFPacket(packet2.data(), static_cast(packet2.size())); + + const GSDebugSnapshot snap2 = gs.getDebugSnapshot(); + t.Equals(snap2.transferCopiedPixels, 64u, "second, correctly-sized T4HL transfer should copy exactly rrw*rrh texels"); + t.Equals(snap2.trxdir, 3u, "second T4HL transfer should also deactivate cleanly"); + + bool pattern2Ok = true; + for (uint32_t y = 0; y < kRrh; ++y) + { + for (uint32_t x = 0; x < kRrw; ++x) + { + const uint32_t off = GSPSMCT32::addrPSMCT32(kDbp2, kDbw, x, y); + uint32_t word = 0u; + std::memcpy(&word, vram.data() + off, sizeof(word)); + if (((word >> 24) & 0xFu) != indexPattern2(x, y)) + pattern2Ok = false; + } + } + t.IsTrue(pattern2Ok, + "second T4HL transfer to a different DBP should be byte-correct, proving the discarded excess bytes from the first transfer did not leak into subsequent transfer state"); + }); }); }