Runtime: SampleTexture casts unbounded or NaN texture coordinates to int, which is undefined behaviour
Maintainers usually reply within 5 days
Nobody has claimed this yet.
- #282 by @luisbraganca — closed without merging
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 68/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- cpp
- Domain
- computer-graphics, reverse-engineering
Research direction
Open ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp and find GSCpuBackend::SampleTexture, where texUf/texVf are computed then static_cast<int> (point sample and bilinear floor paths). Add a finite clamp (as sketched next to fabsQ) on those texel coords before any int conversion so NaN/huge ST cannot hit conv.fpint UB. Done when both paths go through the clamp, the file still builds, and wrap/CLAMP still runs on bounded ints. Do not rewrite vertices unless maintainers choose that path.
Written by the indexing model from the issue text.
Description
What happens
GSCpuBackend::SampleTexture converts interpolated texture coordinates to int without bounding them first. When a game emits large or non-finite S/T, the conversion is undefined behaviour, not merely a wrong texel.
ps2xRuntime/src/lib/gs/gs_cpu_backend.cpp:
const float invQ = 1.0f / fabsQ(q);
texUf = s * invQ * static_cast<float>(texW);
texVf = t * invQ * static_cast<float>(texH);
and then, by two separate paths:
return samplePoint(static_cast<int>(texUf), static_cast<int>(texVf)); // point sample
...
const int u0 = static_cast<int>(std::floor(sampleU)); // bilinear
const int v0 = static_cast<int>(std::floor(sampleV));
q is already guarded, and neatly so — fabsQ returns 1.0f whenever std::fabs(q) <= 1e-8, and since std::fabs(NaN) > 1e-8 is false, a NaN q lands there too.
s and t are not guarded at all. A large or NaN s gives a texUf that is non-finite or outside the range of int, and static_cast<int> of such a value is UB ([conv.fpint]). wrapTextureCoordinate then operates on whatever came out, so even the CLAMP/REGION_CLAMP modes cannot save it.
This is not theoretical
PCSX2 merged PR #13970 on 2026-09-04 for exactly this input shape:
This is to prevent graphical glitches in Ridge Racer V (RRV), which draws reflections on cars with large (maybe invalid?) ST coordinates.
Their detection includes a NaN test (m_vt.nan.value), so the bad vertices carry NaN as well as large magnitudes. Scope, from the PR author's own dump run over ~2700 traces: more than 1000 had vertices needing a rewrite, and more than 200 had it on over 5% of draw calls. So this input shape is not one game's quirk.
The decisive detail: the author replayed the RRV dump on a real PlayStation 2 and saw no glitches. The game is not at fault — the hardware simply has a bound that a float rasteriser does not.
The bound, and where it comes from
The GS texture-coordinate path is finite: a signed 16-bit fixed-point value. PCSX2 uses (1 << 15) - 2 = 32766:
const GSVector4 large_val = GSVector4::cxpr(static_cast<float>((1 << 15) - 2)) / tsize;
Their threshold is compared against normalised ST (hence the division by texture size); in SampleTexture the values are already texels, so the same bound is 32766 texels directly.
Suggested fix
A helper beside fabsQ, and one call after texUf/texVf are computed — which covers both conversion paths at once. <cmath> and <algorithm> are already included.
// The GS texture-coordinate path is a signed 16-bit fixed-point value.
// (1 << 15) - 2 is the bound PCSX2 settles on; the -2 leaves room for the
// bilinear neighbour's +1 without overflowing.
constexpr float kGsTexCoordLimit = static_cast<float>((1 << 15) - 2);
float clampTexCoord(float c)
{
if (!std::isfinite(c))
{
return 0.0f;
}
return std::fmin(std::fmax(c, -kGsTexCoordLimit), kGsTexCoordLimit);
}
texUf = clampTexCoord(texUf);
texVf = clampTexCoord(texVf);
Builds clean with clang-cl, no new warnings.
What this does and does not do. It removes the UB and bounds sampling to what the hardware can address. It does not claim to reproduce PCSX2's pixels: PCSX2 clamps ST at the vertex, before interpolation, which keeps the DDA gradients sane, whereas this clamps the interpolated value. On a triangle with a single out-of-range vertex the two diverge. The vertex-level fix is the more faithful one, at the cost PR #13970 itself flags — de-indexing and rewriting the vertex queue, with measurable CPU overhead. The two-line version seemed the better default for a runtime; the choice is yours.
Independently of the fix
Two smaller things noticed in the same function, neither touched by the above:
fabsQreturnsq, notstd::fabs(q), despite its name — so a negativeqyields a negativeinvQ. That may well be intentional (the GS does divide by a signedQ), but the name says otherwise and it cost a second read.- The guard is worth having even where
CLAMPis active, because the UB happens at the cast, beforewrapTextureCoordinateever sees the value.
Found while statically recompiling Ridge Racer V (NTSC-U, SLUS_200.02) with PS2Recomp — the game whose reflections prompted the PCSX2 change, so this path is one we expect to reach.
Tested on commit 75d729ce. Host: Windows 11 x64, clang-cl 22.1.3 + Ninja, MSVC toolset 14.51.36231, CMake 4.2.3, -flto=thin.
Not specific to this game, and not specific to Windows or to any toolchain: the undefined behaviour is in the cast.
- Dominant language
- C++
- Stars
- 3.3k
- Forks
- 149
- Avg merge
- 8h 46m
- Merged PRs (30d)
- 1
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from ran-j/PS2Recomp
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
ran-j/PS2Recomp#393 · 1 comment ·
Maintainers usually reply within 5 days
-
EE sceCdStatus stub always reports READ (6), hanging games that wait for PAUSEPossibly taken @ahmedgamalalzatary claimed this 11 days ago. Open
Difficulty 1/5 Under an hour Newbie friendliness 95/100
Maintainers usually reply within 5 days
-
VU0 macro VSQI / VSQD: address and source registers swapped in generated codePossibly taken @yanm1103 claimed this 19 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 5 days
-
Build: /Qspectre- is MSVC-only and breaks every Release target under clang-clPossibly taken @ahmedgamalalzatary claimed this 11 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 5 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 5 days
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
meshcore-dev/MeshCore#3760 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 88/100
deepseek-ai/DeepGEMM-Ascend#29 ·
Maintainers usually reply within 1 day
-
documentation
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
EchoTools/nevr-runtime#2042 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
emscripten-core/emscripten#28160 · 1 comment ·
Maintainers usually reply within 1 day
-
Download size and speed show scientific notation between 1000 and 1023 unitsPossibly taken @RaphaelFakhri claimed this today. Open
Difficulty 2/5 Under an hour Newbie friendliness 88/100
kiwix/kiwix-desktop#1570 ·
Maintainers usually reply within 1 day