diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 9768d672..5c88caec 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -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(field); + if (kMGPipeInputFieldSticky[index]) return; // forwarded: no storage to refresh + const MGPipeFieldMask& mask = + kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[static_cast(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{}; diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h new file mode 100644 index 00000000..fcbdc03e --- /dev/null +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -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 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 +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 diff --git a/MobileGL/MG_State/GLState/TextureState/TextureState.h b/MobileGL/MG_State/GLState/TextureState/TextureState.h index c85ae8a4..ab7af05a 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureState.h +++ b/MobileGL/MG_State/GLState/TextureState/TextureState.h @@ -8,6 +8,7 @@ #pragma once #include +#include #include #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