[Fix, Test] (MG_Impl, MG_Backend/DirectGLES): DSA by-name texture calls corrupted borrowed-slot memo pairings - process-wide glyph death under Iris

Field report: on Espryt with a BSL Iris pipeline built, every glyph in the
game died - HUD, menu labels, even the vanilla title screen after leaving
the world - while sprites kept rendering. Captured on-device (FCL apitrace
rig), reproduced headlessly on llvmpipe, and pinned with a three-way replay:
the same trace renders full text on raw Mesa desktop GL and on Magma, so
the stream was intact and the execution was Espryt's.

MECHANISM. WithTemporarilyBoundNamedTexture implements the by-name (DSA)
texture entry points by binding the named texture onto the active unit's
real slot, running the bound-texture code, and restoring - without moving
the texture bind generation on either edge. DirectGLES's per-draw texture
sync memo keys on that generation and BORROWS the slot pointer, so a memo
built for texture A kept passing every key while a by-name call had
texture B sitting in the slot: A's backend twin was driven with B's
frontend object, and SyncMipmapsToBackend re-specified A's storage with
B's shape. In the trace, a by-name upload to a BSL 2048x2048 map while
the 16x16 lightmap was bound re-specified the lightmap's GL texture
2048x2048-NULL and back 16x16-NULL. The lightmap exists only as render
output - no glTexSubImage2D ever touches it - so it stayed zero forever,
and rendertype_text (vertexColor = Color * texelFetch(lightmap, ...)),
alpha-discards every glyph. Background quads never sample the lightmap,
which is why only text died.

FIX, class-level, two layers:
- Frontend (shared, closes the same hole for DirectVulkan's generation-
  keyed memos): the temporary bind and the restore each bump the texture
  bind generation (only when the slot actually changed), and the restore
  is an RAII scope guard so a throwing body can no longer leak the
  temporary binding - a second latent bug of the same class. Deliberately
  a generation bump and not a touched-unit note: the high-water mark must
  not chase by-name calls, and a completed bind/restore pair leaves the
  content epoch unchanged, so the cost is an owner-compare re-walk, not a
  memo rebuild.
- DirectGLES defense in depth: both borrowed-pair memos
  (g_unitTextureSyncList, g_fboTextureSyncList) record which frontend
  texture each backend twin was paired with and re-check it before any
  replay (last in the key conjunction, behind the context-id compare). A
  stale pairing now costs a list rebuild instead of silent cross-texture
  storage corruption.

Tests, both red with their own layer reverted:
TextureTest.NamedTextureCallKeepsUnitBindingAccountingCoherent (the
accounting contract) and DirectGLESTextureSync.UnitMemoRefusesToDriveA-
TwinFromAnotherTexture (the corrupting sequence shape against a mock GLES
table, asserting the resident texture's storage is never re-specified).
595/595 unit at default and with the async kill switch. Replay evidence:
the captured BSL ESC-menu trace renders all text through Espryt post-fix,
byte-comparable to the Mesa-direct and Magma replays; the no-shaderpack
control is unchanged. A trace fixture wiring this scene into CI follows
in a separate commit.
This commit is contained in:
BZLZHH
2026-08-09 11:28:24 -04:00
parent 3e0460e472
commit 107669b3db
5 changed files with 261 additions and 9 deletions
+27 -6
View File
@@ -1127,10 +1127,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
// slot the key covers still holds a reference to it. Holding either side by shared_ptr
// instead would keep dead frontend textures alive and defeat the registry's
// weak-reference GC.
//
// `texture` records WHICH frontend object `backend` was paired with when the entry was
// built, and PairingsIntact re-checks it before any replay. The keys above are the
// primary guard, but they are all derived state: a slot swap that never reaches the
// bind generation (the DSA by-name emulation used to swap a slot silently) would leave
// every key matching while the borrowed slot pointed at a different texture, and the
// replay would then drive texture A's backend twin from texture B's frontend state -
// re-specifying A's backend storage with B's shape and destroying A's contents. A raw
// pointer compare per entry is far cheaper than the walk it guards, and a stale pairing
// costs only a list rebuild, so this stays as the structural net under the keys.
struct UnitTextureSyncEntry {
const SharedPtr<MG_State::GLState::ITextureObject>* slot = nullptr;
MG_State::GLState::ITextureObject* texture = nullptr;
BackendTextureObject* backend = nullptr;
};
// True while every entry's borrowed slot still holds the texture the entry was paired
// with. Callers put it LAST in the key conjunction so it only runs on a key hit.
static Bool PairingsIntact(const Vector<UnitTextureSyncEntry>& list) {
for (const auto& entry : list) {
if (entry.slot->get() != entry.texture) return false;
}
return true;
}
static Vector<UnitTextureSyncEntry> g_unitTextureSyncList;
static Bool g_unitTextureSyncListValid = false;
static Uint64 g_unitTextureSyncListContextId = 0;
@@ -1197,7 +1216,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_unitTextureSyncListMaxUnit == maxTouchedUnit &&
g_unitTextureSyncListContextGeneration == g_textureContextGeneration &&
g_unitTextureSyncListEpoch == unitBindingsEpoch &&
g_unitTextureSyncListSamplingGeneration == samplingGeneration) {
g_unitTextureSyncListSamplingGeneration == samplingGeneration &&
PairingsIntact(g_unitTextureSyncList)) {
for (const auto& entry : g_unitTextureSyncList) {
// Aggregate gate == the conjunction of the three callees' own
// early-outs (see IsDrawSyncClean); skipping on true is
@@ -1219,8 +1239,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
// An image-less default texture (name 0) is the slot's initial / "unbound"
// state; it has nothing to sync, so skip it as cheaply as the old null slot.
if (textureObject && !MG_State::GLState::IsUndefinedDefaultTexture(textureObject.get())) {
g_unitTextureSyncList.push_back(
{&textureObject, SyncTextureObjectToBackend(textureObject).get()});
g_unitTextureSyncList.push_back({&textureObject, textureObject.get(),
SyncTextureObjectToBackend(textureObject).get()});
}
}
}
@@ -1254,7 +1274,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fboTextureSyncListSlotVersion == fboSlotVersion &&
g_fboTextureSyncListObjectVersion == fboObjectVersion &&
g_fboTextureSyncListContextId == keys.contextId &&
g_fboTextureSyncListContextGeneration == g_textureContextGeneration;
g_fboTextureSyncListContextGeneration == g_textureContextGeneration &&
PairingsIntact(g_fboTextureSyncList);
if (fboListValid) {
for (const auto& entry : g_fboTextureSyncList) {
// Same aggregate gate as the unit list above.
@@ -1273,8 +1294,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!attachment.IsTexture()) continue;
auto& textureObject = attachment.GetTexture();
if (textureObject) {
g_fboTextureSyncList.push_back(
{&textureObject, SyncTextureObjectToBackend(textureObject).get()});
g_fboTextureSyncList.push_back({&textureObject, textureObject.get(),
SyncTextureObjectToBackend(textureObject).get()});
}
}
g_fboTextureSyncListFbo = currentFBO.get();
@@ -630,6 +630,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
Bool imageBindableStorageRequired = false);
// Brings every texture the next draw reads - the touched units' bindings and the draw
// FBO's texture attachments - onto the backend, through the two borrowed-pair memos
// documented at their definitions. Declared here so tests can drive those memos directly.
void SyncNeccessaryTextures();
extern Array<Array<BackendTextureObject*, (SizeT)TextureTarget::TextureTargetCount>,
MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundTexturesCache;