diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def new file mode 100644 index 00000000..3fe79067 --- /dev/null +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -0,0 +1,151 @@ +// 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. One per row, and where a mutator has more than one true answer the row carries +// the COARSER one - the one that cannot under-fire: +// +// 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_* 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. +// kPulledEveryVerb no shutter exists, and none is needed yet: the PipeInputs field this +// 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. +// +// 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: NEW_PIPELINE_STATE when a setter calls */ \ + /* BumpVersions (P2 brief D6's only rule), NEW_RENDER_STATE otherwise */ \ + X(SetBlendEquation, NEW_PIPELINE_STATE) \ + X(SetBlendEquationIndexed, NEW_PIPELINE_STATE) \ + X(SetBlendFunc, NEW_PIPELINE_STATE) \ + X(SetBlendFuncIndexed, NEW_PIPELINE_STATE) \ + /* SetCapability's ClipDistance0..7 arms move only m_version; every other */ \ + /* arm calls BumpVersions, and the coarser answer is the one that holds. */ \ + X(SetCapability, NEW_PIPELINE_STATE) \ + X(SetCapabilityIndexed, NEW_PIPELINE_STATE) \ + X(SetColorMask, NEW_PIPELINE_STATE) \ + X(SetColorMaskIndexed, NEW_PIPELINE_STATE) \ + X(SetCullFaceMode, NEW_PIPELINE_STATE) \ + X(SetDepthFunc, NEW_PIPELINE_STATE) \ + X(SetDepthMask, NEW_PIPELINE_STATE) \ + X(SetFrontFaceMode, NEW_PIPELINE_STATE) \ + X(SetLogicOp, NEW_PIPELINE_STATE) \ + X(SetMinSampleShadingValue, NEW_PIPELINE_STATE) \ + X(SetPolygonMode, NEW_PIPELINE_STATE) \ + X(SetProvokingVertexMode, NEW_PIPELINE_STATE) \ + X(SetSampleCoverage, NEW_PIPELINE_STATE) \ + X(SetSampleMaskValue, NEW_PIPELINE_STATE) \ + /* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask */ \ + /* (dynamic D3/D4); SetStencilOp is wholly pipeline, SetStencilMask wholly */ \ + /* dynamic. That split is what keeps a glStencilFunc that moves only the */ \ + /* reference from evicting a cached pipeline. */ \ + X(SetStencilFunc, NEW_PIPELINE_STATE) \ + X(SetStencilOp, 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 ---- */ \ + X(SetPixelStoreParam, NEW_PIXEL_PACK) \ + X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE) \ + X(SetPatchDefaultOuterLevel, NEW_PATCH_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) \ + 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) \ + X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) \ + /* ---- an object's death: no generation, because there is no longer an */ \ + /* object to carry one. Espryt 0b's delete_* is what publishes these. */ \ + X(MarkBufferObjectForDeletion, kExplicitDestroy) \ + X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \ + X(MarkProgramForDeletion, kExplicitDestroy) \ + X(MarkProgramPipelineForDeletion, kExplicitDestroy) \ + X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \ + X(MarkSamplerObjectForDeletion, kExplicitDestroy) \ + X(MarkShaderForDeletion, kExplicitDestroy) \ + 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) + +// clang-format on diff --git a/MobileGL/MG_Pipe/FillPoints.def b/MobileGL/MG_Pipe/FillPoints.def index b71a9b4c..01edcd78 100644 --- a/MobileGL/MG_Pipe/FillPoints.def +++ b/MobileGL/MG_Pipe/FillPoints.def @@ -26,6 +26,44 @@ // Fatal{UnmigratedPipeInput, "Field@Verb"} found there is fixed by adding the (class, field) // row, never by marking the field sticky. // +// --------------------------------------------------------------------------------------- +// THE VERDICT ON THE EIGHT STATICALLY OVER-APPROXIMATED ROWS (P2 brief C.1, MEASUREMENTS.md +// section 4). Every one of them is KEPT, and the reason is the same in all three groups: the +// row is not a guess, it names a concrete backend path, and the only evidence that could +// retire it is DYNAMIC - a corpus that never reaches the path proves nothing, because a row +// removed on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build. +// +// kReadback + IsTransformFeedbackActive / IsTransformFeedbackPaused +// KEPT. The depth/stencil read emulation draws (ScopedEmulationDrawState, DirectGLES.cpp) +// and pauses an active capture around its own draw, so a glReadPixels of a depth or +// stencil attachment reads the transform-feedback state exactly as a draw does. Reached +// only when the emulation is armed, which is a driver-shaped decision, so no desktop +// corpus can decide it. +// +// kTextureOp + IsCapabilityEnabled, kDispatch + IsCapabilityEnabled +// KEPT. Magma's GenerateMipmap materialises a texture's queued clear before it blits and +// PrepareStorageImageTextures does the same for every storage image a dispatch writes; +// both go through VkClearManager::PreCompensateSrgbClearColor, which reads +// GL_FRAMEBUFFER_SRGB. The P2 contract gave that capability real storage for the first +// time, so this row went from reading a compile-time constant to reading real state - +// which is the opposite of a row that could be dropped. +// +// kBlitOrCopy / kTextureOp + the shader blit's viewport and vertex/buffer bindings +// (GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode, GetBufferBindingPoint) +// KEPT. TryBlitToDefaultFramebufferWithShader is a real draw of a backend-owned helper +// program: ApplyGLViewportState -> ComputeGLViewport reads viewport 0 and its depth range, +// GetOrCreateBlitPipeline -> SelectProvokingVertexMode reads the provoking vertex, and +// BindProgramUniformBuffers' block resolvers read the frontend binding points. It is taken +// when a blit's destination is the default framebuffer and the driver cannot do it +// natively - again a driver-shaped decision. +// +// What WOULD retire a row: the poison build already answers "was this field read at this +// verb" exactly (MOBILEGL_PIPE_POISON_OMIT withholds one field's stamp for one verb and a +// read of it aborts naming the pair). Turning that into a retirement gate means running the +// omission across the full CTS caselist on both devices, not the desktop corpus, and that is +// recorded as P3a work rather than done here on evidence that cannot support it. +// --------------------------------------------------------------------------------------- +// // gen_pipe.py's block regexes end at a blank line: keep the empty line after each macro. // // clang-format off diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index 36deee1c..f11ed2c4 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -21,8 +21,10 @@ publish points, the ones that must map onto an aggregate generation. P0 is the skeleton: it reports. P1 adds the mapping file and CI regenerates it with `git diff --exit-code` and zero unmapped mutators, the same shape as gen_pipe.py's G6. - python3 scripts/gen_pipe_dirty_surface.py # human-readable report - python3 scripts/gen_pipe_dirty_surface.py --summary # counts only + python3 scripts/gen_pipe_dirty_surface.py # human-readable report + python3 scripts/gen_pipe_dirty_surface.py --summary # counts only + python3 scripts/gen_pipe_dirty_surface.py --check # THE GATE: rc 1 on any hole + python3 scripts/gen_pipe_dirty_surface.py --self-test # the gate's own negative controls """ import argparse @@ -135,14 +137,76 @@ def scan_file(path): return findings, all_mutators -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--summary", action="store_true", help="print the counts only") - args = parser.parse_args() +DEF_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_Pipe", "DirtySurface.def") +TRACKER_PATH = os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe", "Tracker.h") - if not os.path.isdir(SCAN_ROOT): - sys.exit("missing %s" % SCAN_ROOT) +ROW_RE = re.compile(r"^[ \t]*X\((\w+),\s*(\w+)\)\s*\\?\s*$", re.M) +DIRTY_NAME_RE = re.compile(r'^\s*"(NEW_[A-Z0-9_]+)",\s*$', re.M) +# The answers that are not a dirty-bit name. Each one is documented in DirtySurface.def's +# header; a row that uses anything else is a typo, and a typo that read as "mapped" would be +# exactly the silent hole this gate exists to close. +NON_BIT_ANSWERS = ("kImmediate", "kReverseChannel", "kNoBackendRead", "kExplicitDestroy", + "kPulledEveryVerb") + + +def dirty_bit_names(): + """The MGPipeDirty bit names, read out of Tracker.h's kMGPipeDirtyNames so a row cannot + name a bit that does not exist and a bit cannot be renamed out from under a row. Read + from the RAW text on purpose: the names are string literals, which is exactly what + mask_comments_and_strings blanks.""" + with open(TRACKER_PATH, "r", encoding="utf-8", errors="replace") as handle: + return set(DIRTY_NAME_RE.findall(handle.read())) + + +def load_mapping(text=None): + """{mutator: answer} from DirtySurface.def, or from `text` for the self-test.""" + if text is None: + with open(DEF_PATH, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + rows = {} + duplicates = [] + for match in ROW_RE.finditer(mask_comments_and_strings(text)): + mutator, answer = match.group(1), match.group(2) + if mutator in rows: + duplicates.append(mutator) + rows[mutator] = answer + return rows, duplicates + + +def check_mapping(mapping, duplicates, scanned, bits): + """Every problem the gate fails on, as a list of human-readable lines. BOTH directions: + an unmapped mutator renders stale, and a row naming a mutator the scan no longer finds is + a stale row that would keep a real hole looking covered.""" + problems = [] + for mutator in sorted(set(scanned) - set(mapping)): + problems.append("UNMAPPED mutator %s - add a row to MG_Pipe/DirtySurface.def" % mutator) + for mutator in sorted(set(mapping) - set(scanned)): + problems.append("STALE row %s - the scan no longer finds this mutator; delete the row" + % mutator) + for mutator in sorted(duplicates): + problems.append("DUPLICATE row %s" % mutator) + for mutator in sorted(mapping): + answer = mapping[mutator] + if answer in NON_BIT_ANSWERS: + continue + if answer in bits: + continue + problems.append("BAD answer %s for %s - not a MGPipeDirty bit name and not one of %s" + % (answer, mutator, ", ".join(NON_BIT_ANSWERS))) + return problems + + +SELF_TEST_WITHHELD = """ +#define MGP_DIRTY_SURFACE_LIST(X) \\ + X(RecordError, kReverseChannel) +""" + +SELF_TEST_STALE = None # built from the real def at run time + + +def scan_all(): + """(findings-per-file, {mutator: call count}) over the whole scan root.""" sources = [] for root, _, files in os.walk(SCAN_ROOT): for name in sorted(files): @@ -150,15 +214,102 @@ def main(): sources.append(os.path.join(root, name)) sources.sort() - total_functions = 0 - total_mutators = 0 - deferred_mutators = 0 - distinct_mutators = {} + per_file = [] distinct_all = {} for path in sources: findings, all_mutators = scan_file(path) for mutator, _ in all_mutators: distinct_all[mutator] = distinct_all.get(mutator, 0) + 1 + per_file.append((path, findings, all_mutators)) + return sources, per_file, distinct_all + + +def self_test(scanned, bits): + """Canned negative controls. Each MUST trip; trips == 0 is an error, which is the shape + check_include_closure.py and gen_pipe.py --self-test already use.""" + trips = 0 + failures = [] + + # 1. a mutator withheld from the def. + mapping, duplicates = load_mapping(SELF_TEST_WITHHELD) + problems = check_mapping(mapping, duplicates, scanned, bits) + if any(p.startswith("UNMAPPED") for p in problems): + trips += 1 + else: + failures.append("negative control 1 (a withheld mutator) did NOT trip") + + # 2. a row naming a mutator the scan does not find. + real, real_duplicates = load_mapping() + with_ghost = dict(real) + with_ghost["SetSomethingThatDoesNotExist"] = "kImmediate" + problems = check_mapping(with_ghost, real_duplicates, scanned, bits) + if any(p.startswith("STALE") for p in problems): + trips += 1 + else: + failures.append("negative control 2 (a stale row) did NOT trip") + + # 3. a row whose answer is neither a dirty bit nor one of the documented non-bit answers. + with_typo = dict(real) + with_typo["RecordError"] = "NEW_TYPO_THAT_IS_NOT_A_BIT" + problems = check_mapping(with_typo, real_duplicates, scanned, bits) + if any(p.startswith("BAD answer") for p in problems): + trips += 1 + else: + failures.append("negative control 3 (a bad answer) did NOT trip") + + for failure in failures: + print("dirty-surface self-test: %s" % failure) + if trips == 0: + print("dirty-surface self-test: NOTHING tripped - the gate cannot fail, which is worse " + "than a red gate") + return 1 + if failures: + return 1 + print("dirty-surface self-test: %d negative controls, all tripped" % trips) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--summary", action="store_true", help="print the counts only") + parser.add_argument("--check", action="store_true", + help="fail when a scanned mutator has no row in DirtySurface.def, or a " + "row names a mutator the scan no longer finds") + parser.add_argument("--self-test", action="store_true", + help="run the canned negative controls; each must trip") + args = parser.parse_args() + + if not os.path.isdir(SCAN_ROOT): + sys.exit("missing %s" % SCAN_ROOT) + if not os.path.isfile(DEF_PATH): + sys.exit("missing %s" % DEF_PATH) + + sources, per_file, distinct_all = scan_all() + bits = dirty_bit_names() + if not bits: + sys.exit("could not read the MGPipeDirty bit names out of %s" % TRACKER_PATH) + + if args.self_test: + return self_test(distinct_all, bits) + + mapping, duplicates = load_mapping() + + if args.check: + problems = check_mapping(mapping, duplicates, distinct_all, bits) + for problem in problems: + print("dirty-surface: %s" % problem) + if problems: + print("dirty-surface: %d problem(s); the mapping must cover every mutator the scan " + "finds, in both directions" % len(problems)) + return 1 + print("dirty-surface: %d mutators, all mapped, no stale rows" % len(mapping)) + return 0 + + total_functions = 0 + total_mutators = 0 + deferred_mutators = 0 + distinct_mutators = {} + for path, findings, all_mutators in per_file: deferred_mutators += len(all_mutators) if not findings: continue @@ -175,7 +326,7 @@ def main(): print(" %s (line %d) -> backend: %s" % (finding["function"], finding["line"], ", ".join(finding["backend"][:4]))) for mutator, line in finding["mutators"]: - print(" %-44s :%d UNMAPPED" % (mutator, line)) + print(" %-44s :%d %s" % (mutator, line, mapping.get(mutator, "UNMAPPED"))) print("\ndirty-surface: %d files scanned under MG_Impl/GLImpl" % len(sources)) print("dirty-surface: %d mutator calls in total, %d distinct mutators" % (deferred_mutators, @@ -186,12 +337,17 @@ def main(): print("dirty-surface: the remaining %d are DEFERRED: nothing reaches the backend in the same " "function, so the next verb publishes them, and each one needs an aggregate generation" % (deferred_mutators - total_mutators)) - print("dirty-surface: distinct mutators, by call count") + print("dirty-surface: distinct mutators, by call count, with what publishes each") for mutator in sorted(distinct_all, key=lambda k: (-distinct_all[k], k)): - print(" %5d %s%s" % (distinct_all[mutator], mutator, - " (immediate)" if mutator in distinct_mutators else "")) - print("dirty-surface: every mutator above is UNMAPPED - the aggregate-generation mapping file " - "lands in P1, and this report is what it has to cover.") + print(" %5d %-42s %s%s" % (distinct_all[mutator], mutator, + mapping.get(mutator, "UNMAPPED"), + " (immediate)" if mutator in distinct_mutators else "")) + unmapped = sorted(set(distinct_all) - set(mapping)) + if unmapped: + print("dirty-surface: %d UNMAPPED - run --check, which is a gate since P2" % len(unmapped)) + else: + print("dirty-surface: every mutator above is mapped (MG_Pipe/DirtySurface.def); --check " + "is a gate and --self-test proves it can fail") print("dirty-surface: known limits of this scanner - it matches braced function bodies " "textually, so a mutator inside a lambda is attributed to the enclosing function, and a " "mutation published through a helper the entry point calls reads as deferred here.")