diff --git a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp index f0ad5070b..0e95081e5 100644 --- a/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp +++ b/ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp @@ -1166,26 +1166,31 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) const GSVertex &v1 = batch.vertices[1]; const auto &ctx = state.context; - int ofx = ctx.xyoffset.ofx >> 4; - int ofy = ctx.xyoffset.ofy >> 4; + const float ofx = static_cast(ctx.xyoffset.ofx) / 16.0f; + const float ofy = static_cast(ctx.xyoffset.ofy) / 16.0f; - int x0 = static_cast(v0.x) - ofx; - int y0 = static_cast(v0.y) - ofy; - int x1 = static_cast(v1.x) - ofx; - int y1 = static_cast(v1.y) - ofy; + float x0 = v0.x - ofx; + float y0 = v0.y - ofy; + float x1 = v1.x - ofx; + float y1 = v1.y - ofy; u32 z1 = static_cast(v1.z); - if (x0 > x1) + const bool reverseX = x0 > x1; + const bool reverseY = y0 > y1; + if (reverseX) std::swap(x0, x1); - if (y0 > y1) + if (reverseY) std::swap(y0, y1); - const int unclippedX0 = x0; - const int unclippedY0 = y0; - const int spanX = std::max(1, x1 - x0); - const int spanY = std::max(1, y1 - y0); - const int unclippedX1 = unclippedX0 + spanX - 1; - const int unclippedY1 = unclippedY0 + spanY - 1; + // GS samples sprites at integer pixel coordinates; the lower bounds are + // inclusive and the upper bounds exclusive, after rounding up. Keep the + // fractional endpoints for texture interpolation and sort UV per axis too. + const int unclippedX0 = static_cast(std::ceil(x0)); + const int unclippedY0 = static_cast(std::ceil(y0)); + const int unclippedX1 = static_cast(std::ceil(x1)) - 1; + const int unclippedY1 = static_cast(std::ceil(y1)) - 1; + if (unclippedX0 > unclippedX1 || unclippedY0 > unclippedY1) + return; // If the sprite rectangle is fully outside scissor, nothing should render. if (unclippedX1 < ctx.scissor.x0 || unclippedX0 > ctx.scissor.x1 || @@ -1212,50 +1217,44 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) float u0f, v0f, u1f, v1f; if (state.prim.fst) { - u0f = static_cast(v0.u >> 4); - v0f = static_cast(v0.v >> 4); - u1f = static_cast(v1.u >> 4); - v1f = static_cast(v1.v >> 4); + u0f = static_cast(v0.u) / 16.0f; + v0f = static_cast(v0.v) / 16.0f; + u1f = static_cast(v1.u) / 16.0f; + v1f = static_cast(v1.v) / 16.0f; } else { - const float q0 = fabsQ(v0.q); + // Sprite Q is flat, taken from the second vertex. const float q1 = fabsQ(v1.q); - u0f = (v0.s / q0) * static_cast(texW); - v0f = (v0.t / q0) * static_cast(texH); + u0f = (v0.s / q1) * static_cast(texW); + v0f = (v0.t / q1) * static_cast(texH); u1f = (v1.s / q1) * static_cast(texW); v1f = (v1.t / q1) * static_cast(texH); } - float spriteW = static_cast(spanX); - float spriteH = static_cast(spanY); - if (spriteW < 1.0f) - spriteW = 1.0f; - if (spriteH < 1.0f) - spriteH = 1.0f; + if (reverseX) + std::swap(u0f, u1f); + if (reverseY) + std::swap(v0f, v1f); + + const float spriteW = x1 - x0; + const float spriteH = y1 - y0; + // Use the floating-coordinate sampler for interpolated UV as well as + // STQ, avoiding a second quantization to the vertex UV's four bits. + GSDrawState textureState = state; + textureState.prim.fst = false; for (int y = drawY0; y <= drawY1; ++y) { - float ty = (static_cast(y - unclippedY0) + 0.5f) / spriteH; + float ty = (static_cast(y) - y0) / spriteH; float texVf = v0f + (v1f - v0f) * ty; for (int x = drawX0; x <= drawX1; ++x) { - float tx = (static_cast(x - unclippedX0) + 0.5f) / spriteW; + float tx = (static_cast(x) - x0) / spriteW; float texUf = u0f + (u1f - u0f) * tx; - uint32_t texel = 0xFFFF00FFu; - if (state.prim.fst) - { - const int fixedU = static_cast((texUf * 16.0f) + 0.5f); - const int fixedV = static_cast((texVf * 16.0f) + 0.5f); - const uint16_t sampleU = static_cast(clampInt(fixedU, 0, 0xFFFF)); - const uint16_t sampleV = static_cast(clampInt(fixedV, 0, 0xFFFF)); - texel = SampleTexture(state, 0.0f, 0.0f, 1.0f, sampleU, sampleV); - } - else - { - texel = SampleTexture(state, texUf / static_cast(texW), texVf / static_cast(texH), 1.0f, 0u, 0u); - } + const uint32_t texel = SampleTexture(textureState, + texUf / static_cast(texW), texVf / static_cast(texH), 1.0f, 0u, 0u); uint8_t tr = static_cast(texel & 0xFF); uint8_t tg = static_cast((texel >> 8) & 0xFF); @@ -1270,8 +1269,10 @@ void GSCpuBackend::DrawSprite(const GSPrimitiveBatch &batch) else { for (int y = drawY0; y <= drawY1; ++y) + { for (int x = drawX0; x <= drawX1; ++x) WritePixel(state, x, y, z1, r, g, b, a, v1.fog); + } } } diff --git a/ps2xTest/gs_cache/CMakeLists.txt b/ps2xTest/gs_cache/CMakeLists.txt index 6b7210a68..848264e9a 100644 --- a/ps2xTest/gs_cache/CMakeLists.txt +++ b/ps2xTest/gs_cache/CMakeLists.txt @@ -52,6 +52,9 @@ add_gs_cache_suite(ps2_gs_clut_cache_tests gs_clut_cache_tests.cpp clut reserved_cld nonindexed_cld tex2_reload shared_contexts csa_ct32 csa_ct16 csa_ct16s texa_without_reload palette_before_filtering high_planes) +add_gs_cache_suite(ps2_gs_sprite_alignment_tests gs_sprite_alignment_tests.cpp sprite + coordinates feedback) + if(PS2_GS_CACHE_BUILD_MEMORY_TESTS) add_gs_cache_suite(ps2_gs_memory_cache_tests gs_memory_cache_tests.cpp memory ct32 ct24 ct16 ct16s t8 t4 t8h t4hl t4hh z32 z24 z16 z16s diff --git a/ps2xTest/gs_cache/README.md b/ps2xTest/gs_cache/README.md new file mode 100644 index 000000000..ae5307a1e --- /dev/null +++ b/ps2xTest/gs_cache/README.md @@ -0,0 +1,59 @@ +# GS backend tests + +This standalone package compiles the actual CPU backend, frontend and local +memory implementation. It uses synthetic VRAM and public GS APIs, with no game +assets, generated guest functions or runtime substitutes. + +```sh +cmake -S ps2xTest/gs_cache -B build/gs-cache +cmake --build build/gs-cache --config RelWithDebInfo +ctest --test-dir build/gs-cache -C RelWithDebInfo --output-on-failure +``` + +For native Windows MSVC, configure with `-G "Visual Studio 17 2022" -A x64` and +`-DCMAKE_CXX_FLAGS_RELWITHDEBINFO="/O2 /Ob1 /DNDEBUG /Zi /fp:strict"`. +`PS2_GS_RUNTIME_DIR` selects another runtime source checkout; the default is +this repository's `ps2xRuntime`. Optional GCC/Clang sanitizers use +`PS2_GS_CACHE_SANITIZERS=ON`. + +The two `gs_cache.sprite.*` cases add sprite coverage and texture alignment to +the 45 existing texture, CLUT and memory-cache cases: + +- `coordinates` uses an independent scalar interpolation oracle across all 16 + UV fractional nibbles, nearest and bilinear filtering, reversed X/Y axes, + fractional XYOFFSET, clipping, integer pixel sample locations, ceil-exclusive + bounds, second-vertex flat Q/color, degenerate sprites and untextured bounds. +- `feedback` draws four reversed 64-pixel CT32 identity strips through the real + shared single-page texture cache without intervening TEXFLUSH. Every pixel + stays unchanged and every tested boundary retains the original gradient. + +Bilinear color checks permit one unit for floating-point operation-order +rounding. Coverage, untouched pixels and feedback results are exact. Identity +feedback has the same expected pixels for any valid cache-invalidation policy; +the test does not impose a full-frame snapshot rule on arbitrary feedback. + +The fixture sets TEST.ZTE=1/ZTST=ALWAYS and ZMSK=1 to isolate sprite alignment +from the separate disabled-depth-test issue. This change does not include a +depth-pipeline fix. This upstream backend is synchronous: the package does not +claim to test the private runtime's asynchronous raster workers. + +Coordinate rules are supported by pinned primary PCSX2 implementations: +[vertex conversion](https://github.com/PCSX2/pcsx2/blob/355608952714678b3c57832fb82dc6a42956ed25/pcsx2/GS/Renderers/SW/GSRendererSW.cpp#L219) +preserves fractional XYOFFSET/UV and supplies the second vertex's Q for sprites; +[sprite rasterization](https://github.com/PCSX2/pcsx2/blob/355608952714678b3c57832fb82dc6a42956ed25/pcsx2/GS/Renderers/SW/GSRasterizer.cpp#L1088) +sorts the matching texture component per axis, uses ceil-exclusive bounds and +integer pixel sample locations. Linear sampling applies its separate half-texel +offset. + +Native Windows MSVC 19.44 validation passed all 47 CTest entries with +RelWithDebInfo `/O2 /fp:strict`. The two alignment cases passed 853,552 checks +with zero failures, zero changed feedback pixels and zero artificial boundary +steps. Compiling the same fixture against the unchanged main-branch backend +failed 38,810 coordinate checks and 8,029 feedback checks, including 7,812 altered +pixels and 217 extra boundary steps. This control changed only the backend +source under test, keeping the fixture and compiler configuration identical. + +Filter new cases with `ctest --test-dir build/gs-cache -C RelWithDebInfo +-R gs_cache.sprite --output-on-failure`. Full runtime, retail gameplay and Linux +validation are outside this package. The fixture is GPL-3.0-only, like the +linked runtime; see the repository license. diff --git a/ps2xTest/gs_cache/gs_sprite_alignment_tests.cpp b/ps2xTest/gs_cache/gs_sprite_alignment_tests.cpp new file mode 100644 index 000000000..090f69418 --- /dev/null +++ b/ps2xTest/gs_cache/gs_sprite_alignment_tests.cpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "runtime/gs/gs_cpu_backend.h" +#include "runtime/gs/ps2_gs_common.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +unsigned checks = 0, failures = 0; +constexpr unsigned width = 256, height = 64, bw = 4; +constexpr unsigned textureBlock = 2048; +constexpr uint32_t untouched = 0x80ff00ffu; + +void expect(bool condition, const char *message) +{ + ++checks; + if (!condition) + { + if (failures < 24) + std::fprintf(stderr, "FAIL: %s\n", message); + ++failures; + } +} + +uint32_t pattern(int x, int y) +{ + x = std::clamp(x, 0, static_cast(width) - 1); + y = std::clamp(y, 0, static_cast(height) - 1); + return 0x80000000u | static_cast((x % 64) * 4) | + (static_cast((y % 32) * 6) << 8) | + (static_cast(((x + y * 3) % 64) * 4) << 16); +} + +// Scalar oracle: UV keeps its 4 fractional bits, sprite interpolation uses +// integer GS pixel coordinates, and linear filtering subtracts half a texel. +uint32_t filtered(float u, float v, bool linear) +{ + if (!linear) + return pattern(static_cast(u), static_cast(v)); + const float sx = u - 0.5f, sy = v - 0.5f; + const int x0 = static_cast(std::floor(sx)); + const int y0 = static_cast(std::floor(sy)); + const float fx = sx - static_cast(x0), fy = sy - static_cast(y0); + const uint32_t c[4] = {pattern(x0, y0), pattern(x0 + 1, y0), + pattern(x0, y0 + 1), pattern(x0 + 1, y0 + 1)}; + uint32_t result = 0; + for (unsigned shift = 0; shift < 32; shift += 8) + { + const float top = static_cast((c[0] >> shift) & 255u) * (1.0f - fx) + + static_cast((c[1] >> shift) & 255u) * fx; + const float bottom = static_cast((c[2] >> shift) & 255u) * (1.0f - fx) + + static_cast((c[3] >> shift) & 255u) * fx; + const unsigned channel = static_cast(top * (1.0f - fy) + bottom * fy + 0.5f); + result |= std::min(channel, 255u) << shift; + } + return result; +} + +bool withinRounding(uint32_t actual, uint32_t expected, unsigned tolerance) +{ + for (unsigned shift = 0; shift < 32; shift += 8) + { + const int a = static_cast((actual >> shift) & 255u); + const int e = static_cast((expected >> shift) & 255u); + if (std::abs(a - e) > static_cast(tolerance)) + return false; + } + return true; +} + +GSPrimitiveBatch sprite(float left, float top, float right, float bottom, + float u0, float v0, float u1, float v1, unsigned reverse = 0) +{ + GSPrimitiveBatch batch{}; + batch.vertexCount = 2; + batch.state.prim.type = GS_PRIM_SPRITE; + batch.state.prim.tme = true; + batch.state.prim.fst = true; + batch.state.context.frame = {0, bw, GS_PSM_CT32, 0}; + batch.state.context.zbuf = {200, GS_PSM_Z32, true}; + // Isolate alignment from the separate disabled-ZTE bug: enable ALWAYS + // while masking depth writes. This fixture does not require that fix. + batch.state.context.test = (1ull << 16u) | (1ull << 17u); + batch.state.context.scissor = {0, width - 1, 0, height - 1}; + batch.state.context.tex0 = {textureBlock, bw, GS_PSM_CT32, 8, 6, 1, 1}; + batch.state.context.clamp = 5; // Clamp U and V to the texture edges. + batch.state.textureWidth = width; + batch.state.textureHeight = height; + batch.state.linearFilter = true; + auto &a = batch.vertices[0]; + auto &b = batch.vertices[1]; + a.x = left; a.y = top; b.x = right; b.y = bottom; + a.u = static_cast(u0 * 16); a.v = static_cast(v0 * 16); + b.u = static_cast(u1 * 16); b.v = static_cast(v1 * 16); + a.a = b.a = 128; + if ((reverse & 1u) != 0) + { std::swap(a.x, b.x); std::swap(a.u, b.u); } + if ((reverse & 2u) != 0) + { std::swap(a.y, b.y); std::swap(a.v, b.v); } + return batch; +} + +void initialize(GSCpuBackend &gs) +{ + gs.Reset(); + for (unsigned y = 0; y < height; ++y) + for (unsigned x = 0; x < width; ++x) + { + gs.WriteVram(GS_PSM_CT32, 0, bw, x, y, untouched); + gs.WriteVram(GS_PSM_CT32, textureBlock, bw, x, y, pattern(x, y)); + } + gs.TextureFlush(); +} + +void checkSprite(GSCpuBackend &gs, const GSPrimitiveBatch &batch, const char *message) +{ + initialize(gs); + gs.Submit(batch); + gs.Sync(GSSyncReason::Presentation); + const auto &a = batch.vertices[0], &b = batch.vertices[1]; + const float ofx = static_cast(batch.state.context.xyoffset.ofx) / 16; + const float ofy = static_cast(batch.state.context.xyoffset.ofy) / 16; + const float left = std::min(a.x, b.x) - ofx, right = std::max(a.x, b.x) - ofx; + const float top = std::min(a.y, b.y) - ofy, bottom = std::max(a.y, b.y) - ofy; + const float startU = static_cast((a.x <= b.x) ? a.u : b.u) / 16; + const float endU = static_cast((a.x <= b.x) ? b.u : a.u) / 16; + const float startV = static_cast((a.y <= b.y) ? a.v : b.v) / 16; + const float endV = static_cast((a.y <= b.y) ? b.v : a.v) / 16; + const auto &clip = batch.state.context.scissor; + for (unsigned y = 0; y < height; ++y) + for (unsigned x = 0; x < width; ++x) + { + uint32_t expected = untouched; + const bool covered = static_cast(x) >= std::ceil(left) && static_cast(x) < std::ceil(right) && + static_cast(y) >= std::ceil(top) && static_cast(y) < std::ceil(bottom) && + x >= clip.x0 && x <= clip.x1 && y >= clip.y0 && y <= clip.y1; + if (covered) + { + const float tx = (static_cast(x) - left) / (right - left); + const float ty = (static_cast(y) - top) / (bottom - top); + expected = filtered(startU + (endU - startU) * tx, + startV + (endV - startV) * ty, batch.state.linearFilter); + } + // One unit permits different float operation order only for + // bilinear channel rounding; coverage and untouched pixels are exact. + const unsigned tolerance = covered && batch.state.linearFilter ? 1u : 0u; + expect(withinRounding(gs.ReadVram(GS_PSM_CT32, 0, bw, x, y), expected, tolerance), message); + } +} + +void coordinateCases(GSCpuBackend &gs) +{ + for (unsigned reverse = 0; reverse < 4; ++reverse) + { + checkSprite(gs, sprite(0, 0, 192, 16, 0.5f, 0.5f, 192.5f, 16.5f, reverse), + "each reversed axis preserves its matching UV endpoint"); + checkSprite(gs, sprite(0, 1, 63.5f, 32.5f, 0, 0, 64, 32, reverse), + "63.5 endpoint covers column 63 without mirroring the strip"); + auto offset = sprite(0.5f, 0.5f, 64.5f, 16.5f, 0.5f, 0.5f, 64.5f, 16.5f, reverse); + offset.state.context.xyoffset = {8, 8}; + checkSprite(gs, offset, "fractional XYOFFSET cancels fractional guest XY exactly"); + } + for (unsigned fraction = 0; fraction < 16; ++fraction) + { + const float sub = static_cast(fraction) / 16; + for (bool linear : {false, true}) + { + auto batch = sprite(0, 0, 64, 16, sub, sub, 64 + sub, 16 + sub); + batch.state.linearFilter = linear; + checkSprite(gs, batch, "all four UV fraction bits affect integer-position texture sampling"); + } + } + auto clipped = sprite(0, 0, 192, 32, 0.5f, 0.5f, 192.5f, 32.5f, 3); + clipped.state.context.scissor = {61, 130, 7, 20}; + checkSprite(gs, clipped, "scissoring preserves interpolation relative to the original sprite"); + for (unsigned reverse = 0; reverse < 4; ++reverse) + { + auto stq = sprite(0, 0, 64, 16, 0.5f, 0.5f, 64.5f, 16.5f, reverse); + stq.state.prim.fst = false; + stq.vertices[0].q = 0.25f; + stq.vertices[1].q = 2.0f; + for (auto &vertex : stq.vertices) + { + vertex.s = (static_cast(vertex.u) / 16.0f / width) * 2.0f; + vertex.t = (static_cast(vertex.v) / 16.0f / height) * 2.0f; + } + checkSprite(gs, stq, "STQ sprite uses the second vertex's flat Q for both endpoints"); + } + checkSprite(gs, sprite(20.5f, 0, 20.5f, 8, 0, 0, 8, 8), "zero-width sprite draws no pixels"); + checkSprite(gs, sprite(0, 10.5f, 8, 10.5f, 0, 0, 8, 8), "zero-height sprite draws no pixels"); + auto plain = sprite(0.5f, 0.5f, 63.5f, 16.5f, 0, 0, 64, 16, 3); + plain.state.prim.tme = false; + plain.vertices[1].r = 20; plain.vertices[1].g = 40; plain.vertices[1].b = 60; + initialize(gs); + gs.Submit(plain); gs.Sync(GSSyncReason::Presentation); + for (unsigned y = 0; y < 20; ++y) + for (unsigned x = 0; x < 68; ++x) + expect(gs.ReadVram(GS_PSM_CT32, 0, bw, x, y) == + ((x >= 1 && x < 64 && y >= 1 && y < 17) ? 0x803c2814u : untouched), + "untextured sprite uses the same ceil-exclusive coverage"); +} + +void feedbackStrips(GSCpuBackend &gs) +{ + gs.Reset(); + // This identity pass intentionally aliases source/destination. It uses + // the synchronous backend's actual single-page texture cache. + // Matching fractional UV makes every sample equal to its existing pixel, + // so the result is independent of texture-cache invalidation policy. + for (unsigned y = 0; y < height; ++y) + for (unsigned x = 0; x < width; ++x) + gs.WriteVram(GS_PSM_CT32, 0, bw, x, y, 0x80400000u | x | ((255u - x) << 16)); + gs.TextureFlush(); + for (unsigned strip = 0; strip < 4; ++strip) + { + const float left = static_cast(strip * 64); + auto batch = sprite(left, 1, left + 63.5f, 32.5f, + left + 0.5f, 1.5f, left + 64, 33, 3); + batch.state.context.tex0.tbp0 = 0; + gs.Submit(batch); + // Deliberately no TEXFLUSH between adjacent 64px CT32 pages. + } + gs.Sync(GSSyncReason::Presentation); + unsigned changed = 0, seams = 0; + for (unsigned y = 0; y < height; ++y) + for (unsigned x = 0; x < width; ++x) + { + const uint32_t expected = 0x80400000u | x | ((255u - x) << 16); + const uint32_t value = gs.ReadVram(GS_PSM_CT32, 0, bw, x, y); + changed += value != expected; + expect(value == expected, "reversed 64px feedback strips retain the original aligned pixels"); + if (y >= 1 && y < 33 && x > 0 && (x % 64 == 0 || x % 64 == 63)) + { + const int previous = static_cast(gs.ReadVram(GS_PSM_CT32, 0, bw, x - 1, y) & 255u); + const bool aligned = static_cast(value & 255u) - previous == 1; + seams += !aligned; + expect(aligned, "feedback page/strip boundaries have no extra brightness step"); + } + } + std::printf("Feedback strips: %u altered pixels, %u artificial boundary steps\n", changed, seams); +} +} + +int main(int argc, char** argv) +{ + std::vector vram(4u * 1024u * 1024u); + GSCpuBackend gs; + gs.Initialize(vram.data(), static_cast(vram.size())); + const std::string scenario = argc > 1 ? argv[1] : "all"; + if (scenario == "coordinates" || scenario == "all") coordinateCases(gs); + if (scenario == "feedback" || scenario == "all") feedbackStrips(gs); + if (scenario != "coordinates" && scenario != "feedback" && scenario != "all") return 2; + std::printf("GS alignment regression: %u checks, %u failures\n", checks, failures); + return failures == 0 ? 0 : 1; +}