Files
MobileGL/MobileGL/MG_Pipe/DirtySurface.def
T
swung0x48 59191cd296 [Fix] (Pipe, DirtySurface): resolve both sides of the dirty-surface derivation to member+field, follow reference and pointer aliases, and turn every write the analysis cannot place into an UNDECIDED answer instead of a verdict
- The writer side recorded a member-rooted write bound to a reference (`for (auto& blendState : m_parameters.BlendStates)`) as the field alone, so seven RenderState setters read as writing nothing; the reader side resolved `render.PatchVertices` to the whole of m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and a row saying glClearColor publishes the patch state was green. Both were the same defect: the two sides did not resolve to the same token.
- Both sides now carry MEM:<member> and FIELD:<member>.<leaf>; a whole-member write or read is every field. A reference, pointer or range-for alias bound to a member-rooted lvalue is followed (rebinds and aliases of aliases included), a write through a call-result lvalue and a mutating call on a member-rooted lvalue count as whole writes, a const alias cannot be written through with `.`.
- A write, or a non-read-only method call, whose root the analysis cannot place - a reference parameter, a call result, a member without the m_ prefix, an unattributable assignment operator - taints the function; the taint rides the call-graph fixed point and every (row, bit) that depends on a tainted function is UNDECIDED, never a verdict. Nothing is trusted by name.
- Match rule: a writer supports a bit iff the two sides share a member and, both field-resolved, their field sets intersect; a member in common with no field information on one side is COARSE, reported and never counted; UNDER-FIRING only when both sides are resolved and disjoint for every member the shutter reads.
- --check counts only supported answers as derived, prints the COARSE and UNDECIDED tallies, and fails on an UNDECIDED row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST in DirtySurface.def marks it; a mark on a row the derivation decides is a red gate too. The list is empty: all 8 non-render bit answers are supported at field level, and NEW_PATCH_STATE has exactly three legal carriers again.
- --self-test grows from 10 to 21 negative controls, including the synthetic bodies of every shape above through the real extractor, the NEW_PATCH_STATE analogue of the value-class control, the taint, COARSE and stale-mark paths, and positive controls for SetPixelStoreParam's pasted writes and the seven alias setters.
2026-09-07 23:18:09 -04:00

288 lines
24 KiB
Modula-2

// MobileGL - MobileGL/MG_Pipe/DirtySurface.def
// 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
// The dirty-surface mapping (ARCHITECTURE.md 5.2 corollary 4, P2 brief D16).
//
// MGPipe replaces "the backend rediscovers what changed" with "the frontend says what
// changed", which only works if EVERY frontend mutation a backend can observe has an answer
// to "what publishes this". The failure mode is silent and one-directional: a mutation that
// forgets to publish renders stale, and no purity gate can see it.
//
// So the surface is enumerated MECHANICALLY. scripts/gen_pipe_dirty_surface.py scans
// MG_Impl/GLImpl for every pGLContext-> mutator call and, with --check, fails if a scanned
// mutator has no row here or a row here names a mutator the scan no longer finds. Both
// directions, so a deleted mutator cannot leave a stale row behind either.
//
// ANSWERS. A row lists EVERY publisher that fires on EVERY path through that mutator,
// and only those; several are joined with '|'. A publisher that fires on some paths but
// not all must not appear, because a shutter built from this file would then UNDER-fire,
// and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. The one row that
// carries a bit which fires on only some paths says so in its answer - kPulledPartialShutter
// joined with that bit - because the alternative, dropping the bit, tells a reader of this
// file that a bit P2 already emits a call for has no shutter at all.
//
// "EVERY PATH" MEANS EVERY PATH THAT MUTATES. A setter that returns early because the value
// did not change publishes nothing and needs to publish nothing - there is no mutation to
// carry - so a redundant-write guard (SetColorMask's `if (changed) BumpVersions();`, the
// BitwiseEqual guards on the patch levels) does not make its publisher conditional in the
// sense this rule cares about. A publisher reached on only SOME of the paths that DO mutate
// - SetCapability's ClipDistance arms, SetStencilFunc's reference-only call - is the thing
// that must not be named.
//
// EVERY BIT ANSWER IN THIS FILE IS DERIVED AND CHECKED, in two families and one
// direction. The RenderState family (45 rows) is checked both ways against RenderState.cpp,
// below. Every other NEW_* answer is checked against the shutter Tracker.h builds for that
// bit: gen_pipe_dirty_surface.py resolves what the shutter READS and what each mutator
// transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop it reads out of
// MGPipeNoteAggregate's own switch, and through the function-like macros of MG_State, which
// it EXPANDS - sixteen of RenderState.cpp's writes exist only after the preprocessor has
// pasted them together) to the SAME two-level token, MEM:<member> plus FIELD:<member>.<leaf>,
// and fails a row that names a bit whose shutter its mutator moves on no path at all. That
// half is one-directional on purpose - "it does write something the shutter reads" cannot
// prove it does so on EVERY path - so it catches under-firing and not over-claiming.
//
// AN ABSENCE CLAIM IS ONLY WORTH THE READING BEHIND IT, and this gate learned that twice:
// its write analysis first recorded a write through a member's field as the field alone
// and never the member, so --check printed, as a fact about RenderState.cpp, that
// SetPixelStoreParam "writes nothing NEW_PIXEL_PACK's shutter reads" about a setter whose
// whole body is sixteen writes to exactly that member; then, once it read the member, it
// still could not see a write through a REFERENCE (SetBlendEquation's `for (auto&
// blendState : m_parameters.BlendStates)`) and said the same false thing about seven more
// setters - while its reader side resolved `render.PatchVertices` to the WHOLE of
// m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and
// a row saying glClearColor publishes the patch state was green. So now: a reference or
// pointer bound to a member-rooted lvalue is followed, and its writes are credited to the
// member and the field it was bound to; a write whose root the analysis cannot place (a
// reference parameter, a call result, a member without the m_ prefix, a token it could not
// expand) TAINTS the function, and every answer that depends on a tainted function is
// UNDECIDED - printed with its reason, never a verdict; a writer supports a bit only when
// the two sides share a member AND, both resolved to fields, their field sets intersect (a
// whole-member write or read is every field); a member in common with no field information
// on one side is COARSE, reported and never counted. --check counts only the supported
// answers as derived, prints the COARSE and UNDECIDED tallies, and FAILS on an UNDECIDED
// row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST at the bottom of this file marks it - a
// mark that outlives its reason is a red gate too. What it still cannot claim: a shutter
// member written outside MG_State/GLState + MG_Impl/Pipe is undecided in the absence
// direction, a call is resolved by NAME to every body of that name, and a FIELD token is
// not scoped to a type - all three only widen what a mutator is credited with, and the
// second is also how a taint spreads.
//
// The prose answers (kImmediate, kExplicitDestroy, kUnpublishedDestroy, kNoBackendRead,
// kPulledEveryVerb, kPulledPartialShutter, kReverseChannel) are statements no derivation
// checks - except the bits a kPulledPartialShutter row names, which are checked like any
// other bit answer. --check prints how many rows carry a prose answer, so "all mapped" can
// never be read as "all verified".
//
// For the RenderState family that answer is not a matter of taste and it is CHECKED
// rather than asserted: scripts/gen_pipe_dirty_surface.py reads RenderState.cpp and
// derives, per setter, which of NEW_RENDER_STATE / NEW_PIPELINE_STATE moves on every
// path - BumpVersions() moves both, a bare ++m_version moves only NEW_RENDER_STATE, and
// a setter with both kinds of path therefore always-fires only NEW_RENDER_STATE - and
// --check fails when a row disagrees, in either direction. That check exists because
// this file got exactly two rows wrong: SetCapability, whose ClipDistance0..7 arms move
// only m_version, and SetStencilFunc, whose pipeline bump is conditional on Func moving.
// Both named NEW_PIPELINE_STATE, which does not fire for glEnable(GL_CLIP_DISTANCE0) or
// for a reference-only glStencilFunc.
//
// NEW_* a MGPipeDirty bit (MG_Impl/Pipe/Tracker.h). The tracker's shutter for
// that bit moves when this mutator runs, so the next verb publishes it.
// kImmediate the mutating function also reaches the backend in the same body, so the
// mutation is published inline and needs no shutter at all.
// kReverseChannel not state: a write INTO the frontend from the backend's side.
// kNoBackendRead no backend read point observes this state at all.
// kExplicitDestroy published by the delete_* / resource_destroy call the Track H slice
// emits when the object's last reference drops - an object's DEATH,
// which no generation shutters because there is no longer an object
// to carry one. Only for a kind that HAS an identity on the wire to
// destroy: the resources and CSOs of PipeCalls.def, which is what P2
// brief D13 scopes Espryt 0b's explicit destroy to.
// kUnpublishedDestroy
// the same event for a kind NOTHING publishes: a program, a program
// pipeline and a shader have no per-object handle on the wire at all
// in P2 - resource_destroy and the delete_* family name resources and
// CSOs - so their DirectGLES twins are still reclaimed by the
// backend's own registry teardown and no frontend call says they
// died. Recorded as a hole rather than dressed up as a mechanism that
// exists; naming kExplicitDestroy here would be the same defect the
// RenderState derivation above exists to stop, one class down in
// stakes. (D13's prose says 'six kinds' while the Core.cpp ranges it
// cites also cover MarkProgram/MarkShaderForDeletion; the tree
// decides, and the tree has no wire object for those three.)
// kPulledEveryVerb no shutter exists at all - no MGPipeDirty bit moves on any path through
// this mutator - and none is needed yet: the PipeInputs field it writes is
// in its verb class's may-read mask, so the residual fill copies it at
// EVERY verb of that class. A shutter here is a P3/P4 optimisation, not a
// correctness gap.
// kPulledPartialShutter
// the same pull, but a bit DOES move - on some of the paths that mutate,
// not all of them - so this row must never be read as "no shutter exists".
// The bits that move are named after the '|', which is the one place this
// file joins a prose answer with a bit, and the reason is exactly that a
// P3a shutter builder has to be able to tell "no bit covers this" from "a
// bit covers half of it". The named bits are checked the same way every
// other bit answer is - a dead one is a red gate - but they are NOT a
// licence to narrow: what holds on every mutating path is the pull.
// Which rows need this answer is a human judgement and stays one: the
// derivation's "it does move that shutter" direction over-approximates
// (a call name resolves to every body of that name, a write inside an
// `if` counts), so it can refute a named bit but cannot find the rows
// that should have named one.
//
// KNOWN BLIND SPOTS OF THE SCANNER, recorded here rather than left implicit
// (gen_pipe_dirty_surface.py's own notes plus its scan root):
// 1. it matches braced function bodies textually, so a mutator inside a LAMBDA is
// attributed to the enclosing function;
// 2. a mutation published through a HELPER the entry point calls reads as deferred here;
// 3. the scan root is MG_Impl/GLImpl only, so the four MGP_NOTE_MUTATION sites in
// MG_State/GLState/TextureState/TextureState.h are outside it entirely.
// The gate is therefore a COMPLETENESS gate over what the scanner does see. The semantic
// proof stays the MOBILEGL_PIPE_VERIFY lane, which is blind to none of the three.
//
// clang-format off
// X(Mutator, Answer)
#define MGP_DIRTY_SURFACE_LIST(X) \
/* ---- the reverse channel: 836 of the 926 calls, 90% of the surface ---- */ \
X(RecordError, kReverseChannel) \
/* ---- immediate publish points: the same body reaches the backend ---- */ \
X(SetActiveTextureUnit, kImmediate) \
X(BeginTransformFeedback, kImmediate) \
X(EndTransformFeedback, kImmediate) \
X(SetTransformFeedbackPaused, kImmediate) \
X(MarkTransformFeedbackObjectForDeletion, kImmediate) \
/* ---- the render state. Derived from RenderState.cpp and gated by --check: */ \
/* a setter that calls BumpVersions() on every path publishes BOTH counters; */ \
/* one that also has a bare ++m_version path publishes only NEW_RENDER_STATE. */ \
X(SetBlendEquation, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetBlendEquationIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetBlendFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetBlendFuncIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
/* SetCapability's ClipDistance0..7 arms write ClipDistanceEnabledMask (dynamic */ \
/* chunk D7) and deliberately do NOT BumpVersions, so NEW_PIPELINE_STATE does */ \
/* not fire at all for glEnable(GL_CLIP_DISTANCE0): set_dynamic_state publishes */ \
/* it, and NEW_RENDER_STATE is the only answer that holds on every arm. */ \
X(SetCapability, NEW_RENDER_STATE) \
X(SetCapabilityIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetColorMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetColorMaskIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetCullFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetDepthFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetDepthMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetFrontFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetLogicOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetMinSampleShadingValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetPolygonMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetProvokingVertexMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetSampleCoverage, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetSampleMaskValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
/* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask (dynamic */ \
/* D3/D4), and ++m_pipelineStateVersion is CONDITIONAL on Func moving - which is */ \
/* what keeps a glStencilFunc that moves only the reference from evicting a */ \
/* cached pipeline, and is why only NEW_RENDER_STATE fires on every call. */ \
/* SetStencilOp is wholly pipeline, SetStencilMask wholly dynamic. */ \
X(SetStencilFunc, NEW_RENDER_STATE) \
X(SetStencilOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetStencilMask, NEW_RENDER_STATE) \
X(SetBlendColor, NEW_RENDER_STATE) \
X(SetClampReadColor, NEW_RENDER_STATE) \
X(SetClearColor, NEW_RENDER_STATE) \
X(SetClearDepth, NEW_RENDER_STATE) \
X(SetClearStencil, NEW_RENDER_STATE) \
X(SetClipControl, NEW_RENDER_STATE) \
X(SetDepthRange, NEW_RENDER_STATE) \
X(SetDepthRangeIndexed, NEW_RENDER_STATE) \
X(SetHint, NEW_RENDER_STATE) \
X(SetLineWidth, NEW_RENDER_STATE) \
X(SetPointFadeThresholdSize, NEW_RENDER_STATE) \
X(SetPointSize, NEW_RENDER_STATE) \
X(SetPointSpriteCoordOrigin, NEW_RENDER_STATE) \
X(SetPolygonOffset, NEW_RENDER_STATE) \
X(SetPolygonOffsetClamped, NEW_RENDER_STATE) \
X(SetPrimitiveRestartIndex, NEW_RENDER_STATE) \
X(SetScissorBox, NEW_RENDER_STATE) \
X(SetScissorBoxIndexed, NEW_RENDER_STATE) \
X(SetViewport, NEW_RENDER_STATE) \
X(SetViewportIndexed, NEW_RENDER_STATE) \
/* ---- the other value-class bits ---- */ \
/* kPulledPartialShutter, NOT kPulledEveryVerb, and NOT a bare NEW_PIXEL_PACK: */ \
/* RenderState::SetPixelStoreParam writes BOTH halves - eight Pack arms and eight */ \
/* Unpack arms - while the tracker's bit 2 is a byte compare of the PACK half alone */ \
/* (Tracker.h), because set_pixel_pack_state deliberately has no unpack counterpart */ \
/* (ARCHITECTURE.md 4.6). So glPixelStorei(GL_PACK_ALIGNMENT, 8) DOES move bit 2 and */ \
/* glPixelStorei(GL_UNPACK_ALIGNMENT, 8) moves nothing at all, and a shutter narrowed */ \
/* to bit 2 would under-fire for eight of the sixteen arms. What is true on every path */ \
/* is the pull: GetPixelStoreParameters is one of the two Coverage.def rows an emitted */ \
/* call does not supply completely (PipeFill.cpp), so the residual fill copies both */ \
/* halves at every verb of the class. The bit is named anyway because P2 already EMITS */ \
/* set_pixel_pack_state off it: a row that said "no shutter exists" about the only */ \
/* mutator behind a shipped call would be a false answer to the one question D16 hands */ \
/* P3a. Splitting this setter into a pack half and an unpack half is what would let the */ \
/* pack half answer NEW_PIXEL_PACK outright; that is P3's move, not P2's. */ \
X(SetPixelStoreParam, kPulledPartialShutter|NEW_PIXEL_PACK) \
X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
/* Also an immediate publish point, but it has a real bit and the bit is */ \
/* the more useful answer: set_patch_state carries it whatever the caller */ \
/* does next. */ \
X(SetPatchVertices, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetCurrentVertexAttributeFloat, NEW_VERTEX_ATTRIB_DEFAULTS) \
X(SetCurrentVertexAttributeInt, NEW_VERTEX_ATTRIB_DEFAULTS) \
X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \
/* ---- object class ---- */ \
X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \
/* NOT NEW_SO_TARGETS, and this one was false on EVERY path: GLContext::SetNamed */ \
/* TransformFeedbackBinding either binds a BufferState binding point (index == the */ \
/* bound XFB object) or writes a saved-bindings entry, and NEW_SO_TARGETS mixes the */ \
/* buffer-CONTENT aggregate with the transform-feedback generation - the first moves */ \
/* only at BufferObject.cpp's content sites, the second only in BeginTransformFeedback. */ \
/* A binding moves neither. It reaches the backend the same way every other buffer */ \
/* binding point does, through GetBufferBindingPoint in the verb class's may-read mask, */ \
/* so the honest answer is the pull. Narrowing it is P3b's, when it takes the subsystem */ \
/* over and the binding points get a generation of their own. */ \
X(SetNamedTransformFeedbackBinding, kPulledEveryVerb) \
/* ---- an object's death: no generation, because there is no longer an object */ \
/* to carry one. Espryt 0b's delete_* / resource_destroy publishes the kinds */ \
/* that have a handle on the wire; programs, program pipelines and shaders have */ \
/* none in P2, so nothing publishes theirs - kUnpublishedDestroy, a known hole. */ \
X(MarkBufferObjectForDeletion, kExplicitDestroy) \
X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \
X(MarkProgramForDeletion, kUnpublishedDestroy) \
X(MarkProgramPipelineForDeletion, kUnpublishedDestroy) \
X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \
X(MarkSamplerObjectForDeletion, kExplicitDestroy) \
X(MarkShaderForDeletion, kUnpublishedDestroy) \
X(MarkTextureObjectForDeletion, kExplicitDestroy) \
X(MarkVertexArrayForDeletion, kExplicitDestroy) \
/* ---- no backend read point observes these at all ---- */ \
/* GL_ANY_SAMPLES_PASSED conditional rendering is resolved wholly in the */ \
/* frontend: IsConditionalRenderActive / GetConditionalRenderQuery have no */ \
/* reader under MG_Backend and no Coverage.def row. */ \
X(BeginConditionalRender, kNoBackendRead) \
X(EndConditionalRender, kNoBackendRead) \
/* ---- pulled at every verb of the class, so the next verb publishes them */ \
/* unconditionally. The transform-feedback accounting counters reach the */ \
/* backend through GetTransformFeedbackCapturedVertices and friends, which */ \
/* are in the kDraw and kXfbSpan may-read masks. */ \
X(AddTransformFeedbackAccountedCaptureDraw, kPulledEveryVerb) \
X(AddTransformFeedbackCapturedVertices, kPulledEveryVerb) \
X(AddTransformFeedbackGeometryCaptureDraw, kPulledEveryVerb) \
X(AddTransformFeedbackInputPrimitives, kPulledEveryVerb) \
X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \
X(AddTransformFeedbackPrimitives, kPulledEveryVerb)
// X(Mutator, Bit) - the (row, bit) pairs above whose derivation is KNOWN to come out
// UNDECIDED, each with the reason --check prints for it. Every bit answer NOT listed here
// is marked derived: --check fails when the derivation cannot decide it, and fails again
// when a mark here names a pair the derivation now decides, so this list can neither hide a
// row nor outlive its reason. Empty today: every bit answer above is supported at field
// level. The ten mutators that reach a tainted body (--check prints the count) all carry a
// prose answer, which no derivation checks.
#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X)
// clang-format on