[Fix] (Pipe, State): refresh a pushed PipeInputs field when the frontend moves it inside a verb

- The verify lane aborted eight integration entries and two retrace cases with
  Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@DrawArrays",
  where=read}, always one line after "ResolveSamplerDescriptor: using fallback
  texture for unbound sampler". The backends write into frontend objects during
  their own verb - Magma synthesises a fallback texture for an unbound sampler
  and gives it a shape, materialises a queued clear, overrides a unit's sampler
  filter - and every one of those writes moves a counter MGP_FILL already
  copied, so the pushed block stops equalling the live context for the rest of
  the verb. That is a real divergence, not a harness artefact: the pull build
  reads the moved value and the push build reads the boundary one.
- Takes the findings' preferred option, push on mutation, over the volatile-in-
  verb class: it keeps the comparator's invariant ("the pushed block equals the
  live context at every read") literally true, keeps push semantics equal to
  pull, and is the shape P2's tracker needs. The fallback would have had to skip
  compare-at-read for the field, which is the one comparator arm that is real in
  P1 - it would have blinded the gate on the very field that found the bug.
- MG_Pipe/PipeMutation.h declares MGP_NOTE_MUTATION(Field), a no-op that
  includes nothing in the pull build; MG_Impl/Pipe/PipeFill.cpp defines the
  notice next to the filler it shares CopyField with. The notice refreshes one
  field's value when a context is live, a verb has been filled, and the field is
  in that verb class's may-read mask; it never touches the poison stamp, so a
  stamp MOBILEGL_PIPE_POISON_OMIT withheld stays withheld and a field the verb
  never filled stays Fatal{UnmigratedPipeInput} rather than being healed.
- The enumeration behind the three hook sites: of the ~40 backend->frontend
  write sites, only the texture family reaches a pushed value. Every path
  through them funnels into TextureState::BumpSamplingResolutionGeneration
  (SamplerObject::BumpVersion for the sampler setters,
  TextureObjectBase::BumpShapeVersion for AllocateStorage / SetInternalFormat /
  TruncateMipmapLevels / SetSamples / SetFixedSampleLocations),
  BumpTextureBindGeneration (a default texture becoming defined, delete-unbind,
  a unit's sampler object changing) or NoteUnitTouched (which also moves the
  touched-unit high-water mark), so the notice sits on the counters rather than
  on each writer and covers the whole family including writers added later.
  The buffer, program and VAO writes reach no pushed field: their objects are
  read back through O-class live references, not copied values.
This commit is contained in:
2026-09-06 05:32:13 -04:00
parent 80a6b39003
commit 6b681c4a63
3 changed files with 97 additions and 4 deletions
+28
View File
@@ -16,6 +16,7 @@
#include <MG_State/GLState/BufferState/BufferState.h>
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/Pipe/PipeFill.h>
#include <MG_Pipe/PipeMutation.h>
#include <Config.h>
#include <atomic>
@@ -462,6 +463,33 @@ namespace MobileGL::MG_Pipe {
}
#endif // MOBILEGL_PIPE_VERIFY
// ---- push on mutation (P1 lane finding F2) ----
// A backend that writes a frontend object inside its own verb moves a value the verb
// boundary already copied: Magma's ResolveSamplerDescriptor synthesises a fallback
// texture for an unbound sampler and its AllocateStorage/SetInternalFormat bump the
// context's sampling-resolution generation, so every read of that field after the
// fallback differs from the live context (the two SampledSetStaleness / six
// UnboundImageDescriptor entries the verify lane aborted on). The frontend mutator
// spells MGP_NOTE_MUTATION(Field) at the point of the move and lands here.
//
// Only the value is refreshed. The stamp is deliberately left alone: a field whose stamp
// this verb withheld (negative control B) must stay stale, and a field the verb never
// filled must stay Fatal{UnmigratedPipeInput} on the next read rather than be healed by
// an unrelated frontend write.
void MGPipeNoteFrontendMutation(MGPipeInputField field) {
PipeInputs& inputs = gPipeInputs;
auto* ctx = LiveContext();
if (ctx == nullptr) return;
const auto verb = inputs.CurrentVerb();
if (verb == MGPipeVerb::kVerbCount) return; // nothing has filled the block yet
const auto index = static_cast<SizeT>(field);
if (kMGPipeInputFieldSticky[index]) return; // forwarded: no storage to refresh
const MGPipeFieldMask& mask =
kMGPipeClassFieldMask[static_cast<SizeT>(kMGPipeVerbClass[static_cast<SizeT>(verb)])];
if (!MGPipeFieldMaskHas(mask, field)) return; // this verb never pushed it
MGPipeFillAccess::CopyField(inputs, *ctx, field);
}
void MGPipeSetPoisonOmission(const char* verb, const char* field) {
if (verb == nullptr || field == nullptr) {
g_omission = PoisonOmission{};
+42
View File
@@ -0,0 +1,42 @@
// MobileGL - MobileGL/MG_Pipe/PipeMutation.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#ifndef MOBILEGL_MG_PIPE_MUTATION_H // belt and braces: reachable as <MG_Pipe/..> and <..>
#define MOBILEGL_MG_PIPE_MUTATION_H
// Push-on-mutation (P1 lane finding F2). MGP_FILL copies a verb's may-read set out of the
// live GLContext at the verb boundary; the backend then reads that copy for the whole verb.
// A backend that WRITES a frontend object inside its own verb - Magma synthesising a
// fallback texture for an unbound sampler, materialising a queued clear, or overriding a
// sampler's filter - moves a value the boundary already copied, and every read after that
// point sees a block that no longer equals the live context. That is a real divergence, not
// a harness artefact: the pull build reads the moved value and the push build does not.
//
// The frontend mutator that moves such a value spells MGP_NOTE_MUTATION(Field) right where
// it moves it. The notice refreshes that ONE field in the pushed block when the field
// belongs to the verb currently in flight, so "the pushed block equals the live context at
// every read" stays literally true and the push build keeps pull semantics. It refreshes
// the value only and never the poison stamp, so a withheld stamp (MOBILEGL_PIPE_POISON_OMIT,
// negative control B) stays withheld.
//
// In the pull build the macro is ((void)0) and this header includes nothing, so the pull
// build is byte-identical to a tree without it.
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/MGPipe.h>
namespace MobileGL::MG_Pipe {
// MG_Impl/Pipe/PipeFill.cpp (the client side, the only place that may spell pGLContext).
// A no-op unless a context is live, a verb has been filled, and `field` is in that verb
// class's may-read mask; a forwarded (sticky) field has no storage and is never copied.
void MGPipeNoteFrontendMutation(MGPipeInputField field);
} // namespace MobileGL::MG_Pipe
#define MGP_NOTE_MUTATION(Field) \
::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field)
#else
#define MGP_NOTE_MUTATION(Field) ((void)0)
#endif
#endif
@@ -8,6 +8,7 @@
#pragma once
#include <Includes.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_Util/Miscellany/IndexGenerator.h>
#include "MG_State/GLState/TextureState/TextureObject.h"
#include "MG_Util/Types.h"
@@ -75,7 +76,14 @@ namespace MobileGL::MG_State::GLState {
// Units above it have provably-empty binding slots, so per-draw backend scans
// can stop there instead of walking all MAX_TEXTURE_IMAGE_UNITS units.
void NoteUnitTouched(Int unit, Bool bindingChanged = true) {
if (unit > m_maxTouchedUnit && unit < MAX_TEXTURE_IMAGE_UNITS) m_maxTouchedUnit = unit;
if (unit > m_maxTouchedUnit && unit < MAX_TEXTURE_IMAGE_UNITS) {
m_maxTouchedUnit = unit;
// Push-on-mutation (MG_Pipe/PipeMutation.h): the high-water mark is a pushed
// PipeInputs field, and a bind reached from inside a verb - a backend binding
// its own synthesised fallback texture - would otherwise leave the block
// describing a smaller scan range than the live context has.
MGP_NOTE_MUTATION(GetMaxTouchedTextureUnit);
}
// Every texture/sampler bind entry point (glBindTexture / glBindTextureUnit /
// glBindTextures / glBindSampler) routes through here, so bumping the generation here
// - plus in MarkTextureObjectForDeletion for delete-unbind - covers every change to
@@ -85,11 +93,23 @@ namespace MobileGL::MG_State::GLState {
// Re-binding the object a slot already holds changes nothing that the generation
// guards; such callers pass bindingChanged=false so only the high-water mark advances
// and the backend fast path survives the redundant re-binds apps issue every frame.
if (bindingChanged) ++m_textureBindGeneration;
if (bindingChanged) {
++m_textureBindGeneration;
MGP_NOTE_MUTATION(GetTextureBindGeneration);
}
}
Int GetMaxTouchedUnit() const { return m_maxTouchedUnit; }
Uint64 GetTextureBindGeneration() const { return m_textureBindGeneration; }
void BumpTextureBindGeneration() { ++m_textureBindGeneration; }
// Both counters below are pushed PipeInputs fields AND are moved by writes the
// backends make into frontend objects during their own verb - a synthesised fallback
// texture's AllocateStorage/SetInternalFormat, a sampler override's SetMinFilter, a
// default texture becoming defined. Every such path funnels through these two
// methods (and the bind branch above), so noticing here covers the whole family
// rather than each writer (P1 lane finding F2; MG_Pipe/PipeMutation.h).
void BumpTextureBindGeneration() {
++m_textureBindGeneration;
MGP_NOTE_MUTATION(GetTextureBindGeneration);
}
// Sibling of the bind generation for everything that changes WHICH native texture a
// backend ends up putting on a unit WITHOUT any binding moving. Two families feed it:
@@ -108,7 +128,10 @@ namespace MobileGL::MG_State::GLState {
// its sampled-set memo carries THIS generation alongside the bind one. Any memo of a
// resolved per-unit binding - or of which textures a draw samples at all - needs both.
Uint64 GetSamplingResolutionGeneration() const { return m_samplingResolutionGeneration; }
void BumpSamplingResolutionGeneration() { ++m_samplingResolutionGeneration; }
void BumpSamplingResolutionGeneration() {
++m_samplingResolutionGeneration;
MGP_NOTE_MUTATION(GetSamplingResolutionGeneration);
}
// Globally-unique, never-reused id of THIS texture state, i.e. of the context that owns
// it. Both generations above restart at 0 with a new context, so a backend memo keyed on