GS: stop injecting replacement textures whose upload failed

CreateReplacementTexture discarded the bool from every tex->Update(). The
texture object exists either way, so a failed upload was reported as a
successful replacement and the game was handed a texture with UNDEFINED
contents. On screen that is indistinguishable from a replacement that never
loaded — missing cursors, letters cut in half — which is exactly how #442
presents.

Update can genuinely fail. On Vulkan an upload needs either room in the
shared streaming buffer or a dedicated staging allocation
(GSTextureVK::DoUpdate), and under memory pressure either can fail; OpenGL
has no equivalent staging step, which is consistent with #442 reproducing on
Vulkan and not on OpenGL.

It is worse here than upstream because of our CPU BC decode: a BC7 texture
becomes RGBA8 at four times the size, so the allocation that has to succeed
is four times larger, on exactly the packs most likely to be huge already.

Drop the texture on failure so the game falls back to its original, and log
once with the settings that actually help (Precache off, lower render
resolution). A pack that does not fit now degrades to "not replaced" rather
than "corrupt", and the failure is visible in the log instead of silent.

Mip failures are treated the same way: a garbage mip is still garbage, it
just only shows at distance.

Defect identified by bmdhacks.
This commit is contained in:
jpolo1224
2026-07-26 15:18:51 -04:00
committed by jpolo1224
parent ffe161a576
commit 9a6db38395
@@ -934,8 +934,36 @@ GSTexture* GSTextureReplacements::CreateReplacementTexture(const ReplacementText
if (!tex)
return nullptr;
// Update() CAN fail, and its result was being discarded. On Vulkan an upload needs either room
// in the shared streaming buffer or a dedicated staging allocation (GSTextureVK::DoUpdate), and
// either can fail under memory pressure — at which point the texture exists but its contents are
// UNDEFINED. Injecting it anyway reports success and hands the game garbage, which on screen is
// indistinguishable from a replacement that never loaded: missing cursors, letters cut in half.
//
// It is worse for us than upstream because of the CPU BC decode above: a BC7 texture becomes
// RGBA8 at four times the size, so the upload that has to succeed is four times larger. Drop the
// texture instead, so the game falls back to its original and the pack degrades to "not
// replaced" rather than "corrupt".
const auto upload_failed = [&](u32 level) {
static bool logged_once = false;
if (!logged_once)
{
logged_once = true;
Console.Error("Texture replacements: GPU upload failed (level %u, %dx%d %s). The "
"replacement is being skipped rather than drawn with undefined contents. "
"This usually means texture memory is exhausted — try turning Precache "
"Texture Replacements off, or lowering the render resolution.",
level, rtex.width, rtex.height, GSTexture::GetFormatName(rtex.format));
}
g_gs_device->Recycle(tex);
};
// upload base level
tex->Update(GSVector4i(0, 0, rtex.width, rtex.height), rtex.data.data(), rtex.pitch);
if (!tex->Update(GSVector4i(0, 0, rtex.width, rtex.height), rtex.data.data(), rtex.pitch))
{
upload_failed(0);
return nullptr;
}
// and the mips if they're present in the replacement texture
if (!rtex.mips.empty())
@@ -943,7 +971,13 @@ GSTexture* GSTextureReplacements::CreateReplacementTexture(const ReplacementText
for (u32 i = 0; i < static_cast<u32>(rtex.mips.size()); i++)
{
const ReplacementTexture::MipData& mip = rtex.mips[i];
tex->Update(GSVector4i(0, 0, static_cast<int>(mip.width), static_cast<int>(mip.height)), mip.data.data(), mip.pitch, i + 1);
if (!tex->Update(GSVector4i(0, 0, static_cast<int>(mip.width), static_cast<int>(mip.height)),
mip.data.data(), mip.pitch, i + 1))
{
// A garbage mip is still garbage — it just only shows at distance.
upload_failed(i + 1);
return nullptr;
}
}
}