Skip to content

fix(gs): saturate STQ texel coordinates before converting them to int - #282

Open
luisbraganca wants to merge 1 commit into
ran-j:mainfrom
luisbraganca:fix/gs-stq-texcoord-saturation
Open

luisbraganca wants to merge 1 commit into
ran-j:mainfrom
luisbraganca:fix/gs-stq-texcoord-saturation

Conversation

@luisbraganca

@luisbraganca luisbraganca commented Oct 10, 2026 •

Copy link
Copy Markdown

Fixes #273.

Problem

GSCpuBackend::SampleTexture converts the perspective-divided texel coordinate to int without bounding it. q is guarded by fabsQ, but s/t are not, so a NaN or out-of-range value reaches static_cast<int> on both the point-sample path and the bilinear std::floor path — undefined behaviour under [conv.fpint]. Ridge Racer V emits ST values that reach this (see the issue).

Fix

A saturateTexCoord() helper beside fabsQ, applied to texUf/texVf in the STQ branch, which covers both cast sites and every caller (triangle and sprite).

The bound follows the hardware rather than being an arbitrary guard. Sony's own GS behavioural model, reconstructed in aap/libgpu2, denormalises each coordinate into 12.4 fixed point in TexCoordN (src/txm.c) and the filters call WrapU/WrapV only afterwards, so saturation happens before REPEAT/CLAMP:

  • positive overflow (≥ 2048 texels) → 0x7FFF = 2047.9375 texels
  • negative overflow (≤ −2048 texels) → 0, not -0x7FFF
  • in-range values, including negative ones, are untouched

That model replays Ridge Racer V dumps bit-identically against the original and matches a DTL-T10000 devkit on most captured frames. PCSX2's software renderer uses its own ±32767 ps32 bound, which is an emulator representation, not the hardware's — that is where the issue's first number came from.

NaN maps to zero deliberately: a host-generated NaN has a negative sign on x86 and a positive one on ARM, so saturating it by sign would make rendering host-dependent.

Test

GS STQ texel coordinates saturate like the 12.4 hardware path before wrapping in ps2xTest/src/ps2_gs_tests.cpp, next to the existing CLAMP-mode case. It draws an STQ triangle through the GS frontend with raw ST bits so NaN/±Inf are injected exactly, and checks 11 cases: in-range wrap unchanged (2046.5 and −1 texels), positive and negative overflow, 1e30, ±Inf, NaN point-sampled, NaN under the linear filter, and the same overflow/NaN on T.

Verification

  • ./build/ps2xTest/ps2x_tests: 438 passed, 0 failed.
  • Same suite with the previous gs_cpu_backend.cpp swapped in: 437 passed, 1 failed — only the new case, on the assertions that are deterministic across hosts (positive overflow, negative overflow, NaN in the bilinear weights, T overflow). The other UB cases pass on an ARM host only because fcvtzs saturates; on x86 they fail too.
  • Standalone clang build of the three GS sources with -fsanitize=undefined,float-cast-overflow: the previous backend reports 4e+30 is outside the range of representable values of type 'int' at the point-sample cast and nan … at the bilinear cast; the patched backend reports nothing.

Built on Apple Silicon (clang 21, -DPS2X_ENABLE_FFMPEG=OFF -DPS2X_BUILD_STUDIO=OFF).

Not changed, deliberately

  • TexCoordL in the same model skips the half-texel bilinear offset once a coordinate has saturated; SampleTexture still subtracts 0.5. Small, separable follow-up.
  • fabsQ returning the signed q (noted in the issue) is left as is.
  • No sprite-path test, since Preserve GS sprite endpoint alignment and fractional coordinates #281 is reworking DrawSprite; sprites reach the same SampleTexture code.

GSCpuBackend::SampleTexture converted the perspective-divided texel
coordinate to int unbounded, so a NaN or out-of-range S/T was undefined
behaviour (point and bilinear paths). Saturate to the 12.4 range the GS
uses before the wrap mode, per libgpu2's TexCoordN: positive overflow to
0x7FFF, negative overflow to zero. Adds a frontend-driven regression case.

Fixes ran-j#273
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.

Runtime: SampleTexture casts unbounded or NaN texture coordinates to int, which is undefined behaviour

1 participant